From cc34e77a2c9bb0e53d957162c9e0ecdc6b2e22d5 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 7 Dec 2016 16:57:08 -0600 Subject: [PATCH 0001/3196] Fix permission / grid action bug for email profiles --- tailbone/views/email.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tailbone/views/email.py b/tailbone/views/email.py index 9479bb6f..45da423e 100644 --- a/tailbone/views/email.py +++ b/tailbone/views/email.py @@ -120,7 +120,8 @@ class ProfilesView(MasterView): g.default_sortkey = 'key' # Make edit link visible by default, no "More" actions. - g.main_actions.append(g.more_actions.pop()) + if g.more_actions: + g.main_actions.append(g.more_actions.pop()) def get_instance(self): key = self.request.matchdict['key'] From e1e451403887db169a2f72b6bba87278aca60a91 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 7 Dec 2016 16:57:52 -0600 Subject: [PATCH 0002/3196] 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 8abb9a7a..15b1a762 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.5.51 (2016-12-07) +------------------- + +* Fix permission / grid action bug for email profiles + + 0.5.50 (2016-12-07) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 831c407e..ffbf3928 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- -__version__ = u'0.5.50' +__version__ = u'0.5.51' From cebde053adfad7dea01861ee81a48e886b601277 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 7 Dec 2016 17:04:52 -0600 Subject: [PATCH 0003/3196] Fix permission group label for email bounces --- tailbone/views/bouncer.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tailbone/views/bouncer.py b/tailbone/views/bouncer.py index c7d42b67..6f3540b2 100644 --- a/tailbone/views/bouncer.py +++ b/tailbone/views/bouncer.py @@ -153,6 +153,8 @@ class EmailBouncesView(MasterView): @classmethod def defaults(cls, config): + config.add_tailbone_permission_group('emailbounces', "Email Bounces", overwrite=False) + # mark bounce as processed config.add_route('emailbounces.process', '/email-bounces/{uuid}/process') config.add_view(cls, attr='process', route_name='emailbounces.process', From 96ef75a75dace8b7ed125bfac0f6cf8367decaec Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 7 Dec 2016 20:36:54 -0600 Subject: [PATCH 0004/3196] Hopefully fix some bugs with people view(s) --- tailbone/views/people.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/tailbone/views/people.py b/tailbone/views/people.py index 4ef4f999..472afc30 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -83,11 +83,11 @@ class PeopleView(MasterView): g.sorters['email'] = lambda q, d: q.order_by(getattr(model.PersonEmailAddress.address, d)()) g.sorters['phone'] = lambda q, d: q.order_by(getattr(model.PersonPhoneNumber.number, d)()) - g.default_sortkey = 'display_name' + g.default_sortkey = 'full_name' g.configure( include=[ - g.display_name.label("Full Name"), + g.full_name, g.first_name, g.last_name, g.phone.label("Phone Number"), @@ -108,7 +108,6 @@ class PeopleView(MasterView): raise HTTPNotFound def _preconfigure_fieldset(self, fs): - fs.display_name.set(label="Full Name") fs.phone.set(label="Phone Number", readonly=True) fs.email.set(label="Email Address", readonly=True) fs.address.set(label="Mailing Address", readonly=True) @@ -117,7 +116,7 @@ class PeopleView(MasterView): def configure_fieldset(self, fs): fs.configure( include=[ - fs.display_name, + fs.full_name, fs.first_name, fs.middle_name, fs.last_name, @@ -131,7 +130,7 @@ class PeopleView(MasterView): class PeopleAutocomplete(AutocompleteView): mapped_class = model.Person - fieldname = 'display_name' + fieldname = 'full_name' class PeopleEmployeesAutocomplete(PeopleAutocomplete): From e4a10cf7fcb7c54c12e37c824cd03f4cd9a7105e Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 7 Dec 2016 22:06:27 -0600 Subject: [PATCH 0005/3196] Update footer text/link per new about page --- tailbone/templates/themes/better/base.mako | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/templates/themes/better/base.mako b/tailbone/templates/themes/better/base.mako index 418312a4..77f1369b 100644 --- a/tailbone/templates/themes/better/base.mako +++ b/tailbone/templates/themes/better/base.mako @@ -128,5 +128,5 @@ <%def name="header_logo()"> <%def name="footer()"> - powered by ${h.link_to("Rattail {} / Tailbone {}".format(rattail.__version__, tailbone.__version__), 'https://rattailproject.org/', target='_blank')} + powered by ${h.link_to("Rattail", url('about'))} From 369d5849a90cdf013b978ba63fe6705cdbff4536 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 8 Dec 2016 12:13:45 -0600 Subject: [PATCH 0006/3196] Revert to `display_name` field for person views --- tailbone/views/people.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tailbone/views/people.py b/tailbone/views/people.py index 472afc30..4ef4f999 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -83,11 +83,11 @@ class PeopleView(MasterView): g.sorters['email'] = lambda q, d: q.order_by(getattr(model.PersonEmailAddress.address, d)()) g.sorters['phone'] = lambda q, d: q.order_by(getattr(model.PersonPhoneNumber.number, d)()) - g.default_sortkey = 'full_name' + g.default_sortkey = 'display_name' g.configure( include=[ - g.full_name, + g.display_name.label("Full Name"), g.first_name, g.last_name, g.phone.label("Phone Number"), @@ -108,6 +108,7 @@ class PeopleView(MasterView): raise HTTPNotFound def _preconfigure_fieldset(self, fs): + fs.display_name.set(label="Full Name") fs.phone.set(label="Phone Number", readonly=True) fs.email.set(label="Email Address", readonly=True) fs.address.set(label="Mailing Address", readonly=True) @@ -116,7 +117,7 @@ class PeopleView(MasterView): def configure_fieldset(self, fs): fs.configure( include=[ - fs.full_name, + fs.display_name, fs.first_name, fs.middle_name, fs.last_name, @@ -130,7 +131,7 @@ class PeopleView(MasterView): class PeopleAutocomplete(AutocompleteView): mapped_class = model.Person - fieldname = 'full_name' + fieldname = 'display_name' class PeopleEmployeesAutocomplete(PeopleAutocomplete): From 81baa908730b9b512c55eceacbdca5a63edf9779 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 8 Dec 2016 12:14:37 -0600 Subject: [PATCH 0007/3196] 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 15b1a762..1a9d73e7 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,14 @@ CHANGELOG ========= +0.5.52 (2016-12-08) +------------------- + +* Fix permission group label for email bounces + +* Update footer text/link per new about page + + 0.5.51 (2016-12-07) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index ffbf3928..2548de4f 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- -__version__ = u'0.5.51' +__version__ = u'0.5.52' From ccc1374f6db49c5b5318ba241b50439c8d8446fb Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 9 Dec 2016 10:33:39 -0600 Subject: [PATCH 0008/3196] Fix bug when editing a data row This was a new-ish bug, caused I think by 4a2ba3925d1ab2f33bbd7a67fcc4433993247683 --- tailbone/views/master.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 685f989b..6a8a2d9d 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -1205,6 +1205,7 @@ class MasterView(View): else: kwargs['cancel_url'] = self.get_row_action_url('view', instance) + kwargs.setdefault('session', self.Session()) form = forms.AlchemyForm(self.request, fieldset, **kwargs) form.readonly = self.viewing return form From 468a84aa90900ba6fa95949fa0cea517c7dcb4ec Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 9 Dec 2016 10:35:33 -0600 Subject: [PATCH 0009/3196] 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 1a9d73e7..ed28f746 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.5.53 (2016-12-09) +------------------- + +* Fix bug when editing a data row + + 0.5.52 (2016-12-08) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 2548de4f..5682cd95 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- -__version__ = u'0.5.52' +__version__ = u'0.5.53' From 6c3d221e98e841d74125d594e5bc56f67828dde6 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 8 Dec 2016 17:24:42 -0600 Subject: [PATCH 0010/3196] Add new 'receiving form' for purchase batches --- tailbone/forms/__init__.py | 4 +- .../purchases/batches/receive_form.mako | 147 ++++++++++++++++++ .../templates/purchases/batches/view.mako | 9 +- tailbone/views/products.py | 77 +++++---- tailbone/views/purchases/batch.py | 122 ++++++++++++++- 5 files changed, 323 insertions(+), 36 deletions(-) create mode 100644 tailbone/templates/purchases/batches/receive_form.mako diff --git a/tailbone/forms/__init__.py b/tailbone/forms/__init__.py index 14e5b1a3..e24af4c3 100644 --- a/tailbone/forms/__init__.py +++ b/tailbone/forms/__init__.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2015 Lance Edgar +# Copyright © 2010-2016 Lance Edgar # # This file is part of Rattail. # @@ -24,6 +24,8 @@ Forms """ +from __future__ import unicode_literals, absolute_import + from formencode import Schema from .core import Form, Field, FieldSet, GenericFieldSet diff --git a/tailbone/templates/purchases/batches/receive_form.mako b/tailbone/templates/purchases/batches/receive_form.mako new file mode 100644 index 00000000..0a76c390 --- /dev/null +++ b/tailbone/templates/purchases/batches/receive_form.mako @@ -0,0 +1,147 @@ +## -*- coding: utf-8 -*- +<%inherit file="/base.mako" /> + +<%def name="title()">Receiving Form (${batch.vendor}) + +<%def name="head_tags()"> + ${parent.head_tags()} + ${h.javascript_link(request.static_url('tailbone:static/js/numeric.js'))} + + + + + +<%def name="context_menu_items()"> +
  • ${h.link_to("Back to Purchase Batch", url('purchases.batch.view', uuid=batch.uuid))}
  • + + + +
      + ${self.context_menu_items()} +
    + +
    + ${form.begin(id='receiving-form')} + ${h.hidden('mode')} + +
    + +
    + ${h.hidden('product')} +
    ${h.text('product-textbox', autocomplete='off')}
    +
    +

     

    +
    +
    warning: product not found on current purchase
    +
    +
    +
    + +
    + +
    ${h.text('cases')}
    +
    + +
    + +
    ${h.text('units')}
    +
    + +
    + + + + +
    + + ${form.end()} +
    diff --git a/tailbone/templates/purchases/batches/view.mako b/tailbone/templates/purchases/batches/view.mako index 3115a3b1..62fab912 100644 --- a/tailbone/templates/purchases/batches/view.mako +++ b/tailbone/templates/purchases/batches/view.mako @@ -18,13 +18,20 @@ location.href = '${url('purchases.batch.order_form', uuid=batch.uuid)}'; }); + $('#receive-form').click(function() { + $(this).button('disable').button('option', 'label', "Working, please wait..."); + location.href = '${url('purchases.batch.receiving_form', uuid=batch.uuid)}'; + }); + }); <%def name="leading_buttons()"> % if batch.mode == enum.PURCHASE_BATCH_MODE_NEW and not batch.complete and not batch.executed and request.has_perm('purchases.batch.order_form'): - + + % elif batch.mode == enum.PURCHASE_BATCH_MODE_RECEIVING and not batch.complete and not batch.executed and request.has_perm('purchases.batch.receiving_form'): + % endif diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 14a762b1..34cd6d7e 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -335,6 +335,46 @@ class ProductsView(MasterView): 'instance_title': self.get_instance_title(instance), 'form': form}) + def search(self): + """ + 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. + """ + data = None + upc = self.request.GET.get('upc', '').strip() + upc = re.sub(r'\D', '', upc) + if upc: + product = api.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 = api.get_product_by_upc(Session(), upc) + if product and (not product.deleted or self.request.has_perm('products.view_deleted')): + data = { + 'uuid': product.uuid, + 'upc': unicode(product.upc), + 'upc_pretty': product.upc.pretty(), + 'full_description': product.full_description, + 'image_url': pod.get_image_url(self.rattail_config, product.upc), + } + uuid = self.request.GET.get('with_vendor_cost') + if uuid: + vendor = Session.query(model.Vendor).get(uuid) + if not vendor: + return {'error': "Vendor not found"} + cost = product.cost_for_vendor(vendor) + if cost: + data['cost_found'] = True + if int(cost.case_size) == cost.case_size: + data['cost_case_size'] = int(cost.case_size) + else: + data['cost_case_size'] = '{:0.4f}'.format(cost.case_size) + else: + data['cost_found'] = False + return {'product': data} + def get_supported_batches(self): return { 'labels': 'rattail.batch.labels:LabelBatchHandler', @@ -479,6 +519,11 @@ class ProductsView(MasterView): config.add_view(cls, attr='make_batch', route_name='products.create_batch', renderer='/products/batch.mako', permission='batches.create') + # search (by upc) + config.add_route('products.search', '/products/search') + config.add_view(cls, attr='search', route_name='products.search', + renderer='json', permission='products.view') + cls._defaults(config) @@ -535,34 +580,6 @@ class ProductsAutocomplete(AutocompleteView): return product.full_description -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', '').strip() - upc = re.sub(r'\D', '', upc) - if upc: - product = api.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 = api.get_product_by_upc(Session(), upc) - if product: - if product.deleted and not request.has_perm('products.view_deleted'): - product = None - else: - product = { - 'uuid': product.uuid, - 'upc': unicode(product.upc or ''), - 'full_description': product.full_description, - } - return {'product': product} - - def print_labels(request): profile = request.params.get('profile') profile = Session.query(model.LabelProfile).get(profile) if profile else None @@ -600,9 +617,5 @@ def includeme(config): config.add_view(print_labels, route_name='products.print_labels', renderer='json', permission='products.print_labels') - config.add_route('products.search', '/products/search') - config.add_view(products_search, route_name='products.search', - renderer='json', permission='products.list') - ProductsView.defaults(config) version_defaults(config, ProductVersionView, 'product') diff --git a/tailbone/views/purchases/batch.py b/tailbone/views/purchases/batch.py index ea0cf1b2..eed1a745 100644 --- a/tailbone/views/purchases/batch.py +++ b/tailbone/views/purchases/batch.py @@ -26,9 +26,12 @@ Views for purchase order batches from __future__ import unicode_literals, absolute_import +import re +import logging + from sqlalchemy import orm -from rattail import enum +from rattail import enum, pod from rattail.db import model, api from rattail.gpc import GPC from rattail.time import localtime @@ -36,6 +39,7 @@ from rattail.core import Object from rattail.util import OrderedDict import formalchemy as fa +import formencode as fe from pyramid import httpexceptions from tailbone import forms @@ -43,6 +47,21 @@ from tailbone.db import Session from tailbone.views.batch import BatchMasterView +log = logging.getLogger(__name__) + + +class ReceivingForm(forms.Schema): + allow_extra_fields = True + filter_extra_fields = True + mode = fe.validators.OneOf([ + 'received', + # 'damaged', 'expired', 'mispick', + ]) + product = forms.validators.ValidProduct() + cases = fe.validators.Int() + units = fe.validators.Int() + + class PurchaseBatchView(BatchMasterView): """ Master view for purchase order batches. @@ -578,6 +597,95 @@ class PurchaseBatchView(BatchMasterView): 'batch_po_total': '${:0,.2f}'.format(batch.po_total), } + def receiving_form(self): + """ + Workflow view for receiving items on a purchase batch. + """ + batch = self.get_instance() + if batch.executed: + return self.redirect(self.get_action_url('view', batch)) + + form = forms.SimpleForm(self.request, schema=ReceivingForm) + if form.validate(): + assert form.data['mode'] == 'received' # TODO + + product = form.data['product'] + rows = [row for row in batch.active_rows() if row.product is product] + if rows: + if len(rows) > 1: + log.warning("found {} matching rows in batch {} for product: {}".format( + len(rows), batch.id_str, product.upc.pretty())) + row = rows[0] + else: + row = model.PurchaseBatchRow() + row.product = product + + if form.data['cases']: + row.cases_received = (row.cases_received or 0) + form.data['cases'] + if form.data['units']: + row.units_received = (row.units_received or 0) + form.data['units'] + + if not row.uuid: + batch.add_row(row) + self.handler.refresh_row(row) + + self.request.session.flash("({}) {} cases, {} units: {} {}".format( + form.data['mode'], form.data['cases'] or 0, form.data['units'] or 0, + product.upc.pretty(), product)) + return self.redirect(self.request.current_route_url()) + + title = self.get_instance_title(batch) + return self.render_to_response('receive_form', { + 'batch': batch, + 'instance': batch, + 'instance_title': title, + 'index_title': "{}: {}".format(self.get_model_title(), title), + 'index_url': self.get_action_url('view', batch), + 'vendor': batch.vendor, + 'form': forms.FormRenderer(form), + }) + + def receiving_lookup(self): + """ + Try to locate a product by UPC, and validate it in the context of + current batch, returning some data for client JS. + """ + batch = self.get_instance() + if batch.executed: + return { + 'error': "Current batch has already been executed", + 'redirect': self.get_action_url('view', batch), + } + data = None + upc = self.request.GET.get('upc', '').strip() + upc = re.sub(r'\D', '', upc) + if upc: + product = api.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 = api.get_product_by_upc(Session(), upc) + if product and (not product.deleted or self.request.has_perm('products.view_deleted')): + data = { + 'uuid': product.uuid, + 'upc': unicode(product.upc), + 'upc_pretty': product.upc.pretty(), + 'full_description': product.full_description, + 'image_url': pod.get_image_url(self.rattail_config, product.upc), + } + cost = product.cost_for_vendor(batch.vendor) + if cost: + data['cost_found'] = True + if int(cost.case_size) == cost.case_size: + data['cost_case_size'] = int(cost.case_size) + else: + data['cost_case_size'] = '{:0.4f}'.format(cost.case_size) + else: + data['cost_found'] = False + data['found_in_batch'] = product in [row.product for row in batch.active_rows()] + + return {'product': data} + @classmethod def defaults(cls, config): route_prefix = cls.get_route_prefix() @@ -595,7 +703,7 @@ class PurchaseBatchView(BatchMasterView): cls._batch_defaults(config) cls._defaults(config) - # order form + # ordering form config.add_tailbone_permission(permission_prefix, '{}.order_form'.format(permission_prefix), "Edit new {} in Order Form mode".format(model_title)) config.add_route('{}.order_form'.format(route_prefix), '{}/{{{}}}/order-form'.format(url_prefix, model_key)) @@ -605,6 +713,16 @@ class PurchaseBatchView(BatchMasterView): config.add_view(cls, attr='order_form_update', route_name='{}.order_form_update'.format(route_prefix), renderer='json', permission='{}.order_form'.format(permission_prefix)) + # receiving form, lookup + config.add_tailbone_permission(permission_prefix, '{}.receiving_form'.format(permission_prefix), + "Edit 'receiving' {} in Receiving Form mode".format(model_title)) + config.add_route('{}.receiving_form'.format(route_prefix), '{}/{{{}}}/receiving-form'.format(url_prefix, model_key)) + config.add_view(cls, attr='receiving_form', route_name='{}.receiving_form'.format(route_prefix), + permission='{}.receiving_form'.format(permission_prefix)) + config.add_route('{}.receiving_lookup'.format(route_prefix), '{}/{{{}}}/receiving-form/lookup'.format(url_prefix, model_key)) + config.add_view(cls, attr='receiving_lookup', route_name='{}.receiving_lookup'.format(route_prefix), + renderer='json', permission='{}.receiving_form'.format(permission_prefix)) + def includeme(config): PurchaseBatchView.defaults(config) From c73ba565055ec62835a0eef8438900f30fde0461 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 9 Dec 2016 14:01:06 -0600 Subject: [PATCH 0011/3196] Add support for 'department' field in purchases / batches Also fix logic for deleting a purchase (delete its batches first) --- tailbone/forms/renderers/__init__.py | 4 +-- tailbone/forms/renderers/products.py | 13 +++++--- .../templates/purchases/batches/create.mako | 4 +++ tailbone/views/master.py | 7 ++++- tailbone/views/purchases/batch.py | 30 +++++++++++++++---- tailbone/views/purchases/core.py | 17 +++++++++++ 6 files changed, 62 insertions(+), 13 deletions(-) diff --git a/tailbone/forms/renderers/__init__.py b/tailbone/forms/renderers/__init__.py index 2c6cd0f4..12c467a0 100644 --- a/tailbone/forms/renderers/__init__.py +++ b/tailbone/forms/renderers/__init__.py @@ -43,8 +43,8 @@ from .users import UserFieldRenderer, PermissionsFieldRenderer from .employees import EmployeeFieldRenderer -from .products import (ProductFieldRenderer, GPCFieldRenderer, BrandFieldRenderer, - PriceFieldRenderer, PriceWithExpirationFieldRenderer) +from .products import (GPCFieldRenderer, DepartmentFieldRenderer, BrandFieldRenderer, + ProductFieldRenderer, PriceFieldRenderer, PriceWithExpirationFieldRenderer) from .stores import StoreFieldRenderer diff --git a/tailbone/forms/renderers/products.py b/tailbone/forms/renderers/products.py index d3438119..984942d5 100644 --- a/tailbone/forms/renderers/products.py +++ b/tailbone/forms/renderers/products.py @@ -83,11 +83,16 @@ class DepartmentFieldRenderer(SelectFieldRenderer): """ 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 "" + department = self.raw_value + if not department: + return '' + if department.number: + text = '{} {}'.format(department.number, department.name) + else: + text = department.name + return tags.link_to(text, self.request.route_url('departments.view', uuid=department.uuid)) class SubdepartmentFieldRenderer(SelectFieldRenderer): diff --git a/tailbone/templates/purchases/batches/create.mako b/tailbone/templates/purchases/batches/create.mako index 1a9ca0c4..b3bc0954 100644 --- a/tailbone/templates/purchases/batches/create.mako +++ b/tailbone/templates/purchases/batches/create.mako @@ -9,6 +9,7 @@ if (mode == ${enum.PURCHASE_BATCH_MODE_NEW}) { $('.field-wrapper.store_uuid').show(); $('.field-wrapper.purchase_uuid').hide(); + $('.field-wrapper.department_uuid').show(); $('.field-wrapper.buyer_uuid').show(); $('.field-wrapper.date_ordered').show(); $('.field-wrapper.date_received').hide(); @@ -17,6 +18,7 @@ } else if (mode == ${enum.PURCHASE_BATCH_MODE_RECEIVING}) { $('.field-wrapper.store_uuid').hide(); $('.field-wrapper.purchase_uuid').show(); + $('.field-wrapper.department_uuid').hide(); $('.field-wrapper.buyer_uuid').hide(); $('.field-wrapper.date_ordered').hide(); $('.field-wrapper.date_received').show(); @@ -25,6 +27,7 @@ } else if (mode == ${enum.PURCHASE_BATCH_MODE_COSTING}) { $('.field-wrapper.store_uuid').hide(); $('.field-wrapper.purchase_uuid').show(); + $('.field-wrapper.department_uuid').hide(); $('.field-wrapper.buyer_uuid').hide(); $('.field-wrapper.date_ordered').hide(); $('.field-wrapper.date_received').hide(); @@ -76,6 +79,7 @@ }); $('.field-wrapper.purchase_uuid select').selectmenu(); + $('.field-wrapper.department_uuid select').selectmenu(); show_mode(${form.fieldset.model.mode or enum.PURCHASE_BATCH_MODE_NEW}); diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 6a8a2d9d..a3af3e29 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -526,7 +526,12 @@ class MasterView(View): Return a "humanized" (and plural) version of the model name, for display in templates. """ - return getattr(cls, 'model_title_plural', '{0}s'.format(cls.get_model_title())) + if hasattr(cls, 'model_title_plural'): + return cls.model_title_plural + try: + return cls.get_model_class().get_model_title_plural() + except (NotImplementedError, AttributeError): + return '{}s'.format(cls.get_model_title()) @classmethod def get_route_prefix(cls): diff --git a/tailbone/views/purchases/batch.py b/tailbone/views/purchases/batch.py index eed1a745..bb73d894 100644 --- a/tailbone/views/purchases/batch.py +++ b/tailbone/views/purchases/batch.py @@ -31,7 +31,7 @@ import logging from sqlalchemy import orm -from rattail import enum, pod +from rattail import pod from rattail.db import model, api from rattail.gpc import GPC from rattail.time import localtime @@ -67,7 +67,6 @@ class PurchaseBatchView(BatchMasterView): Master view for purchase order batches. """ model_class = model.PurchaseBatch - model_title_plural = "Purchase Batches" model_row_class = model.PurchaseBatchRow default_handler_spec = 'rattail.batch.purchase:PurchaseBatchHandler' route_prefix = 'purchases.batch' @@ -87,6 +86,10 @@ class PurchaseBatchView(BatchMasterView): default_active=True, default_verb='contains') g.sorters['vendor'] = g.make_sorter(model.Vendor.name) + g.joiners['department'] = lambda q: q.join(model.Department) + g.filters['department'] = g.make_filter('department', model.Department.name) + g.sorters['department'] = g.make_sorter(model.Department.name) + g.joiners['buyer'] = lambda q: q.join(model.Employee).join(model.Person) g.filters['buyer'] = g.make_filter('buyer', model.Person.display_name, default_active=True, default_verb='contains') @@ -106,6 +109,7 @@ class PurchaseBatchView(BatchMasterView): g.id, g.mode, g.vendor, + g.department, g.buyer, g.date_ordered, g.created, @@ -121,6 +125,8 @@ class PurchaseBatchView(BatchMasterView): fs.vendor.set(renderer=forms.renderers.VendorFieldRenderer, attrs={'selected': 'vendor_selected', 'cleared': 'vendor_cleared'}) + fs.department.set(renderer=forms.renderers.DepartmentFieldRenderer, + options=self.get_department_options()) fs.buyer.set(renderer=forms.renderers.EmployeeFieldRenderer) fs.po_number.set(label="PO Number") fs.po_total.set(label="PO Total", readonly=True, renderer=forms.renderers.CurrencyFieldRenderer) @@ -135,6 +141,10 @@ class PurchaseBatchView(BatchMasterView): fs.append(fa.Field('vendor_phone', readonly=True, value=self.get_vendor_phone_number)) + def get_department_options(self): + departments = Session.query(model.Department).order_by(model.Department.number) + return [('{} {}'.format(d.number, d.name), d.uuid) for d in departments] + def get_vendor_phone_number(self, batch): for phone in batch.vendor.phones: if phone.type == 'Voice': @@ -152,6 +162,7 @@ class PurchaseBatchView(BatchMasterView): fs.mode, fs.store, fs.vendor, + fs.department, fs.purchase, fs.vendor_email, fs.vendor_fax, @@ -208,6 +219,7 @@ class PurchaseBatchView(BatchMasterView): fs.mode.set(readonly=True) fs.store.set(readonly=True) fs.vendor.set(readonly=True) + fs.department.set(readonly=True) fs.purchase.set(readonly=True) def eligible_purchases(self): @@ -223,10 +235,10 @@ class PurchaseBatchView(BatchMasterView): purchases = Session.query(model.Purchase)\ .filter(model.Purchase.vendor == vendor) - if mode == enum.PURCHASE_BATCH_MODE_RECEIVING: + if mode == self.enum.PURCHASE_BATCH_MODE_RECEIVING: purchases = purchases.filter(model.Purchase.status == self.enum.PURCHASE_STATUS_ORDERED)\ .order_by(model.Purchase.date_ordered, model.Purchase.created) - elif mode == enum.PURCHASE_BATCH_MODE_COSTING: + elif mode == self.enum.PURCHASE_BATCH_MODE_COSTING: purchases = purchases.filter(model.Purchase.status == self.enum.PURCHASE_STATUS_RECEIVED)\ .order_by(model.Purchase.date_received, model.Purchase.created) @@ -240,7 +252,7 @@ class PurchaseBatchView(BatchMasterView): elif purchase.status == self.enum.PURCHASE_STATUS_RECEIVED: date = purchase.date_received total = purchase.invoice_total - return '{} for ${:0,.2f} ({})'.format(date, total, purchase.buyer) + return '{} for ${:0,.2f} ({})'.format(date, total, purchase.department or purchase.buyer) def get_batch_kwargs(self, batch): kwargs = super(PurchaseBatchView, self).get_batch_kwargs(batch) @@ -253,6 +265,10 @@ class PurchaseBatchView(BatchMasterView): kwargs['vendor'] = batch.vendor elif batch.vendor_uuid: kwargs['vendor_uuid'] = batch.vendor_uuid + if batch.department: + kwargs['department'] = batch.department + elif batch.department_uuid: + kwargs['department_uuid'] = batch.department_uuid if batch.buyer: kwargs['buyer'] = batch.buyer elif batch.buyer_uuid: @@ -274,6 +290,8 @@ class PurchaseBatchView(BatchMasterView): purchase = Session.query(model.Purchase).get(batch.purchase_uuid) assert purchase kwargs['purchase'] = purchase + kwargs['buyer'] = purchase.buyer + kwargs['buyer_uuid'] = purchase.buyer_uuid kwargs['date_ordered'] = purchase.date_ordered kwargs['po_total'] = purchase.po_total @@ -510,7 +528,7 @@ class PurchaseBatchView(BatchMasterView): history = OrderedDict() purchases = Session.query(model.Purchase)\ .filter(model.Purchase.vendor == batch.vendor)\ - .filter(model.Purchase.status >= enum.PURCHASE_STATUS_ORDERED)\ + .filter(model.Purchase.status >= self.enum.PURCHASE_STATUS_ORDERED)\ .order_by(model.Purchase.date_ordered.desc(), model.Purchase.created.desc())\ .options(orm.joinedload(model.Purchase.items))[:6] for purchase in purchases[:6]: diff --git a/tailbone/views/purchases/core.py b/tailbone/views/purchases/core.py index b1c62f51..df5e86b1 100644 --- a/tailbone/views/purchases/core.py +++ b/tailbone/views/purchases/core.py @@ -100,6 +100,10 @@ class PurchaseView(MasterView): default_active=True, default_verb='contains') g.sorters['vendor'] = g.make_sorter(model.Vendor.name) + g.joiners['department'] = lambda q: q.join(model.Department) + g.filters['department'] = g.make_filter('department', model.Department.name) + g.sorters['department'] = g.make_sorter(model.Department.name) + g.joiners['buyer'] = lambda q: q.join(model.Employee).join(model.Person) g.filters['buyer'] = g.make_filter('buyer', model.Person.display_name, default_active=True, default_verb='contains') @@ -121,6 +125,7 @@ class PurchaseView(MasterView): include=[ g.store, g.vendor, + g.department, g.buyer, g.date_ordered, g.date_received, @@ -130,6 +135,7 @@ class PurchaseView(MasterView): def _preconfigure_fieldset(self, fs): fs.vendor.set(renderer=forms.renderers.VendorFieldRenderer) + fs.department.set(renderer=forms.renderers.DepartmentFieldRenderer) fs.status.set(renderer=forms.renderers.EnumFieldRenderer(self.enum.PURCHASE_STATUS), readonly=True) fs.po_number.set(label="PO Number") @@ -142,6 +148,7 @@ class PurchaseView(MasterView): include=[ fs.store, fs.vendor, + fs.department, fs.status, fs.buyer, fs.date_ordered, @@ -162,6 +169,16 @@ class PurchaseView(MasterView): del fs.invoice_number del fs.invoice_total + def delete_instance(self, purchase): + """ + Delete all batches for the purchase, then delete the purchase. + """ + for batch in list(purchase.batches): + self.Session.delete(batch) + self.Session.flush() + self.Session.delete(purchase) + self.Session.flush() + def get_parent(self, item): return item.purchase From dd08b7145889e7953d3cae58c43f280a6bd34d92 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 9 Dec 2016 14:20:16 -0600 Subject: [PATCH 0012/3196] Tweak various views for purchase batches --- tailbone/static/css/purchases.css | 8 ++++++++ tailbone/templates/purchases/batches/view.mako | 5 +++++ tailbone/templates/purchases/view.mako | 9 +++++++++ tailbone/views/purchases/batch.py | 2 ++ tailbone/views/purchases/core.py | 2 ++ 5 files changed, 26 insertions(+) create mode 100644 tailbone/static/css/purchases.css create mode 100644 tailbone/templates/purchases/view.mako diff --git a/tailbone/static/css/purchases.css b/tailbone/static/css/purchases.css new file mode 100644 index 00000000..13ca518a --- /dev/null +++ b/tailbone/static/css/purchases.css @@ -0,0 +1,8 @@ + +/****************************** + * Styles for purchases + ******************************/ + +div.field-wrapper { + padding: 0; +} diff --git a/tailbone/templates/purchases/batches/view.mako b/tailbone/templates/purchases/batches/view.mako index 62fab912..86ce24f7 100644 --- a/tailbone/templates/purchases/batches/view.mako +++ b/tailbone/templates/purchases/batches/view.mako @@ -1,6 +1,11 @@ ## -*- coding: utf-8 -*- <%inherit file="/newbatch/view.mako" /> +<%def name="extra_styles()"> + ${parent.extra_styles()} + ${h.stylesheet_link(request.static_url('tailbone:static/css/purchases.css'))} + + <%def name="head_tags()"> ${parent.head_tags()} + % endif + + + +
    + +
    + ${h.link_to("Home", url('mobile.home'), class_='ui-btn-left')} + ${h.link_to("About", url('mobile.about'), class_='ui-btn-right')} +

    ${self.global_title()}

    +
    + +
    + % if capture(self.title): +

    ${self.title()}

    + % endif + ${self.body()} +
    + +
    +

    powered by ${h.link_to("Rattail", url('mobile.about'))}

    +
    + +
    + + + +<%def name="global_title()">${"[STAGE] " if not request.rattail_config.production() else ''}Rattail Demo diff --git a/tailbone/templates/mobile/home.mako b/tailbone/templates/mobile/home.mako new file mode 100644 index 00000000..3d617892 --- /dev/null +++ b/tailbone/templates/mobile/home.mako @@ -0,0 +1,9 @@ +## -*- coding: utf-8 -*- +<%inherit file="/mobile/base.mako" /> + +<%def name="title()"> + +
    + ${h.image(request.static_url('tailbone:static/img/home_logo.png'), "Rattail Logo", width='400')} +

    Welcome to Tailbone

    +
    diff --git a/tailbone/views/common.py b/tailbone/views/common.py index 3eca9de9..e4be854a 100644 --- a/tailbone/views/common.py +++ b/tailbone/views/common.py @@ -93,9 +93,11 @@ class CommonView(View): @classmethod def defaults(cls, config): + # about config.add_route('about', '/about') - config.add_view(cls, attr='about', route_name='about', - renderer='/about.mako') + config.add_view(cls, attr='about', route_name='about', renderer='/about.mako') + config.add_route('mobile.about', '/mobile/about') + config.add_view(cls, attr='about', route_name='mobile.about', renderer='/mobile/about.mako') config.add_route('feedback', '/feedback') config.add_view(cls, attr='feedback', route_name='feedback', From c2d2b6e0726e14e3f5bb7fd9c04aeb664e13f845 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 11 Dec 2016 01:43:17 -0600 Subject: [PATCH 0029/3196] Change jquery CDN URLs from HTTP to HTTPS --- tailbone/templates/mobile/base.mako | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tailbone/templates/mobile/base.mako b/tailbone/templates/mobile/base.mako index 4708ef07..f62d010a 100644 --- a/tailbone/templates/mobile/base.mako +++ b/tailbone/templates/mobile/base.mako @@ -6,8 +6,8 @@ ${self.global_title()} » ${capture(self.title)} ${h.javascript_link('https://code.jquery.com/jquery-1.12.4.min.js')} - ${h.javascript_link('http://code.jquery.com/mobile/1.4.5/jquery.mobile-1.4.5.min.js')} - ${h.stylesheet_link('http://code.jquery.com/mobile/1.4.5/jquery.mobile-1.4.5.min.css')} + ${h.javascript_link('https://code.jquery.com/mobile/1.4.5/jquery.mobile-1.4.5.min.js')} + ${h.stylesheet_link('https://code.jquery.com/mobile/1.4.5/jquery.mobile-1.4.5.min.css')} % if not request.rattail_config.production(): % endif - - -
    - -
    - ${h.link_to("Home", url('mobile.home'), class_='ui-btn-left')} - ${h.link_to("About", url('mobile.about'), class_='ui-btn-right')} -

    ${self.global_title()}

    -
    - -
    - % if capture(self.title): -

    ${self.title()}

    - % endif - ${self.body()} -
    - -
    -

    powered by ${h.link_to("Rattail", url('mobile.about'))}

    -
    - -
    - + ${self.mobile_body()} -<%def name="global_title()">${"[STAGE] " if not request.rattail_config.production() else ''}Rattail Demo +<%def name="mobile_body()"> + + +
    + + ${self.mobile_usermenu()} + + ${self.mobile_header()} + + ${self.mobile_page_body()} + + ${self.mobile_footer()} + +
    + + + + +<%def name="app_title()">Rattail Demo + +<%def name="global_title()">${"[STAGE] " if not request.rattail_config.production() else ''}${self.app_title()} + +<%def name="page_url()">${request.current_route_url()} + +<%def name="page_title()">${self.title()} + +<%def name="mobile_header()"> +
    + ${self.mobile_header_link()} +

    ${self.global_title()}

    +
    + + +<%def name="mobile_header_link()"> + % if request.user: + ${h.link_to(request.user.get_short_name(), '#usermenu', class_='ui-btn-left ui-btn ui-btn-inline ui-mini ui-corner-all ui-btn-icon-left ui-icon-user')} + % elif request.matched_route.name in ('mobile.login', 'mobile.about'): + ${h.link_to("Home", url('mobile.home'), class_='ui-btn-left ui-btn ui-btn-inline ui-mini ui-corner-all ui-btn-icon-left ui-icon-home')} + % else: + ${h.link_to("Login", url('mobile.login'), class_='ui-btn-left ui-btn ui-btn-inline ui-mini ui-corner-all ui-btn-icon-left ui-icon-user')} + % endif + + +<%def name="mobile_usermenu()"> +
    +
      +
    • ${h.link_to("Home", url('mobile.home'))}
    • +
    • ${h.link_to("Logout", url('logout'), **{'data-ajax': 'false'})}
    • +
    • ${h.link_to("About {}".format(capture(self.app_title)), url('mobile.about'))}
    • +
    +
    + + +<%def name="mobile_page_body()"> +
    + % if capture(self.page_title): +

    ${self.page_title()}

    + % endif + + ${self.body()} + +
    + ${self.mobile_header_link()} +
    + +
    + + +<%def name="mobile_footer()"> +
    +

    powered by ${h.link_to("Rattail", url('mobile.about'))}

    +
    + diff --git a/tailbone/templates/mobile/base_external_toolbars.mako b/tailbone/templates/mobile/base_external_toolbars.mako new file mode 100644 index 00000000..058a627a --- /dev/null +++ b/tailbone/templates/mobile/base_external_toolbars.mako @@ -0,0 +1,20 @@ +## -*- coding: utf-8 -*- +<%inherit file="tailbone:templates/mobile/base.mako" /> + +<%def name="mobile_body()"> + + + ${self.mobile_header()} + +
    + + ${self.mobile_usermenu()} + + ${self.mobile_page_body()} + +
    + + ${self.mobile_footer()} + + + diff --git a/tailbone/templates/mobile/home.mako b/tailbone/templates/mobile/home.mako index 3d617892..9d87d296 100644 --- a/tailbone/templates/mobile/home.mako +++ b/tailbone/templates/mobile/home.mako @@ -1,7 +1,9 @@ ## -*- coding: utf-8 -*- <%inherit file="/mobile/base.mako" /> -<%def name="title()"> +<%def name="title()">Home + +<%def name="page_title()">
    ${h.image(request.static_url('tailbone:static/img/home_logo.png'), "Rattail Logo", width='400')} diff --git a/tailbone/templates/mobile/login.mako b/tailbone/templates/mobile/login.mako new file mode 100644 index 00000000..5a5efb9f --- /dev/null +++ b/tailbone/templates/mobile/login.mako @@ -0,0 +1,7 @@ +## -*- coding: utf-8 -*- +<%inherit file="/mobile/base.mako" /> +<%namespace file="/login.mako" import="login_form" /> + +<%def name="title()">Login + +${login_form()} diff --git a/tailbone/views/auth.py b/tailbone/views/auth.py index 4b1f8e4f..0cb2f60f 100644 --- a/tailbone/views/auth.py +++ b/tailbone/views/auth.py @@ -28,164 +28,181 @@ from __future__ import unicode_literals, absolute_import from rattail.db.auth import authenticate_user, set_user_password -import formencode -from pyramid.httpexceptions import HTTPFound, HTTPForbidden +import formencode as fe +from pyramid.httpexceptions import HTTPForbidden from pyramid.security import remember, forget from pyramid_simpleform import Form -from webhelpers.html import literal -from webhelpers.html import tags +from webhelpers.html import tags, literal +from tailbone import forms from tailbone.db import Session -from tailbone.forms.simpleform import FormRenderer +from tailbone.views import View -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 request.authenticated_userid: - 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) - return HTTPFound(location=request.get_referrer()) - - -class UserLogin(formencode.Schema): +class UserLogin(fe.Schema): allow_extra_fields = True filter_extra_fields = True - username = formencode.validators.NotEmpty() - password = formencode.validators.NotEmpty() + username = fe.validators.NotEmpty() + password = fe.validators.NotEmpty() -def login(request): - """ - The login view, responsible for displaying and handling the login form. - """ - referrer = request.get_referrer() - - # Redirect if already logged in. - if request.user: - return HTTPFound(location=referrer) - - form = Form(request, schema=UserLogin) - if form.validate(): - user = authenticate_user(Session(), - form.data['username'], - form.data['password']) - if user: - 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") - - return {'form': FormRenderer(form), 'referrer': referrer} - - -def logout(request): - """ - View responsible for logging out the current user. - - This deletes/invalidates the current session and then redirects to the - login page. - """ - - request.session.delete() - request.session.invalidate() - headers = forget(request) - referrer = request.get_referrer() - return HTTPFound(location=referrer, headers=headers) - - -def become_root(request): - """ - Elevate the current request to 'root' for full system access. - """ - if not request.is_admin: - raise HTTPForbidden() - request.session['is_root'] = True - request.session.flash("You have been elevated to 'root' and now have full system access") - return HTTPFound(location=request.get_referrer()) - - -def stop_root(request): - """ - Lower the current request from 'root' back to normal access. - """ - if not request.is_admin: - raise HTTPForbidden() - request.session['is_root'] = False - request.session.flash("Your normal system access has been restored") - return HTTPFound(location=request.get_referrer()) - - -class CurrentPasswordCorrect(formencode.validators.FancyValidator): +class CurrentPasswordCorrect(fe.validators.FancyValidator): def _to_python(self, value, state): user = state if not authenticate_user(Session, user.username, value): - raise formencode.Invalid("The password is incorrect.", value, state) + raise fe.Invalid("The password is incorrect.", value, state) return value -class ChangePassword(formencode.Schema): +class ChangePassword(fe.Schema): allow_extra_fields = True filter_extra_fields = True - current_password = formencode.All( - formencode.validators.NotEmpty(), + current_password = fe.All( + fe.validators.NotEmpty(), CurrentPasswordCorrect()) - new_password = formencode.validators.NotEmpty() - confirm_password = formencode.validators.NotEmpty() + new_password = fe.validators.NotEmpty() + confirm_password = fe.validators.NotEmpty() - chained_validators = [formencode.validators.FieldsMatch( + chained_validators = [fe.validators.FieldsMatch( 'new_password', 'confirm_password')] -def change_password(request): - """ - Allows a user to change his or her password. - """ +class AuthenticationView(View): - if not request.user: - return HTTPFound(location=request.route_url('home')) + def forbidden(self): + """ + Access forbidden view. - form = Form(request, schema=ChangePassword, state=request.user) - if form.validate(): - set_user_password(request.user, form.data['new_password']) - return HTTPFound(location=request.get_referrer()) + 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 self.request.authenticated_userid: + msg += literal("  (Perhaps you should %s?)" % + tags.link_to("log in", self.request.route_url('login'))) + # Store current URL in session, for smarter redirect after login. + self.request.session['next_url'] = self.request.current_route_url() + self.request.session.flash(msg, allow_duplicate=False) + return self.redirect(self.request.get_referrer()) - return {'form': FormRenderer(form)} + def login(self, mobile=False): + """ + The login view, responsible for displaying and handling the login form. + """ + home = 'mobile.home' if mobile else 'home' + referrer = self.request.get_referrer(default=self.request.route_url(home)) + # redirect if already logged in + if self.request.user: + if not mobile: + self.request.session.flash("{} is already logged in".format(self.request.user), 'error') + return self.redirect(referrer) + + form = Form(self.request, schema=UserLogin) + context = {'form': forms.FormRenderer(form), 'referrer': referrer, 'dialog': mobile} + if form.validate(): + user = authenticate_user(Session(), + form.data['username'], + form.data['password']) + if user: + # okay now they're truly logged in + headers = remember(self.request, user.uuid) + # Treat URL from session as referrer, if available. + referrer = self.request.session.pop('next_url', referrer) + return self.redirect(referrer, headers=headers) + else: + if mobile: + context['error'] = "Invalid username or password" + else: + self.request.session.flash("Invalid username or password") + return context + + def mobile_login(self): + return self.login(mobile=True) + + def logout(self, mobile=False): + """ + View responsible for logging out the current user. + + This deletes/invalidates the current session and then redirects to the + login page. + """ + self.request.session.delete() + self.request.session.invalidate() + headers = forget(self.request) + login = 'mobile.login' if mobile else 'login' + referrer = self.request.get_referrer(default=self.request.route_url(login)) + return self.redirect(referrer, headers=headers) + + def mobile_logout(self): + return self.logout(mobile=True) + + def change_password(self): + """ + Allows a user to change his or her password. + """ + if not self.request.user: + return self.redirect(self.request.route_url('home')) + + form = Form(self.request, schema=ChangePassword, state=self.request.user) + if form.validate(): + set_user_password(self.request.user, form.data['new_password']) + return self.redirect(self.request.get_referrer()) + + return {'form': forms.FormRenderer(form)} + + def become_root(self): + """ + Elevate the current request to 'root' for full system access. + """ + if not self.request.is_admin: + raise HTTPForbidden() + self.request.session['is_root'] = True + self.request.session.flash("You have been elevated to 'root' and now have full system access") + return self.redirect(self.request.get_referrer()) + + def stop_root(self): + """ + Lower the current request from 'root' back to normal access. + """ + if not self.request.is_admin: + raise HTTPForbidden() + self.request.session['is_root'] = False + self.request.session.flash("Your normal system access has been restored") + return self.redirect(self.request.get_referrer()) + + @classmethod + def defaults(cls, config): + + # forbidden + config.add_forbidden_view(cls, attr='forbidden') + + # login + config.add_route('login', '/login') + config.add_view(cls, attr='login', route_name='login', renderer='/login.mako') + config.add_route('mobile.login', '/mobile/login') + config.add_view(cls, attr='mobile_login', route_name='mobile.login', renderer='/mobile/login.mako') + + # logout + config.add_route('logout', '/logout') + config.add_view(cls, attr='logout', route_name='logout') + config.add_route('mobile.logout', '/mobile/logout') + config.add_view(cls, attr='mobile_logout', route_name='mobile.logout') + + # change password + config.add_route('change_password', '/change-password') + config.add_view(cls, attr='change_password', route_name='change_password', renderer='/change_password.mako') + + # become/stop root + config.add_route('become_root', '/root/yes') + config.add_view(cls, attr='become_root', route_name='become_root') + config.add_route('stop_root', '/root/no') + config.add_view(cls, attr='stop_root', route_name='stop_root') -def add_routes(config): - config.add_route('login', '/login') - config.add_route('logout', '/logout') - config.add_route('become_root', '/root/yes') - config.add_route('stop_root', '/root/no') - config.add_route('change_password', '/change-password') - def includeme(config): - add_routes(config) - - config.add_forbidden_view(forbidden) - - config.add_view(login, route_name='login', - renderer='/login.mako') - - config.add_view(logout, route_name='logout') - - config.add_view(become_root, route_name='become_root') - config.add_view(stop_root, route_name='stop_root') - - config.add_view(change_password, route_name='change_password', - renderer='/change_password.mako') + AuthenticationView.defaults(config) From 024d3c19757d11b4d9823f8c08da39470f48d9e0 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 11 Dec 2016 19:16:24 -0600 Subject: [PATCH 0032/3196] Add mobile support for "become/stop root" feature --- tailbone/static/css/mobile.css | 9 +++++++++ tailbone/templates/mobile/base.mako | 12 +++++++++--- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/tailbone/static/css/mobile.css b/tailbone/static/css/mobile.css index 121213f9..e808a71b 100644 --- a/tailbone/static/css/mobile.css +++ b/tailbone/static/css/mobile.css @@ -3,6 +3,15 @@ * Global styles for mobile templates ****************************************/ +[data-role="header"] a.root-user, +[data-role="header"] a.root-user:hover { + background-color: red; +} + +#usermenu .root-user a { + background-color: red; +} + .replacement-header { display: none; } diff --git a/tailbone/templates/mobile/base.mako b/tailbone/templates/mobile/base.mako index e956b2c9..806ecae7 100644 --- a/tailbone/templates/mobile/base.mako +++ b/tailbone/templates/mobile/base.mako @@ -53,12 +53,13 @@ <%def name="mobile_header_link()"> + <% classes = 'ui-btn-left ui-btn ui-btn-inline ui-mini ui-corner-all ui-btn-icon-left ' %> % if request.user: - ${h.link_to(request.user.get_short_name(), '#usermenu', class_='ui-btn-left ui-btn ui-btn-inline ui-mini ui-corner-all ui-btn-icon-left ui-icon-user')} + ${h.link_to(request.user.get_short_name(), '#usermenu', class_=classes + 'ui-icon-user' + (' root-user' if request.is_root else ''))} % elif request.matched_route.name in ('mobile.login', 'mobile.about'): - ${h.link_to("Home", url('mobile.home'), class_='ui-btn-left ui-btn ui-btn-inline ui-mini ui-corner-all ui-btn-icon-left ui-icon-home')} + ${h.link_to("Home", url('mobile.home'), class_=classes + 'ui-icon-home')} % else: - ${h.link_to("Login", url('mobile.login'), class_='ui-btn-left ui-btn ui-btn-inline ui-mini ui-corner-all ui-btn-icon-left ui-icon-user')} + ${h.link_to("Login", url('mobile.login'), class_=classes + 'ui-icon-user')} % endif @@ -66,6 +67,11 @@
    • ${h.link_to("Home", url('mobile.home'))}
    • + % if request.is_root: +
    • ${h.link_to("Stop being root", url('stop_root'), **{'data-ajax': 'false'})}
    • + % elif request.is_admin: +
    • ${h.link_to("Become root", url('become_root'), **{'data-ajax': 'false'})}
    • + % endif
    • ${h.link_to("Logout", url('logout'), **{'data-ajax': 'false'})}
    • ${h.link_to("About {}".format(capture(self.app_title)), url('mobile.about'))}
    From 22c7fee0f66b020689957a1b7f364b1ebcf068bf Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 11 Dec 2016 19:52:17 -0600 Subject: [PATCH 0033/3196] Tweak icons for mobile menu --- tailbone/templates/mobile/base.mako | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tailbone/templates/mobile/base.mako b/tailbone/templates/mobile/base.mako index 806ecae7..f40d1874 100644 --- a/tailbone/templates/mobile/base.mako +++ b/tailbone/templates/mobile/base.mako @@ -68,11 +68,11 @@
    • ${h.link_to("Home", url('mobile.home'))}
    • % if request.is_root: -
    • ${h.link_to("Stop being root", url('stop_root'), **{'data-ajax': 'false'})}
    • +
    • ${h.link_to("Stop being root", url('stop_root'), **{'data-ajax': 'false'})}
    • % elif request.is_admin: -
    • ${h.link_to("Become root", url('become_root'), **{'data-ajax': 'false'})}
    • +
    • ${h.link_to("Become root", url('become_root'), **{'data-ajax': 'false'})}
    • % endif -
    • ${h.link_to("Logout", url('logout'), **{'data-ajax': 'false'})}
    • +
    • ${h.link_to("Logout", url('logout'), **{'data-ajax': 'false'})}
    • ${h.link_to("About {}".format(capture(self.app_title)), url('mobile.about'))}
    From 06dee96af61c33df6d58f55a1edd1de3c59afbd0 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 11 Dec 2016 21:20:51 -0600 Subject: [PATCH 0034/3196] Add mobile support for datasync restart --- tailbone/static/js/tailbone.mobile.js | 7 +++++++ tailbone/templates/mobile/base.mako | 3 +++ tailbone/templates/mobile/datasync.mako | 8 ++++++++ tailbone/views/datasync.py | 14 ++++++++++---- 4 files changed, 28 insertions(+), 4 deletions(-) create mode 100644 tailbone/templates/mobile/datasync.mako diff --git a/tailbone/static/js/tailbone.mobile.js b/tailbone/static/js/tailbone.mobile.js index 3e633849..f3b59da2 100644 --- a/tailbone/static/js/tailbone.mobile.js +++ b/tailbone/static/js/tailbone.mobile.js @@ -37,3 +37,10 @@ $(document).on('pageshow', function() { el.focus(); } }); + + +$(document).on('click', '#datasync-restart', function() { + + // disable datasync restart button when clicked + $(this).button('disable'); +}); diff --git a/tailbone/templates/mobile/base.mako b/tailbone/templates/mobile/base.mako index f40d1874..3adcac39 100644 --- a/tailbone/templates/mobile/base.mako +++ b/tailbone/templates/mobile/base.mako @@ -72,6 +72,9 @@ % elif request.is_admin:
  • ${h.link_to("Become root", url('become_root'), **{'data-ajax': 'false'})}
  • % endif + % if request.has_perm('datasync.restart'): +
  • ${h.link_to("DataSync", url('datasync.mobile'))}
  • + % endif
  • ${h.link_to("Logout", url('logout'), **{'data-ajax': 'false'})}
  • ${h.link_to("About {}".format(capture(self.app_title)), url('mobile.about'))}
  • diff --git a/tailbone/templates/mobile/datasync.mako b/tailbone/templates/mobile/datasync.mako new file mode 100644 index 00000000..58d42977 --- /dev/null +++ b/tailbone/templates/mobile/datasync.mako @@ -0,0 +1,8 @@ +## -*- coding: utf-8 -*- +<%inherit file="/mobile/base.mako" /> + +<%def name="title()">DataSync + +${h.form(url('datasync.restart'))} +${h.submit('restart', "Restart DataSync", id='datasync-restart')} +${h.end_form()} diff --git a/tailbone/views/datasync.py b/tailbone/views/datasync.py index 23d77d03..6d31599d 100644 --- a/tailbone/views/datasync.py +++ b/tailbone/views/datasync.py @@ -43,7 +43,6 @@ class DataSyncChangesView(MasterView): Master view for the DataSyncChange model. """ model_class = model.DataSyncChange - model_title = "DataSync Change" url_prefix = '/datasync/changes' permission_prefix = 'datasync' @@ -74,7 +73,10 @@ class DataSyncChangesView(MasterView): self.request.session.flash("DataSync daemon has been restarted.") else: self.request.session.flash("DataSync daemon could not be restarted; result was: {}".format(result), 'error') - return self.redirect(self.request.route_url('datasyncchanges')) + return self.redirect(self.request.get_referrer(default=self.request.route_url('datasyncchanges'))) + + def mobile_index(self): + return {} @classmethod def defaults(cls, config): @@ -84,10 +86,14 @@ class DataSyncChangesView(MasterView): # restart daemon config.add_route('datasync.restart', '/datasync/restart') - config.add_view(cls, attr='restart', route_name='datasync.restart', - permission='datasync.restart') + config.add_view(cls, attr='restart', route_name='datasync.restart', permission='datasync.restart') config.add_tailbone_permission('datasync', 'datasync.restart', label="Restart DataSync Daemon") + # mobile + config.add_route('datasync.mobile', '/mobile/datasync/') + config.add_view(cls, attr='mobile_index', route_name='datasync.mobile', + permission='datasync.restart', renderer='/mobile/datasync.mako') + cls._defaults(config) From 79e63571e3be66f2028de79c478af9c52dd80217 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 12 Dec 2016 12:13:59 -0600 Subject: [PATCH 0035/3196] Make `CurrencyFieldRenderer` inherit from `FloatFieldRenderer` Also cleanup some code generally.. --- tailbone/forms/renderers/common.py | 35 ++++++++++++++---------------- 1 file changed, 16 insertions(+), 19 deletions(-) diff --git a/tailbone/forms/renderers/common.py b/tailbone/forms/renderers/common.py index 0ad9350f..58982b5f 100644 --- a/tailbone/forms/renderers/common.py +++ b/tailbone/forms/renderers/common.py @@ -28,20 +28,17 @@ from __future__ import unicode_literals, absolute_import import datetime -import pytz +from rattail.time import localtime, make_utc -from rattail.time import localtime - -import formalchemy -from formalchemy import helpers -from formalchemy.fields import FieldRenderer, SelectFieldRenderer, CheckBoxFieldRenderer +import formalchemy as fa +from formalchemy import fields as fa_fields, helpers as fa_helpers from pyramid.renderers import render from webhelpers.html import HTML from tailbone.util import pretty_datetime, raw_datetime -class StrippedTextFieldRenderer(formalchemy.TextFieldRenderer): +class StrippedTextFieldRenderer(fa.TextFieldRenderer): """ Standard text field renderer, which strips whitespace from either end of the input value on deserialization. @@ -53,7 +50,7 @@ class StrippedTextFieldRenderer(formalchemy.TextFieldRenderer): return value.strip() -class CodeTextAreaFieldRenderer(formalchemy.TextAreaFieldRenderer): +class CodeTextAreaFieldRenderer(fa.TextAreaFieldRenderer): def render_readonly(self, **kwargs): value = self.raw_value @@ -66,7 +63,7 @@ class CodeTextAreaFieldRenderer(formalchemy.TextAreaFieldRenderer): return super(CodeTextAreaFieldRenderer, self).render(**kwargs) -class AutocompleteFieldRenderer(FieldRenderer): +class AutocompleteFieldRenderer(fa.FieldRenderer): """ Custom renderer for an autocomplete field. """ @@ -109,7 +106,7 @@ class AutocompleteFieldRenderer(FieldRenderer): return unicode(value) -class DateTimeFieldRenderer(formalchemy.DateTimeFieldRenderer): +class DateTimeFieldRenderer(fa.DateTimeFieldRenderer): """ This renderer assumes the datetime field value is in UTC, and will convert it to the local time zone before rendering it in the standard "raw" format. @@ -122,7 +119,7 @@ class DateTimeFieldRenderer(formalchemy.DateTimeFieldRenderer): return raw_datetime(self.request.rattail_config, value) -class DateTimePrettyFieldRenderer(formalchemy.DateTimeFieldRenderer): +class DateTimePrettyFieldRenderer(fa.DateTimeFieldRenderer): """ Custom date/time field renderer, which displays a "pretty" value in read-only mode, leveraging config to show the correct timezone. @@ -135,7 +132,7 @@ class DateTimePrettyFieldRenderer(formalchemy.DateTimeFieldRenderer): return pretty_datetime(self.request.rattail_config, value) -class TimeFieldRenderer(formalchemy.TimeFieldRenderer): +class TimeFieldRenderer(fa.TimeFieldRenderer): """ Custom renderer for time fields. In edit mode, renders a simple text input, which is expected to become a 'timepicker' widget in the UI. @@ -145,7 +142,7 @@ class TimeFieldRenderer(formalchemy.TimeFieldRenderer): def render(self, **kwargs): kwargs.setdefault('class_', 'timepicker') - return helpers.text_field(self.name, value=self.value, **kwargs) + return fa_helpers.text_field(self.name, value=self.value, **kwargs) def render_readonly(self, **kwargs): return self.render_value(self.raw_value) @@ -159,7 +156,7 @@ class TimeFieldRenderer(formalchemy.TimeFieldRenderer): def convert_value(self, value): if isinstance(value, datetime.datetime): if not value.tzinfo: - value = pytz.utc.localize(value) + value = make_utc(value, tzinfo=True) return localtime(self.request.rattail_config, value).time() return value @@ -180,7 +177,7 @@ class TimeFieldRenderer(formalchemy.TimeFieldRenderer): pass -class EnumFieldRenderer(SelectFieldRenderer): +class EnumFieldRenderer(fa_fields.SelectFieldRenderer): """ Renderer for simple enumeration fields. """ @@ -211,10 +208,10 @@ class EnumFieldRenderer(SelectFieldRenderer): opts = [(self.enumeration[x], x) for x in self.enumeration] if not self.field.is_required(): opts.insert(0, self.field._null_option) - return SelectFieldRenderer.render(self, opts, **kwargs) + return fa_fields.SelectFieldRenderer.render(self, opts, **kwargs) -class DecimalFieldRenderer(formalchemy.FieldRenderer): +class DecimalFieldRenderer(fa.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 @@ -237,7 +234,7 @@ class DecimalFieldRenderer(formalchemy.FieldRenderer): return fmt.format(value) -class CurrencyFieldRenderer(formalchemy.FieldRenderer): +class CurrencyFieldRenderer(fa_fields.FloatFieldRenderer): """ Sort of generic field renderer for currency values. """ @@ -251,7 +248,7 @@ class CurrencyFieldRenderer(formalchemy.FieldRenderer): return "${:0,.2f}".format(value) -class YesNoFieldRenderer(CheckBoxFieldRenderer): +class YesNoFieldRenderer(fa.CheckBoxFieldRenderer): def render_readonly(self, **kwargs): value = self.raw_value From 14ac7aa1987363ee462c310e8394f24597108b92 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 12 Dec 2016 15:24:09 -0600 Subject: [PATCH 0036/3196] Fix session bug in old CRUD views --- tailbone/views/crud.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tailbone/views/crud.py b/tailbone/views/crud.py index 4dd492d1..29bfbf7e 100644 --- a/tailbone/views/crud.py +++ b/tailbone/views/crud.py @@ -100,6 +100,7 @@ class CrudView(View): kwargs.setdefault('cancel_url', self.cancel_url) kwargs.setdefault('creating', self.creating) kwargs.setdefault('updating', self.updating) + kwargs.setdefault('session', Session()) form = form_factory(self.request, fieldset, **kwargs) if form.creating: From 7f14f50ee0b922a5f649b2954a9b9daa0b494623 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 12 Dec 2016 15:24:26 -0600 Subject: [PATCH 0037/3196] More mobile view improvements, various --- tailbone/static/css/mobile.css | 20 +++++++---- tailbone/static/js/tailbone.mobile.js | 5 +++ tailbone/templates/login.mako | 5 --- tailbone/templates/mobile/about.mako | 2 +- tailbone/templates/mobile/base.mako | 29 ++++++++++++---- ...lbars.mako => base_internal_toolbars.mako} | 8 ++--- tailbone/templates/mobile/datasync.mako | 2 +- tailbone/views/__init__.py | 31 ++++++----------- tailbone/views/auth.py | 10 ++---- tailbone/views/common.py | 33 +++++++++++++++++-- tailbone/views/datasync.py | 3 +- 11 files changed, 92 insertions(+), 56 deletions(-) rename tailbone/templates/mobile/{base_external_toolbars.mako => base_internal_toolbars.mako} (84%) diff --git a/tailbone/static/css/mobile.css b/tailbone/static/css/mobile.css index e808a71b..cf4f9409 100644 --- a/tailbone/static/css/mobile.css +++ b/tailbone/static/css/mobile.css @@ -3,21 +3,29 @@ * Global styles for mobile templates ****************************************/ +/* main user menu button when root */ [data-role="header"] a.root-user, [data-role="header"] a.root-user:hover { background-color: red; } +/* become/stop root menu links */ #usermenu .root-user a { background-color: red; } +/* normal flash messages */ +.flash { + color: green; + margin-bottom: 1em; +} + +/* error flash messages */ +.error { + color: red; + margin-bottom: 1em; +} + .replacement-header { display: none; } - -/* used by login page */ -.error { - color: red; - margin-bottom: 1em; -} diff --git a/tailbone/static/js/tailbone.mobile.js b/tailbone/static/js/tailbone.mobile.js index f3b59da2..9852abba 100644 --- a/tailbone/static/js/tailbone.mobile.js +++ b/tailbone/static/js/tailbone.mobile.js @@ -36,6 +36,11 @@ $(document).on('pageshow', function() { if (el.is(':visible')) { el.focus(); } + + // TODO: seems like this should be better somehow... + // remove all flash messages after 2.5 seconds + window.setTimeout(function() { $('.flash, .error').remove(); }, 2500); + }); diff --git a/tailbone/templates/login.mako b/tailbone/templates/login.mako index bec67e91..d9191026 100644 --- a/tailbone/templates/login.mako +++ b/tailbone/templates/login.mako @@ -18,11 +18,6 @@ ${form.begin(**{'data-ajax': 'false'})} ${form.hidden('referrer', value=referrer)} - ## this is used by mobile view - % if error: -
    ${error}
    - % endif - ${form.field_div('username', form.text('username'))} ${form.field_div('password', form.password('password'))} diff --git a/tailbone/templates/mobile/about.mako b/tailbone/templates/mobile/about.mako index d0ee9d07..ca0e2612 100644 --- a/tailbone/templates/mobile/about.mako +++ b/tailbone/templates/mobile/about.mako @@ -1,7 +1,7 @@ ## -*- coding: utf-8 -*- <%inherit file="/mobile/base.mako" /> -<%def name="title()">About ${project_title} +<%def name="title()">About ${self.app_title()}

    ${project_title} ${project_version}

    diff --git a/tailbone/templates/mobile/base.mako b/tailbone/templates/mobile/base.mako index 3adcac39..31051cd5 100644 --- a/tailbone/templates/mobile/base.mako +++ b/tailbone/templates/mobile/base.mako @@ -22,18 +22,20 @@ <%def name="mobile_body()"> + ## note that our toolbars are *external* (in jqm-speak) by default + + ${self.mobile_header()} +
    ${self.mobile_usermenu()} - ${self.mobile_header()} - ${self.mobile_page_body()} - ${self.mobile_footer()} -
    + ${self.mobile_footer()} + @@ -67,14 +69,14 @@
    • ${h.link_to("Home", url('mobile.home'))}
    • + % if request.has_perm('datasync.restart'): +
    • ${h.link_to("DataSync", url('datasync.mobile'))}
    • + % endif % if request.is_root:
    • ${h.link_to("Stop being root", url('stop_root'), **{'data-ajax': 'false'})}
    • % elif request.is_admin:
    • ${h.link_to("Become root", url('become_root'), **{'data-ajax': 'false'})}
    • % endif - % if request.has_perm('datasync.restart'): -
    • ${h.link_to("DataSync", url('datasync.mobile'))}
    • - % endif
    • ${h.link_to("Logout", url('logout'), **{'data-ajax': 'false'})}
    • ${h.link_to("About {}".format(capture(self.app_title)), url('mobile.about'))}
    @@ -83,6 +85,19 @@ <%def name="mobile_page_body()">
    + + % if request.session.peek_flash('error'): + % for error in request.session.pop_flash('error'): +
    ${error}
    + % endfor + % endif + + % if request.session.peek_flash(): + % for msg in request.session.pop_flash(): +
    ${msg|n}
    + % endfor + % endif + % if capture(self.page_title):

    ${self.page_title()}

    % endif diff --git a/tailbone/templates/mobile/base_external_toolbars.mako b/tailbone/templates/mobile/base_internal_toolbars.mako similarity index 84% rename from tailbone/templates/mobile/base_external_toolbars.mako rename to tailbone/templates/mobile/base_internal_toolbars.mako index 058a627a..107ca928 100644 --- a/tailbone/templates/mobile/base_external_toolbars.mako +++ b/tailbone/templates/mobile/base_internal_toolbars.mako @@ -4,17 +4,17 @@ <%def name="mobile_body()"> - ${self.mobile_header()} -
    ${self.mobile_usermenu()} + ${self.mobile_header()} + ${self.mobile_page_body()} + ${self.mobile_footer()} +
    - ${self.mobile_footer()} - diff --git a/tailbone/templates/mobile/datasync.mako b/tailbone/templates/mobile/datasync.mako index 58d42977..1f72702a 100644 --- a/tailbone/templates/mobile/datasync.mako +++ b/tailbone/templates/mobile/datasync.mako @@ -4,5 +4,5 @@ <%def name="title()">DataSync ${h.form(url('datasync.restart'))} -${h.submit('restart', "Restart DataSync", id='datasync-restart')} +${h.submit('restart', "Restart DataSync Daemon", id='datasync-restart')} ${h.end_form()} diff --git a/tailbone/views/__init__.py b/tailbone/views/__init__.py index 3e04f114..b39739dc 100644 --- a/tailbone/views/__init__.py +++ b/tailbone/views/__init__.py @@ -27,36 +27,24 @@ Pyramid Views from __future__ import unicode_literals, absolute_import from .core import View -from tailbone.views.grids import ( +from .master import MasterView + +# TODO: deprecate / remove some of this +from .autocomplete import AutocompleteView +from .crud import CrudView +from .grids import ( GridView, AlchemyGridView, SortableAlchemyGridView, PagedAlchemyGridView, SearchableAlchemyGridView) -from .crud import CrudView -from .master import MasterView -from tailbone.views.autocomplete import AutocompleteView - - -def home(request): - """ - Default home view. - """ - - return {} - - -def add_routes(config): - config.add_route('home', '/') def includeme(config): - add_routes(config) - - config.add_view(home, route_name='home', - renderer='/home.mako') + # core views config.include('tailbone.views.core') config.include('tailbone.views.common') - config.include('tailbone.views.auth') + + # main table views config.include('tailbone.views.bouncer') config.include('tailbone.views.brands') config.include('tailbone.views.categories') @@ -87,5 +75,6 @@ def includeme(config): config.include('tailbone.views.users') config.include('tailbone.views.vendors') + # batch views config.include('tailbone.views.batches') config.include('tailbone.views.batch.pricing') diff --git a/tailbone/views/auth.py b/tailbone/views/auth.py index 0cb2f60f..ed5fbf47 100644 --- a/tailbone/views/auth.py +++ b/tailbone/views/auth.py @@ -98,8 +98,7 @@ class AuthenticationView(View): # redirect if already logged in if self.request.user: - if not mobile: - self.request.session.flash("{} is already logged in".format(self.request.user), 'error') + self.request.session.flash("{} is already logged in".format(self.request.user), 'error') return self.redirect(referrer) form = Form(self.request, schema=UserLogin) @@ -111,14 +110,11 @@ class AuthenticationView(View): if user: # okay now they're truly logged in headers = remember(self.request, user.uuid) - # Treat URL from session as referrer, if available. + # treat URL from session as referrer, if available referrer = self.request.session.pop('next_url', referrer) return self.redirect(referrer, headers=headers) else: - if mobile: - context['error'] = "Invalid username or password" - else: - self.request.session.flash("Invalid username or password") + self.request.session.flash("Invalid username or password", 'error') return context def mobile_login(self): diff --git a/tailbone/views/common.py b/tailbone/views/common.py index e4be854a..f0b874fd 100644 --- a/tailbone/views/common.py +++ b/tailbone/views/common.py @@ -56,6 +56,18 @@ class CommonView(View): project_title = "Tailbone" project_version = tailbone.__version__ + def home(self, mobile=False): + """ + Home page view. + """ + return {} + + def mobile_home(self): + """ + Home page view for mobile. + """ + return self.home(mobile=True) + def about(self): """ Generic view to show "about project" info page. @@ -89,19 +101,36 @@ class CommonView(View): self.request.session.flash("Thank you for your feedback.") return httpexceptions.HTTPFound(location=form.data['referrer']) return {'form': forms.FormRenderer(form)} + + def bogus_error(self): + """ + A special view which simply raises an error, for the sake of testing + uncaught exception handling. + """ + raise Exception("Congratulations, you have triggered a bogus error.") @classmethod def defaults(cls, config): + # home + config.add_route('home', '/') + config.add_view(cls, attr='home', route_name='home', renderer='/home.mako') + config.add_route('mobile.home', '/mobile/') + config.add_view(cls, attr='mobile_home', route_name='mobile.home', renderer='/mobile/home.mako') + # about config.add_route('about', '/about') config.add_view(cls, attr='about', route_name='about', renderer='/about.mako') config.add_route('mobile.about', '/mobile/about') config.add_view(cls, attr='about', route_name='mobile.about', renderer='/mobile/about.mako') + # feedback config.add_route('feedback', '/feedback') - config.add_view(cls, attr='feedback', route_name='feedback', - renderer='/feedback.mako') + config.add_view(cls, attr='feedback', route_name='feedback', renderer='/feedback.mako') + + # bogus error + config.add_route('bogus_error', '/bogus-error') + config.add_view(cls, attr='bogus_error', route_name='bogus_error', permission='errors.bogus') def includeme(config): diff --git a/tailbone/views/datasync.py b/tailbone/views/datasync.py index 6d31599d..4002e373 100644 --- a/tailbone/views/datasync.py +++ b/tailbone/views/datasync.py @@ -30,7 +30,6 @@ import subprocess import logging from rattail.db import model -from rattail.config import parse_list from tailbone.views import MasterView @@ -66,7 +65,7 @@ class DataSyncChangesView(MasterView): def restart(self): # TODO: Add better validation (e.g. CSRF) here? if self.request.method == 'POST': - cmd = parse_list(self.rattail_config.require('tailbone', 'datasync.restart')) + cmd = self.rattail_config.getlist('tailbone', 'datasync.restart', default='/bin/sleep 3') # simulate by default log.debug("attempting datasync restart with command: {}".format(cmd)) result = subprocess.call(cmd) if result == 0: From f890405162121c28b71c0afa2b69fa84a4b384a8 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 12 Dec 2016 15:26:21 -0600 Subject: [PATCH 0038/3196] 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 d21ded7c..3663e7e8 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,18 @@ CHANGELOG ========= +0.5.57 (2016-12-12) +------------------- + +* Lots of changes for sake of mobile login / user menu etc. + +* Add mobile support for datasync restart + +* Make ``CurrencyFieldRenderer`` inherit from ``FloatFieldRenderer`` + +* Fix session bug in old CRUD views + + 0.5.56 (2016-12-11) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 649e96a9..c625ae31 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- -__version__ = u'0.5.56' +__version__ = u'0.5.57' From acbb3d289c4dd2e595ec0e625ab7e8bc23870773 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 13 Dec 2016 22:27:52 -0600 Subject: [PATCH 0039/3196] Add `ValidGPC` formencode validator --- tailbone/forms/validators.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tailbone/forms/validators.py b/tailbone/forms/validators.py index 6ccfed88..ecd738c9 100644 --- a/tailbone/forms/validators.py +++ b/tailbone/forms/validators.py @@ -26,8 +26,11 @@ Custom Form Validators from __future__ import unicode_literals, absolute_import +import re + from rattail.db import model from rattail.db.util import validate_email_address, validate_phone_number +from rattail.gpc import GPC import formencode as fe import formalchemy as fa @@ -35,6 +38,26 @@ import formalchemy as fa from tailbone.db import Session +class ValidGPC(fe.validators.FancyValidator): + """ + Validator for fields which should contain GPC value. + """ + + def _to_python(self, value, state): + if value is not None: + digits = re.sub(r'\D', '', value) + if digits: + try: + return GPC(digits) + except ValueError as error: + raise fe.Invalid("Invalid UPC: {}".format(error), value, state) + + def _from_python(self, upc, state): + if upc is None: + return '' + return upc.pretty() + + class ModelValidator(fe.validators.FancyValidator): """ Generic validator for data model reference fields. From ed252c6465b6071227a23abb043ddf7ca7fb6785 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 13 Dec 2016 22:28:50 -0600 Subject: [PATCH 0040/3196] Overhaul the Receiving Form to account for "product not found" etc. Also shows ordered/received/etc. quantities --- .../purchases/batches/receive_form.mako | 145 +++++++++++++++-- tailbone/views/purchases/batch.py | 153 +++++++++++++----- 2 files changed, 245 insertions(+), 53 deletions(-) diff --git a/tailbone/templates/purchases/batches/receive_form.mako b/tailbone/templates/purchases/batches/receive_form.mako index 68fe30b6..7bb6a60f 100644 --- a/tailbone/templates/purchases/batches/receive_form.mako +++ b/tailbone/templates/purchases/batches/receive_form.mako @@ -23,16 +23,37 @@ function invalid_product(msg) { $('#received-product-info p').text(msg); $('#received-product-info img').hide(); - $('#received-product-info .rogue-item-warning').hide(); - $('#product-textbox').focus().select(); + $('#upc').focus().select(); $('.field-wrapper.cases input').prop('disabled', true); $('.field-wrapper.units input').prop('disabled', true); $('.buttons button').button('disable'); } + function pretty_quantity(cases, units) { + if (cases && units) { + return cases + " cases, " + units + " units"; + } else if (cases) { + return cases + " cases"; + } else if (units) { + return units + " units"; + } + return ''; + } + + function show_quantity(name, cases, units) { + var quantity = pretty_quantity(cases, units); + var field = $('.field-wrapper.quantity_' + name); + field.find('.field').text(quantity); + if (quantity || name == 'ordered') { + field.show(); + } else { + field.hide(); + } + } + $(function() { - $('#product-textbox').keydown(function(event) { + $('#upc').keydown(function(event) { if (key_allowed(event)) { return true; @@ -41,35 +62,75 @@ $('#product').val(''); $('#received-product-info p').html("please ENTER a scancode"); $('#received-product-info img').hide(); - $('#received-product-info .rogue-item-warning').hide(); + $('#received-product-info .warning').hide(); + $('.product-fields').hide(); + $('.receiving-fields').hide(); $('.field-wrapper.cases input').prop('disabled', true); $('.field-wrapper.units input').prop('disabled', true); $('.buttons button').button('disable'); return true; } + + // when user presses ENTER, do product lookup if (event.which == 13) { - var input = $(this); - var data = {upc: input.val()}; + var upc = $(this).val(); + var data = {'upc': upc}; $.get('${url('purchases.batch.receiving_lookup', uuid=batch.uuid)}', data, function(data) { + if (data.error) { alert(data.error); if (data.redirect) { $('#receiving-form').mask("Redirecting..."); location.href = data.redirect; } + } else if (data.product) { - input.val(data.product.upc_pretty); + $('#upc').val(data.product.upc_pretty); $('#product').val(data.product.uuid); + $('#brand_name').val(data.product.brand_name); + $('#description').val(data.product.description); + $('#size').val(data.product.size); + $('#case_quantity').val(data.product.case_quantity); + $('#received-product-info p').text(data.product.full_description); $('#received-product-info img').attr('src', data.product.image_url).show(); - $('#received-product-info .rogue-item-warning').hide(); - if (! data.product.found_in_batch) { - $('#received-product-info .rogue-item-warning').show(); + if (! data.product.uuid) { + // $('#received-product-info .warning.notfound').show(); + $('.product-fields').show(); + } + if (data.product.found_in_batch) { + show_quantity('ordered', data.product.cases_ordered, data.product.units_ordered); + show_quantity('received', data.product.cases_received, data.product.units_received); + show_quantity('damaged', data.product.cases_damaged, data.product.units_damaged); + show_quantity('expired', data.product.cases_expired, data.product.units_expired); + show_quantity('mispick', data.product.cases_mispick, data.product.units_mispick); + $('.receiving-fields').show(); + } else { + $('#received-product-info .warning.notordered').show(); } $('.field-wrapper.cases input').prop('disabled', false); $('.field-wrapper.units input').prop('disabled', false); $('.buttons button').button('enable'); $('#cases').focus().select(); + + } else if (data.upc) { + $('#upc').val(data.upc_pretty); + $('#received-product-info p').text("product not found in our system"); + $('#received-product-info img').attr('src', data.image_url).show(); + + $('#product').val(''); + $('#brand_name').val(''); + $('#description').val(''); + $('#size').val(''); + $('#case_quantity').val(''); + + $('#received-product-info .warning.notfound').show(); + $('.product-fields').show(); + $('#brand_name').focus(); + $('.field-wrapper.cases input').prop('disabled', false); + $('.field-wrapper.units input').prop('disabled', false); + $('.buttons button').button('enable'); + } else { invalid_product('product not found'); } @@ -212,7 +273,7 @@ $(this).mask("Working..."); }); - $('#product-textbox').focus(); + $('#upc').focus(); $('.field-wrapper.cases input').prop('disabled', true); $('.field-wrapper.units input').prop('disabled', true); $('.buttons button').button('disable'); @@ -235,7 +296,7 @@ margin: 0.5em 0; } - .product-info .rogue-item-warning { + #received-product-info .warning { background: #f66; display: none; } @@ -270,18 +331,72 @@ ${h.hidden('ordered_product')}
    - +
    ${h.hidden('product')} -
    ${h.text('product-textbox', autocomplete='off')}
    +
    ${h.text('upc', autocomplete='off')}

    please ENTER a scancode

    -
    warning: product not found on current purchase
    +
    please confirm UPC and provide more details
    +
    warning: product not found on current purchase
    + + + +
    ${h.text('cases', autocomplete='off')}
    diff --git a/tailbone/views/purchases/batch.py b/tailbone/views/purchases/batch.py index 1be60814..1b910af3 100644 --- a/tailbone/views/purchases/batch.py +++ b/tailbone/views/purchases/batch.py @@ -33,10 +33,11 @@ from sqlalchemy import orm from rattail import pod from rattail.db import model, api +from rattail.db.util import make_full_description from rattail.gpc import GPC from rattail.time import localtime from rattail.core import Object -from rattail.util import OrderedDict +from rattail.util import OrderedDict, pretty_quantity import formalchemy as fa import formencode as fe @@ -55,8 +56,13 @@ class ReceivingForm(forms.Schema): filter_extra_fields = True mode = fe.validators.OneOf(['received', 'damaged', 'expired', 'mispick']) product = forms.validators.ValidProduct() - cases = fe.validators.Int() - units = fe.validators.Int() + upc = forms.validators.ValidGPC() + brand_name = fe.validators.String() + description = fe.validators.String() + size = fe.validators.String() + case_quantity = fe.validators.Number() + cases = fe.validators.Number() + units = fe.validators.Number() expiration_date = fe.validators.DateValidator() ordered_product = forms.validators.ValidProduct() @@ -361,8 +367,10 @@ class PurchaseBatchView(BatchMasterView): def row_grid_row_attrs(self, row, i): attrs = {} - if row.status_code in (row.STATUS_INCOMPLETE, - row.STATUS_ORDERED_RECEIVED_DIFFER): + if row.status_code == row.STATUS_PRODUCT_NOT_FOUND: + attrs['class_'] = 'warning' + elif row.status_code in (row.STATUS_INCOMPLETE, + row.STATUS_ORDERED_RECEIVED_DIFFER): attrs['class_'] = 'notice' return attrs @@ -409,6 +417,9 @@ class PurchaseBatchView(BatchMasterView): fs.item_lookup, fs.upc, fs.product, + fs.brand_name, + fs.description, + fs.size, fs.case_quantity, fs.cases_ordered, fs.units_ordered, @@ -450,6 +461,12 @@ class PurchaseBatchView(BatchMasterView): elif self.viewing: del fs.item_lookup + if fs.model.product: + del (fs.brand_name, + fs.description, + fs.size) + else: + del fs.product def before_create_row(self, form): row = form.fieldset.model @@ -615,8 +632,8 @@ class PurchaseBatchView(BatchMasterView): if row.po_total and not row.removed: batch.po_total -= row.po_total if cases_ordered or units_ordered: - row.cases_ordered = cases_ordered - row.units_ordered = units_ordered + row.cases_ordered = cases_ordered or None + row.units_ordered = units_ordered or None row.removed = False self.handler.refresh_row(row) else: @@ -627,13 +644,13 @@ class PurchaseBatchView(BatchMasterView): row.sequence = max([0] + [r.sequence for r in batch.data_rows]) + 1 row.product = product batch.data_rows.append(row) - row.cases_ordered = cases_ordered - row.units_ordered = units_ordered + row.cases_ordered = cases_ordered or None + row.units_ordered = units_ordered or None self.handler.refresh_row(row) return { - 'row_cases_ordered': '' if row.removed else int(row.cases_ordered), - 'row_units_ordered': '' if row.removed else int(row.units_ordered), + 'row_cases_ordered': '' if row.removed else int(row.cases_ordered or 0), + 'row_units_ordered': '' if row.removed else int(row.units_ordered or 0), 'row_po_total': '' if row.removed else '${:0,.2f}'.format(row.po_total), 'batch_po_total': '${:0,.2f}'.format(batch.po_total), } @@ -689,7 +706,11 @@ class PurchaseBatchView(BatchMasterView): mode = form.data['mode'] shipped_product = form.data['product'] product = form.data['ordered_product'] if mode == 'mispick' else shipped_product - rows = [row for row in batch.active_rows() if row.product is product] + if product: + rows = [row for row in batch.active_rows() if row.product is product] + else: + upc = form.data['upc'] + rows = [row for row in batch.active_rows() if not row.product and row.upc == upc] if rows: if len(rows) > 1: log.warning("found {} matching rows in batch {} for product: {}".format( @@ -698,6 +719,11 @@ class PurchaseBatchView(BatchMasterView): else: row = model.PurchaseBatchRow() row.product = product + row.upc = form.data['upc'] + row.brand_name = form.data['brand_name'] + row.description = form.data['description'] + row.size = form.data['size'] + row.case_quantity = form.data['case_quantity'] batch.add_row(row) cases = form.data['cases'] @@ -716,9 +742,12 @@ class PurchaseBatchView(BatchMasterView): self.handler.refresh_row(row) + description = make_full_description(form.data['brand_name'], + form.data['description'], + form.data['size']) self.request.session.flash("({}) {} cases, {} units: {} {}".format( form.data['mode'], form.data['cases'] or 0, form.data['units'] or 0, - product.upc.pretty(), product)) + form.data['upc'].pretty(), description)) return self.redirect(self.request.current_route_url()) title = self.get_instance_title(batch) @@ -743,35 +772,78 @@ class PurchaseBatchView(BatchMasterView): 'error': "Current batch has already been executed", 'redirect': self.get_action_url('view', batch), } - data = None + data = {} upc = self.request.GET.get('upc', '').strip() upc = re.sub(r'\D', '', upc) if upc: - product = api.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 = api.get_product_by_upc(Session(), upc) - if product and (not product.deleted or self.request.has_perm('products.view_deleted')): - data = { - 'uuid': product.uuid, - 'upc': unicode(product.upc), - 'upc_pretty': product.upc.pretty(), - 'full_description': product.full_description, - 'image_url': pod.get_image_url(self.rattail_config, product.upc), - } - cost = product.cost_for_vendor(batch.vendor) - if cost: - data['cost_found'] = True - if int(cost.case_size) == cost.case_size: - data['cost_case_size'] = int(cost.case_size) + + # first try to locate existing batch row by UPC match + provided = GPC(upc, calc_check_digit=False) + checked = GPC(upc, calc_check_digit='upc') + rows = Session.query(model.PurchaseBatchRow)\ + .filter(model.PurchaseBatchRow.batch == batch)\ + .filter(model.PurchaseBatchRow.upc.in_((provided, checked)))\ + .all() + if rows: + if len(rows) > 1: + log.warning("found multiple UPC matches for {} in batch {}: {}".format( + upc, batch.id_str, batch)) + row = rows[0] + data['uuid'] = row.product_uuid + data['upc'] = unicode(row.upc) + data['upc_pretty'] = row.upc.pretty() + data['full_description'] = make_full_description(row.brand_name, row.description, row.size) + data['brand_name'] = row.brand_name + data['description'] = row.description + data['size'] = row.size + data['case_quantity'] = pretty_quantity(row.case_quantity) + data['image_url'] = pod.get_image_url(self.rattail_config, row.upc) + data['found_in_batch'] = True + data['cases_ordered'] = pretty_quantity(row.cases_ordered, empty_zero=True) + data['units_ordered'] = pretty_quantity(row.units_ordered, empty_zero=True) + data['cases_received'] = pretty_quantity(row.cases_received, empty_zero=True) + data['units_received'] = pretty_quantity(row.units_received, empty_zero=True) + data['cases_damaged'] = pretty_quantity(row.cases_damaged, empty_zero=True) + data['units_damaged'] = pretty_quantity(row.units_damaged, empty_zero=True) + data['cases_expired'] = pretty_quantity(row.cases_expired, empty_zero=True) + data['units_expired'] = pretty_quantity(row.units_expired, empty_zero=True) + data['cases_mispick'] = pretty_quantity(row.cases_mispick, empty_zero=True) + data['units_mispick'] = pretty_quantity(row.units_mispick, empty_zero=True) + + else: # no match in our batch, do full product search + product = api.get_product_by_upc(Session(), provided) + if not product: + product = api.get_product_by_upc(Session(), checked) + if product and (not product.deleted or self.request.has_perm('products.view_deleted')): + data['uuid'] = product.uuid + data['upc'] = unicode(product.upc) + data['upc_pretty'] = product.upc.pretty() + data['full_description'] = product.full_description + data['brand_name'] = unicode(product.brand or '') + data['description'] = product.description + data['size'] = product.size + data['case_quantity'] = 1 # default + cost = product.cost_for_vendor(batch.vendor) + if cost: + data['cost_found'] = True + data['cost_case_size'] = pretty_quantity(cost.case_size) + data['case_quantity'] = pretty_quantity(cost.case_size) else: - data['cost_case_size'] = '{:0.4f}'.format(cost.case_size) - else: - data['cost_found'] = False - data['found_in_batch'] = product in [row.product for row in batch.active_rows()] - - return {'product': data} + data['cost_found'] = False + data['image_url'] = pod.get_image_url(self.rattail_config, product.upc) + data['found_in_batch'] = product in [row.product for row in batch.active_rows()] + + result = {'product': data or None, 'upc': None} + if not data and upc: + upc = GPC(upc) + result['upc'] = unicode(upc) + result['upc_pretty'] = upc.pretty() + result['image_url'] = pod.get_image_url(self.rattail_config, upc) + return result + + def mobile_index(self): + self.mobile = True + return self.render_to_response('mobile_index', {}) @classmethod def defaults(cls, config): @@ -781,6 +853,11 @@ class PurchaseBatchView(BatchMasterView): model_key = cls.get_model_key() model_title = cls.get_model_title() + # mobile + config.add_route('{}.mobile'.format(route_prefix), '/mobile{}'.format(url_prefix)) + config.add_view(cls, attr='mobile_index', route_name='{}.mobile'.format(route_prefix), + permission='{}.list'.format(permission_prefix)) + # eligible purchases (AJAX) config.add_route('{}.eligible_purchases'.format(route_prefix), '{}/eligible-purchases'.format(url_prefix)) config.add_view(cls, attr='eligible_purchases', route_name='{}.eligible_purchases'.format(route_prefix), From 86c667e1f140d637cf1e958fd86381df50077f16 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 13 Dec 2016 22:29:46 -0600 Subject: [PATCH 0041/3196] Auto-append slash to URL when necessary This should make people happy, if they notice.. --- tailbone/views/common.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tailbone/views/common.py b/tailbone/views/common.py index f0b874fd..23f366db 100644 --- a/tailbone/views/common.py +++ b/tailbone/views/common.py @@ -56,6 +56,9 @@ class CommonView(View): project_title = "Tailbone" project_version = tailbone.__version__ + def notfound(self): + return httpexceptions.HTTPNotFound() + def home(self, mobile=False): """ Home page view. @@ -108,10 +111,13 @@ class CommonView(View): uncaught exception handling. """ raise Exception("Congratulations, you have triggered a bogus error.") - + @classmethod def defaults(cls, config): + # auto-correct URLs which require trailing slash + config.add_notfound_view(cls, attr='notfound', append_slash=True) + # home config.add_route('home', '/') config.add_view(cls, attr='home', route_name='home', renderer='/home.mako') From 11e78adaab63086819156a8b9bd2a35634b5e854 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 14 Dec 2016 12:32:41 -0600 Subject: [PATCH 0042/3196] Add "print receiving worksheet" feature, for 'ordered' purchases --- tailbone/helpers.py | 2 + .../purchases/batches/receive_form.mako | 2 +- tailbone/templates/purchases/index.mako | 2 +- .../purchases/receiving_worksheet.mako | 93 +++++++++++++++++++ tailbone/templates/purchases/view.mako | 7 ++ tailbone/views/purchases/core.py | 24 +++++ 6 files changed, 128 insertions(+), 2 deletions(-) create mode 100644 tailbone/templates/purchases/receiving_worksheet.mako diff --git a/tailbone/helpers.py b/tailbone/helpers.py index 0c85c522..c6ffa51b 100644 --- a/tailbone/helpers.py +++ b/tailbone/helpers.py @@ -29,6 +29,8 @@ from __future__ import unicode_literals import datetime from decimal import Decimal +from rattail.util import pretty_quantity + from webhelpers.html import * from webhelpers.html.tags import * diff --git a/tailbone/templates/purchases/batches/receive_form.mako b/tailbone/templates/purchases/batches/receive_form.mako index 7bb6a60f..63d953a2 100644 --- a/tailbone/templates/purchases/batches/receive_form.mako +++ b/tailbone/templates/purchases/batches/receive_form.mako @@ -331,7 +331,7 @@ ${h.hidden('ordered_product')}
    - +
    ${h.hidden('product')}
    ${h.text('upc', autocomplete='off')}
    diff --git a/tailbone/templates/purchases/index.mako b/tailbone/templates/purchases/index.mako index d9d44c2b..e953e23d 100644 --- a/tailbone/templates/purchases/index.mako +++ b/tailbone/templates/purchases/index.mako @@ -4,7 +4,7 @@ <%def name="context_menu_items()"> ${parent.context_menu_items()} % if request.has_perm('purchases.batch.list'): -
  • ${h.link_to("Go to Purchase Batches", url('purchases.batch'))}
  • +
  • ${h.link_to("Go to Purchasing Batches", url('purchases.batch'))}
  • % endif diff --git a/tailbone/templates/purchases/receiving_worksheet.mako b/tailbone/templates/purchases/receiving_worksheet.mako new file mode 100644 index 00000000..31be83f9 --- /dev/null +++ b/tailbone/templates/purchases/receiving_worksheet.mako @@ -0,0 +1,93 @@ +## -*- coding: utf-8 -*- + + + Receiving Worksheet + + + + +

    Receiving Worksheet

    + +

    Notes:

    + +

    + Vendor:  (${purchase.vendor.id}) ${purchase.vendor} + + Phone:  ${purchase.vendor.phone} +

    +

    + Contact:  ${purchase.vendor.contact} + + Fax:  ${purchase.vendor.fax_number} +

    +

    + Store ID:  ${purchase.store.id} + + Buyer:  ${purchase.buyer} + + Order Date:  ${purchase.date_ordered} +

    +

    + Received by (name):  ____________________ + + Received on (date):  ____________________ +

    + + + + + + + + + + + + + + + + % for item in purchase.items: + + + + + + + + + + + % endfor + +
    UPCVend CodeBrandDescriptionCasesUnitsUnit CostTotal Cost
    ${item.upc.pretty()}${item.vendor_code or ''}${(item.brand_name or '')[:15]}${item.description or ''}${h.pretty_quantity(item.cases_ordered or 0)}${h.pretty_quantity(item.units_ordered or 0)}${'${:0,.2f}'.format(item.po_unit_cost) if item.po_unit_cost is not None else ''}${'${:0,.2f}'.format(item.po_total) if item.po_total is not None else ''}
    + + + diff --git a/tailbone/templates/purchases/view.mako b/tailbone/templates/purchases/view.mako index 9a985470..74871427 100644 --- a/tailbone/templates/purchases/view.mako +++ b/tailbone/templates/purchases/view.mako @@ -6,4 +6,11 @@ ${h.stylesheet_link(request.static_url('tailbone:static/css/purchases.css'))} +<%def name="context_menu_items()"> + ${parent.context_menu_items()} + % if instance.status < enum.PURCHASE_STATUS_RECEIVED and request.has_perm('purchases.receiving_worksheet'): +
  • ${h.link_to("Print Receiving Worksheet", url('purchases.receiving_worksheet', uuid=instance.uuid), target='_blank')}
  • + % endif + + ${parent.body()} diff --git a/tailbone/views/purchases/core.py b/tailbone/views/purchases/core.py index bf431724..ca942846 100644 --- a/tailbone/views/purchases/core.py +++ b/tailbone/views/purchases/core.py @@ -258,6 +258,30 @@ class PurchaseView(MasterView): fs.invoice_total, ]) + def receiving_worksheet(self): + purchase = self.get_instance() + return self.render_to_response('receiving_worksheet', { + 'purchase': purchase, + }) + + @classmethod + def defaults(cls, config): + route_prefix = cls.get_route_prefix() + url_prefix = cls.get_url_prefix() + permission_prefix = cls.get_permission_prefix() + model_key = cls.get_model_key() + model_title = cls.get_model_title() + + cls._defaults(config) + + # receiving worksheet + config.add_tailbone_permission(permission_prefix, '{}.receiving_worksheet'.format(permission_prefix), + "Print receiving worksheet for {}".format(model_title)) + config.add_route('{}.receiving_worksheet'.format(route_prefix), '{}/{{{}}}/receiving-worksheet'.format(url_prefix, model_key)) + config.add_view(cls, attr='receiving_worksheet', route_name='{}.receiving_worksheet'.format(route_prefix), + permission='{}.receiving_worksheet'.format(permission_prefix)) + + def includeme(config): PurchaseView.defaults(config) From ab09314ed34bc53aec64cd3998add65d0da187ad Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 14 Dec 2016 15:41:15 -0600 Subject: [PATCH 0043/3196] Add initial support for CSRF token protection --- tailbone/forms/__init__.py | 2 +- tailbone/forms/alchemy.py | 33 ++++++++++++++++++- tailbone/forms/core.py | 11 +++++++ tailbone/forms/simpleform.py | 8 ++++- tailbone/templates/forms/form.mako | 1 + tailbone/templates/login.mako | 1 + .../purchases/batches/receive_form.mako | 1 + tailbone/views/auth.py | 10 ++++-- 8 files changed, 61 insertions(+), 6 deletions(-) diff --git a/tailbone/forms/__init__.py b/tailbone/forms/__init__.py index e24af4c3..cbdc493e 100644 --- a/tailbone/forms/__init__.py +++ b/tailbone/forms/__init__.py @@ -28,7 +28,7 @@ from __future__ import unicode_literals, absolute_import from formencode import Schema -from .core import Form, Field, FieldSet, GenericFieldSet +from .core import Form, Field, FieldSet, GenericFieldSet, invalid_csrf_token from .simpleform import SimpleForm, FormRenderer from .alchemy import AlchemyForm from .fields import AssociationProxyField diff --git a/tailbone/forms/alchemy.py b/tailbone/forms/alchemy.py index 143193b0..874eecc4 100644 --- a/tailbone/forms/alchemy.py +++ b/tailbone/forms/alchemy.py @@ -30,8 +30,10 @@ from rattail.core import Object import formalchemy as fa from pyramid.renderers import render +from webhelpers.html import HTML, tags from tailbone.db import Session +from tailbone.forms import invalid_csrf_token class TemplateEngine(fa.templates.TemplateEngine): @@ -54,11 +56,12 @@ class AlchemyForm(Object): allow_successive_creates = False - def __init__(self, request, fieldset, session=None, **kwargs): + def __init__(self, request, fieldset, session=None, csrf_field='_csrf', **kwargs): super(AlchemyForm, self).__init__(**kwargs) self.request = request self.fieldset = fieldset self.session = session + self.csrf_field = csrf_field def _get_readonly(self): return self.fieldset.readonly @@ -70,6 +73,31 @@ class AlchemyForm(Object): def successive_create_label(self): return "%s and continue" % self.create_label + def csrf(self, name=None): + """ + NOTE: this method was copied from `pyramid_simpleform.FormRenderer` + + Returns the CSRF hidden input. Creates new CSRF token + if none has been assigned yet. + + The name of the hidden field is **_csrf** by default. + """ + name = name or self.csrf_field + + token = self.request.session.get_csrf_token() + if token is None: + token = self.request.session.new_csrf_token() + + return tags.hidden(name, value=token) + + def csrf_token(self, name=None): + """ + NOTE: this method was copied from `pyramid_simpleform.FormRenderer` + + Convenience function. Returns CSRF hidden tag inside hidden DIV. + """ + return HTML.tag("div", self.csrf(name), style="display:none;") + def render(self, **kwargs): kwargs['form'] = self if self.readonly: @@ -86,5 +114,8 @@ class AlchemyForm(Object): self.session.flush() def validate(self): + if invalid_csrf_token(self.request): + self.request.session.flash("Invalid CSRF token", 'error') + return False self.fieldset.rebind(data=self.request.params) return self.fieldset.validate() diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index 0d2728cd..19187bbb 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.py @@ -33,6 +33,17 @@ from formalchemy.helpers import content_tag from pyramid.renderers import render +def invalid_csrf_token(request): + """ + Returns boolean indicating whether the given request has an *invalid* CSRF token. + """ + if request.method == 'POST': + csrf_token = request.session.get_csrf_token() + if request.POST.get('_csrf') != csrf_token: + return True + return False + + class Form(object): """ Base class for all forms. diff --git a/tailbone/forms/simpleform.py b/tailbone/forms/simpleform.py index fa82b0b0..2fd481c3 100644 --- a/tailbone/forms/simpleform.py +++ b/tailbone/forms/simpleform.py @@ -33,7 +33,7 @@ from pyramid_simpleform import renderers from webhelpers.html import tags from webhelpers.html import HTML -from tailbone.forms import Form +from tailbone.forms import Form, invalid_csrf_token class SimpleForm(Form): @@ -52,6 +52,12 @@ class SimpleForm(Form): kwargs['form'] = FormRenderer(self) return super(SimpleForm, self).render(**kwargs) + def validate(self): + if invalid_csrf_token(self.request): + self.request.session.flash("Invalid CSRF token", 'error') + return False + return self._form.validate() + class FormRenderer(renderers.FormRenderer): """ diff --git a/tailbone/templates/forms/form.mako b/tailbone/templates/forms/form.mako index bf68a515..3baf812a 100644 --- a/tailbone/templates/forms/form.mako +++ b/tailbone/templates/forms/form.mako @@ -1,6 +1,7 @@ ## -*- coding: utf-8 -*-
    ${h.form(form.action_url, id=form.id or None, method='post', enctype='multipart/form-data')} + ${form.csrf_token()} ${form.render_fields()|n} diff --git a/tailbone/templates/login.mako b/tailbone/templates/login.mako index d9191026..0c2392b2 100644 --- a/tailbone/templates/login.mako +++ b/tailbone/templates/login.mako @@ -17,6 +17,7 @@
    ${form.begin(**{'data-ajax': 'false'})} ${form.hidden('referrer', value=referrer)} + ${form.csrf_token()} ${form.field_div('username', form.text('username'))} ${form.field_div('password', form.password('password'))} diff --git a/tailbone/templates/purchases/batches/receive_form.mako b/tailbone/templates/purchases/batches/receive_form.mako index 63d953a2..13312490 100644 --- a/tailbone/templates/purchases/batches/receive_form.mako +++ b/tailbone/templates/purchases/batches/receive_form.mako @@ -326,6 +326,7 @@
    ${form.begin(id='receiving-form')} + ${form.csrf_token()} ${h.hidden('mode')} ${h.hidden('expiration_date')} ${h.hidden('ordered_product')} diff --git a/tailbone/views/auth.py b/tailbone/views/auth.py index ed5fbf47..2b4cca73 100644 --- a/tailbone/views/auth.py +++ b/tailbone/views/auth.py @@ -101,8 +101,7 @@ class AuthenticationView(View): self.request.session.flash("{} is already logged in".format(self.request.user), 'error') return self.redirect(referrer) - form = Form(self.request, schema=UserLogin) - context = {'form': forms.FormRenderer(form), 'referrer': referrer, 'dialog': mobile} + form = forms.SimpleForm(self.request, UserLogin) if form.validate(): user = authenticate_user(Session(), form.data['username'], @@ -115,7 +114,12 @@ class AuthenticationView(View): return self.redirect(referrer, headers=headers) else: self.request.session.flash("Invalid username or password", 'error') - return context + + return { + 'form': forms.FormRenderer(form), + 'referrer': referrer, + 'dialog': mobile, + } def mobile_login(self): return self.login(mobile=True) From 4ed522ae4737c7315cf855d1c85f79a40134656d Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 14 Dec 2016 18:37:17 -0600 Subject: [PATCH 0044/3196] Add global CSRF protection --- tailbone/app.py | 3 +++ tailbone/forms/__init__.py | 2 +- tailbone/forms/alchemy.py | 4 ---- tailbone/forms/core.py | 11 ----------- tailbone/forms/simpleform.py | 5 +---- tailbone/helpers.py | 2 +- tailbone/templates/change_password.mako | 1 + tailbone/templates/feedback.mako | 1 + tailbone/templates/master/delete.mako | 1 + tailbone/templates/newbatch/view.mako | 1 + tailbone/templates/products/batch.mako | 2 ++ tailbone/templates/reports/inventory.mako | 1 + tailbone/templates/shifts/lib.mako | 1 + tailbone/templates/shifts/schedule_edit.mako | 3 +++ tailbone/util.py | 12 +++++++++++- 15 files changed, 28 insertions(+), 22 deletions(-) diff --git a/tailbone/app.py b/tailbone/app.py index 508d2b5d..8a665807 100644 --- a/tailbone/app.py +++ b/tailbone/app.py @@ -129,6 +129,9 @@ def make_pyramid_config(settings): config.set_authentication_policy(SessionAuthenticationPolicy()) config.set_authorization_policy(TailboneAuthorizationPolicy()) + # always require CSRF token protection + config.set_default_csrf_options(require_csrf=True, token='_csrf') + # Bring in some Pyramid goodies. config.include('pyramid_beaker') config.include('pyramid_mako') diff --git a/tailbone/forms/__init__.py b/tailbone/forms/__init__.py index cbdc493e..e24af4c3 100644 --- a/tailbone/forms/__init__.py +++ b/tailbone/forms/__init__.py @@ -28,7 +28,7 @@ from __future__ import unicode_literals, absolute_import from formencode import Schema -from .core import Form, Field, FieldSet, GenericFieldSet, invalid_csrf_token +from .core import Form, Field, FieldSet, GenericFieldSet from .simpleform import SimpleForm, FormRenderer from .alchemy import AlchemyForm from .fields import AssociationProxyField diff --git a/tailbone/forms/alchemy.py b/tailbone/forms/alchemy.py index 874eecc4..ee03d5e8 100644 --- a/tailbone/forms/alchemy.py +++ b/tailbone/forms/alchemy.py @@ -33,7 +33,6 @@ from pyramid.renderers import render from webhelpers.html import HTML, tags from tailbone.db import Session -from tailbone.forms import invalid_csrf_token class TemplateEngine(fa.templates.TemplateEngine): @@ -114,8 +113,5 @@ class AlchemyForm(Object): self.session.flush() def validate(self): - if invalid_csrf_token(self.request): - self.request.session.flash("Invalid CSRF token", 'error') - return False self.fieldset.rebind(data=self.request.params) return self.fieldset.validate() diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index 19187bbb..0d2728cd 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.py @@ -33,17 +33,6 @@ from formalchemy.helpers import content_tag from pyramid.renderers import render -def invalid_csrf_token(request): - """ - Returns boolean indicating whether the given request has an *invalid* CSRF token. - """ - if request.method == 'POST': - csrf_token = request.session.get_csrf_token() - if request.POST.get('_csrf') != csrf_token: - return True - return False - - class Form(object): """ Base class for all forms. diff --git a/tailbone/forms/simpleform.py b/tailbone/forms/simpleform.py index 2fd481c3..a07ebec8 100644 --- a/tailbone/forms/simpleform.py +++ b/tailbone/forms/simpleform.py @@ -33,7 +33,7 @@ from pyramid_simpleform import renderers from webhelpers.html import tags from webhelpers.html import HTML -from tailbone.forms import Form, invalid_csrf_token +from tailbone.forms import Form class SimpleForm(Form): @@ -53,9 +53,6 @@ class SimpleForm(Form): return super(SimpleForm, self).render(**kwargs) def validate(self): - if invalid_csrf_token(self.request): - self.request.session.flash("Invalid CSRF token", 'error') - return False return self._form.validate() diff --git a/tailbone/helpers.py b/tailbone/helpers.py index c6ffa51b..254a3885 100644 --- a/tailbone/helpers.py +++ b/tailbone/helpers.py @@ -34,7 +34,7 @@ from rattail.util import pretty_quantity from webhelpers.html import * from webhelpers.html.tags import * -from tailbone.util import pretty_datetime +from tailbone.util import csrf_token, pretty_datetime def pretty_date(date): diff --git a/tailbone/templates/change_password.mako b/tailbone/templates/change_password.mako index 6b1ab948..d9c67235 100644 --- a/tailbone/templates/change_password.mako +++ b/tailbone/templates/change_password.mako @@ -5,6 +5,7 @@
    ${h.form(url('change_password'))} + ${form.csrf_token()} ${form.referrer_field()} ${form.field_div('current_password', form.password('current_password'))} ${form.field_div('new_password', form.password('new_password'))} diff --git a/tailbone/templates/feedback.mako b/tailbone/templates/feedback.mako index ee2b4a35..d9964f37 100644 --- a/tailbone/templates/feedback.mako +++ b/tailbone/templates/feedback.mako @@ -23,6 +23,7 @@
    ${form.begin()} + ${form.csrf_token()} ${form.hidden('user', value=request.user.uuid if request.user else None)}

    diff --git a/tailbone/templates/master/delete.mako b/tailbone/templates/master/delete.mako index 12244adf..85892e35 100644 --- a/tailbone/templates/master/delete.mako +++ b/tailbone/templates/master/delete.mako @@ -36,6 +36,7 @@
    ${h.form(request.current_route_url())} + ${h.csrf_token(request)}

    Whoops, nevermind... diff --git a/tailbone/templates/newbatch/view.mako b/tailbone/templates/newbatch/view.mako index 898580dd..20d12dd8 100644 --- a/tailbone/templates/newbatch/view.mako +++ b/tailbone/templates/newbatch/view.mako @@ -71,6 +71,7 @@ ${rows_grid|n} diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index 00de0891..024ec973 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -22,9 +22,6 @@ ################################################################################ """ Base views for maintaining "new-style" batches. - -.. note:: - This is all still somewhat experimental. """ from __future__ import unicode_literals, absolute_import @@ -50,7 +47,7 @@ from pyramid.response import FileResponse from pyramid_simpleform import Form from webhelpers2.html import HTML, tags -from tailbone import forms, newgrids as grids +from tailbone import forms, grids from tailbone.db import Session from tailbone.views import MasterView from tailbone.forms.renderers.batch import FileFieldRenderer diff --git a/tailbone/views/batch/core2.py b/tailbone/views/batch/core2.py index 2cc900cc..306b48f6 100644 --- a/tailbone/views/batch/core2.py +++ b/tailbone/views/batch/core2.py @@ -30,7 +30,7 @@ import six from rattail.db import model -from tailbone import grids3 as grids +from tailbone import grids from tailbone.views import MasterView2 from tailbone.views.batch import BatchMasterView, FileBatchMasterView from tailbone.views.batch.core import MobileBatchStatusFilter diff --git a/tailbone/views/bouncer.py b/tailbone/views/bouncer.py index c811918a..542f71b6 100644 --- a/tailbone/views/bouncer.py +++ b/tailbone/views/bouncer.py @@ -37,7 +37,7 @@ import formalchemy from pyramid.response import FileResponse from webhelpers2.html import literal -from tailbone import grids3 as grids +from tailbone import grids from tailbone.db import Session from tailbone.views import MasterView2 as MasterView from tailbone.forms.renderers.bouncer import BounceMessageFieldRenderer diff --git a/tailbone/views/departments.py b/tailbone/views/departments.py index e48ed8b9..d990cd46 100644 --- a/tailbone/views/departments.py +++ b/tailbone/views/departments.py @@ -30,7 +30,7 @@ import six from rattail.db import model -from tailbone import grids3 as grids +from tailbone import grids from tailbone.views import MasterView2 as MasterView, AutocompleteView diff --git a/tailbone/views/employees.py b/tailbone/views/employees.py index ae8847b2..1657a257 100644 --- a/tailbone/views/employees.py +++ b/tailbone/views/employees.py @@ -32,7 +32,7 @@ from rattail.db import model import formalchemy as fa -from tailbone import forms, grids3 as grids +from tailbone import forms, grids from tailbone.db import Session from tailbone.views import MasterView2 as MasterView, AutocompleteView diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 3995a239..bb32a38d 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -41,9 +41,8 @@ from pyramid import httpexceptions from pyramid.renderers import get_renderer, render_to_response, render from webhelpers2.html import HTML, tags -from tailbone import forms, newgrids as grids +from tailbone import forms, grids from tailbone.views import View -from tailbone.newgrids import filters, AlchemyGrid, GridAction, MobileGrid class MasterView(View): @@ -161,24 +160,6 @@ class MasterView(View): grid.load_settings() return grid - @classmethod - def get_mobile_grid_factory(cls): - """ - Must return a callable to be used when creating new mobile grid - instances. Instead of overriding this, you can set - :attr:`mobile_grid_factory`. Default factory is :class:`MobileGrid`. - """ - return getattr(cls, 'mobile_grid_factory', MobileGrid) - - @classmethod - def get_mobile_row_grid_factory(cls): - """ - Must return a callable to be used when creating new mobile grid - instances. Instead of overriding this, you can set - :attr:`mobile_grid_factory`. Default factory is :class:`MobileGrid`. - """ - return getattr(cls, 'mobile_row_grid_factory', MobileGrid) - @classmethod def get_mobile_grid_key(cls): """ @@ -422,14 +403,6 @@ class MasterView(View): grid.load_settings() return grid - @classmethod - def get_version_grid_factory(cls): - """ - Returns the grid factory or class which is to be used when creating new - version grid instances. - """ - return getattr(cls, 'version_grid_factory', AlchemyGrid) - @classmethod def get_version_grid_key(cls): """ @@ -1254,23 +1227,6 @@ class MasterView(View): # Grid Stuff ############################## - @classmethod - def get_grid_factory(cls): - """ - Returns the grid factory or class which is to be used when creating new - grid instances. - """ - return getattr(cls, 'grid_factory', AlchemyGrid) - - @classmethod - def get_row_grid_factory(cls): - """ - Must return a callable to be used when creating new row grid instances. - Instead of overriding this, you can set :attr:`row_grid_factory`. - Default factory is :class:`AlchemyGrid`. - """ - return getattr(cls, 'row_grid_factory', AlchemyGrid) - @classmethod def get_grid_key(cls): """ @@ -1385,7 +1341,7 @@ class MasterView(View): if url is None: route = '{}.{}'.format(self.get_route_prefix(), key) url = lambda r, i: self.request.route_url(route, **self.get_action_route_kwargs(r)) - return GridAction(key, url=url, **kwargs) + return grids.GridAction(key, url=url, **kwargs) def get_action_route_kwargs(self, row): """ @@ -1424,20 +1380,6 @@ class MasterView(View): def _preconfigure_grid(self, grid): pass - def configure_grid(self, grid): - """ - Configure the grid, customizing as necessary. Subclasses are - encouraged to override this method. - - As a bare minimum, the logic for this method must at some point invoke - the ``configure()`` method on the grid instance. The default - implementation does exactly (and only) this, passing no arguments. - This requirement is a result of using FormAlchemy under the hood, and - it is in fact a call to :meth:`formalchemy:formalchemy.tables.Grid.configure()`. - """ - if hasattr(grid, 'configure'): - grid.configure() - def get_data(self, session=None): """ Generate the base data set for the grid. This typically will be a diff --git a/tailbone/views/master2.py b/tailbone/views/master2.py index 510efdb8..0b4a83ab 100644 --- a/tailbone/views/master2.py +++ b/tailbone/views/master2.py @@ -26,7 +26,9 @@ Master View from __future__ import unicode_literals, absolute_import -from tailbone import grids3 as grids +import sqlalchemy_continuum as continuum + +from tailbone import grids from tailbone.views import MasterView @@ -64,6 +66,14 @@ class MasterView2(MasterView): """ return getattr(cls, 'row_grid_factory', grids.Grid) + @classmethod + def get_version_grid_factory(cls): + """ + Returns the grid factory or class which is to be used when creating new + version grid instances. + """ + return getattr(cls, 'version_grid_factory', grids.Grid) + @classmethod def get_mobile_grid_factory(cls): """ @@ -82,6 +92,18 @@ class MasterView2(MasterView): """ return getattr(cls, 'mobile_row_grid_factory', grids.MobileGrid) + def get_effective_data(self, session=None, **kwargs): + """ + Convenience method which returns the "effective" data for the master + grid, filtered and sorted to match what would show on the UI, but not + paged etc. + """ + if session is None: + session = self.Session() + kwargs.setdefault('pageable', False) + grid = self.make_grid(session=session, **kwargs) + return grid.make_visible_data() + def make_grid(self, factory=None, key=None, data=None, columns=None, **kwargs): """ Creates a new grid instance @@ -102,18 +124,6 @@ class MasterView2(MasterView): grid.load_settings() return grid - def get_effective_data(self, session=None, **kwargs): - """ - Convenience method which returns the "effective" data for the master - grid, filtered and sorted to match what would show on the UI, but not - paged etc. - """ - if session is None: - session = self.Session() - kwargs.setdefault('pageable', False) - grid = self.make_grid(session=session, **kwargs) - return grid.make_visible_data() - def make_row_grid(self, factory=None, key=None, data=None, columns=None, **kwargs): """ Make and return a new (configured) rows grid instance. @@ -139,6 +149,30 @@ class MasterView2(MasterView): grid.load_settings() return grid + def make_version_grid(self, factory=None, key=None, data=None, columns=None, **kwargs): + """ + Creates a new version grid instance + """ + instance = kwargs.pop('instance', None) + if not instance: + instance = self.get_instance() + + if factory is None: + factory = self.get_version_grid_factory() + if key is None: + key = self.get_version_grid_key() + if data is None: + data = self.get_version_data(instance) + if columns is None: + columns = self.get_version_grid_columns() + + kwargs.setdefault('request', self.request) + kwargs = self.make_version_grid_kwargs(**kwargs) + grid = factory(key, data, columns, **kwargs) + self.configure_version_grid(grid) + grid.load_settings() + return grid + def make_mobile_grid(self, factory=None, key=None, data=None, columns=None, **kwargs): """ Creates a new mobile grid instance @@ -195,6 +229,17 @@ class MasterView2(MasterView): # TODO raise NotImplementedError + def get_version_grid_columns(self): + if hasattr(self, 'version_grid_columns'): + return self.version_grid_columns + # TODO + return [ + 'issued_at', + 'user', + 'remote_addr', + 'comment', + ] + def get_mobile_grid_columns(self): if hasattr(self, 'mobile_grid_columns'): return self.mobile_grid_columns @@ -269,6 +314,26 @@ class MasterView2(MasterView): defaults.update(kwargs) return defaults + def make_version_grid_kwargs(self, **kwargs): + """ + Return a dictionary of kwargs to be passed to the factory when + constructing a new version grid. + """ + defaults = { + 'model_class': continuum.transaction_class(self.get_model_class()), + 'width': 'full', + 'pageable': True, + } + if 'main_actions' not in kwargs: + route = '{}.version'.format(self.get_route_prefix()) + instance = kwargs.get('instance') or self.get_instance() + url = lambda txn, i: self.request.route_url(route, uuid=instance.uuid, txnid=txn.id) + defaults['main_actions'] = [ + self.make_action('view', icon='zoomin', url=url), + ] + defaults.update(kwargs) + return defaults + def make_mobile_grid_kwargs(self, **kwargs): """ Must return a dictionary of kwargs to be passed to the factory when @@ -342,6 +407,9 @@ class MasterView2(MasterView): def configure_row_grid(self, grid): pass + def configure_version_grid(self, grid): + pass + def configure_mobile_grid(self, grid): pass diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 9d6df890..8f21c7d2 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -46,7 +46,7 @@ from pyramid import httpexceptions from pyramid.renderers import render_to_response from webhelpers2.html import tags, HTML -from tailbone import forms, grids3 as grids +from tailbone import forms, grids from tailbone.db import Session from tailbone.views import MasterView2 as MasterView, AutocompleteView from tailbone.progress import SessionProgress diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index a4c51a57..1d7e2336 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -39,7 +39,7 @@ import formalchemy as fa import formencode as fe from webhelpers2.html import tags -from tailbone import forms, grids3 as grids +from tailbone import forms, grids from tailbone.views.purchasing import PurchasingBatchView diff --git a/tailbone/views/roles.py b/tailbone/views/roles.py index e7778250..7d514e1b 100644 --- a/tailbone/views/roles.py +++ b/tailbone/views/roles.py @@ -34,7 +34,7 @@ from rattail.db.auth import has_permission, administrator_role, guest_role, auth import formalchemy as fa from formalchemy.fields import IntegerFieldRenderer -from tailbone import forms, grids3 as grids +from tailbone import forms, grids from tailbone.db import Session from tailbone.views.principal import PrincipalMasterView From 292546e44b2cc31b30e9ef70aaa4c10c7e973b10 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 14 Jul 2017 21:15:22 -0500 Subject: [PATCH 0345/3196] Final grid refactor for all templates and CSS/JS (newgrid -> grid) --- tailbone/static/css/grids.css | 281 ++++++++++++------ tailbone/static/css/grids3.css | 75 ----- tailbone/static/css/newgrids.css | 275 ----------------- tailbone/static/css/theme-better.css | 2 +- tailbone/static/js/jquery.ui.tailbone.js | 99 ++---- tailbone/static/js/tailbone.batch.js | 2 +- tailbone/templates/base.mako | 2 - tailbone/templates/grids/complete.mako | 4 +- tailbone/templates/grids/grid.mako | 2 +- tailbone/templates/master/index.mako | 91 ------ tailbone/templates/master/versions.mako | 2 +- tailbone/templates/master/view.mako | 4 +- tailbone/templates/master2/index.mako | 78 ++++- tailbone/templates/messages/index.mako | 8 +- tailbone/templates/newbatch/edit.mako | 2 +- tailbone/templates/newbatch/view.mako | 2 +- tailbone/templates/ordering/order_form.mako | 2 +- .../templates/principal/find_by_perm.mako | 4 +- tailbone/templates/products/index.mako | 4 +- tailbone/templates/products/view.mako | 4 +- .../templates/purchases/batches/index.mako | 4 +- .../purchases/batches/mobile_create.mako | 49 --- tailbone/templates/purchases/index.mako | 4 +- tailbone/templates/shifts/schedule_print.mako | 4 +- .../templates/themes/better/master/index.mako | 6 - .../themes/better/master2/index.mako | 6 + 26 files changed, 329 insertions(+), 687 deletions(-) delete mode 100644 tailbone/static/css/grids3.css delete mode 100644 tailbone/static/css/newgrids.css delete mode 100644 tailbone/templates/master/index.mako delete mode 100644 tailbone/templates/purchases/batches/mobile_create.mako delete mode 100644 tailbone/templates/themes/better/master/index.mako create mode 100644 tailbone/templates/themes/better/master2/index.mako diff --git a/tailbone/static/css/grids.css b/tailbone/static/css/grids.css index 094effb0..fde782b5 100644 --- a/tailbone/static/css/grids.css +++ b/tailbone/static/css/grids.css @@ -1,179 +1,278 @@ +/******************************************************************************** + * grids.css + * + * Style tweaks for the new grids. + ********************************************************************************/ + + /****************************** - * Grid Header + * header table ******************************/ -table.grid-header { - padding-bottom: 5px; +.grid-wrapper .grid-header td.filters { + vertical-align: bottom; width: 100%; } +.grid-wrapper .grid-header td.menu { + padding: 0.5em; + vertical-align: top; + white-space: nowrap; +} -/****************************** - * Form (Filters etc.) - ******************************/ - -table.grid-header td.form { +.grid-wrapper .grid-header td.tools { + margin: 0; + padding: 0; vertical-align: bottom; + white-space: nowrap; +} + +.grid-wrapper .grid-header td.tools p { + line-height: 2em; + margin: 0; + padding: 0 0.5em 0 0; +} + +.grid-wrapper .grid-header td.tools form { + display: inline-block; } /****************************** - * Context Menu + * filters ******************************/ -table.grid-header td.context-menu { +.grid-wrapper .newfilters { + margin: 0; +} + +.grid-wrapper .newfilters fieldset { + margin: 0; + padding: 1px 5px 5px 5px; + width: 80%; +} + +.grid-wrapper .newfilters .filter { + margin-bottom: 2px; +} + +.grid-wrapper .newfilters .filter:last-child { + margin-bottom: 0; +} + +.grid-wrapper .newfilters .filter .toggle { + margin: 0; + text-align: left; + width: 15em; +} + +.grid-wrapper .newfilters .ui-button-text { + line-height: 1.4em; +} + +.grid-wrapper .ui-button-text-icon-primary .ui-button-text { + padding: 0.2em 1em 0.2em 2.1em; +} + +.grid-wrapper .ui-selectmenu-button .ui-selectmenu-text { + padding: 0.2em 2.1em 0.2em 1em; +} + +.grid-wrapper .newfilters .filter label { + font-weight: bold; + padding: 0.4em 0.2em; + position: relative; + top: -14px; +} + +.grid-wrapper .newfilters .filter .value { + display: inline-block; vertical-align: top; } -table.grid-header td.context-menu ul { - list-style-type: none; - margin: 0px; - text-align: right; +.grid-wrapper .newfilters .filter .value input { + height: 19px; } -table.grid-header td.context-menu ul li { - line-height: 2em; +.grid-wrapper .newfilters .filter .inputs { + display: inline-block; + /* TODO: Would be nice not to hard-code a height here... */ + height: 26px; + vertical-align: top; } -/****************************** - * Tools - ******************************/ - -table.grid-header td.tools { - padding-bottom: 10px; - text-align: right; - vertical-align: bottom; +.grid-wrapper .newfilters .buttons { + margin: 0.5em 0 0 0; + padding: 0; } -table.grid-header td.tools div.buttons button { - margin-left: 5px; +.grid-wrapper .newfilters #add-filter-button { + margin-right: 5px; + vertical-align: middle; } /****************************** - * Grid + * table ******************************/ -div.grid { +.grid { clear: both; + margin-top: 1em; } -div.grid table { - background-color: White; - border-top: 1px solid black; - border-left: 1px solid black; +.grid.no-border { + margin-top: 0; +} + +.grid table { + background-color: white; + border: 1px solid black; border-collapse: collapse; - font-size: 9pt; + font-size: 10pt; line-height: normal; white-space: nowrap; } -div.grid.full table { +.grid.full table { width: 100%; } -div.grid.no-border table { +.grid.half table { + width: 50%; +} + +.grid.no-border table { border-left: none; border-top: none; } -div.grid table th, -div.grid table td { + +/****************************** + * thead + ******************************/ + +.grid tr.header td { border-right: 1px solid black; border-bottom: 1px solid black; + font-weight: bold; padding: 2px 3px; + text-align: center; } -div.grid table th.sortable a { +/* .grid table thead th:last-child { */ +/* border-right: none; */ +/* } */ + +.grid tr.header a { display: block; padding-right: 18px; } -div.grid table th.sorted { +.grid tr.header .asc, +.grid tr.header .dsc { background-position: right center; background-repeat: no-repeat; } -div.grid table th.sorted.asc { +.grid tr.header .asc { background-image: url(../img/sort_arrow_up.png); } -div.grid table th.sorted.desc { +.grid tr.header .dsc { background-image: url(../img/sort_arrow_down.png); } -div.grid table tbody td { - text-align: left; + +/****************************** + * tbody + ******************************/ + +.grid tbody td { + padding: 5px 6px; } -div.grid table tbody td.center { - text-align: center; +.grid.selectable tbody td { + cursor: default; } -div.grid table tbody td.right { - float: none; - text-align: right; -} - -div.grid table tr.odd { +.grid tr.odd { background-color: #e0e0e0; } -div.grid table tbody tr.hovering { +.grid tr:not(.header).hovering { background-color: #bbbbbb; } -div.grid table tbody tr td.checkbox { +.grid tr:not(.header).warning.odd { + background-color: #fcc; +} + +.grid tr:not(.header).warning.even { + background-color: #ebb; +} + +.grid tr:not(.header).warning.hovering { + background-color: #daa; +} + +.grid tr:not(.header).notice.odd { + background-color: #fe8; +} + +.grid tr:not(.header).notice.even { + background-color: #fd6; +} + +.grid tr:not(.header).notice.hovering { + background-color: #ec7; +} + +.grid tr:not(.header) td.checkbox { text-align: center; } -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; - cursor: pointer; - min-width: 18px; - text-align: center; - width: 18px; + +/****************************** + * main actions + ******************************/ + +.grid .actions { + width: 1px; } -div.grid table tbody tr td.view { - background-image: url(../img/view.png); +.grid .actions a { + margin: 0 5px 0 0; + position: relative; + top: -2px; } -div.grid table tbody tr td.edit { - background-image: url(../img/edit.png); +.grid .actions a:last-child { + margin: 0; } -div.grid table tbody tr td.save { - background-image: url(../img/save.png); +.grid .actions .ui-icon { + display: inline-block; + position: relative; + top: 3px; } -div.grid table tbody tr td.delete { - background-image: url(../img/delete.png); + +/****************************** + * more actions + ******************************/ + +.grid .actions div.more { + background-color: white; + border: 1px solid black; + display: none; + padding: 3px 10px 3px 5px; + position: absolute; + z-index: 1; } -div.pager { - margin-bottom: 20px; - margin-top: 5px; -} - -div.pager p { - font-size: 10pt; - margin: 0px; -} - -div.pager p.showing { - float: left; -} - -div.pager #grid-page-count { - font-size: 8pt; -} - -div.pager p.page-links { - float: right; +.grid .actions .more a { + display: block; + padding: 2px 0; } diff --git a/tailbone/static/css/grids3.css b/tailbone/static/css/grids3.css deleted file mode 100644 index 830c035d..00000000 --- a/tailbone/static/css/grids3.css +++ /dev/null @@ -1,75 +0,0 @@ - -/******************************************************************************** - * grids3.css - * - * Style tweaks for the new grids. - ********************************************************************************/ - - -/****************************** - * thead - ******************************/ - -.grid3 tr.header td { - border-right: 1px solid black; - border-bottom: 1px solid black; - font-weight: bold; - padding: 2px 3px; - text-align: center; -} - -.grid3 tr.header a { - display: block; - padding-right: 18px; -} - -.grid3 tr.header .asc, -.grid3 tr.header .dsc { - background-position: right center; - background-repeat: no-repeat; -} - -.grid3 tr.header .asc { - background-image: url(../img/sort_arrow_up.png); -} - -.grid3 tr.header .dsc { - background-image: url(../img/sort_arrow_down.png); -} - - -/****************************** - * tbody - ******************************/ - -.grid3 tr.odd { - background-color: #e0e0e0; -} - -.newgrid.grid3 tr:not(.header).hovering { - background-color: #bbbbbb; -} - -.newgrid.grid3 tr:not(.header).warning.odd { - background-color: #fcc; -} - -.newgrid.grid3 tr:not(.header).warning.even { - background-color: #ebb; -} - -.newgrid.grid3 tr:not(.header).warning.hovering { - background-color: #daa; -} - -.newgrid.grid3 tr:not(.header).notice.odd { - background-color: #fe8; -} - -.newgrid.grid3 tr:not(.header).notice.even { - background-color: #fd6; -} - -.newgrid.grid3 tr:not(.header).notice.hovering { - background-color: #ec7; -} diff --git a/tailbone/static/css/newgrids.css b/tailbone/static/css/newgrids.css deleted file mode 100644 index e92f2400..00000000 --- a/tailbone/static/css/newgrids.css +++ /dev/null @@ -1,275 +0,0 @@ - -/******************************************************************************** - * newgrids.css - * - * Style tweaks for the new grids. - ********************************************************************************/ - - -/****************************** - * header table - ******************************/ - -.newgrid-wrapper .grid-header td.filters { - vertical-align: bottom; - width: 100%; -} - -.newgrid-wrapper .grid-header td.menu { - padding: 0.5em; - vertical-align: top; - white-space: nowrap; -} - -.newgrid-wrapper .grid-header td.tools { - margin: 0; - padding: 0; - vertical-align: bottom; - white-space: nowrap; -} - -.newgrid-wrapper .grid-header td.tools p { - line-height: 2em; - margin: 0; - padding: 0 0.5em 0 0; -} - -.newgrid-wrapper .grid-header td.tools form { - display: inline-block; -} - - -/****************************** - * filters - ******************************/ - -.newgrid-wrapper .newfilters { - margin: 0; -} - -.newgrid-wrapper .newfilters fieldset { - margin: 0; - padding: 1px 5px 5px 5px; - width: 80%; -} - -.newgrid-wrapper .newfilters .filter { - margin-bottom: 2px; -} - -.newgrid-wrapper .newfilters .filter:last-child { - margin-bottom: 0; -} - -.newgrid-wrapper .newfilters .filter .toggle { - margin: 0; - text-align: left; - width: 15em; -} - -.newgrid-wrapper .newfilters .ui-button-text { - line-height: 1.4em; -} - -.newgrid-wrapper .ui-button-text-icon-primary .ui-button-text { - padding: 0.2em 1em 0.2em 2.1em; -} - -.newgrid-wrapper .ui-selectmenu-button .ui-selectmenu-text { - padding: 0.2em 2.1em 0.2em 1em; -} - -.newgrid-wrapper .newfilters .filter label { - font-weight: bold; - padding: 0.4em 0.2em; - position: relative; - top: -14px; -} - -.newgrid-wrapper .newfilters .filter .value { - display: inline-block; - vertical-align: top; -} - -.newgrid-wrapper .newfilters .filter .value input { - height: 19px; -} - -.newgrid-wrapper .newfilters .filter .inputs { - display: inline-block; - /* TODO: Would be nice not to hard-code a height here... */ - height: 26px; - vertical-align: top; -} - -.newgrid-wrapper .newfilters .buttons { - margin: 0.5em 0 0 0; - padding: 0; -} - -.newgrid-wrapper .newfilters #add-filter-button { - margin-right: 5px; - vertical-align: middle; -} - - -/****************************** - * table - ******************************/ - -.newgrid { - clear: both; - margin-top: 1em; -} - -.newgrid.no-border { - margin-top: 0; -} - -.newgrid table { - background-color: white; - border: 1px solid black; - border-collapse: collapse; - font-size: 10pt; - line-height: normal; - white-space: nowrap; -} - -.newgrid.full table { - width: 100%; -} - -.newgrid.half table { - width: 50%; -} - -.newgrid.no-border table { - border-left: none; - border-top: none; -} - - -/****************************** - * thead - ******************************/ - -.newgrid table thead th { - border-right: 1px solid black; - border-bottom: 1px solid black; - padding: 2px 3px; -} - -.newgrid table thead th:last-child { - border-right: none; -} - -.newgrid table thead th.sortable a { - display: block; - padding-right: 18px; -} - -.newgrid table thead th.sorted { - background-position: right center; - background-repeat: no-repeat; -} - -.newgrid table thead th.sorted.asc { - background-image: url(../img/sort_arrow_up.png); -} - -.newgrid table thead th.sorted.desc { - background-image: url(../img/sort_arrow_down.png); -} - - -/****************************** - * tbody - ******************************/ - -.newgrid tbody td { - padding: 5px 6px; -} - -.newgrid.selectable tbody td { - cursor: default; -} - -/* .newgrid tbody tr:nth-child(odd) { */ -/* background-color: #e0e0e0; */ -/* } */ - -/* .newgrid tbody tr.hovering { */ -/* background-color: #bbbbbb; */ -/* } */ - -/* .newgrid tbody tr.notice { */ -/* background-color: #fd6; */ -/* } */ - -/* .newgrid tbody tr.notice:nth-child(odd) { */ -/* background-color: #fe8; */ -/* } */ - -/* .newgrid tbody tr.notice.hovering { */ -/* background-color: #ec7; */ -/* } */ - -/* .newgrid tbody tr.warning { */ -/* background-color: #fcc; */ -/* } */ - -/* .newgrid tbody tr.warning:nth-child(odd) { */ -/* background-color: #ebb; */ -/* } */ - -/* .newgrid tbody tr.warning.hovering { */ -/* background-color: #daa; */ -/* } */ - -.newgrid tbody td.checkbox { - text-align: center; -} - - -/****************************** - * main actions - ******************************/ - -.newgrid .actions { - width: 1px; -} - -.newgrid .actions a { - margin: 0 5px 0 0; - position: relative; - top: -2px; -} - -.newgrid .actions a:last-child { - margin: 0; -} - -.newgrid .actions .ui-icon { - display: inline-block; - position: relative; - top: 3px; -} - - -/****************************** - * more actions - ******************************/ - -.newgrid .actions div.more { - background-color: white; - border: 1px solid black; - display: none; - padding: 3px 10px 3px 5px; - position: absolute; - z-index: 1; -} - -.newgrid .actions .more a { - display: block; - padding: 2px 0; -} diff --git a/tailbone/static/css/theme-better.css b/tailbone/static/css/theme-better.css index ac4f6f2d..d06584df 100644 --- a/tailbone/static/css/theme-better.css +++ b/tailbone/static/css/theme-better.css @@ -25,7 +25,7 @@ a { padding-left: 5em; } -.newgrid-wrapper .grid-header #context-menu { +.grid-wrapper .grid-header #context-menu { float: none; margin: 0; } diff --git a/tailbone/static/js/jquery.ui.tailbone.js b/tailbone/static/js/jquery.ui.tailbone.js index 65e8f7a7..a5a4dadc 100644 --- a/tailbone/static/js/jquery.ui.tailbone.js +++ b/tailbone/static/js/jquery.ui.tailbone.js @@ -23,7 +23,7 @@ this.default_filters = this.filters.find('#default-filters'); this.clear_filters = this.filters.find('#clear-filters'); this.save_defaults = this.filters.find('#save-defaults'); - this.grid = this.element.find('.newgrid'); + this.grid = this.element.find('.grid'); // Enhance filters etc. this.filters.find('.filter').gridfilter(); @@ -117,31 +117,17 @@ }); // Refresh data when user clicks a sortable column header. - if (this.grid.hasClass('grid3')) { - this.element.on('click', 'tr.header a', function() { - var td = $(this).parent(); - var data = { - sortkey: $(this).data('sortkey'), - sortdir: (td.hasClass('asc')) ? 'desc' : 'asc', - page: 1, - partial: true - }; - that.refresh(data); - return false; - }); - } else { - this.element.on('click', 'thead th.sortable a', function() { - var th = $(this).parent(); - var data = { - sortkey: th.data('sortkey'), - sortdir: (th.hasClass('sorted') && th.hasClass('asc')) ? 'desc' : 'asc', - page: 1, - partial: true - }; - that.refresh(data); - return false; - }); - } + this.element.on('click', 'tr.header a', function() { + var td = $(this).parent(); + var data = { + sortkey: $(this).data('sortkey'), + sortdir: (td.hasClass('asc')) ? 'desc' : 'asc', + page: 1, + partial: true + }; + that.refresh(data); + return false; + }); // Refresh data when user chooses a new page size setting. this.element.on('change', '.pager #pagesize', function() { @@ -167,52 +153,29 @@ }); // do some extra stuff for grids with checkboxes - if (this.grid.hasClass('grid3')) { - // (un-)check all rows when clicking check-all box in header - if (this.grid.find('tr.header td.checkbox input').length) { - this.element.on('click', 'tr.header td.checkbox input', function() { - var checked = $(this).prop('checked'); - that.grid.find('tr:not(.header) td.checkbox input').prop('checked', checked); - }); - - } - - // Select current row when clicked, unless clicking checkbox - // (since that already does select the row) or a link (since - // that does something completely different). - this.element.on('click', '.newgrid tr:not(.header) td.checkbox input', function(event) { - event.stopPropagation(); - }); - this.element.on('click', '.newgrid tr:not(.header) a', function(event) { - event.stopPropagation(); - }); - this.element.on('click', '.newgrid tr:not(.header)', function() { - $(this).find('td.checkbox input').click(); - }); - - } else if (this.grid.hasClass('selectable')) { // pre-v3 newgrid.selectable - - // (Un-)Check all rows when clicking check-all box in header. - this.element.on('click', 'thead th.checkbox input', function() { + // (un-)check all rows when clicking check-all box in header + if (this.grid.find('tr.header td.checkbox input').length) { + this.element.on('click', 'tr.header td.checkbox input', function() { var checked = $(this).prop('checked'); - that.grid.find('tbody td.checkbox input').prop('checked', checked); + that.grid.find('tr:not(.header) td.checkbox input').prop('checked', checked); }); - // Select current row when clicked, unless clicking checkbox - // (since that already does select the row) or a link (since - // that does something completely different). - this.element.on('click', 'tbody td.checkbox input', function(event) { - event.stopPropagation(); - }); - this.element.on('click', 'tbody a', function(event) { - event.stopPropagation(); - }); - this.element.on('click', 'tbody tr', function() { - $(this).find('td.checkbox input').click(); - }); } + // Select current row when clicked, unless clicking checkbox + // (since that already does select the row) or a link (since + // that does something completely different). + this.element.on('click', '.grid tr:not(.header) td.checkbox input', function(event) { + event.stopPropagation(); + }); + this.element.on('click', '.grid tr:not(.header) a', function(event) { + event.stopPropagation(); + }); + this.element.on('click', '.grid tr:not(.header)', function() { + $(this).find('td.checkbox input').click(); + }); + // Show 'more' actions when user hovers over 'more' link. this.element.on('mouseenter', '.actions a.more', function() { that.grid.find('.actions div.more').hide(); @@ -238,7 +201,7 @@ this.element.mask("Refreshing data..."); $.get(this.grid.data('url'), settings, function(data) { that.grid.replaceWith(data); - that.grid = that.element.find('.newgrid'); + that.grid = that.element.find('.grid'); that.element.unmask(); }); } @@ -264,7 +227,7 @@ this.checkbox = this.element.find('input[name$="-active"]'); this.label = this.element.find('label'); this.inputs = this.element.find('.inputs'); - this.add_filter = this.element.parents('.newgrid-wrapper').find('#add-filter'); + this.add_filter = this.element.parents('.grid-wrapper').find('#add-filter'); // Hide the checkbox and label, and add button for toggling active status. this.checkbox.addClass('ui-helper-hidden-accessible'); diff --git a/tailbone/static/js/tailbone.batch.js b/tailbone/static/js/tailbone.batch.js index d45fccfc..c3fd30bc 100644 --- a/tailbone/static/js/tailbone.batch.js +++ b/tailbone/static/js/tailbone.batch.js @@ -10,7 +10,7 @@ $(function() { - $('.newgrid-wrapper').gridwrapper(); + $('.grid-wrapper').gridwrapper(); $('#execute-batch').click(function() { if (has_execution_options) { diff --git a/tailbone/templates/base.mako b/tailbone/templates/base.mako index 6bc625d4..1e411d02 100644 --- a/tailbone/templates/base.mako +++ b/tailbone/templates/base.mako @@ -149,8 +149,6 @@ ${h.stylesheet_link(request.static_url('tailbone:static/css/grids.css'))} ${h.stylesheet_link(request.static_url('tailbone:static/css/filters.css'))} ${h.stylesheet_link(request.static_url('tailbone:static/css/forms.css'))} - ${h.stylesheet_link(request.static_url('tailbone:static/css/newgrids.css'))} - ${h.stylesheet_link(request.static_url('tailbone:static/css/grids3.css'))} <%def name="jquery_smoothness_theme()"> diff --git a/tailbone/templates/grids/complete.mako b/tailbone/templates/grids/complete.mako index 86e9ee90..169264c4 100644 --- a/tailbone/templates/grids/complete.mako +++ b/tailbone/templates/grids/complete.mako @@ -1,5 +1,5 @@ ## -*- coding: utf-8 -*- -
    +
    @@ -35,4 +35,4 @@ ${grid.render_grid()|n} - + diff --git a/tailbone/templates/grids/grid.mako b/tailbone/templates/grids/grid.mako index c317332b..283ba289 100644 --- a/tailbone/templates/grids/grid.mako +++ b/tailbone/templates/grids/grid.mako @@ -1,5 +1,5 @@ ## -*- coding: utf-8; -*- -
    +
    ${grid.make_webhelpers_grid()}
    diff --git a/tailbone/templates/master/index.mako b/tailbone/templates/master/index.mako deleted file mode 100644 index fa39a3bd..00000000 --- a/tailbone/templates/master/index.mako +++ /dev/null @@ -1,91 +0,0 @@ -## -*- coding: utf-8 -*- -## ############################################################################## -## -## Default master 'index' template. Features a prominent data table and -## exposes a way to filter and sort the data, etc. Some index pages also -## include a "tools" section, just above the grid on the right. -## -## ############################################################################## -<%inherit file="/base.mako" /> - -<%def name="title()">${grid.model_title_plural} - -<%def name="head_tags()"> - ${parent.head_tags()} - ${h.javascript_link(request.static_url('tailbone:static/js/jquery.ui.tailbone.js'))} - - - -<%def name="context_menu_items()"> - % if master.creatable and request.has_perm('{}.create'.format(grid.permission_prefix)): -
  • ${h.link_to("Create a new {}".format(grid.model_title), url('{}.create'.format(grid.route_prefix)))}
  • - % endif - - -<%def name="grid_tools()"> - % if master.mergeable and request.has_perm('{}.merge'.format(permission_prefix)): - ${h.form(url('{}.merge'.format(route_prefix)), name='merge-things')} - ${h.csrf_token(request)} - ${h.hidden('uuids')} - - ${h.end_form()} - % endif - % if master.bulk_deletable and request.has_perm('{}.bulk_delete'.format(permission_prefix)): - ${h.form(url('{}.bulk_delete'.format(route_prefix)), name='bulk-delete')} - ${h.csrf_token(request)} - - ${h.end_form()} - % endif - - -${grid.render_complete(tools=capture(self.grid_tools).strip(), context_menu=capture(self.context_menu_items).strip())|n} diff --git a/tailbone/templates/master/versions.mako b/tailbone/templates/master/versions.mako index 983b7e86..c3784c24 100644 --- a/tailbone/templates/master/versions.mako +++ b/tailbone/templates/master/versions.mako @@ -13,7 +13,7 @@ ${h.javascript_link(request.static_url('tailbone:static/js/jquery.ui.tailbone.js'))} diff --git a/tailbone/templates/master/view.mako b/tailbone/templates/master/view.mako index fcddff05..7a398676 100644 --- a/tailbone/templates/master/view.mako +++ b/tailbone/templates/master/view.mako @@ -9,11 +9,11 @@ ${h.javascript_link(request.static_url('tailbone:static/js/jquery.ui.tailbone.js'))} diff --git a/tailbone/templates/master2/index.mako b/tailbone/templates/master2/index.mako index 89da2974..1de0dca4 100644 --- a/tailbone/templates/master2/index.mako +++ b/tailbone/templates/master2/index.mako @@ -6,19 +6,91 @@ ## include a "tools" section, just above the grid on the right. ## ## ############################################################################## -<%inherit file="/master/index.mako" /> +<%inherit file="/base.mako" /> <%def name="title()">${model_title_plural} +<%def name="extra_javascript()"> + ${parent.extra_javascript()} + ${h.javascript_link(request.static_url('tailbone:static/js/jquery.ui.tailbone.js'))} + + + <%def name="context_menu_items()"> % if master.creatable and request.has_perm('{}.create'.format(permission_prefix)):
  • ${h.link_to("Create a new {}".format(model_title), url('{}.create'.format(route_prefix)))}
  • % endif +<%def name="grid_tools()"> + % if master.mergeable and request.has_perm('{}.merge'.format(permission_prefix)): + ${h.form(url('{}.merge'.format(route_prefix)), name='merge-things')} + ${h.csrf_token(request)} + ${h.hidden('uuids')} + + ${h.end_form()} + % endif + % if master.bulk_deletable and request.has_perm('{}.bulk_delete'.format(permission_prefix)): + ${h.form(url('{}.bulk_delete'.format(route_prefix)), name='bulk-delete')} + ${h.csrf_token(request)} + + ${h.end_form()} + % endif + + ## ${grid.render_complete(tools=capture(self.grid_tools).strip(), context_menu=capture(self.context_menu_items).strip())|n} -
    +
    @@ -51,5 +123,5 @@ ${grid.render_grid()|n} - + diff --git a/tailbone/templates/messages/index.mako b/tailbone/templates/messages/index.mako index 7192f7c3..6f055132 100644 --- a/tailbone/templates/messages/index.mako +++ b/tailbone/templates/messages/index.mako @@ -8,7 +8,7 @@ var destination = null; function update_move_button() { - var count = $('.newgrid tr:not(.header) td.checkbox input:checked').length; + var count = $('.grid tr:not(.header) td.checkbox input:checked').length; $('form[name="move-selected"] button') .button('option', 'label', "Move " + count + " selected to " + destination) .button('option', 'disabled', count < 1); @@ -18,17 +18,17 @@ update_move_button(); - $('.newgrid-wrapper').on('change', 'tr.header td.checkbox input', function() { + $('.grid-wrapper').on('change', 'tr.header td.checkbox input', function() { update_move_button(); }); - $('.newgrid-wrapper').on('click', 'tr:not(.header) td.checkbox input', function() { + $('.grid-wrapper').on('click', 'tr:not(.header) td.checkbox input', function() { update_move_button(); }); $('form[name="move-selected"]').submit(function() { var uuids = []; - $('.newgrid tr:not(.header) td.checkbox input:checked').each(function() { + $('.grid tr:not(.header) td.checkbox input:checked').each(function() { uuids.push($(this).parents('tr:first').data('uuid')); }); if (! uuids.length) { diff --git a/tailbone/templates/newbatch/edit.mako b/tailbone/templates/newbatch/edit.mako index ce1cbf42..b4aa2b65 100644 --- a/tailbone/templates/newbatch/edit.mako +++ b/tailbone/templates/newbatch/edit.mako @@ -21,7 +21,7 @@ - - -<%def name="context_menu_items()"> -
  • ${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 - - -
    - -
      - ${self.context_menu_items()} -
    - - ${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 index d6922b7c..d96d74ac 100644 --- a/tailbone/templates/batch/edit.mako +++ b/tailbone/templates/batch/edit.mako @@ -1,3 +1,77 @@ -## -*- coding: utf-8 -*- -<%inherit file="/batch/crud.mako" /> -${parent.body()} +## -*- coding: utf-8; -*- +<%inherit file="/master/edit.mako" /> + +<%def name="extra_javascript()"> + ${parent.extra_javascript()} + ${h.javascript_link(request.static_url('tailbone:static/js/jquery.ui.tailbone.js'))} + ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.batch.js'))} + + + +<%def name="extra_styles()"> + ${parent.extra_styles()} + + + +<%def name="buttons()"> +
    + % if master.refreshable: + ${h.submit('save-refresh', "Save & Refresh Data")} + % endif + % if not batch.executed and request.has_perm('{}.execute'.format(permission_prefix)): + + % endif +
    + + +<%def name="grid_tools()"> + % if not batch.executed: +

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

    + % endif + + +
      + ${self.context_menu_items()} +
    + +
    +## TODO: clean this up or fix etc..? +## % if master.edit_with_rows: +## ${form.render(buttons=capture(buttons))|n} +## % else: + ${form.render()|n} +## % endif +
    + +% if master.edit_with_rows: + ${rows_grid.render_complete(allow_save_defaults=False, tools=capture(self.grid_tools))|n} +% endif + + diff --git a/tailbone/templates/batch/handheld/index.mako b/tailbone/templates/batch/handheld/index.mako index af4e0eae..fd4f87c7 100644 --- a/tailbone/templates/batch/handheld/index.mako +++ b/tailbone/templates/batch/handheld/index.mako @@ -1,5 +1,5 @@ ## -*- coding: utf-8; -*- -<%inherit file="/newbatch/index.mako" /> +<%inherit file="/batch/index.mako" /> <%def name="extra_javascript()"> ${parent.extra_javascript()} diff --git a/tailbone/templates/newbatch/index.mako b/tailbone/templates/batch/index.mako similarity index 100% rename from tailbone/templates/newbatch/index.mako rename to tailbone/templates/batch/index.mako diff --git a/tailbone/templates/batch/row.view.mako b/tailbone/templates/batch/row.view.mako deleted file mode 100644 index 7fc33199..00000000 --- a/tailbone/templates/batch/row.view.mako +++ /dev/null @@ -1,10 +0,0 @@ -## -*- 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/batch/rows.mako b/tailbone/templates/batch/rows.mako deleted file mode 100644 index 616fc694..00000000 --- a/tailbone/templates/batch/rows.mako +++ /dev/null @@ -1,22 +0,0 @@ -## -*- coding: utf-8 -*- -
    - -
    - - - - - - -
    - ${search.render()} -
    - ## 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 -
    - - ${grid} - -
    diff --git a/tailbone/templates/batch/view.mako b/tailbone/templates/batch/view.mako index 74cb10d3..e4f7f46b 100644 --- a/tailbone/templates/batch/view.mako +++ b/tailbone/templates/batch/view.mako @@ -1,34 +1,88 @@ -## -*- coding: utf-8 -*- -<%inherit file="/batch/crud.mako" /> +## -*- coding: utf-8; -*- +<%inherit file="/master/view.mako" /> -<%def name="head_tags()"> - ${parent.head_tags()} +<%def name="extra_javascript()"> + ${parent.extra_javascript()} + ${h.javascript_link(request.static_url('tailbone:static/js/jquery.ui.tailbone.js'))} + ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.batch.js'))} +<%def name="extra_styles()"> + ${parent.extra_styles()} + + + <%def name="context_menu_items()"> ${parent.context_menu_items()} - % if request.has_perm('{0}.csv'.format(permission_prefix)): -
  • ${h.link_to("Download this {0} as CSV".format(batch_display), url('{0}.csv'.format(route_prefix), uuid=batch.uuid))}
  • + % if master.rows_downloadable and request.has_perm('{}.csv'.format(permission_prefix)): +
  • ${h.link_to("Download row data as CSV", url('{}.csv'.format(route_prefix), uuid=batch.uuid))}
  • + % endif + % if master.cloneable and request.has_perm('{}.clone'.format(permission_prefix)): +
  • ${h.link_to("Clone as new batch", url('{}.clone'.format(route_prefix), uuid=batch.uuid))}
  • % endif <%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)): - - % endif + ${self.leading_buttons()} + ${refresh_button()} + ${execute_button()}
    -${parent.body()} +<%def name="leading_buttons()"> + +<%def name="refresh_button()"> + % if master.viewing and master.batch_refreshable(batch) and request.has_perm('{}.refresh'.format(permission_prefix)): + + % endif + + +<%def name="execute_button()"> + % if not batch.executed and request.has_perm('{}.execute'.format(permission_prefix)): + + % endif + + +
      + ${self.context_menu_items()} +
    + +
    + ${form.render(form_id='batch-form', buttons=capture(buttons))|n} +
    + +${rows_grid|n} + +% if not batch.executed: + +% endif diff --git a/tailbone/templates/mobile/batch/inventory/view.mako b/tailbone/templates/mobile/batch/inventory/view.mako index 782fb421..2c8f785c 100644 --- a/tailbone/templates/mobile/batch/inventory/view.mako +++ b/tailbone/templates/mobile/batch/inventory/view.mako @@ -1,5 +1,5 @@ ## -*- coding: utf-8; -*- -<%inherit file="/mobile/newbatch/view.mako" /> +<%inherit file="/mobile/batch/view.mako" /> <%def name="title()">${h.link_to("Inventory", url('mobile.batch.inventory'))} » ${batch.id_str} diff --git a/tailbone/templates/mobile/batch/inventory/view_row.mako b/tailbone/templates/mobile/batch/inventory/view_row.mako index 50870075..5ef6165a 100644 --- a/tailbone/templates/mobile/batch/inventory/view_row.mako +++ b/tailbone/templates/mobile/batch/inventory/view_row.mako @@ -1,5 +1,5 @@ ## -*- coding: utf-8; -*- -<%inherit file="/mobile/newbatch/view_row.mako" /> +<%inherit file="/mobile/batch/view_row.mako" /> <%namespace file="/mobile/keypad.mako" import="keypad" /> ## TODO: this is broken for actual page (header) title diff --git a/tailbone/templates/mobile/newbatch/view.mako b/tailbone/templates/mobile/batch/view.mako similarity index 100% rename from tailbone/templates/mobile/newbatch/view.mako rename to tailbone/templates/mobile/batch/view.mako diff --git a/tailbone/templates/mobile/newbatch/view_row.mako b/tailbone/templates/mobile/batch/view_row.mako similarity index 100% rename from tailbone/templates/mobile/newbatch/view_row.mako rename to tailbone/templates/mobile/batch/view_row.mako diff --git a/tailbone/templates/newbatch/create.mako b/tailbone/templates/newbatch/create.mako deleted file mode 100644 index 5d2a2e53..00000000 --- a/tailbone/templates/newbatch/create.mako +++ /dev/null @@ -1,3 +0,0 @@ -## -*- coding: utf-8 -*- -<%inherit file="/master/create.mako" /> -${parent.body()} diff --git a/tailbone/templates/newbatch/edit.mako b/tailbone/templates/newbatch/edit.mako deleted file mode 100644 index b4aa2b65..00000000 --- a/tailbone/templates/newbatch/edit.mako +++ /dev/null @@ -1,73 +0,0 @@ -## -*- coding: utf-8 -*- -<%inherit file="/master/edit.mako" /> - -<%def name="head_tags()"> - ${parent.head_tags()} - ${h.javascript_link(request.static_url('tailbone:static/js/jquery.ui.tailbone.js'))} - ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.batch.js'))} - - - - -<%def name="buttons()"> -
    - % if master.refreshable: - ${h.submit('save-refresh', "Save & Refresh Data")} - % endif - % if not batch.executed and request.has_perm('{}.execute'.format(permission_prefix)): - - % endif -
    - - -<%def name="grid_tools()"> - % if not batch.executed: -

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

    - % endif - - -
      - ${self.context_menu_items()} -
    - -
    -## TODO: clean this up or fix etc..? -## % if master.edit_with_rows: -## ${form.render(buttons=capture(buttons))|n} -## % else: - ${form.render()|n} -## % endif -
    - -% if master.edit_with_rows: - ${rows_grid.render_complete(allow_save_defaults=False, tools=capture(self.grid_tools))|n} -% endif - - diff --git a/tailbone/templates/newbatch/view.mako b/tailbone/templates/newbatch/view.mako deleted file mode 100644 index e4f7f46b..00000000 --- a/tailbone/templates/newbatch/view.mako +++ /dev/null @@ -1,88 +0,0 @@ -## -*- coding: utf-8; -*- -<%inherit file="/master/view.mako" /> - -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - ${h.javascript_link(request.static_url('tailbone:static/js/jquery.ui.tailbone.js'))} - ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.batch.js'))} - - - -<%def name="extra_styles()"> - ${parent.extra_styles()} - - - -<%def name="context_menu_items()"> - ${parent.context_menu_items()} - % if master.rows_downloadable and request.has_perm('{}.csv'.format(permission_prefix)): -
  • ${h.link_to("Download row data as CSV", url('{}.csv'.format(route_prefix), uuid=batch.uuid))}
  • - % endif - % if master.cloneable and request.has_perm('{}.clone'.format(permission_prefix)): -
  • ${h.link_to("Clone as new batch", url('{}.clone'.format(route_prefix), uuid=batch.uuid))}
  • - % endif - - -<%def name="buttons()"> -
    - ${self.leading_buttons()} - ${refresh_button()} - ${execute_button()} -
    - - -<%def name="leading_buttons()"> - -<%def name="refresh_button()"> - % if master.viewing and master.batch_refreshable(batch) and request.has_perm('{}.refresh'.format(permission_prefix)): - - % endif - - -<%def name="execute_button()"> - % if not batch.executed and request.has_perm('{}.execute'.format(permission_prefix)): - - % endif - - -
      - ${self.context_menu_items()} -
    - -
    - ${form.render(form_id='batch-form', buttons=capture(buttons))|n} -
    - -${rows_grid|n} - -% if not batch.executed: - -% endif diff --git a/tailbone/templates/ordering/create.mako b/tailbone/templates/ordering/create.mako index 76c09a7a..4a8c5d7d 100644 --- a/tailbone/templates/ordering/create.mako +++ b/tailbone/templates/ordering/create.mako @@ -1,5 +1,5 @@ ## -*- coding: utf-8; -*- -<%inherit file="/newbatch/create.mako" /> +<%inherit file="/batch/create.mako" /> <%def name="extra_javascript()"> ${parent.extra_javascript()} diff --git a/tailbone/templates/ordering/view.mako b/tailbone/templates/ordering/view.mako index 584d4a41..04c7526d 100644 --- a/tailbone/templates/ordering/view.mako +++ b/tailbone/templates/ordering/view.mako @@ -1,5 +1,5 @@ ## -*- coding: utf-8; -*- -<%inherit file="/newbatch/view.mako" /> +<%inherit file="/batch/view.mako" /> <%def name="extra_javascript()"> ${parent.extra_javascript()} diff --git a/tailbone/templates/purchases/batches/create.mako b/tailbone/templates/purchases/batches/create.mako index b98ce7d5..3a165f01 100644 --- a/tailbone/templates/purchases/batches/create.mako +++ b/tailbone/templates/purchases/batches/create.mako @@ -1,5 +1,5 @@ ## -*- coding: utf-8; -*- -<%inherit file="/newbatch/create.mako" /> +<%inherit file="/batch/create.mako" /> <%def name="extra_javascript()"> ${parent.extra_javascript()} diff --git a/tailbone/templates/purchases/batches/index.mako b/tailbone/templates/purchases/batches/index.mako index 52617583..4290f93b 100644 --- a/tailbone/templates/purchases/batches/index.mako +++ b/tailbone/templates/purchases/batches/index.mako @@ -1,5 +1,5 @@ ## -*- coding: utf-8; -*- -<%inherit file="/master/index.mako" /> +<%inherit file="/batch/index.mako" /> <%def name="context_menu_items()"> ${parent.context_menu_items()} diff --git a/tailbone/templates/purchases/batches/view.mako b/tailbone/templates/purchases/batches/view.mako index 25be1af0..f7870fb1 100644 --- a/tailbone/templates/purchases/batches/view.mako +++ b/tailbone/templates/purchases/batches/view.mako @@ -1,5 +1,5 @@ -## -*- coding: utf-8 -*- -<%inherit file="/newbatch/view.mako" /> +## -*- coding: utf-8; -*- +<%inherit file="/batch/view.mako" /> <%def name="extra_javascript()"> ${parent.extra_javascript()} diff --git a/tailbone/templates/vendors/catalogs/create.mako b/tailbone/templates/vendors/catalogs/create.mako index 8565a547..ccb7d21a 100644 --- a/tailbone/templates/vendors/catalogs/create.mako +++ b/tailbone/templates/vendors/catalogs/create.mako @@ -1,8 +1,8 @@ -## -*- coding: utf-8 -*- -<%inherit file="/newbatch/create.mako" /> +## -*- coding: utf-8; -*- +<%inherit file="/batch/create.mako" /> -<%def name="head_tags()"> - ${parent.head_tags()} +<%def name="extra_javascript()"> + ${parent.extra_javascript()} + <% _focus_rendered = True %> + % endif + % endif + + ## % else: + ## ${field.render()|n} + ## % endif + + % endif + +% endfor + +% if buttons: + ${buttons|n} +% elif not readonly: +
    + ## ${h.submit('create', form.create_label if form.creating else form.update_label)} + ${h.submit('save', "Save")} +## % if form.creating and form.allow_successive_creates: +## ${h.submit('create_and_continue', form.successive_create_label)} +## % endif + ${h.link_to("Cancel", form.cancel_url, class_='button')} +
    +% endif + +% if not readonly: +${h.end_form()} +% endif diff --git a/tailbone/templates/forms2/form.mako b/tailbone/templates/forms2/form.mako new file mode 100644 index 00000000..2ee96e3c --- /dev/null +++ b/tailbone/templates/forms2/form.mako @@ -0,0 +1,5 @@ +## -*- coding: utf-8; -*- + +
    + ${form.render_deform()|n} +
    diff --git a/tailbone/templates/forms2/form_readonly.mako b/tailbone/templates/forms2/form_readonly.mako new file mode 100644 index 00000000..ed61a44e --- /dev/null +++ b/tailbone/templates/forms2/form_readonly.mako @@ -0,0 +1,8 @@ +## -*- coding: utf-8; -*- + +
    + ${form.render_deform(readonly=True)|n} +## % if buttons: +## ${buttons|n} +## % endif +
    diff --git a/tailbone/templates/master/view.mako b/tailbone/templates/master/view.mako index 7a398676..32f7884a 100644 --- a/tailbone/templates/master/view.mako +++ b/tailbone/templates/master/view.mako @@ -1,10 +1,10 @@ -## -*- coding: utf-8 -*- +## -*- coding: utf-8; -*- <%inherit file="/base.mako" /> -<%def name="title()">${model_title}: ${instance_title} +<%def name="title()">${model_title_plural} » ${instance_title} -<%def name="head_tags()"> - ${parent.head_tags()} +<%def name="extra_javascript()"> + ${parent.extra_javascript()} % if master.has_rows: ${h.javascript_link(request.static_url('tailbone:static/js/jquery.ui.tailbone.js'))} + % endif + + +<%def name="extra_styles()"> + ${parent.extra_styles()} + % if master.has_rows: + + +<%def name="worksheet_grid()"> + + +${self.worksheet_grid()} diff --git a/tailbone/templates/master/index.mako b/tailbone/templates/master/index.mako index c7d1802f..e417499c 100644 --- a/tailbone/templates/master/index.mako +++ b/tailbone/templates/master/index.mako @@ -8,7 +8,7 @@ ## ############################################################################## <%inherit file="/base.mako" /> -<%def name="title()">${model_title_plural} +<%def name="title()">${index_title} <%def name="extra_javascript()"> ${parent.extra_javascript()} diff --git a/tailbone/templates/master/view_row.mako b/tailbone/templates/master/view_row.mako index b03ae6e0..532f1e11 100644 --- a/tailbone/templates/master/view_row.mako +++ b/tailbone/templates/master/view_row.mako @@ -3,6 +3,10 @@ <%def name="title()">${model_title} +<%def name="content_title()"> +

    Row ${instance.sequence}

    + + <%def name="context_menu_items()">
  • ${h.link_to("Back to {}".format(parent_model_title), index_url)}
  • % if master.rows_editable and instance_editable and request.has_perm('{}.edit'.format(permission_prefix)): diff --git a/tailbone/templates/themes/better/base.mako b/tailbone/templates/themes/better/base.mako index 24bf20e8..1c7d55e4 100644 --- a/tailbone/templates/themes/better/base.mako +++ b/tailbone/templates/themes/better/base.mako @@ -37,7 +37,7 @@ % if master: » % if master.listing: - ${model_title_plural} + ${index_title} % else: ${h.link_to(index_title, index_url, class_='global')} % if instance_url is not Undefined: diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index 97162e67..dfccc047 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -72,6 +72,7 @@ class BatchMasterView(MasterView): supports_mobile = True mobile_filterable = True mobile_rows_viewable = True + has_worksheet = False def __init__(self, request): super(BatchMasterView, self).__init__(request) @@ -422,6 +423,9 @@ class BatchMasterView(MasterView): def editable_instance(self, batch): return not bool(batch.executed) + def after_edit_row(self, row): + self.handler.refresh_row(row) + def executable(self, batch=None): return self.handler.executable(batch) @@ -954,6 +958,17 @@ class BatchMasterView(MasterView): config.add_view(cls, attr='prefill', route_name='{}.prefill'.format(route_prefix), permission='{}.create'.format(permission_prefix)) + # worksheet + if cls.has_worksheet: + config.add_tailbone_permission(permission_prefix, '{}.worksheet'.format(permission_prefix), + "Edit {} data as worksheet".format(model_title)) + config.add_route('{}.worksheet'.format(route_prefix), '{}/{{{}}}/worksheet'.format(url_prefix, model_key)) + config.add_view(cls, attr='worksheet', route_name='{}.worksheet'.format(route_prefix), + permission='{}.worksheet'.format(permission_prefix)) + config.add_route('{}.worksheet_update'.format(route_prefix), '{}/{{{}}}/worksheet/update'.format(url_prefix, model_key)) + config.add_view(cls, attr='worksheet_update', route_name='{}.worksheet_update'.format(route_prefix), + renderer='json', permission='{}.worksheet'.format(permission_prefix)) + # refresh batch data config.add_route('{}.refresh'.format(route_prefix), '{}/{{uuid}}/refresh'.format(url_prefix)) config.add_view(cls, attr='refresh', route_name='{}.refresh'.format(route_prefix), diff --git a/tailbone/views/batch/core2.py b/tailbone/views/batch/core2.py index ab7e5464..d9a36f3f 100644 --- a/tailbone/views/batch/core2.py +++ b/tailbone/views/batch/core2.py @@ -103,6 +103,7 @@ class BatchMasterView2(MasterView2, BatchMasterView): g.set_label('sequence', "Seq.") g.set_label('status_code', "Status") + g.set_label('item_id', "Item ID") def render_row_status(self, row, column): code = row.status_code diff --git a/tailbone/views/inventory.py b/tailbone/views/inventory.py index 2352733f..cff5f134 100644 --- a/tailbone/views/inventory.py +++ b/tailbone/views/inventory.py @@ -59,10 +59,11 @@ class InventoryBatchView(BatchMasterView): 'id', 'created', 'created_by', + 'mode', 'rowcount', + 'total_cost', 'executed', 'executed_by', - 'mode', ] model_row_class = model.InventoryBatchRow @@ -71,18 +72,21 @@ class InventoryBatchView(BatchMasterView): row_grid_columns = [ 'sequence', 'upc', + 'item_id', 'brand_name', 'description', 'size', 'cases', 'units', 'unit_cost', + 'total_cost', 'status_code', ] def configure_grid(self, g): super(InventoryBatchView, self).configure_grid(g) g.set_enum('mode', self.enum.INVENTORY_MODE) + g.set_type('total_cost', 'currency') g.set_label('mode', "Count Mode") def render_mobile_listitem(self, batch, i): @@ -96,6 +100,7 @@ class InventoryBatchView(BatchMasterView): super(InventoryBatchView, self)._preconfigure_fieldset(fs) fs.mode.set(renderer=forms.renderers.EnumFieldRenderer(self.enum.INVENTORY_MODE), label="Count Mode") + fs.total_cost.set(readonly=True, renderer=forms.renderers.CurrencyFieldRenderer) fs.append(fa.Field('handheld_batches', renderer=forms.renderers.HandheldBatchesFieldRenderer, readonly=True, value=lambda b: b._handhelds)) @@ -114,6 +119,22 @@ class InventoryBatchView(BatchMasterView): fs.executed_by, ]) + def save_edit_row_form(self, form): + row = form.fieldset.model + batch = row.batch + if batch.total_cost is not None and row.total_cost is not None: + batch.total_cost -= row.total_cost + return super(InventoryBatchView, self).save_edit_row_form(form) + + def delete_row(self): + row = self.Session.query(model.InventoryBatchRow).get(self.request.matchdict['uuid']) + if not row: + raise self.notfound() + batch = row.batch + if batch.total_cost is not None and row.total_cost is not None: + batch.total_cost -= row.total_cost + return super(InventoryBatchView, self).delete_row() + def configure_mobile_fieldset(self, fs): fs.configure(include=[ fs.mode, @@ -220,9 +241,15 @@ class InventoryBatchView(BatchMasterView): def configure_row_grid(self, g): super(InventoryBatchView, self).configure_row_grid(g) - g.set_renderer('cases', 'quantity') - g.set_renderer('units', 'quantity') - g.set_renderer('unit_cost', 'currency') + g.set_type('cases', 'quantity') + g.set_type('units', 'quantity') + g.set_type('unit_cost', 'currency') + g.set_type('total_cost', 'currency') + + # TODO: i guess row grids don't support this properly yet? + # g.set_link('upc') + # g.set_link('item_id') + # g.set_link('description') g.set_label('upc', "UPC") g.set_label('brand_name', "Brand") @@ -242,10 +269,14 @@ class InventoryBatchView(BatchMasterView): super(InventoryBatchView, self)._preconfigure_row_fieldset(fs) fs.upc.set(readonly=True, label="UPC", renderer=forms.renderers.GPCFieldRenderer, attrs={'link': lambda r: self.request.route_url('products.view', uuid=r.product_uuid)}) + fs.item_id.set(readonly=True) fs.brand_name.set(readonly=True) fs.description.set(readonly=True) fs.size.set(readonly=True) - fs.unit_cost.set(renderer=forms.renderers.CurrencyFieldRenderer) + fs.cases.set(renderer=forms.renderers.QuantityFieldRenderer) + fs.units.set(renderer=forms.renderers.QuantityFieldRenderer) + fs.unit_cost.set(readonly=True, renderer=forms.renderers.CurrencyFieldRenderer) + fs.total_cost.set(readonly=True, renderer=forms.renderers.CurrencyFieldRenderer) def configure_row_fieldset(self, fs): fs.configure( @@ -259,18 +290,22 @@ class InventoryBatchView(BatchMasterView): fs.cases, fs.units, fs.unit_cost, + fs.total_cost, ]) @classmethod def defaults(cls, config): + cls._inventory_defaults(config) + cls._batch_defaults(config) + cls._defaults(config) + + @classmethod + def _inventory_defaults(cls, config): model_key = cls.get_model_key() route_prefix = cls.get_route_prefix() url_prefix = cls.get_url_prefix() row_permission_prefix = cls.get_row_permission_prefix() - cls._batch_defaults(config) - cls._defaults(config) - # mobile - make new row from UPC config.add_route('mobile.{}.row_from_upc'.format(route_prefix), '/mobile{}/{{{}}}/row-from-upc'.format(url_prefix, model_key)) config.add_view(cls, attr='mobile_row_from_upc', route_name='mobile.{}.row_from_upc'.format(route_prefix), diff --git a/tailbone/views/master.py b/tailbone/views/master.py index b9f82090..d3e30b9e 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -1340,17 +1340,15 @@ class MasterView(View): parent = self.get_parent(row) return self.render_to_response('view_row', { 'instance': row, - 'instance_title': self.get_row_instance_title(row), + 'batch': row.batch, + 'instance_title': self.get_instance_title(row.batch), + 'instance_url': self.get_action_url('view', parent), 'instance_editable': self.row_editable(row), 'instance_deletable': self.row_deletable(row), 'rows_creatable': self.rows_creatable and self.rows_creatable_for(parent), 'model_title': self.get_row_model_title(), 'model_title_plural': self.get_row_model_title_plural(), 'parent_model_title': self.get_model_title(), - 'index_url': self.get_action_url('view', parent), - 'index_title': '{} {}'.format( - self.get_model_title(), - self.get_instance_title(parent)), 'action_url': self.get_row_action_url, 'form': form}) diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 5f5b36a7..b1863c9a 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -291,6 +291,10 @@ class ProductsView(MasterView): fs.last_sold.set(readonly=True) fs.append(fa.Field('current_price_ends', type=fa.types.DateTime, readonly=True, value=lambda p: p.current_price.ends if p.current_price else None)) + fs.append(fa.Field('inventory_on_hand', readonly=True, label="On Hand", + value=lambda p: p.inventory.on_hand if p.inventory else None)) + fs.append(fa.Field('inventory_on_order', readonly=True, label="On Order", + value=lambda p: p.inventory.on_order if p.inventory else None)) def configure_fieldset(self, fs): fs.configure( From e4b2cd638a1fee64d6131cf14c1432fb600c4ecd Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 19 Jul 2017 01:44:42 -0500 Subject: [PATCH 0362/3196] Stop allowing pre-0.7 SQLAlchemy some recent version broke tests, let's just skip this check --- tailbone/db.py | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/tailbone/db.py b/tailbone/db.py index 44388e50..30f1da93 100644 --- a/tailbone/db.py +++ b/tailbone/db.py @@ -145,12 +145,6 @@ def register(session, initial_state=datamanager.STATUS_ACTIVE, 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) - register(TempmonSession) - register(TrainwreckSession) -else: - Session.configure(extension=ZopeTransactionExtension()) - TempmonSession.configure(extension=ZopeTransactionExtension()) - TrainwreckSession.configure(extension=ZopeTransactionExtension()) +register(Session) +register(TempmonSession) +register(TrainwreckSession) From d3bc1abb57ae35b483de3793c8c86f7d98fbb45b Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 19 Jul 2017 03:08:32 -0500 Subject: [PATCH 0363/3196] Add some more support for product inventory and status --- tailbone/views/products.py | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/tailbone/views/products.py b/tailbone/views/products.py index b1863c9a..ff6019a8 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -37,7 +37,7 @@ from rattail.db import model, api, auth, Session as RattailSession from rattail.gpc import GPC from rattail.threads import Thread from rattail.exceptions import LabelPrintingError -from rattail.util import load_object +from rattail.util import load_object, pretty_quantity from rattail.batch import get_batch_handler import wtforms @@ -90,6 +90,10 @@ class ProductsView(MasterView): 'current_price', ] + labels = { + 'status_code': "Status", + } + # 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). @@ -122,6 +126,8 @@ class ProductsView(MasterView): # .options(orm.joinedload(model.Product.current_price))\ # .options(orm.joinedload(model.Product.vendor)) + query = query.outerjoin(model.ProductInventory) + return query def configure_grid(self, g): @@ -175,6 +181,7 @@ class ProductsView(MasterView): g.sorters['department'] = g.make_sorter(model.Department.name) g.sorters['subdepartment'] = g.make_sorter(model.Subdepartment.name) g.sorters['vendor'] = g.make_sorter(model.Vendor.name) + g.set_sorter('on_hand', model.ProductInventory.on_hand) g.filters['upc'].default_active = True g.filters['upc'].default_verb = 'equal' @@ -211,8 +218,10 @@ class ProductsView(MasterView): g.set_renderer('regular_price', self.render_price) g.set_renderer('current_price', self.render_price) g.set_renderer('cost', self.render_cost) + g.set_renderer('on_hand', self.render_on_hand) g.set_link('upc') + g.set_link('item_id') g.set_link('description') g.set_label('upc', "UPC") @@ -245,7 +254,13 @@ class ProductsView(MasterView): cost = product.cost if not cost: return "" - return "'${:0.2f}".format(cost.unit_cost) + return "${:0.2f}".format(cost.unit_cost) + + def render_on_hand(self, product, column): + inventory = product.inventory + if not inventory: + return "" + return pretty_quantity(inventory.on_hand) def template_kwargs_index(self, **kwargs): if self.print_labels: @@ -289,6 +304,8 @@ class ProductsView(MasterView): fs.regular_price.set(renderer=forms.renderers.PriceFieldRenderer, readonly=True) fs.current_price.set(renderer=forms.renderers.PriceFieldRenderer, readonly=True) fs.last_sold.set(readonly=True) + fs.status_code.set(label="Status") + fs.notes.set(renderer=fa.TextAreaFieldRenderer, size=(80, 10)) fs.append(fa.Field('current_price_ends', type=fa.types.DateTime, readonly=True, value=lambda p: p.current_price.ends if p.current_price else None)) fs.append(fa.Field('inventory_on_hand', readonly=True, label="On Hand", @@ -330,6 +347,7 @@ class ProductsView(MasterView): fs.not_for_sale, fs.ingredients, fs.notes, + fs.status_code, fs.discontinued, fs.deleted, fs.last_sold, From c82c55942f234dbabb590cbe2e1735b81a5658ec Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 19 Jul 2017 03:15:10 -0500 Subject: [PATCH 0364/3196] Stop checking for pre-0.7 SQLAlchemy --- tailbone/db.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/tailbone/db.py b/tailbone/db.py index 30f1da93..5104caa5 100644 --- a/tailbone/db.py +++ b/tailbone/db.py @@ -124,11 +124,6 @@ def register(session, initial_state=datamanager.STATUS_ACTIVE, 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( From f1bb603f938627635946a3c39c959f0296e71990 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 26 Jul 2017 15:41:39 -0500 Subject: [PATCH 0365/3196] 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 ef80649c..0e4ddb2d 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,14 @@ CHANGELOG ========= +0.6.12 (2017-07-26) +------------------ + +* Add basic support for product inventory and status + +* Stop allowing pre-0.7 SQLAlchemy + + 0.6.11 (2017-07-18) ------------------ diff --git a/tailbone/_version.py b/tailbone/_version.py index 7b49a038..100336b9 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.6.11' +__version__ = '0.6.12' From 39cf32bb0acc4fa25aaaa710253b65da1f771732 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 26 Jul 2017 17:10:09 -0500 Subject: [PATCH 0366/3196] Allow master view to decide whether each grid checkbox is checked aka. un-break what the v3 grids broke.. --- tailbone/grids/core.py | 12 ++++-------- tailbone/views/master2.py | 1 + 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index d5c3ef62..e8c963a6 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -56,7 +56,7 @@ class Grid(object): joiners={}, filterable=False, filters={}, sortable=False, sorters={}, default_sortkey=None, default_sortdir='asc', pageable=False, default_pagesize=20, default_page=1, - checkboxes=False, main_actions=[], more_actions=[], + checkboxes=False, checked=None, main_actions=[], more_actions=[], **kwargs): self.key = key @@ -90,6 +90,9 @@ class Grid(object): self.default_page = default_page self.checkboxes = checkboxes + self.checked = checked + if self.checked is None: + self.checked = lambda item: False self.main_actions = main_actions self.more_actions = more_actions @@ -882,13 +885,6 @@ class Grid(object): """ return True - def checked(self, item): - """ - Returns boolean indicating whether the given item's row checkbox should - be checked, for initial page load. - """ - return False - def render_checkbox(self, item): """ Renders a checkbox cell for the given item, if applicable. diff --git a/tailbone/views/master2.py b/tailbone/views/master2.py index e6b22c23..68483f31 100644 --- a/tailbone/views/master2.py +++ b/tailbone/views/master2.py @@ -258,6 +258,7 @@ class MasterView2(MasterView): 'url': lambda obj: self.get_action_url('view', obj), 'checkboxes': self.checkboxes or ( self.mergeable and self.request.has_perm('{}.merge'.format(self.get_permission_prefix()))), + 'checked': self.checked, } if 'main_actions' not in kwargs and 'more_actions' not in kwargs: main, more = self.get_grid_actions() From 94894b2d279a4ea69b21063f98c0cc04db7151d6 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 26 Jul 2017 17:11:35 -0500 Subject: [PATCH 0367/3196] 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 0e4ddb2d..5f438936 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.6.13 (2017-07-26) +------------------ + +* Allow master view to decide whether each grid checkbox is checked + + 0.6.12 (2017-07-26) ------------------ diff --git a/tailbone/_version.py b/tailbone/_version.py index 100336b9..aebd59cc 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.6.12' +__version__ = '0.6.13' From 5b35c3dd3becea6529f19c752703c6c8dc512613 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 31 Jul 2017 13:58:38 -0500 Subject: [PATCH 0368/3196] Make login template use same logo as home page --- tailbone/static/css/login.css | 2 +- tailbone/templates/login.mako | 2 +- tailbone/views/auth.py | 5 +++++ 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/tailbone/static/css/login.css b/tailbone/static/css/login.css index 5786565a..2603f961 100644 --- a/tailbone/static/css/login.css +++ b/tailbone/static/css/login.css @@ -4,7 +4,7 @@ ******************************/ #logo { - margin: auto; + margin: 40px auto; } div.form { diff --git a/tailbone/templates/login.mako b/tailbone/templates/login.mako index f0b84c24..93d62ef8 100644 --- a/tailbone/templates/login.mako +++ b/tailbone/templates/login.mako @@ -14,7 +14,7 @@ <%def name="logo()"> - ${h.image(request.static_url('tailbone:static/img/home_logo.png'), "Rattail Logo", id='logo')} + ${h.image(image_url, "{} logo".format(capture(self.app_title)), id='logo', width=500)} <%def name="login_form()"> diff --git a/tailbone/views/auth.py b/tailbone/views/auth.py index 6f782501..1f7dabde 100644 --- a/tailbone/views/auth.py +++ b/tailbone/views/auth.py @@ -117,9 +117,14 @@ class AuthenticationView(View): else: self.request.session.flash("Invalid username or password", 'error') + image_url = self.rattail_config.get( + 'tailbone', 'main_image_url', + default=self.request.static_url('tailbone:static/img/home_logo.png')) + return { 'form': forms.FormRenderer(form), 'referrer': referrer, + 'image_url': image_url, 'dialog': mobile, } From d93cb4f07b16073a391d6258afcc3b6598116e9c Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 1 Aug 2017 14:38:09 -0500 Subject: [PATCH 0369/3196] Fix how we detect grid settings presence in user session ..in case grid has filter settings only --- 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 e8c963a6..fbe50bb9 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -586,7 +586,7 @@ class Grid(object): for key in ['page', 'sortkey']: if 'grid.{}.{}'.format(self.key, key) in self.request.session: return True - return False + return any([key.startswith('grid.{}.filter'.format(self.key)) for key in self.request.session]) def get_setting(self, source, settings, key, normalize=lambda v: v, default=None): """ From cbf4ca8479c82056be063bf609dbe862c7a3641d Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 1 Aug 2017 14:38:53 -0500 Subject: [PATCH 0370/3196] Improve verbiage for exception view suggest the user submit Feedback to be notified of bugfix etc. --- tailbone/templates/exception.mako | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tailbone/templates/exception.mako b/tailbone/templates/exception.mako index b0c8f3c8..47cbab42 100644 --- a/tailbone/templates/exception.mako +++ b/tailbone/templates/exception.mako @@ -21,5 +21,14 @@ Occasionally certain errors will go away on their own.  But if the problem persists, please do NOT keep trying, because the sysadmins will get a new email for each error. :)

    + +

    + If you would like to be notified when this error has been fixed, you might consider using the + Feedback tool (top right on the previous page).  In some cases it may help to know what + you were trying to do, or if you noticed any strange behavior or other clues. +

    + +

    + Go Back From 00027b09f6d4c2736cceb82f441206a624eb4735 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 1 Aug 2017 14:39:38 -0500 Subject: [PATCH 0371/3196] Fix styles for message compose template --- tailbone/templates/messages/create.mako | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tailbone/templates/messages/create.mako b/tailbone/templates/messages/create.mako index 5846ebfa..c434a194 100644 --- a/tailbone/templates/messages/create.mako +++ b/tailbone/templates/messages/create.mako @@ -105,6 +105,10 @@ min-width: 540px; } + .body textarea { + min-width: 540px; + } + From 0171f3ebba1503eae896ba718210dff8113ee4ba Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 1 Aug 2017 14:42:32 -0500 Subject: [PATCH 0372/3196] Various improvements to batch worksheets, index links etc. --- tailbone/templates/mobile/master/index.mako | 6 +++++- tailbone/templates/mobile/receiving/index.mako | 10 ---------- tailbone/templates/ordering/order_form.mako | 8 ++++---- tailbone/templates/ordering/view.mako | 2 +- tailbone/templates/purchases/index.mako | 11 ----------- tailbone/views/purchasing/ordering.py | 4 ++-- tailbone/views/purchasing/receiving.py | 1 + 7 files changed, 13 insertions(+), 29 deletions(-) delete mode 100644 tailbone/templates/mobile/receiving/index.mako delete mode 100644 tailbone/templates/purchases/index.mako diff --git a/tailbone/templates/mobile/master/index.mako b/tailbone/templates/mobile/master/index.mako index 25af12de..b4431b69 100644 --- a/tailbone/templates/mobile/master/index.mako +++ b/tailbone/templates/mobile/master/index.mako @@ -7,6 +7,10 @@ ## ############################################################################## <%inherit file="/mobile/base.mako" /> -<%def name="title()">${model_title_plural} +<%def name="title()">${index_title} + +% if master.mobile_creatable and request.has_perm('{}.create'.format(permission_prefix)): + ${h.link_to("New {}".format(model_title), url('mobile.{}.create'.format(route_prefix)), class_='ui-btn ui-corner-all')} +% endif ${grid.render_complete()|n} diff --git a/tailbone/templates/mobile/receiving/index.mako b/tailbone/templates/mobile/receiving/index.mako deleted file mode 100644 index 10941783..00000000 --- a/tailbone/templates/mobile/receiving/index.mako +++ /dev/null @@ -1,10 +0,0 @@ -## -*- coding: utf-8; -*- -<%inherit file="/mobile/master/index.mako" /> - -<%def name="title()">Receiving - -% if request.has_perm('receiving.create'): - ${h.link_to("New Receiving Batch", url('mobile.receiving.create'), class_='ui-btn ui-corner-all')} -% endif - -${parent.body()} diff --git a/tailbone/templates/ordering/order_form.mako b/tailbone/templates/ordering/order_form.mako index b682c7f1..bca0ff6f 100644 --- a/tailbone/templates/ordering/order_form.mako +++ b/tailbone/templates/ordering/order_form.mako @@ -1,7 +1,7 @@ ## -*- coding: utf-8; -*- <%inherit file="/base.mako" /> -<%def name="title()">Purchase Order Form +<%def name="title()">Ordering Worksheet <%def name="extra_javascript()"> ${parent.extra_javascript()} @@ -237,8 +237,8 @@ ${h.end_form()} - % for cost in subdepartment._order_costs: - + % for i, cost in enumerate(subdepartment._order_costs, 1): + ${self.order_form_row(cost)} % for data in history: @@ -270,7 +270,7 @@ ${h.end_form()} ${h.text('units_ordered_{}'.format(cost.uuid), value=int(cost._batchrow.units_ordered or 0) if cost._batchrow else None)} - ${'${:0,.2f}'.format(cost._batchrow.po_total) if cost._batchrow else ''} + ${'${:0,.2f}'.format(cost._batchrow.po_total or 0) if cost._batchrow else ''} ${self.extra_td(cost)} % endfor diff --git a/tailbone/templates/ordering/view.mako b/tailbone/templates/ordering/view.mako index 04c7526d..4d12e643 100644 --- a/tailbone/templates/ordering/view.mako +++ b/tailbone/templates/ordering/view.mako @@ -36,7 +36,7 @@ <%def name="leading_buttons()"> % if not batch.complete and not batch.executed and request.has_perm('ordering.order_form'): - + % endif diff --git a/tailbone/templates/purchases/index.mako b/tailbone/templates/purchases/index.mako deleted file mode 100644 index 38a665ee..00000000 --- a/tailbone/templates/purchases/index.mako +++ /dev/null @@ -1,11 +0,0 @@ -## -*- coding: utf-8; -*- -<%inherit file="/master/index.mako" /> - -<%def name="context_menu_items()"> - ${parent.context_menu_items()} - % if request.has_perm('purchases.batch.list'): -

  • ${h.link_to("Go to Purchasing Batches", url('purchases.batch'))}
  • - % endif - - -${parent.body()} diff --git a/tailbone/views/purchasing/ordering.py b/tailbone/views/purchasing/ordering.py index 91271912..7f16c9b6 100644 --- a/tailbone/views/purchasing/ordering.py +++ b/tailbone/views/purchasing/ordering.py @@ -50,6 +50,7 @@ class OrderingBatchView(PurchasingBatchView): url_prefix = '/ordering' model_title = "Ordering Batch" model_title_plural = "Ordering Batches" + index_title = "Ordering" row_grid_columns = [ 'sequence', @@ -144,8 +145,7 @@ class OrderingBatchView(PurchasingBatchView): 'batch': batch, 'instance': batch, 'instance_title': title, - 'index_title': "{}: {}".format(self.get_model_title(), title), - 'index_url': self.get_action_url('view', batch), + 'instance_url': self.get_action_url('view', batch), 'vendor': batch.vendor, 'departments': departments, 'history': history, diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index 1d7e2336..4a14bc45 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -88,6 +88,7 @@ class ReceivingBatchView(PurchasingBatchView): url_prefix = '/receiving' model_title = "Receiving Batch" model_title_plural = "Receiving Batches" + index_title = "Receiving" creatable = False rows_deletable = False mobile_creatable = True From 3820891277a57513f498a74ed3c4cf35425d985f Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 1 Aug 2017 14:48:22 -0500 Subject: [PATCH 0373/3196] Fix batch links when viewing purchase object --- tailbone/views/purchases/core.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tailbone/views/purchases/core.py b/tailbone/views/purchases/core.py index 12e6676b..4f3337d8 100644 --- a/tailbone/views/purchases/core.py +++ b/tailbone/views/purchases/core.py @@ -45,6 +45,11 @@ class BatchesFieldRenderer(fa.FieldRenderer): enum = self.request.rattail_config.get_enum() + routes = { + enum.PURCHASE_BATCH_MODE_ORDERING: 'ordering.view', + enum.PURCHASE_BATCH_MODE_RECEIVING: 'receiving.view', + } + def render(batch): if batch.executed: actor = batch.executed_by @@ -55,7 +60,7 @@ class BatchesFieldRenderer(fa.FieldRenderer): display = '{} ({} by {}){}'.format(batch.id_str, enum.PURCHASE_BATCH_MODE[batch.mode], actor, pending) - return tags.link_to(display, self.request.route_url('purchases.batch.view', uuid=batch.uuid)) + return tags.link_to(display, self.request.route_url(routes[batch.mode], uuid=batch.uuid)) items = [HTML.tag('li', c=render(batch)) for batch in batches] return HTML.tag('ul', c=items) From 93fa361292fe05328d50a15db40ee2cd3f6c5746 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 1 Aug 2017 14:51:21 -0500 Subject: [PATCH 0374/3196] Add "on order" count to products grid, tweak product notes panel --- tailbone/templates/products/view.mako | 2 +- tailbone/views/products.py | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/tailbone/templates/products/view.mako b/tailbone/templates/products/view.mako index 41d94854..5abc4d6d 100644 --- a/tailbone/templates/products/view.mako +++ b/tailbone/templates/products/view.mako @@ -209,7 +209,7 @@

    Notes

    - ${render_field_readonly(form.fieldset.notes)} +
    ${form.fieldset.notes.render_readonly()}
    diff --git a/tailbone/views/products.py b/tailbone/views/products.py index ff6019a8..bf4fcd92 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -182,6 +182,7 @@ class ProductsView(MasterView): g.sorters['subdepartment'] = g.make_sorter(model.Subdepartment.name) g.sorters['vendor'] = g.make_sorter(model.Vendor.name) g.set_sorter('on_hand', model.ProductInventory.on_hand) + g.set_sorter('on_order', model.ProductInventory.on_order) g.filters['upc'].default_active = True g.filters['upc'].default_verb = 'equal' @@ -219,6 +220,7 @@ class ProductsView(MasterView): g.set_renderer('current_price', self.render_price) g.set_renderer('cost', self.render_cost) g.set_renderer('on_hand', self.render_on_hand) + g.set_renderer('on_order', self.render_on_order) g.set_link('upc') g.set_link('item_id') @@ -262,6 +264,12 @@ class ProductsView(MasterView): return "" return pretty_quantity(inventory.on_hand) + def render_on_order(self, product, column): + inventory = product.inventory + if not inventory: + return "" + return pretty_quantity(inventory.on_order) + def template_kwargs_index(self, **kwargs): if self.print_labels: kwargs['label_profiles'] = Session.query(model.LabelProfile)\ From b160ac64ebfa375a326fc211fac04b1fb9df7091 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 1 Aug 2017 15:05:34 -0500 Subject: [PATCH 0375/3196] 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 5f438936..7f1df033 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,24 @@ CHANGELOG ========= +0.6.14 (2017-08-01) +------------------- + +* Make login template use same logo as home page + +* Fix how we detect grid settings presence in user session + +* Improve verbiage for exception view + +* Fix styles for message compose template + +* Various improvements to batch worksheets, index links etc. + +* Fix batch links when viewing purchase object + +* Add "on order" count to products grid, tweak product notes panel + + 0.6.13 (2017-07-26) ------------------ diff --git a/tailbone/_version.py b/tailbone/_version.py index aebd59cc..43d6bd5a 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.6.13' +__version__ = '0.6.14' From 09ffdba9ef7f883858d8ed4fa834910605d6261b Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 2 Aug 2017 12:04:03 -0500 Subject: [PATCH 0376/3196] Allow product field renderer to suppress hyperlink --- tailbone/forms/renderers/products.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tailbone/forms/renderers/products.py b/tailbone/forms/renderers/products.py index 58e4827d..20843111 100644 --- a/tailbone/forms/renderers/products.py +++ b/tailbone/forms/renderers/products.py @@ -26,6 +26,8 @@ Product Field Renderers from __future__ import unicode_literals, absolute_import +import six + from rattail.gpc import GPC from rattail.db import model from rattail.db.util import maxlen @@ -57,7 +59,9 @@ class ProductFieldRenderer(AutocompleteFieldRenderer): product = self.raw_value if not product: return '' - return tags.link_to(product, self.request.route_url('products.view', uuid=product.uuid)) + if kwargs.get('hyperlink', True): + return tags.link_to(product, self.request.route_url('products.view', uuid=product.uuid)) + return six.text_type(product) class ProductKeyFieldRenderer(TextFieldRenderer): From 5afa8326843fb544724b34dcca95c9931ce34de6 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 2 Aug 2017 12:04:32 -0500 Subject: [PATCH 0377/3196] Add 'data-uuid' attr for mobile grid list items, if applicable --- tailbone/grids/core.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index fbe50bb9..6026714d 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -964,7 +964,10 @@ class CustomWebhelpersGrid(webhelpers2_grid.Grid): value = self.get_column_value(column_number, i, record, column_name) if self.mobile: url = self.url_generator(record, i) - return HTML.tag('li', tags.link_to(value, url)) + attrs = {} + if hasattr(record, 'uuid'): + attrs['data_uuid'] = record.uuid + return HTML.tag('li', tags.link_to(value, url), **attrs) if self.linked_columns and column_name in self.linked_columns: url = self.url_generator(record, i) value = tags.link_to(value, url) From 65c63dad3edb8663cfa75b15f7967bfdcb2ebecb Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 2 Aug 2017 12:08:23 -0500 Subject: [PATCH 0378/3196] Initial support for mobile ordering plus various other changes required for that --- tailbone/templates/mobile/batch/view.mako | 19 +++ .../templates/mobile/master/create_row.mako | 4 + tailbone/templates/mobile/master/edit.mako | 10 ++ .../templates/mobile/master/edit_row.mako | 16 +++ .../templates/mobile/master/view_row.mako | 8 ++ .../templates/mobile/ordering/create.mako | 22 +++ .../templates/mobile/ordering/create_row.mako | 6 + tailbone/templates/themes/better/base.mako | 5 +- tailbone/views/batch/core.py | 31 ++++- tailbone/views/master.py | 129 ++++++++++++++---- tailbone/views/purchasing/batch.py | 3 + tailbone/views/purchasing/ordering.py | 66 +++++++++ 12 files changed, 289 insertions(+), 30 deletions(-) create mode 100644 tailbone/templates/mobile/master/create_row.mako create mode 100644 tailbone/templates/mobile/master/edit.mako create mode 100644 tailbone/templates/mobile/master/edit_row.mako create mode 100644 tailbone/templates/mobile/ordering/create.mako create mode 100644 tailbone/templates/mobile/ordering/create_row.mako diff --git a/tailbone/templates/mobile/batch/view.mako b/tailbone/templates/mobile/batch/view.mako index 9d3b0c0c..0770e703 100644 --- a/tailbone/templates/mobile/batch/view.mako +++ b/tailbone/templates/mobile/batch/view.mako @@ -4,6 +4,25 @@ ${parent.body()} % if master.has_rows: + % if master.mobile_rows_creatable and not batch.executed and not batch.complete: + ${h.link_to("Add Item", url('mobile.{}.create_row'.format(route_prefix), uuid=instance.uuid), class_='ui-btn ui-corner-all')} + % endif
    ${grid.render_complete()|n} % endif + +% if not batch.executed and request.has_perm('{}.edit'.format(permission_prefix)): + % if batch.complete: + ${h.form(request.route_url('mobile.{}.mark_pending'.format(route_prefix), uuid=batch.uuid))} + ${h.csrf_token(request)} + ${h.hidden('mark-pending', value='true')} + ${h.submit('submit', "Mark Batch as Pending")} + ${h.end_form()} + % else: + ${h.form(request.route_url('mobile.{}.mark_complete'.format(route_prefix), uuid=batch.uuid))} + ${h.csrf_token(request)} + ${h.hidden('mark-complete', value='true')} + ${h.submit('submit', "Mark Batch as Complete")} + ${h.end_form()} + % endif +% endif diff --git a/tailbone/templates/mobile/master/create_row.mako b/tailbone/templates/mobile/master/create_row.mako new file mode 100644 index 00000000..8a6157d2 --- /dev/null +++ b/tailbone/templates/mobile/master/create_row.mako @@ -0,0 +1,4 @@ +## -*- coding: utf-8; -*- +<%inherit file="/mobile/master/create.mako" /> + +${parent.body()} diff --git a/tailbone/templates/mobile/master/edit.mako b/tailbone/templates/mobile/master/edit.mako new file mode 100644 index 00000000..3c13a8e4 --- /dev/null +++ b/tailbone/templates/mobile/master/edit.mako @@ -0,0 +1,10 @@ +## -*- coding: utf-8; -*- +<%inherit file="/mobile/base.mako" /> + +<%def name="title()">${index_title} » ${instance_title} » Edit + +<%def name="page_title()">${h.link_to(index_title, index_url)} » ${h.link_to(instance_title, instance_url)} » Edit + +
    + ${form.render()|n} +
    diff --git a/tailbone/templates/mobile/master/edit_row.mako b/tailbone/templates/mobile/master/edit_row.mako new file mode 100644 index 00000000..31b0156d --- /dev/null +++ b/tailbone/templates/mobile/master/edit_row.mako @@ -0,0 +1,16 @@ +## -*- coding: utf-8; -*- +<%inherit file="/mobile/master/edit.mako" /> + +<%def name="title()">${index_title} » ${parent_title} » ${instance_title} » Edit + +<%def name="page_title()">${h.link_to(index_title, index_url)} » ${h.link_to(parent_title, parent_url)} » ${h.link_to(instance_title, instance_url)} » Edit + +<%def name="buttons()"> +
    + ${h.submit('create', form.update_label)} + Cancel + + +
    + ${form.render(buttons=capture(self.buttons))|n} +
    diff --git a/tailbone/templates/mobile/master/view_row.mako b/tailbone/templates/mobile/master/view_row.mako index 74df7e9e..109ad415 100644 --- a/tailbone/templates/mobile/master/view_row.mako +++ b/tailbone/templates/mobile/master/view_row.mako @@ -1,4 +1,12 @@ ## -*- coding: utf-8; -*- <%inherit file="/mobile/master/view.mako" /> +<%def name="title()">${index_title} » ${parent_title} » ${instance_title} + +<%def name="page_title()">${h.link_to(index_title, index_url)} » ${h.link_to(parent_title, parent_url)} » ${instance_title} + ${parent.body()} + +% if master.mobile_rows_editable and instance_editable and request.has_perm('{}.edit_row'.format(permission_prefix)): + ${h.link_to("Edit", url('mobile.{}.edit'.format(row_route_prefix), uuid=instance.uuid), class_='ui-btn')} +% endif diff --git a/tailbone/templates/mobile/ordering/create.mako b/tailbone/templates/mobile/ordering/create.mako new file mode 100644 index 00000000..68f11737 --- /dev/null +++ b/tailbone/templates/mobile/ordering/create.mako @@ -0,0 +1,22 @@ +## -*- coding: utf-8; -*- +<%inherit file="/mobile/base.mako" /> + +<%def name="title()">${index_title} » New Batch + +<%def name="page_title()">${h.link_to(index_title, index_url)} » New Batch + +${h.form(request.current_route_url(), class_='ui-filterable', name='new-purchasing-batch')} +${h.csrf_token(request)} + +
    +
    + ${h.hidden('vendor')} + ${h.text('new-purchasing-batch-vendor-text', placeholder="Vendor name", autocomplete='off', data_type='search')} +
      + +
      +
      + +
      +${h.submit('submit', "Make Batch")} +${h.end_form()} diff --git a/tailbone/templates/mobile/ordering/create_row.mako b/tailbone/templates/mobile/ordering/create_row.mako new file mode 100644 index 00000000..d31814f8 --- /dev/null +++ b/tailbone/templates/mobile/ordering/create_row.mako @@ -0,0 +1,6 @@ +## -*- coding: utf-8; -*- +<%inherit file="/mobile/master/create_row.mako" /> + +<%def name="title()">${h.link_to(index_title, index_url)} » ${h.link_to(instance_title, instance_url)} » Add Item + +${parent.body()} diff --git a/tailbone/templates/themes/better/base.mako b/tailbone/templates/themes/better/base.mako index 1c7d55e4..76710c34 100644 --- a/tailbone/templates/themes/better/base.mako +++ b/tailbone/templates/themes/better/base.mako @@ -40,7 +40,10 @@ ${index_title} % else: ${h.link_to(index_title, index_url, class_='global')} - % if instance_url is not Undefined: + % if parent_url is not Undefined: + » + ${h.link_to(parent_title, parent_url, class_='global')} + % elif instance_url is not Undefined: » ${h.link_to(instance_title, instance_url, class_='global')} % endif diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index dfccc047..fc9ef4c9 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -354,6 +354,11 @@ class BatchMasterView(MasterView): batch.complete = True return self.redirect(self.get_index_url(mobile=True)) + def mobile_mark_pending(self): + batch = self.get_instance() + batch.complete = False + return self.redirect(self.get_action_url('view', batch, mobile=True)) + def rows_creatable_for(self, batch): """ Only allow creating new rows on a batch if it hasn't yet been executed. @@ -370,6 +375,24 @@ class BatchMasterView(MasterView): return self.redirect(self.get_action_url('view', batch)) return super(BatchMasterView, self).create_row() + def mobile_create_row(self): + """ + Only allow creating a new row if the batch hasn't yet been executed. + """ + batch = self.get_instance() + if batch.executed: + self.request.session.flash("You cannot add new rows to a batch which has been executed") + return self.redirect(self.get_action_url('view', batch, mobile=True)) + return super(BatchMasterView, self).mobile_create_row() + + def before_create_row(self, form): + batch = self.get_instance() + row = form.fieldset.model + self.handler.add_row(batch, row) + + def after_create_row(self, row): + self.handler.refresh_row(row) + def make_default_row_grid_tools(self, batch): if self.rows_creatable and not batch.executed: permission_prefix = self.get_permission_prefix() @@ -659,7 +682,8 @@ class BatchMasterView(MasterView): """ Batch rows are editable only until batch has been executed. """ - return self.rows_editable and not row.batch.executed + batch = row.batch + return self.rows_editable and not batch.executed and not batch.complete def row_edit_action_url(self, row, i): if self.row_editable(row): @@ -989,6 +1013,11 @@ class BatchMasterView(MasterView): config.add_view(cls, attr='mobile_mark_complete', route_name='mobile.{}.mark_complete'.format(route_prefix), permission='{}.edit'.format(permission_prefix)) + # mobile mark pending + config.add_route('mobile.{}.mark_pending'.format(route_prefix), '/mobile{}/{{{}}}/mark-pending'.format(url_prefix, model_key)) + config.add_view(cls, attr='mobile_mark_pending', route_name='mobile.{}.mark_pending'.format(route_prefix), + permission='{}.edit'.format(permission_prefix)) + # execute batch config.add_route('{}.execute'.format(route_prefix), '{}/{{uuid}}/execute'.format(url_prefix)) diff --git a/tailbone/views/master.py b/tailbone/views/master.py index d3e30b9e..3c59e2fe 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -95,8 +95,10 @@ class MasterView(View): rows_bulk_deletable = False rows_default_pagesize = 20 + mobile_rows_creatable = False mobile_rows_filterable = False mobile_rows_viewable = False + mobile_rows_editable = False @property def Session(self): @@ -472,7 +474,12 @@ class MasterView(View): fieldset = self.make_fieldset(row) self.preconfigure_mobile_row_fieldset(fieldset) self.configure_mobile_row_fieldset(fieldset) + kwargs.setdefault('session', self.Session()) + kwargs.setdefault('creating', self.creating) + kwargs.setdefault('editing', self.editing) kwargs.setdefault('action_url', self.request.current_route_url(_query=None)) + if 'cancel_url' not in kwargs: + kwargs['cancel_url'] = self.get_action_url('view', self.get_parent(row), mobile=True) factory = kwargs.pop('factory', forms.AlchemyForm) form = factory(self.request, fieldset, **kwargs) form.readonly = self.viewing @@ -508,11 +515,16 @@ class MasterView(View): """ self.viewing = True row = self.get_row_instance() + parent = self.get_parent(row) form = self.make_mobile_row_form(row) context = { 'row': row, + 'parent_instance': parent, + 'parent_title': self.get_instance_title(parent), + 'parent_url': self.get_action_url('view', parent, mobile=True), 'instance': row, 'instance_title': self.get_row_instance_title(row), + 'instance_editable': self.row_editable(row), 'parent_model_title': self.get_model_title(), 'form': form, } @@ -945,6 +957,8 @@ class MasterView(View): context.update(self.template_kwargs(**context)) if hasattr(self, 'template_kwargs_{}'.format(template)): context.update(getattr(self, 'template_kwargs_{}'.format(template))(**context)) + if hasattr(self, 'mobile_template_kwargs_{}'.format(template)): + context.update(getattr(self, 'mobile_template_kwargs_{}'.format(template))(**context)) # First try the template path most specific to the view. if mobile: @@ -1327,8 +1341,30 @@ class MasterView(View): def after_create_row(self, row_object): pass - def redirect_after_create_row(self, row): - return self.redirect(self.get_action_url('view', self.get_parent(row))) + def redirect_after_create_row(self, row, mobile=False): + return self.redirect(self.get_row_action_url('view', row, mobile=mobile)) + + def mobile_create_row(self): + """ + Mobile view for creating a new row object + """ + self.creating = True + parent = self.get_instance() + instance_url = self.get_action_url('view', parent, mobile=True) + form = self.make_mobile_row_form(self.model_row_class, cancel_url=instance_url) + if self.request.method == 'POST': + if form.validate(): + self.before_create_row(form) + # let save() return alternate object if necessary + obj = self.save_create_row_form(form) or form.fieldset.model + self.after_create_row(obj) + return self.redirect_after_create_row(obj, mobile=True) + return self.render_to_response('create_row', { + 'instance_title': self.get_instance_title(parent), + 'instance_url': instance_url, + 'parent_object': parent, + 'form': form, + }, mobile=True) def view_row(self): """ @@ -1359,6 +1395,14 @@ class MasterView(View): """ return True + def row_editable(self, row): + """ + Returns boolean indicating whether or not the given row can be + considered "editable". Returns ``True`` by default; override as + necessary. + """ + return True + def edit_row(self): """ View for editing an existing model record. @@ -1376,14 +1420,39 @@ class MasterView(View): return self.render_to_response('edit_row', { 'instance': row, 'row_parent': parent, + 'parent_title': self.get_instance_title(parent), + 'parent_url': self.get_action_url('view', parent), + 'parent_instance': parent, 'instance_title': self.get_row_instance_title(row), 'instance_deletable': self.row_deletable(row), - 'index_url': self.get_action_url('view', parent), - 'index_title': '{} {}'.format( - self.get_model_title(), - self.get_instance_title(parent)), 'form': form}) + def mobile_edit_row(self): + """ + Mobile view for editing a row object + """ + self.editing = True + row = self.get_row_instance() + instance_url = self.get_row_action_url('view', row, mobile=True) + form = self.make_mobile_row_form(row) + + if self.request.method == 'POST': + if form.validate(): + self.save_edit_row_form(form) + return self.redirect_after_edit_row(row, mobile=True) + + parent = self.get_parent(row) + return self.render_to_response('edit_row', { + 'instance': row, + 'instance_title': self.get_row_instance_title(row), + 'instance_url': instance_url, + 'instance_deletable': self.row_deletable(row), + 'parent_instance': parent, + 'parent_title': self.get_instance_title(parent), + 'parent_url': self.get_action_url('view', parent, mobile=True), + 'form': form}, + mobile=True) + def save_edit_row_form(self, form): self.save_row_form(form) self.after_edit_row(form.fieldset.model) @@ -1396,16 +1465,8 @@ class MasterView(View): Event hook, called just after an existing row object is saved. """ - def redirect_after_edit_row(self, row): - return self.redirect(self.get_action_url('view', self.get_parent(row))) - - def row_editable(self, row): - """ - Returns boolean indicating whether or not the given row can be - considered "editable". Returns ``True`` by default; override as - necessary. - """ - return True + def redirect_after_edit_row(self, row, mobile=False): + return self.redirect(self.get_row_action_url('view', row, mobile=True)) def row_deletable(self, row): """ @@ -1617,12 +1678,18 @@ class MasterView(View): ### sub-rows stuff follows # create row - if cls.has_rows and cls.rows_creatable: - config.add_route('{}.create_row'.format(route_prefix), '{}/{{{}}}/new-row'.format(url_prefix, model_key)) - config.add_view(cls, attr='create_row', route_name='{}.create_row'.format(route_prefix), - permission='{}.create_row'.format(permission_prefix)) - config.add_tailbone_permission(permission_prefix, '{}.create_row'.format(permission_prefix), - "Create new {} rows".format(model_title)) + if cls.has_rows: + if cls.rows_creatable or cls.mobile_rows_creatable: + config.add_tailbone_permission(permission_prefix, '{}.create_row'.format(permission_prefix), + "Create new {} rows".format(model_title)) + if cls.rows_creatable: + config.add_route('{}.create_row'.format(route_prefix), '{}/{{{}}}/new-row'.format(url_prefix, model_key)) + config.add_view(cls, attr='create_row', route_name='{}.create_row'.format(route_prefix), + permission='{}.create_row'.format(permission_prefix)) + if cls.mobile_rows_creatable: + config.add_route('mobile.{}.create_row'.format(route_prefix), '/mobile{}/{{{}}}/new-row'.format(url_prefix, model_key)) + config.add_view(cls, attr='mobile_create_row', route_name='mobile.{}.create_row'.format(route_prefix), + permission='{}.create_row'.format(permission_prefix)) # view row if cls.has_rows: @@ -1636,12 +1703,18 @@ class MasterView(View): permission='{}.view'.format(permission_prefix)) # edit row - if cls.has_rows and cls.rows_editable: - config.add_route('{}.edit'.format(row_route_prefix), '{}/{{uuid}}/edit'.format(row_url_prefix)) - config.add_view(cls, attr='edit_row', route_name='{}.edit'.format(row_route_prefix), - permission='{}.edit_row'.format(permission_prefix)) - config.add_tailbone_permission(permission_prefix, '{}.edit_row'.format(permission_prefix), - "Edit individual {} rows".format(model_title)) + if cls.has_rows: + if cls.rows_editable or cls.mobile_rows_editable: + config.add_tailbone_permission(permission_prefix, '{}.edit_row'.format(permission_prefix), + "Edit individual {} rows".format(model_title)) + if cls.rows_editable: + config.add_route('{}.edit'.format(row_route_prefix), '{}/{{uuid}}/edit'.format(row_url_prefix)) + config.add_view(cls, attr='edit_row', route_name='{}.edit'.format(row_route_prefix), + permission='{}.edit_row'.format(permission_prefix)) + if cls.mobile_rows_editable: + config.add_route('mobile.{}.edit'.format(row_route_prefix), '/mobile{}/{{uuid}}/edit'.format(row_url_prefix)) + config.add_view(cls, attr='mobile_edit_row', route_name='mobile.{}.edit'.format(row_route_prefix), + permission='{}.edit_row'.format(permission_prefix)) # delete row if cls.has_rows and cls.rows_deletable: diff --git a/tailbone/views/purchasing/batch.py b/tailbone/views/purchasing/batch.py index ca26ea05..738e175e 100644 --- a/tailbone/views/purchasing/batch.py +++ b/tailbone/views/purchasing/batch.py @@ -275,6 +275,7 @@ class PurchasingBatchView(BatchMasterView): elif batch.buyer_uuid: kwargs['buyer_uuid'] = batch.buyer_uuid kwargs['po_number'] = batch.po_number + kwargs['po_total'] = batch.po_total # TODO: should these always get set? if self.batch_mode == self.enum.PURCHASE_BATCH_MODE_ORDERING: @@ -352,6 +353,7 @@ class PurchasingBatchView(BatchMasterView): def _preconfigure_row_fieldset(self, fs): super(PurchasingBatchView, self)._preconfigure_row_fieldset(fs) fs.upc.set(label="UPC") + fs.item_id.set(label="Item ID") fs.brand_name.set(label="Brand") fs.case_quantity.set(renderer=forms.renderers.QuantityFieldRenderer, readonly=True) fs.cases_ordered.set(renderer=forms.renderers.QuantityFieldRenderer) @@ -402,6 +404,7 @@ class PurchasingBatchView(BatchMasterView): include=[ # fs.item_lookup, fs.upc, + fs.item_id, fs.product, fs.brand_name, fs.description, diff --git a/tailbone/views/purchasing/ordering.py b/tailbone/views/purchasing/ordering.py index 7f16c9b6..514fdb62 100644 --- a/tailbone/views/purchasing/ordering.py +++ b/tailbone/views/purchasing/ordering.py @@ -51,6 +51,9 @@ class OrderingBatchView(PurchasingBatchView): model_title = "Ordering Batch" model_title_plural = "Ordering Batches" index_title = "Ordering" + mobile_creatable = True + rows_editable = True + mobile_rows_editable = True row_grid_columns = [ 'sequence', @@ -241,6 +244,69 @@ class OrderingBatchView(PurchasingBatchView): 'batch_po_total': '${:0,.2f}'.format(batch.po_total or 0), } + def render_mobile_listitem(self, batch, i): + return "({}) {} on {} for ${:0,.2f}".format(batch.id_str, batch.vendor, + batch.date_ordered, batch.po_total) + + def mobile_create(self): + """ + Mobile view for creating a new ordering batch + """ + mode = self.batch_mode + data = {'mode': mode} + + vendor = None + if self.request.method == 'POST' and self.request.POST.get('vendor'): + vendor = self.Session.query(model.Vendor).get(self.request.POST['vendor']) + if vendor: + + # fetch first to avoid flush below + store = self.rattail_config.get_store(self.Session()) + + batch = self.model_class() + batch.mode = mode + batch.vendor = vendor + batch.store = store + batch.buyer = self.request.user.employee + batch.date_ordered = localtime(self.rattail_config).date() + batch.created_by = self.request.user + batch.po_total = 0 + kwargs = self.get_batch_kwargs(batch, mobile=True) + batch = self.handler.make_batch(self.Session(), **kwargs) + if self.handler.should_populate(batch): + self.handler.populate(batch) + return self.redirect(self.request.route_url('mobile.ordering.view', uuid=batch.uuid)) + + data['index_title'] = self.get_index_title() + data['index_url'] = self.get_index_url(mobile=True) + data['mode_title'] = self.enum.PURCHASE_BATCH_MODE[mode].capitalize() + return self.render_to_response('create', data, mobile=True) + + def preconfigure_mobile_fieldset(self, fs): + super(OrderingBatchView, self).preconfigure_mobile_fieldset(fs) + fs.vendor.set(attrs={'hyperlink': False}) + + def configure_mobile_fieldset(self, fs): + fields = [ + fs.vendor, + fs.department, + fs.date_ordered, + fs.po_number, + fs.po_total, + fs.created, + fs.created_by, + fs.notes, + fs.status_code, + fs.complete, + ] + batch = fs.model + if (self.viewing or self.deleting) and batch.executed: + fields.extend([ + fs.executed, + fs.executed_by, + ]) + fs.configure(include=fields) + def download_excel(self): """ Download ordering batch as Excel spreadsheet. From 961249722f8bfd5a7cb0618f5924c32a6cb1cf4d Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 2 Aug 2017 13:18:05 -0500 Subject: [PATCH 0379/3196] Some tweaks to ordering batch views --- tailbone/views/purchasing/ordering.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tailbone/views/purchasing/ordering.py b/tailbone/views/purchasing/ordering.py index 514fdb62..32db947a 100644 --- a/tailbone/views/purchasing/ordering.py +++ b/tailbone/views/purchasing/ordering.py @@ -246,7 +246,7 @@ class OrderingBatchView(PurchasingBatchView): def render_mobile_listitem(self, batch, i): return "({}) {} on {} for ${:0,.2f}".format(batch.id_str, batch.vendor, - batch.date_ordered, batch.po_total) + batch.date_ordered, batch.po_total or 0) def mobile_create(self): """ @@ -354,7 +354,7 @@ class OrderingBatchView(PurchasingBatchView): # ordering form config.add_tailbone_permission(permission_prefix, '{}.order_form'.format(permission_prefix), - "Edit new {} in Order Form mode".format(model_title)) + "Edit {} data as worksheet".format(model_title)) config.add_route('{}.order_form'.format(route_prefix), '{}/{{{}}}/order-form'.format(url_prefix, model_key)) config.add_view(cls, attr='order_form', route_name='{}.order_form'.format(route_prefix), permission='{}.order_form'.format(permission_prefix)) From 6ae129ea24e1e3ed6421454e047a11c1fc64682f Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 2 Aug 2017 13:18:19 -0500 Subject: [PATCH 0380/3196] Fix bug when request.user becomes unattached from session (?) this sure seems unexpected. so far the behavior has only been seen on mobile when a new ordering batch was created --- tailbone/views/core.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/tailbone/views/core.py b/tailbone/views/core.py index a9a5b8f2..59db1d3a 100644 --- a/tailbone/views/core.py +++ b/tailbone/views/core.py @@ -47,9 +47,14 @@ class View(object): self.request = request # if user becomes inactive while logged in, log them out - if getattr(request, 'user', None) and not request.user.active: - headers = logout_user(request) - raise self.redirect(request.route_url('home')) + if getattr(request, 'user', None): + # TODO: why is the user sometimes not attached to session? + # (this has only been seen on mobile, when creating a new ordering batch) + if request.user not in Session(): + request.user = Session.merge(request.user) + if not request.user.active: + headers = logout_user(request) + raise self.redirect(request.route_url('home')) config = self.rattail_config if config: From 8186366b69f7fdb58192ebe2c19dae247c308d40 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 2 Aug 2017 19:16:45 -0500 Subject: [PATCH 0381/3196] Add view for consuming new batch ID; misc. tweaks for grids etc. --- tailbone/grids/core.py | 11 ++++- tailbone/static/css/grids.css | 1 + tailbone/static/js/tailbone.js | 11 +++-- tailbone/templates/master/index.mako | 68 ++++++++++++++-------------- tailbone/views/common.py | 23 ++++++++++ 5 files changed, 76 insertions(+), 38 deletions(-) diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index 6026714d..fd0ac595 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -26,6 +26,7 @@ Core Grid Classes from __future__ import unicode_literals, absolute_import +import datetime import urllib import six @@ -34,7 +35,7 @@ from sqlalchemy import orm from rattail.db import api from rattail.db.types import GPCType -from rattail.util import pretty_boolean, pretty_quantity, prettify +from rattail.util import prettify, pretty_boolean, pretty_quantity, pretty_hours import webhelpers2_grid from pyramid.renderers import render @@ -149,6 +150,8 @@ class Grid(object): self.set_renderer(key, self.render_percent) elif type_ == 'quantity': self.set_renderer(key, self.render_quantity) + elif type_ == 'duration': + self.set_renderer(key, self.render_duration) else: raise ValueError("Unsupported type for column '{}': {}".format(key, type_)) @@ -211,6 +214,12 @@ class Grid(object): value = self.obtain_value(obj, column_name) return pretty_quantity(value) + def render_duration(self, obj, column_name): + value = self.obtain_value(obj, column_name) + if value is None: + return "" + return pretty_hours(datetime.timedelta(seconds=value)) + def set_url(self, url): self.url = url diff --git a/tailbone/static/css/grids.css b/tailbone/static/css/grids.css index 98bca954..c9692c85 100644 --- a/tailbone/static/css/grids.css +++ b/tailbone/static/css/grids.css @@ -24,6 +24,7 @@ .grid-wrapper .grid-header td.tools { margin: 0; padding: 0; + text-align: right; vertical-align: bottom; white-space: nowrap; } diff --git a/tailbone/static/js/tailbone.js b/tailbone/static/js/tailbone.js index 753f10eb..334f570e 100644 --- a/tailbone/static/js/tailbone.js +++ b/tailbone/static/js/tailbone.js @@ -27,13 +27,16 @@ function disable_filter_options() { /* - * Convenience function to disable a form button. + * Convenience function to disable a UI button. */ function disable_button(button, label) { - if (label) { - $(button).html(label + ", please wait..."); + $(button).button('disable'); + if (label === undefined) { + label = "Working, please wait..."; + } + if (label) { + $(button).button('option', 'label', label); } - $(button).attr('disabled', 'disabled'); } diff --git a/tailbone/templates/master/index.mako b/tailbone/templates/master/index.mako index e417499c..a01ebfde 100644 --- a/tailbone/templates/master/index.mako +++ b/tailbone/templates/master/index.mako @@ -90,37 +90,39 @@ ## ${grid.render_complete(tools=capture(self.grid_tools).strip(), context_menu=capture(self.context_menu_items).strip())|n} -
      +${grid.render_complete(tools=capture(self.grid_tools).strip(), context_menu=capture(self.context_menu_items).strip())|n} - - - - - - - - - - - - - - -
      - % if grid.filterable: - ## TODO: should this be variable sometimes? - ${grid.render_filters(allow_save_defaults=True)|n} - % endif -
      -
      - ${self.grid_tools()} -
      -
      - - ${grid.render_grid()|n} - -
      +##
      +## +## +## +## +## +## +## +## +## +## +## +## +## +## +## +##
      +## % if grid.filterable: +## ## TODO: should this be variable sometimes? +## ${grid.render_filters(allow_save_defaults=True)|n} +## % endif +##
      +##
      +## ${self.grid_tools()} +##
      +##
      +## +## ${grid.render_grid()|n} +## +##
      diff --git a/tailbone/views/common.py b/tailbone/views/common.py index 9066df9d..cfa731b0 100644 --- a/tailbone/views/common.py +++ b/tailbone/views/common.py @@ -29,6 +29,7 @@ from __future__ import unicode_literals, absolute_import import six import rattail +from rattail.batch import consume_batch_id from rattail.mail import send_email from rattail.util import OrderedDict from rattail.files import resource_path @@ -40,6 +41,7 @@ from pyramid_simpleform import Form import tailbone from tailbone import forms +from tailbone.db import Session from tailbone.views import View @@ -124,6 +126,14 @@ class CommonView(View): return httpexceptions.HTTPFound(location=form.data['referrer']) return {'form': forms.FormRenderer(form)} + def consume_batch_id(self): + """ + Consume next batch ID from the PG sequence, and display via flash message. + """ + batch_id = consume_batch_id(Session()) + self.request.session.flash("Batch ID has been consumed: {:08d}".format(batch_id)) + return self.redirect(self.request.get_referrer()) + def bogus_error(self): """ A special view which simply raises an error, for the sake of testing @@ -133,6 +143,10 @@ class CommonView(View): @classmethod def defaults(cls, config): + cls._defaults(config) + + @classmethod + def _defaults(cls, config): rattail_config = config.registry.settings.get('rattail_config') # auto-correct URLs which require trailing slash @@ -142,6 +156,9 @@ class CommonView(View): if rattail_config and rattail_config.production(): config.add_exception_view(cls, attr='exception', renderer='/exception.mako') + # permissions + config.add_tailbone_permission_group('common', "(common)", overwrite=False) + # home config.add_route('home', '/') config.add_view(cls, attr='home', route_name='home', renderer='/home.mako') @@ -162,6 +179,12 @@ class CommonView(View): config.add_route('feedback', '/feedback') config.add_view(cls, attr='feedback', route_name='feedback', renderer='/feedback.mako') + # consume batch ID + config.add_tailbone_permission('common', 'common.consume_batch_id', + "Consume new Batch ID") + config.add_route('consume_batch_id', '/consume-batch-id') + config.add_view(cls, attr='consume_batch_id', route_name='consume_batch_id') + # bogus error config.add_route('bogus_error', '/bogus-error') config.add_view(cls, attr='bogus_error', route_name='bogus_error', permission='errors.bogus') From f20a40e8189de1a57bb421b9a8321b722805a9f0 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 2 Aug 2017 20:40:02 -0500 Subject: [PATCH 0382/3196] Add some links to various grid columns --- tailbone/views/customers.py | 2 ++ tailbone/views/master2.py | 2 ++ tailbone/views/people.py | 2 ++ tailbone/views/roles.py | 1 + tailbone/views/users.py | 3 +++ 5 files changed, 10 insertions(+) diff --git a/tailbone/views/customers.py b/tailbone/views/customers.py index 5f28b672..df0650c9 100644 --- a/tailbone/views/customers.py +++ b/tailbone/views/customers.py @@ -85,6 +85,8 @@ class CustomersView(MasterView): g.set_label('phone', "Phone Number") g.set_label('email', "Email Address") + g.set_link('name') + def get_mobile_data(self, session=None): # TODO: hacky! return self.get_data(session=session).order_by(model.Customer.name) diff --git a/tailbone/views/master2.py b/tailbone/views/master2.py index 68483f31..cac8cee9 100644 --- a/tailbone/views/master2.py +++ b/tailbone/views/master2.py @@ -410,6 +410,8 @@ class MasterView2(MasterView): g.set_label('issued_at', "Changed") g.set_label('user', "Changed by") g.set_label('remote_addr', "IP Address") + # TODO: why does this render '#' as url? + # g.set_link('issued_at') def render_version_comment(self, transaction, column): return transaction.meta.get('comment', "") diff --git a/tailbone/views/people.py b/tailbone/views/people.py index b2a6147a..b81f8558 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -103,6 +103,8 @@ class PeopleView(MasterView): g.set_label('email', "Email Address") g.set_label('customer_id', "Customer ID") + g.set_link('display_name') + def get_instance(self): # TODO: I don't recall why this fallback check for a vendor contact # exists here, but leaving it intact for now. diff --git a/tailbone/views/roles.py b/tailbone/views/roles.py index 7432cfa7..c5e55765 100644 --- a/tailbone/views/roles.py +++ b/tailbone/views/roles.py @@ -56,6 +56,7 @@ class RolesView(PrincipalMasterView): g.filters['name'].default_active = True g.filters['name'].default_verb = 'contains' g.default_sortkey = 'name' + g.set_link('name') def _preconfigure_fieldset(self, fs): fs.append(PermissionsField('permissions')) diff --git a/tailbone/views/users.py b/tailbone/views/users.py index ece76bf8..9f55c225 100644 --- a/tailbone/views/users.py +++ b/tailbone/views/users.py @@ -174,6 +174,9 @@ class UsersView(PrincipalMasterView): g.set_label('person', "Person's Name") + g.set_link('username') + g.set_link('person') + def _preconfigure_fieldset(self, fs): fs.username.set(renderer=forms.renderers.StrippedTextFieldRenderer, validate=unique_username) fs.person.set(renderer=forms.renderers.PersonFieldRenderer, options=[]) From d1aaac5b16f3715140bb27aa07e8d45cbd7db41c Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 3 Aug 2017 17:06:26 -0500 Subject: [PATCH 0383/3196] Don't assume all rows belong to a batch whooops.. --- tailbone/views/master.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 3c59e2fe..8ff3ab9a 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -1376,8 +1376,7 @@ class MasterView(View): parent = self.get_parent(row) return self.render_to_response('view_row', { 'instance': row, - 'batch': row.batch, - 'instance_title': self.get_instance_title(row.batch), + 'instance_title': self.get_instance_title(parent), 'instance_url': self.get_action_url('view', parent), 'instance_editable': self.row_editable(row), 'instance_deletable': self.row_deletable(row), From bd3d948bf48ec4835790c8b64f7e486f0eb6e32d Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 3 Aug 2017 17:11:05 -0500 Subject: [PATCH 0384/3196] Update changelog --- CHANGES.rst | 20 ++++++++++++++++++++ tailbone/_version.py | 2 +- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 7f1df033..fb18d54b 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,26 @@ CHANGELOG ========= +0.6.15 (2017-08-03) +------------------- + +* Allow product field renderer to suppress hyperlink + +* Add 'data-uuid' attr for mobile grid list items, if applicable + +* Initial (partial) support for mobile ordering + +* Some tweaks to ordering batch views + +* Fix bug when request.user becomes unattached from session (?) + +* Add view for consuming new batch ID + +* Add some links to various grid columns + +* Fix bug in master view_row + + 0.6.14 (2017-08-01) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 43d6bd5a..3ff4643f 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.6.14' +__version__ = '0.6.15' From ea7eb475519c0251221a6fc49fcdacb6c9d079d1 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 3 Aug 2017 19:16:53 -0500 Subject: [PATCH 0385/3196] Add auto-links for most grids probably still missing some yet? --- tailbone/grids/core.py | 2 +- tailbone/views/bouncer.py | 3 +++ tailbone/views/brands.py | 2 ++ tailbone/views/categories.py | 3 +++ tailbone/views/customers.py | 2 ++ tailbone/views/departments.py | 2 ++ tailbone/views/depositlinks.py | 2 ++ tailbone/views/exports.py | 2 ++ tailbone/views/labels/profiles.py | 2 ++ tailbone/views/people.py | 2 ++ tailbone/views/reportcodes.py | 2 ++ tailbone/views/reports.py | 4 ++++ tailbone/views/taxes.py | 2 ++ tailbone/views/tempmon/clients.py | 4 ++++ tailbone/views/tempmon/probes.py | 4 ++++ tailbone/views/tempmon/readings.py | 3 +++ tailbone/views/trainwreck.py | 6 ++++++ tailbone/views/vendors/core.py | 3 +++ 18 files changed, 49 insertions(+), 1 deletion(-) diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index fd0ac595..f4daedc1 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -977,7 +977,7 @@ class CustomWebhelpersGrid(webhelpers2_grid.Grid): if hasattr(record, 'uuid'): attrs['data_uuid'] = record.uuid return HTML.tag('li', tags.link_to(value, url), **attrs) - if self.linked_columns and column_name in self.linked_columns: + if self.linked_columns and column_name in self.linked_columns and value: url = self.url_generator(record, i) value = tags.link_to(value, url) class_name = 'c{} {}'.format(column_number, column_name) diff --git a/tailbone/views/bouncer.py b/tailbone/views/bouncer.py index 542f71b6..72674890 100644 --- a/tailbone/views/bouncer.py +++ b/tailbone/views/bouncer.py @@ -87,6 +87,9 @@ class EmailBouncesView(MasterView): g.set_label('bounce_recipient_address', "Bounced To") g.set_label('intended_recipient_address', "Intended For") + g.set_link('bounced') + g.set_link('intended_recipient_address') + def configure_fieldset(self, fs): bounce = fs.model handler = self.get_handler(bounce) diff --git a/tailbone/views/brands.py b/tailbone/views/brands.py index cb24279c..407d16f7 100644 --- a/tailbone/views/brands.py +++ b/tailbone/views/brands.py @@ -43,9 +43,11 @@ class BrandsView(MasterView): ] def configure_grid(self, g): + super(BrandsView, self).configure_grid(g) g.filters['name'].default_active = True g.filters['name'].default_verb = 'contains' g.default_sortkey = 'name' + g.set_link('name') def configure_fieldset(self, fs): fs.configure( diff --git a/tailbone/views/categories.py b/tailbone/views/categories.py index 5376776b..79082913 100644 --- a/tailbone/views/categories.py +++ b/tailbone/views/categories.py @@ -52,6 +52,9 @@ class CategoriesView(MasterView): g.filters['name'].default_active = True g.filters['name'].default_verb = 'contains' g.default_sortkey = 'code' + g.set_link('code') + g.set_link('number') + g.set_link('name') def configure_fieldset(self, fs): fs.configure( diff --git a/tailbone/views/customers.py b/tailbone/views/customers.py index df0650c9..96802767 100644 --- a/tailbone/views/customers.py +++ b/tailbone/views/customers.py @@ -85,6 +85,8 @@ class CustomersView(MasterView): g.set_label('phone', "Phone Number") g.set_label('email', "Email Address") + g.set_link('id') + g.set_link('number') g.set_link('name') def get_mobile_data(self, session=None): diff --git a/tailbone/views/departments.py b/tailbone/views/departments.py index 8f2a2760..d4ddcbdf 100644 --- a/tailbone/views/departments.py +++ b/tailbone/views/departments.py @@ -51,6 +51,8 @@ class DepartmentsView(MasterView): g.filters['name'].default_active = True g.filters['name'].default_verb = 'contains' g.default_sortkey = 'number' + g.set_link('number') + g.set_link('name') def configure_fieldset(self, fs): fs.configure( diff --git a/tailbone/views/depositlinks.py b/tailbone/views/depositlinks.py index b59bcef2..6fa7acd5 100644 --- a/tailbone/views/depositlinks.py +++ b/tailbone/views/depositlinks.py @@ -52,6 +52,8 @@ class DepositLinksView(MasterView): g.filters['description'].default_verb = 'contains' g.default_sortkey = 'code' g.set_type('amount', 'currency') + g.set_link('code') + g.set_link('description') def configure_fieldset(self, fs): fs.configure( diff --git a/tailbone/views/exports.py b/tailbone/views/exports.py index b24ff7f8..4d121a0b 100644 --- a/tailbone/views/exports.py +++ b/tailbone/views/exports.py @@ -77,6 +77,8 @@ class ExportMasterView(MasterView): g.set_label('id', "ID") g.set_label('created_by', "Created by") + g.set_link('id') + def render_id(self, export, column): return export.id_str diff --git a/tailbone/views/labels/profiles.py b/tailbone/views/labels/profiles.py index 73fd7cb8..3c06d8c8 100644 --- a/tailbone/views/labels/profiles.py +++ b/tailbone/views/labels/profiles.py @@ -55,6 +55,8 @@ class ProfilesView(MasterView): super(ProfilesView, self).configure_grid(g) g.default_sortkey = 'ordinal' g.set_type('visible', 'boolean') + g.set_link('code') + g.set_link('description') def configure_fieldset(self, fs): fs.printer_spec.set(renderer=forms.renderers.StrippedTextFieldRenderer) diff --git a/tailbone/views/people.py b/tailbone/views/people.py index b81f8558..a049be6a 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -104,6 +104,8 @@ class PeopleView(MasterView): g.set_label('customer_id', "Customer ID") g.set_link('display_name') + g.set_link('first_name') + g.set_link('last_name') def get_instance(self): # TODO: I don't recall why this fallback check for a vendor contact diff --git a/tailbone/views/reportcodes.py b/tailbone/views/reportcodes.py index 7aa04751..59646cfc 100644 --- a/tailbone/views/reportcodes.py +++ b/tailbone/views/reportcodes.py @@ -49,6 +49,8 @@ class ReportCodesView(MasterView): g.filters['name'].default_active = True g.filters['name'].default_verb = 'contains' g.default_sortkey = 'code' + g.set_link('code') + g.set_link('name') def configure_fieldset(self, fs): fs.configure( diff --git a/tailbone/views/reports.py b/tailbone/views/reports.py index 2a759168..ac349138 100644 --- a/tailbone/views/reports.py +++ b/tailbone/views/reports.py @@ -212,6 +212,10 @@ class ReportOutputView(ExportMasterView): 'created_by', ] + def configure_grid(self, g): + super(ReportOutputView, self).configure_grid(g) + g.set_link('filename') + def _preconfigure_fieldset(self, fs): super(ReportOutputView, self)._preconfigure_fieldset(fs) if self.viewing: diff --git a/tailbone/views/taxes.py b/tailbone/views/taxes.py index 2cfbbbff..75485342 100644 --- a/tailbone/views/taxes.py +++ b/tailbone/views/taxes.py @@ -51,6 +51,8 @@ class TaxesView(MasterView): g.filters['description'].default_active = True g.filters['description'].default_verb = 'contains' g.default_sortkey = 'code' + g.set_link('code') + g.set_link('description') def configure_fieldset(self, fs): fs.configure( diff --git a/tailbone/views/tempmon/clients.py b/tailbone/views/tempmon/clients.py index c6909275..7f275565 100644 --- a/tailbone/views/tempmon/clients.py +++ b/tailbone/views/tempmon/clients.py @@ -91,6 +91,10 @@ class TempmonClientView(MasterView): g.set_label('config_key', "Key") + g.set_link('config_key') + g.set_link('hostname') + g.set_link('location') + def _preconfigure_fieldset(self, fs): fs.config_key.set(validate=unique_config_key) fs.probes.set(renderer=ProbesFieldRenderer) diff --git a/tailbone/views/tempmon/probes.py b/tailbone/views/tempmon/probes.py index 526bec1b..75d6540c 100644 --- a/tailbone/views/tempmon/probes.py +++ b/tailbone/views/tempmon/probes.py @@ -79,6 +79,10 @@ class TempmonProbeView(MasterView): g.set_label('config_key', "Key") + g.set_link('client') + g.set_link('config_key') + g.set_link('description') + def _preconfigure_fieldset(self, fs): fs.config_key.set(validate=unique_config_key) fs.client.set(label="TempMon Client", renderer=ClientFieldRenderer) diff --git a/tailbone/views/tempmon/readings.py b/tailbone/views/tempmon/readings.py index cd45a4c9..fcd771e9 100644 --- a/tailbone/views/tempmon/readings.py +++ b/tailbone/views/tempmon/readings.py @@ -81,6 +81,9 @@ class TempmonReadingView(MasterView): g.set_renderer('client_key', self.render_client_key) g.set_renderer('client_host', self.render_client_host) + g.set_link('probe') + g.set_link('taken') + def render_client_key(self, reading, column): return reading.client.config_key diff --git a/tailbone/views/trainwreck.py b/tailbone/views/trainwreck.py index f41087c9..ba363cd3 100644 --- a/tailbone/views/trainwreck.py +++ b/tailbone/views/trainwreck.py @@ -92,6 +92,12 @@ class TransactionView(MasterView): g.set_label('receipt_number', "Receipt No.") g.set_label('customer_id', "Customer ID") + g.set_link('start_time') + g.set_link('receipt_number') + g.set_link('customer_id') + g.set_link('customer_name') + g.set_link('total') + def _preconfigure_fieldset(self, fs): fs.system.set(renderer=forms.renderers.EnumFieldRenderer(self.enum.TRAINWRECK_SYSTEM)) fs.terminal_id.set(label="Terminal") diff --git a/tailbone/views/vendors/core.py b/tailbone/views/vendors/core.py index 7976a732..16ee11dd 100644 --- a/tailbone/views/vendors/core.py +++ b/tailbone/views/vendors/core.py @@ -59,6 +59,9 @@ class VendorsView(MasterView): g.set_label('phone', "Phone Number") g.set_label('email', "Email Address") + g.set_link('id') + g.set_link('name') + def configure_fieldset(self, fs): fs.append(forms.AssociationProxyField('contact')) fs.configure( From b4cabadcd9e991902fb9bcf8f1a83cf0ffa90436 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 4 Aug 2017 10:05:43 -0500 Subject: [PATCH 0386/3196] Fix row highlighting for sources panel on product view --- tailbone/templates/products/view.mako | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tailbone/templates/products/view.mako b/tailbone/templates/products/view.mako index 5abc4d6d..4be1a675 100644 --- a/tailbone/templates/products/view.mako +++ b/tailbone/templates/products/view.mako @@ -188,8 +188,8 @@ Status - % for cost in instance.costs: - + % for i, cost in enumerate(instance.costs, 1): + ${'X' if cost.preference == 1 else ''} ${cost.vendor} ${cost.code or ''} From 97fb74f09390c38df58da9fceef4a960a185adec Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 4 Aug 2017 10:06:43 -0500 Subject: [PATCH 0387/3196] 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 fb18d54b..8aed8027 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,14 @@ CHANGELOG ========= +0.6.16 (2017-08-04) +------------------- + +* Add auto-links for most grids + +* Fix row highlighting for sources panel on product view + + 0.6.15 (2017-08-03) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 3ff4643f..62ceaef4 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.6.15' +__version__ = '0.6.16' From dce0efb5faf6c8511bdd526ed096d4657da17ec3 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 4 Aug 2017 11:55:53 -0500 Subject: [PATCH 0388/3196] Various view tweaks --- tailbone/views/customers.py | 4 ++++ tailbone/views/datasync.py | 1 + tailbone/views/people.py | 9 +++++++-- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/tailbone/views/customers.py b/tailbone/views/customers.py index 96802767..4ef932ca 100644 --- a/tailbone/views/customers.py +++ b/tailbone/views/customers.py @@ -130,6 +130,8 @@ class CustomersView(MasterView): fs.email_preference.set(renderer=forms.EnumFieldRenderer(self.enum.EMAIL_PREFERENCE)) fs.append(forms.AssociationProxyField('people', renderer=forms.renderers.PeopleFieldRenderer, readonly=True)) + fs.active_in_pos.set(label="Active in POS") + fs.active_in_pos_sticky.set(label="Always Active in POS") def configure_fieldset(self, fs): fs.configure( @@ -142,6 +144,8 @@ class CustomersView(MasterView): fs.default_email, fs.email_preference, fs.people, + fs.active_in_pos, + fs.active_in_pos_sticky, ]) def configure_mobile_fieldset(self, fs): diff --git a/tailbone/views/datasync.py b/tailbone/views/datasync.py index 09e08635..f9f86e6d 100644 --- a/tailbone/views/datasync.py +++ b/tailbone/views/datasync.py @@ -60,6 +60,7 @@ class DataSyncChangesView(MasterView): def configure_grid(self, g): super(DataSyncChangesView, self).configure_grid(g) g.default_sortkey = 'obtained' + g.set_type('obtained', 'datetime') def restart(self): # TODO: Add better validation (e.g. CSRF) here? diff --git a/tailbone/views/people.py b/tailbone/views/people.py index a049be6a..5660c5ca 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -26,6 +26,7 @@ Person Views from __future__ import unicode_literals, absolute_import +import six import sqlalchemy as sa import formalchemy as fa @@ -48,8 +49,12 @@ class CustomersFieldRenderer(fa.FieldRenderer): items = [] for customer in customers: customer = customer.customer - items.append(HTML.tag('li', c=tags.link_to('{} {}'.format(customer.id, customer), - self.request.route_url('customers.view', uuid=customer.uuid)))) + text = six.text_type(customer) + if customer.id: + text = "({}) {}".format(customer.id, text) + elif customer.number: + text = "({}) {}".format(customer.number, text) + items.append(HTML.tag('li', c=tags.link_to(text, self.request.route_url('customers.view', uuid=customer.uuid)))) return HTML.tag('ul', c=items) From f4d4dcbdd21a4cc758f093c5cee0f3572737ca23 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 4 Aug 2017 12:00:12 -0500 Subject: [PATCH 0389/3196] 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 8aed8027..14a829d0 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.6.17 (2017-08-04) +------------------- + +* Various view tweaks + + 0.6.16 (2017-08-04) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 62ceaef4..ae489c0d 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.6.16' +__version__ = '0.6.17' From d8be651e9594a18af785b39e077b2ba31fef874c Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 4 Aug 2017 15:15:43 -0500 Subject: [PATCH 0390/3196] Make tempmon readings bulk-deletable although if there are enough of them, it can still suck.. need to add a progress bar for bulk-delete at some point.. --- tailbone/views/tempmon/readings.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tailbone/views/tempmon/readings.py b/tailbone/views/tempmon/readings.py index fcd771e9..5181c41e 100644 --- a/tailbone/views/tempmon/readings.py +++ b/tailbone/views/tempmon/readings.py @@ -46,6 +46,7 @@ class TempmonReadingView(MasterView): url_prefix = '/tempmon/readings' creatable = False editable = False + bulk_deletable = True grid_columns = [ 'client_key', From 3205d61ba640d72272224b0f7801fb524f655074 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 4 Aug 2017 16:11:45 -0500 Subject: [PATCH 0391/3196] Add progress support for bulk deletion plus bulk-delete all tempmon readings when deleting client or probe --- tailbone/views/core.py | 4 ++ tailbone/views/master.py | 63 ++++++++++++++++++++++++++----- tailbone/views/tempmon/clients.py | 13 +++++++ tailbone/views/tempmon/core.py | 5 +++ tailbone/views/tempmon/probes.py | 13 +++++++ 5 files changed, 88 insertions(+), 10 deletions(-) diff --git a/tailbone/views/core.py b/tailbone/views/core.py index 59db1d3a..9efdb90f 100644 --- a/tailbone/views/core.py +++ b/tailbone/views/core.py @@ -29,6 +29,7 @@ from __future__ import unicode_literals, absolute_import import os from rattail.db import model +from rattail.util import progress_loop from pyramid import httpexceptions from pyramid.renderers import render_to_response @@ -86,6 +87,9 @@ class View(object): """ return httpexceptions.HTTPFound(location=url, **kwargs) + def progress_loop(self, func, items, factory, *args, **kwargs): + return progress_loop(func, items, factory, *args, **kwargs) + def render_progress(self, kwargs): """ Render the progress page, with given kwargs as context. diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 8ff3ab9a..0ba720fb 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -32,9 +32,11 @@ from sqlalchemy import orm import sqlalchemy_continuum as continuum +from rattail.db import Session as RattailSession from rattail.db.continuum import model_transaction_query from rattail.util import prettify from rattail.time import localtime +from rattail.threads import Thread import formalchemy as fa from pyramid import httpexceptions @@ -43,6 +45,7 @@ from webhelpers2.html import HTML, tags from tailbone import forms, grids from tailbone.views import View +from tailbone.progress import SessionProgress class MasterView(View): @@ -702,20 +705,60 @@ class MasterView(View): Delete all records matching the current grid query """ if self.request.method == 'POST': - query = self.get_effective_query(sortable=False) - count = query.count() - self.bulk_delete_objects(query) - self.request.session.flash("Deleted {:,d} {}".format(count, self.get_model_title_plural())) + key = '{}.bulk_delete'.format(self.model_class.__tablename__) + objects = self.get_effective_data() + progress = SessionProgress(self.request, key) + thread = Thread(target=self.bulk_delete_thread, args=(objects, progress)) + thread.start() + return self.render_progress({ + 'key': key, + 'cancel_url': self.get_index_url(), + 'cancel_msg': "Bulk deletion was canceled", + }) else: self.request.session.flash("Sorry, you must POST to do a bulk delete operation") return self.redirect(self.get_index_url()) - def bulk_delete_objects(self, query): - # TODO: sometimes the first makes sense, and would be preferred for - # efficiency's sake. might even need to add progress to latter? - # query.delete(synchronize_session=False) - for obj in query: - self.Session.delete(obj) + def bulk_delete_objects(self, session, objects, progress=None): + + def delete(obj, i): + session.delete(obj) + + self.progress_loop(delete, objects, progress, + message="Deleting objects") + + def get_bulk_delete_session(self): + return RattailSession() + + def bulk_delete_thread(self, objects, progress): + """ + Thread target for bulk-deleting current results, with progress. + """ + session = self.get_bulk_delete_session() + objects = objects.with_session(session).all() + try: + self.bulk_delete_objects(session, objects, progress=progress) + + # If anything goes wrong, rollback and log the error etc. + except Exception as error: + session.rollback() + log.exception("execution failed for batch results") + session.close() + if progress: + progress.session.load() + progress.session['error'] = True + progress.session['error_msg'] = "Bulk deletion failed: {}: {}".format(type(error).__name__, error) + progress.session.save() + + # If no error, check result flag (false means user canceled). + else: + session.commit() + session.close() + if progress: + progress.session.load() + progress.session['complete'] = True + progress.session['success_url'] = self.get_index_url() + progress.session.save() def get_merge_fields(self): if hasattr(self, 'merge_fields'): diff --git a/tailbone/views/tempmon/clients.py b/tailbone/views/tempmon/clients.py index 7f275565..2144cf14 100644 --- a/tailbone/views/tempmon/clients.py +++ b/tailbone/views/tempmon/clients.py @@ -114,6 +114,19 @@ class TempmonClientView(MasterView): del fs.probes del fs.online + def delete_instance(self, client): + # bulk-delete all readings first + readings = self.Session.query(tempmon.Reading)\ + .filter(tempmon.Reading.client == client) + readings.delete(synchronize_session=False) + self.Session.flush() + self.Session.refresh(client) + + # Flush immediately to force any pending integrity errors etc.; that + # way we don't set flash message until we know we have success. + self.Session.delete(client) + self.Session.flush() + def restartable_client(self, client): return True diff --git a/tailbone/views/tempmon/core.py b/tailbone/views/tempmon/core.py index 6b605206..545eb3cb 100644 --- a/tailbone/views/tempmon/core.py +++ b/tailbone/views/tempmon/core.py @@ -26,6 +26,8 @@ Common stuff for tempmon views from __future__ import unicode_literals, absolute_import +from rattail_tempmon.db import Session as RawTempmonSession + from formalchemy.fields import SelectFieldRenderer from webhelpers2.html import tags @@ -39,6 +41,9 @@ class MasterView(views.MasterView2): """ Session = TempmonSession + def get_bulk_delete_session(self): + return RawTempmonSession() + class ClientFieldRenderer(SelectFieldRenderer): diff --git a/tailbone/views/tempmon/probes.py b/tailbone/views/tempmon/probes.py index 75d6540c..4add1ca2 100644 --- a/tailbone/views/tempmon/probes.py +++ b/tailbone/views/tempmon/probes.py @@ -109,6 +109,19 @@ class TempmonProbeView(MasterView): if self.creating or self.editing: del fs.status + def delete_instance(self, probe): + # bulk-delete all readings first + readings = self.Session.query(tempmon.Reading)\ + .filter(tempmon.Reading.probe == probe) + readings.delete(synchronize_session=False) + self.Session.flush() + self.Session.refresh(probe) + + # Flush immediately to force any pending integrity errors etc.; that + # way we don't set flash message until we know we have success. + self.Session.delete(probe) + self.Session.flush() + def includeme(config): TempmonProbeView.defaults(config) From ba877eb3e91be328b75e26edf92c2e442dbf8508 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 4 Aug 2017 16:13:08 -0500 Subject: [PATCH 0392/3196] 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 14a829d0..9182eaaa 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,14 @@ CHANGELOG ========= +0.6.18 (2017-08-04) +------------------- + +* Add progress support for bulk deletion + +* Make tempmon readings bulk-deletable + + 0.6.17 (2017-08-04) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index ae489c0d..4e140ce1 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.6.17' +__version__ = '0.6.18' From eaa47dbd8aa5813e45f361306bd9c6188d71de0e Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 4 Aug 2017 16:20:18 -0500 Subject: [PATCH 0393/3196] Add rattail-tempmon dependency for tox tests --- tox.ini | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tox.ini b/tox.ini index 9fcbd7c7..f1e1797f 100644 --- a/tox.ini +++ b/tox.ini @@ -8,13 +8,13 @@ deps = mock nose commands = - pip install --upgrade Tailbone rattail[bouncer] + pip install --upgrade Tailbone rattail[bouncer] rattail-tempmon nosetests {posargs} [testenv:coverage] basepython = python commands = - pip install --upgrade Tailbone rattail[bouncer] + pip install --upgrade Tailbone rattail[bouncer] rattail-tempmon 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 rattail[bouncer] + pip install --upgrade Tailbone rattail[bouncer] rattail-tempmon sphinx-build -b html -d {envtmpdir}/doctrees -W -T . {envtmpdir}/docs From 82e8f49dd135c74e0f2922d96f8bdae52c0444d3 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 4 Aug 2017 16:48:33 -0500 Subject: [PATCH 0394/3196] Record basic user login/logout events --- tailbone/auth.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tailbone/auth.py b/tailbone/auth.py index 8716552b..2f8f4782 100644 --- a/tailbone/auth.py +++ b/tailbone/auth.py @@ -28,6 +28,8 @@ from __future__ import unicode_literals, absolute_import import logging +from rattail import enum +from rattail.db import model from rattail.util import prettify, NOTSET from zope.interface import implementer @@ -45,6 +47,7 @@ def login_user(request, user, timeout=NOTSET): Perform the steps necessary to login the given user. Note that this returns a ``headers`` dict which you should pass to the redirect. """ + user.record_event(enum.USER_EVENT_LOGIN) headers = remember(request, user.uuid) if timeout is NOTSET: timeout = session_timeout_for_user(user) @@ -58,6 +61,9 @@ def logout_user(request): Perform the logout action for the given request. Note that this returns a ``headers`` dict which you should pass to the redirect. """ + user = request.user + if user: + user.record_event(enum.USER_EVENT_LOGOUT) request.session.delete() request.session.invalidate() headers = forget(request) From 2f0f3fa46308dd7270f72453591c459fa96d8262 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 4 Aug 2017 17:14:38 -0500 Subject: [PATCH 0395/3196] Expose UserEvent table in UI normal table access, plus per-user row grid --- tailbone/views/users.py | 68 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/tailbone/views/users.py b/tailbone/views/users.py index 9f55c225..53649789 100644 --- a/tailbone/views/users.py +++ b/tailbone/views/users.py @@ -40,6 +40,7 @@ from webhelpers2.html import HTML, tags from tailbone import forms from tailbone.db import Session +from tailbone.views import MasterView2 as MasterView from tailbone.views.principal import PrincipalMasterView @@ -128,6 +129,8 @@ class UsersView(PrincipalMasterView): Master view for the User model. """ model_class = model.User + has_rows = True + model_row_class = model.UserEvent has_versions = True mergeable = True @@ -149,6 +152,11 @@ class UsersView(PrincipalMasterView): 'person', ] + row_grid_columns = [ + 'type_code', + 'occurred', + ] + def query(self, session): return session.query(model.User)\ .options(orm.joinedload(model.User.person)) @@ -217,6 +225,20 @@ class UsersView(PrincipalMasterView): return user.username != 'chuck' return True + def get_row_data(self, user): + return self.Session.query(model.UserEvent)\ + .filter(model.UserEvent.user == user) + + def configure_row_grid(self, g): + super(UsersView, self).configure_row_grid(g) + g.width = 'half' + g.filterable = False + g.default_sortkey = 'occurred' + g.default_sortdir = 'desc' + g.set_enum('type_code', self.enum.USER_EVENT) + g.set_label('type_code', "Event Type") + g.main_actions = [] + def get_version_child_classes(self): return [ (model.UserRole, 'user_uuid'), @@ -262,5 +284,51 @@ class UsersView(PrincipalMasterView): self.Session.delete(removing) +class UserEventsView(MasterView): + """ + Master view for all user events + """ + model_class = model.UserEvent + url_prefix = '/user-events' + viewable = False + creatable = False + editable = False + deletable = False + + grid_columns = [ + 'user', + 'person', + 'type_code', + 'occurred', + ] + + def get_data(self, session=None): + query = super(UserEventsView, self).get_data(session=session) + return query.join(model.User) + + def configure_grid(self, g): + super(UserEventsView, self).configure_grid(g) + g.set_joiner('person', lambda q: q.outerjoin(model.Person)) + g.set_sorter('user', model.User.username) + g.set_sorter('person', model.Person.display_name) + g.filters['user'] = g.make_filter('user', model.User.username) + g.filters['person'] = g.make_filter('person', model.Person.display_name) + g.set_enum('type_code', self.enum.USER_EVENT) + g.set_type('occurred', 'datetime') + g.set_renderer('user', self.render_user) + g.set_renderer('person', self.render_person) + g.default_sortkey = 'occurred' + g.default_sortdir = 'desc' + g.set_label('user', "Username") + g.set_label('type_code', "Event Type") + + def render_user(self, event, column): + return event.user.username + + def render_person(self, event, column): + return event.user.person.display_name + + def includeme(config): UsersView.defaults(config) + UserEventsView.defaults(config) From 54a364aa0c183e589e81804662837ed2af53dde1 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 4 Aug 2017 18:18:08 -0500 Subject: [PATCH 0396/3196] 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 9182eaaa..94c5af33 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,14 @@ CHANGELOG ========= +0.6.19 (2017-08-04) +------------------- + +* Record basic user login/logout events + +* Expose UserEvent table in UI + + 0.6.18 (2017-08-04) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 4e140ce1..0893f189 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.6.18' +__version__ = '0.6.19' From 941ce1a9cb2b92921e827efbdfbdce18f2af5c44 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 5 Aug 2017 16:11:56 -0500 Subject: [PATCH 0397/3196] Record become/stop root user events --- tailbone/views/auth.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tailbone/views/auth.py b/tailbone/views/auth.py index 1f7dabde..de6e33ee 100644 --- a/tailbone/views/auth.py +++ b/tailbone/views/auth.py @@ -181,6 +181,7 @@ class AuthenticationView(View): """ if not self.request.is_admin: raise HTTPForbidden() + self.request.user.record_event(self.enum.USER_EVENT_BECOME_ROOT) self.request.session['is_root'] = True self.request.session.flash("You have been elevated to 'root' and now have full system access") return self.redirect(self.request.get_referrer()) @@ -191,6 +192,7 @@ class AuthenticationView(View): """ if not self.request.is_admin: raise HTTPForbidden() + self.request.user.record_event(self.enum.USER_EVENT_STOP_ROOT) self.request.session['is_root'] = False self.request.session.flash("Your normal system access has been restored") return self.redirect(self.request.get_referrer()) From f476c696fd27c45494fd62921dfdf111899b062e Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 5 Aug 2017 16:12:06 -0500 Subject: [PATCH 0398/3196] Make datasync changes bulk-deletable --- tailbone/templates/datasync/changes/index.mako | 1 + tailbone/views/datasync.py | 1 + 2 files changed, 2 insertions(+) diff --git a/tailbone/templates/datasync/changes/index.mako b/tailbone/templates/datasync/changes/index.mako index 44033370..43fdfcc0 100644 --- a/tailbone/templates/datasync/changes/index.mako +++ b/tailbone/templates/datasync/changes/index.mako @@ -17,6 +17,7 @@ <%def name="grid_tools()"> + ${parent.grid_tools()} ${h.form(url('datasync.restart'), name='restart-datasync')} ${h.csrf_token(request)} diff --git a/tailbone/views/datasync.py b/tailbone/views/datasync.py index f9f86e6d..b6080bf8 100644 --- a/tailbone/views/datasync.py +++ b/tailbone/views/datasync.py @@ -47,6 +47,7 @@ class DataSyncChangesView(MasterView): creatable = False editable = False + bulk_deletable = True grid_columns = [ 'source', From f5688f1f909851f7587ab0a156242767cabc443e Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 5 Aug 2017 22:07:49 -0500 Subject: [PATCH 0399/3196] Add basic support for performing / tracking app upgrades also add `MasterView.executable` and friends --- tailbone/forms2/core.py | 24 +++- tailbone/static/__init__.py | 5 +- tailbone/static/js/tailbone.js | 3 + tailbone/templates/master/create.mako | 13 +- tailbone/templates/master/edit.mako | 17 ++- tailbone/templates/upgrades/view.mako | 19 +++ tailbone/views/__init__.py | 1 + tailbone/views/batch/core.py | 17 +-- tailbone/views/master.py | 112 ++++++++++++++- tailbone/views/master3.py | 4 + tailbone/views/upgrades.py | 197 ++++++++++++++++++++++++++ 11 files changed, 386 insertions(+), 26 deletions(-) create mode 100644 tailbone/templates/upgrades/view.mako create mode 100644 tailbone/views/upgrades.py diff --git a/tailbone/forms2/core.py b/tailbone/forms2/core.py index 40dbc44d..b736df39 100644 --- a/tailbone/forms2/core.py +++ b/tailbone/forms2/core.py @@ -33,7 +33,7 @@ import sqlalchemy as sa from sqlalchemy import orm from sqlalchemy.ext.associationproxy import AssociationProxy, ASSOCIATION_PROXY -from rattail.util import prettify +from rattail.util import prettify, pretty_boolean import colander from colanderalchemy import SQLAlchemySchemaNode @@ -42,6 +42,8 @@ from deform import widget as dfwidget from pyramid.renderers import render from webhelpers2.html import tags, HTML +from tailbone.util import raw_datetime + log = logging.getLogger(__name__) @@ -176,7 +178,7 @@ class Form(object): model_instance=None, model_class=None, labels={}, renderers={}, widgets={}, action_url=None, cancel_url=None): - self.fields = fields + self.fields = list(fields) if fields is not None else None self.schema = schema self.request = request self.readonly = readonly @@ -274,9 +276,15 @@ class Form(object): self.readonly_fields.remove(key) def set_type(self, key, type_): - if type_ == 'codeblock': + if type_ == 'datetime': + self.set_renderer(key, self.render_datetime) + elif type_ == 'boolean': + self.set_renderer(key, self.render_boolean) + elif type_ == 'codeblock': self.set_renderer(key, self.render_codeblock) self.set_widget(key, dfwidget.TextAreaWidget(cols=80, rows=8)) + else: + raise ValueError("unknown type for '{}' field: {}".format(key, type_)) def set_renderer(self, key, renderer): self.renderers[key] = renderer @@ -354,6 +362,16 @@ class Form(object): return "" return six.text_type(value) + def render_datetime(self, record, field_name): + value = self.obtain_value(record, field_name) + if value is None: + return "" + return raw_datetime(self.request.rattail_config, value) + + def render_boolean(self, record, field_name): + value = self.obtain_value(record, field_name) + return pretty_boolean(value) + def render_codeblock(self, record, field_name): value = self.obtain_value(record, field_name) if value is None: diff --git a/tailbone/static/__init__.py b/tailbone/static/__init__.py index 8628dd54..2ad5161a 100644 --- a/tailbone/static/__init__.py +++ b/tailbone/static/__init__.py @@ -1,4 +1,4 @@ -# -*- coding: utf-8 -*- +# -*- coding: utf-8; -*- ################################################################################ # # Rattail -- Retail Software Framework @@ -24,8 +24,9 @@ Static Assets """ -from __future__ import unicode_literals +from __future__ import unicode_literals, absolute_import def includeme(config): config.add_static_view('tailbone', 'tailbone:static') + config.add_static_view('deform', 'deform:static') diff --git a/tailbone/static/js/tailbone.js b/tailbone/static/js/tailbone.js index 334f570e..63141400 100644 --- a/tailbone/static/js/tailbone.js +++ b/tailbone/static/js/tailbone.js @@ -174,6 +174,9 @@ $(function() { $('button, a.button').button(); $('input[type=submit]').button(); $('input[type=reset]').button(); + $('input[type="submit"].autodisable').click(function() { + disable_button(this); + }); /* * enhance dropdowns diff --git a/tailbone/templates/master/create.mako b/tailbone/templates/master/create.mako index 76bee07c..fe52f76b 100644 --- a/tailbone/templates/master/create.mako +++ b/tailbone/templates/master/create.mako @@ -1,4 +1,4 @@ -## -*- coding: utf-8 -*- +## -*- coding: utf-8; -*- <%inherit file="/base.mako" /> <%def name="title()">New ${model_title} @@ -6,6 +6,17 @@ <%def name="extra_javascript()"> ${parent.extra_javascript()} ${self.disable_button_js()} + % if dform is not Undefined: + % for field in dform: + <% resources = field.get_widget_resources() %> + % for path in resources['js']: + ${h.javascript_link(request.static_url(path))} + % endfor + % for path in resources['css']: + ${h.stylesheet_link(request.static_url(path))} + % endfor + % endfor + % endif <%def name="disable_button_js()"> diff --git a/tailbone/templates/master/edit.mako b/tailbone/templates/master/edit.mako index 9599a2c2..cbe88fae 100644 --- a/tailbone/templates/master/edit.mako +++ b/tailbone/templates/master/edit.mako @@ -1,10 +1,10 @@ -## -*- coding: utf-8 -*- +## -*- coding: utf-8; -*- <%inherit file="/base.mako" /> <%def name="title()">Edit ${model_title}: ${instance_title} -<%def name="head_tags()"> - ${parent.head_tags()} +<%def name="extra_javascript()"> + ${parent.extra_javascript()} + % if dform is not Undefined: + % for field in dform: + <% resources = field.get_widget_resources() %> + % for path in resources['js']: + ${h.javascript_link(request.static_url(path))} + % endfor + % for path in resources['css']: + ${h.stylesheet_link(request.static_url(path))} + % endfor + % endfor + % endif <%def name="context_menu_items()"> diff --git a/tailbone/templates/upgrades/view.mako b/tailbone/templates/upgrades/view.mako new file mode 100644 index 00000000..d65e874d --- /dev/null +++ b/tailbone/templates/upgrades/view.mako @@ -0,0 +1,19 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/view.mako" /> + +${parent.body()} + +% if not instance.executed and request.has_perm('{}.execute'.format(permission_prefix)): +
      + % if instance.enabled and not instance.executing: + ${h.form(url('{}.execute'.format(route_prefix), uuid=instance.uuid))} + ${h.csrf_token(request)} + ${h.submit('execute', "Execute this upgrade", class_='autodisable')} + ${h.end_form()} + % elif instance.enabled: + + % else: + + % endif +
      +% endif diff --git a/tailbone/views/__init__.py b/tailbone/views/__init__.py index 6b44f398..d8090a19 100644 --- a/tailbone/views/__init__.py +++ b/tailbone/views/__init__.py @@ -69,6 +69,7 @@ def includeme(config): config.include('tailbone.views.stores') config.include('tailbone.views.subdepartments') config.include('tailbone.views.taxes') + config.include('tailbone.views.upgrades') config.include('tailbone.views.users') config.include('tailbone.views.vendors') diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index fc9ef4c9..6c4f005e 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -69,6 +69,7 @@ class BatchMasterView(MasterView): refresh_after_create = False edit_with_rows = False cloneable = False + executable = True supports_mobile = True mobile_filterable = True mobile_rows_viewable = True @@ -107,13 +108,13 @@ class BatchMasterView(MasterView): kwargs['batch'] = batch kwargs['handler'] = self.handler kwargs['execute_title'] = self.get_execute_title(batch) - kwargs['execute_enabled'] = self.executable(batch) + kwargs['execute_enabled'] = self.instance_executable(batch) if kwargs['execute_enabled'] and self.has_execution_options: kwargs['rendered_execution_options'] = self.render_execution_options(batch) return kwargs def template_kwargs_index(self, **kwargs): - kwargs['execute_enabled'] = self.executable() + kwargs['execute_enabled'] = self.instance_executable(None) if kwargs['execute_enabled'] and self.has_execution_options: kwargs['rendered_execution_options'] = self.render_execution_options() return kwargs @@ -339,7 +340,7 @@ class BatchMasterView(MasterView): 'form': form, 'batch': batch, 'execute_title': self.get_execute_title(batch), - 'execute_enabled': self.executable(batch), + 'execute_enabled': self.instance_executable(batch), } if self.edit_with_rows: @@ -449,7 +450,7 @@ class BatchMasterView(MasterView): def after_edit_row(self, row): self.handler.refresh_row(row) - def executable(self, batch=None): + def instance_executable(self, batch=None): return self.handler.executable(batch) def batch_refreshable(self, batch): @@ -1018,14 +1019,6 @@ class BatchMasterView(MasterView): config.add_view(cls, attr='mobile_mark_pending', route_name='mobile.{}.mark_pending'.format(route_prefix), permission='{}.edit'.format(permission_prefix)) - - # execute batch - config.add_route('{}.execute'.format(route_prefix), '{}/{{uuid}}/execute'.format(url_prefix)) - config.add_view(cls, attr='execute', route_name='{}.execute'.format(route_prefix), - permission='{}.execute'.format(permission_prefix)) - config.add_tailbone_permission(permission_prefix, '{}.execute'.format(permission_prefix), - "Execute {}".format(model_title)) - # execute (multiple) batch results config.add_route('{}.execute_results'.format(route_prefix), '{}/execute-results'.format(url_prefix)) config.add_view(cls, attr='execute_results', route_name='{}.execute_results'.format(route_prefix), diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 0ba720fb..e4e32beb 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -26,21 +26,25 @@ Model Master View from __future__ import unicode_literals, absolute_import +import os +import logging + import six import sqlalchemy as sa from sqlalchemy import orm import sqlalchemy_continuum as continuum -from rattail.db import Session as RattailSession +from rattail.db import model, Session as RattailSession from rattail.db.continuum import model_transaction_query from rattail.util import prettify -from rattail.time import localtime +from rattail.time import localtime #, make_utc from rattail.threads import Thread import formalchemy as fa from pyramid import httpexceptions from pyramid.renderers import get_renderer, render_to_response, render +from pyramid.response import FileResponse from webhelpers2.html import HTML, tags from tailbone import forms, grids @@ -48,6 +52,9 @@ from tailbone.views import View from tailbone.progress import SessionProgress +log = logging.getLogger(__name__) + + class MasterView(View): """ Base "master" view class. All model master views should derive from this. @@ -64,6 +71,7 @@ class MasterView(View): bulk_deletable = False mergeable = False downloadable = False + executable = False supports_mobile = False mobile_creatable = False @@ -236,7 +244,10 @@ class MasterView(View): self.after_create(obj) self.flash_after_create(obj) return self.redirect_after_create(obj) - return self.render_to_response('create', {'form': form}) + context = {'form': form} + if hasattr(form, 'make_deform_form'): + context['dform'] = form.make_deform_form() + return self.render_to_response('create', context) def mobile_create(self): """ @@ -624,6 +635,27 @@ class MasterView(View): self.grid_count = len(data) return self.view(instance) + def download(self): + """ + View for downloading a data file. + """ + obj = self.get_instance() + filename = self.request.GET.get('filename', None) + path = self.download_path(obj, filename) + response = FileResponse(path, request=self.request) + response.content_length = os.path.getsize(path) + content_type = self.download_content_type(path, filename) + if content_type: + response.content_type = six.binary_type(content_type) + filename = os.path.basename(path).encode('ascii', 'replace') + response.content_disposition = b'attachment; filename={}'.format(filename) + return response + + def download_content_type(self, path, filename): + """ + Return a content type for a file download, if known. + """ + def edit(self): """ View for editing an existing model record. @@ -647,11 +679,15 @@ class MasterView(View): self.get_model_title(), self.get_instance_title(instance))) return self.redirect_after_edit(instance) - return self.render_to_response('edit', { + context = { 'instance': instance, 'instance_title': instance_title, 'instance_deletable': self.deletable_instance(instance), - 'form': form}) + 'form': form, + } + if hasattr(form, 'make_deform_form'): + context['dform'] = form.make_deform_form() + return self.render_to_response('edit', context) def validate_form(self, form): return form.validate() @@ -760,6 +796,64 @@ class MasterView(View): progress.session['success_url'] = self.get_index_url() progress.session.save() + def execute(self): + """ + Execute an object. + """ + obj = self.get_instance() + model_title = self.get_model_title() + if self.request.method == 'POST': + + key = '{}.execute'.format(self.get_grid_key()) + kwargs = {'progress': SessionProgress(self.request, key)} + thread = Thread(target=self.execute_thread, args=(obj.uuid, self.request.user.uuid), kwargs=kwargs) + thread.start() + + return self.render_progress({ + 'key': key, + 'cancel_url': self.get_action_url('view', obj), + 'cancel_msg': "{} execution was canceled".format(model_title), + }) + + self.request.session.flash("Sorry, you must POST to execute a {}.".format(model_title), 'error') + return self.redirect(self.get_action_url('view', obj)) + + def execute_thread(self, uuid, user_uuid, progress=None, **kwargs): + """ + Thread target for executing an object. + """ + session = RattailSession() + obj = session.query(self.model_class).get(uuid) + user = session.query(model.User).get(user_uuid) + try: + self.execute_instance(obj, user, progress=progress, **kwargs) + + # If anything goes wrong, rollback and log the error etc. + except Exception as error: + session.rollback() + log.exception("execution failed for object: {}".format(obj)) + session.close() + if progress: + progress.session.load() + progress.session['error'] = True + progress.session['error_msg'] = self.execute_error_message(error) + progress.session.save() + + # If no error, check result flag (false means user canceled). + else: + session.commit() + session.refresh(obj) + success_url = self.get_execute_success_url(obj) + session.close() + if progress: + progress.session.load() + progress.session['complete'] = True + progress.session['success_url'] = success_url + progress.session.save() + + def get_execute_success_url(self, obj, **kwargs): + return self.get_action_url('view', obj, **kwargs) + def get_merge_fields(self): if hasattr(self, 'merge_fields'): return self.merge_fields @@ -1709,6 +1803,14 @@ class MasterView(View): config.add_tailbone_permission(permission_prefix, '{0}.edit'.format(permission_prefix), "Edit {0}".format(model_title)) + # execute + if cls.executable: + config.add_tailbone_permission(permission_prefix, '{}.execute'.format(permission_prefix), + "Execute {}".format(model_title)) + config.add_route('{}.execute'.format(route_prefix), '{}/{{{}}}/execute'.format(url_prefix, model_key)) + config.add_view(cls, attr='execute', route_name='{}.execute'.format(route_prefix), + permission='{}.execute'.format(permission_prefix)) + # delete if cls.deletable: config.add_route('{0}.delete'.format(route_prefix), '{0}/{{{1}}}/delete'.format(url_prefix, model_key)) diff --git a/tailbone/views/master3.py b/tailbone/views/master3.py index e6e0d9a8..459ebfe0 100644 --- a/tailbone/views/master3.py +++ b/tailbone/views/master3.py @@ -124,10 +124,14 @@ class MasterView3(MasterView2): def save_create_form(self, form): self.before_create(form) obj = form.schema.objectify(self.form_deserialized) + self.before_create_flush(obj) self.Session.add(obj) self.Session.flush() return obj + def before_create_flush(self, obj): + pass + def save_edit_form(self, form): obj = form.schema.objectify(self.form_deserialized, context=form.model_instance) self.after_edit(obj) diff --git a/tailbone/views/upgrades.py b/tailbone/views/upgrades.py new file mode 100644 index 00000000..d83b382c --- /dev/null +++ b/tailbone/views/upgrades.py @@ -0,0 +1,197 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2017 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see . +# +################################################################################ +""" +Views for app upgrades +""" + +from __future__ import unicode_literals, absolute_import + +import os + +from sqlalchemy import orm + +from rattail.db import model, Session as RattailSession +from rattail.time import make_utc +from rattail.threads import Thread +from rattail.upgrades import get_upgrade_handler + +from deform import widget as dfwidget +from webhelpers2.html import tags + +from tailbone.views import MasterView3 as MasterView +from tailbone.progress import SessionProgress + + +class UpgradeView(MasterView): + """ + Master view for all user events + """ + model_class = model.Upgrade + executable = True + downloadable = True + + grid_columns = [ + 'created', + 'description', + # 'not_until', + 'enabled', + 'executing', + 'executed', + 'executed_by', + ] + + form_fields = [ + 'description', + # 'not_until', + # 'requirements', + 'notes', + 'created', + 'created_by', + 'enabled', + 'executing', + 'executed', + 'executed_by', + 'stdout_file', + 'stderr_file', + ] + + def __init__(self, request): + super(UpgradeView, self).__init__(request) + self.handler = self.get_handler() + + def get_handler(self): + """ + Returns the ``UpgradeHandler`` instance for the view. The handler + factory for this may be defined by config, e.g.: + + .. code-block:: ini + + [rattail.upgrades] + handler = myapp.upgrades:CustomUpgradeHandler + """ + return get_upgrade_handler(self.rattail_config) + + def configure_grid(self, g): + super(UpgradeView, self).configure_grid(g) + g.set_joiner('executed_by', lambda q: q.join(model.User).outerjoin(model.Person)) + g.set_sorter('executed_by', model.Person.display_name) + g.set_type('created', 'datetime') + g.set_type('executed', 'datetime') + g.default_sortkey = 'created' + g.default_sortdir = 'desc' + g.set_label('executed_by', "Executed by") + g.set_link('created') + g.set_link('description') + # g.set_link('not_until') + g.set_link('executed') + + def configure_form(self, f): + super(UpgradeView, self).configure_form(f) + f.set_type('created', 'datetime') + f.set_type('enabled', 'boolean') + f.set_type('executing', 'boolean') + f.set_type('executed', 'datetime') + # f.set_widget('not_until', dfwidget.DateInputWidget()) + f.set_widget('notes', dfwidget.TextAreaWidget(cols=80, rows=8)) + f.set_renderer('stdout_file', self.render_stdout_file) + f.set_renderer('stderr_file', self.render_stdout_file) + # f.set_readonly('created') + # f.set_readonly('created_by') + f.set_readonly('executing') + f.set_readonly('executed') + f.set_readonly('executed_by') + f.set_label('stdout_file', "STDOUT") + f.set_label('stderr_file', "STDERR") + upgrade = f.model_instance + if self.creating or self.editing: + f.remove_field('created') + f.remove_field('created_by') + f.remove_field('stdout_file') + f.remove_field('stderr_file') + if self.creating or not upgrade.executed: + f.remove_field('executing') + f.remove_field('executed') + f.remove_field('executed_by') + if self.editing and upgrade.executed: + f.remove_field('enabled') + + elif f.model_instance.executed: + f.remove_field('enabled') + f.remove_field('executing') + + else: + f.remove_field('executed') + f.remove_field('executed_by') + f.remove_field('stdout_file') + f.remove_field('stderr_file') + + def render_stdout_file(self, upgrade, fieldname): + if fieldname.startswith('stderr'): + filename = 'stderr.log' + else: + filename = 'stdout.log' + path = self.rattail_config.upgrade_filepath(upgrade.uuid, filename=filename) + if path: + content = "{} ({})".format(filename, self.readable_size(path)) + url = '{}?filename={}'.format(self.get_action_url('download', upgrade), filename) + return tags.link_to(content, url) + return filename + + def get_size(self, path): + try: + return os.path.getsize(path) + except os.error: + return 0 + + def readable_size(self, path): + # TODO: this was shamelessly copied from FormAlchemy ... + length = self.get_size(path) + if length == 0: + return '0 KB' + if length <= 1024: + return '1 KB' + if length > 1048576: + return '%0.02f MB' % (length / 1048576.0) + return '%0.02f KB' % (length / 1024.0) + + def download_path(self, upgrade, filename): + return self.rattail_config.upgrade_filepath(upgrade.uuid, filename=filename) + + def download_content_type(self, path, filename): + return 'text/plain' + + def before_create_flush(self, upgrade): + upgrade.created_by = self.request.user + + def execute_instance(self, upgrade, user, **kwargs): + session = orm.object_session(upgrade) + upgrade.executing = True + session.commit() + self.handler.execute(upgrade, user, **kwargs) + upgrade.executing = False + upgrade.executed = make_utc() + upgrade.executed_by = user + + +def includeme(config): + UpgradeView.defaults(config) From f203f2c377465869b034603a48d2f5b0f195ed30 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 7 Aug 2017 14:38:09 -0500 Subject: [PATCH 0400/3196] Update changelog --- CHANGES.rst | 10 ++++++++++ tailbone/_version.py | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 94c5af33..6c8ffb76 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,16 @@ CHANGELOG ========= +0.6.20 (2017-08-07) +------------------- + +* Record become/stop root user events + +* Make datasync changes bulk-deletable + +* Add basic support for performing / tracking app upgrades + + 0.6.19 (2017-08-04) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 0893f189..c3a68ada 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.6.19' +__version__ = '0.6.20' From f46e20c119e270b8ee029470ae5fbaccf5a185a9 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 7 Aug 2017 18:19:29 -0500 Subject: [PATCH 0401/3196] Refactor progress bars somewhat to allow file-based sessions hoping this solves issue of Apache restart at end of upgrade --- tailbone/progress.py | 19 +++++++++++++------ tailbone/templates/progress.mako | 6 +++--- tailbone/views/batch/core.py | 24 +++++++++++------------- tailbone/views/core.py | 3 ++- tailbone/views/master.py | 16 +++++++++------- tailbone/views/products.py | 6 ++---- tailbone/views/progress.py | 7 ++++--- tailbone/views/upgrades.py | 4 ++++ 8 files changed, 48 insertions(+), 37 deletions(-) diff --git a/tailbone/progress.py b/tailbone/progress.py index e6d35b9a..0879025b 100644 --- a/tailbone/progress.py +++ b/tailbone/progress.py @@ -1,4 +1,4 @@ -# -*- coding: utf-8 -*- +# -*- coding: utf-8; -*- ################################################################################ # # Rattail -- Retail Software Framework @@ -26,15 +26,21 @@ Progress Indicator from __future__ import unicode_literals, absolute_import +import os + from beaker.session import Session -def get_progress_session(request, key): +def get_progress_session(request, key, **kwargs): """ Create/get a Beaker session object, to be used for progress. """ - id = '{0}.progress.{1}'.format(request.session.id, key) - return Session(request, id, use_cookies=False) + id = '{}.progress.{}'.format(request.session.id, key) + kwargs['use_cookies'] = False + if kwargs.get('type') == 'file': + kwargs['data_dir'] = os.path.join(request.rattail_config.appdir(), 'sessions') + session = Session(request, id, **kwargs) + return session class SessionProgress(object): @@ -46,8 +52,9 @@ class SessionProgress(object): for display to the user. """ - def __init__(self, request, key): - self.session = get_progress_session(request, key) + def __init__(self, request, key, session_type=None): + self.key = key + self.session = get_progress_session(request, key, type=session_type) self.canceled = False self.clear() diff --git a/tailbone/templates/progress.mako b/tailbone/templates/progress.mako index bab70473..4bd2b4bd 100644 --- a/tailbone/templates/progress.mako +++ b/tailbone/templates/progress.mako @@ -1,4 +1,4 @@ -## -*- coding: utf-8 -*- +## -*- coding: utf-8; -*- <%namespace file="tailbone:templates/base.mako" import="core_javascript" /> <%namespace file="/base.mako" import="jquery_theme" /> @@ -64,7 +64,7 @@ function update_progress() { $.ajax({ - url: '${url('progress', key=key)}', + url: '${url('progress', key=progress.key)}?sessiontype=${progress.session.type}', success: function(data) { if (data.error) { location.href = '${cancel_url}'; @@ -106,7 +106,7 @@ clearInterval(updater); $(this).button('disable').button('option', 'label', "Canceling, please wait..."); $.ajax({ - url: '${url('progress.cancel', key=key)}', + url: '${url('progress', key=progress.key)}?sessiontype=${progress.session.type}', data: { 'cancel_msg': '${cancel_msg}', }, diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index 6c4f005e..a3a9891d 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -506,14 +506,13 @@ class BatchMasterView(MasterView): permission_prefix = self.get_permission_prefix() # showing progress requires a separate thread; start that first - progress_key = '{}.prefill'.format(route_prefix) - progress = SessionProgress(self.request, progress_key) + key = '{}.prefill'.format(route_prefix) + progress = SessionProgress(self.request, key) thread = Thread(target=self.prefill_thread, args=(batch.uuid, progress)) thread.start() # Send user to progress page. kwargs = { - 'key': progress_key, 'cancel_url': self.get_action_url('view', batch), 'cancel_msg': "Batch prefill was canceled.", } @@ -522,7 +521,7 @@ class BatchMasterView(MasterView): if not self.request.has_perm('{}.view'.format(permission_prefix)): kwargs['cancel_url'] = self.request.route_url('{}.create'.format(route_prefix)) - return self.render_progress(kwargs) + return self.render_progress(progress, kwargs) def prefill_thread(self, batch_uuid, progress): """ @@ -601,16 +600,15 @@ class BatchMasterView(MasterView): # Send user to progress page. kwargs = { - 'key': key, 'cancel_url': self.get_action_url('view', batch), 'cancel_msg': "Batch refresh was canceled.", - } + } # TODO: This seems hacky...it exists for (only) one specific scenario. if not self.request.has_perm('{}.view'.format(permission_prefix)): kwargs['cancel_url'] = self.request.route_url('{}.create'.format(route_prefix)) - return self.render_progress(kwargs) + return self.render_progress(progress, kwargs) def refresh_data(self, session, batch, cognizer=None, progress=None): """ @@ -764,12 +762,12 @@ class BatchMasterView(MasterView): self.request.session['batch.{}.execute_option.{}'.format(batch.batch_key, key)] = unicode(value) key = '{}.execute'.format(self.model_class.__tablename__) - kwargs['progress'] = SessionProgress(self.request, key) + progress = SessionProgress(self.request, key) + kwargs['progress'] = progress thread = Thread(target=self.execute_thread, args=(batch.uuid, self.request.user.uuid), kwargs=kwargs) thread.start() - return self.render_progress({ - 'key': key, + return self.render_progress(progress, { 'cancel_url': self.get_action_url('view', batch), 'cancel_msg': "Batch execution was canceled.", }) @@ -850,12 +848,12 @@ class BatchMasterView(MasterView): key = '{}.execute_results'.format(self.model_class.__tablename__) batches = self.get_effective_data() - kwargs['progress'] = SessionProgress(self.request, key) + progress = SessionProgress(self.request, key) + kwargs['progress'] = progress thread = Thread(target=self.execute_results_thread, args=(batches, self.request.user.uuid), kwargs=kwargs) thread.start() - return self.render_progress({ - 'key': key, + return self.render_progress(progress, { 'cancel_url': self.get_index_url(), 'cancel_msg': "Batch execution was canceled", }) diff --git a/tailbone/views/core.py b/tailbone/views/core.py index 9efdb90f..c1a2f0fe 100644 --- a/tailbone/views/core.py +++ b/tailbone/views/core.py @@ -90,10 +90,11 @@ class View(object): def progress_loop(self, func, items, factory, *args, **kwargs): return progress_loop(func, items, factory, *args, **kwargs) - def render_progress(self, kwargs): + def render_progress(self, progress, kwargs): """ Render the progress page, with given kwargs as context. """ + kwargs['progress'] = progress return render_to_response('/progress.mako', kwargs, request=self.request) def file_response(self, path): diff --git a/tailbone/views/master.py b/tailbone/views/master.py index e4e32beb..f772f365 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -741,13 +741,12 @@ class MasterView(View): Delete all records matching the current grid query """ if self.request.method == 'POST': - key = '{}.bulk_delete'.format(self.model_class.__tablename__) objects = self.get_effective_data() + key = '{}.bulk_delete'.format(self.model_class.__tablename__) progress = SessionProgress(self.request, key) thread = Thread(target=self.bulk_delete_thread, args=(objects, progress)) thread.start() - return self.render_progress({ - 'key': key, + return self.render_progress(progress, { 'cancel_url': self.get_index_url(), 'cancel_msg': "Bulk deletion was canceled", }) @@ -804,13 +803,12 @@ class MasterView(View): model_title = self.get_model_title() if self.request.method == 'POST': - key = '{}.execute'.format(self.get_grid_key()) - kwargs = {'progress': SessionProgress(self.request, key)} + progress = self.make_execute_progress() + kwargs = {'progress': progress} thread = Thread(target=self.execute_thread, args=(obj.uuid, self.request.user.uuid), kwargs=kwargs) thread.start() - return self.render_progress({ - 'key': key, + return self.render_progress(progress, { 'cancel_url': self.get_action_url('view', obj), 'cancel_msg': "{} execution was canceled".format(model_title), }) @@ -818,6 +816,10 @@ class MasterView(View): self.request.session.flash("Sorry, you must POST to execute a {}.".format(model_title), 'error') return self.redirect(self.get_action_url('view', obj)) + def make_execute_progress(self): + key = '{}.execute'.format(self.get_grid_key()) + return SessionProgress(self.request, key) + def execute_thread(self, uuid, user_uuid, progress=None, **kwargs): """ Thread target for executing an object. diff --git a/tailbone/views/products.py b/tailbone/views/products.py index bf4fcd92..10f85361 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -489,13 +489,11 @@ class ProductsView(MasterView): handler = get_batch_handler(self.rattail_config, batch_key, default=supported[batch_key].spec) products = self.get_effective_data() - progress_key = 'products.batch' - progress = SessionProgress(self.request, progress_key) + progress = SessionProgress(self.request, 'products.batch') thread = Thread(target=self.make_batch_thread, args=(handler, self.request.user.uuid, products, params, progress)) thread.start() - return self.render_progress({ - 'key': progress_key, + return self.render_progress(progress, { 'cancel_url': self.get_index_url(), 'cancel_msg': "Batch creation was canceled.", }) diff --git a/tailbone/views/progress.py b/tailbone/views/progress.py index b6c583f1..e3cbef01 100644 --- a/tailbone/views/progress.py +++ b/tailbone/views/progress.py @@ -20,17 +20,18 @@ # Rattail. If not, see . # ################################################################################ - """ Progress Views """ -from ..progress import get_progress_session +from __future__ import unicode_literals, absolute_import + +from tailbone.progress import get_progress_session def progress(request): key = request.matchdict['key'] - session = get_progress_session(request, key) + session = get_progress_session(request, key, type=request.GET.get('sessiontype')) if session.get('complete'): msg = session.get('success_msg') if msg: diff --git a/tailbone/views/upgrades.py b/tailbone/views/upgrades.py index d83b382c..380e0723 100644 --- a/tailbone/views/upgrades.py +++ b/tailbone/views/upgrades.py @@ -183,6 +183,10 @@ class UpgradeView(MasterView): def before_create_flush(self, upgrade): upgrade.created_by = self.request.user + def make_execute_progress(self): + key = '{}.execute'.format(self.get_grid_key()) + return SessionProgress(self.request, key, session_type='file') + def execute_instance(self, upgrade, user, **kwargs): session = orm.object_session(upgrade) upgrade.executing = True From 4cb4d9b14c57a6fef417defab9f3e17f0e51d81b Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 7 Aug 2017 18:50:50 -0500 Subject: [PATCH 0402/3196] Stop trying to persist session used for upgrade execution progress apparently that trick won't work as long as we're waiting in-process for the upgrade process to complete.. --- tailbone/views/upgrades.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tailbone/views/upgrades.py b/tailbone/views/upgrades.py index 380e0723..85566717 100644 --- a/tailbone/views/upgrades.py +++ b/tailbone/views/upgrades.py @@ -183,9 +183,11 @@ class UpgradeView(MasterView): def before_create_flush(self, upgrade): upgrade.created_by = self.request.user - def make_execute_progress(self): - key = '{}.execute'.format(self.get_grid_key()) - return SessionProgress(self.request, key, session_type='file') + # TODO: this was an attempt to make the progress bar survive Apache restart, + # but it didn't work... need to "fork" instead of waiting for execution? + # def make_execute_progress(self): + # key = '{}.execute'.format(self.get_grid_key()) + # return SessionProgress(self.request, key, session_type='file') def execute_instance(self, upgrade, user, **kwargs): session = orm.object_session(upgrade) From 430a1416c6da78b4afc6008bb751b6b63bba650a Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 7 Aug 2017 19:09:03 -0500 Subject: [PATCH 0403/3196] Fix recipients renderer for email settings grid --- tailbone/views/email.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/tailbone/views/email.py b/tailbone/views/email.py index c5df16a8..3aa1b014 100644 --- a/tailbone/views/email.py +++ b/tailbone/views/email.py @@ -84,9 +84,8 @@ class ProfilesView(MasterView): g.sorters['to'] = g.make_simple_sorter('to', foldcase=True) g.sorters['enabled'] = g.make_simple_sorter('enabled') g.default_sortkey = 'key' - g.set_type('enabled', 'boolean') - + g.set_renderer('to', self.render_to) g.set_link('key') g.set_link('subject') @@ -94,6 +93,15 @@ class ProfilesView(MasterView): if g.more_actions: g.main_actions.append(g.more_actions.pop()) + def render_to(self, email, column): + value = email['to'] + if not value: + return "" + recips = parse_list(value) + if len(recips) < 3: + return value + return "{}, ...".format(', '.join(recips[:2])) + def normalize(self, email): def get_recips(type_): recips = email.get_recips(type_) From e14b5a89c38577e4458f20fecdc6b1eb90f6f53a Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 7 Aug 2017 22:23:07 -0500 Subject: [PATCH 0404/3196] Improve status tracking for upgrades; add package version diff --- tailbone/diffs.py | 71 ++++++++++++++++++++++++++++++++++ tailbone/forms2/core.py | 21 +++++++++- tailbone/static/css/diffs.css | 31 +++++++++++++++ tailbone/templates/base.mako | 1 + tailbone/templates/diff.mako | 19 +++++++++ tailbone/views/master.py | 5 ++- tailbone/views/upgrades.py | 72 ++++++++++++++++++++++++++++++----- 7 files changed, 209 insertions(+), 11 deletions(-) create mode 100644 tailbone/diffs.py create mode 100644 tailbone/static/css/diffs.css create mode 100644 tailbone/templates/diff.mako diff --git a/tailbone/diffs.py b/tailbone/diffs.py new file mode 100644 index 00000000..f388cc3f --- /dev/null +++ b/tailbone/diffs.py @@ -0,0 +1,71 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2017 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see . +# +################################################################################ +""" +Tools for displaying data diffs +""" + +from __future__ import unicode_literals, absolute_import + +from pyramid.renderers import render +from webhelpers2.html import HTML + + +class Diff(object): + """ + Core diff class. In sore need of documentation. + """ + + def __init__(self, old_data, new_data, columns=None, fields=None, render_value=None): + self.old_data = old_data + self.new_data = new_data + self.columns = columns or ["field name", "old value", "new value"] + self.fields = fields or self.make_fields() + self.render_value = render_value or self.render_value_default + + def make_fields(self): + return sorted(set(self.old_data) | set(self.new_data), key=lambda x: x.lower()) + + def old_value(self, field): + return self.old_data[field] + + def new_value(self, field): + return self.new_data[field] + + def values_differ(self, field): + return self.new_value(field) != self.old_value(field) + + def render_html(self, template='/diff.mako', **kwargs): + context = kwargs + context['diff'] = self + return HTML.literal(render(template, context)) + + def render_value_default(self, field, value): + return repr(value) + + def render_old_value(self, field): + value = self.old_value(field) + return self.render_value(field, value) + + def render_new_value(self, field): + value = self.new_value(field) + return self.render_value(field, value) diff --git a/tailbone/forms2/core.py b/tailbone/forms2/core.py index b736df39..e15391ad 100644 --- a/tailbone/forms2/core.py +++ b/tailbone/forms2/core.py @@ -175,7 +175,7 @@ class Form(object): """ def __init__(self, fields=None, schema=None, request=None, readonly=False, readonly_fields=[], - model_instance=None, model_class=None, labels={}, renderers={}, widgets={}, + model_instance=None, model_class=None, enums={}, labels={}, renderers={}, widgets={}, action_url=None, cancel_url=None): self.fields = list(fields) if fields is not None else None @@ -189,6 +189,7 @@ class Form(object): self.model_class = type(self.model_instance) if self.model_class and self.fields is None: self.fields = self.make_fields() + self.enums = enums or {} self.labels = labels or {} self.renderers = renderers or {} self.widgets = widgets or {} @@ -280,12 +281,21 @@ class Form(object): self.set_renderer(key, self.render_datetime) elif type_ == 'boolean': self.set_renderer(key, self.render_boolean) + elif type_ == 'enum': + self.set_renderer(key, self.render_enum) elif type_ == 'codeblock': self.set_renderer(key, self.render_codeblock) self.set_widget(key, dfwidget.TextAreaWidget(cols=80, rows=8)) else: raise ValueError("unknown type for '{}' field: {}".format(key, type_)) + def set_enum(self, key, enum): + if enum: + self.enums[key] = enum + self.set_type(key, 'enum') + else: + self.enums.pop(key, None) + def set_renderer(self, key, renderer): self.renderers[key] = renderer @@ -372,6 +382,15 @@ class Form(object): value = self.obtain_value(record, field_name) return pretty_boolean(value) + def render_enum(self, record, field_name): + value = self.obtain_value(record, field_name) + if value is None: + return "" + enum = self.enums.get(field_name) + if enum and value in enum: + return six.text_type(enum[value]) + return six.text_type(value) + def render_codeblock(self, record, field_name): value = self.obtain_value(record, field_name) if value is None: diff --git a/tailbone/static/css/diffs.css b/tailbone/static/css/diffs.css new file mode 100644 index 00000000..66bf4064 --- /dev/null +++ b/tailbone/static/css/diffs.css @@ -0,0 +1,31 @@ + +table.diff { + background-color: White; + border-collapse: collapse; + border-left: 1px solid Black; + border-top: 1px solid Black; +} + +table.diff th, +table.diff td { + border-bottom: 1px solid Black; + border-right: 1px solid Black; +} + +table.diff td { + padding: 2px 5px; +} + +table.diff td.old-value, +table.diff td.new-value{ + font-family: monospace; + white-space: pre; +} + +table.diff tr.diff td.new-value { + background-color: #cfc; +} + +table.diff tr.diff td.old-value { + background-color: #fcc; +} diff --git a/tailbone/templates/base.mako b/tailbone/templates/base.mako index 1e411d02..a929dfe1 100644 --- a/tailbone/templates/base.mako +++ b/tailbone/templates/base.mako @@ -149,6 +149,7 @@ ${h.stylesheet_link(request.static_url('tailbone:static/css/grids.css'))} ${h.stylesheet_link(request.static_url('tailbone:static/css/filters.css'))} ${h.stylesheet_link(request.static_url('tailbone:static/css/forms.css'))} + ${h.stylesheet_link(request.static_url('tailbone:static/css/diffs.css'))} <%def name="jquery_smoothness_theme()"> diff --git a/tailbone/templates/diff.mako b/tailbone/templates/diff.mako new file mode 100644 index 00000000..2ac2796f --- /dev/null +++ b/tailbone/templates/diff.mako @@ -0,0 +1,19 @@ +## -*- coding: utf-8; -*- + + + + % for column in diff.columns: + + % endfor + + + + % for field in diff.fields: + + + + + + % endfor + +
      ${column}
      ${field}${diff.render_old_value(field)}${diff.render_new_value(field)}
      diff --git a/tailbone/views/master.py b/tailbone/views/master.py index f772f365..d06f1f52 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -47,7 +47,7 @@ from pyramid.renderers import get_renderer, render_to_response, render from pyramid.response import FileResponse from webhelpers2.html import HTML, tags -from tailbone import forms, grids +from tailbone import forms, grids, diffs from tailbone.views import View from tailbone.progress import SessionProgress @@ -1685,6 +1685,9 @@ class MasterView(View): # TODO: make this smarter? return {'uuid': row.uuid} + def make_diff(self, old_data, new_data, **kwargs): + return diffs.Diff(old_data, new_data, **kwargs) + ############################## # Config Stuff ############################## diff --git a/tailbone/views/upgrades.py b/tailbone/views/upgrades.py index 85566717..2589379d 100644 --- a/tailbone/views/upgrades.py +++ b/tailbone/views/upgrades.py @@ -27,7 +27,11 @@ Views for app upgrades from __future__ import unicode_literals, absolute_import import os +import re +import six +from pip.download import PipSession +from pip.req import parse_requirements from sqlalchemy import orm from rattail.db import model, Session as RattailSession @@ -49,13 +53,19 @@ class UpgradeView(MasterView): model_class = model.Upgrade executable = True downloadable = True + labels = { + 'executed_by': "Executed by", + 'status_code': "Status", + 'stdout_file': "STDOUT", + 'stderr_file': "STDERR", + } grid_columns = [ 'created', 'description', # 'not_until', 'enabled', - 'executing', + 'status_code', 'executed', 'executed_by', ] @@ -68,11 +78,12 @@ class UpgradeView(MasterView): 'created', 'created_by', 'enabled', - 'executing', 'executed', 'executed_by', + 'status_code', 'stdout_file', 'stderr_file', + 'package_diff', ] def __init__(self, request): @@ -95,11 +106,11 @@ class UpgradeView(MasterView): super(UpgradeView, self).configure_grid(g) g.set_joiner('executed_by', lambda q: q.join(model.User).outerjoin(model.Person)) g.set_sorter('executed_by', model.Person.display_name) + g.set_enum('status_code', self.enum.UPGRADE_STATUS) g.set_type('created', 'datetime') g.set_type('executed', 'datetime') g.default_sortkey = 'created' g.default_sortdir = 'desc' - g.set_label('executed_by', "Executed by") g.set_link('created') g.set_link('description') # g.set_link('not_until') @@ -107,29 +118,28 @@ class UpgradeView(MasterView): def configure_form(self, f): super(UpgradeView, self).configure_form(f) + f.set_enum('status_code', self.enum.UPGRADE_STATUS) f.set_type('created', 'datetime') f.set_type('enabled', 'boolean') - f.set_type('executing', 'boolean') f.set_type('executed', 'datetime') # f.set_widget('not_until', dfwidget.DateInputWidget()) f.set_widget('notes', dfwidget.TextAreaWidget(cols=80, rows=8)) f.set_renderer('stdout_file', self.render_stdout_file) f.set_renderer('stderr_file', self.render_stdout_file) + f.set_renderer('package_diff', self.render_package_diff) # f.set_readonly('created') # f.set_readonly('created_by') - f.set_readonly('executing') f.set_readonly('executed') f.set_readonly('executed_by') - f.set_label('stdout_file', "STDOUT") - f.set_label('stderr_file', "STDERR") upgrade = f.model_instance if self.creating or self.editing: f.remove_field('created') f.remove_field('created_by') f.remove_field('stdout_file') f.remove_field('stderr_file') + if self.creating: + f.remove_field('status_code') if self.creating or not upgrade.executed: - f.remove_field('executing') f.remove_field('executed') f.remove_field('executed_by') if self.editing and upgrade.executed: @@ -137,7 +147,6 @@ class UpgradeView(MasterView): elif f.model_instance.executed: f.remove_field('enabled') - f.remove_field('executing') else: f.remove_field('executed') @@ -145,6 +154,9 @@ class UpgradeView(MasterView): f.remove_field('stdout_file') f.remove_field('stderr_file') + if not self.viewing or not upgrade.executed: + f.remove_field('package_diff') + def render_stdout_file(self, upgrade, fieldname): if fieldname.startswith('stderr'): filename = 'stderr.log' @@ -157,6 +169,45 @@ class UpgradeView(MasterView): return tags.link_to(content, url) return filename + def render_package_diff(self, upgrade, fieldname): + try: + before = self.parse_requirements(upgrade, 'before') + after = self.parse_requirements(upgrade, 'after') + diff = self.make_diff(before, after, + columns=["package", "old version", "new version"], + render_value=self.render_diff_value, + ) + return diff.render_html() + except: + return "(not available for this upgrade)" + + def render_diff_value(self, field, value): + if value.startswith("u'") and value.endswith("'"): + return value[2:1] + return value + + def parse_requirements(self, upgrade, type_): + packages = {} + path = self.rattail_config.upgrade_filepath(upgrade.uuid, filename='requirements.{}.txt'.format(type_)) + session = PipSession() + for req in parse_requirements(path, session=session): + version = self.version_from_requirement(req) + packages[req.name] = version + return packages + + def version_from_requirement(self, req): + if req.specifier: + match = re.match(r'^==(.*)$', six.text_type(req.specifier)) + if match: + return match.group(1) + return six.text_type(req.specifier) + elif req.link: + match = re.match(r'^.*@(.*)#egg=.*$', six.text_type(req.link)) + if match: + return match.group(1) + return six.text_type(req.link) + return "" + def get_size(self, path): try: return os.path.getsize(path) @@ -182,6 +233,7 @@ class UpgradeView(MasterView): def before_create_flush(self, upgrade): upgrade.created_by = self.request.user + upgrade.status_code = self.enum.UPGRADE_STATUS_PENDING # TODO: this was an attempt to make the progress bar survive Apache restart, # but it didn't work... need to "fork" instead of waiting for execution? @@ -192,9 +244,11 @@ class UpgradeView(MasterView): def execute_instance(self, upgrade, user, **kwargs): session = orm.object_session(upgrade) upgrade.executing = True + upgrade.status_code = self.enum.UPGRADE_STATUS_EXECUTING session.commit() self.handler.execute(upgrade, user, **kwargs) upgrade.executing = False + upgrade.status_code = self.enum.UPGRADE_STATUS_SUCCEEDED upgrade.executed = make_utc() upgrade.executed_by = user From 2714d3c03cc3455f95a0bf049694fb6ca53226d3 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 7 Aug 2017 23:07:36 -0500 Subject: [PATCH 0405/3196] Tweak logging when object fails to be executed --- tailbone/views/master.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/views/master.py b/tailbone/views/master.py index d06f1f52..4189b551 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -833,7 +833,7 @@ class MasterView(View): # If anything goes wrong, rollback and log the error etc. except Exception as error: session.rollback() - log.exception("execution failed for object: {}".format(obj)) + log.exception("{} failed to execute: {}".format(self.get_model_title(), obj)) session.close() if progress: progress.session.load() From 3fcc105b7876ab1950fd7c40bc22cf20a2a86f9f Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 7 Aug 2017 23:22:47 -0500 Subject: [PATCH 0406/3196] Only use monospace fonts in diff table if so specified --- tailbone/diffs.py | 3 ++- tailbone/static/css/diffs.css | 4 ++-- tailbone/templates/diff.mako | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/tailbone/diffs.py b/tailbone/diffs.py index f388cc3f..f179128f 100644 --- a/tailbone/diffs.py +++ b/tailbone/diffs.py @@ -35,12 +35,13 @@ class Diff(object): Core diff class. In sore need of documentation. """ - def __init__(self, old_data, new_data, columns=None, fields=None, render_value=None): + def __init__(self, old_data, new_data, columns=None, fields=None, render_value=None, monospace=False): self.old_data = old_data self.new_data = new_data self.columns = columns or ["field name", "old value", "new value"] self.fields = fields or self.make_fields() self.render_value = render_value or self.render_value_default + self.monospace = monospace def make_fields(self): return sorted(set(self.old_data) | set(self.new_data), key=lambda x: x.lower()) diff --git a/tailbone/static/css/diffs.css b/tailbone/static/css/diffs.css index 66bf4064..f76b336d 100644 --- a/tailbone/static/css/diffs.css +++ b/tailbone/static/css/diffs.css @@ -16,8 +16,8 @@ table.diff td { padding: 2px 5px; } -table.diff td.old-value, -table.diff td.new-value{ +table.diff.monospace td.old-value, +table.diff.monospace td.new-value{ font-family: monospace; white-space: pre; } diff --git a/tailbone/templates/diff.mako b/tailbone/templates/diff.mako index 2ac2796f..7f2132fd 100644 --- a/tailbone/templates/diff.mako +++ b/tailbone/templates/diff.mako @@ -1,5 +1,5 @@ ## -*- coding: utf-8; -*- - +
      % for column in diff.columns: From 93353815604959270616af72e9a3120aee8a0657 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 8 Aug 2017 00:50:20 -0500 Subject: [PATCH 0407/3196] Update changelog --- CHANGES.rst | 10 ++++++++++ tailbone/_version.py | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 6c8ffb76..ecdb4dce 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,16 @@ CHANGELOG ========= +0.6.21 (2017-08-08) +------------------- + +* Refactor progress bars somewhat to allow file-based sessions + +* Fix recipients renderer for email settings grid + +* Improve status tracking for upgrades; add package version diff + + 0.6.20 (2017-08-07) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index c3a68ada..663bb503 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.6.20' +__version__ = '0.6.21' From e91f18f3441c01a5fb01c6cbb3cb7d704cec5518 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 8 Aug 2017 13:02:57 -0500 Subject: [PATCH 0408/3196] Add some links to employees grid --- tailbone/views/employees.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tailbone/views/employees.py b/tailbone/views/employees.py index 92119537..f02154c4 100644 --- a/tailbone/views/employees.py +++ b/tailbone/views/employees.py @@ -100,6 +100,10 @@ class EmployeesView(MasterView): g.set_label('phone', "Phone Number") g.set_label('email', "Email Address") + g.set_link('id') + g.set_link('first_name') + g.set_link('last_name') + if not self.request.has_perm('employees.edit'): g.hide_column('id') g.hide_column('status') From 820841d4e0941c097b797c4f193e7a053b57ca33 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 8 Aug 2017 14:41:52 -0500 Subject: [PATCH 0409/3196] Remove unwanted import (which broke versioning) ugh, now there's a check on startup to hopefully prevent this sort of thing from sneaking up on us again --- tailbone/auth.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tailbone/auth.py b/tailbone/auth.py index 2f8f4782..9db292ad 100644 --- a/tailbone/auth.py +++ b/tailbone/auth.py @@ -29,7 +29,6 @@ from __future__ import unicode_literals, absolute_import import logging from rattail import enum -from rattail.db import model from rattail.util import prettify, NOTSET from zope.interface import implementer From 2df51bfef8ea91a33640f39549f7bebcba7a195d Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 8 Aug 2017 14:44:42 -0500 Subject: [PATCH 0410/3196] 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 ecdb4dce..7b5569ec 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,14 @@ CHANGELOG ========= +0.6.22 (2017-08-08) +------------------- + +* Remove unwanted import (which broke versioning) + +* Add some links to employees grid + + 0.6.21 (2017-08-08) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 663bb503..17346b9c 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.6.21' +__version__ = '0.6.22' From 2dc539c3573c45c5d9eee58d8562830d5ba96db3 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 8 Aug 2017 16:57:05 -0500 Subject: [PATCH 0411/3196] Fix bulk-delete for batch rows, allow it for pricing batches --- tailbone/views/batch/core.py | 2 +- tailbone/views/batch/pricing.py | 3 ++- tailbone/views/master.py | 13 +++++++++++++ 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index a3a9891d..d0ec1245 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -740,7 +740,7 @@ class BatchMasterView(MasterView): "Delete" all rows matching the current row grid view query. This sets the ``removed`` flag on the rows but does not truly delete them. """ - query = self.get_effective_row_query() + query = self.get_effective_row_data(sort=False) query.update({'removed': True}, synchronize_session=False) return self.redirect(self.get_action_url('view', self.get_instance())) diff --git a/tailbone/views/batch/pricing.py b/tailbone/views/batch/pricing.py index 729aa6a8..f35ba04a 100644 --- a/tailbone/views/batch/pricing.py +++ b/tailbone/views/batch/pricing.py @@ -43,8 +43,9 @@ class PricingBatchView(BatchMasterView): route_prefix = 'batch.pricing' url_prefix = '/batches/pricing' creatable = False - rows_editable = True bulk_deletable = True + rows_editable = True + rows_bulk_deletable = True grid_columns = [ 'id', diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 4189b551..75ff6e3c 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -570,6 +570,19 @@ class MasterView(View): """ raise NotImplementedError + def get_effective_row_data(self, session=None, sort=False, **kwargs): + """ + Convenience method which returns the "effective" data for the row grid, + filtered (and optionally sorted) to match what would show on the UI, + but not paged. + """ + if session is None: + session = self.Session() + kwargs.setdefault('pageable', False) + kwargs.setdefault('sortable', sort) + grid = self.make_row_grid(session=session, **kwargs) + return grid.make_visible_data() + @classmethod def get_row_route_prefix(cls): """ From 4101e056e423e67c126a667f83a10d1fa3677e69 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 8 Aug 2017 17:00:38 -0500 Subject: [PATCH 0412/3196] Fix permission check for deleting single batch rows --- tailbone/views/master2.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/views/master2.py b/tailbone/views/master2.py index cac8cee9..c0c2b8b7 100644 --- a/tailbone/views/master2.py +++ b/tailbone/views/master2.py @@ -272,7 +272,7 @@ class MasterView2(MasterView): Return a dict of kwargs to be used when constructing a new rows grid. """ route_prefix = self.get_row_route_prefix() - permission_prefix = self.get_row_permission_prefix() + permission_prefix = self.get_permission_prefix() defaults = { 'model_class': self.model_row_class, From e80f8b31c1e337be2cb2d40bca22da9b08ee76d7 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 8 Aug 2017 17:04:59 -0500 Subject: [PATCH 0413/3196] Fix numeric filter to allow 3 decimal places by default --- tailbone/grids/filters.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tailbone/grids/filters.py b/tailbone/grids/filters.py index f0857059..87964693 100644 --- a/tailbone/grids/filters.py +++ b/tailbone/grids/filters.py @@ -74,6 +74,7 @@ class NumericValueRenderer(FilterValueRenderer): """ def render(self, value=None, **kwargs): + kwargs.setdefault('step', '0.001') return tags.text(self.name, value=value, type='number', **kwargs) From 158755377b03da5558bf4113b9f003ee7a4d2a9c Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 8 Aug 2017 17:06:02 -0500 Subject: [PATCH 0414/3196] Update changelog --- CHANGES.rst | 10 ++++++++++ tailbone/_version.py | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 7b5569ec..47350651 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,16 @@ CHANGELOG ========= +0.6.23 (2017-08-08) +------------------- + +* Fix bulk-delete for batch rows, allow it for pricing batches + +* Fix permission check for deleting single batch rows + +* Fix numeric filter to allow 3 decimal places by default + + 0.6.22 (2017-08-08) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 17346b9c..34cf4869 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.6.22' +__version__ = '0.6.23' From b28dc0702e2522fb05b4e83272ec9f0798afcc67 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 8 Aug 2017 17:59:57 -0500 Subject: [PATCH 0415/3196] Fix bug which caused new empty worked shift when editing time sheet --- tailbone/static/js/tailbone.timesheet.edit.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tailbone/static/js/tailbone.timesheet.edit.js b/tailbone/static/js/tailbone.timesheet.edit.js index daf7e6d7..f2fcb271 100644 --- a/tailbone/static/js/tailbone.timesheet.edit.js +++ b/tailbone/static/js/tailbone.timesheet.edit.js @@ -183,6 +183,9 @@ function update_timetable() { var end_time = $(this).children('input[name|="edit_end_time"]').val(); var shift = editing_day.children('.shift[data-uuid="' + uuid + '"]'); if (! shift.length) { + if (! (start_time || end_time)) { + return; + } shift = $('

      '); shift.append($('')); From c40a99327354a7c285f3a9c5549085908bfc452a Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 8 Aug 2017 18:00:58 -0500 Subject: [PATCH 0416/3196] 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 47350651..6f41bb28 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.6.24 (2017-08-08) +------------------- + +* Fix bug which caused new empty worked shift when editing time sheet + + 0.6.23 (2017-08-08) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 34cf4869..9e938a70 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.6.23' +__version__ = '0.6.24' From 33a9516042ae7bbed036a1de3c2f585b4b654084 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 8 Aug 2017 19:38:54 -0500 Subject: [PATCH 0417/3196] Specify `expire_on_commit` for tailbone db session is this right..? seems to be necessary for login now, in some cases.. which surely doesn't make sense --- tailbone/db.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/db.py b/tailbone/db.py index 5104caa5..041f750e 100644 --- a/tailbone/db.py +++ b/tailbone/db.py @@ -35,7 +35,7 @@ from rattail.db import SessionBase from rattail.db.continuum import versioning_manager -Session = scoped_session(sessionmaker(class_=SessionBase, rattail_config=None, rattail_record_changes=False)) +Session = scoped_session(sessionmaker(class_=SessionBase, rattail_config=None, rattail_record_changes=False, expire_on_commit=False)) # not necessarily used, but here if you need it TempmonSession = scoped_session(sessionmaker()) From 7a14b423453af85463ba8bb48dde14fc222cf473 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 8 Aug 2017 19:41:38 -0500 Subject: [PATCH 0418/3196] 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 6f41bb28..42d70d2d 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.6.25 (2017-08-08) +------------------- + +* Specify ``expire_on_commit`` for tailbone db session + + 0.6.24 (2017-08-08) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 9e938a70..d2c7b9e7 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.6.24' +__version__ = '0.6.25' From 77880abb87768a4513609289d9651f68f66b58de Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 8 Aug 2017 20:32:17 -0500 Subject: [PATCH 0419/3196] Add awareness of upgrade exit code, success/fail --- tailbone/views/upgrades.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/tailbone/views/upgrades.py b/tailbone/views/upgrades.py index 2589379d..1dacb2f1 100644 --- a/tailbone/views/upgrades.py +++ b/tailbone/views/upgrades.py @@ -83,6 +83,7 @@ class UpgradeView(MasterView): 'status_code', 'stdout_file', 'stderr_file', + 'exit_code', 'package_diff', ] @@ -116,6 +117,12 @@ class UpgradeView(MasterView): # g.set_link('not_until') g.set_link('executed') + def grid_extra_class(self, upgrade, i): + if upgrade.status_code == self.enum.UPGRADE_STATUS_FAILED: + return 'warning' + if upgrade.status_code == self.enum.UPGRADE_STATUS_EXECUTING: + return 'notice' + def configure_form(self, f): super(UpgradeView, self).configure_form(f) f.set_enum('status_code', self.enum.UPGRADE_STATUS) @@ -156,6 +163,7 @@ class UpgradeView(MasterView): if not self.viewing or not upgrade.executed: f.remove_field('package_diff') + f.remove_field('exit_code') def render_stdout_file(self, upgrade, fieldname): if fieldname.startswith('stderr'): @@ -246,9 +254,12 @@ class UpgradeView(MasterView): upgrade.executing = True upgrade.status_code = self.enum.UPGRADE_STATUS_EXECUTING session.commit() - self.handler.execute(upgrade, user, **kwargs) + result = self.handler.execute(upgrade, user, **kwargs) upgrade.executing = False - upgrade.status_code = self.enum.UPGRADE_STATUS_SUCCEEDED + if result: + upgrade.status_code = self.enum.UPGRADE_STATUS_SUCCEEDED + else: + upgrade.status_code = self.enum.UPGRADE_STATUS_FAILED upgrade.executed = make_utc() upgrade.executed_by = user From d7f5211fc4870c8e95a04a7a39fbde3b24be3ada Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 8 Aug 2017 21:26:06 -0500 Subject: [PATCH 0420/3196] Add support for cloning an upgrade record until this is all ironed out, seems like it will often be helpful --- tailbone/static/js/tailbone.js | 3 ++ tailbone/templates/master/clone.mako | 24 ++++++++++++++ tailbone/templates/master/view.mako | 7 +++- .../templates/themes/better/master/view.mako | 16 --------- tailbone/views/master.py | 33 +++++++++++++++++++ tailbone/views/upgrades.py | 15 +++++++++ 6 files changed, 81 insertions(+), 17 deletions(-) create mode 100644 tailbone/templates/master/clone.mako diff --git a/tailbone/static/js/tailbone.js b/tailbone/static/js/tailbone.js index 63141400..d9022dbf 100644 --- a/tailbone/static/js/tailbone.js +++ b/tailbone/static/js/tailbone.js @@ -174,6 +174,9 @@ $(function() { $('button, a.button').button(); $('input[type=submit]').button(); $('input[type=reset]').button(); + $('a.button.autodisable').click(function() { + disable_button(this); + }); $('input[type="submit"].autodisable').click(function() { disable_button(this); }); diff --git a/tailbone/templates/master/clone.mako b/tailbone/templates/master/clone.mako new file mode 100644 index 00000000..c4ed4380 --- /dev/null +++ b/tailbone/templates/master/clone.mako @@ -0,0 +1,24 @@ +## -*- coding: utf-8; -*- +<%inherit file="/base.mako" /> + +<%def name="title()">Clone ${model_title}: ${instance_title} + +
      +

      You are about to clone the following ${model_title} as a new record:

      + +
      + ${form.render()|n} +
      + +
      +

      Are you sure about this?

      +
      + +${h.form(request.current_route_url())} +${h.csrf_token(request)} +${h.hidden('clone', value='clone')} +
      + ${h.link_to("Whoops, nevermind...", form.cancel_url, class_='button autodisable')} + ${h.submit('submit', "Yes, please clone away", class_='autodisable')} +
      +${h.end_form()} diff --git a/tailbone/templates/master/view.mako b/tailbone/templates/master/view.mako index 32f7884a..e50ebd71 100644 --- a/tailbone/templates/master/view.mako +++ b/tailbone/templates/master/view.mako @@ -27,8 +27,10 @@ <%def name="context_menu_items()"> -
    • ${h.link_to("Back to {}".format(model_title_plural), index_url)}
    • ${h.link_to("Permalink for this {}".format(model_title), action_url('view', instance))}
    • + % if master.has_versions and request.rattail_config.versioning_enabled() and request.has_perm('{}.versions'.format(permission_prefix)): +
    • ${h.link_to("Version History", action_url('versions', instance))}
    • + % endif % if master.editable and instance_editable and request.has_perm('{}.edit'.format(permission_prefix)):
    • ${h.link_to("Edit this {}".format(model_title), action_url('edit', instance))}
    • % endif @@ -38,6 +40,9 @@ % if master.creatable and request.has_perm('{}.create'.format(permission_prefix)):
    • ${h.link_to("Create a new {}".format(model_title), url('{}.create'.format(route_prefix)))}
    • % endif + % if master.cloneable and request.has_perm('{}.clone'.format(permission_prefix)): +
    • ${h.link_to("Clone this as new {}".format(model_title), url('{}.clone'.format(route_prefix), uuid=instance.uuid))}
    • + % endif
        diff --git a/tailbone/templates/themes/better/master/view.mako b/tailbone/templates/themes/better/master/view.mako index 7f51d2d1..5159a548 100644 --- a/tailbone/templates/themes/better/master/view.mako +++ b/tailbone/templates/themes/better/master/view.mako @@ -5,20 +5,4 @@

        ${instance_title}

        -<%def name="context_menu_items()"> -
      • ${h.link_to("Permalink for this {}".format(model_title), action_url('view', instance))}
      • - % if master.has_versions and request.rattail_config.versioning_enabled() and request.has_perm('{}.versions'.format(permission_prefix)): -
      • ${h.link_to("Version History", action_url('versions', instance))}
      • - % endif - % if master.editable and instance_editable and request.has_perm('{}.edit'.format(permission_prefix)): -
      • ${h.link_to("Edit this {}".format(model_title), action_url('edit', instance))}
      • - % endif - % if master.deletable and instance_deletable and request.has_perm('{}.delete'.format(permission_prefix)): -
      • ${h.link_to("Delete this {}".format(model_title), action_url('delete', instance))}
      • - % endif - % if master.creatable and request.has_perm('{}.create'.format(permission_prefix)): -
      • ${h.link_to("Create a new {}".format(model_title), url('{}.create'.format(route_prefix)))}
      • - % endif - - ${parent.body()} diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 75ff6e3c..6163bd98 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -72,6 +72,7 @@ class MasterView(View): mergeable = False downloadable = False executable = False + cloneable = False supports_mobile = False mobile_creatable = False @@ -312,6 +313,30 @@ class MasterView(View): tools=self.make_row_grid_tools(instance)) return self.render_to_response('view', context) + def clone(self): + """ + View for cloning an object's data into a new object. + """ + self.viewing = True + instance = self.get_instance() + form = self.make_form(instance) + self.configure_clone_form(form) + if self.request.method == 'POST' and self.request.POST.get('clone') == 'clone': + cloned = self.clone_instance(instance) + return self.redirect(self.get_action_url('view', cloned)) + return self.render_to_response('clone', { + 'instance': instance, + 'instance_title': self.get_instance_title(instance), + 'instance_url': self.get_action_url('view', instance), + 'form': form, + }) + + def configure_clone_form(self, form): + pass + + def clone_instance(self, instance): + raise NotImplementedError + def versions(self): """ View to list version history for an object. @@ -1805,6 +1830,14 @@ class MasterView(View): config.add_view(cls, attr='view_version', route_name='{}.version'.format(route_prefix), permission='{}.versions'.format(permission_prefix)) + # clone + if cls.cloneable: + config.add_tailbone_permission(permission_prefix, '{}.clone'.format(permission_prefix), + "Clone an existing {0} as a new {0}".format(model_title)) + config.add_route('{}.clone'.format(route_prefix), '{}/{{{}}}/clone'.format(url_prefix, model_key)) + config.add_view(cls, attr='clone', route_name='{}.clone'.format(route_prefix), + permission='{}.clone'.format(permission_prefix)) + # download if cls.downloadable: config.add_route('{}.download'.format(route_prefix), '{}/{{{}}}/download'.format(url_prefix, model_key)) diff --git a/tailbone/views/upgrades.py b/tailbone/views/upgrades.py index 1dacb2f1..fab03016 100644 --- a/tailbone/views/upgrades.py +++ b/tailbone/views/upgrades.py @@ -53,6 +53,7 @@ class UpgradeView(MasterView): model_class = model.Upgrade executable = True downloadable = True + cloneable = True labels = { 'executed_by': "Executed by", 'status_code': "Status", @@ -165,6 +166,20 @@ class UpgradeView(MasterView): f.remove_field('package_diff') f.remove_field('exit_code') + def configure_clone_form(self, f): + f.fields = ['description', 'notes', 'enabled'] + + def clone_instance(self, original): + cloned = self.model_class() + cloned.created = make_utc() + cloned.created_by = self.request.user + cloned.description = original.description + cloned.notes = original.notes + cloned.enabled = original.enabled + self.Session.add(cloned) + self.Session.flush() + return cloned + def render_stdout_file(self, upgrade, fieldname): if fieldname.startswith('stderr'): filename = 'stderr.log' From fbd73a48c43b461f4a8109163fcd67ed36925208 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 8 Aug 2017 21:43:04 -0500 Subject: [PATCH 0421/3196] Fix status when cloning upgrade --- tailbone/views/upgrades.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tailbone/views/upgrades.py b/tailbone/views/upgrades.py index fab03016..b02ac095 100644 --- a/tailbone/views/upgrades.py +++ b/tailbone/views/upgrades.py @@ -147,6 +147,8 @@ class UpgradeView(MasterView): f.remove_field('stderr_file') if self.creating: f.remove_field('status_code') + else: + f.set_readonly('status_code') if self.creating or not upgrade.executed: f.remove_field('executed') f.remove_field('executed_by') @@ -175,6 +177,7 @@ class UpgradeView(MasterView): cloned.created_by = self.request.user cloned.description = original.description cloned.notes = original.notes + cloned.status_code = self.enum.UPGRADE_STATUS_PENDING cloned.enabled = original.enabled self.Session.add(cloned) self.Session.flush() From e5b0fe7198846d02bdc1bbbd9fb6a721fd7bafd0 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 9 Aug 2017 11:44:31 -0500 Subject: [PATCH 0422/3196] Add running display of stdout.log when executing upgrade --- tailbone/static/css/progress.css | 59 ++++++++++++++ tailbone/templates/progress.mako | 135 ++++++++++++------------------- tailbone/templates/upgrade.mako | 77 ++++++++++++++++++ tailbone/views/core.py | 7 +- tailbone/views/master.py | 12 ++- tailbone/views/upgrades.py | 45 ++++++++++- 6 files changed, 243 insertions(+), 92 deletions(-) create mode 100644 tailbone/static/css/progress.css create mode 100644 tailbone/templates/upgrade.mako diff --git a/tailbone/static/css/progress.css b/tailbone/static/css/progress.css new file mode 100644 index 00000000..84aab4e4 --- /dev/null +++ b/tailbone/static/css/progress.css @@ -0,0 +1,59 @@ + +/******************************************************************************** + * progress.css + * + * Styles for progress bar page. + ********************************************************************************/ + + +/****************************** + * general + ******************************/ + +#body-wrapper { + position: relative; +} + +#wrapper { + height: 60px; + left: 50%; + margin-top: -45px; + margin-left: -350px; + position: absolute; + top: 50%; + width: 700px; +} + +/****************************** + * progress bar + ******************************/ + +#progress-wrapper { + border-collapse: collapse; +} + +#progress { + border-collapse: collapse; + height: 25px; + width: 550px; +} + +#complete { + background-color: Gray; + width: 0px; +} + +#remaining { + background-color: LightGray; + width: 100%; +} + +#percentage { + padding-left: 3px; + min-width: 50px; + width: 50px; +} + +#cancel .ui-button-text { + white-space: nowrap; +} diff --git a/tailbone/templates/progress.mako b/tailbone/templates/progress.mako index 4bd2b4bd..c378a986 100644 --- a/tailbone/templates/progress.mako +++ b/tailbone/templates/progress.mako @@ -5,98 +5,19 @@ - Working... + ${initial_msg or "Working"}... ${core_javascript()} ${h.stylesheet_link(request.static_url('tailbone:static/css/normalize.css'))} ${jquery_theme()} ${h.stylesheet_link(request.static_url('tailbone:static/css/base.css'))} ${h.stylesheet_link(request.static_url('tailbone:static/css/layout.css'))} - - + + +<%def name="extra_styles()"> + +<%def name="after_progress()"> diff --git a/tailbone/templates/upgrade.mako b/tailbone/templates/upgrade.mako new file mode 100644 index 00000000..fad77dd3 --- /dev/null +++ b/tailbone/templates/upgrade.mako @@ -0,0 +1,77 @@ +## -*- coding: utf-8; -*- +<%inherit file="/progress.mako" /> + +<%def name="update_progress_func()"> + + + +<%def name="extra_styles()"> + ${parent.extra_styles()} + + + +<%def name="after_progress()"> +
        + + +${parent.body()} diff --git a/tailbone/views/core.py b/tailbone/views/core.py index c1a2f0fe..ba3d1f9b 100644 --- a/tailbone/views/core.py +++ b/tailbone/views/core.py @@ -90,12 +90,15 @@ class View(object): def progress_loop(self, func, items, factory, *args, **kwargs): return progress_loop(func, items, factory, *args, **kwargs) - def render_progress(self, progress, kwargs): + # TODO: this signature seems wonky + def render_progress(self, progress, kwargs, template=None): """ Render the progress page, with given kwargs as context. """ + if not template: + template = '/progress.mako' kwargs['progress'] = progress - return render_to_response('/progress.mako', kwargs, request=self.request) + return render_to_response(template, kwargs, request=self.request) def file_response(self, path): """ diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 6163bd98..173d4ff8 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -71,8 +71,10 @@ class MasterView(View): bulk_deletable = False mergeable = False downloadable = False - executable = False cloneable = False + executable = False + execute_progress_template = None + execute_progress_initial_msg = None supports_mobile = False mobile_creatable = False @@ -841,20 +843,22 @@ class MasterView(View): model_title = self.get_model_title() if self.request.method == 'POST': - progress = self.make_execute_progress() + progress = self.make_execute_progress(obj) kwargs = {'progress': progress} thread = Thread(target=self.execute_thread, args=(obj.uuid, self.request.user.uuid), kwargs=kwargs) thread.start() return self.render_progress(progress, { + 'instance': obj, + 'initial_msg': self.execute_progress_initial_msg, 'cancel_url': self.get_action_url('view', obj), 'cancel_msg': "{} execution was canceled".format(model_title), - }) + }, template=self.execute_progress_template) self.request.session.flash("Sorry, you must POST to execute a {}.".format(model_title), 'error') return self.redirect(self.get_action_url('view', obj)) - def make_execute_progress(self): + def make_execute_progress(self, obj): key = '{}.execute'.format(self.get_grid_key()) return SessionProgress(self.request, key) diff --git a/tailbone/views/upgrades.py b/tailbone/views/upgrades.py index b02ac095..0ef43e5f 100644 --- a/tailbone/views/upgrades.py +++ b/tailbone/views/upgrades.py @@ -43,7 +43,7 @@ from deform import widget as dfwidget from webhelpers2.html import tags from tailbone.views import MasterView3 as MasterView -from tailbone.progress import SessionProgress +from tailbone.progress import SessionProgress, get_progress_session class UpgradeView(MasterView): @@ -51,9 +51,12 @@ class UpgradeView(MasterView): Master view for all user events """ model_class = model.Upgrade - executable = True downloadable = True cloneable = True + executable = True + execute_progress_template = '/upgrade.mako' + execute_progress_initial_msg = "Upgrading" + labels = { 'executed_by': "Executed by", 'status_code': "Status", @@ -281,6 +284,44 @@ class UpgradeView(MasterView): upgrade.executed = make_utc() upgrade.executed_by = user + def execute_progress(self): + upgrade = self.get_instance() + key = '{}.execute'.format(self.get_grid_key()) + session = get_progress_session(self.request, key) + if session.get('complete'): + msg = session.get('success_msg') + if msg: + self.request.session.flash(msg) + elif session.get('error'): + self.request.session.flash(session.get('error_msg', "An unspecified error occurred."), 'error') + data = dict(session) + + path = self.rattail_config.upgrade_filepath(upgrade.uuid, filename='stdout.log') + offset = session.get('stdout.offset', 0) + size = os.path.getsize(path) - offset + with open(path, 'rb') as f: + f.seek(offset) + chunk = f.read(size) + data['stdout'] = chunk.replace('\n', '
        ') + session['stdout.offset'] = offset + size + session.save() + + return data + + @classmethod + def defaults(cls, config): + route_prefix = cls.get_route_prefix() + url_prefix = cls.get_url_prefix() + permission_prefix = cls.get_permission_prefix() + model_key = cls.get_model_key() + + # execution progress + config.add_route('{}.execute_progress'.format(route_prefix), '{}/{{{}}}/execute/progress'.format(url_prefix, model_key)) + config.add_view(cls, attr='execute_progress', route_name='{}.execute_progress'.format(route_prefix), + permission='{}.execute'.format(permission_prefix), renderer='json') + + cls._defaults(config) + def includeme(config): UpgradeView.defaults(config) From 773a0c769d644bf5f84f8450eacf7f048f53747f Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 9 Aug 2017 11:56:25 -0500 Subject: [PATCH 0423/3196] Fix upgrade stdout handling if file doesn't exist yet plus some other tweaks.. --- tailbone/templates/upgrade.mako | 12 +++++++----- tailbone/views/master.py | 4 ++++ tailbone/views/upgrades.py | 15 ++++++++------- 3 files changed, 19 insertions(+), 12 deletions(-) diff --git a/tailbone/templates/upgrade.mako b/tailbone/templates/upgrade.mako index fad77dd3..bb512510 100644 --- a/tailbone/templates/upgrade.mako +++ b/tailbone/templates/upgrade.mako @@ -12,11 +12,13 @@ location.href = '${cancel_url}'; } else { - var stdout = $('.stdout'); - var height = $(window).height() - stdout.offset().top - 50; - stdout.height(height); - stdout.append(data.stdout); - stdout.animate({scrollTop: stdout.get(0).scrollHeight - height}, 250); + if (data.stdout) { + var stdout = $('.stdout'); + var height = $(window).height() - stdout.offset().top - 50; + stdout.height(height); + stdout.append(data.stdout); + stdout.animate({scrollTop: stdout.get(0).scrollHeight - height}, 250); + } if (data.complete || data.maximum) { $('#message').html(data.message); diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 173d4ff8..6fb33572 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -895,6 +895,10 @@ class MasterView(View): progress.session['success_url'] = success_url progress.session.save() + def execute_error_message(self, error): + return "Execution of {} failed: {}: {}".format(self.get_model_title(), + type(error).__name__, error) + def get_execute_success_url(self, obj, **kwargs): return self.get_action_url('view', obj, **kwargs) diff --git a/tailbone/views/upgrades.py b/tailbone/views/upgrades.py index 0ef43e5f..458b2e5d 100644 --- a/tailbone/views/upgrades.py +++ b/tailbone/views/upgrades.py @@ -298,13 +298,14 @@ class UpgradeView(MasterView): path = self.rattail_config.upgrade_filepath(upgrade.uuid, filename='stdout.log') offset = session.get('stdout.offset', 0) - size = os.path.getsize(path) - offset - with open(path, 'rb') as f: - f.seek(offset) - chunk = f.read(size) - data['stdout'] = chunk.replace('\n', '
        ') - session['stdout.offset'] = offset + size - session.save() + if os.path.exists(path): + size = os.path.getsize(path) - offset + with open(path, 'rb') as f: + f.seek(offset) + chunk = f.read(size) + data['stdout'] = chunk.replace('\n', '
        ') + session['stdout.offset'] = offset + size + session.save() return data From fcffe0f79d10ebb77f338348f6707e7e7f21e3ed Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 9 Aug 2017 14:16:53 -0500 Subject: [PATCH 0424/3196] Update changelog --- CHANGES.rst | 10 ++++++++++ tailbone/_version.py | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 42d70d2d..7553fea8 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,16 @@ CHANGELOG ========= +0.6.26 (2017-08-09) +------------------- + +* Add awareness of upgrade exit code, success/fail + +* Add support for cloning an upgrade record + +* Add running display of stdout.log when executing upgrade + + 0.6.25 (2017-08-08) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index d2c7b9e7..33376489 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.6.25' +__version__ = '0.6.26' From 18f4b4ff5c4fae6f7f9a2e0ace9f26fe56b74b30 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 9 Aug 2017 21:41:42 -0500 Subject: [PATCH 0425/3196] Various changes to support a certain new app improve inventory support, plus "hiding" person data but still using it --- tailbone/views/batch/core.py | 1 + tailbone/views/batch/core2.py | 3 +++ tailbone/views/customers.py | 37 +++++++++++++++++++++++------------ tailbone/views/inventory.py | 11 ++++++++++- tailbone/views/master.py | 2 +- tailbone/views/products.py | 2 ++ tailbone/views/users.py | 26 ++++++++++++++++++++---- 7 files changed, 64 insertions(+), 18 deletions(-) diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index d0ec1245..24cd7850 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -274,6 +274,7 @@ class BatchMasterView(MasterView): kwargs['created_by'] = batch.created_by elif batch.created_by_uuid: kwargs['created_by_uuid'] = batch.created_by_uuid + kwargs['description'] = batch.description kwargs['notes'] = batch.notes if hasattr(batch, 'filename'): kwargs['filename'] = batch.filename diff --git a/tailbone/views/batch/core2.py b/tailbone/views/batch/core2.py index d9a36f3f..d2542803 100644 --- a/tailbone/views/batch/core2.py +++ b/tailbone/views/batch/core2.py @@ -80,6 +80,9 @@ class BatchMasterView2(MasterView2, BatchMasterView): g.set_renderer('id', self.render_batch_id) g.set_link('id') + g.set_link('description') + g.set_link('created') + g.set_link('executed') g.set_label('id', "Batch ID") g.set_label('created_by', "Created by") diff --git a/tailbone/views/customers.py b/tailbone/views/customers.py index 4ef932ca..0a4b8649 100644 --- a/tailbone/views/customers.py +++ b/tailbone/views/customers.py @@ -31,6 +31,7 @@ import re import sqlalchemy as sa from sqlalchemy import orm +import formalchemy as fa from pyramid.httpexceptions import HTTPNotFound from tailbone import forms @@ -127,26 +128,29 @@ class CustomersView(MasterView): fs.id.set(label="ID") fs.append(forms.fields.DefaultPhoneField('default_phone', label="Phone Number")) fs.append(forms.fields.DefaultEmailField('default_email', label="Email Address")) - fs.email_preference.set(renderer=forms.EnumFieldRenderer(self.enum.EMAIL_PREFERENCE)) + fs.email_preference.set(renderer=forms.EnumFieldRenderer(self.enum.EMAIL_PREFERENCE), + attrs={'auto-enhance': 'true'}) + fs.email_preference._null_option = ("(no preference)", '') fs.append(forms.AssociationProxyField('people', renderer=forms.renderers.PeopleFieldRenderer, readonly=True)) fs.active_in_pos.set(label="Active in POS") fs.active_in_pos_sticky.set(label="Always Active in POS") def configure_fieldset(self, fs): - fs.configure( - include=[ - fs.id, - fs.name, - # fs.phone.label("Phone Number").readonly(), - fs.default_phone, - # fs.email.label("Email Address").readonly(), - fs.default_email, - fs.email_preference, + include = [ + fs.id, + fs.name, + fs.default_phone, + fs.default_email, + fs.email_preference, + fs.active_in_pos, + fs.active_in_pos_sticky, + ] + if not self.creating: + include.extend([ fs.people, - fs.active_in_pos, - fs.active_in_pos_sticky, ]) + fs.configure(include=include) def configure_mobile_fieldset(self, fs): fs.configure( @@ -164,6 +168,15 @@ class CustomersView(MasterView): ] +def unique_id(value, field): + customer = field.parent.model + query = Session.query(model.Customer).filter(model.Customer.id == value) + if customer.uuid: + query = query.filter(model.Customer.uuid != customer.uuid) + if query.count(): + raise fa.ValidationError("Customer ID must be unique") + + class CustomerNameAutocomplete(AutocompleteView): """ Autocomplete view which operates on customer name. diff --git a/tailbone/views/inventory.py b/tailbone/views/inventory.py index cff5f134..dff7f435 100644 --- a/tailbone/views/inventory.py +++ b/tailbone/views/inventory.py @@ -59,6 +59,7 @@ class InventoryBatchView(BatchMasterView): 'id', 'created', 'created_by', + 'description', 'mode', 'rowcount', 'total_cost', @@ -76,6 +77,7 @@ class InventoryBatchView(BatchMasterView): 'brand_name', 'description', 'size', + 'previous_units_on_hand', 'cases', 'units', 'unit_cost', @@ -99,7 +101,7 @@ class InventoryBatchView(BatchMasterView): def _preconfigure_fieldset(self, fs): super(InventoryBatchView, self)._preconfigure_fieldset(fs) fs.mode.set(renderer=forms.renderers.EnumFieldRenderer(self.enum.INVENTORY_MODE), - label="Count Mode") + label="Count Mode", required=True, attrs={'auto-enhance': 'true'}) fs.total_cost.set(readonly=True, renderer=forms.renderers.CurrencyFieldRenderer) fs.append(fa.Field('handheld_batches', renderer=forms.renderers.HandheldBatchesFieldRenderer, readonly=True, value=lambda b: b._handhelds)) @@ -108,6 +110,7 @@ class InventoryBatchView(BatchMasterView): fs.configure( include=[ fs.id, + fs.description, fs.created, fs.created_by, fs.handheld_batches, @@ -118,6 +121,8 @@ class InventoryBatchView(BatchMasterView): fs.executed, fs.executed_by, ]) + if not self.creating: + fs.mode.set(readonly=True) def save_edit_row_form(self, form): row = form.fieldset.model @@ -241,6 +246,7 @@ class InventoryBatchView(BatchMasterView): def configure_row_grid(self, g): super(InventoryBatchView, self).configure_row_grid(g) + g.set_type('previous_units_on_hand', 'quantity') g.set_type('cases', 'quantity') g.set_type('units', 'quantity') g.set_type('unit_cost', 'currency') @@ -254,6 +260,7 @@ class InventoryBatchView(BatchMasterView): g.set_label('upc', "UPC") g.set_label('brand_name', "Brand") g.set_label('status_code', "Status") + g.set_label('previous_units_on_hand', "Prev. On Hand") def row_grid_extra_class(self, row, i): if row.status_code == row.STATUS_PRODUCT_NOT_FOUND: @@ -273,6 +280,7 @@ class InventoryBatchView(BatchMasterView): fs.brand_name.set(readonly=True) fs.description.set(readonly=True) fs.size.set(readonly=True) + fs.previous_units_on_hand.set(label="Prev. On Hand") fs.cases.set(renderer=forms.renderers.QuantityFieldRenderer) fs.units.set(renderer=forms.renderers.QuantityFieldRenderer) fs.unit_cost.set(readonly=True, renderer=forms.renderers.CurrencyFieldRenderer) @@ -287,6 +295,7 @@ class InventoryBatchView(BatchMasterView): fs.description, fs.size, fs.status_code, + fs.previous_units_on_hand, fs.cases, fs.units, fs.unit_cost, diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 6fb33572..7d2c943a 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -1650,7 +1650,7 @@ class MasterView(View): """ def redirect_after_edit_row(self, row, mobile=False): - return self.redirect(self.get_row_action_url('view', row, mobile=True)) + return self.redirect(self.get_row_action_url('view', row, mobile=mobile)) def row_deletable(self, row): """ diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 10f85361..a7bc02ae 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -256,6 +256,8 @@ class ProductsView(MasterView): cost = product.cost if not cost: return "" + if cost.unit_cost is None: + return "" return "${:0.2f}".format(cost.unit_cost) def render_on_hand(self, product, column): diff --git a/tailbone/views/users.py b/tailbone/views/users.py index 53649789..d17722dd 100644 --- a/tailbone/views/users.py +++ b/tailbone/views/users.py @@ -159,6 +159,7 @@ class UsersView(PrincipalMasterView): def query(self, session): return session.query(model.User)\ + .outerjoin(model.Person)\ .options(orm.joinedload(model.User.person)) def configure_grid(self, g): @@ -177,28 +178,44 @@ class UsersView(PrincipalMasterView): g.filters['password'] = g.make_filter('password', model.User.password, verbs=['is_null', 'is_not_null']) - g.sorters['person'] = lambda q, d: q.order_by(getattr(model.Person.display_name, d)()) + g.set_sorter('person', model.Person.display_name) + g.set_sorter('first_name', model.Person.first_name) + g.set_sorter('last_name', model.Person.last_name) + g.set_sorter('display_name', model.Person.display_name) g.default_sortkey = 'username' g.set_label('person', "Person's Name") g.set_link('username') g.set_link('person') + g.set_link('first_name') + g.set_link('last_name') + g.set_link('display_name') def _preconfigure_fieldset(self, fs): fs.username.set(renderer=forms.renderers.StrippedTextFieldRenderer, validate=unique_username) fs.person.set(renderer=forms.renderers.PersonFieldRenderer, options=[]) fs.append(PasswordField('password', label="Set Password")) - fs.password.attrs(autocomplete='off') fs.append(formalchemy.Field('confirm_password', renderer=PasswordFieldRenderer)) - fs.confirm_password.attrs(autocomplete='off') fs.append(RolesField('roles', renderer=RolesFieldRenderer(self.request), size=10)) + fs.append(forms.AssociationProxyField('first_name')) + fs.append(forms.AssociationProxyField('last_name')) + fs.append(forms.AssociationProxyField('display_name', label="Full Name")) + + # hm this should work according to MDN but doesn't seem to... + # https://developer.mozilla.org/en-US/docs/Web/Security/Securing_your_site/Turning_off_form_autocompletion + fs.username.attrs(autocomplete='new-password') + fs.password.attrs(autocomplete='new-password') + fs.confirm_password.attrs(autocomplete='new-password') def configure_fieldset(self, fs): fs.configure( include=[ fs.username, fs.person, + fs.first_name, + fs.last_name, + fs.display_name, fs.active, fs.active_sticky, fs.password, @@ -326,7 +343,8 @@ class UserEventsView(MasterView): return event.user.username def render_person(self, event, column): - return event.user.person.display_name + if event.user.person: + return event.user.person.display_name def includeme(config): From a3e7556a0641a54afbafe065a381718503464071 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 9 Aug 2017 22:34:03 -0500 Subject: [PATCH 0426/3196] Fix encoding bug when reading stdout during upgrade --- tailbone/views/upgrades.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/views/upgrades.py b/tailbone/views/upgrades.py index 458b2e5d..109aed0c 100644 --- a/tailbone/views/upgrades.py +++ b/tailbone/views/upgrades.py @@ -303,7 +303,7 @@ class UpgradeView(MasterView): with open(path, 'rb') as f: f.seek(offset) chunk = f.read(size) - data['stdout'] = chunk.replace('\n', '
        ') + data['stdout'] = chunk.decode('utf8').replace('\n', '
        ') session['stdout.offset'] = offset + size session.save() From 3c3300a54122092ae5a3557aa11aa44644a54994 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 9 Aug 2017 22:53:11 -0500 Subject: [PATCH 0427/3196] 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 7553fea8..01d02306 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,14 @@ CHANGELOG ========= +0.6.27 (2017-08-09) +------------------- + +* Improve inventory support, plus "hiding" person data while still using it + +* Fix encoding bug when reading stdout during upgrade + + 0.6.26 (2017-08-09) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 33376489..d2fe1162 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.6.26' +__version__ = '0.6.27' From 4f2bf5431df35908e03102d50f3aa8206fbab81b Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 9 Aug 2017 23:15:38 -0500 Subject: [PATCH 0428/3196] Fix clone config bug for label batches --- tailbone/views/batch/core.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index 24cd7850..24811611 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -1033,14 +1033,6 @@ class BatchMasterView(MasterView): config.add_tailbone_permission(permission_prefix, '{}.csv'.format(permission_prefix), "Download {} rows as CSV".format(model_title)) - # clone as new batch - if cls.cloneable: - config.add_route('{}.clone'.format(route_prefix), '{}/{{uuid}}/clone'.format(url_prefix)) - config.add_view(cls, attr='clone', route_name='{}.clone'.format(route_prefix), - permission='{}.clone'.format(permission_prefix)) - config.add_tailbone_permission(permission_prefix, '{}.clone'.format(permission_prefix), - "Clone {} as new batch".format(model_title)) - class FileBatchMasterView(BatchMasterView): """ From 1d9489169b2a6805fc7bd270647c668713dcc3fb Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 9 Aug 2017 23:16:13 -0500 Subject: [PATCH 0429/3196] 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 01d02306..27b934d6 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.6.28 (2017-08-09) +------------------- + +* Fix clone config bug for label batches + + 0.6.27 (2017-08-09) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index d2fe1162..9889b166 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.6.27' +__version__ = '0.6.28' From 0ad2113b819a373189ec11336c9977c5a2450c9f Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 10 Aug 2017 11:10:42 -0500 Subject: [PATCH 0430/3196] Various tweaks to inventory batch logic really to support zero-all mode, but several generic changes too --- tailbone/templates/batch/view.mako | 2 +- tailbone/views/batch/core.py | 16 ++++++---------- tailbone/views/inventory.py | 15 +++++++++++++++ tailbone/views/master.py | 22 ++++++++++++++++++++-- 4 files changed, 42 insertions(+), 13 deletions(-) diff --git a/tailbone/templates/batch/view.mako b/tailbone/templates/batch/view.mako index a7da6e54..a1de0863 100644 --- a/tailbone/templates/batch/view.mako +++ b/tailbone/templates/batch/view.mako @@ -57,7 +57,7 @@ <%def name="leading_buttons()"> - % if master.has_worksheet and not batch.executed and request.has_perm('{}.worksheet'.format(permission_prefix)): + % if master.has_worksheet and master.allow_worksheet(batch) and request.has_perm('{}.worksheet'.format(permission_prefix)): % endif diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index 24811611..2213163f 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -113,6 +113,9 @@ class BatchMasterView(MasterView): kwargs['rendered_execution_options'] = self.render_execution_options(batch) return kwargs + def allow_worksheet(self, batch): + return not batch.executed and not batch.complete + def template_kwargs_index(self, **kwargs): kwargs['execute_enabled'] = self.instance_executable(None) if kwargs['execute_enabled'] and self.has_execution_options: @@ -161,7 +164,8 @@ class BatchMasterView(MasterView): fs.cognized_by.set(label="Cognized by", renderer=forms.renderers.UserFieldRenderer) fs.rowcount.set(label="Row Count", readonly=True) fs.status_code.set(label="Status", renderer=StatusRenderer(self.model_class.STATUS)) - fs.executed_by.set(label="Executed by", renderer=forms.renderers.UserFieldRenderer) + fs.executed.set(readonly=True) + fs.executed_by.set(label="Executed by", renderer=forms.renderers.UserFieldRenderer, readonly=True) fs.notes.set(renderer=fa.TextAreaFieldRenderer, size=(80, 10)) if self.creating and self.request.user: @@ -310,7 +314,7 @@ class BatchMasterView(MasterView): """ self.editing = True batch = self.get_instance() - if batch.executed: + if not self.editable_instance(batch): return self.redirect(self.get_action_url('view', batch)) if self.edit_with_rows: @@ -685,20 +689,12 @@ class BatchMasterView(MasterView): batch = row.batch return self.rows_editable and not batch.executed and not batch.complete - def row_edit_action_url(self, row, i): - if self.row_editable(row): - return self.get_row_action_url('edit', row) - def row_deletable(self, row): """ Batch rows are deletable only until batch has been executed. """ return self.rows_deletable and not row.batch.executed - def row_delete_action_url(self, row, i): - if self.row_deletable(row): - return self.get_row_action_url('delete', row) - def _preconfigure_row_fieldset(self, fs): fs.sequence.set(readonly=True) fs.status_code.set(renderer=StatusRenderer(self.model_row_class.STATUS), diff --git a/tailbone/views/inventory.py b/tailbone/views/inventory.py index dff7f435..03ef10ba 100644 --- a/tailbone/views/inventory.py +++ b/tailbone/views/inventory.py @@ -98,6 +98,15 @@ class InventoryBatchView(BatchMasterView): batch.created_by, localtime(self.request.rattail_config, batch.created, from_utc=True).strftime('%Y-%m-%d')) + def editable_instance(self, batch): + return True + + def mutable_batch(self, batch): + return not batch.executed and not batch.complete and batch.mode != self.enum.INVENTORY_MODE_ZERO_ALL + + def allow_worksheet(self, batch): + return self.mutable_batch(batch) + def _preconfigure_fieldset(self, fs): super(InventoryBatchView, self)._preconfigure_fieldset(fs) fs.mode.set(renderer=forms.renderers.EnumFieldRenderer(self.enum.INVENTORY_MODE), @@ -124,6 +133,12 @@ class InventoryBatchView(BatchMasterView): if not self.creating: fs.mode.set(readonly=True) + def row_editable(self, row): + return self.mutable_batch(row.batch) + + def row_deletable(self, row): + return self.mutable_batch(row.batch) + def save_edit_row_form(self, form): row = form.fieldset.model batch = row.batch diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 7d2c943a..2aea4523 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -634,11 +634,29 @@ class MasterView(View): """ return "{}.rows".format(cls.get_permission_prefix()) + def row_editable(self, row): + """ + Returns boolean indicating whether or not the given row can be + considered "editable". Returns ``True`` by default; override as + necessary. + """ + return True + def row_edit_action_url(self, row, i): - return self.get_row_action_url('edit', row) + if self.row_editable(row): + return self.get_row_action_url('edit', row) + + def row_deletable(self, row): + """ + Returns boolean indicating whether or not the given row can be + considered "deletable". Returns ``True`` by default; override as + necessary. + """ + return True def row_delete_action_url(self, row, i): - return self.get_row_action_url('delete', row) + if self.row_deletable(row): + return self.get_row_action_url('delete', row) def row_grid_row_attrs(self, row, i): return {} From 46a43981df8c573e3157394a3dc3d82225757392 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 10 Aug 2017 15:32:04 -0500 Subject: [PATCH 0431/3196] Fix join bug for users grid --- tailbone/views/users.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tailbone/views/users.py b/tailbone/views/users.py index d17722dd..fc374e20 100644 --- a/tailbone/views/users.py +++ b/tailbone/views/users.py @@ -165,8 +165,6 @@ class UsersView(PrincipalMasterView): def configure_grid(self, g): super(UsersView, self).configure_grid(g) - g.joiners['person'] = lambda q: q.outerjoin(model.Person) - del g.filters['password'] del g.filters['salt'] g.filters['username'].default_active = True From 41e09271a2ffbc7ff472a720a0b7c3048496fad7 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 10 Aug 2017 16:32:03 -0500 Subject: [PATCH 0432/3196] Flush session once every 1000 records when bulk-deleting --- tailbone/views/master.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 2aea4523..28e0efd5 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -816,6 +816,8 @@ class MasterView(View): def delete(obj, i): session.delete(obj) + if i % 1000 == 0: + session.flush() self.progress_loop(delete, objects, progress, message="Deleting objects") From d51b9d2ad74ab669b247f85789647992c93081f1 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 11 Aug 2017 11:55:24 -0500 Subject: [PATCH 0433/3196] Update changelog --- CHANGES.rst | 10 ++++++++++ tailbone/_version.py | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 27b934d6..6744c56e 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,16 @@ CHANGELOG ========= +0.6.29 (2017-08-11) +------------------- + +* Various tweaks to inventory batch logic (zero-all mode etc.) + +* Fix join bug for users grid + +* Flush session once every 1000 records when bulk-deleting + + 0.6.28 (2017-08-09) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 9889b166..fbd37f3f 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.6.28' +__version__ = '0.6.29' From bf09071e1df2243242a332c4e5eee31c2cc75401 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 12 Aug 2017 20:26:11 -0500 Subject: [PATCH 0434/3196] Make product field renderer allow override of link text rendering --- tailbone/forms/renderers/products.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tailbone/forms/renderers/products.py b/tailbone/forms/renderers/products.py index 20843111..daca5dc5 100644 --- a/tailbone/forms/renderers/products.py +++ b/tailbone/forms/renderers/products.py @@ -58,9 +58,14 @@ class ProductFieldRenderer(AutocompleteFieldRenderer): def render_readonly(self, **kwargs): product = self.raw_value if not product: - return '' + return "" + render = kwargs.get('render_product', self.render_product) + text = render(product) if kwargs.get('hyperlink', True): - return tags.link_to(product, self.request.route_url('products.view', uuid=product.uuid)) + return tags.link_to(text, self.request.route_url('products.view', uuid=product.uuid)) + return text + + def render_product(self, product): return six.text_type(product) From 24d89db025d7c99960b5e436ed83ecc6f77d67a0 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 12 Aug 2017 20:40:58 -0500 Subject: [PATCH 0435/3196] 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 6744c56e..98bcadd7 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.6.30 (2017-08-12) +------------------- + +* Make product field renderer allow override of link text rendering + + 0.6.29 (2017-08-11) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index fbd37f3f..a3205364 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.6.29' +__version__ = '0.6.30' From 4b5e4151472f13d5720dcd3c812a7a8af0c72717 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 12 Aug 2017 22:38:23 -0500 Subject: [PATCH 0436/3196] Add show all vs. show diffs for upgrade packages plus some related tweaks --- tailbone/static/css/diffs.css | 3 --- tailbone/templates/forms2/deform.mako | 2 +- tailbone/templates/upgrades/view.mako | 35 +++++++++++++++++++++++++++ tailbone/views/upgrades.py | 10 ++++++-- 4 files changed, 44 insertions(+), 6 deletions(-) diff --git a/tailbone/static/css/diffs.css b/tailbone/static/css/diffs.css index f76b336d..81a4c8e7 100644 --- a/tailbone/static/css/diffs.css +++ b/tailbone/static/css/diffs.css @@ -10,9 +10,6 @@ table.diff th, table.diff td { border-bottom: 1px solid Black; border-right: 1px solid Black; -} - -table.diff td { padding: 2px 5px; } diff --git a/tailbone/templates/forms2/deform.mako b/tailbone/templates/forms2/deform.mako index afc08096..29d264ea 100644 --- a/tailbone/templates/forms2/deform.mako +++ b/tailbone/templates/forms2/deform.mako @@ -72,7 +72,7 @@ ${h.csrf_token(request)} ## % if form.creating and form.allow_successive_creates: ## ${h.submit('create_and_continue', form.successive_create_label)} ## % endif - ${h.link_to("Cancel", form.cancel_url, class_='button')} + ${h.link_to("Cancel", form.cancel_url, class_='button autodisable')} % endif diff --git a/tailbone/templates/upgrades/view.mako b/tailbone/templates/upgrades/view.mako index d65e874d..e6d2a86e 100644 --- a/tailbone/templates/upgrades/view.mako +++ b/tailbone/templates/upgrades/view.mako @@ -1,6 +1,41 @@ ## -*- coding: utf-8; -*- <%inherit file="/master/view.mako" /> +<%def name="extra_javascript()"> + ${parent.extra_javascript()} + + + ${parent.body()} % if not instance.executed and request.has_perm('{}.execute'.format(permission_prefix)): diff --git a/tailbone/views/upgrades.py b/tailbone/views/upgrades.py index 109aed0c..60d5d484 100644 --- a/tailbone/views/upgrades.py +++ b/tailbone/views/upgrades.py @@ -40,7 +40,7 @@ from rattail.threads import Thread from rattail.upgrades import get_upgrade_handler from deform import widget as dfwidget -from webhelpers2.html import tags +from webhelpers2.html import tags, HTML from tailbone.views import MasterView3 as MasterView from tailbone.progress import SessionProgress, get_progress_session @@ -206,7 +206,13 @@ class UpgradeView(MasterView): columns=["package", "old version", "new version"], render_value=self.render_diff_value, ) - return diff.render_html() + showing = HTML.tag('div', + "showing: " + + tags.link_to("all", '#', class_='all') + + " / " + + tags.link_to("diffs only", '#', class_='diffs'), + class_='showing') + return showing + diff.render_html() except: return "(not available for this upgrade)" From 4360d263e4c384d75c6e29ec05a7deddbb5c09b0 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 12 Aug 2017 22:47:11 -0500 Subject: [PATCH 0437/3196] Test commit --- docs/api/views/master.rst | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/api/views/master.rst b/docs/api/views/master.rst index dca8c423..b953fafa 100644 --- a/docs/api/views/master.rst +++ b/docs/api/views/master.rst @@ -1,4 +1,3 @@ -.. -*- coding: utf-8 -*- ``tailbone.views.master`` ========================= From 55f96c4730b8b0df7176e11188487edb4c289d4e Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 13 Aug 2017 00:24:58 -0500 Subject: [PATCH 0438/3196] Add initial support for changelog links for upgrade package diffs definitely still just playing around so far... --- tailbone/diffs.py | 9 ++++++++- tailbone/templates/diff.mako | 2 +- tailbone/views/upgrades.py | 16 ++++++++++++++++ 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/tailbone/diffs.py b/tailbone/diffs.py index f179128f..2771b7da 100644 --- a/tailbone/diffs.py +++ b/tailbone/diffs.py @@ -35,11 +35,12 @@ class Diff(object): Core diff class. In sore need of documentation. """ - def __init__(self, old_data, new_data, columns=None, fields=None, render_value=None, monospace=False): + def __init__(self, old_data, new_data, columns=None, fields=None, render_field=None, render_value=None, monospace=False): self.old_data = old_data self.new_data = new_data self.columns = columns or ["field name", "old value", "new value"] self.fields = fields or self.make_fields() + self._render_field = render_field or self.render_field_default self.render_value = render_value or self.render_value_default self.monospace = monospace @@ -60,6 +61,12 @@ class Diff(object): context['diff'] = self return HTML.literal(render(template, context)) + def render_field(self, field): + return self._render_field(field, self) + + def render_field_default(self, field, diff): + return field + def render_value_default(self, field, value): return repr(value) diff --git a/tailbone/templates/diff.mako b/tailbone/templates/diff.mako index 7f2132fd..4b8888e6 100644 --- a/tailbone/templates/diff.mako +++ b/tailbone/templates/diff.mako @@ -10,7 +10,7 @@
      % for field in diff.fields: - + diff --git a/tailbone/views/upgrades.py b/tailbone/views/upgrades.py index 60d5d484..c1423678 100644 --- a/tailbone/views/upgrades.py +++ b/tailbone/views/upgrades.py @@ -204,6 +204,7 @@ class UpgradeView(MasterView): after = self.parse_requirements(upgrade, 'after') diff = self.make_diff(before, after, columns=["package", "old version", "new version"], + render_field=self.render_diff_field, render_value=self.render_diff_value, ) showing = HTML.tag('div', @@ -216,6 +217,21 @@ class UpgradeView(MasterView): except: return "(not available for this upgrade)" + def render_diff_field(self, field, diff): + if field == 'rattail': + # TODO: use changelog from latest docs *unless* running from src + # url = 'https://rattailproject.org/buildbot/docs/rattail/changelog.html' + url = 'https://rattailproject.org/trac/log/rattail/?rev={}&stop_rev={}&limit=100'.format( + diff.new_value(field), diff.old_value(field)) + return tags.link_to(field, url, target='_blank') + if field == 'Tailbone': + # TODO: use changelog from latest docs *unless* running from src + # url = 'https://rattailproject.org/buildbot/docs/tailbone/changelog.html' + url = 'https://rattailproject.org/trac/log/tailbone/?rev={}&stop_rev={}&limit=100'.format( + diff.new_value(field), diff.old_value(field)) + return tags.link_to(field, url, target='_blank') + return field + def render_diff_value(self, field, value): if value.startswith("u'") and value.endswith("'"): return value[2:1] From 852bafdfa0af16f62f5c405338a11626fd076aa9 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 13 Aug 2017 16:30:31 -0500 Subject: [PATCH 0439/3196] Improve logic for generating changelog links for upgrade package diffs --- tailbone/views/upgrades.py | 41 +++++++++++++++++++++++++++----------- 1 file changed, 29 insertions(+), 12 deletions(-) diff --git a/tailbone/views/upgrades.py b/tailbone/views/upgrades.py index c1423678..9fbc6ac5 100644 --- a/tailbone/views/upgrades.py +++ b/tailbone/views/upgrades.py @@ -217,19 +217,36 @@ class UpgradeView(MasterView): except: return "(not available for this upgrade)" + def changelog_link(self, project, url): + return tags.link_to(project, url, target='_blank') + + commit_hash_pattern = re.compile(r'^.{40}$') + + def get_changelog_url(self, project, old_version, new_version): + projects = { + 'rattail': 'rattail', + 'Tailbone': 'tailbone', + } + if project not in projects: + return + if self.commit_hash_pattern.match(new_version): + if new_version == old_version: + return 'https://rattailproject.org/trac/log/{}/?rev={}&limit=100'.format( + projects[project], new_version) + else: + return 'https://rattailproject.org/trac/log/{}/?rev={}&stop_rev={}&limit=100'.format( + projects[project], new_version, old_version) + else: + # TODO: use changelog from latest docs + # return 'https://rattailproject.org/buildbot/docs/{}/changelog.html'.format(projects[project]) + pass + def render_diff_field(self, field, diff): - if field == 'rattail': - # TODO: use changelog from latest docs *unless* running from src - # url = 'https://rattailproject.org/buildbot/docs/rattail/changelog.html' - url = 'https://rattailproject.org/trac/log/rattail/?rev={}&stop_rev={}&limit=100'.format( - diff.new_value(field), diff.old_value(field)) - return tags.link_to(field, url, target='_blank') - if field == 'Tailbone': - # TODO: use changelog from latest docs *unless* running from src - # url = 'https://rattailproject.org/buildbot/docs/tailbone/changelog.html' - url = 'https://rattailproject.org/trac/log/tailbone/?rev={}&stop_rev={}&limit=100'.format( - diff.new_value(field), diff.old_value(field)) - return tags.link_to(field, url, target='_blank') + old_version = diff.old_value(field) + new_version = diff.new_value(field) + url = self.get_changelog_url(field, old_version, new_version) + if url: + return self.changelog_link(field, url) return field def render_diff_value(self, field, value): From c0a28716f56d20a9dae823235ed3e7e1c771a321 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 13 Aug 2017 18:28:40 -0500 Subject: [PATCH 0440/3196] Add prev/next buttons when viewing upgrade details --- tailbone/templates/master/view.mako | 18 ++++++++++++++++++ .../templates/themes/better/master/view.mako | 4 +--- tailbone/views/master.py | 1 + tailbone/views/upgrades.py | 13 +++++++++++++ 4 files changed, 33 insertions(+), 3 deletions(-) diff --git a/tailbone/templates/master/view.mako b/tailbone/templates/master/view.mako index e50ebd71..6c3fc928 100644 --- a/tailbone/templates/master/view.mako +++ b/tailbone/templates/master/view.mako @@ -26,6 +26,24 @@ % endif +<%def name="content_title()"> + % if master.supports_prev_next: +
      + % if prev_instance: + ${h.link_to(u"« Older", url('{}.view'.format(route_prefix), uuid=prev_instance.uuid), class_='button')} + % else: + ${h.link_to(u"« Older", '#', class_='button', disabled='disabled')} + % endif + % if next_instance: + ${h.link_to(u"Newer »", url('{}.view'.format(route_prefix), uuid=next_instance.uuid), class_='button')} + % else: + ${h.link_to(u"Newer »", '#', class_='button', disabled='disabled')} + % endif +
      + % endif +

      ${instance_title}

      + + <%def name="context_menu_items()">
    • ${h.link_to("Permalink for this {}".format(model_title), action_url('view', instance))}
    • % if master.has_versions and request.rattail_config.versioning_enabled() and request.has_perm('{}.versions'.format(permission_prefix)): diff --git a/tailbone/templates/themes/better/master/view.mako b/tailbone/templates/themes/better/master/view.mako index 5159a548..455df4d8 100644 --- a/tailbone/templates/themes/better/master/view.mako +++ b/tailbone/templates/themes/better/master/view.mako @@ -1,8 +1,6 @@ ## -*- coding: utf-8; -*- <%inherit file="tailbone:templates/master/view.mako" /> -<%def name="content_title()"> -

      ${instance_title}

      - +## TODO: remove this once it's safe (no callers use it)... ${parent.body()} diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 28e0efd5..c06f1e70 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -75,6 +75,7 @@ class MasterView(View): executable = False execute_progress_template = None execute_progress_initial_msg = None + supports_prev_next = False supports_mobile = False mobile_creatable = False diff --git a/tailbone/views/upgrades.py b/tailbone/views/upgrades.py index 9fbc6ac5..de4cb164 100644 --- a/tailbone/views/upgrades.py +++ b/tailbone/views/upgrades.py @@ -56,6 +56,7 @@ class UpgradeView(MasterView): executable = True execute_progress_template = '/upgrade.mako' execute_progress_initial_msg = "Upgrading" + supports_prev_next = True labels = { 'executed_by': "Executed by", @@ -127,6 +128,18 @@ class UpgradeView(MasterView): if upgrade.status_code == self.enum.UPGRADE_STATUS_EXECUTING: return 'notice' + def template_kwargs_view(self, **kwargs): + upgrade = kwargs['instance'] + upgrades = self.Session.query(model.Upgrade)\ + .filter(model.Upgrade.uuid != upgrade.uuid) + kwargs['prev_instance'] = upgrades.filter(model.Upgrade.created <= upgrade.created)\ + .order_by(model.Upgrade.created.desc())\ + .first() + kwargs['next_instance'] = upgrades.filter(model.Upgrade.created >= upgrade.created)\ + .order_by(model.Upgrade.created)\ + .first() + return kwargs + def configure_form(self, f): super(UpgradeView, self).configure_form(f) f.set_enum('status_code', self.enum.UPGRADE_STATUS) From 7d0bb80a908c01028b38faf26e4767c826519a54 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 13 Aug 2017 19:11:53 -0500 Subject: [PATCH 0441/3196] Merge 'better' theme into base templates i.e. for now there is no 'better' (or any other) theme --- tailbone/app.py | 6 +- tailbone/static/css/base.css | 7 +- tailbone/static/css/forms.css | 4 + tailbone/static/css/grids.css | 5 + tailbone/static/css/layout.css | 94 ++++++++ tailbone/static/css/theme-better.css | 124 ---------- tailbone/templates/base.mako | 223 ++++++++++-------- tailbone/templates/master/create.mako | 6 +- tailbone/templates/master/edit.mako | 3 +- tailbone/templates/master/index.mako | 2 + tailbone/templates/themes/better/base.mako | 154 ------------ .../themes/better/master/create.mako | 6 - .../templates/themes/better/master/edit.mako | 18 -- .../templates/themes/better/master/index.mako | 6 - .../templates/themes/better/master/view.mako | 6 - 15 files changed, 241 insertions(+), 423 deletions(-) delete mode 100644 tailbone/static/css/theme-better.css delete mode 100644 tailbone/templates/themes/better/base.mako delete mode 100644 tailbone/templates/themes/better/master/create.mako delete mode 100644 tailbone/templates/themes/better/master/edit.mako delete mode 100644 tailbone/templates/themes/better/master/index.mako delete mode 100644 tailbone/templates/themes/better/master/view.mako diff --git a/tailbone/app.py b/tailbone/app.py index 556189e4..419c9b70 100644 --- a/tailbone/app.py +++ b/tailbone/app.py @@ -1,4 +1,4 @@ -# -*- coding: utf-8 -*- +# -*- coding: utf-8; -*- ################################################################################ # # Rattail -- Retail Software Framework @@ -175,9 +175,7 @@ def main(global_config, **settings): """ This function returns a Pyramid WSGI application. """ - settings.setdefault('mako.directories', [ - 'tailbone:templates/themes/better', - 'tailbone:templates']) + settings.setdefault('mako.directories', ['tailbone:templates']) rattail_config = make_rattail_config(settings) pyramid_config = make_pyramid_config(settings) pyramid_config.include('tailbone') diff --git a/tailbone/static/css/base.css b/tailbone/static/css/base.css index c59635b3..f3cd2ebe 100644 --- a/tailbone/static/css/base.css +++ b/tailbone/static/css/base.css @@ -13,7 +13,7 @@ body { } a { - color: #3D6E1C; + color: #0972a5; text-decoration: none; } @@ -76,6 +76,11 @@ div.error-messages div.ui-state-error { margin-bottom: 8px; } +.flash-messages, +.error-messages { + margin: 0.5em 0 0 0; +} + div.error { color: #dd6666; font-weight: bold; diff --git a/tailbone/static/css/forms.css b/tailbone/static/css/forms.css index 0338f5ec..a08bf6d5 100644 --- a/tailbone/static/css/forms.css +++ b/tailbone/static/css/forms.css @@ -36,6 +36,10 @@ div.fieldset { margin-top: 10px; } +.form { + padding-left: 5em; +} + /****************************** * Fieldsets diff --git a/tailbone/static/css/grids.css b/tailbone/static/css/grids.css index c9692c85..04cb867f 100644 --- a/tailbone/static/css/grids.css +++ b/tailbone/static/css/grids.css @@ -21,6 +21,11 @@ white-space: nowrap; } +.grid-wrapper .grid-header #context-menu { + float: none; + margin: 0; +} + .grid-wrapper .grid-header td.tools { margin: 0; padding: 0; diff --git a/tailbone/static/css/layout.css b/tailbone/static/css/layout.css index 5c303d21..39e38164 100644 --- a/tailbone/static/css/layout.css +++ b/tailbone/static/css/layout.css @@ -57,6 +57,59 @@ body > #body-wrapper { margin-left: 0.5em; } +/* new stuff from 'better' theme begins here */ + +header .global { + background-color: #eaeaea; + height: 60px; +} + +header .global a.home, +header .global a.global, +header .global span.global { + display: block; + float: left; + font-size: 2em; + font-weight: bold; + line-height: 60px; + margin-left: 10px; +} + +header .global a.home img { + display: block; + float: left; + padding: 5px 5px 5px 30px; +} + +header .global .grid-nav { + display: inline-block; + font-size: 16px; + font-weight: bold; + line-height: 60px; + margin-left: 5em; +} + +header .global .grid-nav .ui-button, +header .global .grid-nav span.viewing { + margin-left: 1em; +} + +header .global .feedback { + float: right; + line-height: 60px; + margin-right: 1em; +} + +header .page { + border-bottom: 1px solid lightgrey; + padding: 0.5em; +} + +header .page h1 { + margin: 0; + padding: 0 0 0 0.5em; +} + /****************************** * Logo ******************************/ @@ -67,6 +120,29 @@ body > #body-wrapper { } +/**************************************** + * content + ****************************************/ + +body > #body-wrapper { + margin: 0px; + position: relative; +} + +.content-wrapper { + height: 100%; + padding-bottom: 30px; +} + +#scrollpane { + height: 100%; +} + +#scrollpane .inner-content { + padding: 0 0.5em 0.5em 0.5em; +} + + /****************************** * context menu ******************************/ @@ -74,7 +150,9 @@ body > #body-wrapper { #context-menu { float: right; list-style-type: none; + margin: 0.5em; text-align: right; + white-space: nowrap; } @@ -110,3 +188,19 @@ body > #body-wrapper { overflow: auto; padding: 5px; } + +/**************************************** + * footer + ****************************************/ + +#footer { + border-top: 1px solid lightgray; + bottom: 0; + font-size: 9pt; + height: 20px; + left: 0; + line-height: 20px; + margin: 0; + position: absolute; + width: 100%; +} diff --git a/tailbone/static/css/theme-better.css b/tailbone/static/css/theme-better.css deleted file mode 100644 index d06584df..00000000 --- a/tailbone/static/css/theme-better.css +++ /dev/null @@ -1,124 +0,0 @@ - -/********************************************************************** - * styles for 'better' theme - **********************************************************************/ - -/**************************************** - * core overrides - ****************************************/ - -a { - color: #0972a5; -} - -.flash-messages, -.error-messages { - margin: 0.5em 0 0 0; -} - -#context-menu { - margin: 0.5em; - white-space: nowrap; -} - -.form { - padding-left: 5em; -} - -.grid-wrapper .grid-header #context-menu { - float: none; - margin: 0; -} - -/**************************************** - * header - ****************************************/ - -header .global { - background-color: #eaeaea; - height: 60px; -} - -header .global a.home, -header .global a.global, -header .global span.global { - display: block; - float: left; - font-size: 2em; - font-weight: bold; - line-height: 60px; - margin-left: 10px; -} - -header .global a.home img { - display: block; - float: left; - padding: 5px 5px 5px 30px; -} - -header .global .grid-nav { - display: inline-block; - font-size: 16px; - font-weight: bold; - line-height: 60px; - margin-left: 5em; -} - -header .global .grid-nav .ui-button, -header .global .grid-nav span.viewing { - margin-left: 1em; -} - -header .global .feedback { - float: right; - line-height: 60px; - margin-right: 1em; -} - -header .page { - border-bottom: 1px solid lightgrey; - padding: 0.5em; -} - -header .page h1 { - margin: 0; - padding: 0 0 0 0.5em; -} - -/**************************************** - * content - ****************************************/ - -body > #body-wrapper { - margin: 0px; - position: relative; -} - -.content-wrapper { - height: 100%; - padding-bottom: 30px; -} - -#scrollpane { - height: 100%; -} - -#scrollpane .inner-content { - padding: 0 0.5em 0.5em 0.5em; -} - -/**************************************** - * footer - ****************************************/ - -#footer { - border-top: 1px solid lightgray; - bottom: 0; - font-size: 9pt; - height: 20px; - left: 0; - line-height: 20px; - margin: 0; - position: absolute; - width: 100%; -} diff --git a/tailbone/templates/base.mako b/tailbone/templates/base.mako index a929dfe1..c4f8b01d 100644 --- a/tailbone/templates/base.mako +++ b/tailbone/templates/base.mako @@ -1,124 +1,132 @@ -## -*- coding: utf-8 -*- +## -*- coding: utf-8; -*- +<%namespace file="/menu.mako" import="main_menu_items" /> +<%namespace file="/grids/nav.mako" import="grid_index_nav" /> - + - ${self.global_title()} » ${capture(self.title)} - - ${self.core_javascript()} - ${self.core_styles()} - ${self.extra_styles()} + ${self.global_title()} » ${capture(self.title)|n} + ${self.favicon()} + ${self.header_core()} + + % if not request.rattail_config.production(): + + % endif + ${self.head_tags()} -
      -
      ${field}${diff.render_field(field)} ${diff.render_old_value(field)} ${diff.render_new_value(field)}
      +
      @@ -110,14 +66,14 @@ % for field in fields_for_version(version): - - + + % endfor
      field name
      ${field}${repr(getattr(version.previous, field))} ${repr(getattr(version.previous, field))} 
      % elif version.previous: - +
      @@ -129,14 +85,14 @@ % for field in fields_for_version(version): - - + + % endfor
      field name${field}${repr(getattr(version.previous, field))}${repr(getattr(version, field))}${repr(getattr(version.previous, field))}${repr(getattr(version, field))}
      % else: - +
      @@ -148,8 +104,8 @@ % for field in fields_for_version(version): - - + + % endfor From fb140f24c13a41c47ddf20b03d7d6241bc73eb50 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 6 Jan 2018 20:28:59 -0600 Subject: [PATCH 0600/3196] Add basic UI support for "importer batch" feature --- .../templates/batch/importer/view_row.mako | 69 +++++ tailbone/views/batch/core.py | 22 +- tailbone/views/batch/core2.py | 11 +- tailbone/views/batch/importer.py | 277 ++++++++++++++++++ 4 files changed, 368 insertions(+), 11 deletions(-) create mode 100644 tailbone/templates/batch/importer/view_row.mako create mode 100644 tailbone/views/batch/importer.py diff --git a/tailbone/templates/batch/importer/view_row.mako b/tailbone/templates/batch/importer/view_row.mako new file mode 100644 index 00000000..55efb6d1 --- /dev/null +++ b/tailbone/templates/batch/importer/view_row.mako @@ -0,0 +1,69 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/view_row.mako" /> + +<%def name="context_menu_items()"> + % if not batch.executed and request.has_perm('{}.delete_row'.format(permission_prefix)): +
    • ${h.link_to("Delete this Row", url('{}.delete_row'.format(route_prefix), uuid=batch.uuid, row_uuid=instance.uuid))}
    • + % endif + + +${parent.body()} + +% if instance.status_code == enum.IMPORTER_BATCH_ROW_STATUS_CREATE: +
      field name
      ${field} ${repr(getattr(version, field))} ${repr(getattr(version, field))}
      + + + + + + + + + % for field in diff_fields: + + + + + + % endfor + +
      field nameold valuenew value
      ${field} ${repr(diff_new_values[field])}
      +% elif instance.status_code in (enum.IMPORTER_BATCH_ROW_STATUS_UPDATE, enum.IMPORTER_BATCH_ROW_STATUS_NOCHANGE): + + + + + + + + + + % for field in diff_fields: + + + + + + % endfor + +
      field nameold valuenew value
      ${field}${repr(diff_old_values[field])}${repr(diff_new_values[field])}
      +% elif instance.status_code == enum.IMPORTER_BATCH_ROW_STATUS_DELETE: + + + + + + + + + + % for field in diff_fields: + + + + + + % endfor + +
      field nameold valuenew value
      ${field}${repr(diff_old_values[field])} 
      +% endif diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index 318bbea0..5e0359ec 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -439,7 +439,8 @@ class BatchMasterView(MasterView): """ if hasattr(batch, 'delete_data'): batch.delete_data(self.rattail_config) - del batch.data_rows[:] + if hasattr(batch, 'data_rows'): + del batch.data_rows[:] super(BatchMasterView, self).delete_instance(batch) def get_fallback_templates(self, template, mobile=False): @@ -688,14 +689,18 @@ class BatchMasterView(MasterView): """ Batch rows are editable only until batch has been executed. """ - batch = row.batch + batch = self.get_parent(row) return self.rows_editable and not batch.executed and not batch.complete def row_deletable(self, row): """ Batch rows are deletable only until batch has been executed. """ - return self.rows_deletable and not row.batch.executed + if self.rows_deletable: + batch = self.get_parent(row) + if not batch.executed: + return True + return False def _preconfigure_row_fieldset(self, fs): fs.sequence.set(readonly=True) @@ -958,11 +963,12 @@ class BatchMasterView(MasterView): renderer='json', permission='{}.worksheet'.format(permission_prefix)) # refresh batch data - config.add_route('{}.refresh'.format(route_prefix), '{}/{{uuid}}/refresh'.format(url_prefix)) - config.add_view(cls, attr='refresh', route_name='{}.refresh'.format(route_prefix), - permission='{}.refresh'.format(permission_prefix)) - config.add_tailbone_permission(permission_prefix, '{}.refresh'.format(permission_prefix), - "Refresh data for {}".format(model_title)) + if cls.refreshable: + config.add_route('{}.refresh'.format(route_prefix), '{}/{{uuid}}/refresh'.format(url_prefix)) + config.add_view(cls, attr='refresh', route_name='{}.refresh'.format(route_prefix), + permission='{}.refresh'.format(permission_prefix)) + config.add_tailbone_permission(permission_prefix, '{}.refresh'.format(permission_prefix), + "Refresh data for {}".format(model_title)) # bulk delete rows if cls.rows_bulk_deletable: diff --git a/tailbone/views/batch/core2.py b/tailbone/views/batch/core2.py index 17669b92..3a70366b 100644 --- a/tailbone/views/batch/core2.py +++ b/tailbone/views/batch/core2.py @@ -96,11 +96,13 @@ class BatchMasterView2(MasterView2, BatchMasterView): def configure_row_grid(self, g): super(BatchMasterView2, self).configure_row_grid(g) - g.filters['status_code'].set_value_renderer(grids.filters.EnumValueRenderer(self.model_row_class.STATUS)) + if 'status_code' in g.filters: + g.filters['status_code'].set_value_renderer(grids.filters.EnumValueRenderer(self.model_row_class.STATUS)) g.set_sort_defaults('sequence') - g.set_enum('status_code', self.model_row_class.STATUS) + if self.model_row_class: + g.set_enum('status_code', self.model_row_class.STATUS) g.set_renderer('status_code', self.render_row_status) @@ -108,11 +110,14 @@ class BatchMasterView2(MasterView2, BatchMasterView): g.set_label('status_code', "Status") g.set_label('item_id', "Item ID") + def get_row_status_enum(self): + return self.model_row_class.STATUS + def render_row_status(self, row, column): code = row.status_code if code is None: return "" - text = self.model_row_class.STATUS.get(code, six.text_type(code)) + text = self.get_row_status_enum().get(code, six.text_type(code)) if row.status_text: return HTML.tag('span', title=row.status_text, c=text) return text diff --git a/tailbone/views/batch/importer.py b/tailbone/views/batch/importer.py new file mode 100644 index 00000000..fd87942d --- /dev/null +++ b/tailbone/views/batch/importer.py @@ -0,0 +1,277 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2018 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see . +# +################################################################################ +""" +Views for importer batches +""" + +from __future__ import unicode_literals, absolute_import + +import sqlalchemy as sa + +from rattail.core import Object +from rattail.db import model + +from tailbone import forms, forms2 +from tailbone.views.batch import BatchMasterView2 as BatchMasterView + + +class ImporterBatchView(BatchMasterView): + """ + Master view for importer batches. + """ + model_class = model.ImporterBatch + default_handler_spec = 'rattail.batch.importer:ImporterBatchHandler' + model_title_plural = "Import / Export Batches" + route_prefix = 'batch.importer' + url_prefix = '/batches/importer' + template_prefix = '/batch/importer' + creatable = False + refreshable = False + bulk_deletable = True + rows_downloadable_csv = False + rows_bulk_deletable = True + + grid_columns = [ + 'id', + 'description', + 'host_title', + 'local_title', + 'importer_key', + 'created', + 'created_by', + 'rowcount', + 'executed', + 'executed_by', + ] + + labels = { + 'host_title': "Source", + 'local_title': "Target", + 'importer_key': "Model", + } + + row_grid_columns = [ + 'sequence', + 'object_key', + 'object_str', + 'status_code', + ] + + def configure_fieldset(self, fs): + fs.configure( + include=[ + fs.id, + fs.description, + # fs.batch_handler_spec.readonly(), + fs.import_handler_spec.readonly(), + fs.host_title.readonly().label("Source"), + fs.local_title.readonly().label("Target"), + fs.importer_key.readonly().label("Model"), + fs.notes, + fs.created, + fs.created_by, + fs.row_table.readonly(), + fs.rowcount, + fs.executed, + fs.executed_by, + ]) + + def delete_instance(self, batch): + self.make_row_table(batch.row_table) + self.current_row_table.drop() + super(ImporterBatchView, self).delete_instance(batch) + + def make_row_table(self, name): + if not hasattr(self, 'current_row_table'): + metadata = sa.MetaData(schema='batch', bind=self.Session.bind) + self.current_row_table = sa.Table(name, metadata, autoload=True) + + def get_row_data(self, batch): + self.make_row_table(batch.row_table) + return self.Session.query(self.current_row_table) + + def get_row_status_enum(self): + return self.enum.IMPORTER_BATCH_ROW_STATUS + + def configure_row_grid(self, g): + super(ImporterBatchView, self).configure_row_grid(g) + + def make_filter(field, **kwargs): + column = getattr(self.current_row_table.c, field) + g.set_filter(field, column, **kwargs) + + make_filter('object_key') + make_filter('object_str') + make_filter('status_code', label="Status", + value_enum=self.enum.IMPORTER_BATCH_ROW_STATUS) + + def make_sorter(field): + column = getattr(self.current_row_table.c, field) + g.sorters[field] = lambda q, d: q.order_by(getattr(column, d)()) + + make_sorter('sequence') + make_sorter('object_key') + make_sorter('object_str') + make_sorter('status_code') + + g.set_sort_defaults('sequence') + + g.set_label('object_str', "Object Description") + + g.set_link('sequence') + g.set_link('object_key') + g.set_link('object_str') + + def row_grid_extra_class(self, row, i): + if row.status_code == self.enum.IMPORTER_BATCH_ROW_STATUS_DELETE: + return 'warning' + if row.status_code in (self.enum.IMPORTER_BATCH_ROW_STATUS_CREATE, + self.enum.IMPORTER_BATCH_ROW_STATUS_UPDATE): + return 'notice' + + def get_row_action_route_kwargs(self, row): + return { + 'uuid': self.current_row_table.name, + 'row_uuid': row.uuid, + } + + def get_row_instance(self): + batch_uuid = self.request.matchdict['uuid'] + row_uuid = self.request.matchdict['row_uuid'] + self.make_row_table(batch_uuid) + return self.Session.query(self.current_row_table)\ + .filter(self.current_row_table.c.uuid == row_uuid)\ + .one() + + def get_parent(self, row): + uuid = self.current_row_table.name + return self.Session.query(model.ImporterBatch).get(uuid) + + def get_row_instance_title(self, row): + if row.object_str: + return row.object_str + if row.object_key: + return row.object_key + return "Row {}".format(row.sequence) + + def template_kwargs_view_row(self, **kwargs): + batch = kwargs['parent_instance'] + row = kwargs['instance'] + kwargs['batch'] = batch + kwargs['instance_title'] = batch.id_str + + fields = set() + old_values = {} + new_values = {} + for col in self.current_row_table.c: + if col.name.startswith('key_'): + field = col.name[4:] + fields.add(field) + old_values[field] = new_values[field] = getattr(row, col.name) + elif col.name.startswith('pre_'): + field = col.name[4:] + fields.add(field) + old_values[field] = getattr(row, col.name) + elif col.name.startswith('post_'): + field = col.name[5:] + fields.add(field) + new_values[field] = getattr(row, col.name) + + kwargs['diff_fields'] = sorted(fields) + kwargs['diff_old_values'] = old_values + kwargs['diff_new_values'] = new_values + return kwargs + + def make_row_form(self, instance=None, factory=None, fields=None, schema=None, **kwargs): + """ + Creates a new form for the given model class/instance + """ + if factory is None: + factory = forms2.Form + if fields is None: + fields = ['sequence', 'object_key', 'object_str', 'status_code'] + for col in self.current_row_table.c: + if col.name.startswith('key_'): + fields.append(col.name) + + if not self.creating: + kwargs['model_instance'] = instance + kwargs = self.make_row_form_kwargs(**kwargs) + form = factory(fields, schema, **kwargs) + self.configure_row_form(form) + return form + + def make_row_form_kwargs(self, **kwargs): + """ + Return a dictionary of kwargs to be passed to the factory when creating + new row form instances. + """ + defaults = { + 'request': self.request, + 'readonly': self.viewing, + 'model_class': getattr(self, 'model_class', None), + 'action_url': self.request.current_route_url(_query=None), + } + instance = kwargs['model_instance'] + defaults.update(kwargs) + return defaults + + def configure_row_form(self, f): + """ + Configure the row form. + """ + # object_str + f.set_label('object_str', "Object Description") + + # status_code + f.set_renderer('status_code', self.render_row_status_code) + f.set_label('status_code', "Status") + + def render_row_status_code(self, row, field): + status = self.enum.IMPORTER_BATCH_ROW_STATUS[row.status_code] + if row.status_code == self.enum.IMPORTER_BATCH_ROW_STATUS_UPDATE and row.status_text: + return "{} ({})".format(status, row.status_text) + return status + + def delete_row(self): + row = self.get_row_instance() + if not row: + raise self.notfound() + + batch = self.get_parent(row) + query = self.current_row_table.delete().where(self.current_row_table.c.uuid == row.uuid) + query.execute() + batch.rowcount -= 1 + return self.redirect(self.get_action_url('view', batch)) + + def bulk_delete_rows(self): + batch = self.get_instance() + query = self.get_effective_row_data(sort=False) + batch.rowcount -= query.count() + delete_query = self.current_row_table.delete().where(self.current_row_table.c.uuid.in_([row.uuid for row in query])) + delete_query.execute() + return self.redirect(self.get_action_url('view', batch)) + + +def includeme(config): + ImporterBatchView.defaults(config) From a68bf572cc061ce4060483ebdbbf224070358355 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 7 Jan 2018 17:02:15 -0600 Subject: [PATCH 0601/3196] Update changelog --- CHANGES.rst | 34 ++++++++++++++++++++++++++++++++++ tailbone/_version.py | 2 +- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 5b1c2091..69dc6b15 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,40 @@ CHANGELOG ========= +0.6.57 (2018-01-07) +------------------- + +* Fix some styles for execution options dialog. + +* Show 'static_prices' flag for label batches. + +* Add field name as wrapper class name. + +* Change how select menus are enhanced for batch exec options. + +* Add view for InventoryAdjustmentReason model. + +* Stop setting execution details when multiple batches executed. + +* Add empty default when displaying values in grid. + +* Let grids be paginated even when they have no model class. + +* Exclude JS for refreshing batch unless it's relevant. + +* Tweak conditions for CSV row download link. + +* Add basic support for row grid view links. + +* Refactor away the ``row_route_prefix`` concept. + +* Add ``row_title`` to template context for ``view_row``. + +* Tweak ``diffs.css`` and refactor 'view_version' template to use it. + +* Add basic UI support for "importer batch" feature. + + 0.6.56 (2018-01-05) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index c4525b0e..b59c6ae0 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.6.56' +__version__ = '0.6.57' From 66d3b7b4afb8d8aaeeb2970c4129a192302a643f Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 7 Jan 2018 19:44:39 -0600 Subject: [PATCH 0602/3196] Tweak diff styles when viewing upgrade --- tailbone/static/css/diffs.css | 4 ++-- tailbone/templates/diff.mako | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tailbone/static/css/diffs.css b/tailbone/static/css/diffs.css index d0a89901..9af7710c 100644 --- a/tailbone/static/css/diffs.css +++ b/tailbone/static/css/diffs.css @@ -5,8 +5,8 @@ table.diff { border-left: 1px solid Black; border-top: 1px solid Black; font-size: 11pt; - margin-top: 2em; - margin-left: 50px; + /* margin-top: 2em; */ + /* margin-left: 50px; */ min-width: 80%; } diff --git a/tailbone/templates/diff.mako b/tailbone/templates/diff.mako index 4b8888e6..0985d74d 100644 --- a/tailbone/templates/diff.mako +++ b/tailbone/templates/diff.mako @@ -1,5 +1,5 @@ ## -*- coding: utf-8; -*- - +
      % for column in diff.columns: From c00f7e2144c1534d4d447f1bd169f3ac16b71f81 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 8 Jan 2018 12:49:21 -0600 Subject: [PATCH 0603/3196] 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 69dc6b15..52d6b5d1 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.6.58 (2018-01-08) +------------------- + +* Tweak diff styles when viewing upgrade. + + 0.6.57 (2018-01-07) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index b59c6ae0..93cabe98 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.6.57' +__version__ = '0.6.58' From 8d35955d032b984463178a775e2ae8af1c93bd2c Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 8 Jan 2018 15:32:36 -0600 Subject: [PATCH 0604/3196] Fix bug when printing product label --- 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 0af8b09a..f60abc75 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -663,7 +663,7 @@ def print_labels(request): return {'error': "Couldn't get printer from label profile"} try: - printer.print_labels([(product, quantity)]) + printer.print_labels([(product, quantity, {})]) except Exception, error: return {'error': str(error)} return {} From d9a5b4a0f5e118a1ad8683ce55ed6a1bfe0c6530 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 8 Jan 2018 15:33:06 -0600 Subject: [PATCH 0605/3196] 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 52d6b5d1..bd14dcaa 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.6.59 (2018-01-08) +------------------- + +* Fix bug when printing product label. + + 0.6.58 (2018-01-08) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 93cabe98..adb2f0ac 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.6.58' +__version__ = '0.6.59' From 3097f46aa1f04ce80c65bf99ad774db67affd83c Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 8 Jan 2018 18:03:51 -0600 Subject: [PATCH 0606/3196] Refactor products view to use master3 --- tailbone/forms2/core.py | 19 +- tailbone/templates/products/view.mako | 61 ++++--- tailbone/views/master.py | 2 + tailbone/views/products.py | 244 ++++++++++++++++++-------- 4 files changed, 221 insertions(+), 105 deletions(-) diff --git a/tailbone/forms2/core.py b/tailbone/forms2/core.py index 9dad0652..be67c585 100644 --- a/tailbone/forms2/core.py +++ b/tailbone/forms2/core.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar +# Copyright © 2010-2018 Lance Edgar # # This file is part of Rattail. # @@ -482,6 +482,8 @@ class Form(object): self.set_renderer(key, self.render_currency) elif type_ == 'quantity': self.set_renderer(key, self.render_quantity) + elif type_ == 'gpc': + self.set_renderer(key, self.render_gpc) elif type_ == 'enum': self.set_renderer(key, self.render_enum) elif type_ == 'codeblock': @@ -638,9 +640,12 @@ class Form(object): value = self.obtain_value(record, field_name) if value is None: return "" - if value < 0: - return "(${:0,.2f})".format(0 - value) - return "${:0,.2f}".format(value) + try: + if value < 0: + return "(${:0,.2f})".format(0 - value) + return "${:0,.2f}".format(value) + except ValueError: + return six.text_type(value) def render_quantity(self, obj, field): value = self.obtain_value(obj, field) @@ -648,6 +653,12 @@ class Form(object): return "" return pretty_quantity(value) + def render_gpc(self, obj, field): + value = self.obtain_value(obj, field) + if value is None: + return "" + return value.pretty() + def render_enum(self, record, field_name): value = self.obtain_value(record, field_name) if value is None: diff --git a/tailbone/templates/products/view.mako b/tailbone/templates/products/view.mako index e7026713..10db733e 100644 --- a/tailbone/templates/products/view.mako +++ b/tailbone/templates/products/view.mako @@ -1,6 +1,5 @@ ## -*- coding: utf-8; -*- <%inherit file="/master/view.mako" /> -<%namespace file="/forms/lib.mako" import="render_field_readonly" /> <%def name="extra_styles()"> ${parent.extra_styles()} @@ -63,15 +62,15 @@ ############################## <%def name="render_main_fields(form)"> - ${render_field_readonly(form.fieldset.upc)} - ${render_field_readonly(form.fieldset.brand)} - ${render_field_readonly(form.fieldset.description)} - ${render_field_readonly(form.fieldset.size)} - ${render_field_readonly(form.fieldset.unit_size)} - ${render_field_readonly(form.fieldset.unit_of_measure)} - ${render_field_readonly(form.fieldset.unit)} - ${render_field_readonly(form.fieldset.pack_size)} - ${render_field_readonly(form.fieldset.case_size)} + ${form.render_field_readonly('upc')} + ${form.render_field_readonly('brand')} + ${form.render_field_readonly('description')} + ${form.render_field_readonly('size')} + ${form.render_field_readonly('unit_size')} + ${form.render_field_readonly('unit_of_measure')} + ${form.render_field_readonly('unit')} + ${form.render_field_readonly('pack_size')} + ${form.render_field_readonly('case_size')} ${self.extra_main_fields(form)} @@ -113,30 +112,30 @@ <%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)} + ${form.render_field_readonly('department')} + ${form.render_field_readonly('subdepartment')} + ${form.render_field_readonly('category')} + ${form.render_field_readonly('family')} + ${form.render_field_readonly('report_code')} <%def name="render_price_fields(form)"> - ${render_field_readonly(form.fieldset.price_required)} - ${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)} + ${form.render_field_readonly('price_required')} + ${form.render_field_readonly('regular_price')} + ${form.render_field_readonly('current_price')} + ${form.render_field_readonly('current_price_ends')} + ${form.render_field_readonly('deposit_link')} + ${form.render_field_readonly('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.discontinued)} - ${render_field_readonly(form.fieldset.deleted)} + ${form.render_field_readonly('weighed')} + ${form.render_field_readonly('discountable')} + ${form.render_field_readonly('special_order')} + ${form.render_field_readonly('organic')} + ${form.render_field_readonly('not_for_sale')} + ${form.render_field_readonly('discontinued')} + ${form.render_field_readonly('deleted')} <%def name="movement_panel()"> @@ -149,7 +148,7 @@ <%def name="render_movement_fields(form)"> - ${render_field_readonly(form.fieldset.last_sold)} + ${form.render_field_readonly('last_sold')} <%def name="lookup_codes_panel()"> @@ -210,7 +209,7 @@

      Notes

      -
      ${form.fieldset.notes.render_readonly()}
      +
      ${form.render_field_readonly('notes')}
      @@ -219,7 +218,7 @@

      Ingredients

      - ${render_field_readonly(form.fieldset.ingredients)} + ${form.render_field_readonly('ingredients')}
      diff --git a/tailbone/views/master.py b/tailbone/views/master.py index c5573397..3f4a88c4 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -381,6 +381,8 @@ class MasterView(View): 'instance_deletable': self.deletable_instance(instance), 'form': form, } + if hasattr(form, 'make_deform_form'): + context['dform'] = form.make_deform_form() if self.has_rows: context['rows_grid'] = grid.render_complete(allow_save_defaults=False, tools=self.make_row_grid_tools(instance)) diff --git a/tailbone/views/products.py b/tailbone/views/products.py index f60abc75..461cb956 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -41,16 +41,15 @@ from rattail.util import load_object, pretty_quantity from rattail.batch import get_batch_handler import colander -import formalchemy as fa from deform import widget as dfwidget from pyramid import httpexceptions -from pyramid.renderers import render_to_response from webhelpers2.html import tags, HTML -from tailbone import forms, forms2, grids +from tailbone import forms2 as forms, grids from tailbone.db import Session -from tailbone.views import MasterView2 as MasterView, AutocompleteView +from tailbone.views import MasterView3 as MasterView, AutocompleteView from tailbone.progress import SessionProgress +from tailbone.util import raw_datetime # TODO: For a moment I thought this was going to be necessary, but now I think @@ -91,6 +90,46 @@ class ProductsView(MasterView): 'current_price', ] + form_fields = [ + 'upc', + 'brand', + 'description', + 'unit_size', + 'unit_of_measure', + 'size', + 'unit', + 'pack_size', + 'case_size', + 'weighed', + 'department', + 'subdepartment', + 'category', + 'family', + 'report_code', + 'regular_price', + 'current_price', + 'current_price_ends', + 'deposit_link', + 'tax', + 'organic', + 'kosher', + 'vegan', + 'vegetarian', + 'gluten_free', + 'sugar_free', + 'discountable', + 'special_order', + 'not_for_sale', + 'ingredients', + 'notes', + 'status_code', + 'discontinued', + 'deleted', + 'last_sold', + 'inventory_on_hand', + 'inventory_on_order', + ] + labels = { 'status_code': "Status", } @@ -312,71 +351,136 @@ class ProductsView(MasterView): return price.product raise httpexceptions.HTTPNotFound() - def _preconfigure_fieldset(self, fs): - fs.upc.set(label="UPC", renderer=forms.renderers.GPCFieldRenderer) - fs.brand.set(renderer=forms.renderers.BrandFieldRenderer, options=[]) - fs.department.set(renderer=forms.renderers.DepartmentFieldRenderer) - fs.subdepartment.set(renderer=forms.renderers.SubdepartmentFieldRenderer) - fs.category.set(renderer=forms.renderers.CategoryFieldRenderer) - fs.unit_size.set(renderer=forms.renderers.QuantityFieldRenderer) - fs.unit_of_measure.set(label="Unit of Measure", - renderer=forms.renderers.EnumFieldRenderer(self.enum.UNIT_OF_MEASURE)) - fs.unit.set(renderer=forms.renderers.ProductFieldRenderer, label="Unit Item") - fs.pack_size.set(renderer=forms.renderers.QuantityFieldRenderer) - fs.regular_price.set(renderer=forms.renderers.PriceFieldRenderer, readonly=True) - fs.current_price.set(renderer=forms.renderers.PriceFieldRenderer, readonly=True) - fs.last_sold.set(readonly=True) - fs.status_code.set(label="Status") - fs.notes.set(renderer=fa.TextAreaFieldRenderer, size=(80, 10)) - fs.append(fa.Field('current_price_ends', type=fa.types.DateTime, readonly=True, - value=lambda p: p.current_price.ends if p.current_price else None)) - fs.append(fa.Field('inventory_on_hand', readonly=True, label="On Hand", - value=lambda p: p.inventory.on_hand if p.inventory else None)) - fs.append(fa.Field('inventory_on_order', readonly=True, label="On Order", - value=lambda p: p.inventory.on_order if p.inventory else None)) + def configure_form(self, f): + super(ProductsView, self).configure_form(f) + + # upc + f.set_type('upc', 'gpc') + f.set_label('upc', "UPC") + + # department + f.set_renderer('department', self.render_department) + + # subdepartment + f.set_renderer('subdepartment', self.render_subdepartment) + + # category + f.set_renderer('category', self.render_category) + + # unit_size + f.set_type('unit_size', 'quantity') + + # unit_of_measure + f.set_enum('unit_of_measure', self.enum.UNIT_OF_MEASURE) + f.set_label('unit_of_measure', "Unit of Measure") + + # unit + f.set_renderer('unit', self.render_unit) + f.set_label('unit', "Unit Item") + + # pack_size + f.set_type('pack_size', 'quantity') + + # regular_price + f.set_readonly('regular_price') + f.set_renderer('regular_price', self.render_price) + + # current_price + f.set_readonly('current_price') + f.set_renderer('current_price', self.render_price) + + # last_sold + f.set_readonly('last_sold') + + # status_code + f.set_label('status_code', "Status") + + # notes + f.set_widget('notes', dfwidget.TextAreaWidget(cols=80, rows=10)) + + # current_price_ends + f.set_readonly('current_price_ends') + f.set_renderer('current_price_ends', self.render_current_price_ends) + + # inventory_on_hand + f.set_readonly('inventory_on_hand') + f.set_renderer('inventory_on_hand', self.render_inventory_on_hand) + f.set_label('inventory_on_hand', "On Hand") + + # inventory_on_order + f.set_readonly('inventory_on_order') + f.set_renderer('inventory_on_order', self.render_inventory_on_order) + f.set_label('inventory_on_order', "On Order") - def configure_fieldset(self, fs): - fs.configure( - include=[ - fs.upc, - fs.brand, - fs.description, - fs.unit_size, - fs.unit_of_measure, - fs.size, - fs.unit, - fs.pack_size, - fs.case_size, - fs.weighed, - fs.department, - fs.subdepartment, - fs.category, - fs.family, - fs.report_code, - fs.price_required, - fs.regular_price, - fs.current_price, - fs.current_price_ends, - fs.deposit_link, - fs.tax, - fs.organic, - fs.kosher, - fs.vegan, - fs.vegetarian, - fs.gluten_free, - fs.sugar_free, - fs.discountable, - fs.special_order, - fs.not_for_sale, - fs.ingredients, - fs.notes, - fs.status_code, - fs.discontinued, - fs.deleted, - fs.last_sold, - ]) if not self.request.has_perm('products.view_deleted'): - del fs.deleted + f.remove('deleted') + + def render_department(self, product, field): + department = product.department + if not department: + return "" + if department.number: + text = '({}) {}'.format(department.number, department.name) + else: + text = department.name + url = self.request.route_url('departments.view', uuid=department.uuid) + return tags.link_to(text, url) + + def render_subdepartment(self, product, field): + subdepartment = product.subdepartment + if not subdepartment: + return "" + if subdepartment.number: + text = '({}) {}'.format(subdepartment.number, subdepartment.name) + else: + text = subdepartment.name + url = self.request.route_url('subdepartments.view', uuid=subdepartment.uuid) + return tags.link_to(text, url) + + def render_category(self, product, field): + category = product.category + if not category: + return "" + if category.code: + text = '({}) {}'.format(category.code, category.name) + elif category.number: + text = '({}) {}'.format(category.number, category.name) + else: + text = category.name + url = self.request.route_url('categories.view', uuid=category.uuid) + return tags.link_to(text, url) + + def render_unit(self, product, field): + product = product.unit + if not product: + return "" + text = product.full_description + url = self.request.route_url('products.view', uuid=product.uuid) + return tags.link_to(text, url) + + def render_current_price_ends(self, product, field): + if not product.current_price: + return "" + value = product.current_price.ends + if not value: + return "" + return raw_datetime(self.request.rattail_config, value) + + def render_inventory_on_hand(self, product, field): + if not product.inventory: + return "" + value = product.inventory.on_hand + if not value: + return "" + return pretty_quantity(value) + + def render_inventory_on_order(self, product, field): + if not product.inventory: + return "" + value = product.inventory.on_order + if not value: + return "" + return pretty_quantity(value) def template_kwargs_view(self, **kwargs): kwargs['image'] = False @@ -491,8 +595,8 @@ class ProductsView(MasterView): colander.SchemaNode(colander.String(), name='notes', missing=colander.null), ) - form = forms2.Form(schema=schema, request=self.request, - cancel_url=self.get_index_url()) + form = forms.Form(schema=schema, request=self.request, + cancel_url=self.get_index_url()) form.set_type('notes', 'text') params_forms = {} @@ -504,7 +608,7 @@ class ProductsView(MasterView): for node in schema: node.param_name = node.name node.name = '{}_{}'.format(key, node.name) - params_forms[key] = forms2.Form(schema=schema, request=self.request) + params_forms[key] = forms.Form(schema=schema, request=self.request) if self.request.method == 'POST': controls = self.request.POST.items() From ce0195bd51ef7d28455d98cfc19300c7b715da82 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 8 Jan 2018 20:41:31 -0600 Subject: [PATCH 0607/3196] Refactor several more straggler views to use master3 --- tailbone/views/custorders/items.py | 93 ++++++++++++++++++----------- tailbone/views/custorders/orders.py | 53 ++++++++++------ tailbone/views/depositlinks.py | 18 +++--- tailbone/views/families.py | 18 +++--- tailbone/views/labels/profiles.py | 34 +++++------ tailbone/views/purchases/credits.py | 8 +-- tailbone/views/reportcodes.py | 17 +++--- tailbone/views/tables.py | 4 +- tailbone/views/taxes.py | 18 +++--- tailbone/views/trainwreck.py | 72 ++++++++++++---------- 10 files changed, 187 insertions(+), 148 deletions(-) diff --git a/tailbone/views/custorders/items.py b/tailbone/views/custorders/items.py index 4a14da89..7963763f 100644 --- a/tailbone/views/custorders/items.py +++ b/tailbone/views/custorders/items.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar +# Copyright © 2010-2018 Lance Edgar # # This file is part of Rattail. # @@ -33,10 +33,7 @@ from sqlalchemy import orm from rattail.db import model from rattail.time import localtime -import formalchemy as fa - -from tailbone import forms -from tailbone.views import MasterView2 as MasterView +from tailbone.views import MasterView3 as MasterView from tailbone.util import raw_datetime @@ -78,6 +75,21 @@ class CustomerOrderItemsView(MasterView): 'note', ] + form_fields = [ + 'person', + 'product', + 'product_brand', + 'product_description', + 'product_size', + 'case_quantity', + 'cases_ordered', + 'units_ordered', + 'unit_price', + 'total_price', + 'paid_amount', + 'status_code', + ] + def query(self, session): return session.query(model.CustomerOrderItem)\ .join(model.CustomerOrder)\ @@ -118,36 +130,49 @@ class CustomerOrderItemsView(MasterView): value = localtime(self.rattail_config, item.order.created, from_utc=True) return raw_datetime(self.rattail_config, value) - def _preconfigure_fieldset(self, fs): - fs.order.set(renderer=forms.renderers.CustomerOrderFieldRenderer) - fs.product.set(renderer=forms.renderers.ProductFieldRenderer) - fs.product_unit_of_measure.set(renderer=forms.renderers.EnumFieldRenderer(self.enum.UNIT_OF_MEASURE)) - fs.case_quantity.set(renderer=forms.renderers.QuantityFieldRenderer) - fs.cases_ordered.set(renderer=forms.renderers.QuantityFieldRenderer) - fs.units_ordered.set(renderer=forms.renderers.QuantityFieldRenderer) - fs.unit_price.set(renderer=forms.renderers.CurrencyFieldRenderer) - fs.total_price.set(renderer=forms.renderers.CurrencyFieldRenderer) - fs.paid_amount.set(renderer=forms.renderers.CurrencyFieldRenderer) - fs.status_code.set(label="Status") - fs.append(fa.Field('person', value=lambda i: i.order.person, - renderer=forms.renderers.PersonFieldRenderer)) + def configure_form(self, f): + super(CustomerOrderItemsView, self).configure_form(f) - def configure_fieldset(self, fs): - fs.configure( - include=[ - fs.person, - fs.product, - fs.product_brand, - fs.product_description, - fs.product_size, - fs.case_quantity, - fs.cases_ordered, - fs.units_ordered, - fs.unit_price, - fs.total_price, - fs.paid_amount, - fs.status_code, - ]) + # order + f.set_renderer('order', self.render_order) + + # product + f.set_renderer('product', self.render_product) + + # product uom + f.set_enum('product_unit_of_measure', self.enum.UNIT_OF_MEASURE) + + # quantity fields + f.set_type('case_quantity', 'quantity') + f.set_type('cases_ordered', 'quantity') + f.set_type('units_ordered', 'quantity') + + # currency fields + f.set_type('unit_price', 'currency') + f.set_type('total_price', 'currency') + f.set_type('paid_amount', 'currency') + + # person + f.set_renderer('person', self.render_person) + + # label overrides + f.set_label('status_code', "Status") + + def render_order(self, item, field): + order = item.order + if not order: + return "" + text = six.text_type(order) + url = self.request.route_url('custorders.view', uuid=order.uuid) + return tags.link_to(text, url) + + def render_product(self, order, field): + product = order.product + if not product: + return "" + text = six.text_type(product) + url = self.request.route_url('products.view', uuid=product.uuid) + return tags.link_to(text, url) def get_row_data(self, item): return self.Session.query(model.CustomerOrderItemEvent)\ diff --git a/tailbone/views/custorders/orders.py b/tailbone/views/custorders/orders.py index c10333eb..eb8b89a6 100644 --- a/tailbone/views/custorders/orders.py +++ b/tailbone/views/custorders/orders.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar +# Copyright © 2010-2018 Lance Edgar # # This file is part of Rattail. # @@ -26,13 +26,15 @@ Customer Order Views from __future__ import unicode_literals, absolute_import +import six from sqlalchemy import orm from rattail.db import model -from tailbone import forms +from webhelpers2.html import tags + from tailbone.db import Session -from tailbone.views import MasterView2 as MasterView +from tailbone.views import MasterView as MasterView class CustomerOrdersView(MasterView): @@ -53,6 +55,14 @@ class CustomerOrdersView(MasterView): 'status_code', ] + form_fields = [ + 'id', + 'customer', + 'person', + 'created', + 'status_code', + ] + def query(self, session): return session.query(model.CustomerOrder)\ .options(orm.joinedload(model.CustomerOrder.customer)) @@ -81,22 +91,29 @@ class CustomerOrdersView(MasterView): g.set_label('status_code', "Status") g.set_label('id', "ID") - def _preconfigure_fieldset(self, fs): - fs.customer.set(options=[]) - fs.id.set(label="ID", readonly=True) - fs.person.set(renderer=forms.renderers.PersonFieldRenderer) - fs.created.set(readonly=True) - fs.status_code.set(label="Status") + def configure_form(self, f): + super(CustomerOrdersView, self).configure_form(f) - def configure_fieldset(self, fs): - fs.configure( - include=[ - fs.id, - fs.customer, - fs.person, - fs.created, - fs.status_code, - ]) + # id + f.set_readonly('id') + f.set_label('id', "ID") + + # person + f.set_renderer('person', self.render_person) + + # created + f.set_readonly('created') + + # label overrides + f.set_label('status_code', "Status") + + def render_person(self, order, field): + person = order.person + if not person: + return "" + text = six.text_type(person) + url = self.request.route_url('people.view', uuid=person.uuid) + return tags.link_to(text, url) def includeme(config): diff --git a/tailbone/views/depositlinks.py b/tailbone/views/depositlinks.py index b7f5876d..340cf818 100644 --- a/tailbone/views/depositlinks.py +++ b/tailbone/views/depositlinks.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar +# Copyright © 2010-2018 Lance Edgar # # This file is part of Rattail. # @@ -29,7 +29,7 @@ from __future__ import unicode_literals, absolute_import from rattail.db import model from tailbone import forms -from tailbone.views import MasterView2 as MasterView +from tailbone.views import MasterView3 as MasterView class DepositLinksView(MasterView): @@ -46,6 +46,12 @@ class DepositLinksView(MasterView): 'amount', ] + form_fields = [ + 'code', + 'description', + 'amount', + ] + def configure_grid(self, g): super(DepositLinksView, self).configure_grid(g) g.filters['description'].default_active = True @@ -55,14 +61,6 @@ class DepositLinksView(MasterView): g.set_link('code') g.set_link('description') - def configure_fieldset(self, fs): - fs.configure( - include=[ - fs.code, - fs.description, - fs.amount, - ]) - def includeme(config): DepositLinksView.defaults(config) diff --git a/tailbone/views/families.py b/tailbone/views/families.py index b9babf66..02c370b6 100644 --- a/tailbone/views/families.py +++ b/tailbone/views/families.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar +# Copyright © 2010-2018 Lance Edgar # # This file is part of Rattail. # @@ -28,7 +28,7 @@ from __future__ import unicode_literals, absolute_import from rattail.db import model -from tailbone.views import MasterView2 as MasterView +from tailbone.views import MasterView3 as MasterView class FamiliesView(MasterView): @@ -46,19 +46,17 @@ class FamiliesView(MasterView): 'name', ] + form_fields = [ + 'code', + 'name', + ] + def configure_grid(self, g): + super(FamiliesView, self).configure_grid(g) g.filters['name'].default_active = True g.filters['name'].default_verb = 'contains' g.set_sort_defaults('code') - def configure_fieldset(self, fs): - fs.configure( - include=[ - fs.code, - fs.name, - ]) - return fs - def includeme(config): FamiliesView.defaults(config) diff --git a/tailbone/views/labels/profiles.py b/tailbone/views/labels/profiles.py index de2eaac7..7a71913e 100644 --- a/tailbone/views/labels/profiles.py +++ b/tailbone/views/labels/profiles.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar +# Copyright © 2010-2018 Lance Edgar # # This file is part of Rattail. # @@ -30,9 +30,8 @@ from rattail.db import model from pyramid.httpexceptions import HTTPFound -from tailbone import forms from tailbone.db import Session -from tailbone.views import MasterView2 as MasterView +from tailbone.views import MasterView3 as MasterView class ProfilesView(MasterView): @@ -51,6 +50,16 @@ class ProfilesView(MasterView): 'visible', ] + form_fields = [ + 'ordinal', + 'code', + 'description', + 'printer_spec', + 'formatter_spec', + 'format', + 'visible', + ] + def configure_grid(self, g): super(ProfilesView, self).configure_grid(g) g.set_sort_defaults('ordinal') @@ -58,20 +67,11 @@ class ProfilesView(MasterView): g.set_link('code') g.set_link('description') - def configure_fieldset(self, fs): - fs.printer_spec.set(renderer=forms.renderers.StrippedTextFieldRenderer) - fs.formatter_spec.set(renderer=forms.renderers.StrippedTextFieldRenderer) - fs.format.set(renderer=forms.renderers.CodeTextAreaFieldRenderer) - fs.configure( - include=[ - fs.ordinal, - fs.code, - fs.description, - fs.printer_spec, - fs.formatter_spec, - fs.format, - fs.visible, - ]) + def configure_form(self, f): + super(ProfilesView, self).configure_form(f) + + # format + f.set_type('format', 'codeblock') def after_create(self, profile): self.after_edit(profile) diff --git a/tailbone/views/purchases/credits.py b/tailbone/views/purchases/credits.py index 464e194b..b32c6172 100644 --- a/tailbone/views/purchases/credits.py +++ b/tailbone/views/purchases/credits.py @@ -1,8 +1,8 @@ -# -*- coding: utf-8 -*- +# -*- coding: utf-8; -*- ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar +# Copyright © 2010-2018 Lance Edgar # # This file is part of Rattail. # @@ -30,8 +30,8 @@ from rattail.db import model from webhelpers2.html import tags -from tailbone import forms, grids -from tailbone.views import MasterView2 as MasterView +from tailbone import grids +from tailbone.views import MasterView3 as MasterView class PurchaseCreditView(MasterView): diff --git a/tailbone/views/reportcodes.py b/tailbone/views/reportcodes.py index bb3b03ec..c60dbfe9 100644 --- a/tailbone/views/reportcodes.py +++ b/tailbone/views/reportcodes.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar +# Copyright © 2010-2018 Lance Edgar # # This file is part of Rattail. # @@ -28,7 +28,7 @@ from __future__ import unicode_literals, absolute_import from rattail.db import model -from tailbone.views import MasterView2 as MasterView +from tailbone.views import MasterView3 as MasterView class ReportCodesView(MasterView): @@ -44,6 +44,11 @@ class ReportCodesView(MasterView): 'name', ] + form_fields = [ + 'code', + 'name', + ] + def configure_grid(self, g): super(ReportCodesView, self).configure_grid(g) g.filters['name'].default_active = True @@ -52,14 +57,6 @@ class ReportCodesView(MasterView): g.set_link('code') g.set_link('name') - def configure_fieldset(self, fs): - fs.configure( - include=[ - fs.code, - fs.name, - ]) - return fs - def includeme(config): ReportCodesView.defaults(config) diff --git a/tailbone/views/tables.py b/tailbone/views/tables.py index b94d590f..4fb08008 100644 --- a/tailbone/views/tables.py +++ b/tailbone/views/tables.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar +# Copyright © 2010-2018 Lance Edgar # # This file is part of Rattail. # @@ -26,7 +26,7 @@ Views with info about the underlying Rattail tables from __future__ import unicode_literals, absolute_import -from tailbone.views import MasterView2 as MasterView +from tailbone.views import MasterView3 as MasterView class TablesView(MasterView): diff --git a/tailbone/views/taxes.py b/tailbone/views/taxes.py index ef2efd75..1f53de5e 100644 --- a/tailbone/views/taxes.py +++ b/tailbone/views/taxes.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar +# Copyright © 2010-2018 Lance Edgar # # This file is part of Rattail. # @@ -28,7 +28,7 @@ from __future__ import unicode_literals, absolute_import from rattail.db import model -from tailbone.views import MasterView2 as MasterView +from tailbone.views import MasterView3 as MasterView class TaxesView(MasterView): @@ -46,6 +46,12 @@ class TaxesView(MasterView): 'rate', ] + form_fields = [ + 'code', + 'description', + 'rate', + ] + def configure_grid(self, g): super(TaxesView, self).configure_grid(g) g.filters['description'].default_active = True @@ -54,14 +60,6 @@ class TaxesView(MasterView): g.set_link('code') g.set_link('description') - def configure_fieldset(self, fs): - fs.configure( - include=[ - fs.code, - fs.description, - fs.rate, - ]) - def includeme(config): TaxesView.defaults(config) diff --git a/tailbone/views/trainwreck.py b/tailbone/views/trainwreck.py index 5d314106..d1b994ee 100644 --- a/tailbone/views/trainwreck.py +++ b/tailbone/views/trainwreck.py @@ -32,7 +32,7 @@ from rattail.time import localtime from tailbone import forms from tailbone.db import TrainwreckSession -from tailbone.views import MasterView2 as MasterView +from tailbone.views import MasterView3 as MasterView class TransactionView(MasterView): @@ -77,6 +77,27 @@ class TransactionView(MasterView): 'void', ] + form_fields = [ + 'system', + 'system_id', + 'terminal_id', + 'receipt_number', + 'start_time', + 'end_time', + 'upload_time', + 'cashier_id', + 'cashier_name', + 'customer_id', + 'customer_name', + 'shopper_id', + 'shopper_name', + 'subtotal', + 'discounted_subtotal', + 'tax', + 'total', + 'void', + ] + def configure_grid(self, g): super(TransactionView, self).configure_grid(g) g.filters['receipt_number'].default_active = True @@ -98,39 +119,24 @@ class TransactionView(MasterView): g.set_link('customer_name') g.set_link('total') - def _preconfigure_fieldset(self, fs): - fs.system.set(renderer=forms.renderers.EnumFieldRenderer(self.enum.TRAINWRECK_SYSTEM)) - fs.system_id.set(label="System ID") - fs.terminal_id.set(label="Terminal") - fs.cashier_id.set(label="Cashier ID") - fs.customer_id.set(label="Customer ID") - fs.shopper_id.set(label="Shopper ID") - fs.subtotal.set(renderer=forms.renderers.CurrencyFieldRenderer) - fs.discounted_subtotal.set(renderer=forms.renderers.CurrencyFieldRenderer) - fs.tax.set(renderer=forms.renderers.CurrencyFieldRenderer) - fs.total.set(renderer=forms.renderers.CurrencyFieldRenderer) + def configure_form(self, f): + super(TransactionView, self).configure_form(f) - def configure_fieldset(self, fs): - fs.configure(include=[ - fs.system, - fs.system_id, - fs.terminal_id, - fs.receipt_number, - fs.start_time, - fs.end_time, - fs.upload_time, - fs.cashier_id, - fs.cashier_name, - fs.customer_id, - fs.customer_name, - fs.shopper_id, - fs.shopper_name, - fs.subtotal, - fs.discounted_subtotal, - fs.tax, - fs.total, - fs.void, - ]) + # system + f.set_enum('system', self.enum.TRAINWRECK_SYSTEM) + + # currency fields + f.set_type('subtotal', 'currency') + f.set_type('discounted_subtotal', 'currency') + f.set_type('tax', 'currency') + f.set_type('total', 'currency') + + # label overrides + f.set_label('system_id', "System ID") + f.set_label('terminal_id', "Terminal") + f.set_label('cashier_id', "Cashier ID") + f.set_label('customer_id', "Customer ID") + f.set_label('shopper_id', "Shopper ID") def get_row_data(self, transaction): return self.Session.query(self.model_row_class)\ From 365a48110cc7255cb5ae81e335fab50dd77c48f3 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 8 Jan 2018 20:57:22 -0600 Subject: [PATCH 0608/3196] Refactor all tempmon views to use master3 --- tailbone/views/tempmon/__init__.py | 6 +- tailbone/views/tempmon/clients.py | 85 ++++++++++++++-------------- tailbone/views/tempmon/core.py | 25 +------- tailbone/views/tempmon/probes.py | 91 ++++++++++++++++++------------ tailbone/views/tempmon/readings.py | 52 ++++++++++++----- 5 files changed, 140 insertions(+), 119 deletions(-) diff --git a/tailbone/views/tempmon/__init__.py b/tailbone/views/tempmon/__init__.py index 81e94211..5f26a065 100644 --- a/tailbone/views/tempmon/__init__.py +++ b/tailbone/views/tempmon/__init__.py @@ -1,8 +1,8 @@ -# -*- coding: utf-8 -*- +# -*- coding: utf-8; -*- ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar +# Copyright © 2010-2018 Lance Edgar # # This file is part of Rattail. # @@ -26,7 +26,7 @@ Views for tempmon from __future__ import unicode_literals, absolute_import -from .core import MasterView, ClientFieldRenderer, ProbeFieldRenderer +from .core import MasterView def includeme(config): diff --git a/tailbone/views/tempmon/clients.py b/tailbone/views/tempmon/clients.py index d1b9db65..ef6d453a 100644 --- a/tailbone/views/tempmon/clients.py +++ b/tailbone/views/tempmon/clients.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar +# Copyright © 2010-2018 Lance Edgar # # This file is part of Rattail. # @@ -28,37 +28,16 @@ from __future__ import unicode_literals, absolute_import import subprocess +import six + from rattail_tempmon.db import model as tempmon -import formalchemy as fa +import colander from webhelpers2.html import HTML, tags -from tailbone.db import TempmonSession from tailbone.views.tempmon import MasterView -class ProbesFieldRenderer(fa.FieldRenderer): - - def render_readonly(self, **kwargs): - probes = self.raw_value - if not probes: - return '' - items = [] - for probe in probes: - items.append(HTML.tag('li', c=tags.link_to(probe, self.request.route_url('tempmon.probes.view', uuid=probe.uuid)))) - return HTML.tag('ul', c=items) - - -def unique_config_key(value, field): - client = field.parent.model - query = TempmonSession.query(tempmon.Client)\ - .filter(tempmon.Client.config_key == value) - if client.uuid: - query = query.filter(tempmon.Client.uuid != client.uuid) - if query.count(): - raise fa.ValidationError("Config key must be unique") - - class TempmonClientView(MasterView): """ Master view for tempmon clients. @@ -78,6 +57,16 @@ class TempmonClientView(MasterView): 'online', ] + form_fields = [ + 'config_key', + 'hostname', + 'location', + 'delay', + 'probes', + 'enabled', + 'online', + ] + def configure_grid(self, g): super(TempmonClientView, self).configure_grid(g) g.filters['hostname'].default_active = True @@ -95,24 +84,38 @@ class TempmonClientView(MasterView): g.set_link('hostname') g.set_link('location') - def _preconfigure_fieldset(self, fs): - fs.config_key.set(validate=unique_config_key) - fs.probes.set(renderer=ProbesFieldRenderer) + def configure_form(self, f): + super(TempmonClientView, self).configure_form(f) + + # config_key + f.set_validator('config_key', self.unique_config_key) + + # probes + f.set_renderer('probes', self.render_probes) - def configure_fieldset(self, fs): - fs.configure( - include=[ - fs.config_key, - fs.hostname, - fs.location, - fs.delay, - fs.probes, - fs.enabled, - fs.online, - ]) if self.creating or self.editing: - del fs.probes - del fs.online + f.remove_fields('probes', + 'online') + + def unique_config_key(self, node, value): + query = self.Session.query(tempmon.Client)\ + .filter(tempmon.Client.config_key == value) + if self.editing: + client = self.get_instance() + query = query.filter(tempmon.Client.uuid != client.uuid) + if query.count(): + raise colander.Invalid(node, "Config key must be unique") + + def render_probes(self, client, field): + probes = client.probes + if not probes: + return "" + items = [] + for probe in probes: + text = six.text_type(probe) + url = self.request.route_url('tempmon.probes.view', uuid=probe.uuid) + items.append(HTML.tag('li', c=tags.link_to(text, url))) + return HTML.tag('ul', c=items) def delete_instance(self, client): # bulk-delete all readings first diff --git a/tailbone/views/tempmon/core.py b/tailbone/views/tempmon/core.py index 545eb3cb..f32c596a 100644 --- a/tailbone/views/tempmon/core.py +++ b/tailbone/views/tempmon/core.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar +# Copyright © 2010-2018 Lance Edgar # # This file is part of Rattail. # @@ -28,14 +28,11 @@ from __future__ import unicode_literals, absolute_import from rattail_tempmon.db import Session as RawTempmonSession -from formalchemy.fields import SelectFieldRenderer -from webhelpers2.html import tags - from tailbone import views from tailbone.db import TempmonSession -class MasterView(views.MasterView2): +class MasterView(views.MasterView3): """ Base class for tempmon views. """ @@ -43,21 +40,3 @@ class MasterView(views.MasterView2): def get_bulk_delete_session(self): return RawTempmonSession() - - -class ClientFieldRenderer(SelectFieldRenderer): - - def render_readonly(self, **kwargs): - client = self.raw_value - if not client: - return '' - return tags.link_to(client, self.request.route_url('tempmon.clients.view', uuid=client.uuid)) - - -class ProbeFieldRenderer(SelectFieldRenderer): - - def render_readonly(self, **kwargs): - probe = self.raw_value - if not probe: - return '' - return tags.link_to(probe, self.request.route_url('tempmon.probes.view', uuid=probe.uuid)) diff --git a/tailbone/views/tempmon/probes.py b/tailbone/views/tempmon/probes.py index b55e9e65..8043759d 100644 --- a/tailbone/views/tempmon/probes.py +++ b/tailbone/views/tempmon/probes.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar +# Copyright © 2010-2018 Lance Edgar # # This file is part of Rattail. # @@ -26,23 +26,15 @@ Views for tempmon probes from __future__ import unicode_literals, absolute_import +import six + from rattail_tempmon.db import model as tempmon -import formalchemy as fa +import colander +from webhelpers2.html import tags from tailbone import forms -from tailbone.db import TempmonSession -from tailbone.views.tempmon import MasterView, ClientFieldRenderer - - -def unique_config_key(value, field): - probe = field.parent.model - query = TempmonSession.query(tempmon.Probe)\ - .filter(tempmon.Probe.config_key == value) - if probe.uuid: - query = query.filter(tempmon.Probe.uuid != probe.uuid) - if query.count(): - raise fa.ValidationError("Config key must be unique") +from tailbone.views.tempmon import MasterView class TempmonProbeView(MasterView): @@ -65,6 +57,22 @@ class TempmonProbeView(MasterView): 'status', ] + form_fields = [ + 'client', + 'config_key', + 'appliance_type', + 'description', + 'device_path', + 'critical_temp_min', + 'good_temp_min', + 'good_temp_max', + 'critical_temp_max', + 'therm_status_timeout', + 'status_alert_timeout', + 'enabled', + 'status', + ] + def configure_grid(self, g): super(TempmonProbeView, self).configure_grid(g) @@ -83,31 +91,40 @@ class TempmonProbeView(MasterView): g.set_link('config_key') g.set_link('description') - def _preconfigure_fieldset(self, fs): - fs.config_key.set(validate=unique_config_key) - fs.client.set(label="TempMon Client", renderer=ClientFieldRenderer) - fs.appliance_type.set(renderer=forms.renderers.EnumFieldRenderer(self.enum.TEMPMON_APPLIANCE_TYPE)) - fs.status.set(renderer=forms.renderers.EnumFieldRenderer(self.enum.TEMPMON_PROBE_STATUS)) + def configure_form(self, f): + super(TempmonProbeView, self).configure_form(f) - def configure_fieldset(self, fs): - fs.configure( - include=[ - fs.client, - fs.config_key, - fs.appliance_type, - fs.description, - fs.device_path, - fs.critical_temp_min, - fs.good_temp_min, - fs.good_temp_max, - fs.critical_temp_max, - fs.therm_status_timeout, - fs.status_alert_timeout, - fs.enabled, - fs.status, - ]) + # config_key + f.set_validator('config_key', self.unique_config_key) + + # client + f.set_renderer('client', self.render_client) + f.set_label('client', "Tempmon Client") + + # appliance_type + f.set_enum('appliance_type', self.enum.TEMPMON_APPLIANCE_TYPE) + + # status + f.set_enum('status', self.enum.TEMPMON_PROBE_STATUS) if self.creating or self.editing: - del fs.status + f.remove_fields('status') + + def unique_config_key(self, node, value): + query = self.Session.query(tempmon.Probe)\ + .filter(tempmon.Probe.config_key == value) + if self.editing: + probe = self.get_instance() + query = query.filter(tempmon.Probe.uuid != probe.uuid) + if query.count(): + raise colander.Invalid(node, "Config key must be unique") + + def render_client(self, probe, field): + client = probe.client + if not client: + return "" + text = six.text_type(client) + url = self.request.route_url('tempmon.clients.view', uuid=client.uuid) + return tags.link_to(text, url) def delete_instance(self, probe): # bulk-delete all readings first diff --git a/tailbone/views/tempmon/readings.py b/tailbone/views/tempmon/readings.py index 4e4dc5b8..a9c6d2c2 100644 --- a/tailbone/views/tempmon/readings.py +++ b/tailbone/views/tempmon/readings.py @@ -1,8 +1,8 @@ -# -*- coding: utf-8 -*- +# -*- coding: utf-8; -*- ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar +# Copyright © 2010-2018 Lance Edgar # # This file is part of Rattail. # @@ -26,13 +26,14 @@ Views for tempmon readings from __future__ import unicode_literals, absolute_import +import six from sqlalchemy import orm from rattail_tempmon.db import model as tempmon -import formalchemy as fa +from webhelpers2.html import tags -from tailbone.views.tempmon import MasterView, ClientFieldRenderer, ProbeFieldRenderer +from tailbone.views.tempmon import MasterView class TempmonReadingView(MasterView): @@ -56,6 +57,13 @@ class TempmonReadingView(MasterView): 'degrees_f', ] + form_fields = [ + 'client', + 'probe', + 'taken', + 'degrees_f', + ] + def query(self, session): return session.query(tempmon.Reading)\ .join(tempmon.Client)\ @@ -89,18 +97,32 @@ class TempmonReadingView(MasterView): def render_client_host(self, reading, column): return reading.client.hostname - def _preconfigure_fieldset(self, fs): - fs.client.set(label="TempMon Client", renderer=ClientFieldRenderer) - fs.probe.set(label="TempMon Probe", renderer=ProbeFieldRenderer) + def configure_form(self, f): + super(TempmonReadingView, self).configure_form(f) - def configure_fieldset(self, fs): - fs.configure( - include=[ - fs.client, - fs.probe, - fs.taken, - fs.degrees_f, - ]) + # client + f.set_renderer('client', self.render_client) + f.set_label('client', "Tempmon Client") + + # probe + f.set_renderer('probe', self.render_probe) + f.set_label('probe', "Tempmon Probe") + + def render_client(self, reading, field): + client = reading.client + if not client: + return "" + text = six.text_type(client) + url = self.request.route_url('tempmon.clients.view', uuid=client.uuid) + return tags.link_to(text, url) + + def render_probe(self, reading, field): + probe = reading.probe + if not probe: + return "" + text = six.text_type(probe) + url = self.request.route_url('tempmon.probes.view', uuid=probe.uuid) + return tags.link_to(text, url) def includeme(config): From acb4a7703249f30c92bac9811fa493c443c40f55 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 8 Jan 2018 22:49:45 -0600 Subject: [PATCH 0609/3196] Add first attempt at master3 for batch views --- tailbone/views/batch/__init__.py | 3 +- tailbone/views/batch/core3.py | 184 +++++++++++++++++++++++++++++++ 2 files changed, 186 insertions(+), 1 deletion(-) create mode 100644 tailbone/views/batch/core3.py diff --git a/tailbone/views/batch/__init__.py b/tailbone/views/batch/__init__.py index 39f70fb3..50951b98 100644 --- a/tailbone/views/batch/__init__.py +++ b/tailbone/views/batch/__init__.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar +# Copyright © 2010-2018 Lance Edgar # # This file is part of Rattail. # @@ -28,3 +28,4 @@ from __future__ import unicode_literals, absolute_import from .core import BatchMasterView, FileBatchMasterView from .core2 import BatchMasterView2, FileBatchMasterView2 +from .core3 import BatchMasterView3, FileBatchMasterView3 diff --git a/tailbone/views/batch/core3.py b/tailbone/views/batch/core3.py new file mode 100644 index 00000000..d668a96a --- /dev/null +++ b/tailbone/views/batch/core3.py @@ -0,0 +1,184 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2018 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see . +# +################################################################################ +""" +Base views for maintaining batches +""" + +from __future__ import unicode_literals, absolute_import + +import os + +import six + +from webhelpers2.html import tags + +from tailbone.views import MasterView3 +from tailbone.views.batch import BatchMasterView2, FileBatchMasterView2 + + +class BatchMasterView3(MasterView3, BatchMasterView2): + """ + Base class for all "batch master" views + """ + + form_fields = [ + 'id', + 'created', + 'created_by', + 'rowcount', + 'cognized', + 'cognized_by', + 'executed', + 'executed_by', + 'purge', + ] + + def configure_form(self, f): + super(BatchMasterView3, self).configure_form(f) + + # id + f.set_readonly('id') + f.set_renderer('id', self.render_batch_id) + f.set_label('id', "Batch ID") + + # created + f.set_readonly('created') + f.set_readonly('created_by') + f.set_renderer('created_by', self.render_user) + f.set_label('created_by', "Created by") + + # cognized + f.set_renderer('cognized_by', self.render_user) + f.set_label('cognized_by', "Cognized by") + + # row count + f.set_readonly('rowcount') + f.set_label('rowcount', "Row Count") + + # status_code + f.set_renderer('status_code', self.make_status_renderer(self.model_class.STATUS)) + f.set_label('status_code', "Status") + + # executed + f.set_readonly('executed') + f.set_readonly('executed_by') + f.set_renderer('executed_by', self.render_user) + f.set_label('executed_by', "Executed by") + + # notes + f.set_type('notes', 'text') + + # if self.creating and self.request.user: + # batch = fs.model + # batch.created_by_uuid = self.request.user.uuid + + if self.creating: + f.remove_fields('id', + 'rowcount', + 'created', + 'created_by', + 'cognized', + 'cognized_by', + 'executed', + 'executed_by', + 'purge') + + else: # not creating + batch = self.get_instance() + if not batch.executed: + f.remove_fields('executed', + 'executed_by') + + def save_create_form(self, form): + self.before_create(form) + + with self.Session.no_autoflush: + + # transfer form data to batch instance + batch = self.objectify(form, self.form_deserialized) + + # current user is batch creator + batch.created_by = self.request.user or self.late_login_user() + + # destroy initial batch and re-make using handler + kwargs = self.get_batch_kwargs(batch) + self.Session.expunge(batch) + batch = self.handler.make_batch(self.Session(), **kwargs) + + self.Session.flush() + + # TODO: this needs work yet surely... + # if batch has input data file, let handler properly establish that + filename = getattr(batch, 'filename', None) + if filename: + path = os.path.join(self.upload_dir, filename) + if os.path.exists(path): + self.handler.set_input_file(batch, path) + os.remove(path) + + return batch + + def make_status_renderer(self, enum): + def render_status(self, batch, field): + value = batch.status_code + if value is None: + return "" + status_code_text = enum.get(value, six.text_type(value)) + if batch.status_text: + return HTML.tag('span', title=batch.status_text, c=status_code_text) + return status_code_text + return render_status + + def render_user(self, batch, field): + user = getattr(batch, field) + if not user: + return "" + title = six.text_type(user) + url = self.request.route_url('users.view', uuid=user.uuid) + return tags.link_to(title, url) + + +class FileBatchMasterView3(BatchMasterView3, FileBatchMasterView2): + """ + Base class for all file-based "batch master" views + """ + + def configure_form(self, f): + super(FileBatchMasterView3, self).configure_form(f) + + # filename + f.set_renderer('filename', self.render_filename) + f.set_label('filename', "Data File") + if self.editing: + f.set_readonly('filename') + + # if creating, let filename be our only field by default + if self.creating: + f.fields = [ + 'filename', + ] + + def render_filename(self, batch, field): + path = batch.filepath(self.rattail_config, filename=batch.filename) + url = self.get_action_url('download', batch) + return self.render_file_field(path, url) From 485c96fec1a4a878878ac2dbdb81ecc63f49753f Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 10 Jan 2018 19:56:14 -0600 Subject: [PATCH 0610/3196] 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 bd14dcaa..d8332322 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,14 @@ CHANGELOG ========= +0.6.60 (2018-01-10) +------------------- + +* Refactor several straggler views to use master3. + +* Add first attempt at master3 for batch views. + + 0.6.59 (2018-01-08) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index adb2f0ac..f4656ce2 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.6.59' +__version__ = '0.6.60' From bfa398bee1a355fde10872b09c1d79f7c5e68533 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 10 Jan 2018 20:46:49 -0600 Subject: [PATCH 0611/3196] Provide some default readonly form field renderers --- tailbone/forms2/core.py | 32 ++++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/tailbone/forms2/core.py b/tailbone/forms2/core.py index be67c585..a23470c7 100644 --- a/tailbone/forms2/core.py +++ b/tailbone/forms2/core.py @@ -286,7 +286,7 @@ class Form(object): """ def __init__(self, fields=None, schema=None, request=None, readonly=False, readonly_fields=[], - model_instance=None, model_class=None, nodes={}, enums={}, labels={}, renderers={}, + model_instance=None, model_class=None, nodes={}, enums={}, labels={}, renderers=None, widgets={}, defaults={}, validators={}, required={}, helptext={}, action_url=None, cancel_url=None): @@ -304,7 +304,10 @@ class Form(object): self.nodes = nodes or {} self.enums = enums or {} self.labels = labels or {} - self.renderers = renderers or {} + if renderers is None and self.model_class: + self.renderers = self.make_renderers() + else: + self.renderers = renderers or {} self.widgets = widgets or {} self.defaults = defaults or {} self.validators = validators or {} @@ -334,6 +337,31 @@ class Form(object): return fields + def make_renderers(self): + """ + Return a default set of field renderers, based on :attr:`model_class`. + """ + if not self.model_class: + raise ValueError("Must define model_class to use make_renderers()") + + mapper = orm.class_mapper(self.model_class) + renderers = {} + + for field in self.fields: + if mapper.has_property(field): + prop = mapper.get_property(field) + if isinstance(prop, orm.ColumnProperty): + if len(prop.columns) == 1: + column = prop.columns[0] + + if isinstance(column.type, sa.DateTime): + renderers[field] = self.render_datetime + + elif isinstance(column.type, sa.Boolean): + renderers[field] = self.render_boolean + + return renderers + def append(self, field): self.fields.append(field) From e3ca3c9370147174d58a55a9cd7d0f2c57ee5b71 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 10 Jan 2018 21:18:38 -0600 Subject: [PATCH 0612/3196] Fix readonly default renderers for association proxy fields --- tailbone/forms2/core.py | 57 +++++++++++++++++++++++++++++++++-------- 1 file changed, 46 insertions(+), 11 deletions(-) diff --git a/tailbone/forms2/core.py b/tailbone/forms2/core.py index a23470c7..f3a3cae7 100644 --- a/tailbone/forms2/core.py +++ b/tailbone/forms2/core.py @@ -67,6 +67,31 @@ def get_association_proxy(mapper, field): return desc +def get_association_proxy_target(inspector, field): + """ + Returns the property on the main class, which represents the "target" + for the given association proxy field name. Typically this will refer + to the "extension" model class. + """ + proxy = get_association_proxy(inspector, field) + if proxy: + proxy_target = inspector.get_property(proxy.target_collection) + if isinstance(proxy_target, orm.RelationshipProperty) and not proxy_target.uselist: + return proxy_target + + +def get_association_proxy_column(inspector, field): + """ + Returns the property on the proxy target class, for the column which is + reflected by the proxy. + """ + proxy_target = get_association_proxy_target(inspector, field) + if proxy_target: + prop = proxy_target.mapper.get_property(field) + if isinstance(prop, orm.ColumnProperty) and isinstance(prop.columns[0], sa.Column): + return prop + + class CustomSchemaNode(SQLAlchemySchemaNode): def association_proxy(self, field): @@ -344,21 +369,31 @@ class Form(object): if not self.model_class: raise ValueError("Must define model_class to use make_renderers()") - mapper = orm.class_mapper(self.model_class) + inspector = sa.inspect(self.model_class) renderers = {} - for field in self.fields: - if mapper.has_property(field): - prop = mapper.get_property(field) - if isinstance(prop, orm.ColumnProperty): - if len(prop.columns) == 1: - column = prop.columns[0] + # TODO: clearly this should be leaner... - if isinstance(column.type, sa.DateTime): - renderers[field] = self.render_datetime + # first look at regular column fields + for prop in inspector.iterate_properties: + if isinstance(prop, orm.ColumnProperty): + if len(prop.columns) == 1: + column = prop.columns[0] + if isinstance(column.type, sa.DateTime): + renderers[prop.key] = self.render_datetime + elif isinstance(column.type, sa.Boolean): + renderers[prop.key] = self.render_boolean - elif isinstance(column.type, sa.Boolean): - renderers[field] = self.render_boolean + # then look at association proxy fields + for key, desc in inspector.all_orm_descriptors.items(): + if desc.extension_type == ASSOCIATION_PROXY: + prop = get_association_proxy_column(inspector, key) + if prop: + column = prop.columns[0] + if isinstance(column.type, sa.DateTime): + renderers[key] = self.render_datetime + elif isinstance(column.type, sa.Boolean): + renderers[key] = self.render_boolean return renderers From c750ea235582d97fb04c9321d593092c98a0f491 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 10 Jan 2018 21:23:58 -0600 Subject: [PATCH 0613/3196] Tweak feedback dialog styles a bit --- tailbone/static/js/tailbone.feedback.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/static/js/tailbone.feedback.js b/tailbone/static/js/tailbone.feedback.js index a9412949..f6d44875 100644 --- a/tailbone/static/js/tailbone.feedback.js +++ b/tailbone/static/js/tailbone.feedback.js @@ -9,7 +9,7 @@ $(function() { textarea.val(''); dialog.dialog({ title: "User Feedback", - width: 500, + width: 600, modal: true, buttons: [ { From 99ed897bf483111a65741997b181ee6be7e08cca Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 11 Jan 2018 12:15:19 -0600 Subject: [PATCH 0614/3196] Add getting started doc for dev environment also change docs theme --- docs/conf.py | 14 ++++-- docs/devenv.rst | 78 +++++++++++++++++++++++++++++++++ docs/images/rattail_avatar.png | Bin 0 -> 7770 bytes docs/index.rst | 11 +++++ setup.py | 9 ++-- 5 files changed, 104 insertions(+), 8 deletions(-) create mode 100644 docs/devenv.rst create mode 100644 docs/images/rattail_avatar.png diff --git a/docs/conf.py b/docs/conf.py index 4529fcc2..8cbe5273 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,4 +1,4 @@ -# -*- coding: utf-8 -*- +# -*- coding: utf-8; -*- # # Tailbone documentation build configuration file, created by # sphinx-quickstart on Sat Feb 15 23:15:27 2014. @@ -15,6 +15,8 @@ import sys import os +import sphinx_rtd_theme + exec(open(os.path.join(os.pardir, 'tailbone', '_version.py')).read()) @@ -58,14 +60,15 @@ master_doc = 'index' # General information about the project. project = u'Tailbone' -copyright = u'2015, Lance Edgar' +copyright = u'2010 - 2018, 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' +# version = '0.3' +version = '.'.join(__version__.split('.')[:2]) # The full version, including alpha/beta/rc tags. release = __version__ @@ -112,7 +115,8 @@ pygments_style = 'sphinx' # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. -html_theme = 'classic' +# html_theme = 'classic' +html_theme = 'sphinx_rtd_theme' # 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 @@ -121,6 +125,7 @@ html_theme = 'classic' # Add any paths that contain custom themes here, relative to this directory. #html_theme_path = [] +html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] # The name for this set of Sphinx documents. If None, it defaults to # " v documentation". @@ -132,6 +137,7 @@ html_theme = 'classic' # The name of an image file (relative to this directory) to place at the top # of the sidebar. #html_logo = None +html_logo = 'images/rattail_avatar.png' # 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 diff --git a/docs/devenv.rst b/docs/devenv.rst new file mode 100644 index 00000000..c8900f60 --- /dev/null +++ b/docs/devenv.rst @@ -0,0 +1,78 @@ + +Development Environment +======================= + +.. contents:: :local: + +Base System +----------- + +Development for Tailbone in particular is assumed to occur on a Linux machine. +This is because it's assumed that the web app would run on Linux. It should be +possible (presumably) to do either on Windows or Mac but that is not officially +supported. + +Furthermore it is assumed the Linux flavor in use is either Debian or Ubuntu, +or a similar alternative. Presumably any Linux would work although some +details may differ from what's shown here. + +Prerequisites +------------- + +Python +^^^^^^ + +The only supported Python is 2.7. Of course that should already be present on +Linux. + +It usually is required at some point to compile C code for certain Python +extension modules. In practice this means you probably want the Python header +files as well: + +.. code-block:: sh + + sudo apt-get install python-dev + +pip +^^^ + +The only supported Python package manager is ``pip``. This can be installed a +few ways, one of which is: + +.. code-block:: sh + + sudo apt-get install python-pip + +virtualenvwrapper +^^^^^^^^^^^^^^^^^ + +While not technically required, it is recommended to use ``virtualenvwrapper`` +as well. There is more than one way to set this up, e.g.: + +.. code-block:: sh + + sudo apt-get install python-virtualenvwrapper + +The main variable as concerns these docs, is where your virtual environment(s) +will live. If you install virtualenvwrapper via the above command, then most +likely your ``$WORKON_HOME`` environment variable will be set to +``~/.virtualenvs`` - however these docs will assume ``/srv/envs`` instead. +Please adjust any commands as needed. + +PostgreSQL +^^^^^^^^^^ + +The other primary requirement is PostgreSQL. Technically that may be installed +on a separate machine, which allows connection from the development machine. +But of course it will usually just be installed on the dev machine: + +.. code-block:: sh + + sudo apt-get install postgresql + +Regardless of where your PG server lives, you will probably need some extras in +order to compile extensions for the ``psycopg2`` package: + +.. code-block:: sh + + sudo apt-get install libpq-dev diff --git a/docs/images/rattail_avatar.png b/docs/images/rattail_avatar.png new file mode 100644 index 0000000000000000000000000000000000000000..99640af34c5c956f5bcb1b89b16240a6f561e60b GIT binary patch literal 7770 zcmV-g9;M-lP)Px#32;bRa{vK1Yybf_Yyo8L@*DsF00(qQO+^RY1_cEeH3*45QUCxQ>`6pHRCwCO zdv}zaSC!{`UyhY?cU9-0PEt!PSwfP7EK3L*jBN}Ui~$n{SQyUCz}cNS%)!g-?3@F; zEMwSVXN)mm4-S}Mj4VsCm1W5-b&%9Ksk^GWa!z0V-u@xWHqf$Vqn7KBsz2(hy1)Co z_uY5zyZ1o^uEz@j09XD7yDstp1Q5N401z-vnBYAG0F8V0r>HnEu)Bi7s1R(fs?@dqmSX%|@^i9{B0R%zinfrTw=X>~x`qbL#YTnh;iB^)oiU6Rww0LXNZwm6qMvOpp8+;aC-#CE4;Pe`H zEU4}|jXT0ybb>TBZDhzyBZ9PikWqb{ue%S!dGe>S%yva+;UxQ`hL&#ScV7y5Yx2&SFtDV zddfG`F=vSVm8I&o+PSZJu17|MAcD&YTX38?`1!G-rSF7ywoYDVnTs18XYPACppJEoZNi2F+XY}me*ki@OPP%LUh#}K?D(ihdAGIV(gQn%-EME zZ+Nx`4}XG8-eKDdy1~0M0RmSnDOP&cJWM6o{gP85x z&yYtI%UJ|gV;R1w=0pI^X3fn9OvaPKgN1BkG;bBNg|$bmG;|QluAN{t5)ugE8w4oh z>iRQ2I(9tcm^v1UX(eVk9sI4+QR)Oj?`C)fRj*kjMCi%phQZ~62-d7yn%I>;uax(4 z%Hd`)(tiGkw?{%n!}cOO+6^$ZG2L^E$p8pud0t1kj~7w1OoOKnQ~I z29_*Tq2JobRy^ayjir4%e^cz>%Vn;3_cWEg1);y-gaiQu5xS%({ImZt0feP}C7DaY zTP9_7BHUd~6PmhfszID@p#15(eo~Qi5?*Ofxf&B(_Md2WF1akb@zF+w%we`!TPJVC z9EQ!zhwp4PxoAVghW3ZlqISzuH}Zp>zMLx)@pg=ohJWLggW1~vKo9_6YFg~@LVnR* zg^6vH$>ketXM?x}ue$m2VrwQ~9X8!LUsn}sMJ?XU6&eRyYmuF!i}xQ5DGg)%>>j~! z<#75|01yNK#9kTYj6}@WvpA5EE3V))bc1NC1PFgN%Xi86E1A2LK4!~uc2m)Xfi?w}Y5}EiqPp3Tl29irRXhlqK zt*CIRle0*6Rh#k!)!=EK4$sJ9wmukXL(afp>)6`Ka^Deg&p5twu#mp}U@*HI!>*Qr z*LUB=M~2!mJ8Hp(@We+WK{iu$oiiYzT3w5Nde-BW{7*cn*67)_7uTRDjEn5v9=|g0 zyV%U1-)2jOT{AbBN7t=B|V_dRxN6WQ+nUI&n&+5)3&M6y-<_gAGV(sit50a&d2H%$GP=0q|}*e)Tg@oju+iGzx9Ux1^@&gf?)r_;?ft}8yM3-C7uEq zTH;HTRb{xSQ?n*4cp0T#L)(@D>CVA++sijkx00s^RCcOsstuoNPB@}*$+sMKQ_GS1 z;V*k6@Bl%<*v}3?_2!cp?&hd&$o~{0b(3Sp%{y^M%by96vY;R zLeyIIltecu)By|RCmUhT!0f;_=i!yqmZYf4yo~I1Q>Ue2*E$n|w=%$G1`tHB_JgCs zmn?024{Ua!NPwX5$%%V*m8q0sVKoi26m1C4x$-)sEm=BFbdB_3b4?fe!9`NE=Jk9# zs-T&Hv))MC;$#n@MwMoKF4oeNz$^9EI{NishTxy(SolK#AP7O_@vnCFJ!rvq#vg!2 zTdBzftnF~-^o>081@)3Cx4Q41})s_wXzNV;d5iY{xtIFZ| zczW+Mutj0M-Sh7I^3~bJ`4*Ho(+|^1D=y$o);z7$ ziK83!4F!h};I*4TFmO@C9{uO3nI%}L;S_+cA;BL)m5rsp`(WLGi~dob{6+wk3ohM; zQ+6ih&5+_tS`VWMN}VS}jqAgCdn=%}xB$hqTF&yJiN)2c2AAi^I4+ufh>h%|>sAHExG zt$87bR~Dy}YYLj_mh`|QyDhA20ZA2l8J$#UH75XyAb2s|?u?8!5KuszWdeAbrWMf@ z^~x=O=sS1Y@4A<>yoE?ptz=Eq19S^X@YzN!kn8l~PN^x-lH`efgHJwrwwNNoR^snk zXc|4kC!0raq8g#=knIyPK;|P`YZbk9tmi;Bp$ms9+nm_ zI%>FsCiMHMZr*(IV^nx@8%PEm*=_B496NMd3*yxI3qWj)(Nd-g6*w{g0Dxeb6bMg` zH7Zb)N~Z$Z;uOcxIbe|qV5v$PY~le7`kJ@wvQ!LUtLi|lIy_VOdZ3Lm*~!t_bZp_Y z0W5kV)Jc^p!-Kcgd%bfY|MhV&Tu(GWuGb#rI{|KQ728Clmk}2XO1hYb6X$L_NdO3d z`5;}5E+!|p@ffWHu%uvhYNo~Dbee?}#POQMfE**n3w=IJFa2sQqsUIFSSlR3Kx=tK zK($g~S?C5Bs}rS7yZooxIVEOua?36+DO4O2t*J~alf|hI>uo7}@7=+>ee?U)YFx~e zp2s0XK>eGovku<=Onbk@#S#QEMTxXwxCBL0RdvafIY1gHRnEP-MW@bociXV}-<49S z;&Lcp*fm}fMNinO%8pj9SRGK-!D4sTj-hA>Tu?$L%!aBysdd6yhc~A0t?4k@L3* zHj{AUJuhLgH3d2KuRr!n>dq&pl5mk#dG}DW4uw*QC|5W(qmvYl0UbsO9ah+?U>8O& zgrhJ2@?XMIM^9b4Tw(xQFYwNkBKb28g^oA(Z@N`BT9P&z#t;s=<(Z8Z6K|8C@;k3t zoy0K_0}c~qw4A*3=|DX9N*UUu8_ygY{%(>Grs~4a#{1h$Axemp#jlpj9I(Vy?HtcE zF94H3svJ(XxtbmLDTMdRnR$$F;iy6(8(Au3s}=0Il;r4g^kR~1?~Mbn;dvgwAwQsu zWr~Bg&xg^vRs+97u!ph$STNK;y#M=iao{bWs06Djjt}{!3kBM?)>r2BlG8}Dv3jFY z5p_y_)lWi@7pqk>Tbphd*PJG%oZh%Q!C`JpO`C*-5G>Z=yBBJAEXB^Z`k(5@fMBZ4 zRYqbt{@lS9_sp3#p)}Pji}=Q-Ku|E4LY$Q_fBVlX1Zz>sFG`nQuvT<-1vt1JEc7@z z(D_meP-g0&2GoYLjq-ZMjV0Qt6P9{n7{3YYHaQX>>dTN>y|@3DUehUA+M?>?zC5=e z{qTGH{~Ns+!U)o^8z*{AADvZ_c)WEgVB%?KR!WXKpd*MDjtB3UR|!Tfurf!QpZ4}z zM0#%*%D6S?tnelYT(vW5NLzA>*s_JLJKJRORaslcmv-ZysbP{UBY29W;;2=+?tBcS zOR#92w8e$Nkw1LBvUWSgNd!(xCP66{J!U73vfZr71QcXTy=B+`9V$+xj*% zu{ZRrt?kC?f_S1M6t^r2OU-76f<)9ec0<|+rn7ZYZKz&cirIK(vCdOPe*7PPP;app zldQoIC4+(3rw<%#s2)p{TrVw+N}ox6_*T6_RxEOXBgn;w(@XLS@`#!*CNeUP%_QSq9-t~e{Y z6EkoQAVMS5RGBmAMNX!5$&jchjGD0>>jPS`p*?H|uC!ZS+=_#IHsCndFwqvbft*D^ zI%)zek#{NfIfn&crEXOSy+OwSqQ5cwiODC*Q-{cfnPQ64kYA(}KP72& z2_Zt^G33Jpf)Q{#-Qr ztc#oiFV>|XJZ1sO$tFOI2Pt5b>K#is2r5(cECwKgfT@Rtw5=eKrfD~L2opGODO$B9R*D-2!^j#DgTWegUNo(QbCN>M^EGA1l-|%^ z1J2G08DQjm4p1woJ^|tO7+mz5DBaYlR6iUBL!1L7@rN#*2%x4<8h@Dm zyl-!0LxIwxqUNU9q#(?@k+Bdk+<<`^ho5qVke!u~DinCpqNX`#C6OOcvZfoC=Qh!0 z&J>oNkcoTbEbW54Uh6Kh5=gl~a=ssxb3QDJg(#XHYf-4Ywc;c^37y0)pOhi?p&v8< zJosO?y)czs>Nhd1dXb1P2C7-JrI?$DB$R}n>z|R6nUqwOqAaH61d6GYCis15=9tkf4N`Dih6t0xB(-+L{Jv1Vu%IKXn+zBi4(#1sVVL zrQ#d|2taJ(15*!+yS@Ktw^^%H)>Wo6p{lPIbtW7IzhRJ6Sa-sVy~L0)hoj6`jytmQ zoVmrUjyE%oWV}--Lp)p$)qIFm_^iv36DQ?n96fzAPGwSlZ$8bUYR16~@{Yz7U6yKP zJr~0t0uTZWKm&sSA?pV%zp#5AcqrKzD%rT4=Adc07_6`*Us|fzq(r4?XUaYz=QPT7 z3Y!gDHE~_WnOLi_(aHvZQZ75KaHH8yB(s{G9Cwx#fvV|EIq6KRO`7%#S~+PI7bUyC zIBUX8VT7sT->7f^0*EjG0E7Un-!XQW`NuQQ#nvw)(viiLDrU_R&N3ysN)`@ZTQwzI z=CKOIR3euvh0*qmQ+EzFPHs9|XB_tPYQhaMmPt=J*fO5$4orB)dDTjxvU~VKgE=wd z1^ERjb0)=DyWNYQ-4e&e`@j983I+^}gb+Xkfn`hWhs~e2{rmJT-LI6xAzrhjLj|jZ zD5^@wMG@B-NyOq-P9+&2|L1+~S=C8JsSuU07u%0_7;42`x9BSN9_mQvf()E-(e<;I zR6fiPZJHd9wB{FPtKM_Bebf=z8gL(5vkotO@-8Ox;?MU!^4vnXfq)QVpnK;dwSkXa z{6S!I!Cqx@P^~M&DksyadMq@_i%AjENgrM+U_wf`@Ss$SH{j99J{WiBJx-YAXIjBY z7ZglKZp~mLh_bvs66-7{6os4dlq2oyo6~o`?-bvpKf1wV`{f3__wG;L-rb>WvX@^t z@QuetUi{fHSVZf;!}s4nK6<{zYOq$LLYEn%R;Of^ta8CxYKZ2Y%#aijT0NA4%pT?N(fAWilz2sX=khl8+a z%Yxu4QiYI4vxn|O=R0{aBUsttK=sx`9df3N5YG8?HQY8+&nvcUx$%{u;@;|1>M44>KSDQ>R8yoM0Nz_c_1L1`RoIA&Mv>t4& zT(p>H9dhB7^xzTk*1z}D5B#vUlX|{k&)#x}?VMv^MD@9!Y2op2lXTBRtIyL;&)4$xm!_20 zYJS_@T*&aJ+Dt0qCTnS5(p$l2TGZylPko5lQ9Wa97NrdEkViI-r9MEuuy(R{(k_Mt zfzlJ+li}$fIWM1xApl?c{r@(Po&OB~tsPquhwsNubDQyRLq?rk5A2_5&b?4w9^#tz zG+XOt{o%;ZjxG#0W$Qlhvh!j~ui{A4Tv@Zr5m&M5f(p@;>Nx#V>PtFE_qRPZkWLTv z*HX)Y-h<67o=w$1BR6GSnS!|&zw=An>)lNVQA^jusol*-q>nEgS=W(NV9^2*K=7{L zA^;JTzcc4|vqYERk1>Etj`(A*M&}5)xO9}NM4mjv?>1YL&ShVtwZfl>7u+WYp8nQ`Dhp))n8i7_J?kzCGzkA%_hkCE zGKVO2yLz~?UMrAdYUxAeh=E}*4ro8yL`BkHUhDX?Q(^=Gs2#ib(L)1`k|}`K+i)*r z9(etkeg6hgU0WmRoFbv6WKD)e=|fnF40`twjAquE93Dx{cL!*~Ui|e374Pu2k@fMX zrgwW|8;j{}=O#C02mRQ~wl%xES3aXg5J3ZfqdW83VYN=Yi6wdN;isH?`Vx2>0TjhU z_$n4W-Bh0KO0#Z#GK3PYi47%we1kOHC!`5rM&eVZ)*=RpWt(l-l^Y&AiE{+J(m6eX zmwN+m06=ek`jXF#zB-kkZ0(=l=&8zH*Cvz9XB#|yugXg9=Ky2>lHdsqT+17Gl_Jtjd zk^-(d18+0dgaCliA*|ME6l1u~uj#znb@&daXs+0Xh5&%N+c@V!mb)X%@%swbBo4ge zS#EVYWtvs3*fAWa+jeyjuHi%_1c2&YmjOl_N#9<=w5!wY+KzN0;JnwBE`Z{6OZ)lj zZi36vB6gnTYu3ECjkp>kTfzY+fhoz#seL>H&a%W(fd|i{B)F?EIs|a@7Dpg@P+oHsHvQy`SFjTGfZ(D*C`mE{_02 z(8zwL^lt*XR9nI8aP4;mVEVI({+nLIc-;ZKhT-t38|xdvdj#MThGYJ=_Y%M*(eS$6 gzF(n+fR}Lme+@+aL=ZhhG5`Po07*qoM6N<$g0;}+BLDyZ literal 0 HcmV?d00001 diff --git a/docs/index.rst b/docs/index.rst index 3fbf704e..b5968992 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -4,6 +4,10 @@ Tailbone Welcome to Tailbone, part of the Rattail project. +.. + While the core Rattail package provides the data schema / ORM / application + layer, the Tailbone package provides the web application layer. + The documentation you are currently reading is for the Tailbone web application package. Some additional information is available on the `website`_. Clearly not everything is documented yet. Below you can see what has received some @@ -11,6 +15,13 @@ attention thus far. .. _website: https://rattailproject.org/ +Getting Started: + +.. toctree:: + :maxdepth: 1 + + devenv + Narrative Documentation: .. toctree:: diff --git a/setup.py b/setup.py index 96e9458d..84fbcf18 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar +# Copyright © 2010-2018 Lance Edgar # # This file is part of Rattail. # @@ -112,7 +112,8 @@ extras = { # package # low high 'Sphinx', # 1.2 - ], + 'sphinx-rtd-theme', # 0.2.4 + ], 'tests': [ # @@ -122,8 +123,8 @@ extras = { 'fixture', # 1.5 'mock', # 1.0.1 'nose', # 1.3.0 - ], - } + ], +} setup( From 3dce9d9ed3dfb1276a1dff3ae95ca1c1df7da145 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 11 Jan 2018 12:17:21 -0600 Subject: [PATCH 0615/3196] Fix dependencies when building docs via tox --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index 560033d0..615850c0 100644 --- a/tox.ini +++ b/tox.ini @@ -19,7 +19,7 @@ commands = [testenv:docs] basepython = python -deps = Sphinx +deps = Sphinx sphinx-rtd-theme changedir = docs commands = pip install --upgrade Tailbone rattail[auth,bouncer] rattail-tempmon From e2cdb4387a33d1add01269b25b78606f0537175e Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 11 Jan 2018 12:25:25 -0600 Subject: [PATCH 0616/3196] Fix row query bug when deleting batch row --- tailbone/views/batch/core.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index 5e0359ec..720ef62f 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -729,15 +729,15 @@ class BatchMasterView(MasterView): "Delete" a row from the batch. This sets the ``removed`` flag on the row but does not truly delete it. """ - row = self.Session.query(self.model_row_class).get(self.request.matchdict['uuid']) + row = self.Session.query(self.model_row_class).get(self.request.matchdict['row_uuid']) if not row: raise httpexceptions.HTTPNotFound() row.removed = True - batch = row.batch + batch = self.get_parent(row) self.handler.refresh_batch_status(batch) if batch.rowcount is not None: batch.rowcount -= 1 - return self.redirect(self.get_action_url('view', self.get_parent(row))) + return self.redirect(self.get_action_url('view', batch)) def bulk_delete_rows(self): """ From 935752c78684b4bd79fff3c7be3bfb18518114f5 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 11 Jan 2018 12:26:38 -0600 Subject: [PATCH 0617/3196] 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 d8332322..6683d89f 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,14 @@ CHANGELOG ========= +0.6.61 (2018-01-11) +------------------- + +* Provide some default readonly form field renderers. + +* Fix row query bug when deleting batch row. + + 0.6.60 (2018-01-10) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index f4656ce2..9090d271 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.6.60' +__version__ = '0.6.61' From 4147752672f1d60737a2435f8910689208a2277e Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 11 Jan 2018 12:32:31 -0600 Subject: [PATCH 0618/3196] Fix dialog button click event when executing price batch i.e. fix it for Chrome's sake --- tailbone/static/js/tailbone.batch.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/static/js/tailbone.batch.js b/tailbone/static/js/tailbone.batch.js index f421e9fe..fa66f96b 100644 --- a/tailbone/static/js/tailbone.batch.js +++ b/tailbone/static/js/tailbone.batch.js @@ -22,7 +22,7 @@ $(function() { { text: "Execute", click: function(event) { - $(event.target).button('option', 'label', "Executing, please wait...").button('disable'); + dialog_button(event).button('option', 'label', "Executing, please wait...").button('disable'); $('form[name="batch-execution"]').submit(); } }, From 0e0ebe925128fec0a81c1a6a18f6aa760a30e288 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 11 Jan 2018 12:38:44 -0600 Subject: [PATCH 0619/3196] Fix some mobile view URLs --- tailbone/views/inventory.py | 2 +- tailbone/views/purchasing/receiving.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tailbone/views/inventory.py b/tailbone/views/inventory.py index 28f5640b..5b8b4762 100644 --- a/tailbone/views/inventory.py +++ b/tailbone/views/inventory.py @@ -254,7 +254,7 @@ class InventoryBatchView(BatchMasterView): self.handler.add_row(batch, row) self.Session.flush() - return self.redirect(self.mobile_row_route_url('view', uuid=row.uuid)) + return self.redirect(self.mobile_row_route_url('view', uuid=row.batch_uuid, row_uuid=row.uuid)) def template_kwargs_view_row(self, **kwargs): row = kwargs['instance'] diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index 38fa60ba..895f90cd 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -258,7 +258,7 @@ class ReceivingBatchView(PurchasingBatchView): self.handler.refresh_batch_status(batch) self.Session.flush() - return self.redirect(self.mobile_row_route_url('view', uuid=row.uuid)) + return self.redirect(self.mobile_row_route_url('view', uuid=row.batch_uuid, row_uuid=row.uuid)) def mobile_view_row(self): """ From e3345645201e6ad3e263666d3daf60394dd4f2f9 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 11 Jan 2018 13:07:10 -0600 Subject: [PATCH 0620/3196] Show case quantity for inventory batch rows --- tailbone/views/inventory.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tailbone/views/inventory.py b/tailbone/views/inventory.py index 5b8b4762..bb47f7dc 100644 --- a/tailbone/views/inventory.py +++ b/tailbone/views/inventory.py @@ -355,6 +355,7 @@ class InventoryBatchView(BatchMasterView): fs.description.set(readonly=True) fs.size.set(readonly=True) fs.previous_units_on_hand.set(label="Prev. On Hand") + fs.case_quantity.set(renderer=forms.renderers.QuantityFieldRenderer) fs.cases.set(renderer=forms.renderers.QuantityFieldRenderer) fs.units.set(renderer=forms.renderers.QuantityFieldRenderer) fs.unit_cost.set(readonly=True, renderer=forms.renderers.CurrencyFieldRenderer) @@ -370,6 +371,7 @@ class InventoryBatchView(BatchMasterView): fs.size, fs.status_code, fs.previous_units_on_hand, + fs.case_quantity, fs.cases, fs.units, fs.unit_cost, From f4aa83788bf8d9fdf66e21f5f604d3efddffe5b3 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 11 Jan 2018 13:17:07 -0600 Subject: [PATCH 0621/3196] Fix tox dependency for docs (for real) --- tox.ini | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index 615850c0..56a07f56 100644 --- a/tox.ini +++ b/tox.ini @@ -19,7 +19,9 @@ commands = [testenv:docs] basepython = python -deps = Sphinx sphinx-rtd-theme +deps = + Sphinx + sphinx-rtd-theme changedir = docs commands = pip install --upgrade Tailbone rattail[auth,bouncer] rattail-tempmon From f021df446cfd1749912d6a39b36d7d9617616483 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 11 Jan 2018 15:15:12 -0600 Subject: [PATCH 0622/3196] Let custom schema node start out with empty children sometimes that's just necessary --- tailbone/forms2/core.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/tailbone/forms2/core.py b/tailbone/forms2/core.py index f3a3cae7..d3301bdf 100644 --- a/tailbone/forms2/core.py +++ b/tailbone/forms2/core.py @@ -145,10 +145,20 @@ class CustomSchemaNode(SQLAlchemySchemaNode): msg = 'excludes and includes are mutually exclusive.' raise ValueError(msg) - properties = sorted(self.inspector.attrs, key=_creation_order) # sorted to maintain the order in which the attributes # are defined - for name in includes or [item.key for item in properties]: + properties = sorted(self.inspector.attrs, key=_creation_order) + if excludes: + if includes: + raise ValueError("Must pass includes *or* excludes, but not both") + supported = [prop.key for prop in properties + if prop.key not in excludes] + elif includes: + supported = includes + elif includes is not None: + supported = [] + + for name in supported: prop = self.inspector.attrs.get(name, name) if name in excludes or (includes and name not in includes): From dfd43b55aad26f8b0745f5c9b990f5e48fedb32a Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 11 Jan 2018 15:17:58 -0600 Subject: [PATCH 0623/3196] Allow passing None to `Form.set_renderer()` i.e. to remove any renderer which has been set --- tailbone/forms2/core.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tailbone/forms2/core.py b/tailbone/forms2/core.py index d3301bdf..95561fed 100644 --- a/tailbone/forms2/core.py +++ b/tailbone/forms2/core.py @@ -576,7 +576,11 @@ class Form(object): self.enums.pop(key, None) def set_renderer(self, key, renderer): - self.renderers[key] = renderer + if renderer is None: + if key in self.renderers: + del self.renderers[key] + else: + self.renderers[key] = renderer def set_widget(self, key, widget): self.widgets[key] = widget From c996bf47ea643ccd88eb2cc28e11ee7c825c3f75 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 11 Jan 2018 15:29:40 -0600 Subject: [PATCH 0624/3196] 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 6683d89f..12eeeaf5 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,20 @@ CHANGELOG ========= +0.6.62 (2018-01-11) +------------------- + +* Fix dialog button click event when executing price batch (for Chrome). + +* Fix some mobile view URLs. + +* Show case quantity for inventory batch rows. + +* Let custom schema node start out with empty children. + +* Allow passing None to ``Form.set_renderer()``. + + 0.6.61 (2018-01-11) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 9090d271..fd6e0290 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.6.61' +__version__ = '0.6.62' From f9d1d347639f50aca86e8667d3997ab03583ede7 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 15 Jan 2018 15:11:12 -0600 Subject: [PATCH 0625/3196] Fix bug when locating association proxy column --- tailbone/forms2/core.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tailbone/forms2/core.py b/tailbone/forms2/core.py index 95561fed..dc44e7f4 100644 --- a/tailbone/forms2/core.py +++ b/tailbone/forms2/core.py @@ -87,9 +87,10 @@ def get_association_proxy_column(inspector, field): """ proxy_target = get_association_proxy_target(inspector, field) if proxy_target: - prop = proxy_target.mapper.get_property(field) - if isinstance(prop, orm.ColumnProperty) and isinstance(prop.columns[0], sa.Column): - return prop + if proxy_target.mapper.has_property(field): + prop = proxy_target.mapper.get_property(field) + if isinstance(prop, orm.ColumnProperty) and isinstance(prop.columns[0], sa.Column): + return prop class CustomSchemaNode(SQLAlchemySchemaNode): From 8291c4d2732c4f9e46745894313489d3cf853caa Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 15 Jan 2018 15:21:29 -0600 Subject: [PATCH 0626/3196] Fix client field when creating / editing tempmon probe --- tailbone/views/tempmon/probes.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/tailbone/views/tempmon/probes.py b/tailbone/views/tempmon/probes.py index 8043759d..157277b4 100644 --- a/tailbone/views/tempmon/probes.py +++ b/tailbone/views/tempmon/probes.py @@ -1,4 +1,4 @@ -# -*- coding: utf-8 -*- +# -*- coding: utf-8; -*- ################################################################################ # # Rattail -- Retail Software Framework @@ -31,6 +31,7 @@ import six from rattail_tempmon.db import model as tempmon import colander +from deform import widget as dfwidget from webhelpers2.html import tags from tailbone import forms @@ -100,6 +101,14 @@ class TempmonProbeView(MasterView): # client f.set_renderer('client', self.render_client) f.set_label('client', "Tempmon Client") + if self.creating or self.editing: + f.replace('client', 'client_uuid') + clients = self.Session.query(tempmon.Client)\ + .order_by(tempmon.Client.config_key) + client_values = [(client.uuid, "{} ({})".format(client.config_key, client.hostname)) + for client in clients] + f.set_widget('client_uuid', dfwidget.SelectWidget(values=client_values)) + f.set_label('client_uuid', "Tempmon Client") # appliance_type f.set_enum('appliance_type', self.enum.TEMPMON_APPLIANCE_TYPE) From 0675be8835e02090777112d2fe9a204548cc8b95 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 15 Jan 2018 16:01:46 -0600 Subject: [PATCH 0627/3196] Allow editing of inventory batch count mode and reason code --- tailbone/views/inventory.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/tailbone/views/inventory.py b/tailbone/views/inventory.py index bb47f7dc..b9f56009 100644 --- a/tailbone/views/inventory.py +++ b/tailbone/views/inventory.py @@ -179,9 +179,6 @@ class InventoryBatchView(BatchMasterView): fs.executed, fs.executed_by, ]) - if not self.creating: - fs.mode.set(readonly=True) - fs.reason_code.set(readonly=True) def row_editable(self, row): return self.mutable_batch(row.batch) From c7af97f301c575e154e8696a26c1b166ab087768 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 15 Jan 2018 21:37:10 -0600 Subject: [PATCH 0628/3196] Add docs for app structure, and creating new project --- docs/images/poser-architecture.png | Bin 0 -> 23065 bytes docs/index.rst | 17 ++-- docs/newproject.rst | 154 +++++++++++++++++++++++++++++ docs/structure.rst | 131 ++++++++++++++++++++++++ 4 files changed, 293 insertions(+), 9 deletions(-) create mode 100644 docs/images/poser-architecture.png create mode 100644 docs/newproject.rst create mode 100644 docs/structure.rst diff --git a/docs/images/poser-architecture.png b/docs/images/poser-architecture.png new file mode 100644 index 0000000000000000000000000000000000000000..7e697990127a5e2f4569e8a205899142b39e74a2 GIT binary patch literal 23065 zcmd6P2Rzp8`}SRmhG+Y|AKWkWoFiuRhl?i^q9-v?Gwu=!&tisO{)?e=Vn|l9KXrE>2F){rg|x)xEt% zg@u(3bNxx>vZ10ze1Wr3dU#ju2R=F~N;4yg>{BTmC(mw=ijKDbdXvU&{cS3SjS@$X z9djJ7l=2cDy|eyVa`GWj(cEXxBvx%;WX!$R{_)3;AMx?rF%KUJ9a&OTB6yl~yRbrjT$^dff2JO_904uAFPR6y*@m#fyTQ+$D`w)+0*AsxT* z_l}Mj^^CIt+ZY)&H8eO{t%q7fVy+Ds@4>sw$7e@o=GESa2odE=N=~+@e0FTtaSz%9 z7vDcScCRd0C^F{r=g;23!3+!x16^G-w6wHLOj_s9cfT05yu!>lCUff4DLJ{g7~)Gt zPEPLBsn4CEV$QkI(bDHF=Bqw`KC7TmTwE+9Bvc+OF65k>V8CP&0l%zwmL3%=2dE)ABWnjSFc*^heUR`g@$T5Iy#Dr zyQr%aYG@Q6Ol|zGKdVr+GOTj>#gZUUS?)yj*pMC zhS*4~s;sMfDbn+5)#}v-hKA=}P&0`N8+;B=xmrt3PJaB@F{7sRgoua_>$ddK5~B&T z7O||i&vD|a?|8rULey-59{I|Z2BAJtFQcQ^Qc)?rn4h0dGj5(4NNY_^O=T3a>i_6T zSM0}OF4=teW9cz$m@|DTyS}%G+R5FlblxN)9QY(Ch@OGLXrlj!u5QO0SBk8(il!!d zMnqs)TF)wAMQ0;8IZ$Sv1!(G%HrL|`g{W) z7McC*>~piNi=r2BPd2e^-n`jirYEwpva%>zP*CtTHPijOcSokCyzbo_o19D)e8v6v zn)wD2iS`TQ%U$2cDb@+rF*6yiNo(`Dhv^BrFqWBHMa_EN(R{h1WB0OhBG?Mk#xM=N ziu7AeUO|1F2)jYy8l%=bS@qM*AFK;w^B`U`e%E~7{_L{xyOsZ2k@!E2`yB&=L^5pK zebT#0W4Z9U5fa~U8ab&=HIY3lYXymF2Qh3~>bq;_-E3SKN31DG71kan$UeS(UWi}( zFQ%0L^g@0muu&^(BD>jed+*N3T4A$Y#m{&3acXT+1JjX(?YPo$w&T=cTHd=2j%+27 z&VS9@t^T%mlx>7WR@%ABrBuvxt{QiSR{K2}<)N6W@;1DcOUrQovZkSLAMVMC&0Cx~ zQ}OBHu1y;^Zl|O3o#rdpW;NI(s8{khEG(=m)HydPNg<%Vvhv}>hX<_s&-)ZUdiW4) z>}g88^WwstJFB#`T2+$teEt0PkNrNG%`R$SfV89$(%@9d!SdG{S=d{tFJ7=5I&?@# zsIlyAsKaPst(LRK*|TO}-`;GUAG9tiF=|Zp_4N(LM(M9l+Oua*W12~=AJ^W!dpS9c z4Gq=PO~3Y5NBag$PfvgN@Bz63FV@h|z>=Sz8pOp>9`)8$$v9qY#8^RDvhTuJ=QF$k zB93N;*$1;_#di}E6Qvk!kFJteQcBe+^mcb&v*YNwb6=#G9K%bvI5<4Lyp%hoz0YZB zneYU-t+yU*T1`cjYuESJaub#urKzw0Vy-k-&Uh(*Yg4AB^r{0opB`jc54kgbJEEo*=;h_L{#j0rTLt8tU&CwjNZ!c=6fQmep8QZ#Gc3IZ;s2e<~{4j{G?{J%&tl z)%oknwOdaI@(olt>6e*0*2aj|JPuj%YhT# z-v>7eGcDz>ld|JV5wkPT8N-H_&?a?B4`(iWv7_Oeu}6M>oT|zC@9%5?SU8`oUzuUv zmFz!bLSrCUs>2fz9)5_ISMaPHX3EM6NH7@*Qd?Ww!NK9eg$rVe-JAq$b)nF%kExxR zp?+`1AtCWk5J2?Hm$WW2K%B*+%36-qH8rVD^LA3bDw!4r3JO&t?FE5>fn4h84}yZ` z8m`8z6OST0iRCZ2FIXt=>q7wmoY-fJi~H^?*X1w`jn5`-TTO2wvuIy^EpMhej^*^@ z;oj)DE9R+&^=B0oCy>ik2KRK7p7I{=3^ZAoa$dBSlzivqH2!>XI&?9BN7tP|4<@a- z^ZD1wI5;>)Mn>ZD7G{ROzVq#HUPLBZn99qun|M`knA#M(`(y)^p}zhdPfsI5!(=V5 z^>53|<8pIFnVG}#tZoaP50?FzKHhfnF654v8N~_-NNp6IAFt{t_E#&j9n9#$1BGkO zT>EN&q$salv&Q}2Jvur%;GcA?PhCAHC#P%IuF1>G>+0%a)yvA>Pn{#c&&;?%w4wF~ zv+~9$sZ|F|TCx?Rl>jqd#l)2Mq`fxxV9Y0%T}^U{?p}0upIeN2d z8hsy`a-lYFp`*S1d8xtGt7B7BL%qEl7o9w{rW0~=TXc9#*hEk7&{f8;D3YR;$MT9jxw62|*|tYRQ?cOSps!0dhHc z;zV7xZBix0rX`YNdS_lVXPj9*%Rp}9=FJuW#KnR@$Zy`f5#fl_AtQiRsMYGcUx@hm zM**NAIERpbRM^vEL&|CGm-!5 z+8U@bn`GkFW|{WcZKa`!Y^sT~oocpj|Mu;X0fm>u&uEcqGSr+EnWN)8C*{|1>((t- z3hMieA`K7i4k0upBqSgPv&w~7giDZ({7h1I;Ua6VI$vL?>kdQw_yu-$@7c3@2NlEN z@sD&SL5D3*+42eqq-SRfv#@-0+GSv15ET_=KQ)NhkZJn$ZK_ct6_aQa(?khkMClV= z^RBXWl$5VyVww{!6$A;I2Q@x0K46C{sMsY$GdjzYtvz<6>&WAEZtMRP5vi!CBwa2a zv{a5mkRAE}G-!)-?*(<0=ApHR~M zbstfmd#Wf_y@$!L_vkJx7w+|p1UfbKuI6>>B45v*r|8KkI)3Xi0hJg}$9e4$s9PQ; zxwOs&Br_(5j-Ftq6E9ziC1ua!sHr9YM-279gcE=GXDx%AB$9=c_ik%{lE6_vU3|0t z=Cx&p?DJvOMA@2ZFtyrK$x3xeEaI4!HP!1dNUSz0ubHm6dcheaSk*ZmYQWX91=Ae zd#mdQvP&WqhP;o(Pv9}<*M@Sv6>i6vs<}t<&%eoc@6cYpgZP9@T^AeeZf)(m5AZC@ue7cl){bMI91Mw=mu+~bl^0nmKZvkxr-DclgT9@@S{s? zaXLcUzn{ynt006jMC>uiW#NUt4R;;+y39+o%Z9a!6kXQRa3ale?Ai07xr5jq8t+}- z2N(vS;)p@$M;~@BW-?K2&zCc3-6wWBpV5k|rDS%t__^ z(Gq&_EE+9`4I4IG$jBdPOal~%Q_pzDZa6WpSH$;%Ifqd3yLYtR)F}(n)y_AYwN{%t7~hA?LN6| z*)mYe`^oK1s^H#bA|vA8ZtX1iw0lLq!U^v~pifh+&d!K6i$ja^dGFu9fAr{)ySqD} zT1&R=7-Wiui%M5j7V4_-*Xc##1!To)?uF#{A50(JmGA0y`*wN2!7k^;DQBoRAdw(m znhFX&PoBKuSWi!{uBq8lj&#il2Y4J?KSogU)lX{nkzr$>YOar+XG!&R@t@W~FgiY@pukI(>YX z@!sX6{PRJBv6P)uUI~;9&!0cHVJ7e6Uhaw!(4J`J>Q!l05wV#O<}-M=kHu%-o;?v! zQH9N0qR5TnPHT;bAFw&g`}3uX6Q_l9%P)nR-5igO%e`{-s#=~?PFC@@{&8CBqiGjb zl+WHwSVOJ@cr{+4XA;h}m}?Z<_t25C;O4DcxeGIWD_5>8@>0r@n3eYq&(6+?ie$hg$Q*cJ6#zTWekOS|uVPqNwB^4Htn=jvP6%OX~jV z6DRbcI&{)(-6|W%S6W-kzjNm|Xa|G2#{y$4EiA$#B36=4{O z?d>BYM{&V7qci)@^3UfS+hsPK<2+e^IZ`f^FR)bN4ug=D!@_hINDVMNKz9QV#tW9? z>FJ5xt)63-!ZQ|dIYcGP3Y5VJx+GACl~wBRHNbkudwgoerKQ-L`#3n}Axc7foc+-< zAxWS^%K2uwfnp`$7h=s*{LdC$JC2ic&^2ewpUKQYU?e{*J)!j~cujMoOj=Uac%Cu? z>q+m?PEoW0^*}mrRzhRvmP9{diSee?I8rjF( zPiJ{fnP{&1+SP@}FHVPw*z-XL<>o%)9q!`dGBabRy8yg)&)xlnZ18pl2FGvjS5v4( zM@QfD@HqFPz>8_xbK7IPN?Tsr0PbIyX zA3yZ{=i)T7^fIz_S-IKSUq5^H_RYFIEG#d=!_S>N*OX&F)ma({TBgt`2kHZU4FLA3 zxL7U2EW$PBrkmS25Vb1YWUAN*=i}}aYo5EUh!$E)(PbipBzCz=%tq_*4n8J}7tz{1 z1!4;$gUwlpE*Pove4o}-a-I7Ywwf32Yh>Y2jTN*{(BsYouBBHhd`n>?qaif^TY z+;3u)T|q%n4mA_hT!bSP!f!n=RIm{L{{2OxFLnh|(F;8A_2s?z{tlQPo>vac1EEGj z!CWhA+IGxOwOYr&Ee+(qee~Q-R#xQQL^Xi3d*-9WM{M;sZB9FQf_Y<4?X^_mluCG$MIVZ zhw4QGueUs;rs{hh!dDqbgHY$vI5E#8qOr34K|p}*KtsxtCr{SV@x3o7$jYLx9R^5^ z)v?Wb*x?|w`e|+J>4K`2dEX%!>>r(%%WB{`CNAr~FE^wFcNtZa|8-}lbWOyUpS z0`Ji`B^PIm0RsU=Q-Hqw6|H4RKZ3T95KVC_&4(JBjw3B{=SS$F0Syp76I@m z8C`cW)*TsQa-r(!dS2JIaV~C|Hgm|ZJ`a9L@~7i`%;>0Xzmx7Dq#B={nS%v%##b?LA#_x>d>gUh? z5e0radBwn>2l_hF-iRyHT-#-_SruptV!p(ur~57Yc745spRS4@Azx!*Ll4I7OR(tq z{Nlw6lneOz`L)tbcL)kjr_^dHXuf*+@_fAZn@w`U*LprjAah5C*zM=!9E83#$U&q( zQ?~=(uJ1f_Xndkp3qsNvr~dBlPoF-~3R%4_)G7M(sk^)T%z}IN0BeEP|-yYq9%yY@9%D6vg>|| za3eO`pM)4*Bn{oX_X+P+KEpa~o`BOV3J_WF&}p`@m8yhuIeo!-pr-D!6GVQcac)dC z0<*mgRUAO(QN3NZ49 z(Ol*o?|+fuqNHo3y-AjFt^P3{gQ(@oQwLQ>MeTS+QWb$dtOg{KKnL zWNiW$iOCDvU7YyYzI(`|%gT<;d-uMp)z#HG!*AD0+>yLZwR*?1XU{}ux}X2l)Kpcs zqdX!nzr$@6HFazHwM&pZ&YwR&b$m0JIs$B{ux;Y{Z%~Fn9*d&AcWeAC6%}1Xg+j`U zG5C;y_xfi~pI+!J3GnCA+A7hM>j-MtTyUQu%k--R1T6`Pw+O6c%a^;YZ)s^+oT<&j zCnOsoSFT*K!UAFo*Spn+#m)~#j3uTR&pbYGDa5Wficltywg+RRx@LJK9j@h#Ui2(f&X?BHnY3_s}7FVH?x&?#iN)2M->+ zdbM9S^Vu^j-E#pUd`1mOvd|SC`unGub)3?DQ9Zw9$BrGi9Yx7CC#Lqid-o0*qGJ}x z;(@YN}V0A)3a;vr~upAZ=s*Tm4+q_waC%|f;fj8b{J5Y}+HlfV#1;vIrBoAv#%hmVp z%bS{-+S+DxWFkYMEQ7Dc_zRM9fhT^iIPy3Hn=?5KACZ)lg#3f7rleE@dEk*hRE|Vw zRM?-OCmLB+giuNC{NvpVnZTJ#)cNU;c0|dbUfP_gXj^e?RRR*|__##}CvWj(?GW3} z0Bl{{u$nJl+-}{{yL|cT0KJ&goR6GnhV7W1nb|0U)_r=x(X_nzn1qCpyoJ6zr8rHa zluB?HEtJv1!@onc7FqXt8RSsetSZ^T%d#dBktq!g4Ok8QP}PxJUaO^nH^~GzqXKl& zhh_T5kJG`2Q(nG25s-npE++hn4AyguY6`HZJhj-|F#Z%(*(>I1*b0rHCEp8uZm*-8 z?0)WrMRdDp4&g{^?iPK)PWB{K~ifL=UW#!)YFG4#C}{~PjMpV&|ka& z$k`iOon~XIW#bzbsWbV<9pam$yL7LnXyn)t)xrY@v$fTFIi;PE#&F|Z_t9@poZ@yBLQfhUF zpIjxoX0{EsLw$Yy;#`-Qa*S#OhrzUz7-kS8=Gn7v*o=s@{R@;Ht`d@xDN@G!+1O@> zMUhVnh{BHXrr*Gg$toqR5_wbu0K|nig`O9`3J%0RF)d0xPJ5yK23fAI2Z&&MaW)YEjm0lm@Q2A?hDhZUpyr$;(Q@=lg*?!?Ni)weV-%6w?tJ!WHA(y zsZdh=T#cHEqOx+<@K?()E>%_4#H1t;LXf+4Iwo(^Qt&e2-jFZRj%Nv_HlQn>bK$StU-iSFIoZQ?wuHnnoFB`46w=O9A5{PW`j^+{5JdBEK$s&&lfA>{c6{03hX zSv#&O$Q8|wG}${;RkoZ&O;iY9A|Iw*seT2^1fqsY+2VMW9CD|zU{^+02;2VsiLDK} zj^Zo$zb>JDtXzx>77={K#8h{}w6d9ohM$iQ0{fZSg`v5*xo_XT zA-NY76~)HJqSkFRt(hm?siKz?~*k&9R{U#TaOL)d%&RN7Z)8(2UwRlhYeyB zyTG(K!nAGMHWZRzt9T;jynw_;(Rr}X=8(!iuFdyku3xq5FH|H}1~*dgS^2oM6EF|i zgO`ubX>O!|#is{#dSIA_v(+2-`{_9%XKOz$MjN@y-cL{WEb4dPG~)q0ok5;&q}k5)ITyh>h9@TT3#+@I~u-z zCopTyR7;Y2TeEemM!fbBx#+aCw8iOiXWRh4q3GAI9lC<88yXuqXs}0688VFzA09R> zCTAM&duKm$%Z%i*1x6@wUQ|7(MSp~$2#6G#nR%Fv?J97j#2pT`)c(da2p&TXNOQ3o zFs97DV_Jwu^bKhcboen0XVI>mJCg;0u|rXt(^?p%DgL1J`h0?Mb0*daLMzsELmX;9 z9K5_KNU=#tV~F#2@7|>zuX$v$w-s-T<@j?fM4jOf%Cf(4B8b`yhppdv_;6-?{4nxr z0QM2;6KNIAmxAm{++_nI>$*;kR(3bfBoy6oFJXC;}^^W!%E2d1?-x(<61IV)jja~TWjmOABq z=&^mM5-RdN0|O8hjHmoASRN%Gn~Ff#gCFDR_-OE;)W(ej6`A&Op1TCR7if}ez!%u8 zLFKko=@EV@I@!;$=>s$MYT#B{L9_4J_)MEOk0UP01Ue&-B7K0{dv52wQXV2=rT0h9 zwV26Qlh1dboSho-x^riEVj`ILsuBt}ldn1w;`6c-o0F51^Yg`h3X`!zMtDTeeI<0T&tlv`gX+;4neQt?v^^2?$FK}H}_kdza*Uc07+dyyHFngT261QbCfoU0s+jaK{*yO`=HG{$i?3?2iJDC-d1t3Cx+7{ z6szBC@OzWrz6ir?7*ozdm+$Pyo&mdCSYt6NNguE1{fsnj>k-j|p#LJMJ%^a7%OyIMmoAIgDMu?g@yT9WT_4G^uiGCZ?2`8RFwk-0s3bc3N~)Z7G!5P z#EL;yc0~RFeD7^@pW!zxXFPB6xcDoTX{{zt_khRW-Sf-TZW~TnhQD2~nG3TIr2g_` z!MeXq{_NR}OiU)-Y9399SX+z_C3?}hqqyUx$u%vuH@d8xdE0?uZumBE8=|A4>Oq$r z9I~^DuWd8oH&(M}RJX9-@96p*cKcdEbYf$SXydn zEy&rP8hCFZW|iH$t>f|hO-`o-3c_@s3#BogTH_LJYW@(xQdYTpJkQX-f7ATXdD=T2 zJ>SbJ$vn5plTY6@7AzCcw?^IW_yu0?;V`!4Gv8A$Q@=jnV#Y7PbgXR?M$El);p)40 z-Fq1Kl6Q?23E!kd^a`#}7GCdaLVNuP2r#Mp_Tabc7r7~SaqHs|DW~$o^Flrq$Ns3{ zFwsL^%y@}-sKsHsu!MdQ`;Xkqtv4Y?il>bW*@cZfzOaIP9sVZF90JrD$=fS#5J8vn z4flw6rO5784A$u}gSS2#1>;GVx7s7(7bf=HJWPL@nb3yv?@(@|-pnU?!}O|F_UyZ= z6=WpYF^}>~AguRLH+(j0{c_!~=K|lNvkzgq);@a6i(-3VYf67Zu6>;H=!umi^&4U= zB$D+=kWE<6MD#{=AHZqQ(%W%!tN1m;a(A7N_QNkB@jtcZ zU$OL zBta;(-J4i5JzSsq_gnz6`ha50r^Nn2i5Z3^H;`?azg}` z+|$yp8WucQ)W}c!*yUV<}^6fMG;Kg z_cqEH^VR~kq7a6S$GsTmeM0BH0}K^;fieoiZFlL?^U-z)0*uacBTy2BnioDtNC!T7 z0u836smTGlJYY|)rhVy3OKWo^Sp@tEsbae&Ux5ubXHSLN%{A0itJ#!Z+rZo5+ryRn z0}dYNkq-0}}Kl1lS zajdhWV~<=DYYpe$!S!%q)(j! ze;LkdosYF|Crq^|SE^Afg8ahX#a3|4Pj`R>d*Sa)^r^#tr>SMK+@KKoZc2$hn4BwO`Vj8JXj!c>gj{LF-{t!*OvBB2z(l8xJT zO~o(Gq&4K>!yUz_aQWoT4hrcO0()bj$Odecc;xH5uL{*gAcST$V*>-*4qs(P`~Cw= zOrh|0Eq3zf85kSaD5a^JL5)}&_* z9TX>-oaYh}Gds#gmOzQpRL$;_JdkDNQx!aFo>cF*QcYzSpRK%-i@|MLLd12QEozSJ zaXs6cVK3VSNdPh;43!G24xouZZ0;*HmWY?gkA zQe{dHcAH+zy5-{y8NZmTJdj^*$s`7Eo#hAjEk)8l?MP_AKyt)vOP@adrKYH)B-?HR zrc>pE2M_vlsMX}u+m3ZQ!fy{r1u8Ix1$z+&F0Z+Uii(qqtJd%I@7e+qRU(5g+I`b5 z-X8tdLW=K;=T%zTAT&rQS1vAYreI61^6hPZ5o~;Bakl0&>KyyMYzxK73)&16v6m$@5(Jz7dYg$62oQ3Ly}kXE9tff{0+Yvf-lBp6 zRvsQR)WT3NE|P{Pb(^4Bc#*4KSv2)h{c;V5Zi=4{a_-{U z12Y@9us1FncUcPOtb!umVdY+_D^@Jkm>j7PgN|6y~_zy37BlZ0z> z8QXo*%zE|k-X#+k>CBSzO#8%=liY^+&$91-n)nFH_val<|5&P%+f~f-%Y(HvHrmHa z88Sqi&g0*&*Zpr#FfrtA|0S#d$ijEhTSvvpdJE$nVkNkU+E%Q+qVCdnZ_qw&ak8sT z_$*=0OcWrI?w`r@qMmV9@Y9WH71Uqsf+6+OE-!d)RR`Vc>gq;E&1PrAXQMgmZ}S!G z>`&{DdPlP~2g2MZz17VO>JLM^Ct!NU4BK$~G1zs*GO5Mz z3P`@a$CaCYseo#r0Leak{Ihg}7Y&yt(MI8V4?E5MKytgezNJDmro#T3<)Eddi|uivpC2=%d&ANW)W8Orj1p2)0+Q zT+!lt_~=nGU>W+B@C7S^KFwUjN=@;o*yH8tAlps}n3R~PR~{0>VPL*(*SAU?5U`gbXQW=*Gn!^n=vF`4G*EHVTmpOTZ?kV3n^$7kw2wa*V&r#Ig3fQ~Hdz`u;Y{gD zyxy^2byvud%<{i9S7bzZz&44txAUcj1_p&?WoTub92;9pNeOEi7dyL!wC!Q3pYfh^ zsLKBAi3i|r9Yp71OpFn5x^dK3PzKx4Le>~PG+h2+gXP(fvZWgha)RcG<|EC@oi)66En)QY@4Si8{lE1lo=&O{3Jj!!a7{~_Zq`v$ zQ=_Eobop`@WPT*F(=1gegeM<=ecxb8vPr>paEa`ie##$j7Gc@F*W~*jSPIu)MA;8| zKW|`ZkvG1hx0QO;s`U~MKi!H-nhn2~YptYG8>_1)2pRg#jg?AcUH?#+%1MR`qFih- z8^jGbJvCJ+Q0q71F5AJev9Wvja(73S6fqLE{72L1naHylxd;yo>}QbNzW%u18+9bD z(mNU=688^*&l?h}yv!Ufbj=tzzIJdim;g=uS;MXzt@iWtV_F!#T{P~o?O==ZqN9o* zmu5EY_U%BwuUZ}dy?1!(ddiZ}bJz>5eek%wW+7F`u1xnowLtqQJW zr-g!{PX{}_f+U*X-EZyIWWS@;Y3lTC4FMx-5oBMmSRJ%hAnN`>~ytG<6~n<^v=VhqnXx2np0PN1ujGeYtqqcBY6{j zu%y`U@Aou@Y~S`NQR7%t_Fr9*vZqgO&Y{xJh8DM~aSBdaULYr$zJ#uGyl%ey+Km{t34+cYIVzN;TTZehhy_12oLmgmp{PX}SYNJtKHW zz@YeQf8V{`YiUk&0S{&k>6JdIo{#uvL{By0;s=zVu#iz2^9=|vaNhKL>Uge`m6i3f zo*vOs2l|NZ*)U2Jm|-k$ZfH=|(14k9v0;R~`6i(SRjg&rT!33di~qpzZ>^QJqY*nK zZ=FUB8LomzOUOg$RXMoSJr@bbtZ_qfRBh>&EnDWHJTM%#(4X@ES&SB`gb# zqzKzz(CMA=JxPXxu19+9$APy*7vH{pg%1xbD~Qp|In2VsA|ld!(}-%LustrcZ8wc( z=hUYSu9_&gr?uFjL}>@(UaV0@Ed_N2+t19VeWIbOyl!ree%8b3Yn~Sj9UFhv+!Uv0 z4$>q#u+Ww*HS!9SXK*frts{3ovM_0RKal8U{-2pA^A~Gdaz30;VWgyY);i^;yNhk@ zEd`abGv}XdJ>~v{<;1|T9b3AeajZ?)@2hm>+KzQAi@rXyS+#Q4?H&vG3yup?87 z+U{nUpUSfFgy5>jx-=Hc9(FMAD_Gf?WfNMK*1XA{;rgD*?8hpjyL>P-j>>QQoc8-( z>DZ=!r#5K%C8#E{Yo_$<*xrX{6h^ODbXV-);xa)-zPFX*uPOz3A)GIlUup+bFklY(%<5wCQ&(W2p@T6zk_}R?dyuf4ISP@&? z9JaVds-a5a2;LZG%_g{akTx%uKCy(|TUDxhwigno5uz>ZnK^y2S%jy6X}((uGGn16 zWK6*i`m3?$OC=>q)isYuzZ(gFk5Av zjC()8f@QefJn2RMHHNeq_kgcaaN91ps`#z|25)H%VlC;{J7dxr9AzH^bpg{prXeQ6Zg<9WKA$8rG+FNRf7LnAma&>nu= zz3wm=C!n^Cs-u*=#kco&@$;Tgp;JtXQ^R}R(VLKlz??TfV1hG5`cd7Jm#>7tJ~le~ zZ148N7TsVz$33@KSJ>c{%IDAjsJ|RqEo>|K4!!7v;TVpP*@-^uF-`Brr=5)_vGi49 z9^S1eHFC}L*Z)4pAM{wacP?gdZUNm^Iq+THXAs(YOddv*E3qW3s;@w2m7b%_1GtYU ztZSuQA*#?&XBm#k%gUC46(2@-wRsw(33hb715u!IdiPm3o7Qv@w8*O=*-tO@qfiOm z5)i4pyxbY$F^g}&? zEBHPa9LTR#lAg?;8=}NM3p?OhQmdAfeP)Oz_~wxYltc;dC%h<-GZ!b*@?`jkYXZ5) z#pVH|A`88c3vGF%Ac7@A^fdt6$wN++mru~Id^Y6JB~&OWCp_{VmxzT@9wc}Wg;t$* zf<422g*%M3f`w5lashXQlM^HLg5BkL>cV-|tB9H|bXNy=im#P*Vk>K6+_N|p29F>C zuTV)K#-*L_^8G3Lt)Ql6U@9QV!*~#M;L>pg*>s2LQ9=SiYj<};N*`QaU(kDsV+urU z^#N?Dg@^NT7z>t+`k6Bi4Arr*;R+puuNHK~gGnrRa5dfbkD%#rq660;C`_X9HF?xh zpDh>ZPbbAs#_h_2ar#Q1mr4uDDSOTH?8O6dzk2IxT$hpboj+^28Fg%HlWl(ow8inr zVeiY855v}_Un;~kDIKU~_4yYm;V&WYU*?WKf8noS&awGtFuycGq@>qaQvHVv#^WPrG zKfKUI>2CWEKKkWpQcGD*T&O1TE>g!SrpKoujC=2dDK5ZHLZ;z=*a_DuS`^$mvNI{t zw8y{>*#ODzaZ3wwCSp)^)6<%Tl);bJ9MGeVsX^8PG6q23b6MJoh)YQJT6R4VTJyci zfcrSu0P7ET8vPQts0_O^2b5<9z7ug{;%d>m$;OFohW|Lbo4 zBKZC$2h_%%V%3PCJ4I}|5C>PC0)ja=_7uK9lx|tmii?V_Fg$)kK%z^=%{n4@(~QACB*h&+NX z{D6nWp^{lUu@j76KW&|AeNWA-a_D6(0zLS%Iy!h^BP4uaj}JW0xcp<#I!_3e=s2VZ zoIkKoYT{J&{<$Bg#$lL)5tniAQsd)I3%&Q0<`@fgWG&5c{n3|tTapboS|*iguc=M< zm^RLj>BO%{9Li(-^D!#o@fY)hUtChd5W;d!EgA)NGIlFBG$kzU;Zti_W*XX3N6zy5 z{}+h-AA-i8LHFOhu)D4X;61G4n}BiOjtBBTqZVH4x(eKH$8hBHIlePck&?yzIzcAR_zSyG zK4ND$FOEni{WA!=oK`Te%wN8oRKfmhKst{yi&t)4j~GVaY>*db^FzsJw3i{n?fgY? zc#gJ9uULA?FMh*Z6@E7A+fdBZf19K~z1nm8w;Yovvd((c&0Td2h-R1_X28==^v~ij6i;M?A!2*AEq1rHQD^5X{<=7nHRXO2n}|bbRcrtX(L~h?%yDCpNO;*eet?QO}^o)@q@Y11jnTGJ;0!Mh|k8;0gd?QN+0hph^m{ zYe_oZ>_x77-QjIchEfFvQ;r6s&*6j(l5qS)zj{UVnW4AuaD5j{tB#!km!YhB(F=Y? zDg_+;t2vAQPr*Fftqw(;CvZoHTGUr|Qu; zA?2Yek7wPmDiu-@HNR)w0K`36Fne2;j}gw2iD)gE(4-nqr}>$AoC-wrB$)J=XRPCC zzuj`w`mK+VK1qJ_JX(lo1x(vCteX|VKag69tPc=@TI!hNRC5}#j7}P2K053<#Kgql zTfYr&2$b@x!Kp1{wGB2%x8wGNCkn@e5gHrv5oBT3u)QE-(9c_3XPNt$u z4C2=7u&_4Fxk`?mW#R}37_3pW+}h&M{6cUP8PUR1I7yraflLNqGT5587`iYX$&zcq zVa_^Nu`Dd-Q(d*IYWczXI^_26$C19D?IoNcGlQ5-pGMf4ZMvV{9vvHN7bwHAk4|$o zlKyo#c>^~S?Fu4bPS!29U0X%BC4MZ`XnRG#IAHQF! zh_YH(SzwpMq0nL)ZXtqLmcJwML~{t<3{WB zk{?g`D}#r1=w^yluhFBwgZ*wD<i#H0rOyOFv_-2!~V(6>%Eag$rNdrP|6nw>4(! z8N`4_ba3L(tIfx7AD#MQ(g1?D9jnBl8sK57pqW#`yyaR$`M0PTGEpW)A53+21y0q1 z+gSh&347g%z8N&g;4Dii`EzH^fOg-2p=%PSNKSKI7;9QBWD;<1_t`%o4tM)V)R z6pr&5NVl<#VlSYoG6u_XQ=Ct1JpD0SVssQVcAk4rVV z8+vgF_l39E1$6c1u>Imt1I#Oq7qW-jT~$O!-}|>@dK^Q2Vt)&_)fxzsXk_)yf}XpN zi%ZTJ<%o%aM*fUu@Y8X-59k?kdRK)@=2EeG8HJuqbcAZK^i*-Ek4ReMa4L(o{Odq#^r%un_Kn)!diAyCCEzw3)s~L#Ex=6T z^i}E54dJ|w*Q0k3HwLyj{@fAI@5aA@ZZlll9sAT;RWTC)dgv~4S{Ms$9ZvoOp8iKj zvFDzu1DT7^8NJSBO Date: Mon, 15 Jan 2018 23:19:29 -0600 Subject: [PATCH 0629/3196] Add basic schema docs --- docs/concepts/schema.rst | 45 ++++++++++++++++++++++++++++ docs/index.rst | 9 +++++- docs/schemachange.rst | 63 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 116 insertions(+), 1 deletion(-) create mode 100644 docs/concepts/schema.rst create mode 100644 docs/schemachange.rst diff --git a/docs/concepts/schema.rst b/docs/concepts/schema.rst new file mode 100644 index 00000000..f10436cb --- /dev/null +++ b/docs/concepts/schema.rst @@ -0,0 +1,45 @@ + +Database Schema +=============== + +.. contents:: :local: + +Rattail provides a "core" schema which is assumed to be the foundation of any +Poser app database. + + +Core Tables +----------- + +All tables which are considered part of the Rattail "core" schema, are defined +as ORM classes within the ``rattail.db.model`` package. + +.. note:: + + The Rattail project has its roots in retail grocery-type stores, and its + schema reflects that to a large degree. In practice however the software + may be used to support a wide variety of apps. The next section describes + that a bit more. + + +Customizing the Schema +---------------------- + +Almost certainly a custom app will need some of the core tables, but just as +certainly, it will *not* need others. And to make things even more +interesting, it may need some tables but also need to "supplement" them +somehow, to track additional data for each record etc. + +Any table in the core schema which is *not* needed, may simply be ignored, +i.e. hidden from the app UI etc. + +Any table which is "missing" from core schema, from the custom app's +perspective, should be added as a custom table. + +Also, any table which is "present but missing columns" from the app's +perspective, will require a custom table. In this case each record in the +custom table will "tie back to" the core table record. The custom record will +then supply any additional data for the core record. + +Defining custom tables, and associated tasks, are documented in +:doc:`../schemachange`. diff --git a/docs/index.rst b/docs/index.rst index 10e95a39..f4e537d6 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -12,7 +12,7 @@ attention thus far. .. _website: https://rattailproject.org/ -Getting Started (with Custom Apps): +Quick Start for Custom Apps: .. toctree:: :maxdepth: 1 @@ -20,6 +20,13 @@ Getting Started (with Custom Apps): structure devenv newproject + schemachange + +Concept Guide: + +.. toctree:: + + concepts/schema Narrative Documentation: diff --git a/docs/schemachange.rst b/docs/schemachange.rst new file mode 100644 index 00000000..5d385b7b --- /dev/null +++ b/docs/schemachange.rst @@ -0,0 +1,63 @@ + +Migrating the Schema +==================== + +.. contents:: :local: + +As development progresses for your custom app, you may need to migrate the +database schema from time to time. + +See also this general discussion of the :doc:`concepts/schema`. + +.. note:: + + The only "safe" migrations are those which add or modify (or remove) + "custom" tables, i.e. those *not* provided by the ``rattail.db.model`` + package. This doc assumes you are aware of this and are only attempting a + safe migration. + + +Modify ORM Classes +------------------ + +First step is to modify the ORM classes defined by your app, so they reflect +the "desired" schema. Typically this will mean editing files under the +``poser.db.model`` package within your source. In particular when adding new +tables, you must be sure to include them within ``poser/db/model/__init__.py``. + +As noted above, only those classes *not* provided by ``rattail.db.model`` +should be modified here, to be safe. If you wish to "extend" an existing +table, you must create a secondary table which ties back to the first via +one-to-one foreign key relationship. + + +Create Migration Script +----------------------- + +Next you will create the Alembic script which is responsible for performing the +schema migration against a database. This is typically done like so: + +.. code-block:: sh + + workon poser + cdvirtualenv + bin/alembic -c app/rattail.conf revision --autogenerate --head poser@head -m "describe migration here" + +This will create a new file under +e.g. ``~/src/poser/poser/db/alembic/versions/``. You should edit this file as +needed to ensure it performs all steps required for the migration. Technically +it should support downgrade as well as upgrade, although in practice that isn't +always required. + + +Upgrade Database Schema +----------------------- + +Once you're happy with the new script, you can apply it against your dev +database with something like: + +.. code-block:: sh + + workon poser + cdvirtualenv + bin/alembic -c app/rattail.conf upgrade heads From 7af29a243b78ca6092423735b9f5e2603cb38913 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 16 Jan 2018 00:22:46 -0600 Subject: [PATCH 0630/3196] Add concept doc for data batches --- docs/concepts/batches.rst | 65 +++++++++++++++++++++++++++++++++++++++ docs/index.rst | 1 + 2 files changed, 66 insertions(+) create mode 100644 docs/concepts/batches.rst diff --git a/docs/concepts/batches.rst b/docs/concepts/batches.rst new file mode 100644 index 00000000..bdf66b11 --- /dev/null +++ b/docs/concepts/batches.rst @@ -0,0 +1,65 @@ + +Data Batches +============ + +.. contents:: :local: + +Data "batches" are one of the most powerful features of Rattail / Tailbone. +However each "batch type" is different, and they usually require custom +development. In all cases they require a Rattail-based app database, for +storage. + + +General Overview +---------------- + +You can think of data batches as a sort of "temporary spreadsheet" feature. +When a batch is created, it is usually populated with rows, from some data +source. The user(s) may then manipulate the batch data as needed, with the +final goal being to "execute" the batch. What execution specifically means +will depend on context, e.g. type of batch, but generally it will "commit" the +"pending changes" which are represented by the batch. + +Note that when a batch is executed, it becomes read-only ("frozen in time") and +at that point may be considered part of an audit trail of sorts. The utility +of this may vary depending on the nature of the batch data. + +Beyond that it's difficult to describe batches very well at this level, +precisely because they're all different. + +.. + This graphic tries to show how batches are created and executed over time. + Note that each batch type is free to target a different system(s) upon + execution. + + TODO: need graphic + + +Batch Tables +------------ + +In most cases the table(s) underlying a particular batch type, have a "static" +schema and must be defined as ORM classes, e.g. within the ``poser.db.model`` +package. + +In some rare cases the batch data (row) table may be dynamic; however the batch +header table must still be defined. + + +Batch Handlers +-------------- + +Once the batch table(s) are present, the next puzzle piece is the batch +handler. Again there is generally (at least) one handler defined for each +batch type. + +The batch "handler" is considered part of the data layer and provides logic for +populating the batch, executing it etc. + + +Batch Views +----------- + +This discussion would not be complete without mentioning the web views for the +batch. Again each batch type will require a custom view(s) although these +"usually" are simple wrappers as most logic is provided by the base view. diff --git a/docs/index.rst b/docs/index.rst index f4e537d6..d674b5c7 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -27,6 +27,7 @@ Concept Guide: .. toctree:: concepts/schema + concepts/batches Narrative Documentation: From bbcba05fdd16c391853d6847cd503a69faba62ec Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 16 Jan 2018 11:32:36 -0600 Subject: [PATCH 0631/3196] Add placeholder doc for Configuration --- docs/concepts/config.rst | 7 +++++++ docs/index.rst | 1 + 2 files changed, 8 insertions(+) create mode 100644 docs/concepts/config.rst diff --git a/docs/concepts/config.rst b/docs/concepts/config.rst new file mode 100644 index 00000000..d20aa723 --- /dev/null +++ b/docs/concepts/config.rst @@ -0,0 +1,7 @@ + +Configuration +============= + +.. contents:: :local: + +TODO diff --git a/docs/index.rst b/docs/index.rst index d674b5c7..6f95b883 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -26,6 +26,7 @@ Concept Guide: .. toctree:: + concepts/config concepts/schema concepts/batches From 07e7c5c4a010d7b924a84837d3e49b65ffc1fb18 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 16 Jan 2018 13:24:10 -0600 Subject: [PATCH 0632/3196] Update changelog --- CHANGES.rst | 10 ++++++++++ tailbone/_version.py | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 12eeeaf5..f81f2c6c 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,16 @@ CHANGELOG ========= +0.6.63 (2018-01-16) +------------------- + +* Fix bug when locating association proxy column. + +* Fix client field when creating / editing tempmon probe. + +* Allow editing of inventory batch count mode and reason code. + + 0.6.62 (2018-01-11) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index fd6e0290..6b824369 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.6.62' +__version__ = '0.6.63' From dd7c2a0763dfa16c2bf9887a4b773ec1f92b2109 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 17 Jan 2018 14:45:09 -0600 Subject: [PATCH 0633/3196] Warn if user "scans" UPC with more than 14 digits, for mobile inventory never assume such a UPC is valid, warn instead of adding batch row --- tailbone/views/inventory.py | 32 +++++++++++++++++++------------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/tailbone/views/inventory.py b/tailbone/views/inventory.py index b9f56009..be276b8e 100644 --- a/tailbone/views/inventory.py +++ b/tailbone/views/inventory.py @@ -235,20 +235,26 @@ class InventoryBatchView(BatchMasterView): upc = re.sub(r'\D', '', upc) if upc: - # try to locate general product by UPC; add to batch either way - provided = GPC(upc, calc_check_digit=False) - checked = GPC(upc, calc_check_digit='upc') - product = api.get_product_by_upc(self.Session(), provided) - if not product: - product = api.get_product_by_upc(self.Session(), checked) - row = model.InventoryBatchRow() - if product: - row.product = product - row.upc = product.upc + if len(upc) <= 14: + + # try to locate general product by UPC; add to batch either way + provided = GPC(upc, calc_check_digit=False) + checked = GPC(upc, calc_check_digit='upc') + product = api.get_product_by_upc(self.Session(), provided) + if not product: + product = api.get_product_by_upc(self.Session(), checked) + row = model.InventoryBatchRow() + if product: + row.product = product + row.upc = product.upc + else: + row.upc = provided # TODO: why not 'checked' instead? how to choose? + row.description = "(unknown product)" + self.handler.add_row(batch, row) + else: - row.upc = provided # TODO: why not 'checked' instead? how to choose? - row.description = "(unknown product)" - self.handler.add_row(batch, row) + self.request.session.flash("UPC has too many digits ({}): {}".format(len(upc), upc), 'error') + return self.redirect(self.get_action_url('view', batch, mobile=True)) self.Session.flush() return self.redirect(self.mobile_row_route_url('view', uuid=row.batch_uuid, row_uuid=row.uuid)) From a542cd70da052ae47ea7159454b8f3f87f878aa2 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 17 Jan 2018 14:55:17 -0600 Subject: [PATCH 0634/3196] Add option for preventing new inventory batch rows for unknown products --- tailbone/views/inventory.py | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/tailbone/views/inventory.py b/tailbone/views/inventory.py index be276b8e..83cc77a3 100644 --- a/tailbone/views/inventory.py +++ b/tailbone/views/inventory.py @@ -221,6 +221,9 @@ class InventoryBatchView(BatchMasterView): else: del fs.complete + # TODO: document this, maybe move it etc. + unknown_product_creates_row = True + # TODO: this view can create new rows, with only a GET query. that should # probably be changed to require POST; for now we just require the "create # batch row" perm and call it good.. @@ -243,14 +246,19 @@ class InventoryBatchView(BatchMasterView): product = api.get_product_by_upc(self.Session(), provided) if not product: product = api.get_product_by_upc(self.Session(), checked) - row = model.InventoryBatchRow() - if product: - row.product = product - row.upc = product.upc + if product or self.unknown_product_creates_row: + row = model.InventoryBatchRow() + if product: + row.product = product + row.upc = product.upc + else: + row.upc = provided # TODO: why not 'checked' instead? how to choose? + row.description = "(unknown product)" + self.handler.add_row(batch, row) + else: - row.upc = provided # TODO: why not 'checked' instead? how to choose? - row.description = "(unknown product)" - self.handler.add_row(batch, row) + self.request.session.flash("Product not found: {}".format(upc), 'error') + return self.redirect(self.get_action_url('view', batch, mobile=True)) else: self.request.session.flash("UPC has too many digits ({}): {}".format(len(upc), upc), 'error') From 80e9a9cf1cd734df57274a2e14f9145a82de0f1c Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 18 Jan 2018 11:46:10 -0600 Subject: [PATCH 0635/3196] Add `creates_multiple` flag for master view --- tailbone/templates/master/create.mako | 2 +- tailbone/templates/master/index.mako | 6 +++++- tailbone/templates/master/view.mako | 6 +++++- tailbone/views/master.py | 1 + 4 files changed, 12 insertions(+), 3 deletions(-) diff --git a/tailbone/templates/master/create.mako b/tailbone/templates/master/create.mako index 53700f59..4340ae6c 100644 --- a/tailbone/templates/master/create.mako +++ b/tailbone/templates/master/create.mako @@ -1,7 +1,7 @@ ## -*- coding: utf-8; -*- <%inherit file="/base.mako" /> -<%def name="title()">New ${model_title} +<%def name="title()">New ${model_title_plural if master.creates_multiple else model_title} <%def name="extra_javascript()"> ${parent.extra_javascript()} diff --git a/tailbone/templates/master/index.mako b/tailbone/templates/master/index.mako index bc219b00..014e1f97 100644 --- a/tailbone/templates/master/index.mako +++ b/tailbone/templates/master/index.mako @@ -73,7 +73,11 @@
    • ${h.link_to("Download results as CSV", url('{}.results_csv'.format(route_prefix)))}
    • % endif % if master.creatable and request.has_perm('{}.create'.format(permission_prefix)): -
    • ${h.link_to("Create a new {}".format(model_title), url('{}.create'.format(route_prefix)))}
    • + % if master.creates_multiple: +
    • ${h.link_to("Create new {}".format(model_title_plural), url('{}.create'.format(route_prefix)))}
    • + % else: +
    • ${h.link_to("Create a new {}".format(model_title), url('{}.create'.format(route_prefix)))}
    • + % endif % endif diff --git a/tailbone/templates/master/view.mako b/tailbone/templates/master/view.mako index 02f20eda..cb7f9504 100644 --- a/tailbone/templates/master/view.mako +++ b/tailbone/templates/master/view.mako @@ -56,7 +56,11 @@
    • ${h.link_to("Delete this {}".format(model_title), action_url('delete', instance))}
    • % endif % if master.creatable and request.has_perm('{}.create'.format(permission_prefix)): -
    • ${h.link_to("Create a new {}".format(model_title), url('{}.create'.format(route_prefix)))}
    • + % if master.creates_multiple: +
    • ${h.link_to("Create new {}".format(model_title_plural), url('{}.create'.format(route_prefix)))}
    • + % else: +
    • ${h.link_to("Create a new {}".format(model_title), url('{}.create'.format(route_prefix)))}
    • + % endif % endif % if master.cloneable and request.has_perm('{}.clone'.format(permission_prefix)):
    • ${h.link_to("Clone this as new {}".format(model_title), url('{}.clone'.format(route_prefix), uuid=instance.uuid))}
    • diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 3f4a88c4..826b693c 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -86,6 +86,7 @@ class MasterView(View): listing = False creating = False + creates_multiple = False viewing = False editing = False deleting = False From 18af33c9bb3f285279618c449916e604a93d5956 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 18 Jan 2018 11:47:11 -0600 Subject: [PATCH 0636/3196] Add basic support for per-page help URL --- tailbone/templates/base.mako | 3 +++ tailbone/views/master.py | 4 ++++ 2 files changed, 7 insertions(+) diff --git a/tailbone/templates/base.mako b/tailbone/templates/base.mako index 37ab3dd5..15d5974d 100644 --- a/tailbone/templates/base.mako +++ b/tailbone/templates/base.mako @@ -54,6 +54,9 @@ % endif diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 826b693c..a21188df 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -1192,6 +1192,9 @@ class MasterView(View): route_prefix = 'mobile.{}'.format(route_prefix) return self.request.route_url('{}.{}'.format(route_prefix, action), **kw) + def get_help_url(self): + return getattr(self, 'help_url', None) + def render_to_response(self, template, data, mobile=False): """ Return a response with the given template rendered with the given data. @@ -1210,6 +1213,7 @@ class MasterView(View): 'index_url': self.get_index_url(mobile=mobile), 'action_url': self.get_action_url, 'grid_index': self.grid_index, + 'help_url': self.get_help_url(), } if self.grid_index: From a428bebda9012fc07d502f7960e00373c908364f Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 22 Jan 2018 16:30:09 -0600 Subject: [PATCH 0637/3196] Add basic "config" concept doc --- docs/concepts/config.rst | 110 +++++++++++++++++++++++++++++++++++++- docs/concepts/console.rst | 7 +++ docs/index.rst | 2 + 3 files changed, 118 insertions(+), 1 deletion(-) create mode 100644 docs/concepts/console.rst diff --git a/docs/concepts/config.rst b/docs/concepts/config.rst index d20aa723..9d7eacb3 100644 --- a/docs/concepts/config.rst +++ b/docs/concepts/config.rst @@ -4,4 +4,112 @@ Configuration .. contents:: :local: -TODO +Configuration for an app can come from two sources: configuration file(s), and +the Settings table in the database. + + +Config File Inheritance +----------------------- + +An important thing to understand regarding Rattail config files, is that one +file may "include" another file(s), which in turn may "include" others etc. +Invocation of the app will often require only a single config file to be +specified, since that file may include others as needed. + +For example ``web.conf`` will typically include ``rattail.conf`` but the web +app need only be invoked with ``web.conf`` - config from both files will inform +the app's behavior. + + +Typical Config Files +-------------------- + +A typical Poser (Rattail-based) app will have at the very least, one file named +``rattail.conf`` - this is considered the most fundamental config file. It +will usually define database connections, logging config, and any other "core" +things which would be required for any invocation of the app, regardless of the +environment (e.g. console vs. web). + +Note that even ``rattail.conf`` is free to include other files. This may be +useful for instance, if you have a single site-wide config file which is shared +among all Rattail apps. + +There is no *strict* requirement for having a ``rattail.conf`` file, but these +docs will assume its presence. Here are some other typical files, which the +docs also may reference occasionally: + +**web.conf** - This is the "core" config file for the web app, although it +still includes the ``rattail.conf`` file. In production (running on Apache +etc.) it is specified within the WSGI module which is responsible for +instantiating the web app. When running the development server, it is +specified via command line. + +**quiet.conf** - This is a slight wrapper around ``rattail.conf`` for the sake +of a "quieter" console, when running app commands via console. It may be used +in place of ``rattail.conf`` - i.e. you would specify ``-c quiet.conf`` when +running the command. The only function of this wrapper is to set the level to +INFO for the console logging handler. In practice this hides DEBUG logging +messages which are shown by default when using ``rattail.conf`` as the app +config file. + +**cron.conf** - Another wrapper around ``rattail.conf`` which suppresses +logging even further. The idea is that this config file would be used by cron +jobs; that way the only actual output is warnings and errors, hence cron would +not send email unless something actually went wrong. It may be used in place +of ``rattail.conf`` - i.e. you would specify ``-c cron.conf`` when running the +command. The only function of this wrapper is to set the level to WARNING for +the console logging handler. + +**ignore-changes.conf** - This file is only relevant if your ``rattail.conf`` +says to "record changes" when write activity occurs in the database(s). Note +that this file does *not* include ``rattail.conf`` because it is meant to be +supplemental only. For instance on the command line, you would need to specify +two config files, first ``rattail.conf`` or a suitable alternative, but then +``ignore-changes.conf`` also. If specified, this file will cause changes to be +ignored, i.e. **not recorded** when write activity occurs. + +**without-versioning.conf** - This file is only relevant if your +``rattail.conf`` says to enable "data versioning" when write activity occurs in +the database(s). Note that this file does *not* include ``rattail.conf`` +because it is meant to be supplemental only. For instance on the command line, +you would need to specify two config files, first ``rattail.conf`` or a +suitable alternative, but then ``without-versioning.conf`` also. If specified, +this file will disable the data versioning system entirely. Note that if +versioning is undesirable for a given app run, this is the only way to +effectively disable it; once loaded that feature cannot be disabled. + + +Settings from Database +---------------------- + +The other (often more convenient) source of app configuration is the Settings +table within the app database. Whether or not this table is a valid source for +app configuration, ultimately depends on what the config file(s) has to say +about it. + +Assuming the config file(s) defines a database connection and declares it a +valid source for config values, then the Settings table may contribute to the +running app config. The nice thing about this is that these settings are +checked in real-time. So whereas changing a config file will require an app +restart, any edits to the settings table should take effect immediately. + +Usually the settings table will *override* values found in the config file. +This behavior also is configurable to some extent, and in some cases a config +value may *only* come from a config file and never the settings table. + +An example may help here. If the config file contained the following value: + +.. code-block:: ini + + [poser] + foo = bar + +Then you could create a new Setting in the database with the following fields: + +* **name** = poser.foo +* **value** = baz + +Assuming typical setup, i.e. where settings table may override config file, the +app would consider 'baz' to be the config value. So basically the setting name +must correspond to a combination of the config file "section" name, then a dot, +then the "option" name. diff --git a/docs/concepts/console.rst b/docs/concepts/console.rst new file mode 100644 index 00000000..32912d6a --- /dev/null +++ b/docs/concepts/console.rst @@ -0,0 +1,7 @@ + +Console Commands +================ + +.. contents:: :local: + +TODO diff --git a/docs/index.rst b/docs/index.rst index 6f95b883..e547fb4e 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -27,6 +27,8 @@ Concept Guide: .. toctree:: concepts/config + .. + concepts/console concepts/schema concepts/batches From 3d18460d2384d6703d0166d3f5acbd26030e360d Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 22 Jan 2018 16:31:19 -0600 Subject: [PATCH 0638/3196] 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 f81f2c6c..40e858fd 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,18 @@ CHANGELOG ========= +0.6.64 (2018-01-22) +------------------- + +* Warn if user "scans" UPC with more than 14 digits, for mobile inventory. + +* Add option for preventing new inventory batch rows for unknown products. + +* Add ``creates_multiple`` flag for master view. + +* Add basic support for per-page help URL. + + 0.6.63 (2018-01-16) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 6b824369..28fe74d2 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.6.63' +__version__ = '0.6.64' From 96421ee1e512d77727e972e67c1f2a6f4d3794b3 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 22 Jan 2018 17:27:58 -0600 Subject: [PATCH 0639/3196] Fix doc reference --- docs/index.rst | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/index.rst b/docs/index.rst index e547fb4e..ebfad998 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -27,8 +27,7 @@ Concept Guide: .. toctree:: concepts/config - .. - concepts/console + concepts/console concepts/schema concepts/batches From eefc3b33d7bc882dd02999069c93d08e6d0398e2 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 23 Jan 2018 17:41:01 -0600 Subject: [PATCH 0640/3196] Fix some master3 edit issues for products view --- tailbone/views/products.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 461cb956..2533f535 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -498,9 +498,8 @@ class ProductsView(MasterView): form = self.make_form(instance) product_deleted = instance.deleted if self.request.method == 'POST': - if form.validate(): - self.save_form(form) - self.after_edit(instance) + if self.validate_form(form): + self.save_edit_form(form) self.request.session.flash("{} {} has been updated.".format( self.get_model_title(), self.get_instance_title(instance))) return self.redirect(self.get_action_url('view', instance)) From 04d1e303befa913d7b31d83946c04634db6fa56d Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 23 Jan 2018 19:00:33 -0600 Subject: [PATCH 0641/3196] Let custom inventory batch view override logic for mobile UPC scanning --- .../mobile/batch/inventory/view_row.mako | 5 +- tailbone/views/inventory.py | 55 ++++++++++++------- 2 files changed, 39 insertions(+), 21 deletions(-) diff --git a/tailbone/templates/mobile/batch/inventory/view_row.mako b/tailbone/templates/mobile/batch/inventory/view_row.mako index 5ef6165a..46f3abfe 100644 --- a/tailbone/templates/mobile/batch/inventory/view_row.mako +++ b/tailbone/templates/mobile/batch/inventory/view_row.mako @@ -11,7 +11,10 @@ if row.cases: uom = 'CS' elif row.units: - uom = 'EA' + if row.product and row.product.weighed: + uom = 'LB' + else: + uom = 'EA' elif row.case_quantity: uom = 'CS' else: diff --git a/tailbone/views/inventory.py b/tailbone/views/inventory.py index 83cc77a3..215322ca 100644 --- a/tailbone/views/inventory.py +++ b/tailbone/views/inventory.py @@ -27,6 +27,7 @@ Views for inventory batches from __future__ import unicode_literals, absolute_import import re +import decimal import six @@ -239,24 +240,8 @@ class InventoryBatchView(BatchMasterView): if upc: if len(upc) <= 14: - - # try to locate general product by UPC; add to batch either way - provided = GPC(upc, calc_check_digit=False) - checked = GPC(upc, calc_check_digit='upc') - product = api.get_product_by_upc(self.Session(), provided) - if not product: - product = api.get_product_by_upc(self.Session(), checked) - if product or self.unknown_product_creates_row: - row = model.InventoryBatchRow() - if product: - row.product = product - row.upc = product.upc - else: - row.upc = provided # TODO: why not 'checked' instead? how to choose? - row.description = "(unknown product)" - self.handler.add_row(batch, row) - - else: + row = self.add_row_for_upc(batch, upc) + if not row: self.request.session.flash("Product not found: {}".format(upc), 'error') return self.redirect(self.get_action_url('view', batch, mobile=True)) @@ -267,6 +252,27 @@ class InventoryBatchView(BatchMasterView): self.Session.flush() return self.redirect(self.mobile_row_route_url('view', uuid=row.batch_uuid, row_uuid=row.uuid)) + def add_row_for_upc(self, batch, upc): + """ + Add a row to the batch for the given UPC, if applicable. + """ + # try to locate general product by UPC; add to batch either way + provided = GPC(upc, calc_check_digit=False) + checked = GPC(upc, calc_check_digit='upc') + product = api.get_product_by_upc(self.Session(), provided) + if not product: + product = api.get_product_by_upc(self.Session(), checked) + if product or self.unknown_product_creates_row: + row = model.InventoryBatchRow() + if product: + row.product = product + row.upc = product.upc + else: + row.upc = provided # TODO: why not 'checked' instead? how to choose? + row.description = "(unknown product)" + self.handler.add_row(batch, row) + return row + def template_kwargs_view_row(self, **kwargs): row = kwargs['instance'] kwargs['product_image_url'] = pod.get_image_url(self.rattail_config, row.upc) @@ -425,12 +431,21 @@ class ValidBatchRow(forms.validators.ModelValidator): return row +class Decimal(fe.validators.Number): + + def _to_python(self, value, state): + try: + return decimal.Decimal(value) + except ValueError: + raise Invalid(self.message('number', state), value, state) + + class InventoryForm(forms.Schema): allow_extra_fields = True filter_extra_fields = True row = ValidBatchRow() - cases = fe.validators.Number() - units = fe.validators.Number() + cases = Decimal() + units = Decimal() def includeme(config): From 440cfd0d72387268c362ccf2df968ddafd297943 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 23 Jan 2018 20:08:24 -0600 Subject: [PATCH 0642/3196] Show new `cashback` field for Trainwreck transaction --- tailbone/views/trainwreck.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tailbone/views/trainwreck.py b/tailbone/views/trainwreck.py index d1b994ee..5360878d 100644 --- a/tailbone/views/trainwreck.py +++ b/tailbone/views/trainwreck.py @@ -60,6 +60,10 @@ class TransactionView(MasterView): 'total', ] + labels = { + 'cashback': "Cash Back", + } + has_rows = True # model_row_class = trainwreck.TransactionItem rows_default_pagesize = 100 @@ -94,6 +98,7 @@ class TransactionView(MasterView): 'subtotal', 'discounted_subtotal', 'tax', + 'cashback', 'total', 'void', ] @@ -129,6 +134,7 @@ class TransactionView(MasterView): f.set_type('subtotal', 'currency') f.set_type('discounted_subtotal', 'currency') f.set_type('tax', 'currency') + f.set_type('cashback', 'currency') f.set_type('total', 'currency') # label overrides From 8044039d78d0b36db9786fbe16a24010a0734e91 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 24 Jan 2018 13:12:42 -0600 Subject: [PATCH 0643/3196] Add 'delete-instance' class to delete link when viewing a record so that JS can watch its click event --- tailbone/templates/master/view.mako | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/templates/master/view.mako b/tailbone/templates/master/view.mako index cb7f9504..a5b81814 100644 --- a/tailbone/templates/master/view.mako +++ b/tailbone/templates/master/view.mako @@ -53,7 +53,7 @@
    • ${h.link_to("Edit this {}".format(model_title), action_url('edit', instance))}
    • % endif % if master.deletable and instance_deletable and request.has_perm('{}.delete'.format(permission_prefix)): -
    • ${h.link_to("Delete this {}".format(model_title), action_url('delete', instance))}
    • +
    • ${h.link_to("Delete this {}".format(model_title), action_url('delete', instance), class_='delete-instance')}
    • % endif % if master.creatable and request.has_perm('{}.create'.format(permission_prefix)): % if master.creates_multiple: From b8c6e95b7368f2b9aaaf2ad4379a6b7b543ce153 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 24 Jan 2018 18:20:19 -0600 Subject: [PATCH 0644/3196] 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 40e858fd..b572f92f 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,18 @@ CHANGELOG ========= +0.6.65 (2018-01-24) +------------------- + +* Fix some master3 edit issues for products view. + +* Let custom inventory batch view override logic for mobile UPC scanning. + +* Show new ``cashback`` field for Trainwreck transaction. + +* Add 'delete-instance' class to delete link when viewing a record. + + 0.6.64 (2018-01-22) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 28fe74d2..9d6f3252 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.6.64' +__version__ = '0.6.65' From 96e5c4279539229f8d400a90a6c4e31160d4807c Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 24 Jan 2018 23:53:12 -0600 Subject: [PATCH 0645/3196] Add support for detaching Person from Customer --- tailbone/templates/customers/view.mako | 21 +++++ tailbone/views/customers.py | 106 +++++++++++++++++++++++-- 2 files changed, 121 insertions(+), 6 deletions(-) create mode 100644 tailbone/templates/customers/view.mako diff --git a/tailbone/templates/customers/view.mako b/tailbone/templates/customers/view.mako new file mode 100644 index 00000000..27cc619c --- /dev/null +++ b/tailbone/templates/customers/view.mako @@ -0,0 +1,21 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/view.mako" /> + +<%def name="extra_javascript()"> + ${parent.extra_javascript()} + % if master.people_detachable and request.has_perm('{}.detach_person'.format(permission_prefix)): + + % endif + + +${parent.body()} diff --git a/tailbone/views/customers.py b/tailbone/views/customers.py index 8135c52c..012a151f 100644 --- a/tailbone/views/customers.py +++ b/tailbone/views/customers.py @@ -37,6 +37,7 @@ from deform import widget as dfwidget from pyramid.httpexceptions import HTTPNotFound from webhelpers2.html import HTML, tags +from tailbone import grids from tailbone.db import Session from tailbone.views import MasterView3 as MasterView, AutocompleteView @@ -50,6 +51,7 @@ class CustomersView(MasterView): model_class = model.Customer has_versions = True supports_mobile = True + people_detachable = True labels = { 'id': "ID", @@ -78,6 +80,7 @@ class CustomersView(MasterView): 'active_in_pos', 'active_in_pos_sticky', 'people', + 'groups', ] def configure_grid(self, g): @@ -151,6 +154,11 @@ class CustomersView(MasterView): def configure_form(self, f): super(CustomersView, self).configure_form(f) customer = f.model_instance + permission_prefix = self.get_permission_prefix() + + # id + if not self.creating: + f.set_readonly('id') f.set_renderer('default_email', self.render_default_email) if not self.creating and customer.emails: @@ -160,21 +168,33 @@ class CustomersView(MasterView): if not self.creating and customer.phones: f.set_default('default_phone', customer.phones[0].number) - f.set_renderer('default_address', self.render_default_address) - f.set_readonly('default_address') + # default_address + if self.creating: + f.remove_field('default_address') + else: + f.set_renderer('default_address', self.render_default_address) + f.set_readonly('default_address') f.set_enum('email_preference', self.enum.EMAIL_PREFERENCE) preferences = list(self.enum.EMAIL_PREFERENCE.items()) preferences.insert(0, ('', "(no preference)")) f.set_widget('email_preference', dfwidget.SelectWidget(values=preferences)) - f.set_renderer('people', self.render_people) - f.set_readonly('people') - + # people if self.creating: f.remove_field('people') + elif self.viewing and self.request.has_perm('{}.detach_person'.format(permission_prefix)): + f.set_renderer('people', self.render_people_removable) else: - f.set_readonly('id') + f.set_renderer('people', self.render_people) + f.set_readonly('people') + + # groups + if self.creating: + f.remove_field('groups') + else: + f.set_renderer('groups', self.render_groups) + f.set_readonly('groups') # TODO: something like this should be supported for default_email, default_phone # def after_edit(self, customer): @@ -226,6 +246,46 @@ class CustomersView(MasterView): items.append(HTML.tag('li', link)) return HTML.tag('ul', HTML.literal('').join(items)) + def render_people_removable(self, customer, field): + people = customer.people + if not people: + return "" + + route_prefix = self.get_route_prefix() + permission_prefix = self.get_permission_prefix() + + view_url = lambda p, i: self.request.route_url('people.view', uuid=p.uuid) + actions = [ + grids.GridAction('view', icon='zoomin', url=view_url), + ] + if self.people_detachable and self.request.has_perm('{}.detach_person'.format(permission_prefix)): + url = lambda p, i: self.request.route_url('{}.detach_person'.format(route_prefix), + uuid=customer.uuid, person_uuid=p.uuid) + actions.append( + grids.GridAction('detach', icon='trash', url=url)) + + columns = ['first_name', 'last_name', 'display_name'] + g = grids.Grid( + key='{}.people'.format(route_prefix), + data=customer.people, + columns=columns, + labels={'display_name': "Full Name"}, + url=lambda p: self.request.route_url('people.view', uuid=p.uuid), + linked_columns=columns, + main_actions=actions) + return HTML.literal(g.render_grid()) + + def render_groups(self, customer, field): + groups = customer.groups + if not groups: + return "" + items = [] + for group in groups: + text = "({}) {}".format(group.id, group.name) + url = self.request.route_url('customergroups.view', uuid=group.uuid) + items.append(HTML.tag('li', tags.link_to(text, url))) + return HTML.tag('ul', HTML.literal('').join(items)) + # def configure_mobile_fieldset(self, fs): # fs.configure( # include=[ @@ -241,6 +301,40 @@ class CustomersView(MasterView): (model.CustomerPerson, 'customer_uuid'), ] + def detach_person(self): + customer = self.get_instance() + person = self.Session.query(model.Person).get(self.request.matchdict['person_uuid']) + if not person: + return self.notfound() + + if person in customer.people: + customer.people.remove(person) + else: + self.request.session.flash("No change; person \"{}\" not attached to customer \"{}\"".format( + person, customer)) + + return self.redirect(self.request.get_referrer()) + + @classmethod + def defaults(cls, config): + route_prefix = cls.get_route_prefix() + url_prefix = cls.get_url_prefix() + permission_prefix = cls.get_permission_prefix() + model_key = cls.get_model_key() + model_title = cls.get_model_title() + + cls._defaults(config) + + # detach person + if cls.people_detachable: + config.add_tailbone_permission(permission_prefix, '{}.detach_person'.format(permission_prefix), + "Detach a Person from a {}".format(model_title)) + config.add_route('{}.detach_person'.format(route_prefix), '{}/{{{}}}/detach-person/{{person_uuid}}'.format(url_prefix, model_key), + # request_method='POST', + ) + config.add_view(cls, attr='detach_person', route_name='{}.detach_person'.format(route_prefix), + permission='{}.detach_person'.format(permission_prefix)) + # # TODO: this is referenced by some custom apps, but should be moved?? # def unique_id(value, field): From f32cf3342c84d1b2418e870aca48014582cb97bc Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 25 Jan 2018 14:15:25 -0600 Subject: [PATCH 0646/3196] Allow disabling auto-dismiss of flash messages on mobile --- tailbone/static/js/tailbone.mobile.js | 4 ---- tailbone/templates/mobile/base.mako | 9 +++++++++ 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/tailbone/static/js/tailbone.mobile.js b/tailbone/static/js/tailbone.mobile.js index 35d691e5..04c1f64e 100644 --- a/tailbone/static/js/tailbone.mobile.js +++ b/tailbone/static/js/tailbone.mobile.js @@ -62,10 +62,6 @@ $(document).on('pageshow', function() { setfocus(); - // TODO: seems like this should be better somehow... - // remove all flash messages after 2.5 seconds - window.setTimeout(function() { $('.flash, .error').remove(); }, 2500); - }); diff --git a/tailbone/templates/mobile/base.mako b/tailbone/templates/mobile/base.mako index dd5cfa11..28d5ffe1 100644 --- a/tailbone/templates/mobile/base.mako +++ b/tailbone/templates/mobile/base.mako @@ -11,6 +11,15 @@ ${h.javascript_link(request.static_url('tailbone:static/js/jquery.ui.tailbone.mobile.js'))} ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.mobile.js'))} ${self.extra_javascript()} + % if request.rattail_config.getbool('tailbone', 'mobile.flash.autodismiss', default=True): + + % endif ${h.stylesheet_link('https://code.jquery.com/mobile/1.4.5/jquery.mobile-1.4.5.min.css')} ${h.stylesheet_link(request.static_url('tailbone:static/css/mobile.css'))} From 62d1918892cfbcd333ea188469fbefd2bc894d10 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 25 Jan 2018 14:43:30 -0600 Subject: [PATCH 0647/3196] Add `FieldList` wrapper for grid columns list needs to be merged with the "forms2" equivalent at some point... --- tailbone/grids/core.py | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index c5290a31..3a17493f 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -48,6 +48,20 @@ from tailbone.db import Session from tailbone.util import raw_datetime +class FieldList(list): + """ + Convenience wrapper for a field list. + """ + + def insert_before(self, field, newfield): + i = self.index(field) + self.insert(i, newfield) + + def insert_after(self, field, newfield): + i = self.index(field) + self.insert(i + 1, newfield) + + class Grid(object): """ Core grid class. In sore need of documentation. @@ -63,7 +77,7 @@ class Grid(object): self.key = key self.data = data - self.columns = columns + self.columns = FieldList(columns) if columns is not None else None self.width = width self.request = request self.mobile = mobile @@ -114,6 +128,12 @@ class Grid(object): if key in self.columns: self.columns.remove(key) + def insert_before(self, field, newfield): + self.columns.insert_before(field, newfield) + + def insert_after(self, field, newfield): + self.columns.insert_after(field, newfield) + def set_joiner(self, key, joiner): if joiner is None: self.joiners.pop(key, None) From 37de777b2a835d2efca80430df496d3bd5a6c24d Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 25 Jan 2018 14:45:06 -0600 Subject: [PATCH 0648/3196] Show "unit cost" column by default, for products grid --- tailbone/views/products.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 2533f535..bdb46be8 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -86,6 +86,7 @@ class ProductsView(MasterView): 'size', 'subdepartment', 'vendor', + 'cost', 'regular_price', 'current_price', ] @@ -239,13 +240,6 @@ class ProductsView(MasterView): g.filters['vendor_code'] = g.make_filter('vendor_code', ProductCostCode.code) g.filters['vendor_code_any'] = g.make_filter('vendor_code_any', ProductCostCodeAny.code) - g.joiners['cost'] = lambda q: q.outerjoin(model.ProductCost, - sa.and_( - model.ProductCost.product_uuid == model.Product.uuid, - model.ProductCost.preference == 1)) - g.sorters['cost'] = g.make_sorter(model.ProductCost.unit_cost) - g.filters['cost'] = g.make_filter('cost', model.ProductCost.unit_cost) - g.set_label('regular_price', "Reg. Price") g.set_joiner('regular_price', lambda q: q.outerjoin( self.RegularPrice, self.RegularPrice.uuid == model.Product.regular_price_uuid)) @@ -258,6 +252,16 @@ class ProductsView(MasterView): g.set_sorter('current_price', self.CurrentPrice.price) g.set_filter('current_price', self.CurrentPrice.price, label="Current Price") + # (unit) cost + g.set_joiner('cost', lambda q: q.outerjoin(model.ProductCost, + sa.and_( + model.ProductCost.product_uuid == model.Product.uuid, + model.ProductCost.preference == 1))) + g.set_sorter('cost', model.ProductCost.unit_cost) + g.set_filter('cost', model.ProductCost.unit_cost) + g.set_renderer('cost', self.render_cost) + g.set_label('cost', "Unit Cost") + # report_code_name g.set_joiner('report_code_name', lambda q: q.outerjoin(model.ReportCode)) g.set_filter('report_code_name', model.ReportCode.name) @@ -271,7 +275,6 @@ class ProductsView(MasterView): g.set_renderer('regular_price', self.render_price) g.set_renderer('current_price', self.render_price) - g.set_renderer('cost', self.render_cost) g.set_renderer('on_hand', self.render_on_hand) g.set_renderer('on_order', self.render_on_order) From e8dfe92be33cb85761963ccb70d60a12322d8a1d Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 25 Jan 2018 15:07:16 -0600 Subject: [PATCH 0649/3196] Improve case/unit quantity validation for order worksheet --- tailbone/views/purchasing/ordering.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tailbone/views/purchasing/ordering.py b/tailbone/views/purchasing/ordering.py index 32db947a..fb1a6a4f 100644 --- a/tailbone/views/purchasing/ordering.py +++ b/tailbone/views/purchasing/ordering.py @@ -202,11 +202,15 @@ class OrderingBatchView(PurchasingBatchView): if not cases_ordered or not cases_ordered.isdigit(): return {'error': "Invalid value for cases ordered: {}".format(cases_ordered)} cases_ordered = int(cases_ordered) + if cases_ordered >= 100000: # TODO: really this depends on underlying column + return {'error': "Invalid value for cases ordered: {}".format(cases_ordered)} units_ordered = self.request.POST.get('units_ordered', '0') if not units_ordered or not units_ordered.isdigit(): return {'error': "Invalid value for units ordered: {}".format(units_ordered)} units_ordered = int(units_ordered) + if units_ordered >= 100000: # TODO: really this depends on underlying column + return {'error': "Invalid value for units ordered: {}".format(units_ordered)} uuid = self.request.POST.get('product_uuid') product = self.Session.query(model.Product).get(uuid) if uuid else None From b2b3a633d0a869855e51cbfaa13d9208d373e500 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 25 Jan 2018 16:09:49 -0600 Subject: [PATCH 0650/3196] Show new 'exposed' field for brands table --- tailbone/views/brands.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tailbone/views/brands.py b/tailbone/views/brands.py index 46965cb1..40ecf83f 100644 --- a/tailbone/views/brands.py +++ b/tailbone/views/brands.py @@ -40,19 +40,26 @@ class BrandsView(MasterView): grid_columns = [ 'name', + 'confirmed', ] form_fields = [ 'name', + 'confirmed', ] def configure_grid(self, g): super(BrandsView, self).configure_grid(g) + + # name g.filters['name'].default_active = True g.filters['name'].default_verb = 'contains' g.set_sort_defaults('name') g.set_link('name') + # confirmed + g.set_type('confirmed', 'boolean') + class BrandsAutocomplete(AutocompleteView): From 1453d33123ff88cff282b2680dd719fc3d8c9aaf Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 25 Jan 2018 17:02:53 -0600 Subject: [PATCH 0651/3196] Add support for extra column(s) in timesheet view table --- tailbone/helpers.py | 2 +- tailbone/subscribers.py | 2 ++ tailbone/templates/shifts/base.mako | 13 +++++++++++-- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/tailbone/helpers.py b/tailbone/helpers.py index 83c79518..67e90fb5 100644 --- a/tailbone/helpers.py +++ b/tailbone/helpers.py @@ -30,7 +30,7 @@ import datetime from decimal import Decimal from rattail.time import localtime, make_utc -from rattail.util import pretty_quantity +from rattail.util import pretty_quantity, pretty_hours from webhelpers2.html import * from webhelpers2.html.tags import * diff --git a/tailbone/subscribers.py b/tailbone/subscribers.py index df9e3078..a6a62ffa 100644 --- a/tailbone/subscribers.py +++ b/tailbone/subscribers.py @@ -28,6 +28,7 @@ from __future__ import unicode_literals, absolute_import import six import json +import datetime import rattail from rattail.db import model @@ -84,6 +85,7 @@ def before_render(event): renderer_globals['enum'] = request.rattail_config.get_enum() renderer_globals['six'] = six renderer_globals['json'] = json + renderer_globals['datetime'] = datetime def add_inbox_count(event): diff --git a/tailbone/templates/shifts/base.mako b/tailbone/templates/shifts/base.mako index 051e57db..fd608d86 100644 --- a/tailbone/templates/shifts/base.mako +++ b/tailbone/templates/shifts/base.mako @@ -218,10 +218,10 @@ -<%def name="timesheet(render_day=None)"> +<%def name="timesheet(render_day=None, extra_columns=0)"> @@ -233,6 +233,7 @@
      % endfor + ${self.render_extra_headers()} @@ -254,6 +255,7 @@ + ${self.render_extra_cells(employee)} % endfor % if employee is Undefined: @@ -275,6 +277,7 @@ + ${self.render_extra_totals(employee)} % endif @@ -291,5 +294,11 @@ <%def name="render_employee_day_total(day)"> +<%def name="render_extra_headers()"> + +<%def name="render_extra_cells(employee)"> + +<%def name="render_extra_totals(employee)"> + ${self.timesheet_wrapper()} From eaad87c7043a333b3b2e3bb16e37ff7f6e998016 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 26 Jan 2018 12:01:58 -0600 Subject: [PATCH 0652/3196] Add 'single' context var when rendering timesheet template --- tailbone/views/shifts/lib.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tailbone/views/shifts/lib.py b/tailbone/views/shifts/lib.py index c2f24001..253abd05 100644 --- a/tailbone/views/shifts/lib.py +++ b/tailbone/views/shifts/lib.py @@ -324,6 +324,7 @@ class TimeSheetView(View): self.modify_employees([employee], weekdays) context = { + 'single': True, 'page_title': "Employee {}".format(self.get_title()), 'form': forms.FormRenderer(form) if form else None, 'employee': employee, From e5c5a071f2add6b25c042fe4e2920eac8cc80e9e Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 26 Jan 2018 14:24:06 -0600 Subject: [PATCH 0653/3196] Add generic "download results as XLSX" feature --- tailbone/templates/master/index.mako | 3 ++ tailbone/views/master.py | 63 +++++++++++++++++++++++++++- 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/tailbone/templates/master/index.mako b/tailbone/templates/master/index.mako index 014e1f97..21070505 100644 --- a/tailbone/templates/master/index.mako +++ b/tailbone/templates/master/index.mako @@ -72,6 +72,9 @@ % if master.results_downloadable_csv and request.has_perm('{}.results_csv'.format(permission_prefix)):
    • ${h.link_to("Download results as CSV", url('{}.results_csv'.format(route_prefix)))}
    • % endif + % if master.results_downloadable_xlsx and request.has_perm('{}.results_xlsx'.format(permission_prefix)): +
    • ${h.link_to("Download results as XLSX", url('{}.results_xlsx'.format(route_prefix)))}
    • + % endif % if master.creatable and request.has_perm('{}.create'.format(permission_prefix)): % if master.creates_multiple:
    • ${h.link_to("Create new {}".format(model_title_plural), url('{}.create'.format(route_prefix)))}
    • diff --git a/tailbone/views/master.py b/tailbone/views/master.py index a21188df..bb320612 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar +# Copyright © 2010-2018 Lance Edgar # # This file is part of Rattail. # @@ -41,6 +41,8 @@ from rattail.util import prettify from rattail.time import localtime #, make_utc from rattail.threads import Thread from rattail.csvutil import UnicodeDictWriter +from rattail.files import temp_path +from rattail.excel import ExcelWriter import formalchemy as fa from pyramid import httpexceptions @@ -66,6 +68,7 @@ class MasterView(View): listable = True results_downloadable_csv = False + results_downloadable_xlsx = False creatable = True viewable = True editable = True @@ -1472,6 +1475,57 @@ class MasterView(View): response.content_disposition = b'attachment; filename={}.csv'.format(self.get_grid_key()) return response + def results_xlsx(self): + """ + Download current list results as XLSX. + """ + results = self.get_effective_data() + fields = self.get_xlsx_fields() + path = temp_path(suffix='.xlsx') + writer = ExcelWriter(path, fields, sheet_title=self.get_model_title_plural()) + writer.write_header() + + rows = [] + for obj in results: + data = self.get_xlsx_row(obj, fields) + row = [data[field] for field in fields] + rows.append(row) + + writer.write_rows(rows) + writer.auto_freeze() + writer.auto_filter() + writer.save() + + response = self.request.response + with open(path, 'rb') as f: + response.body = f.read() + os.remove(path) + + response.content_length = len(response.body) + response.content_type = b'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + response.content_disposition = b'attachment; filename={}.xlsx'.format(self.get_grid_key()) + return response + + def get_xlsx_fields(self): + """ + Return the list of fields to be written to XLSX download. + """ + fields = [] + mapper = orm.class_mapper(self.model_class) + for prop in mapper.iterate_properties: + if isinstance(prop, orm.ColumnProperty): + fields.append(prop.key) + return fields + + def get_xlsx_row(self, obj, fields): + """ + Return a dict for use when writing the row's data to CSV download. + """ + row = {} + for field in fields: + row[field] = getattr(obj, field, None) + return row + def row_results_csv(self): """ Download current row results data for an object, as CSV @@ -1965,6 +2019,13 @@ class MasterView(View): config.add_view(cls, attr='results_csv', route_name='{}.results_csv'.format(route_prefix), permission='{}.results_csv'.format(permission_prefix)) + if cls.results_downloadable_xlsx: + config.add_tailbone_permission(permission_prefix, '{}.results_xlsx'.format(permission_prefix), + "Download {} as XLSX".format(model_title_plural)) + config.add_route('{}.results_xlsx'.format(route_prefix), '{}/xlsx'.format(url_prefix)) + config.add_view(cls, attr='results_xlsx', route_name='{}.results_xlsx'.format(route_prefix), + permission='{}.results_xlsx'.format(permission_prefix)) + # create if cls.creatable or cls.mobile_creatable: From 580f817dd9cf45cca5a7ed16a70310316ad3bb86 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 26 Jan 2018 18:30:29 -0600 Subject: [PATCH 0654/3196] Add vendor links in cost grid when viewing product --- tailbone/templates/products/view.mako | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tailbone/templates/products/view.mako b/tailbone/templates/products/view.mako index 10db733e..a6e157d1 100644 --- a/tailbone/templates/products/view.mako +++ b/tailbone/templates/products/view.mako @@ -191,7 +191,13 @@ % for i, cost in enumerate(instance.costs, 1):
      - + From efdbc3c5b5fc17dd84ebb05def0362877c57c22b Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 26 Jan 2018 19:04:34 -0600 Subject: [PATCH 0655/3196] Show "buttons" when viewing an object, with forms2 also tweak logic when creating a batch..we'll see if it works.. --- tailbone/templates/forms2/form_readonly.mako | 6 +++--- tailbone/views/batch/core3.py | 4 +++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/tailbone/templates/forms2/form_readonly.mako b/tailbone/templates/forms2/form_readonly.mako index ed61a44e..306282e9 100644 --- a/tailbone/templates/forms2/form_readonly.mako +++ b/tailbone/templates/forms2/form_readonly.mako @@ -2,7 +2,7 @@
      ${form.render_deform(readonly=True)|n} -## % if buttons: -## ${buttons|n} -## % endif + % if buttons: + ${buttons|n} + % endif
      diff --git a/tailbone/views/batch/core3.py b/tailbone/views/batch/core3.py index d668a96a..07fb039f 100644 --- a/tailbone/views/batch/core3.py +++ b/tailbone/views/batch/core3.py @@ -120,9 +120,11 @@ class BatchMasterView3(MasterView3, BatchMasterView2): # current user is batch creator batch.created_by = self.request.user or self.late_login_user() + # TODO: is this still necessary with colander? # destroy initial batch and re-make using handler kwargs = self.get_batch_kwargs(batch) - self.Session.expunge(batch) + # if batch in self.Session: + # self.Session.expunge(batch) batch = self.handler.make_batch(self.Session(), **kwargs) self.Session.flush() From d20601c35949226407e6dbdf1e498021dcf41a0b Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 26 Jan 2018 19:14:15 -0600 Subject: [PATCH 0656/3196] Refactor label batch view to use master3 --- tailbone/views/batch/core.py | 2 +- tailbone/views/labels/batch.py | 59 ++++++++++++++++++++-------------- 2 files changed, 35 insertions(+), 26 deletions(-) diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index 720ef62f..b2ec1669 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -336,7 +336,7 @@ class BatchMasterView(MasterView): form = self.make_form(batch) if self.request.method == 'POST': - if form.validate(): + if self.validate_form(form): self.save_edit_form(form) self.request.session.flash("{} has been updated: {}".format( self.get_model_title(), self.get_instance_title(batch))) diff --git a/tailbone/views/labels/batch.py b/tailbone/views/labels/batch.py index 0e97d0bf..e08a6a17 100644 --- a/tailbone/views/labels/batch.py +++ b/tailbone/views/labels/batch.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar +# Copyright © 2010-2018 Lance Edgar # # This file is part of Rattail. # @@ -28,10 +28,9 @@ from __future__ import unicode_literals, absolute_import from rattail.db import model -import formalchemy as fa +from webhelpers2.html import HTML, tags -from tailbone import forms -from tailbone.views.batch import BatchMasterView2 as BatchMasterView +from tailbone.views.batch import BatchMasterView3 as BatchMasterView class LabelBatchView(BatchMasterView): @@ -64,28 +63,38 @@ class LabelBatchView(BatchMasterView): 'status_code', ] - def _preconfigure_fieldset(self, fs): - super(LabelBatchView, self)._preconfigure_fieldset(fs) - fs.append(fa.Field('handheld_batches', renderer=forms.renderers.HandheldBatchesFieldRenderer, readonly=True, - value=lambda b: b._handhelds)) + form_fields = [ + 'id', + 'description', + 'static_prices', + 'notes', + 'created', + 'created_by', + 'handheld_batches', + 'rowcount', + 'executed', + 'executed_by', + ] - def configure_fieldset(self, fs): - fs.configure( - include=[ - fs.id, - fs.description, - fs.static_prices, - fs.notes, - fs.created, - fs.created_by, - fs.handheld_batches, - fs.rowcount, - fs.executed, - fs.executed_by, - ]) - batch = fs.model - if self.viewing and not batch._handhelds: - del fs.handheld_batches + def configure_form(self, f): + super(LabelBatchView, self).configure_form(f) + + # handheld_batches + f.set_readonly('handheld_batches') + f.set_renderer('handheld_batches', self.render_handheld_batches) + + if self.viewing or self.deleting: + batch = self.get_instance() + if not batch._handhelds: + f.remove_field('handheld_batches') + + def render_handheld_batches(self, label_batch, field): + items = '' + for handheld in label_batch._handhelds: + text = handheld.handheld.id_str + url = self.request.route_url('batch.handheld.view', uuid=handheld.handheld_uuid) + items += HTML.tag('li', c=tags.link_to(text, url)) + return HTML.tag('ul', c=items) def configure_row_grid(self, g): super(LabelBatchView, self).configure_row_grid(g) From eac59ba5c8ccefefe3544836178c13efea38c032 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 27 Jan 2018 11:59:52 -0600 Subject: [PATCH 0657/3196] Refactor purchasing batch views to use master3 --- tailbone/templates/batch/edit.mako | 11 +- tailbone/templates/ordering/create.mako | 1 - tailbone/views/batch/core.py | 58 +---- tailbone/views/batch/core3.py | 3 +- tailbone/views/purchasing/batch.py | 308 +++++++++++++++++------- tailbone/views/purchasing/ordering.py | 6 + 6 files changed, 233 insertions(+), 154 deletions(-) diff --git a/tailbone/templates/batch/edit.mako b/tailbone/templates/batch/edit.mako index c47c43b6..c0183535 100644 --- a/tailbone/templates/batch/edit.mako +++ b/tailbone/templates/batch/edit.mako @@ -54,18 +54,9 @@
      -## TODO: clean this up or fix etc..? -## % if master.edit_with_rows: -## ${form.render(buttons=capture(buttons))|n} -## % else: - ${form.render()|n} -## % endif + ${form.render()|n}
      -% if master.edit_with_rows: - ${rows_grid.render_complete(allow_save_defaults=False, tools=capture(self.grid_tools))|n} -% endif - diff --git a/tailbone/views/auth.py b/tailbone/views/auth.py index de6e33ee..44a6ac0d 100644 --- a/tailbone/views/auth.py +++ b/tailbone/views/auth.py @@ -143,7 +143,8 @@ class AuthenticationView(View): """ headers = logout_user(self.request) if self.rattail_config.getbool('tailbone', 'home_after_logout', default=False): - return self.redirect(self.request.route_url('home'), headers=headers) + home = 'mobile.home' if mobile else 'home' + return self.redirect(self.request.route_url(home), headers=headers) login = 'mobile.login' if mobile else 'login' referrer = self.request.get_referrer(default=self.request.route_url(login)) return self.redirect(referrer, headers=headers) From e821b2a025d0c2c97645c06e1e48de340773cdd9 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 29 Jan 2018 22:47:30 -0600 Subject: [PATCH 0667/3196] Always redirect to mobile home page, if "other" page is refreshed also applies when becoming / stopping root, and maybe other cases? --- tailbone/templates/mobile/base.mako | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tailbone/templates/mobile/base.mako b/tailbone/templates/mobile/base.mako index 29a217bb..75d8c0e0 100644 --- a/tailbone/templates/mobile/base.mako +++ b/tailbone/templates/mobile/base.mako @@ -11,6 +11,16 @@ ${h.javascript_link(request.static_url('tailbone:static/js/jquery.ui.tailbone.mobile.js'))} ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.mobile.js'))} ${self.extra_javascript()} + + ## since jquery mobile will "utterly cache" the first page which is loaded + ## by the client, we must make sure that is always the home page. so if + ## user tries to e.g. "refresh" some other page, redirect to home page + % if request.matched_route.name != 'mobile.home': + + % endif + % if request.rattail_config.getbool('tailbone', 'mobile.flash.autodismiss', default=True): From 868b184069f17582e98cf51dc07ec5b716376cca Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 4 Feb 2018 14:32:05 -0600 Subject: [PATCH 0679/3196] Add 'plain' and 'jquery' templates for deform select widget need to refactor things to get all that straight, at some point --- tailbone/forms2/widgets.py | 8 ++++ tailbone/templates/deform/select_jquery.pt | 52 ++++++++++++++++++++++ tailbone/templates/deform/select_plain.pt | 41 +++++++++++++++++ 3 files changed, 101 insertions(+) create mode 100644 tailbone/templates/deform/select_jquery.pt create mode 100644 tailbone/templates/deform/select_plain.pt diff --git a/tailbone/forms2/widgets.py b/tailbone/forms2/widgets.py index 0be84a2e..d99853b7 100644 --- a/tailbone/forms2/widgets.py +++ b/tailbone/forms2/widgets.py @@ -48,6 +48,14 @@ class ReadonlyWidget(dfwidget.HiddenWidget): return HTML.tag('span', text) + tags.hidden(field.name, value=cstruct, id=field.oid) +class PlainSelectWidget(dfwidget.SelectWidget): + template = 'select_plain' + + +class JQuerySelectWidget(dfwidget.SelectWidget): + template = 'select_jquery' + + class JQueryDateWidget(dfwidget.DateInputWidget): """ Uses the jQuery datepicker UI widget, instead of whatever it is deform uses diff --git a/tailbone/templates/deform/select_jquery.pt b/tailbone/templates/deform/select_jquery.pt new file mode 100644 index 00000000..d276b4b7 --- /dev/null +++ b/tailbone/templates/deform/select_jquery.pt @@ -0,0 +1,52 @@ +
      + + + + + +
      diff --git a/tailbone/templates/deform/select_plain.pt b/tailbone/templates/deform/select_plain.pt new file mode 100644 index 00000000..60c283de --- /dev/null +++ b/tailbone/templates/deform/select_plain.pt @@ -0,0 +1,41 @@ +
      + + + + +
      From ab16ffc8234abaf5782d2c3cd610642d9c8813fc Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 5 Feb 2018 12:53:57 -0600 Subject: [PATCH 0680/3196] Add "hidden" concept for form fields i.e. include hidden fields but don't show label or other dressing --- tailbone/forms2/core.py | 12 +++++++++++- tailbone/templates/forms2/deform.mako | 12 +++++------- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/tailbone/forms2/core.py b/tailbone/forms2/core.py index 120db31d..3c0a2a17 100644 --- a/tailbone/forms2/core.py +++ b/tailbone/forms2/core.py @@ -320,10 +320,11 @@ class Form(object): """ Base class for all forms. """ + update_label = "Save" def __init__(self, fields=None, schema=None, request=None, readonly=False, readonly_fields=[], model_instance=None, model_class=None, nodes={}, enums={}, labels={}, renderers=None, - widgets={}, defaults={}, validators={}, required={}, helptext={}, + hidden={}, widgets={}, defaults={}, validators={}, required={}, helptext={}, action_url=None, cancel_url=None): self.fields = None @@ -346,6 +347,7 @@ class Form(object): self.renderers = self.make_renderers() else: self.renderers = renderers or {} + self.hidden = hidden or {} self.widgets = widgets or {} self.defaults = defaults or {} self.validators = validators or {} @@ -588,6 +590,9 @@ class Form(object): else: self.renderers[key] = renderer + def set_hidden(self, key, hidden=True): + self.hidden[key] = hidden + def set_widget(self, key, widget): self.widgets[key] = widget @@ -681,6 +686,11 @@ class Form(object): context['render_field_readonly'] = self.render_field_readonly return render('/forms2/deform.mako', context) + def field_visible(self, field): + if self.hidden and self.hidden.get(field): + return False + return True + def render_field_readonly(self, field_name, **kwargs): label = HTML.tag('label', self.get_label(field_name), for_=field_name) field = self.render_field_value(field_name) or '' diff --git a/tailbone/templates/forms2/deform.mako b/tailbone/templates/forms2/deform.mako index 3e42f9ba..d30113d6 100644 --- a/tailbone/templates/forms2/deform.mako +++ b/tailbone/templates/forms2/deform.mako @@ -20,11 +20,8 @@ ${h.csrf_token(request)} % elif field in dform: <% field = dform[field] %> - ## % if field.requires_label: + % if form.field_visible(field.name):
      - ## % for error in field.errors: - ##
      ${error}
      - ## % endfor % if field.error:
      % for msg in field.error.messages(): @@ -61,9 +58,10 @@ ${h.csrf_token(request)} % endif % endif - ## % else: - ## ${field.render()|n} - ## % endif + % else: + ## hidden field + ${field.serialize()|n} + % endif % endif From 63290154ebe59250276a2322d51c25f94cd999e4 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 3 Feb 2018 16:12:36 -0600 Subject: [PATCH 0681/3196] Add master4, refactor customers view to use it --- .../templates/mobile/master/create_row.mako | 2 + .../templates/mobile/master/view_row.mako | 2 +- tailbone/views/__init__.py | 5 +- tailbone/views/customers.py | 24 +- tailbone/views/master.py | 14 +- tailbone/views/master4.py | 319 ++++++++++++++++++ 6 files changed, 348 insertions(+), 18 deletions(-) create mode 100644 tailbone/views/master4.py diff --git a/tailbone/templates/mobile/master/create_row.mako b/tailbone/templates/mobile/master/create_row.mako index 8a6157d2..7b5dae0c 100644 --- a/tailbone/templates/mobile/master/create_row.mako +++ b/tailbone/templates/mobile/master/create_row.mako @@ -1,4 +1,6 @@ ## -*- coding: utf-8; -*- <%inherit file="/mobile/master/create.mako" /> +<%def name="title()">New ${model_title} Row + ${parent.body()} diff --git a/tailbone/templates/mobile/master/view_row.mako b/tailbone/templates/mobile/master/view_row.mako index 36817eb1..513563d9 100644 --- a/tailbone/templates/mobile/master/view_row.mako +++ b/tailbone/templates/mobile/master/view_row.mako @@ -8,5 +8,5 @@ ${parent.body()} % if master.mobile_rows_editable and instance_editable and request.has_perm('{}.edit_row'.format(permission_prefix)): - ${h.link_to("Edit", url('mobile.{}.edit_row'.format(route_prefix), uuid=instance.uuid), class_='ui-btn')} + ${h.link_to("Edit", url('mobile.{}.edit_row'.format(route_prefix), uuid=instance.batch_uuid, row_uuid=instance.uuid), class_='ui-btn')} % endif diff --git a/tailbone/views/__init__.py b/tailbone/views/__init__.py index d8090a19..f2b4fcdc 100644 --- a/tailbone/views/__init__.py +++ b/tailbone/views/__init__.py @@ -1,8 +1,8 @@ -# -*- coding: utf-8 -*- +# -*- coding: utf-8; -*- ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar +# Copyright © 2010-2018 Lance Edgar # # This file is part of Rattail. # @@ -30,6 +30,7 @@ from .core import View from .master import MasterView from .master2 import MasterView2 from .master3 import MasterView3 +from .master4 import MasterView4 # TODO: deprecate / remove some of this from .autocomplete import AutocompleteView diff --git a/tailbone/views/customers.py b/tailbone/views/customers.py index 012a151f..a5305033 100644 --- a/tailbone/views/customers.py +++ b/tailbone/views/customers.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar +# Copyright © 2010-2018 Lance Edgar # # This file is part of Rattail. # @@ -39,7 +39,7 @@ from webhelpers2.html import HTML, tags from tailbone import grids from tailbone.db import Session -from tailbone.views import MasterView3 as MasterView, AutocompleteView +from tailbone.views import MasterView4 as MasterView, AutocompleteView from rattail.db import model @@ -83,6 +83,19 @@ class CustomersView(MasterView): 'groups', ] + mobile_form_fields = [ + 'id', + 'name', + 'default_phone', + 'default_email', + 'default_address', + 'email_preference', + 'active_in_pos', + 'active_in_pos_sticky', + 'people', + 'groups', + ] + def configure_grid(self, g): super(CustomersView, self).configure_grid(g) @@ -286,13 +299,6 @@ class CustomersView(MasterView): items.append(HTML.tag('li', tags.link_to(text, url))) return HTML.tag('ul', HTML.literal('').join(items)) - # def configure_mobile_fieldset(self, fs): - # fs.configure( - # include=[ - # fs.email, - # fs.phone, - # ]) - def get_version_child_classes(self): return [ (model.CustomerPhoneNumber, 'parent_uuid'), diff --git a/tailbone/views/master.py b/tailbone/views/master.py index d0873a92..4a9bc50e 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -824,6 +824,9 @@ class MasterView(View): def validate_mobile_form(self, form): return form.validate() + def validate_row_form(self, form): + return form.validate() + def save_edit_form(self, form): self.save_form(form) self.after_edit(form.fieldset.model) @@ -1741,10 +1744,9 @@ class MasterView(View): index_url = self.get_action_url('view', parent) form = self.make_row_form(self.model_row_class, cancel_url=index_url) if self.request.method == 'POST': - if form.validate(): + if self.validate_row_form(form): self.before_create_row(form) - self.save_create_row_form(form) - obj = form.fieldset.model + obj = self.save_create_row_form(form) or form.fieldset.model self.after_create_row(obj) return self.redirect_after_create_row(obj) return self.render_to_response('create_row', { @@ -1775,7 +1777,7 @@ class MasterView(View): instance_url = self.get_action_url('view', parent, mobile=True) form = self.make_mobile_row_form(self.model_row_class, cancel_url=instance_url) if self.request.method == 'POST': - if form.validate(): + if self.validate_mobile_row_form(form): self.before_create_row(form) # let save() return alternate object if necessary obj = self.save_create_row_form(form) or form.fieldset.model @@ -1835,7 +1837,7 @@ class MasterView(View): form = self.make_row_form(row) if self.request.method == 'POST': - if form.validate(): + if self.validate_row_form(form): self.save_edit_row_form(form) return self.redirect_after_edit_row(row) @@ -1860,7 +1862,7 @@ class MasterView(View): form = self.make_mobile_row_form(row) if self.request.method == 'POST': - if form.validate(): + if self.validate_mobile_row_form(form): self.save_edit_row_form(form) return self.redirect_after_edit_row(row, mobile=True) diff --git a/tailbone/views/master4.py b/tailbone/views/master4.py new file mode 100644 index 00000000..07ae1247 --- /dev/null +++ b/tailbone/views/master4.py @@ -0,0 +1,319 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2018 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see . +# +################################################################################ +""" +Master View (v4) +""" + +from __future__ import unicode_literals, absolute_import + +import deform + +from tailbone import forms2 as forms +from tailbone.views import MasterView3 + + +class MasterView4(MasterView3): + """ + Base "master" view class. All model master views should derive from this. + """ + row_labels = {} + + def make_mobile_form(self, instance=None, factory=None, fields=None, schema=None, **kwargs): + """ + Creates a new mobile form for the given model class/instance. + """ + if factory is None: + factory = self.get_mobile_form_factory() + if fields is None: + fields = self.get_mobile_form_fields() + if schema is None: + schema = self.make_mobile_form_schema() + + if not self.creating: + kwargs['model_instance'] = instance + kwargs = self.make_mobile_form_kwargs(**kwargs) + form = factory(fields, schema, **kwargs) + self.configure_mobile_form(form) + return form + + def make_row_form(self, instance=None, factory=None, fields=None, schema=None, **kwargs): + """ + Creates a new row form for the given model class/instance. + """ + if factory is None: + factory = self.get_row_form_factory() + if fields is None: + fields = self.get_row_form_fields() + if schema is None: + schema = self.make_row_form_schema() + + if not self.creating: + kwargs['model_instance'] = instance + kwargs = self.make_row_form_kwargs(**kwargs) + form = factory(fields, schema, **kwargs) + self.configure_row_form(form) + return form + + def make_mobile_row_form(self, instance=None, factory=None, fields=None, schema=None, **kwargs): + """ + Creates a new mobile form for the given model class/instance. + """ + if factory is None: + factory = self.get_mobile_row_form_factory() + if fields is None: + fields = self.get_mobile_row_form_fields() + if schema is None: + schema = self.make_mobile_row_form_schema() + + if not self.creating: + kwargs['model_instance'] = instance + kwargs = self.make_mobile_row_form_kwargs(**kwargs) + form = factory(fields, schema, **kwargs) + self.configure_mobile_row_form(form) + return form + + @classmethod + def get_mobile_form_factory(cls): + """ + Returns the factory or class which is to be used when creating new + mobile forms. + """ + return getattr(cls, 'mobile_form_factory', forms.Form) + + @classmethod + def get_row_form_factory(cls): + """ + Returns the factory or class which is to be used when creating new row + forms. + """ + return getattr(cls, 'row_form_factory', forms.Form) + + @classmethod + def get_mobile_row_form_factory(cls): + """ + Returns the factory or class which is to be used when creating new + mobile row forms. + """ + return getattr(cls, 'mobile_row_form_factory', forms.Form) + + def make_mobile_form_schema(self): + if not self.model_class: + # TODO + raise NotImplementedError + + def make_row_form_schema(self): + if not self.model_row_class: + # TODO + raise NotImplementedError + + def make_mobile_row_form_schema(self): + if not self.model_row_class: + # TODO + raise NotImplementedError + + def get_mobile_form_fields(self): + if hasattr(self, 'mobile_form_fields'): + return self.mobile_form_fields + # TODO + # raise NotImplementedError + + def get_row_form_fields(self): + if hasattr(self, 'row_form_fields'): + return self.row_form_fields + # TODO + # raise NotImplementedError + + def get_mobile_row_form_fields(self): + if hasattr(self, 'mobile_row_form_fields'): + return self.mobile_row_form_fields + # TODO + # raise NotImplementedError + + def make_mobile_form_kwargs(self, **kwargs): + """ + Return a dictionary of kwargs to be passed to the factory when creating + new mobile forms. + """ + defaults = { + 'request': self.request, + 'readonly': self.viewing, + 'model_class': getattr(self, 'model_class', None), + 'action_url': self.request.current_route_url(_query=None), + } + if self.creating: + defaults['cancel_url'] = self.get_index_url(mobile=True) + else: + instance = kwargs['model_instance'] + defaults['cancel_url'] = self.get_action_url('view', instance, mobile=True) + defaults.update(kwargs) + return defaults + + def make_row_form_kwargs(self, **kwargs): + """ + Return a dictionary of kwargs to be passed to the factory when creating + new row forms. + """ + defaults = { + 'request': self.request, + 'readonly': self.viewing, + 'model_class': getattr(self, 'model_row_class', None), + 'action_url': self.request.current_route_url(_query=None), + } + if self.creating: + kwargs.setdefault('cancel_url', self.request.get_referrer()) + else: + instance = kwargs['model_instance'] + kwargs.setdefault('cancel_url', self.get_row_action_url('view', instance)) + defaults.update(kwargs) + return defaults + + def make_mobile_row_form_kwargs(self, **kwargs): + """ + Return a dictionary of kwargs to be passed to the factory when creating + new mobile row forms. + """ + defaults = { + 'request': self.request, + 'readonly': self.viewing, + 'model_class': getattr(self, 'model_row_class', None), + 'action_url': self.request.current_route_url(_query=None), + } + if self.creating: + defaults['cancel_url'] = self.request.get_referrer() + else: + instance = kwargs['model_instance'] + defaults['cancel_url'] = self.get_row_action_url('view', instance, mobile=True) + defaults.update(kwargs) + return defaults + + def configure_mobile_form(self, form): + """ + Configure the primary mobile form. + """ + # TODO: is any of this stuff from configure_form() needed? + # if self.editing: + # model_class = self.get_model_class(error=False) + # if model_class: + # mapper = orm.class_mapper(model_class) + # for key in mapper.primary_key: + # for field in form.fields: + # if field == key.name: + # form.set_readonly(field) + # break + # form.remove_field('uuid') + + self.set_labels(form) + + def configure_row_grid(self, grid): + super(MasterView4, self).configure_row_grid(grid) + self.set_row_labels(grid) + + def configure_row_form(self, form): + """ + Configure a row form. + """ + # TODO: is any of this stuff from configure_form() needed? + # if self.editing: + # model_class = self.get_model_class(error=False) + # if model_class: + # mapper = orm.class_mapper(model_class) + # for key in mapper.primary_key: + # for field in form.fields: + # if field == key.name: + # form.set_readonly(field) + # break + # form.remove_field('uuid') + + self.set_row_labels(form) + + def configure_mobile_row_form(self, form): + """ + Configure the mobile row form. + """ + # TODO: is any of this stuff from configure_form() needed? + # if self.editing: + # model_class = self.get_model_class(error=False) + # if model_class: + # mapper = orm.class_mapper(model_class) + # for key in mapper.primary_key: + # for field in form.fields: + # if field == key.name: + # form.set_readonly(field) + # break + # form.remove_field('uuid') + + self.set_row_labels(form) + + def set_row_labels(self, obj): + for key, label in self.row_labels.items(): + obj.set_label(key, label) + + def validate_mobile_form(self, form): + controls = self.request.POST.items() + try: + self.form_deserialized = form.validate(controls) + except deform.ValidationFailure: + return False + return True + + def validate_row_form(self, form): + controls = self.request.POST.items() + try: + self.form_deserialized = form.validate(controls) + except deform.ValidationFailure: + return False + return True + + def validate_mobile_row_form(self, form): + controls = self.request.POST.items() + try: + self.form_deserialized = form.validate(controls) + except deform.ValidationFailure: + return False + return True + + def save_mobile_create_form(self, form): + self.before_create(form) + with self.Session.no_autoflush: + obj = self.objectify(form, self.form_deserialized) + self.before_create_flush(obj, form) + self.Session.add(obj) + self.Session.flush() + return obj + + # TODO: still need to verify this logic + def save_create_row_form(self, form): + # self.before_create(form) + # with self.Session().no_autoflush: + # obj = self.objectify(form, self.form_deserialized) + # self.before_create_flush(obj, form) + obj = self.objectify(form, self.form_deserialized) + self.Session.add(obj) + self.Session.flush() + return obj + + def save_edit_row_form(self, form): + obj = self.objectify(form, self.form_deserialized) + self.after_edit_row(obj) + self.Session.flush() + return obj From 410ee8eb65e64c93a051239852d525c33b2ba5f3 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 3 Feb 2018 16:24:54 -0600 Subject: [PATCH 0682/3196] Add base master4 batch view --- tailbone/views/batch/__init__.py | 1 + tailbone/views/batch/core4.py | 127 +++++++++++++++++++++++++++++++ 2 files changed, 128 insertions(+) create mode 100644 tailbone/views/batch/core4.py diff --git a/tailbone/views/batch/__init__.py b/tailbone/views/batch/__init__.py index 50951b98..9a053ca2 100644 --- a/tailbone/views/batch/__init__.py +++ b/tailbone/views/batch/__init__.py @@ -29,3 +29,4 @@ from __future__ import unicode_literals, absolute_import from .core import BatchMasterView, FileBatchMasterView from .core2 import BatchMasterView2, FileBatchMasterView2 from .core3 import BatchMasterView3, FileBatchMasterView3 +from .core4 import BatchMasterView4, FileBatchMasterView4 diff --git a/tailbone/views/batch/core4.py b/tailbone/views/batch/core4.py new file mode 100644 index 00000000..6d9ac792 --- /dev/null +++ b/tailbone/views/batch/core4.py @@ -0,0 +1,127 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2018 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see . +# +################################################################################ +""" +Base views for maintaining batches +""" + +from __future__ import unicode_literals, absolute_import + +from tailbone.views import MasterView4 +from tailbone.views.batch import BatchMasterView3, FileBatchMasterView3 + + +class BatchMasterView4(MasterView4, BatchMasterView3): + """ + Base class for all "batch master" views + """ + + row_labels = { + 'status_code': "Status", + } + + def configure_mobile_form(self, f): + super(BatchMasterView4, self).configure_mobile_form(f) + batch = f.model_instance + + if self.creating: + f.remove_fields('id', + 'rowcount', + 'created', + 'created_by', + 'cognized', + 'cognized_by', + 'executed', + 'executed_by', + 'purge') + + else: # not creating + if not batch.executed: + f.remove_fields('executed', + 'executed_by') + if not batch.complete: + f.remove_field('complete') + + def save_mobile_create_form(self, form): + self.before_create(form) + session = self.Session() + with session.no_autoflush: + + # transfer form data to batch instance + batch = self.objectify(form, self.form_deserialized) + + # current user is batch creator + batch.created_by = self.request.user + + # TODO: is this still necessary with colander? + # destroy initial batch and re-make using handler + kwargs = self.get_batch_kwargs(batch) + if batch in session: + session.expunge(batch) + batch = self.handler.make_batch(session, **kwargs) + + session.flush() + return batch + + def configure_row_form(self, f): + super(BatchMasterView4, self).configure_row_form(f) + + # sequence + f.set_readonly('sequence') + + # status_code + if self.model_row_class: + f.set_enum('status_code', self.model_row_class.STATUS) + f.set_renderer('status_code', self.render_row_status) + f.set_readonly('status_code') + f.set_label('status_code', "Status") + + def configure_mobile_row_form(self, f): + super(BatchMasterView4, self).configure_mobile_row_form(f) + + # sequence + f.set_readonly('sequence') + + # status_code + if self.model_row_class: + f.set_enum('status_code', self.model_row_class.STATUS) + f.set_renderer('status_code', self.render_row_status) + f.set_readonly('status_code') + f.set_label('status_code', "Status") + + # NOTE: must override default logic here, by doing nothing + def before_create_row(self, form): + pass + + def save_create_row_form(self, form): + batch = self.get_instance() + row = self.objectify(form, self.form_deserialized) + self.handler.add_row(batch, row) + self.Session.flush() + return row + + +class FileBatchMasterView4(BatchMasterView4, FileBatchMasterView3): + """ + Base class for all file-based "batch master" views + """ + From 88fe195615d2ef75e94f73755919d9395c4acb4b Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 4 Feb 2018 10:46:27 -0600 Subject: [PATCH 0683/3196] Refactor all "easy" views per master4 --- tailbone/views/bouncer.py | 4 ++-- tailbone/views/brands.py | 4 ++-- tailbone/views/categories.py | 4 ++-- tailbone/views/customergroups.py | 4 ++-- tailbone/views/custorders/items.py | 2 +- tailbone/views/datasync.py | 4 ++-- tailbone/views/departments.py | 4 ++-- tailbone/views/depositlinks.py | 3 +-- tailbone/views/email.py | 2 +- tailbone/views/employees.py | 4 ++-- tailbone/views/exports.py | 4 ++-- tailbone/views/families.py | 2 +- tailbone/views/inventory.py | 2 +- tailbone/views/labels/profiles.py | 2 +- tailbone/views/messages.py | 4 ++-- tailbone/views/people.py | 4 ++-- tailbone/views/principal.py | 4 ++-- tailbone/views/products.py | 4 ++-- tailbone/views/purchases/credits.py | 2 +- tailbone/views/reportcodes.py | 2 +- tailbone/views/settings.py | 4 ++-- tailbone/views/shifts/core.py | 4 ++-- tailbone/views/stores.py | 4 ++-- tailbone/views/subdepartments.py | 4 ++-- tailbone/views/tables.py | 2 +- tailbone/views/taxes.py | 2 +- tailbone/views/tempmon/core.py | 2 +- tailbone/views/upgrades.py | 4 ++-- tailbone/views/users.py | 4 ++-- tailbone/views/vendors/catalogs.py | 2 +- tailbone/views/vendors/core.py | 4 ++-- tailbone/views/vendors/invoices.py | 2 +- 32 files changed, 51 insertions(+), 52 deletions(-) diff --git a/tailbone/views/bouncer.py b/tailbone/views/bouncer.py index 343d6da0..861f6a86 100644 --- a/tailbone/views/bouncer.py +++ b/tailbone/views/bouncer.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar +# Copyright © 2010-2018 Lance Edgar # # This file is part of Rattail. # @@ -37,7 +37,7 @@ from pyramid.response import FileResponse from webhelpers2.html import HTML, tags from tailbone import grids -from tailbone.views import MasterView3 as MasterView +from tailbone.views import MasterView4 as MasterView class EmailBouncesView(MasterView): diff --git a/tailbone/views/brands.py b/tailbone/views/brands.py index 40ecf83f..3a0b177f 100644 --- a/tailbone/views/brands.py +++ b/tailbone/views/brands.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar +# Copyright © 2010-2018 Lance Edgar # # This file is part of Rattail. # @@ -28,7 +28,7 @@ from __future__ import unicode_literals, absolute_import from rattail.db import model -from tailbone.views import MasterView3 as MasterView, AutocompleteView +from tailbone.views import MasterView4 as MasterView, AutocompleteView class BrandsView(MasterView): diff --git a/tailbone/views/categories.py b/tailbone/views/categories.py index c2a46a77..f5a7731e 100644 --- a/tailbone/views/categories.py +++ b/tailbone/views/categories.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar +# Copyright © 2010-2018 Lance Edgar # # This file is part of Rattail. # @@ -28,7 +28,7 @@ from __future__ import unicode_literals, absolute_import from rattail.db import model -from tailbone.views import MasterView3 as MasterView +from tailbone.views import MasterView4 as MasterView class CategoriesView(MasterView): diff --git a/tailbone/views/customergroups.py b/tailbone/views/customergroups.py index 74ba59c2..0ee806b2 100644 --- a/tailbone/views/customergroups.py +++ b/tailbone/views/customergroups.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar +# Copyright © 2010-2018 Lance Edgar # # This file is part of Rattail. # @@ -29,7 +29,7 @@ from __future__ import unicode_literals, absolute_import from rattail.db import model from tailbone.db import Session -from tailbone.views import MasterView3 as MasterView +from tailbone.views import MasterView4 as MasterView class CustomerGroupsView(MasterView): diff --git a/tailbone/views/custorders/items.py b/tailbone/views/custorders/items.py index 7963763f..e075e1d8 100644 --- a/tailbone/views/custorders/items.py +++ b/tailbone/views/custorders/items.py @@ -33,7 +33,7 @@ from sqlalchemy import orm from rattail.db import model from rattail.time import localtime -from tailbone.views import MasterView3 as MasterView +from tailbone.views import MasterView4 as MasterView from tailbone.util import raw_datetime diff --git a/tailbone/views/datasync.py b/tailbone/views/datasync.py index 0b568f01..6d602666 100644 --- a/tailbone/views/datasync.py +++ b/tailbone/views/datasync.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar +# Copyright © 2010-2018 Lance Edgar # # This file is part of Rattail. # @@ -31,7 +31,7 @@ import logging from rattail.db import model -from tailbone.views import MasterView3 as MasterView +from tailbone.views import MasterView4 as MasterView log = logging.getLogger(__name__) diff --git a/tailbone/views/departments.py b/tailbone/views/departments.py index 4fa33edd..29c6ea4e 100644 --- a/tailbone/views/departments.py +++ b/tailbone/views/departments.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar +# Copyright © 2010-2018 Lance Edgar # # This file is part of Rattail. # @@ -33,7 +33,7 @@ from rattail.db import model from deform import widget as dfwidget from tailbone import grids -from tailbone.views import MasterView3 as MasterView, AutocompleteView +from tailbone.views import MasterView4 as MasterView, AutocompleteView class DepartmentsView(MasterView): diff --git a/tailbone/views/depositlinks.py b/tailbone/views/depositlinks.py index 340cf818..9be6260c 100644 --- a/tailbone/views/depositlinks.py +++ b/tailbone/views/depositlinks.py @@ -28,8 +28,7 @@ from __future__ import unicode_literals, absolute_import from rattail.db import model -from tailbone import forms -from tailbone.views import MasterView3 as MasterView +from tailbone.views import MasterView4 as MasterView class DepositLinksView(MasterView): diff --git a/tailbone/views/email.py b/tailbone/views/email.py index e3240b9a..932840b4 100644 --- a/tailbone/views/email.py +++ b/tailbone/views/email.py @@ -37,7 +37,7 @@ from deform import widget as dfwidget from webhelpers2.html import HTML from tailbone.db import Session -from tailbone.views import View, MasterView3 as MasterView +from tailbone.views import View, MasterView4 as MasterView class ProfilesView(MasterView): diff --git a/tailbone/views/employees.py b/tailbone/views/employees.py index dd6626d9..42025eba 100644 --- a/tailbone/views/employees.py +++ b/tailbone/views/employees.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar +# Copyright © 2010-2018 Lance Edgar # # This file is part of Rattail. # @@ -37,7 +37,7 @@ from webhelpers2.html import tags, HTML from tailbone import grids from tailbone.db import Session -from tailbone.views import MasterView3 as MasterView, AutocompleteView +from tailbone.views import MasterView4 as MasterView, AutocompleteView class EmployeesView(MasterView): diff --git a/tailbone/views/exports.py b/tailbone/views/exports.py index 5f583013..8dc7c1ae 100644 --- a/tailbone/views/exports.py +++ b/tailbone/views/exports.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar +# Copyright © 2010-2018 Lance Edgar # # This file is part of Rattail. # @@ -36,7 +36,7 @@ from pyramid.response import FileResponse from webhelpers2.html import HTML, tags from tailbone import forms2 as forms -from tailbone.views import MasterView3 as MasterView +from tailbone.views import MasterView4 as MasterView class ExportMasterView(MasterView): diff --git a/tailbone/views/families.py b/tailbone/views/families.py index 02c370b6..72feba3b 100644 --- a/tailbone/views/families.py +++ b/tailbone/views/families.py @@ -28,7 +28,7 @@ from __future__ import unicode_literals, absolute_import from rattail.db import model -from tailbone.views import MasterView3 as MasterView +from tailbone.views import MasterView4 as MasterView class FamiliesView(MasterView): diff --git a/tailbone/views/inventory.py b/tailbone/views/inventory.py index 7b20e452..83a2474d 100644 --- a/tailbone/views/inventory.py +++ b/tailbone/views/inventory.py @@ -42,7 +42,7 @@ import formencode as fe from webhelpers2.html import HTML, tags from tailbone import forms, grids -from tailbone.views import MasterView3 as MasterView +from tailbone.views import MasterView4 as MasterView from tailbone.views.batch import BatchMasterView3 as BatchMasterView diff --git a/tailbone/views/labels/profiles.py b/tailbone/views/labels/profiles.py index 7a71913e..d2b821dc 100644 --- a/tailbone/views/labels/profiles.py +++ b/tailbone/views/labels/profiles.py @@ -31,7 +31,7 @@ from rattail.db import model from pyramid.httpexceptions import HTTPFound from tailbone.db import Session -from tailbone.views import MasterView3 as MasterView +from tailbone.views import MasterView4 as MasterView class ProfilesView(MasterView): diff --git a/tailbone/views/messages.py b/tailbone/views/messages.py index 8b978ad2..d216e8ce 100644 --- a/tailbone/views/messages.py +++ b/tailbone/views/messages.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar +# Copyright © 2010-2018 Lance Edgar # # This file is part of Rattail. # @@ -37,7 +37,7 @@ from pyramid import httpexceptions from webhelpers2.html import tags, HTML from tailbone.db import Session -from tailbone.views import MasterView3 as MasterView +from tailbone.views import MasterView4 as MasterView from tailbone.util import raw_datetime diff --git a/tailbone/views/people.py b/tailbone/views/people.py index 65b9a8e8..3a3f1773 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar +# Copyright © 2010-2018 Lance Edgar # # This file is part of Rattail. # @@ -34,7 +34,7 @@ from rattail.db import model, api from pyramid.httpexceptions import HTTPFound, HTTPNotFound from webhelpers2.html import HTML, tags -from tailbone.views import MasterView3 as MasterView, AutocompleteView +from tailbone.views import MasterView4 as MasterView, AutocompleteView class PeopleView(MasterView): diff --git a/tailbone/views/principal.py b/tailbone/views/principal.py index f68f1036..494fda2d 100644 --- a/tailbone/views/principal.py +++ b/tailbone/views/principal.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar +# Copyright © 2010-2018 Lance Edgar # # This file is part of Rattail. # @@ -35,7 +35,7 @@ import wtforms from webhelpers2.html import HTML from tailbone.db import Session -from tailbone.views import MasterView3 as MasterView +from tailbone.views import MasterView4 as MasterView class PrincipalMasterView(MasterView): diff --git a/tailbone/views/products.py b/tailbone/views/products.py index bdb46be8..f8e07a6d 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar +# Copyright © 2010-2018 Lance Edgar # # This file is part of Rattail. # @@ -47,7 +47,7 @@ from webhelpers2.html import tags, HTML from tailbone import forms2 as forms, grids from tailbone.db import Session -from tailbone.views import MasterView3 as MasterView, AutocompleteView +from tailbone.views import MasterView4 as MasterView, AutocompleteView from tailbone.progress import SessionProgress from tailbone.util import raw_datetime diff --git a/tailbone/views/purchases/credits.py b/tailbone/views/purchases/credits.py index b32c6172..2188df3d 100644 --- a/tailbone/views/purchases/credits.py +++ b/tailbone/views/purchases/credits.py @@ -31,7 +31,7 @@ from rattail.db import model from webhelpers2.html import tags from tailbone import grids -from tailbone.views import MasterView3 as MasterView +from tailbone.views import MasterView4 as MasterView class PurchaseCreditView(MasterView): diff --git a/tailbone/views/reportcodes.py b/tailbone/views/reportcodes.py index c60dbfe9..db82a743 100644 --- a/tailbone/views/reportcodes.py +++ b/tailbone/views/reportcodes.py @@ -28,7 +28,7 @@ from __future__ import unicode_literals, absolute_import from rattail.db import model -from tailbone.views import MasterView3 as MasterView +from tailbone.views import MasterView4 as MasterView class ReportCodesView(MasterView): diff --git a/tailbone/views/settings.py b/tailbone/views/settings.py index 7c1b021c..5eff7d34 100644 --- a/tailbone/views/settings.py +++ b/tailbone/views/settings.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar +# Copyright © 2010-2018 Lance Edgar # # This file is part of Rattail. # @@ -32,7 +32,7 @@ from rattail.db import model import colander -from tailbone.views import MasterView3 as MasterView +from tailbone.views import MasterView4 as MasterView class SettingsView(MasterView): diff --git a/tailbone/views/shifts/core.py b/tailbone/views/shifts/core.py index 10d9f3e3..0a15aa05 100644 --- a/tailbone/views/shifts/core.py +++ b/tailbone/views/shifts/core.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar +# Copyright © 2010-2018 Lance Edgar # # This file is part of Rattail. # @@ -33,7 +33,7 @@ import humanize from rattail.db import model from rattail.time import localtime -from tailbone.views import MasterView3 as MasterView +from tailbone.views import MasterView4 as MasterView def render_shift_length(shift, field): diff --git a/tailbone/views/stores.py b/tailbone/views/stores.py index bf8f2a6e..7fd989b4 100644 --- a/tailbone/views/stores.py +++ b/tailbone/views/stores.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar +# Copyright © 2010-2018 Lance Edgar # # This file is part of Rattail. # @@ -32,7 +32,7 @@ from rattail.db import model import colander -from tailbone.views import MasterView3 as MasterView +from tailbone.views import MasterView4 as MasterView class StoresView(MasterView): diff --git a/tailbone/views/subdepartments.py b/tailbone/views/subdepartments.py index 36ae1b53..31b6a43d 100644 --- a/tailbone/views/subdepartments.py +++ b/tailbone/views/subdepartments.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar +# Copyright © 2010-2018 Lance Edgar # # This file is part of Rattail. # @@ -29,7 +29,7 @@ from __future__ import unicode_literals, absolute_import from rattail.db import model from tailbone.db import Session -from tailbone.views import MasterView3 as MasterView +from tailbone.views import MasterView4 as MasterView class SubdepartmentsView(MasterView): diff --git a/tailbone/views/tables.py b/tailbone/views/tables.py index 4fb08008..e8ddfc77 100644 --- a/tailbone/views/tables.py +++ b/tailbone/views/tables.py @@ -26,7 +26,7 @@ Views with info about the underlying Rattail tables from __future__ import unicode_literals, absolute_import -from tailbone.views import MasterView3 as MasterView +from tailbone.views import MasterView4 as MasterView class TablesView(MasterView): diff --git a/tailbone/views/taxes.py b/tailbone/views/taxes.py index 1f53de5e..4a26dd35 100644 --- a/tailbone/views/taxes.py +++ b/tailbone/views/taxes.py @@ -28,7 +28,7 @@ from __future__ import unicode_literals, absolute_import from rattail.db import model -from tailbone.views import MasterView3 as MasterView +from tailbone.views import MasterView4 as MasterView class TaxesView(MasterView): diff --git a/tailbone/views/tempmon/core.py b/tailbone/views/tempmon/core.py index f32c596a..11a8cb55 100644 --- a/tailbone/views/tempmon/core.py +++ b/tailbone/views/tempmon/core.py @@ -32,7 +32,7 @@ from tailbone import views from tailbone.db import TempmonSession -class MasterView(views.MasterView3): +class MasterView(views.MasterView4): """ Base class for tempmon views. """ diff --git a/tailbone/views/upgrades.py b/tailbone/views/upgrades.py index 8b9cd265..13447ff7 100644 --- a/tailbone/views/upgrades.py +++ b/tailbone/views/upgrades.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar +# Copyright © 2010-2018 Lance Edgar # # This file is part of Rattail. # @@ -43,7 +43,7 @@ from rattail.upgrades import get_upgrade_handler from deform import widget as dfwidget from webhelpers2.html import tags, HTML -from tailbone.views import MasterView3 as MasterView +from tailbone.views import MasterView4 as MasterView from tailbone.progress import SessionProgress, get_progress_session diff --git a/tailbone/views/users.py b/tailbone/views/users.py index b98b485b..48e05315 100644 --- a/tailbone/views/users.py +++ b/tailbone/views/users.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar +# Copyright © 2010-2018 Lance Edgar # # This file is part of Rattail. # @@ -40,7 +40,7 @@ from webhelpers2.html import HTML, tags from tailbone import forms2 as forms from tailbone.db import Session -from tailbone.views import MasterView3 as MasterView +from tailbone.views import MasterView4 as MasterView from tailbone.views.principal import PrincipalMasterView, PermissionsRenderer diff --git a/tailbone/views/vendors/catalogs.py b/tailbone/views/vendors/catalogs.py index 632601a9..16e8417a 100644 --- a/tailbone/views/vendors/catalogs.py +++ b/tailbone/views/vendors/catalogs.py @@ -39,7 +39,7 @@ from webhelpers2.html import tags from tailbone import forms2 as forms from tailbone.db import Session -from tailbone.views.batch import FileBatchMasterView3 as FileBatchMasterView +from tailbone.views.batch import FileBatchMasterView4 as FileBatchMasterView log = logging.getLogger(__name__) diff --git a/tailbone/views/vendors/core.py b/tailbone/views/vendors/core.py index 34869422..c36bcb25 100644 --- a/tailbone/views/vendors/core.py +++ b/tailbone/views/vendors/core.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar +# Copyright © 2010-2018 Lance Edgar # # This file is part of Rattail. # @@ -32,7 +32,7 @@ from rattail.db import model from webhelpers2.html import tags -from tailbone.views import MasterView3 as MasterView, AutocompleteView +from tailbone.views import MasterView4 as MasterView, AutocompleteView class VendorsView(MasterView): diff --git a/tailbone/views/vendors/invoices.py b/tailbone/views/vendors/invoices.py index 7ee6ed6e..ad7dee11 100644 --- a/tailbone/views/vendors/invoices.py +++ b/tailbone/views/vendors/invoices.py @@ -33,7 +33,7 @@ from rattail.vendors.invoices import iter_invoice_parsers, require_invoice_parse import colander from deform import widget as dfwidget -from tailbone.views.batch import FileBatchMasterView3 as FileBatchMasterView +from tailbone.views.batch import FileBatchMasterView4 as FileBatchMasterView class VendorInvoicesView(FileBatchMasterView): From 0737faa034a6f72fb0fc9e9d407892bf67f78f83 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 4 Feb 2018 10:58:30 -0600 Subject: [PATCH 0684/3196] Refactor label batch views per master4 --- tailbone/views/labels/batch.py | 140 +++++++++++++++++---------------- 1 file changed, 73 insertions(+), 67 deletions(-) diff --git a/tailbone/views/labels/batch.py b/tailbone/views/labels/batch.py index e08a6a17..663d3416 100644 --- a/tailbone/views/labels/batch.py +++ b/tailbone/views/labels/batch.py @@ -30,7 +30,7 @@ from rattail.db import model from webhelpers2.html import HTML, tags -from tailbone.views.batch import BatchMasterView3 as BatchMasterView +from tailbone.views.batch import BatchMasterView4 as BatchMasterView class LabelBatchView(BatchMasterView): @@ -76,6 +76,37 @@ class LabelBatchView(BatchMasterView): 'executed_by', ] + row_labels = { + 'upc': "UPC", + 'vendor_id': "Vendor ID", + 'label_profile': "Label Type", + } + + row_form_fields = [ + 'sequence', + 'upc', + 'product', + 'brand_name', + 'description', + 'size', + 'department_number', + 'department_name', + 'regular_price', + 'pack_quantity', + 'pack_price', + 'sale_price', + 'sale_start', + 'sale_stop', + 'vendor_id', + 'vendor_name', + 'vendor_item_code', + 'case_quantity', + 'label_profile', + 'label_quantity', + 'status_code', + 'status_text', + ] + def configure_form(self, f): super(LabelBatchView, self).configure_form(f) @@ -98,82 +129,57 @@ class LabelBatchView(BatchMasterView): def configure_row_grid(self, g): super(LabelBatchView, self).configure_row_grid(g) - g.set_label('upc', "UPC") + + # short labels g.set_label('brand_name', "Brand") g.set_label('regular_price', "Reg Price") - g.set_label('label_profile', "Label Type") g.set_label('label_quantity', "Qty") def row_grid_extra_class(self, row, i): if row.status_code != row.STATUS_OK: return 'warning' - def _preconfigure_row_fieldset(self, fs): - fs.sequence.set(readonly=True) - fs.product.set(readonly=True) - fs.upc.set(readonly=True, label="UPC") - fs.brand_name.set(readonly=True) - fs.description.set(readonly=True) - fs.size.set(readonly=True) - fs.department_number.set(readonly=True) - fs.department_name.set(readonly=True) - fs.regular_price.set(readonly=True) - fs.pack_quantity.set(readonly=True) - fs.pack_price.set(readonly=True) - fs.sale_price.set(readonly=True) - fs.sale_start.set(readonly=True) - fs.sale_stop.set(readonly=True) - fs.vendor_id.set(readonly=True, label="Vendor ID") - fs.vendor_name.set(readonly=True) - fs.vendor_item_code.set(readonly=True) - fs.case_quantity.set(readonly=True) - fs.status_code.set(readonly=True) - fs.status_text.set(readonly=True) + def configure_row_form(self, f): + super(LabelBatchView, self).configure_row_form(f) - fs.label_profile.set(label="Label Type") + # readonly fields + f.set_readonly('sequence') + f.set_readonly('product') + f.set_readonly('upc') + f.set_readonly('brand_name') + f.set_readonly('description') + f.set_readonly('size') + f.set_readonly('department_number') + f.set_readonly('department_name') + f.set_readonly('regular_price') + f.set_readonly('pack_quantity') + f.set_readonly('pack_price') + f.set_readonly('sale_price') + f.set_readonly('sale_start') + f.set_readonly('sale_stop') + f.set_readonly('vendor_id') + f.set_readonly('vendor_name') + f.set_readonly('vendor_item_code') + f.set_readonly('case_quantity') + f.set_readonly('status_code') + f.set_readonly('status_text') - def configure_row_fieldset(self, fs): - if self.viewing: - fs.configure( - include=[ - fs.sequence, - fs.upc, - fs.brand_name, - fs.description, - fs.size, - fs.department_number, - fs.department_name, - fs.regular_price, - fs.pack_quantity, - fs.pack_price, - fs.sale_price, - fs.sale_start, - fs.sale_stop, - fs.vendor_id, - fs.vendor_name, - fs.vendor_item_code, - fs.case_quantity, - fs.label_profile, - fs.label_quantity, - fs.status_code, - fs.status_text, - ]) - - elif self.editing: - fs.configure( - include=[ - fs.sequence, - fs.upc, - fs.product, - fs.department_number, - fs.department_name, - fs.regular_price, - fs.sale_price, - fs.label_profile, - fs.label_quantity, - fs.status_code, - fs.status_text, - ]) + if self.editing: + f.remove_fields( + 'brand_name', + 'description', + 'size', + 'pack_quantity', + 'pack_price', + 'sale_start', + 'sale_stop', + 'vendor_id', + 'vendor_name', + 'vendor_item_code', + 'case_quantity', + ) + else: + f.remove_field('product') def includeme(config): From 7cee515187d5485b6b21f8aa046bc49223164262 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 4 Feb 2018 11:09:35 -0600 Subject: [PATCH 0685/3196] Refactor handheld batch views per master4 --- tailbone/views/handheld.py | 58 +++++++++++++++++++++++--------------- 1 file changed, 36 insertions(+), 22 deletions(-) diff --git a/tailbone/views/handheld.py b/tailbone/views/handheld.py index bea1c378..015c7e41 100644 --- a/tailbone/views/handheld.py +++ b/tailbone/views/handheld.py @@ -34,9 +34,8 @@ from rattail.util import OrderedDict import formencode as fe from webhelpers2.html import tags -from tailbone import forms from tailbone.db import Session -from tailbone.views.batch import FileBatchMasterView3 as FileBatchMasterView +from tailbone.views.batch import FileBatchMasterView4 as FileBatchMasterView ACTION_OPTIONS = OrderedDict([ @@ -92,6 +91,10 @@ class HandheldBatchView(FileBatchMasterView): 'executed_by', ] + row_labels = { + 'upc': "UPC", + } + row_grid_columns = [ 'sequence', 'upc', @@ -103,6 +106,17 @@ class HandheldBatchView(FileBatchMasterView): 'status_code', ] + row_form_fields = [ + 'sequence', + 'upc', + 'brand_name', + 'description', + 'size', + 'status_code', + 'cases', + 'units', + ] + def configure_grid(self, g): super(HandheldBatchView, self).configure_grid(g) device_types = OrderedDict(sorted(self.enum.HANDHELD_DEVICE_TYPE.items(), @@ -153,33 +167,33 @@ class HandheldBatchView(FileBatchMasterView): super(HandheldBatchView, self).configure_row_grid(g) g.set_type('cases', 'quantity') g.set_type('units', 'quantity') - g.set_label('upc', "UPC") g.set_label('brand_name', "Brand") def row_grid_extra_class(self, row, i): if row.status_code == row.STATUS_PRODUCT_NOT_FOUND: return 'warning' - def _preconfigure_row_fieldset(self, fs): - super(HandheldBatchView, self)._preconfigure_row_fieldset(fs) - fs.upc.set(readonly=True, label="UPC", renderer=forms.renderers.GPCFieldRenderer, - attrs={'link': lambda r: self.request.route_url('products.view', uuid=r.product_uuid)}) - fs.brand_name.set(readonly=True) - fs.description.set(readonly=True) - fs.size.set(readonly=True) + def configure_row_form(self, f): + super(HandheldBatchView, self).configure_row_form(f) - def configure_row_fieldset(self, fs): - fs.configure( - include=[ - fs.sequence, - fs.upc, - fs.brand_name, - fs.description, - fs.size, - fs.status_code, - fs.cases, - fs.units, - ]) + # readonly fields + f.set_readonly('upc') + f.set_readonly('brand_name') + f.set_readonly('description') + f.set_readonly('size') + + # upc + f.set_renderer('upc', self.render_upc) + + def render_upc(self, row, field): + upc = row.upc + if not upc: + return "" + text = upc.pretty() + if row.product_uuid: + url = self.request.route_url('products.view', uuid=row.product_uuid) + return tags.link_to(text, url) + return text def get_exec_options_kwargs(self, **kwargs): kwargs['ACTION_OPTIONS'] = list(ACTION_OPTIONS.iteritems()) From 533b491124fa78fbffcacee3b6a7f7677ad29b1e Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 4 Feb 2018 12:04:27 -0600 Subject: [PATCH 0686/3196] Refactor purchase views per master4 --- tailbone/views/purchases/core.py | 76 ++++++++++++++++++-------------- 1 file changed, 42 insertions(+), 34 deletions(-) diff --git a/tailbone/views/purchases/core.py b/tailbone/views/purchases/core.py index 0659647b..c8d0ddc8 100644 --- a/tailbone/views/purchases/core.py +++ b/tailbone/views/purchases/core.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar +# Copyright © 2010-2018 Lance Edgar # # This file is part of Rattail. # @@ -28,12 +28,10 @@ from __future__ import unicode_literals, absolute_import from rattail.db import model -import formalchemy as fa from webhelpers2.html import HTML, tags -from tailbone import forms from tailbone.db import Session -from tailbone.views import MasterView3 as MasterView +from tailbone.views import MasterView4 as MasterView class PurchaseView(MasterView): @@ -77,6 +75,13 @@ class PurchaseView(MasterView): 'batches', ] + row_labels = { + 'vendor_code': "Vendor Item Code", + 'upc': "UPC", + 'po_unit_cost': "PO Unit Cost", + 'po_total': "PO Total", + } + row_grid_columns = [ 'sequence', 'upc', @@ -92,6 +97,27 @@ class PurchaseView(MasterView): 'invoice_total', ] + row_form_fields = [ + 'sequence', + 'vendor_code', + 'upc', + 'product', + 'department', + 'case_quantity', + 'cases_ordered', + 'units_ordered', + 'cases_received', + 'units_received', + 'cases_damaged', + 'units_damaged', + 'cases_expired', + 'units_expired', + 'po_unit_cost', + 'po_total', + 'invoice_unit_cost', + 'invoice_total', + ] + def get_instance_title(self, purchase): if purchase.status >= self.enum.PURCHASE_STATUS_COSTED: if purchase.invoice_date: @@ -268,38 +294,20 @@ class PurchaseView(MasterView): self.enum.PURCHASE_STATUS_COSTED): g.hide_column('po_total') - def _preconfigure_row_fieldset(self, fs): - fs.vendor_code.set(label="Vendor Item Code") - fs.upc.set(label="UPC") - fs.po_unit_cost.set(label="PO Unit Cost", renderer=forms.renderers.CurrencyFieldRenderer) - fs.po_total.set(label="PO Total", renderer=forms.renderers.CurrencyFieldRenderer) - fs.invoice_unit_cost.set(renderer=forms.renderers.CurrencyFieldRenderer) - fs.invoice_total.set(renderer=forms.renderers.CurrencyFieldRenderer) - fs.append(fa.Field('department', value=lambda i: '{} {}'.format(i.department_number, i.department_name))) + def configure_row_form(self, f): + super(PurchaseView, self).configure_row_form(f) - def configure_row_fieldset(self, fs): + # currency fields + f.set_type('po_unit_cost', 'currency') + f.set_type('po_total', 'currency') + f.set_type('invoice_unit_cost', 'currency') + f.set_type('invoice_total', 'currency') - fs.configure( - include=[ - fs.sequence, - fs.vendor_code, - fs.upc, - fs.product, - fs.department, - fs.case_quantity, - fs.cases_ordered, - fs.units_ordered, - fs.cases_received, - fs.units_received, - fs.cases_damaged, - fs.units_damaged, - fs.cases_expired, - fs.units_expired, - fs.po_unit_cost, - fs.po_total, - fs.invoice_unit_cost, - fs.invoice_total, - ]) + # department + f.set_renderer('department', self.render_row_department) + + def render_row_department(self, row, field): + return "{} {}".format(row.department_number, row.department_name) def receiving_worksheet(self): purchase = self.get_instance() From e78d1ac3c105260465ba6f6925c79be9c32620b0 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 4 Feb 2018 12:30:51 -0600 Subject: [PATCH 0687/3196] Refactor inventory batch views per master4 --- tailbone/views/inventory.py | 172 +++++++++++++++++++++--------------- 1 file changed, 99 insertions(+), 73 deletions(-) diff --git a/tailbone/views/inventory.py b/tailbone/views/inventory.py index 83a2474d..e0e0976d 100644 --- a/tailbone/views/inventory.py +++ b/tailbone/views/inventory.py @@ -39,11 +39,12 @@ from rattail.util import pretty_quantity import colander import formencode as fe +from deform import widget as dfwidget from webhelpers2.html import HTML, tags -from tailbone import forms, grids +from tailbone import forms, forms2, grids from tailbone.views import MasterView4 as MasterView -from tailbone.views.batch import BatchMasterView3 as BatchMasterView +from tailbone.views.batch import BatchMasterView4 as BatchMasterView class InventoryAdjustmentReasonsView(MasterView): @@ -90,6 +91,10 @@ class InventoryBatchView(BatchMasterView): mobile_creatable = True mobile_rows_creatable = True + labels = { + 'mode': "Count Mode", + } + grid_columns = [ 'id', 'created', @@ -108,6 +113,16 @@ class InventoryBatchView(BatchMasterView): 'created', 'created_by', 'handheld_batches', + 'mode', + 'reason_code', + 'total_cost', + 'rowcount', + 'complete', + 'executed', + 'executed_by', + ] + + mobile_form_fields = [ 'mode', 'reason_code', 'rowcount', @@ -119,6 +134,11 @@ class InventoryBatchView(BatchMasterView): model_row_class = model.InventoryBatchRow rows_editable = True + row_labels = { + 'upc': "UPC", + 'previous_units_on_hand': "Prev. On Hand", + } + row_grid_columns = [ 'sequence', 'upc', @@ -134,6 +154,21 @@ class InventoryBatchView(BatchMasterView): 'status_code', ] + row_form_fields = [ + 'sequence', + 'upc', + 'brand_name', + 'description', + 'size', + 'status_code', + 'previous_units_on_hand', + 'case_quantity', + 'cases', + 'units', + 'unit_cost', + 'total_cost', + ] + def configure_grid(self, g): super(InventoryBatchView, self).configure_grid(g) @@ -141,7 +176,6 @@ class InventoryBatchView(BatchMasterView): g.set_enum('mode', self.enum.INVENTORY_MODE) g.filters['mode'].set_value_renderer( grids.filters.EnumValueRenderer(self.enum.INVENTORY_MODE)) - g.set_label('mode', "Count Mode") # total_cost g.set_type('total_cost', 'currency') @@ -162,10 +196,8 @@ class InventoryBatchView(BatchMasterView): def allow_worksheet(self, batch): return self.mutable_batch(batch) - def configure_form(self, f): - super(InventoryBatchView, self).configure_form(f) + def get_available_modes(self): permission_prefix = self.get_permission_prefix() - modes = dict(self.enum.INVENTORY_MODE) if not self.request.has_perm('{}.create.replace'.format(permission_prefix)): if hasattr(self.enum, 'INVENTORY_MODE_REPLACE'): @@ -175,16 +207,24 @@ class InventoryBatchView(BatchMasterView): if not self.request.has_perm('{}.create.zero'.format(permission_prefix)): if hasattr(self.enum, 'INVENTORY_MODE_ZERO_ALL'): modes.pop(self.enum.INVENTORY_MODE_ZERO_ALL, None) + return modes + + def configure_form(self, f): + super(InventoryBatchView, self).configure_form(f) # mode + modes = self.get_available_modes() f.set_enum('mode', modes) f.set_label('mode', "Count Mode") if len(modes) == 1: f.set_readonly('mode') # total_cost - f.set_readonly('total_cost') - f.set_type('total_cost', 'currency') + if self.creating: + f.remove_field('total_cost') + else: + f.set_readonly('total_cost') + f.set_type('total_cost', 'currency') # handheld_batches f.set_readonly('handheld_batches') @@ -205,7 +245,7 @@ class InventoryBatchView(BatchMasterView): return self.mutable_batch(row.batch) def save_edit_row_form(self, form): - row = form.fieldset.model + row = form.model_instance batch = row.batch if batch.total_cost is not None and row.total_cost is not None: batch.total_cost -= row.total_cost @@ -220,40 +260,19 @@ class InventoryBatchView(BatchMasterView): batch.total_cost -= row.total_cost return super(InventoryBatchView, self).delete_row() - def configure_mobile_fieldset(self, fs): - permission_prefix = self.get_permission_prefix() + def configure_mobile_form(self, f): + super(InventoryBatchView, self).configure_mobile_form(f) + batch = f.model_instance - # TODO: this was copied from configure_form() - modes = dict(self.enum.INVENTORY_MODE) - if not self.request.has_perm('{}.create.replace'.format(permission_prefix)): - if hasattr(self.enum, 'INVENTORY_MODE_REPLACE'): - modes.pop(self.enum.INVENTORY_MODE_REPLACE, None) - if hasattr(self.enum, 'INVENTORY_MODE_REPLACE_ADJUST'): - modes.pop(self.enum.INVENTORY_MODE_REPLACE_ADJUST, None) - if not self.request.has_perm('{}.create.zero'.format(permission_prefix)): - if hasattr(self.enum, 'INVENTORY_MODE_ZERO_ALL'): - modes.pop(self.enum.INVENTORY_MODE_ZERO_ALL, None) + # mode + modes = self.get_available_modes() + f.set_enum('mode', modes) + mode_values = [(k, v) for k, v in sorted(modes.items())] + f.set_widget('mode', forms2.widgets.PlainSelectWidget(values=mode_values)) - fs.mode.set(renderer=forms.renderers.EnumFieldRenderer(modes), - label="Count Mode", required=True, attrs={'auto-enhance': 'true'}) - - fs.configure(include=[ - fs.mode, - fs.reason_code, - fs.rowcount, - fs.complete, - fs.executed, - fs.executed_by, - ]) - batch = fs.model - if self.creating: - del fs.rowcount - if not batch.executed: - del [fs.executed, fs.executed_by] - if not batch.complete: - del fs.complete - else: - del fs.complete + # complete + if self.creating or batch.executed or not batch.complete: + f.remove_field('complete') # TODO: document this, maybe move it etc. unknown_product_creates_row = True @@ -371,17 +390,20 @@ class InventoryBatchView(BatchMasterView): def configure_row_grid(self, g): super(InventoryBatchView, self).configure_row_grid(g) + # quantity fields g.set_type('previous_units_on_hand', 'quantity') g.set_type('cases', 'quantity') g.set_type('units', 'quantity') + + # currency fields g.set_type('unit_cost', 'currency') g.set_type('total_cost', 'currency') - g.set_label('upc', "UPC") + # short labels g.set_label('brand_name', "Brand") g.set_label('status_code', "Status") - g.set_label('previous_units_on_hand', "Prev. On Hand") + # links g.set_link('upc') g.set_link('item_id') g.set_link('description') @@ -396,37 +418,41 @@ class InventoryBatchView(BatchMasterView): qty = "{} {}".format(pretty_quantity(row.cases or row.units), 'CS' if row.cases else unit_uom) return "({}) {} - {}".format(row.upc.pretty(), description, qty) - def _preconfigure_row_fieldset(self, fs): - super(InventoryBatchView, self)._preconfigure_row_fieldset(fs) - fs.upc.set(readonly=True, label="UPC", renderer=forms.renderers.GPCFieldRenderer, - attrs={'link': lambda r: self.request.route_url('products.view', uuid=r.product_uuid)}) - fs.item_id.set(readonly=True) - fs.brand_name.set(readonly=True) - fs.description.set(readonly=True) - fs.size.set(readonly=True) - fs.previous_units_on_hand.set(label="Prev. On Hand") - fs.case_quantity.set(renderer=forms.renderers.QuantityFieldRenderer) - fs.cases.set(renderer=forms.renderers.QuantityFieldRenderer) - fs.units.set(renderer=forms.renderers.QuantityFieldRenderer) - fs.unit_cost.set(readonly=True, renderer=forms.renderers.CurrencyFieldRenderer) - fs.total_cost.set(readonly=True, renderer=forms.renderers.CurrencyFieldRenderer) + def configure_row_form(self, f): + super(InventoryBatchView, self).configure_row_form(f) - def configure_row_fieldset(self, fs): - fs.configure( - include=[ - fs.sequence, - fs.upc, - fs.brand_name, - fs.description, - fs.size, - fs.status_code, - fs.previous_units_on_hand, - fs.case_quantity, - fs.cases, - fs.units, - fs.unit_cost, - fs.total_cost, - ]) + # readonly fields + f.set_readonly('upc') + f.set_readonly('item_id') + f.set_readonly('brand_name') + f.set_readonly('description') + f.set_readonly('size') + f.set_readonly('previous_units_on_hand') + f.set_readonly('case_quantity') + f.set_readonly('unit_cost') + f.set_readonly('total_cost') + + # quantity fields + f.set_type('case_quantity', 'quantity') + f.set_type('cases', 'quantity') + f.set_type('units', 'quantity') + + # currency fields + f.set_type('unit_cost', 'currency') + f.set_type('total_cost', 'currency') + + # upc + f.set_renderer('upc', self.render_upc) + + def render_upc(self, row, field): + upc = row.upc + if not upc: + return "" + text = upc.pretty() + if row.product_uuid: + url = self.request.route_url('products.view', uuid=row.product_uuid) + return tags.link_to(text, url) + return text @classmethod def defaults(cls, config): From 38afb35b65f8ce28285bb1697faed9cfaadcca44 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 4 Feb 2018 12:44:57 -0600 Subject: [PATCH 0688/3196] Refactor pricing batch views per master4 --- tailbone/views/batch/pricing.py | 76 +++++++++++++++++++-------------- 1 file changed, 45 insertions(+), 31 deletions(-) diff --git a/tailbone/views/batch/pricing.py b/tailbone/views/batch/pricing.py index 6d947bc4..a378614c 100644 --- a/tailbone/views/batch/pricing.py +++ b/tailbone/views/batch/pricing.py @@ -28,8 +28,9 @@ from __future__ import unicode_literals, absolute_import from rattail.db import model -from tailbone import forms -from tailbone.views.batch import BatchMasterView3 as BatchMasterView +from webhelpers2.html import tags + +from tailbone.views.batch import BatchMasterView4 as BatchMasterView class PricingBatchView(BatchMasterView): @@ -73,6 +74,10 @@ class PricingBatchView(BatchMasterView): 'executed_by', ] + row_labels = { + 'upc': "UPC", + } + row_grid_columns = [ 'sequence', 'upc', @@ -88,6 +93,27 @@ class PricingBatchView(BatchMasterView): 'status_code', ] + row_form_fields = [ + 'sequence', + 'product', + 'upc', + 'brand_name', + 'description', + 'size', + 'department_number', + 'department_name', + 'vendor', + 'regular_unit_cost', + 'discounted_unit_cost', + 'old_price', + 'new_price', + 'price_diff', + 'price_margin', + 'price_markup', + 'status_code', + 'status_text', + ] + def configure_row_grid(self, g): super(PricingBatchView, self).configure_row_grid(g) @@ -109,36 +135,24 @@ class PricingBatchView(BatchMasterView): if row.status_code in (row.STATUS_PRICE_INCREASE, row.STATUS_PRICE_DECREASE): return 'notice' - def _preconfigure_row_fieldset(self, fs): - super(PricingBatchView, self)._preconfigure_row_fieldset(fs) - fs.upc.set(label="UPC") - fs.vendor.set(renderer=forms.renderers.VendorFieldRenderer) - fs.old_price.set(renderer=forms.renderers.CurrencyFieldRenderer) - fs.new_price.set(renderer=forms.renderers.CurrencyFieldRenderer) - fs.price_diff.set(renderer=forms.renderers.CurrencyFieldRenderer) + def configure_row_form(self, f): + super(PricingBatchView, self).configure_row_form(f) - def configure_row_fieldset(self, fs): - fs.configure( - include=[ - fs.sequence, - fs.product, - fs.upc, - fs.brand_name, - fs.description, - fs.size, - fs.department_number, - fs.department_name, - fs.vendor, - fs.regular_unit_cost, - fs.discounted_unit_cost, - fs.old_price, - fs.new_price, - fs.price_diff, - fs.price_margin, - fs.price_markup, - fs.status_code, - fs.status_text, - ]) + # currency fields + f.set_type('old_price', 'currency') + f.set_type('new_price', 'currency') + f.set_type('price_diff', 'currency') + + # vendor + f.set_renderer('vendor', self.render_vendor) + + def render_vendor(self, row, field): + vendor = row.vendor + if not vendor: + return "" + text = "({}) {}".format(vendor.id, vendor.name) + url = self.request.route_url('vendors.view', uuid=vendor.uuid) + return tags.link_to(text, url) def includeme(config): From 4ab41ba82e1e2e088365806f8fbf81e3f45669bd Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 4 Feb 2018 12:54:20 -0600 Subject: [PATCH 0689/3196] Refactor trainwreck views per master4 --- tailbone/views/trainwreck.py | 97 +++++++++++++++++++----------------- 1 file changed, 50 insertions(+), 47 deletions(-) diff --git a/tailbone/views/trainwreck.py b/tailbone/views/trainwreck.py index 5360878d..6c851524 100644 --- a/tailbone/views/trainwreck.py +++ b/tailbone/views/trainwreck.py @@ -30,9 +30,8 @@ import six from rattail.time import localtime -from tailbone import forms from tailbone.db import TrainwreckSession -from tailbone.views import MasterView3 as MasterView +from tailbone.views import MasterView4 as MasterView class TransactionView(MasterView): @@ -49,6 +48,10 @@ class TransactionView(MasterView): editable = False deletable = False + labels = { + 'cashback': "Cash Back", + } + grid_columns = [ 'start_time', 'system', @@ -60,27 +63,6 @@ class TransactionView(MasterView): 'total', ] - labels = { - 'cashback': "Cash Back", - } - - has_rows = True - # model_row_class = trainwreck.TransactionItem - rows_default_pagesize = 100 - - row_grid_columns = [ - 'sequence', - 'item_type', - 'item_scancode', - 'department_number', - 'description', - 'unit_quantity', - 'subtotal', - 'tax', - 'total', - 'void', - ] - form_fields = [ 'system', 'system_id', @@ -103,6 +85,42 @@ class TransactionView(MasterView): 'void', ] + has_rows = True + # model_row_class = trainwreck.TransactionItem + rows_default_pagesize = 100 + + row_labels = { + 'item_id': "Item ID", + 'department_number': "Dept. No.", + } + + row_grid_columns = [ + 'sequence', + 'item_type', + 'item_scancode', + 'department_number', + 'description', + 'unit_quantity', + 'subtotal', + 'tax', + 'total', + 'void', + ] + + row_form_fields = [ + 'sequence', + 'item_type', + 'item_scancode', + 'item_id', + 'department_number', + 'description', + 'unit_quantity', + 'subtotal', + 'tax', + 'total', + 'void', + ] + def configure_grid(self, g): super(TransactionView, self).configure_grid(g) g.filters['receipt_number'].default_active = True @@ -162,29 +180,14 @@ class TransactionView(MasterView): g.set_type('tax', 'currency') g.set_type('total', 'currency') - g.set_label('item_id', "Item ID") - g.set_label('department_number', "Dept. No.") + def configure_row_form(self, f): + super(TransactionView, self).configure_row_form(f) - def _preconfigure_row_fieldset(self, fs): - fs.item_id.set(label="Item ID") - fs.department_number.set(label="Dept. No.") - fs.unit_quantity.set(renderer=forms.renderers.QuantityFieldRenderer) - fs.subtotal.set(renderer=forms.renderers.CurrencyFieldRenderer) - fs.discounted_subtotal.set(renderer=forms.renderers.CurrencyFieldRenderer) - fs.tax.set(renderer=forms.renderers.CurrencyFieldRenderer) - fs.total.set(renderer=forms.renderers.CurrencyFieldRenderer) + # quantity fields + f.set_type('unit_quantity', 'quantity') - def configure_row_fieldset(self, fs): - fs.configure(include=[ - fs.sequence, - fs.item_type, - fs.item_scancode, - fs.item_id, - fs.department_number, - fs.description, - fs.unit_quantity, - fs.subtotal, - fs.tax, - fs.total, - fs.void, - ]) + # currency fields + f.set_type('subtotal', 'currency') + f.set_type('discounted_subtotal', 'currency') + f.set_type('tax', 'currency') + f.set_type('total', 'currency') From dfc5e0f50e423279a0645dd07cd490a50c6c3c22 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 4 Feb 2018 13:01:49 -0600 Subject: [PATCH 0690/3196] Refactor importer batch views per master4 --- tailbone/views/batch/importer.py | 58 +++++++------------------------- 1 file changed, 12 insertions(+), 46 deletions(-) diff --git a/tailbone/views/batch/importer.py b/tailbone/views/batch/importer.py index ea5e5ee8..9e966f66 100644 --- a/tailbone/views/batch/importer.py +++ b/tailbone/views/batch/importer.py @@ -30,8 +30,7 @@ import sqlalchemy as sa from rattail.db import model -from tailbone import forms2 as forms -from tailbone.views.batch import BatchMasterView3 as BatchMasterView +from tailbone.views.batch import BatchMasterView4 as BatchMasterView class ImporterBatchView(BatchMasterView): @@ -50,6 +49,12 @@ class ImporterBatchView(BatchMasterView): rows_downloadable_csv = False rows_bulk_deletable = True + labels = { + 'host_title': "Source", + 'local_title': "Target", + 'importer_key': "Model", + } + grid_columns = [ 'id', 'description', @@ -79,12 +84,6 @@ class ImporterBatchView(BatchMasterView): 'executed_by', ] - labels = { - 'host_title': "Source", - 'local_title': "Target", - 'importer_key': "Model", - } - row_grid_columns = [ 'sequence', 'object_key', @@ -95,19 +94,11 @@ class ImporterBatchView(BatchMasterView): def configure_form(self, f): super(ImporterBatchView, self).configure_form(f) - # import_handler_spec + # readonly fields f.set_readonly('import_handler_spec') - - # host_title f.set_readonly('host_title') - - # local_title f.set_readonly('local_title') - - # importer_key f.set_readonly('importer_key') - - # row_table f.set_readonly('row_table') def delete_instance(self, batch): @@ -216,39 +207,14 @@ class ImporterBatchView(BatchMasterView): kwargs['diff_new_values'] = new_values return kwargs - def make_row_form(self, instance=None, factory=None, fields=None, schema=None, **kwargs): - """ - Creates a new form for the given model class/instance - """ - if factory is None: - factory = forms.Form - if fields is None: + def make_row_form(self, instance=None, **kwargs): + if kwargs.get('fields') is None: fields = ['sequence', 'object_key', 'object_str', 'status_code'] for col in self.current_row_table.c: if col.name.startswith('key_'): fields.append(col.name) - - if not self.creating: - kwargs['model_instance'] = instance - kwargs = self.make_row_form_kwargs(**kwargs) - form = factory(fields, schema, **kwargs) - self.configure_row_form(form) - return form - - def make_row_form_kwargs(self, **kwargs): - """ - Return a dictionary of kwargs to be passed to the factory when creating - new row form instances. - """ - defaults = { - 'request': self.request, - 'readonly': self.viewing, - 'model_class': getattr(self, 'model_class', None), - 'action_url': self.request.current_route_url(_query=None), - } - instance = kwargs['model_instance'] - defaults.update(kwargs) - return defaults + kwargs['fields'] = fields + return super(ImporterBatchView, self).make_row_form(instance=instance, **kwargs) def configure_row_form(self, f): """ From 8137d715df1388977d9c280e71f0cb3db8dda27b Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 5 Feb 2018 12:59:34 -0600 Subject: [PATCH 0691/3196] Refactor purchasing batch views per master4 --- .../templates/mobile/ordering/create_row.mako | 2 +- tailbone/templates/ordering/order_form.mako | 6 +- .../purchases/receiving_worksheet.mako | 4 +- tailbone/views/purchasing/batch.py | 417 ++++++++++++------ tailbone/views/purchasing/ordering.py | 46 +- tailbone/views/purchasing/receiving.py | 39 +- 6 files changed, 328 insertions(+), 186 deletions(-) diff --git a/tailbone/templates/mobile/ordering/create_row.mako b/tailbone/templates/mobile/ordering/create_row.mako index d31814f8..79d83630 100644 --- a/tailbone/templates/mobile/ordering/create_row.mako +++ b/tailbone/templates/mobile/ordering/create_row.mako @@ -1,6 +1,6 @@ ## -*- coding: utf-8; -*- <%inherit file="/mobile/master/create_row.mako" /> -<%def name="title()">${h.link_to(index_title, index_url)} » ${h.link_to(instance_title, instance_url)} » Add Item +<%def name="page_title()">${h.link_to(index_title, index_url)} » ${h.link_to(instance_title, instance_url)} » Add Item ${parent.body()} diff --git a/tailbone/templates/ordering/order_form.mako b/tailbone/templates/ordering/order_form.mako index bca0ff6f..d427af4c 100644 --- a/tailbone/templates/ordering/order_form.mako +++ b/tailbone/templates/ordering/order_form.mako @@ -288,5 +288,9 @@ ${h.end_form()}
      - + diff --git a/tailbone/templates/purchases/receiving_worksheet.mako b/tailbone/templates/purchases/receiving_worksheet.mako index 38696dd4..596bfd43 100644 --- a/tailbone/templates/purchases/receiving_worksheet.mako +++ b/tailbone/templates/purchases/receiving_worksheet.mako @@ -1,4 +1,4 @@ -## -*- coding: utf-8 -*- +## -*- coding: utf-8; -*- Receiving Worksheet @@ -76,7 +76,7 @@ % for item in purchase.items: - + diff --git a/tailbone/views/purchasing/batch.py b/tailbone/views/purchasing/batch.py index c7311f40..6a41cdca 100644 --- a/tailbone/views/purchasing/batch.py +++ b/tailbone/views/purchasing/batch.py @@ -36,8 +36,9 @@ from deform import widget as dfwidget from pyramid import httpexceptions from webhelpers2.html import tags -from tailbone import forms, forms2 -from tailbone.views.batch import BatchMasterView3 as BatchMasterView +# from tailbone import forms +from tailbone import forms2 +from tailbone.views.batch import BatchMasterView4 as BatchMasterView class PurchasingBatchView(BatchMasterView): @@ -56,27 +57,11 @@ class PurchasingBatchView(BatchMasterView): 'date_ordered', 'created', 'created_by', + 'rowcount', 'status_code', 'executed', ] - # row_grid_columns = [ - # 'sequence', - # 'upc', - # # 'item_id', - # 'brand_name', - # 'description', - # 'size', - # 'cases_ordered', - # 'units_ordered', - # 'cases_received', - # 'units_received', - # 'po_total', - # 'invoice_total', - # 'credits', - # 'status_code', - # ] - form_fields = [ 'id', 'store', @@ -104,6 +89,88 @@ class PurchasingBatchView(BatchMasterView): 'executed_by', ] + row_labels = { + 'upc': "UPC", + 'item_id': "Item ID", + 'brand_name': "Brand", + 'po_line_number': "PO Line Number", + 'po_unit_cost': "PO Unit Cost", + 'po_total': "PO Total", + } + + # row_grid_columns = [ + # 'sequence', + # 'upc', + # # 'item_id', + # 'brand_name', + # 'description', + # 'size', + # 'cases_ordered', + # 'units_ordered', + # 'cases_received', + # 'units_received', + # 'po_total', + # 'invoice_total', + # 'credits', + # 'status_code', + # ] + + row_form_fields = [ + 'upc', + 'item_id', + 'product', + 'brand_name', + 'description', + 'size', + 'case_quantity', + 'cases_ordered', + 'units_ordered', + 'cases_received', + 'units_received', + 'cases_damaged', + 'units_damaged', + 'cases_expired', + 'units_expired', + 'cases_mispick', + 'units_mispick', + 'po_line_number', + 'po_unit_cost', + 'po_total', + 'invoice_line_number', + 'invoice_unit_cost', + 'invoice_total', + 'status_code', + 'credits', + ] + + mobile_row_form_fields = [ + 'upc', + 'item_id', + 'product', + 'brand_name', + 'description', + 'size', + 'case_quantity', + 'cases_ordered', + 'units_ordered', + 'cases_received', + 'units_received', + 'cases_damaged', + 'units_damaged', + 'cases_expired', + 'units_expired', + 'cases_mispick', + 'units_mispick', + # 'po_line_number', + 'po_unit_cost', + 'po_total', + # 'invoice_line_number', + 'invoice_unit_cost', + 'invoice_total', + 'status_code', + # 'credits', + ] + @property def batch_mode(self): raise NotImplementedError("Please define `batch_mode` for your purchasing batch view") @@ -156,7 +223,11 @@ class PurchasingBatchView(BatchMasterView): # TODO: this hardly seems complete... # store - if not self.creating: + if self.creating: + f.replace('store', 'store_uuid') + f.set_widget('store_uuid', dfwidget.SelectWidget(values=self.get_store_values())) + f.set_label('store_uuid', "Store") + else: f.set_readonly('store') f.set_renderer('store', self.render_store) @@ -189,12 +260,13 @@ class PurchasingBatchView(BatchMasterView): # department f.set_renderer('department', self.render_department) if self.creating: - f.replace('department', 'department_uuid') - f.set_node('department_uuid', colander.String()) - dept_options = self.get_department_options() - dept_values = [(v, k) for k, v in dept_options] - f.set_widget('department_uuid', dfwidget.SelectWidget(values=dept_values)) - f.set_label('department_uuid', "Department") + if 'department' in f.fields: + f.replace('department', 'department_uuid') + f.set_node('department_uuid', colander.String()) + dept_options = self.get_department_options() + dept_values = [(v, k) for k, v in dept_options] + f.set_widget('department_uuid', dfwidget.SelectWidget(values=dept_values)) + f.set_label('department_uuid', "Department") else: f.set_readonly('department') @@ -271,6 +343,12 @@ class PurchasingBatchView(BatchMasterView): 'vendor_contact', 'status_code') + def configure_mobile_form(self, f): + super(PurchasingBatchView, self).configure_mobile_form(f) + + # currency fields + f.set_type('po_total', 'currency') + def render_store(self, batch, field): store = batch.store if not store: @@ -330,6 +408,37 @@ class PurchasingBatchView(BatchMasterView): return tags.link_to(text, url) return text + def get_store_values(self): + stores = self.Session.query(model.Store)\ + .order_by(model.Store.id) + return [(s.uuid, "({}) {}".format(s.id, s.name)) + for s in stores] + + def get_vendors(self): + return self.Session.query(model.Vendor)\ + .order_by(model.Vendor.name) + + def get_vendor_values(self): + vendors = self.get_vendors() + return [(v.uuid, "({}) {}".format(v.id, v.name)) + for v in vendors] + + def get_vendor_values(self): + vendors = self.get_vendors() + return [(v.uuid, "({}) {}".format(v.id, v.name)) + for v in vendors] + + def get_buyers(self): + return self.Session.query(model.Employee)\ + .join(model.Person)\ + .filter(model.Employee.status == self.enum.EMPLOYEE_STATUS_CURRENT)\ + .order_by(model.Person.display_name) + + def get_buyer_values(self): + buyers = self.get_buyers() + return [(b.uuid, six.text_type(b)) + for b in buyers] + def get_department_options(self): departments = self.Session.query(model.Department).order_by(model.Department.number) return [('{} {}'.format(d.number, d.name), d.uuid) for d in departments] @@ -456,8 +565,6 @@ class PurchasingBatchView(BatchMasterView): g.set_type('invoice_total', 'currency') g.set_type('credits', 'boolean') - g.set_label('upc', "UPC") - g.set_label('brand_name', "Brand") g.set_label('cases_ordered', "Cases Ord.") g.set_label('units_ordered', "Units Ord.") g.set_label('cases_received', "Cases Rec.") @@ -475,30 +582,125 @@ class PurchasingBatchView(BatchMasterView): if row.status_code in (row.STATUS_INCOMPLETE, row.STATUS_ORDERED_RECEIVED_DIFFER): return 'notice' - def _preconfigure_row_fieldset(self, fs): - super(PurchasingBatchView, self)._preconfigure_row_fieldset(fs) - fs.upc.set(label="UPC") - fs.item_id.set(label="Item ID") - fs.brand_name.set(label="Brand") - fs.case_quantity.set(renderer=forms.renderers.QuantityFieldRenderer, readonly=True) - fs.cases_ordered.set(renderer=forms.renderers.QuantityFieldRenderer) - fs.units_ordered.set(renderer=forms.renderers.QuantityFieldRenderer) - fs.cases_received.set(renderer=forms.renderers.QuantityFieldRenderer) - fs.units_received.set(renderer=forms.renderers.QuantityFieldRenderer) - fs.cases_damaged.set(renderer=forms.renderers.QuantityFieldRenderer) - fs.units_damaged.set(renderer=forms.renderers.QuantityFieldRenderer) - fs.cases_expired.set(renderer=forms.renderers.QuantityFieldRenderer) - fs.units_expired.set(renderer=forms.renderers.QuantityFieldRenderer) - fs.cases_mispick.set(renderer=forms.renderers.QuantityFieldRenderer) - fs.units_mispick.set(renderer=forms.renderers.QuantityFieldRenderer) - fs.po_line_number.set(label="PO Line Number") - fs.po_unit_cost.set(label="PO Unit Cost", renderer=forms.renderers.CurrencyFieldRenderer) - fs.po_total.set(label="PO Total", renderer=forms.renderers.CurrencyFieldRenderer) - fs.invoice_unit_cost.set(renderer=forms.renderers.CurrencyFieldRenderer) - fs.invoice_total.set(renderer=forms.renderers.CurrencyFieldRenderer) - fs.credits.set(readonly=True) - # fs.append(fa.Field('item_lookup', label="Item Lookup Code", required=True, - # validate=self.item_lookup)) + def configure_row_form(self, f): + super(PurchasingBatchView, self).configure_row_form(f) + row = f.model_instance + if self.creating: + batch = self.get_instance() + else: + batch = self.get_parent(row) + + # readonly fields + f.set_readonly('case_quantity') + f.set_readonly('credits') + + # quantity fields + f.set_type('case_quantity', 'quantity') + f.set_type('cases_ordered', 'quantity') + f.set_type('units_ordered', 'quantity') + f.set_type('cases_received', 'quantity') + f.set_type('units_received', 'quantity') + f.set_type('cases_damaged', 'quantity') + f.set_type('units_damaged', 'quantity') + f.set_type('cases_expired', 'quantity') + f.set_type('units_expired', 'quantity') + f.set_type('cases_mispick', 'quantity') + f.set_type('units_mispick', 'quantity') + + # currency fields + f.set_type('po_unit_cost', 'currency') + f.set_type('po_total', 'currency') + f.set_type('invoice_unit_cost', 'currency') + f.set_type('invoice_total', 'currency') + + if self.creating: + f.remove_fields( + 'upc', + 'product', + 'po_total', + 'invoice_total', + ) + if self.batch_mode == self.enum.PURCHASE_BATCH_MODE_ORDERING: + f.remove_fields('cases_received', + 'units_received') + elif self.batch_mode == self.enum.PURCHASE_BATCH_MODE_RECEIVING: + f.remove_fields('cases_ordered', + 'units_ordered') + + elif self.editing: + f.set_readonly('upc') + f.set_readonly('product') + f.remove_fields('po_total', + 'invoice_total', + 'status_code') + + elif self.viewing: + if row.product: + f.remove_fields('brand_name', + 'description', + 'size') + else: + f.remove_field('product') + + def configure_mobile_row_form(self, f): + super(PurchasingBatchView, self).configure_mobile_row_form(f) + # row = f.model_instance + # if self.creating: + # batch = self.get_instance() + # else: + # batch = self.get_parent(row) + + # # readonly fields + # f.set_readonly('case_quantity') + # f.set_readonly('credits') + + # quantity fields + f.set_type('case_quantity', 'quantity') + f.set_type('cases_ordered', 'quantity') + f.set_type('units_ordered', 'quantity') + f.set_type('cases_received', 'quantity') + f.set_type('units_received', 'quantity') + f.set_type('cases_damaged', 'quantity') + f.set_type('units_damaged', 'quantity') + f.set_type('cases_expired', 'quantity') + f.set_type('units_expired', 'quantity') + f.set_type('cases_mispick', 'quantity') + f.set_type('units_mispick', 'quantity') + + # currency fields + f.set_type('po_unit_cost', 'currency') + f.set_type('po_total', 'currency') + f.set_type('invoice_unit_cost', 'currency') + f.set_type('invoice_total', 'currency') + + # if self.creating: + # f.remove_fields( + # 'upc', + # 'product', + # 'po_total', + # 'invoice_total', + # ) + # if self.batch_mode == self.enum.PURCHASE_BATCH_MODE_ORDERING: + # f.remove_fields('cases_received', + # 'units_received') + # elif self.batch_mode == self.enum.PURCHASE_BATCH_MODE_RECEIVING: + # f.remove_fields('cases_ordered', + # 'units_ordered') + + # elif self.editing: + # f.set_readonly('upc') + # f.set_readonly('product') + # f.remove_fields('po_total', + # 'invoice_total', + # 'status_code') + + # elif self.viewing: + # if row.product: + # f.remove_fields('brand_name', + # 'description', + # 'size') + # else: + # f.remove_field('product') # def item_lookup(self, value, field=None): # """ @@ -519,71 +721,6 @@ class PurchasingBatchView(BatchMasterView): # return product.uuid # raise fa.ValidationError("Product not found") - def configure_row_fieldset(self, fs): - try: - batch = self.get_instance() - except httpexceptions.HTTPNotFound: - batch = self.get_row_instance().batch - - fs.configure( - include=[ - # fs.item_lookup, - fs.upc, - fs.item_id, - fs.product, - fs.brand_name, - fs.description, - fs.size, - fs.case_quantity, - fs.cases_ordered, - fs.units_ordered, - fs.cases_received, - fs.units_received, - fs.cases_damaged, - fs.units_damaged, - fs.cases_expired, - fs.units_expired, - fs.cases_mispick, - fs.units_mispick, - fs.po_line_number, - fs.po_unit_cost, - fs.po_total, - fs.invoice_line_number, - fs.invoice_unit_cost, - fs.invoice_total, - fs.status_code, - fs.credits, - ]) - - if self.creating: - del fs.upc - del fs.product - del fs.po_total - del fs.invoice_total - if self.batch_mode == self.enum.PURCHASE_BATCH_MODE_ORDERING: - del fs.cases_received - del fs.units_received - elif self.batch_mode == self.enum.PURCHASE_BATCH_MODE_RECEIVING: - del fs.cases_ordered - del fs.units_ordered - - elif self.editing: - # del fs.item_lookup - fs.upc.set(readonly=True) - fs.product.set(readonly=True) - del fs.po_total - del fs.invoice_total - del fs.status_code - - elif self.viewing: - # del fs.item_lookup - if fs.model.product: - del (fs.brand_name, - fs.description, - fs.size) - else: - del fs.product - # def before_create_row(self, form): # row = form.fieldset.model # batch = self.get_instance() @@ -594,34 +731,38 @@ class PurchasingBatchView(BatchMasterView): # def after_create_row(self, row): # self.handler.refresh_row(row) -# def after_edit_row(self, row): -# batch = row.batch + def save_edit_row_form(self, form): + row = form.model_instance + batch = row.batch -# # first undo any totals previously in effect for the row -# if batch.mode == self.enum.PURCHASE_BATCH_MODE_ORDERING and row.po_total: -# batch.po_total -= row.po_total -# elif batch.mode == self.enum.PURCHASE_BATCH_MODE_RECEIVING and row.invoice_total: -# batch.invoice_total -= row.invoice_total + # first undo any totals previously in effect for the row + if batch.mode == self.enum.PURCHASE_BATCH_MODE_ORDERING and row.po_total: + batch.po_total -= row.po_total + elif batch.mode == self.enum.PURCHASE_BATCH_MODE_RECEIVING and row.invoice_total: + batch.invoice_total -= row.invoice_total -# self.handler.refresh_row(row) + row = super(PurchasingBatchView, self).save_edit_row_form(form) + # TODO: is this needed? + # self.handler.refresh_row(row) + return row # def redirect_after_create_row(self, row): # self.request.session.flash("Added item: {} {}".format(row.upc.pretty(), row.product)) # return self.redirect(self.request.current_route_url()) -# def delete_row(self): -# """ -# Update the PO total in addition to marking row as removed. -# """ -# row = self.Session.query(self.model_row_class).get(self.request.matchdict['uuid']) -# if not row: -# raise httpexceptions.HTTPNotFound() -# if row.po_total: -# row.batch.po_total -= row.po_total -# if row.invoice_total: -# row.batch.invoice_total -= row.invoice_total -# row.removed = True -# return self.redirect(self.get_action_url('view', row.batch)) + def delete_row(self): + """ + Update the batch totals in addition to marking row as removed. + """ + row = self.Session.query(self.model_row_class).get(self.request.matchdict['row_uuid']) + if not row: + raise self.notfound() + batch = row.batch + if row.po_total: + batch.po_total -= row.po_total + if row.invoice_total: + batch.invoice_total -= row.invoice_total + return super(PurchasingBatchView, self).delete_row() # def get_execute_success_url(self, batch, result, **kwargs): # # if batch execution yielded a Purchase, redirect to it diff --git a/tailbone/views/purchasing/ordering.py b/tailbone/views/purchasing/ordering.py index f319f9e9..5b4e7421 100644 --- a/tailbone/views/purchasing/ordering.py +++ b/tailbone/views/purchasing/ordering.py @@ -38,7 +38,6 @@ from rattail.time import localtime from pyramid.response import FileResponse -from tailbone import forms from tailbone.views.purchasing import PurchasingBatchView @@ -55,6 +54,21 @@ class OrderingBatchView(PurchasingBatchView): rows_editable = True mobile_rows_editable = True + mobile_form_fields = [ + 'vendor', + 'department', + 'date_ordered', + 'po_number', + 'po_total', + 'created', + 'created_by', + 'notes', + 'status_code', + 'complete', + 'executed', + 'executed_by', + ] + row_grid_columns = [ 'sequence', 'upc', @@ -233,7 +247,9 @@ class OrderingBatchView(PurchasingBatchView): if cases_ordered or units_ordered: row.cases_ordered = cases_ordered or None row.units_ordered = units_ordered or None - row.removed = False + if row.removed: + row.removed = False + batch.rowcount += 1 self.handler.refresh_row(row) else: row.removed = True @@ -246,6 +262,7 @@ class OrderingBatchView(PurchasingBatchView): row.cases_ordered = cases_ordered or None row.units_ordered = units_ordered or None self.handler.refresh_row(row) + batch.rowcount += 1 return { 'row_cases_ordered': '' if not row or row.removed else int(row.cases_ordered or 0), @@ -292,31 +309,6 @@ class OrderingBatchView(PurchasingBatchView): data['mode_title'] = self.enum.PURCHASE_BATCH_MODE[mode].capitalize() return self.render_to_response('create', data, mobile=True) - def preconfigure_mobile_fieldset(self, fs): - super(OrderingBatchView, self).preconfigure_mobile_fieldset(fs) - fs.vendor.set(attrs={'hyperlink': False}) - - def configure_mobile_fieldset(self, fs): - fields = [ - fs.vendor, - fs.department, - fs.date_ordered, - fs.po_number, - fs.po_total, - fs.created, - fs.created_by, - fs.notes, - fs.status_code, - fs.complete, - ] - batch = fs.model - if (self.viewing or self.deleting) and batch.executed: - fields.extend([ - fs.executed, - fs.executed_by, - ]) - fs.configure(include=fields) - def download_excel(self): """ Download ordering batch as Excel spreadsheet. diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index 87487cd6..2e6c53e1 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar +# Copyright © 2010-2018 Lance Edgar # # This file is part of Rattail. # @@ -35,7 +35,6 @@ from rattail.db import model, api from rattail.gpc import GPC from rattail.util import pretty_quantity, prettify -import formalchemy as fa import formencode as fe from webhelpers2.html import tags @@ -95,6 +94,11 @@ class ReceivingBatchView(PurchasingBatchView): mobile_rows_filterable = True mobile_rows_creatable = True + mobile_form_fields = [ + 'vendor', + 'department', + ] + row_grid_columns = [ 'sequence', 'upc', @@ -112,6 +116,14 @@ class ReceivingBatchView(PurchasingBatchView): 'status_code', ] + row_form_fields = [ + 'vendor', + 'department', + 'complete', + 'executed', + 'executed_by', + ] + @property def batch_mode(self): return self.enum.PURCHASE_BATCH_MODE_RECEIVING @@ -186,21 +198,14 @@ class ReceivingBatchView(PurchasingBatchView): kwargs['sms_transaction_number'] = batch.sms_transaction_number return kwargs - def configure_mobile_fieldset(self, fs): - fs.configure(include=[ - fs.vendor.with_renderer(fa.TextFieldRenderer), - fs.department.with_renderer(fa.TextFieldRenderer), - fs.complete, - fs.executed, - fs.executed_by, - ]) - batch = fs.model - if not batch.executed: - del [fs.executed, fs.executed_by] - if not batch.complete: - del fs.complete - else: - del fs.complete + def configure_mobile_form(self, f): + super(ReceivingBatchView, self).configure_mobile_form(f) + + # vendor + # fs.vendor.with_renderer(fa.TextFieldRenderer), + + # department + # fs.department.with_renderer(fa.TextFieldRenderer), def render_mobile_row_listitem(self, row, i): description = row.product.full_description if row.product else row.description From 22d9981c2e9d748c599caef7eca2d3fd1fc1ef50 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 5 Feb 2018 14:24:21 -0600 Subject: [PATCH 0692/3196] Use master4 for custorder views guess i missed that one... --- tailbone/views/custorders/orders.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/views/custorders/orders.py b/tailbone/views/custorders/orders.py index eb8b89a6..8dfcae28 100644 --- a/tailbone/views/custorders/orders.py +++ b/tailbone/views/custorders/orders.py @@ -34,7 +34,7 @@ from rattail.db import model from webhelpers2.html import tags from tailbone.db import Session -from tailbone.views import MasterView as MasterView +from tailbone.views import MasterView4 as MasterView class CustomerOrdersView(MasterView): From 6cc509f5b45b77aeea5e0af922564e6639225df7 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 5 Feb 2018 14:24:49 -0600 Subject: [PATCH 0693/3196] Add `Form.show_cancel` flag, for hiding that button also use fields from schema by default, if fields not provided --- tailbone/forms2/core.py | 3 +++ tailbone/templates/forms2/deform.mako | 4 +++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/tailbone/forms2/core.py b/tailbone/forms2/core.py index 3c0a2a17..d5b3cb81 100644 --- a/tailbone/forms2/core.py +++ b/tailbone/forms2/core.py @@ -321,6 +321,7 @@ class Form(object): Base class for all forms. """ update_label = "Save" + show_cancel = True def __init__(self, fields=None, schema=None, request=None, readonly=False, readonly_fields=[], model_instance=None, model_class=None, nodes={}, enums={}, labels={}, renderers=None, @@ -331,6 +332,8 @@ class Form(object): if fields is not None: self.set_fields(fields) self.schema = schema + if self.fields is None and self.schema: + self.set_fields([f.name for f in self.schema]) self.request = request self.readonly = readonly self.readonly_fields = set(readonly_fields or []) diff --git a/tailbone/templates/forms2/deform.mako b/tailbone/templates/forms2/deform.mako index d30113d6..ce8a73d0 100644 --- a/tailbone/templates/forms2/deform.mako +++ b/tailbone/templates/forms2/deform.mako @@ -76,7 +76,9 @@ ${h.csrf_token(request)} ## % if form.creating and form.allow_successive_creates: ## ${h.submit('create_and_continue', form.successive_create_label)} ## % endif - ${h.link_to("Cancel", form.cancel_url, class_='button autodisable')} + % if getattr(form, 'show_cancel', True): + ${h.link_to("Cancel", form.cancel_url, class_='button autodisable')} + % endif % endif From 7730080afc7032e3382a2d64952e91a309c9009e Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 5 Feb 2018 16:53:17 -0600 Subject: [PATCH 0694/3196] Let each form define its "save" button text where applicable etc. --- tailbone/forms2/core.py | 1 + tailbone/templates/forms2/deform.mako | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/tailbone/forms2/core.py b/tailbone/forms2/core.py index d5b3cb81..938a40ef 100644 --- a/tailbone/forms2/core.py +++ b/tailbone/forms2/core.py @@ -320,6 +320,7 @@ class Form(object): """ Base class for all forms. """ + save_label = "Save" update_label = "Save" show_cancel = True diff --git a/tailbone/templates/forms2/deform.mako b/tailbone/templates/forms2/deform.mako index ce8a73d0..ef33d686 100644 --- a/tailbone/templates/forms2/deform.mako +++ b/tailbone/templates/forms2/deform.mako @@ -72,7 +72,7 @@ ${h.csrf_token(request)} % elif not readonly:
      ## ${h.submit('create', form.create_label if form.creating else form.update_label)} - ${h.submit('save', "Save")} + ${h.submit('save', getattr(form, 'save_label', "Save"))} ## % if form.creating and form.allow_successive_creates: ## ${h.submit('create_and_continue', form.successive_create_label)} ## % endif From 7c62b6f7a7374353362d341370572aca24f4fdd8 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 5 Feb 2018 18:25:45 -0600 Subject: [PATCH 0695/3196] Remove unused reference to legacy forms --- tailbone/views/tempmon/probes.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tailbone/views/tempmon/probes.py b/tailbone/views/tempmon/probes.py index 157277b4..48176900 100644 --- a/tailbone/views/tempmon/probes.py +++ b/tailbone/views/tempmon/probes.py @@ -34,7 +34,6 @@ import colander from deform import widget as dfwidget from webhelpers2.html import tags -from tailbone import forms from tailbone.views.tempmon import MasterView From 2219315ccc0be3379c0854eca7ee19481d1df37e Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 5 Feb 2018 21:23:23 -0600 Subject: [PATCH 0696/3196] Collapse all master4 views back to just 'master' --- tailbone/views/__init__.py | 3 - tailbone/views/batch/__init__.py | 3 - tailbone/views/batch/core.py | 316 ++++++++-- tailbone/views/batch/core2.py | 129 ---- tailbone/views/batch/core3.py | 205 ------ tailbone/views/batch/core4.py | 127 ---- tailbone/views/batch/importer.py | 2 +- tailbone/views/batch/pricing.py | 2 +- tailbone/views/bouncer.py | 2 +- tailbone/views/brands.py | 2 +- tailbone/views/categories.py | 2 +- tailbone/views/customergroups.py | 2 +- tailbone/views/customers.py | 2 +- tailbone/views/custorders/items.py | 2 +- tailbone/views/custorders/orders.py | 2 +- tailbone/views/datasync.py | 2 +- tailbone/views/departments.py | 2 +- tailbone/views/depositlinks.py | 2 +- tailbone/views/email.py | 2 +- tailbone/views/employees.py | 2 +- tailbone/views/exports.py | 2 +- tailbone/views/families.py | 2 +- tailbone/views/handheld.py | 2 +- tailbone/views/inventory.py | 4 +- tailbone/views/labels/batch.py | 2 +- tailbone/views/labels/profiles.py | 2 +- tailbone/views/master.py | 923 +++++++++++++++++++++++----- tailbone/views/master2.py | 422 ------------- tailbone/views/master3.py | 178 ------ tailbone/views/master4.py | 319 ---------- tailbone/views/messages.py | 2 +- tailbone/views/people.py | 2 +- tailbone/views/principal.py | 2 +- tailbone/views/products.py | 2 +- tailbone/views/purchases/core.py | 2 +- tailbone/views/purchases/credits.py | 2 +- tailbone/views/purchasing/batch.py | 2 +- tailbone/views/reportcodes.py | 2 +- tailbone/views/settings.py | 2 +- tailbone/views/shifts/core.py | 2 +- tailbone/views/stores.py | 2 +- tailbone/views/subdepartments.py | 2 +- tailbone/views/tables.py | 2 +- tailbone/views/taxes.py | 2 +- tailbone/views/tempmon/core.py | 2 +- tailbone/views/trainwreck.py | 2 +- tailbone/views/upgrades.py | 2 +- tailbone/views/users.py | 2 +- tailbone/views/vendors/catalogs.py | 2 +- tailbone/views/vendors/core.py | 2 +- tailbone/views/vendors/invoices.py | 2 +- 51 files changed, 1096 insertions(+), 1613 deletions(-) delete mode 100644 tailbone/views/batch/core2.py delete mode 100644 tailbone/views/batch/core3.py delete mode 100644 tailbone/views/batch/core4.py delete mode 100644 tailbone/views/master2.py delete mode 100644 tailbone/views/master3.py delete mode 100644 tailbone/views/master4.py diff --git a/tailbone/views/__init__.py b/tailbone/views/__init__.py index f2b4fcdc..8d5c680b 100644 --- a/tailbone/views/__init__.py +++ b/tailbone/views/__init__.py @@ -28,9 +28,6 @@ from __future__ import unicode_literals, absolute_import from .core import View from .master import MasterView -from .master2 import MasterView2 -from .master3 import MasterView3 -from .master4 import MasterView4 # TODO: deprecate / remove some of this from .autocomplete import AutocompleteView diff --git a/tailbone/views/batch/__init__.py b/tailbone/views/batch/__init__.py index 9a053ca2..e4b61802 100644 --- a/tailbone/views/batch/__init__.py +++ b/tailbone/views/batch/__init__.py @@ -27,6 +27,3 @@ Views for batches from __future__ import unicode_literals, absolute_import from .core import BatchMasterView, FileBatchMasterView -from .core2 import BatchMasterView2, FileBatchMasterView2 -from .core3 import BatchMasterView3, FileBatchMasterView3 -from .core4 import BatchMasterView4, FileBatchMasterView4 diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index ab28c429..2d066ae8 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -73,6 +73,33 @@ class BatchMasterView(MasterView): mobile_rows_viewable = True has_worksheet = False + grid_columns = [ + 'id', + 'description', + 'created', + 'created_by', + 'rowcount', + # 'status_code', + # 'complete', + 'executed', + 'executed_by', + ] + + form_fields = [ + 'id', + 'created', + 'created_by', + 'rowcount', + 'status_code', + 'executed', + 'executed_by', + 'purge', + ] + + row_labels = { + 'status_code': "Status", + } + def __init__(self, request): super(BatchMasterView, self).__init__(request) self.handler = self.get_handler() @@ -114,6 +141,44 @@ class BatchMasterView(MasterView): def allow_worksheet(self, batch): return not batch.executed and not batch.complete + def configure_grid(self, g): + super(BatchMasterView, self).configure_grid(g) + + g.joiners['created_by'] = lambda q: q.join(model.User, model.User.uuid == self.model_class.created_by_uuid) + g.joiners['executed_by'] = lambda q: q.outerjoin(model.User, model.User.uuid == self.model_class.executed_by_uuid) + + g.filters['executed'].default_active = True + g.filters['executed'].default_verb = 'is_null' + + # TODO: not sure this todo is still relevant? + # TODO: in some cases grid has no sorters yet..e.g. when building query for bulk-delete + # if hasattr(g, 'sorters'): + g.sorters['created_by'] = g.make_sorter(model.User.username) + g.sorters['executed_by'] = g.make_sorter(model.User.username) + + g.set_sort_defaults('id', 'desc') + + g.set_enum('status_code', self.model_class.STATUS) + + g.set_type('created', 'datetime') + g.set_type('executed', 'datetime') + + g.set_renderer('id', self.render_batch_id) + + g.set_link('id') + g.set_link('description') + g.set_link('created') + g.set_link('executed') + + g.set_label('id', "Batch ID") + g.set_label('created_by', "Created by") + g.set_label('rowcount', "Rows") + g.set_label('status_code', "Status") + g.set_label('executed_by', "Executed by") + + def render_batch_id(self, batch, column): + return batch.id_str + def template_kwargs_index(self, **kwargs): kwargs['execute_enabled'] = self.instance_executable(None) if kwargs['execute_enabled'] and self.has_execution_options(): @@ -151,6 +216,82 @@ class BatchMasterView(MasterView): filters['status'] = MobileBatchStatusFilter(self.model_class, 'status', default_value='pending') return filters + def configure_form(self, f): + super(BatchMasterView, self).configure_form(f) + + # id + f.set_readonly('id') + f.set_renderer('id', self.render_batch_id) + f.set_label('id', "Batch ID") + + # created + f.set_readonly('created') + f.set_readonly('created_by') + f.set_renderer('created_by', self.render_user) + f.set_label('created_by', "Created by") + + # cognized + f.set_renderer('cognized_by', self.render_user) + f.set_label('cognized_by', "Cognized by") + + # row count + f.set_readonly('rowcount') + f.set_label('rowcount', "Row Count") + + # status_code + f.set_readonly('status_code') + f.set_renderer('status_code', self.make_status_renderer(self.model_class.STATUS)) + f.set_label('status_code', "Status") + + # executed + f.set_readonly('executed') + f.set_readonly('executed_by') + f.set_renderer('executed_by', self.render_user) + f.set_label('executed_by', "Executed by") + + # notes + f.set_type('notes', 'text') + + # if self.creating and self.request.user: + # batch = fs.model + # batch.created_by_uuid = self.request.user.uuid + + if self.creating: + f.remove_fields('id', + 'rowcount', + 'created', + 'created_by', + 'cognized', + 'cognized_by', + 'executed', + 'executed_by', + 'purge') + + else: # not creating + batch = self.get_instance() + if not batch.executed: + f.remove_fields('executed', + 'executed_by') + + def make_status_renderer(self, enum): + def render_status(batch, field): + value = batch.status_code + if value is None: + return "" + status_code_text = enum.get(value, six.text_type(value)) + if batch.status_text: + return HTML.tag('span', title=batch.status_text, c=status_code_text) + return status_code_text + return render_status + + def render_user(self, batch, field): + user = getattr(batch, field) + if not user: + return "" + title = six.text_type(user) + url = self.request.route_url('users.view', uuid=user.uuid) + return tags.link_to(title, url) + def _preconfigure_fieldset(self, fs): """ Apply some commonly-useful pre-configuration to the main batch @@ -240,67 +381,93 @@ class BatchMasterView(MasterView): download_url=download_url) fs.append(fa.Field(name, **kwargs)) + def configure_mobile_form(self, f): + super(BatchMasterView, self).configure_mobile_form(f) + batch = f.model_instance + + if self.creating: + f.remove_fields('id', + 'rowcount', + 'created', + 'created_by', + 'cognized', + 'cognized_by', + 'executed', + 'executed_by', + 'purge') + + else: # not creating + if not batch.executed: + f.remove_fields('executed', + 'executed_by') + if not batch.complete: + f.remove_field('complete') + def save_create_form(self, form): self.before_create(form) - with Session.no_autoflush: + session = self.Session() + with session.no_autoflush: # transfer form data to batch instance - form.fieldset.sync() - batch = form.fieldset.model + batch = self.objectify(form, self.form_deserialized) # current user is batch creator batch.created_by = self.request.user or self.late_login_user() - # destroy initial batch and re-make using handler + # obtain kwargs for making batch via handler, below kwargs = self.get_batch_kwargs(batch) - Session.expunge(batch) - batch = self.handler.make_batch(Session(), **kwargs) - Session.flush() + # TODO: this needs work yet surely... + if 'filename' in form.schema: + filedict = kwargs.pop('filename', None) + filepath = None + if filedict: + kwargs['filename'] = '' # null not allowed + tempdir = tempfile.mkdtemp() + filepath = os.path.join(tempdir, filedict['filename']) + tmpinfo = form.deform_form['filename'].widget.tmpstore.get(filedict['uid']) + tmpdata = tmpinfo['fp'].read() + with open(filepath, 'wb') as f: + f.write(tmpdata) + + # TODO: is this still necessary with colander? + # destroy initial batch and re-make using handler + # if batch in self.Session: + # self.Session.expunge(batch) + batch = self.handler.make_batch(session, **kwargs) + + self.Session.flush() # TODO: this needs work yet surely... # if batch has input data file, let handler properly establish that - filename = getattr(batch, 'filename', None) - if filename: - path = os.path.join(self.upload_dir, filename) - if os.path.exists(path): - self.handler.set_input_file(batch, path) - os.remove(path) + if 'filename' in form.schema: + if filedict: + self.handler.set_input_file(batch, filepath) + os.remove(filepath) + os.rmdir(tempdir) - # return this object to replace the original return batch - # TODO: this is a totaly copy of save_create_form() def save_mobile_create_form(self, form): self.before_create(form) - - with Session.no_autoflush: + session = self.Session() + with session.no_autoflush: # transfer form data to batch instance - form.fieldset.sync() - batch = form.fieldset.model + batch = self.objectify(form, self.form_deserialized) # current user is batch creator - batch.created_by = self.request.user or self.late_login_user() + batch.created_by = self.request.user + # TODO: is this still necessary with colander? # destroy initial batch and re-make using handler kwargs = self.get_batch_kwargs(batch) - Session.expunge(batch) - batch = self.handler.make_batch(Session(), **kwargs) + if batch in session: + session.expunge(batch) + batch = self.handler.make_batch(session, **kwargs) - Session.flush() - - # TODO: this needs work yet surely... - # if batch has input data file, let handler properly establish that - filename = getattr(batch, 'filename', None) - if filename: - path = os.path.join(self.upload_dir, filename) - if os.path.exists(path): - self.handler.set_input_file(batch, path) - os.remove(path) - - # return this object to replace the original + session.flush() return batch def get_batch_kwargs(self, batch, mobile=False): @@ -367,6 +534,35 @@ class BatchMasterView(MasterView): """ return not batch.executed + def configure_row_grid(self, g): + super(BatchMasterView, self).configure_row_grid(g) + + if 'status_code' in g.filters: + g.filters['status_code'].set_value_renderer(grids.filters.EnumValueRenderer(self.model_row_class.STATUS)) + + g.set_sort_defaults('sequence') + + if self.model_row_class: + g.set_enum('status_code', self.model_row_class.STATUS) + + g.set_renderer('status_code', self.render_row_status) + + g.set_label('sequence', "Seq.") + g.set_label('status_code', "Status") + g.set_label('item_id', "Item ID") + + def get_row_status_enum(self): + return self.model_row_class.STATUS + + def render_row_status(self, row, column): + code = row.status_code + if code is None: + return "" + text = self.get_row_status_enum().get(code, six.text_type(code)) + if row.status_text: + return HTML.tag('span', title=row.status_text, c=text) + return text + def create_row(self): """ Only allow creating a new row if the batch hasn't yet been executed. @@ -387,14 +583,42 @@ class BatchMasterView(MasterView): return self.redirect(self.get_action_url('view', batch, mobile=True)) return super(BatchMasterView, self).mobile_create_row() - def before_create_row(self, form): + def save_create_row_form(self, form): batch = self.get_instance() - row = form.fieldset.model + row = self.objectify(form, self.form_deserialized) self.handler.add_row(batch, row) + self.Session.flush() + return row def after_create_row(self, row): self.handler.refresh_row(row) + def configure_row_form(self, f): + super(BatchMasterView, self).configure_row_form(f) + + # sequence + f.set_readonly('sequence') + + # status_code + if self.model_row_class: + f.set_enum('status_code', self.model_row_class.STATUS) + f.set_renderer('status_code', self.render_row_status) + f.set_readonly('status_code') + f.set_label('status_code', "Status") + + def configure_mobile_row_form(self, f): + super(BatchMasterView, self).configure_mobile_row_form(f) + + # sequence + f.set_readonly('sequence') + + # status_code + if self.model_row_class: + f.set_enum('status_code', self.model_row_class.STATUS) + f.set_renderer('status_code', self.render_row_status) + f.set_readonly('status_code') + f.set_label('status_code', "Status") + def make_default_row_grid_tools(self, batch): if self.rows_creatable and not batch.executed: permission_prefix = self.get_permission_prefix() @@ -1007,6 +1231,26 @@ class FileBatchMasterView(BatchMasterView): os.makedirs(uploads) return uploads + def configure_form(self, f): + super(FileBatchMasterView, self).configure_form(f) + + # filename + f.set_renderer('filename', self.render_filename) + f.set_label('filename', "Data File") + if self.editing: + f.set_readonly('filename') + + if self.creating: + if 'filename' not in f.fields: + f.fields.insert(0, 'filename') + tmpstore = SessionFileUploadTempStore(self.request) + f.set_node('filename', colander.SchemaNode(deform.FileData(), widget=dfwidget.FileUploadWidget(tmpstore))) + + def render_filename(self, batch, field): + path = batch.filepath(self.rattail_config, filename=batch.filename) + url = self.get_action_url('download', batch) + return self.render_file_field(path, url) + def _preconfigure_fieldset(self, fs): super(FileBatchMasterView, self)._preconfigure_fieldset(fs) fs.filename.set(label="Data File", renderer=FileFieldRenderer.new(self)) diff --git a/tailbone/views/batch/core2.py b/tailbone/views/batch/core2.py deleted file mode 100644 index 3a70366b..00000000 --- a/tailbone/views/batch/core2.py +++ /dev/null @@ -1,129 +0,0 @@ -# -*- coding: utf-8; -*- -################################################################################ -# -# Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar -# -# This file is part of Rattail. -# -# Rattail is free software: you can redistribute it and/or modify it under the -# terms of the GNU General Public License as published by the Free Software -# Foundation, either version 3 of the License, or (at your option) any later -# version. -# -# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY -# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more -# details. -# -# You should have received a copy of the GNU General Public License along with -# Rattail. If not, see . -# -################################################################################ -""" -Base views for maintaining batches -""" - -from __future__ import unicode_literals, absolute_import - -import six - -from rattail.db import model - -from webhelpers2.html import HTML - -from tailbone import grids -from tailbone.views import MasterView2 -from tailbone.views.batch import BatchMasterView, FileBatchMasterView -from tailbone.views.batch.core import MobileBatchStatusFilter - - -class BatchMasterView2(MasterView2, BatchMasterView): - """ - Base class for all "batch master" views - """ - - grid_columns = [ - 'id', - 'description', - 'created', - 'created_by', - 'rowcount', - # 'status_code', - # 'complete', - 'executed', - 'executed_by', - ] - - def configure_grid(self, g): - super(BatchMasterView2, self).configure_grid(g) - - g.joiners['created_by'] = lambda q: q.join(model.User, model.User.uuid == self.model_class.created_by_uuid) - g.joiners['executed_by'] = lambda q: q.outerjoin(model.User, model.User.uuid == self.model_class.executed_by_uuid) - - g.filters['executed'].default_active = True - g.filters['executed'].default_verb = 'is_null' - - # TODO: not sure this todo is still relevant? - # TODO: in some cases grid has no sorters yet..e.g. when building query for bulk-delete - # if hasattr(g, 'sorters'): - g.sorters['created_by'] = g.make_sorter(model.User.username) - g.sorters['executed_by'] = g.make_sorter(model.User.username) - - g.set_sort_defaults('id', 'desc') - - g.set_enum('status_code', self.model_class.STATUS) - - g.set_type('created', 'datetime') - g.set_type('executed', 'datetime') - - g.set_renderer('id', self.render_batch_id) - - g.set_link('id') - g.set_link('description') - g.set_link('created') - g.set_link('executed') - - g.set_label('id', "Batch ID") - g.set_label('created_by', "Created by") - g.set_label('rowcount', "Rows") - g.set_label('status_code', "Status") - g.set_label('executed_by', "Executed by") - - def render_batch_id(self, batch, column): - return batch.id_str - - def configure_row_grid(self, g): - super(BatchMasterView2, self).configure_row_grid(g) - - if 'status_code' in g.filters: - g.filters['status_code'].set_value_renderer(grids.filters.EnumValueRenderer(self.model_row_class.STATUS)) - - g.set_sort_defaults('sequence') - - if self.model_row_class: - g.set_enum('status_code', self.model_row_class.STATUS) - - g.set_renderer('status_code', self.render_row_status) - - g.set_label('sequence', "Seq.") - g.set_label('status_code', "Status") - g.set_label('item_id', "Item ID") - - def get_row_status_enum(self): - return self.model_row_class.STATUS - - def render_row_status(self, row, column): - code = row.status_code - if code is None: - return "" - text = self.get_row_status_enum().get(code, six.text_type(code)) - if row.status_text: - return HTML.tag('span', title=row.status_text, c=text) - return text - - -class FileBatchMasterView2(BatchMasterView2, FileBatchMasterView): - """ - Base class for all file-based "batch master" views - """ diff --git a/tailbone/views/batch/core3.py b/tailbone/views/batch/core3.py deleted file mode 100644 index 92dedf26..00000000 --- a/tailbone/views/batch/core3.py +++ /dev/null @@ -1,205 +0,0 @@ -# -*- coding: utf-8; -*- -################################################################################ -# -# Rattail -- Retail Software Framework -# Copyright © 2010-2018 Lance Edgar -# -# This file is part of Rattail. -# -# Rattail is free software: you can redistribute it and/or modify it under the -# terms of the GNU General Public License as published by the Free Software -# Foundation, either version 3 of the License, or (at your option) any later -# version. -# -# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY -# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more -# details. -# -# You should have received a copy of the GNU General Public License along with -# Rattail. If not, see . -# -################################################################################ -""" -Base views for maintaining batches -""" - -from __future__ import unicode_literals, absolute_import - -import os -import tempfile - -import six -import colander -import deform -from deform import widget as dfwidget -from pyramid_deform import SessionFileUploadTempStore -from webhelpers2.html import tags - -from tailbone.views import MasterView3 -from tailbone.views.batch import BatchMasterView2, FileBatchMasterView2 - - -class BatchMasterView3(MasterView3, BatchMasterView2): - """ - Base class for all "batch master" views - """ - - form_fields = [ - 'id', - 'created', - 'created_by', - 'rowcount', - 'status_code', - 'executed', - 'executed_by', - 'purge', - ] - - def configure_form(self, f): - super(BatchMasterView3, self).configure_form(f) - - # id - f.set_readonly('id') - f.set_renderer('id', self.render_batch_id) - f.set_label('id', "Batch ID") - - # created - f.set_readonly('created') - f.set_readonly('created_by') - f.set_renderer('created_by', self.render_user) - f.set_label('created_by', "Created by") - - # cognized - f.set_renderer('cognized_by', self.render_user) - f.set_label('cognized_by', "Cognized by") - - # row count - f.set_readonly('rowcount') - f.set_label('rowcount', "Row Count") - - # status_code - f.set_readonly('status_code') - f.set_renderer('status_code', self.make_status_renderer(self.model_class.STATUS)) - f.set_label('status_code', "Status") - - # executed - f.set_readonly('executed') - f.set_readonly('executed_by') - f.set_renderer('executed_by', self.render_user) - f.set_label('executed_by', "Executed by") - - # notes - f.set_type('notes', 'text') - - # if self.creating and self.request.user: - # batch = fs.model - # batch.created_by_uuid = self.request.user.uuid - - if self.creating: - f.remove_fields('id', - 'rowcount', - 'created', - 'created_by', - 'cognized', - 'cognized_by', - 'executed', - 'executed_by', - 'purge') - - else: # not creating - batch = self.get_instance() - if not batch.executed: - f.remove_fields('executed', - 'executed_by') - - def save_create_form(self, form): - self.before_create(form) - - session = self.Session() - with session.no_autoflush: - - # transfer form data to batch instance - batch = self.objectify(form, self.form_deserialized) - - # current user is batch creator - batch.created_by = self.request.user or self.late_login_user() - - # obtain kwargs for making batch via handler, below - kwargs = self.get_batch_kwargs(batch) - - # TODO: this needs work yet surely... - if 'filename' in form.schema: - filedict = kwargs.pop('filename', None) - filepath = None - if filedict: - kwargs['filename'] = '' # null not allowed - tempdir = tempfile.mkdtemp() - filepath = os.path.join(tempdir, filedict['filename']) - tmpinfo = form.deform_form['filename'].widget.tmpstore.get(filedict['uid']) - tmpdata = tmpinfo['fp'].read() - with open(filepath, 'wb') as f: - f.write(tmpdata) - - # TODO: is this still necessary with colander? - # destroy initial batch and re-make using handler - # if batch in self.Session: - # self.Session.expunge(batch) - batch = self.handler.make_batch(session, **kwargs) - - self.Session.flush() - - # TODO: this needs work yet surely... - # if batch has input data file, let handler properly establish that - if 'filename' in form.schema: - if filedict: - self.handler.set_input_file(batch, filepath) - os.remove(filepath) - os.rmdir(tempdir) - - return batch - - def make_status_renderer(self, enum): - def render_status(batch, field): - value = batch.status_code - if value is None: - return "" - status_code_text = enum.get(value, six.text_type(value)) - if batch.status_text: - return HTML.tag('span', title=batch.status_text, c=status_code_text) - return status_code_text - return render_status - - def render_user(self, batch, field): - user = getattr(batch, field) - if not user: - return "" - title = six.text_type(user) - url = self.request.route_url('users.view', uuid=user.uuid) - return tags.link_to(title, url) - - -class FileBatchMasterView3(BatchMasterView3, FileBatchMasterView2): - """ - Base class for all file-based "batch master" views - """ - - def configure_form(self, f): - super(FileBatchMasterView3, self).configure_form(f) - - # filename - f.set_renderer('filename', self.render_filename) - f.set_label('filename', "Data File") - if self.editing: - f.set_readonly('filename') - - if self.creating: - if 'filename' not in f.fields: - f.fields.insert(0, 'filename') - tmpstore = SessionFileUploadTempStore(self.request) - f.set_node('filename', colander.SchemaNode(deform.FileData(), widget=dfwidget.FileUploadWidget(tmpstore))) - - def render_filename(self, batch, field): - path = batch.filepath(self.rattail_config, filename=batch.filename) - url = self.get_action_url('download', batch) - return self.render_file_field(path, url) diff --git a/tailbone/views/batch/core4.py b/tailbone/views/batch/core4.py deleted file mode 100644 index 6d9ac792..00000000 --- a/tailbone/views/batch/core4.py +++ /dev/null @@ -1,127 +0,0 @@ -# -*- coding: utf-8; -*- -################################################################################ -# -# Rattail -- Retail Software Framework -# Copyright © 2010-2018 Lance Edgar -# -# This file is part of Rattail. -# -# Rattail is free software: you can redistribute it and/or modify it under the -# terms of the GNU General Public License as published by the Free Software -# Foundation, either version 3 of the License, or (at your option) any later -# version. -# -# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY -# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more -# details. -# -# You should have received a copy of the GNU General Public License along with -# Rattail. If not, see . -# -################################################################################ -""" -Base views for maintaining batches -""" - -from __future__ import unicode_literals, absolute_import - -from tailbone.views import MasterView4 -from tailbone.views.batch import BatchMasterView3, FileBatchMasterView3 - - -class BatchMasterView4(MasterView4, BatchMasterView3): - """ - Base class for all "batch master" views - """ - - row_labels = { - 'status_code': "Status", - } - - def configure_mobile_form(self, f): - super(BatchMasterView4, self).configure_mobile_form(f) - batch = f.model_instance - - if self.creating: - f.remove_fields('id', - 'rowcount', - 'created', - 'created_by', - 'cognized', - 'cognized_by', - 'executed', - 'executed_by', - 'purge') - - else: # not creating - if not batch.executed: - f.remove_fields('executed', - 'executed_by') - if not batch.complete: - f.remove_field('complete') - - def save_mobile_create_form(self, form): - self.before_create(form) - session = self.Session() - with session.no_autoflush: - - # transfer form data to batch instance - batch = self.objectify(form, self.form_deserialized) - - # current user is batch creator - batch.created_by = self.request.user - - # TODO: is this still necessary with colander? - # destroy initial batch and re-make using handler - kwargs = self.get_batch_kwargs(batch) - if batch in session: - session.expunge(batch) - batch = self.handler.make_batch(session, **kwargs) - - session.flush() - return batch - - def configure_row_form(self, f): - super(BatchMasterView4, self).configure_row_form(f) - - # sequence - f.set_readonly('sequence') - - # status_code - if self.model_row_class: - f.set_enum('status_code', self.model_row_class.STATUS) - f.set_renderer('status_code', self.render_row_status) - f.set_readonly('status_code') - f.set_label('status_code', "Status") - - def configure_mobile_row_form(self, f): - super(BatchMasterView4, self).configure_mobile_row_form(f) - - # sequence - f.set_readonly('sequence') - - # status_code - if self.model_row_class: - f.set_enum('status_code', self.model_row_class.STATUS) - f.set_renderer('status_code', self.render_row_status) - f.set_readonly('status_code') - f.set_label('status_code', "Status") - - # NOTE: must override default logic here, by doing nothing - def before_create_row(self, form): - pass - - def save_create_row_form(self, form): - batch = self.get_instance() - row = self.objectify(form, self.form_deserialized) - self.handler.add_row(batch, row) - self.Session.flush() - return row - - -class FileBatchMasterView4(BatchMasterView4, FileBatchMasterView3): - """ - Base class for all file-based "batch master" views - """ - diff --git a/tailbone/views/batch/importer.py b/tailbone/views/batch/importer.py index 9e966f66..22e4323a 100644 --- a/tailbone/views/batch/importer.py +++ b/tailbone/views/batch/importer.py @@ -30,7 +30,7 @@ import sqlalchemy as sa from rattail.db import model -from tailbone.views.batch import BatchMasterView4 as BatchMasterView +from tailbone.views.batch import BatchMasterView class ImporterBatchView(BatchMasterView): diff --git a/tailbone/views/batch/pricing.py b/tailbone/views/batch/pricing.py index a378614c..e871c3df 100644 --- a/tailbone/views/batch/pricing.py +++ b/tailbone/views/batch/pricing.py @@ -30,7 +30,7 @@ from rattail.db import model from webhelpers2.html import tags -from tailbone.views.batch import BatchMasterView4 as BatchMasterView +from tailbone.views.batch import BatchMasterView class PricingBatchView(BatchMasterView): diff --git a/tailbone/views/bouncer.py b/tailbone/views/bouncer.py index 861f6a86..680bdef8 100644 --- a/tailbone/views/bouncer.py +++ b/tailbone/views/bouncer.py @@ -37,7 +37,7 @@ from pyramid.response import FileResponse from webhelpers2.html import HTML, tags from tailbone import grids -from tailbone.views import MasterView4 as MasterView +from tailbone.views import MasterView class EmailBouncesView(MasterView): diff --git a/tailbone/views/brands.py b/tailbone/views/brands.py index 3a0b177f..c3796af8 100644 --- a/tailbone/views/brands.py +++ b/tailbone/views/brands.py @@ -28,7 +28,7 @@ from __future__ import unicode_literals, absolute_import from rattail.db import model -from tailbone.views import MasterView4 as MasterView, AutocompleteView +from tailbone.views import MasterView, AutocompleteView class BrandsView(MasterView): diff --git a/tailbone/views/categories.py b/tailbone/views/categories.py index f5a7731e..057e6d6a 100644 --- a/tailbone/views/categories.py +++ b/tailbone/views/categories.py @@ -28,7 +28,7 @@ from __future__ import unicode_literals, absolute_import from rattail.db import model -from tailbone.views import MasterView4 as MasterView +from tailbone.views import MasterView class CategoriesView(MasterView): diff --git a/tailbone/views/customergroups.py b/tailbone/views/customergroups.py index 0ee806b2..5c6892d5 100644 --- a/tailbone/views/customergroups.py +++ b/tailbone/views/customergroups.py @@ -29,7 +29,7 @@ from __future__ import unicode_literals, absolute_import from rattail.db import model from tailbone.db import Session -from tailbone.views import MasterView4 as MasterView +from tailbone.views import MasterView class CustomerGroupsView(MasterView): diff --git a/tailbone/views/customers.py b/tailbone/views/customers.py index a5305033..c6b64379 100644 --- a/tailbone/views/customers.py +++ b/tailbone/views/customers.py @@ -39,7 +39,7 @@ from webhelpers2.html import HTML, tags from tailbone import grids from tailbone.db import Session -from tailbone.views import MasterView4 as MasterView, AutocompleteView +from tailbone.views import MasterView, AutocompleteView from rattail.db import model diff --git a/tailbone/views/custorders/items.py b/tailbone/views/custorders/items.py index e075e1d8..f4c540f0 100644 --- a/tailbone/views/custorders/items.py +++ b/tailbone/views/custorders/items.py @@ -33,7 +33,7 @@ from sqlalchemy import orm from rattail.db import model from rattail.time import localtime -from tailbone.views import MasterView4 as MasterView +from tailbone.views import MasterView from tailbone.util import raw_datetime diff --git a/tailbone/views/custorders/orders.py b/tailbone/views/custorders/orders.py index 8dfcae28..dee21f15 100644 --- a/tailbone/views/custorders/orders.py +++ b/tailbone/views/custorders/orders.py @@ -34,7 +34,7 @@ from rattail.db import model from webhelpers2.html import tags from tailbone.db import Session -from tailbone.views import MasterView4 as MasterView +from tailbone.views import MasterView class CustomerOrdersView(MasterView): diff --git a/tailbone/views/datasync.py b/tailbone/views/datasync.py index 6d602666..def5ee5f 100644 --- a/tailbone/views/datasync.py +++ b/tailbone/views/datasync.py @@ -31,7 +31,7 @@ import logging from rattail.db import model -from tailbone.views import MasterView4 as MasterView +from tailbone.views import MasterView log = logging.getLogger(__name__) diff --git a/tailbone/views/departments.py b/tailbone/views/departments.py index 29c6ea4e..b0eba5be 100644 --- a/tailbone/views/departments.py +++ b/tailbone/views/departments.py @@ -33,7 +33,7 @@ from rattail.db import model from deform import widget as dfwidget from tailbone import grids -from tailbone.views import MasterView4 as MasterView, AutocompleteView +from tailbone.views import MasterView, AutocompleteView class DepartmentsView(MasterView): diff --git a/tailbone/views/depositlinks.py b/tailbone/views/depositlinks.py index 9be6260c..db28e3f6 100644 --- a/tailbone/views/depositlinks.py +++ b/tailbone/views/depositlinks.py @@ -28,7 +28,7 @@ from __future__ import unicode_literals, absolute_import from rattail.db import model -from tailbone.views import MasterView4 as MasterView +from tailbone.views import MasterView class DepositLinksView(MasterView): diff --git a/tailbone/views/email.py b/tailbone/views/email.py index 932840b4..de700260 100644 --- a/tailbone/views/email.py +++ b/tailbone/views/email.py @@ -37,7 +37,7 @@ from deform import widget as dfwidget from webhelpers2.html import HTML from tailbone.db import Session -from tailbone.views import View, MasterView4 as MasterView +from tailbone.views import View, MasterView class ProfilesView(MasterView): diff --git a/tailbone/views/employees.py b/tailbone/views/employees.py index 42025eba..89d7e014 100644 --- a/tailbone/views/employees.py +++ b/tailbone/views/employees.py @@ -37,7 +37,7 @@ from webhelpers2.html import tags, HTML from tailbone import grids from tailbone.db import Session -from tailbone.views import MasterView4 as MasterView, AutocompleteView +from tailbone.views import MasterView, AutocompleteView class EmployeesView(MasterView): diff --git a/tailbone/views/exports.py b/tailbone/views/exports.py index 8dc7c1ae..9e842616 100644 --- a/tailbone/views/exports.py +++ b/tailbone/views/exports.py @@ -36,7 +36,7 @@ from pyramid.response import FileResponse from webhelpers2.html import HTML, tags from tailbone import forms2 as forms -from tailbone.views import MasterView4 as MasterView +from tailbone.views import MasterView class ExportMasterView(MasterView): diff --git a/tailbone/views/families.py b/tailbone/views/families.py index 72feba3b..997255b3 100644 --- a/tailbone/views/families.py +++ b/tailbone/views/families.py @@ -28,7 +28,7 @@ from __future__ import unicode_literals, absolute_import from rattail.db import model -from tailbone.views import MasterView4 as MasterView +from tailbone.views import MasterView class FamiliesView(MasterView): diff --git a/tailbone/views/handheld.py b/tailbone/views/handheld.py index 015c7e41..13a74c54 100644 --- a/tailbone/views/handheld.py +++ b/tailbone/views/handheld.py @@ -35,7 +35,7 @@ import formencode as fe from webhelpers2.html import tags from tailbone.db import Session -from tailbone.views.batch import FileBatchMasterView4 as FileBatchMasterView +from tailbone.views.batch import FileBatchMasterView ACTION_OPTIONS = OrderedDict([ diff --git a/tailbone/views/inventory.py b/tailbone/views/inventory.py index e0e0976d..418515b0 100644 --- a/tailbone/views/inventory.py +++ b/tailbone/views/inventory.py @@ -43,8 +43,8 @@ from deform import widget as dfwidget from webhelpers2.html import HTML, tags from tailbone import forms, forms2, grids -from tailbone.views import MasterView4 as MasterView -from tailbone.views.batch import BatchMasterView4 as BatchMasterView +from tailbone.views import MasterView +from tailbone.views.batch import BatchMasterView class InventoryAdjustmentReasonsView(MasterView): diff --git a/tailbone/views/labels/batch.py b/tailbone/views/labels/batch.py index 663d3416..acf33d9e 100644 --- a/tailbone/views/labels/batch.py +++ b/tailbone/views/labels/batch.py @@ -30,7 +30,7 @@ from rattail.db import model from webhelpers2.html import HTML, tags -from tailbone.views.batch import BatchMasterView4 as BatchMasterView +from tailbone.views.batch import BatchMasterView class LabelBatchView(BatchMasterView): diff --git a/tailbone/views/labels/profiles.py b/tailbone/views/labels/profiles.py index d2b821dc..25ecfa0e 100644 --- a/tailbone/views/labels/profiles.py +++ b/tailbone/views/labels/profiles.py @@ -31,7 +31,7 @@ from rattail.db import model from pyramid.httpexceptions import HTTPFound from tailbone.db import Session -from tailbone.views import MasterView4 as MasterView +from tailbone.views import MasterView class ProfilesView(MasterView): diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 4a9bc50e..6c6080d1 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -38,19 +38,19 @@ import sqlalchemy_continuum as continuum from rattail.db import model, Session as RattailSession from rattail.db.continuum import model_transaction_query from rattail.util import prettify -from rattail.time import localtime #, make_utc +from rattail.time import localtime from rattail.threads import Thread from rattail.csvutil import UnicodeDictWriter from rattail.files import temp_path from rattail.excel import ExcelWriter -import formalchemy as fa +import deform from pyramid import httpexceptions from pyramid.renderers import get_renderer, render_to_response, render from pyramid.response import FileResponse from webhelpers2.html import HTML, tags -from tailbone import forms, grids, diffs +from tailbone import forms2 as forms, grids, diffs from tailbone.views import View from tailbone.progress import SessionProgress @@ -67,6 +67,7 @@ class MasterView(View): checkboxes = False listable = True + sortable = True results_downloadable_csv = False results_downloadable_xlsx = False creatable = True @@ -85,6 +86,7 @@ class MasterView(View): supports_mobile = False mobile_creatable = False + mobile_pageable = True mobile_filterable = False listing = False @@ -103,12 +105,15 @@ class MasterView(View): has_versions = False + labels = {'uuid': "UUID"} + # ROW-RELATED ATTRS FOLLOW: has_rows = False model_row_class = None - rows_filterable = True + rows_pageable = True rows_sortable = True + rows_filterable = True rows_viewable = True rows_creatable = False rows_editable = False @@ -123,6 +128,8 @@ class MasterView(View): mobile_rows_viewable = False mobile_rows_editable = False + row_labels = {} + @property def Session(self): """ @@ -132,6 +139,56 @@ class MasterView(View): from tailbone.db import Session return Session + @classmethod + def get_grid_factory(cls): + """ + Returns the grid factory or class which is to be used when creating new + grid instances. + """ + return getattr(cls, 'grid_factory', grids.Grid) + + @classmethod + def get_row_grid_factory(cls): + """ + Returns the grid factory or class which is to be used when creating new + row grid instances. + """ + return getattr(cls, 'row_grid_factory', grids.Grid) + + @classmethod + def get_version_grid_factory(cls): + """ + Returns the grid factory or class which is to be used when creating new + version grid instances. + """ + return getattr(cls, 'version_grid_factory', grids.Grid) + + @classmethod + def get_mobile_grid_factory(cls): + """ + Must return a callable to be used when creating new mobile grid + instances. Instead of overriding this, you can set + :attr:`mobile_grid_factory`. Default factory is :class:`MobileGrid`. + """ + return getattr(cls, 'mobile_grid_factory', grids.MobileGrid) + + @classmethod + def get_mobile_row_grid_factory(cls): + """ + Must return a callable to be used when creating new mobile row grid + instances. Instead of overriding this, you can set + :attr:`mobile_row_grid_factory`. Default factory is :class:`MobileGrid`. + """ + return getattr(cls, 'mobile_row_grid_factory', grids.MobileGrid) + + def set_labels(self, obj): + for key, label in self.labels.items(): + obj.set_label(key, label) + + def set_row_labels(self, obj): + for key, label in self.row_labels.items(): + obj.set_label(key, label) + ############################## # Available Views ############################## @@ -164,6 +221,223 @@ class MasterView(View): return self.render_to_response('index', {'grid': grid}) + def make_grid(self, factory=None, key=None, data=None, columns=None, **kwargs): + """ + Creates a new grid instance + """ + if factory is None: + factory = self.get_grid_factory() + if key is None: + key = self.get_grid_key() + if data is None: + data = self.get_data(session=kwargs.get('session')) + if columns is None: + columns = self.get_grid_columns() + + kwargs.setdefault('request', self.request) + kwargs = self.make_grid_kwargs(**kwargs) + grid = factory(key, data, columns, **kwargs) + self.configure_grid(grid) + grid.load_settings() + return grid + + def get_effective_data(self, session=None, **kwargs): + """ + Convenience method which returns the "effective" data for the master + grid, filtered and sorted to match what would show on the UI, but not + paged etc. + """ + if session is None: + session = self.Session() + kwargs.setdefault('pageable', False) + grid = self.make_grid(session=session, **kwargs) + return grid.make_visible_data() + + def get_grid_columns(self): + if hasattr(self, 'grid_columns'): + return self.grid_columns + # TODO + raise NotImplementedError + + def make_grid_kwargs(self, **kwargs): + """ + Return a dictionary of kwargs to be passed to the factory when creating + new grid instances. + """ + defaults = { + 'model_class': getattr(self, 'model_class', None), + 'width': 'full', + 'filterable': self.filterable, + 'sortable': self.sortable, + 'pageable': self.pageable, + 'extra_row_class': self.grid_extra_class, + 'url': lambda obj: self.get_action_url('view', obj), + 'checkboxes': self.checkboxes or ( + self.mergeable and self.request.has_perm('{}.merge'.format(self.get_permission_prefix()))), + 'checked': self.checked, + } + if 'main_actions' not in kwargs and 'more_actions' not in kwargs: + main, more = self.get_grid_actions() + defaults['main_actions'] = main + defaults['more_actions'] = more + defaults.update(kwargs) + return defaults + + def configure_grid(self, grid): + self.set_labels(grid) + + def grid_extra_class(self, obj, i): + """ + Returns string of extra class(es) for the table row corresponding to + the given object, or ``None``. + """ + + def make_row_grid(self, factory=None, key=None, data=None, columns=None, **kwargs): + """ + Make and return a new (configured) rows grid instance. + """ + instance = kwargs.pop('instance', None) + if not instance: + instance = self.get_instance() + + if factory is None: + factory = self.get_row_grid_factory() + if key is None: + key = self.get_row_grid_key() + if data is None: + data = self.get_row_data(instance) + if columns is None: + columns = self.get_row_grid_columns() + + kwargs.setdefault('request', self.request) + kwargs = self.make_row_grid_kwargs(**kwargs) + grid = factory(key, data, columns, **kwargs) + self.configure_row_grid(grid) + grid.load_settings() + return grid + + def get_row_grid_columns(self): + if hasattr(self, 'row_grid_columns'): + return self.row_grid_columns + # TODO + raise NotImplementedError + + def make_row_grid_kwargs(self, **kwargs): + """ + Return a dict of kwargs to be used when constructing a new rows grid. + """ + permission_prefix = self.get_permission_prefix() + + defaults = { + 'model_class': self.model_row_class, + 'width': 'full', + 'filterable': self.rows_filterable, + 'sortable': self.rows_sortable, + 'pageable': self.rows_pageable, + 'default_pagesize': self.rows_default_pagesize, + 'extra_row_class': self.row_grid_extra_class, + 'url': lambda obj: self.get_row_action_url('view', obj), + } + + if self.has_rows and 'main_actions' not in defaults: + actions = [] + + # view action + if self.rows_viewable: + view = lambda r, i: self.get_row_action_url('view', r) + actions.append(grids.GridAction('view', icon='zoomin', url=view)) + + # edit action + if self.rows_editable: + actions.append(grids.GridAction('edit', icon='pencil', url=self.row_edit_action_url)) + + # delete action + if self.rows_deletable and self.request.has_perm('{}.delete_row'.format(permission_prefix)): + actions.append(grids.GridAction('delete', icon='trash', url=self.row_delete_action_url)) + defaults['delete_speedbump'] = self.rows_deletable_speedbump + + defaults['main_actions'] = actions + + defaults.update(kwargs) + return defaults + + def configure_row_grid(self, grid): + # super(MasterView, self).configure_row_grid(grid) + self.set_row_labels(grid) + + def row_grid_extra_class(self, obj, i): + """ + Returns string of extra class(es) for the table row corresponding to + the given row object, or ``None``. + """ + + def make_version_grid(self, factory=None, key=None, data=None, columns=None, **kwargs): + """ + Creates a new version grid instance + """ + instance = kwargs.pop('instance', None) + if not instance: + instance = self.get_instance() + + if factory is None: + factory = self.get_version_grid_factory() + if key is None: + key = self.get_version_grid_key() + if data is None: + data = self.get_version_data(instance) + if columns is None: + columns = self.get_version_grid_columns() + + kwargs.setdefault('request', self.request) + kwargs = self.make_version_grid_kwargs(**kwargs) + grid = factory(key, data, columns, **kwargs) + self.configure_version_grid(grid) + grid.load_settings() + return grid + + def get_version_grid_columns(self): + if hasattr(self, 'version_grid_columns'): + return self.version_grid_columns + # TODO + return [ + 'issued_at', + 'user', + 'remote_addr', + 'comment', + ] + + def make_version_grid_kwargs(self, **kwargs): + """ + Return a dictionary of kwargs to be passed to the factory when + constructing a new version grid. + """ + defaults = { + 'model_class': continuum.transaction_class(self.get_model_class()), + 'width': 'full', + 'pageable': True, + } + if 'main_actions' not in kwargs: + route = '{}.version'.format(self.get_route_prefix()) + instance = kwargs.get('instance') or self.get_instance() + url = lambda txn, i: self.request.route_url(route, uuid=instance.uuid, txnid=txn.id) + defaults['main_actions'] = [ + self.make_action('view', icon='zoomin', url=url), + ] + defaults.update(kwargs) + return defaults + + def configure_version_grid(self, g): + g.set_sort_defaults('issued_at', 'desc') + g.set_renderer('comment', self.render_version_comment) + g.set_label('issued_at', "Changed") + g.set_label('user', "Changed by") + g.set_label('remote_addr', "IP Address") + # TODO: why does this render '#' as url? + # g.set_link('issued_at') + + def render_version_comment(self, transaction, column): + return transaction.meta.get('comment', "") + def mobile_index(self): """ Mobile "home" page for the data model @@ -184,6 +458,33 @@ class MasterView(View): return cls.mobile_grid_key return 'mobile.{}'.format(cls.get_route_prefix()) + def make_mobile_grid(self, factory=None, key=None, data=None, columns=None, **kwargs): + """ + Creates a new mobile grid instance + """ + if factory is None: + factory = self.get_mobile_grid_factory() + if key is None: + key = self.get_mobile_grid_key() + if data is None: + data = self.get_mobile_data(session=kwargs.get('session')) + if columns is None: + columns = self.get_mobile_grid_columns() + + kwargs.setdefault('request', self.request) + kwargs.setdefault('mobile', True) + kwargs = self.make_mobile_grid_kwargs(**kwargs) + grid = factory(key, data, columns, **kwargs) + self.configure_mobile_grid(grid) + grid.load_settings() + return grid + + def get_mobile_grid_columns(self): + if hasattr(self, 'mobile_grid_columns'): + return self.mobile_grid_columns + # TODO + return ['listitem'] + def get_mobile_data(self, session=None): """ Must return the "raw" / full data set for the mobile grid. This data @@ -193,6 +494,93 @@ class MasterView(View): """ return self.get_data(session=session) + def make_mobile_grid_kwargs(self, **kwargs): + """ + Must return a dictionary of kwargs to be passed to the factory when + creating new mobile grid instances. + """ + defaults = { + 'model_class': getattr(self, 'model_class', None), + 'pageable': self.mobile_pageable, + 'sortable': False, + 'filterable': self.mobile_filterable, + 'renderers': self.make_mobile_grid_renderers(), + 'url': lambda obj: self.get_action_url('view', obj, mobile=True), + } + # TODO: this seems wrong.. + if self.mobile_filterable: + defaults['filters'] = self.make_mobile_filters() + defaults.update(kwargs) + return defaults + + def make_mobile_grid_renderers(self): + return { + 'listitem': self.render_mobile_listitem, + } + + def render_mobile_listitem(self, obj, i): + return obj + + def configure_mobile_grid(self, grid): + pass + + def make_mobile_row_grid(self, factory=None, key=None, data=None, columns=None, **kwargs): + """ + Make a new (configured) rows grid instance for mobile. + """ + instance = kwargs.pop('instance', self.get_instance()) + + if factory is None: + factory = self.get_mobile_row_grid_factory() + if key is None: + key = 'mobile.{}.{}'.format(self.get_grid_key(), self.request.matchdict[self.get_model_key()]) + if data is None: + data = self.get_mobile_row_data(instance) + if columns is None: + columns = self.get_mobile_row_grid_columns() + + kwargs.setdefault('request', self.request) + kwargs.setdefault('mobile', True) + kwargs = self.make_mobile_row_grid_kwargs(**kwargs) + grid = factory(key, data, columns, **kwargs) + self.configure_mobile_row_grid(grid) + grid.load_settings() + return grid + + def get_mobile_row_grid_columns(self): + if hasattr(self, 'mobile_row_grid_columns'): + return self.mobile_row_grid_columns + # TODO + return ['listitem'] + + def make_mobile_row_grid_kwargs(self, **kwargs): + """ + Must return a dictionary of kwargs to be passed to the factory when + creating new mobile *row* grid instances. + """ + defaults = { + 'model_class': self.model_row_class, + # TODO + 'pageable': self.pageable, + 'sortable': False, + 'filterable': self.mobile_rows_filterable, + 'renderers': self.make_mobile_row_grid_renderers(), + 'url': lambda obj: self.get_row_action_url('view', obj, mobile=True), + } + # TODO: this seems wrong.. + if self.mobile_rows_filterable: + defaults['filters'] = self.make_mobile_row_filters() + defaults.update(kwargs) + return defaults + + def make_mobile_row_grid_renderers(self): + return { + 'listitem': self.render_mobile_row_listitem, + } + + def configure_mobile_row_grid(self, grid): + pass + def make_mobile_filters(self): """ Returns a set of filters for the mobile grid, if applicable. @@ -203,45 +591,8 @@ class MasterView(View): Returns a set of filters for the mobile row grid, if applicable. """ - def mobile_listitem_field(self): - """ - Must return a FormAlchemy field to be appended to grid, or ``None`` if - none is desired. - """ - return fa.Field('listitem', value=lambda obj: obj, - renderer=self.mobile_listitem_renderer()) - - def mobile_listitem_renderer(self): - """ - Must return a FormAlchemy field renderer callable for the mobile grid's - list item field. - """ - master = self - - class ListItemRenderer(fa.FieldRenderer): - - def render_readonly(self, **kwargs): - obj = self.raw_value - if obj is None: - return '' - title = master.get_instance_title(obj) - url = master.get_action_url('view', obj, mobile=True) - return tags.link_to(title, url) - - return ListItemRenderer - - def mobile_row_listitem_renderer(self): - """ - Must return a FormAlchemy field renderer callable for the mobile row - grid's list item field. - """ - master = self - - class ListItemRenderer(fa.FieldRenderer): - def render_readonly(self, **kwargs): - return master.render_mobile_row_listitem(self.raw_value, **kwargs) - - return ListItemRenderer + def render_mobile_row_listitem(self, obj, i): + return obj def create(self): """ @@ -276,17 +627,30 @@ class MasterView(View): return self.redirect_after_create(obj, mobile=True) return self.render_to_response('create', {'form': form}, mobile=True) + def save_create_form(self, form): + self.before_create(form) + with self.Session().no_autoflush: + obj = self.objectify(form, self.form_deserialized) + self.before_create_flush(obj, form) + self.Session.add(obj) + self.Session.flush() + return obj + + def before_create_flush(self, obj, form): + pass + def flash_after_create(self, obj): self.request.session.flash("{} has been created: {}".format( self.get_model_title(), self.get_instance_title(obj))) - def save_create_form(self, form): - self.before_create(form) - form.save() - def save_mobile_create_form(self, form): self.before_create(form) - form.save() + with self.Session.no_autoflush: + obj = self.objectify(form, self.form_deserialized) + self.before_create_flush(obj, form) + self.Session.add(obj) + self.Session.flush() + return obj def redirect_after_create(self, instance, mobile=False): if self.populatable and self.should_populate(instance): @@ -563,50 +927,160 @@ class MasterView(View): context['grid'] = self.make_mobile_row_grid(instance=instance) return self.render_to_response('view', context, mobile=True) - def make_mobile_form(self, instance, **kwargs): + def make_mobile_form(self, instance=None, factory=None, fields=None, schema=None, **kwargs): """ - Make a FormAlchemy-based form for use with mobile CRUD views + Creates a new mobile form for the given model class/instance. """ - fieldset = self.make_fieldset(instance) - self.preconfigure_mobile_fieldset(fieldset) - self.configure_mobile_fieldset(fieldset) - kwargs.setdefault('creating', self.creating) - kwargs.setdefault('editing', self.editing) - kwargs.setdefault('action_url', self.request.current_route_url(_query=None)) - if self.creating: - kwargs.setdefault('cancel_url', self.get_index_url(mobile=True)) - else: - kwargs.setdefault('cancel_url', self.get_action_url('view', instance, mobile=True)) - factory = kwargs.pop('factory', forms.AlchemyForm) - kwargs.setdefault('session', self.Session()) - form = factory(self.request, fieldset, **kwargs) - form.readonly = self.viewing + if factory is None: + factory = self.get_mobile_form_factory() + if fields is None: + fields = self.get_mobile_form_fields() + if schema is None: + schema = self.make_mobile_form_schema() + + if not self.creating: + kwargs['model_instance'] = instance + kwargs = self.make_mobile_form_kwargs(**kwargs) + form = factory(fields, schema, **kwargs) + self.configure_mobile_form(form) return form + def get_mobile_form_fields(self): + if hasattr(self, 'mobile_form_fields'): + return self.mobile_form_fields + # TODO + # raise NotImplementedError + + def make_mobile_form_schema(self): + if not self.model_class: + # TODO + raise NotImplementedError + + def make_mobile_form_kwargs(self, **kwargs): + """ + Return a dictionary of kwargs to be passed to the factory when creating + new mobile forms. + """ + defaults = { + 'request': self.request, + 'readonly': self.viewing, + 'model_class': getattr(self, 'model_class', None), + 'action_url': self.request.current_route_url(_query=None), + } + if self.creating: + defaults['cancel_url'] = self.get_index_url(mobile=True) + else: + instance = kwargs['model_instance'] + defaults['cancel_url'] = self.get_action_url('view', instance, mobile=True) + defaults.update(kwargs) + return defaults + + def configure_mobile_form(self, form): + """ + Configure the primary mobile form. + """ + # TODO: is any of this stuff from configure_form() needed? + # if self.editing: + # model_class = self.get_model_class(error=False) + # if model_class: + # mapper = orm.class_mapper(model_class) + # for key in mapper.primary_key: + # for field in form.fields: + # if field == key.name: + # form.set_readonly(field) + # break + # form.remove_field('uuid') + + self.set_labels(form) + def preconfigure_mobile_row_fieldset(self, fieldset): self._preconfigure_row_fieldset(fieldset) def configure_mobile_row_fieldset(self, fieldset): self.configure_row_fieldset(fieldset) - def make_mobile_row_form(self, row, **kwargs): + def validate_mobile_form(self, form): + controls = self.request.POST.items() + try: + self.form_deserialized = form.validate(controls) + except deform.ValidationFailure: + return False + return True + + def make_mobile_row_form(self, instance=None, factory=None, fields=None, schema=None, **kwargs): """ - Make a form for use with mobile CRUD views, for the given row object. + Creates a new mobile form for the given model class/instance. """ - fieldset = self.make_fieldset(row) - self.preconfigure_mobile_row_fieldset(fieldset) - self.configure_mobile_row_fieldset(fieldset) - kwargs.setdefault('session', self.Session()) - kwargs.setdefault('creating', self.creating) - kwargs.setdefault('editing', self.editing) - kwargs.setdefault('action_url', self.request.current_route_url(_query=None)) - if 'cancel_url' not in kwargs: - kwargs['cancel_url'] = self.get_action_url('view', self.get_parent(row), mobile=True) - factory = kwargs.pop('factory', forms.AlchemyForm) - form = factory(self.request, fieldset, **kwargs) - form.readonly = self.viewing + if factory is None: + factory = self.get_mobile_row_form_factory() + if fields is None: + fields = self.get_mobile_row_form_fields() + if schema is None: + schema = self.make_mobile_row_form_schema() + + if not self.creating: + kwargs['model_instance'] = instance + kwargs = self.make_mobile_row_form_kwargs(**kwargs) + form = factory(fields, schema, **kwargs) + self.configure_mobile_row_form(form) return form + def get_mobile_row_form_fields(self): + if hasattr(self, 'mobile_row_form_fields'): + return self.mobile_row_form_fields + # TODO + # raise NotImplementedError + + def make_mobile_row_form_schema(self): + if not self.model_row_class: + # TODO + raise NotImplementedError + + def make_mobile_row_form_kwargs(self, **kwargs): + """ + Return a dictionary of kwargs to be passed to the factory when creating + new mobile row forms. + """ + defaults = { + 'request': self.request, + 'readonly': self.viewing, + 'model_class': getattr(self, 'model_row_class', None), + 'action_url': self.request.current_route_url(_query=None), + } + if self.creating: + defaults['cancel_url'] = self.request.get_referrer() + else: + instance = kwargs['model_instance'] + defaults['cancel_url'] = self.get_row_action_url('view', instance, mobile=True) + defaults.update(kwargs) + return defaults + + def configure_mobile_row_form(self, form): + """ + Configure the mobile row form. + """ + # TODO: is any of this stuff from configure_form() needed? + # if self.editing: + # model_class = self.get_model_class(error=False) + # if model_class: + # mapper = orm.class_mapper(model_class) + # for key in mapper.primary_key: + # for field in form.fields: + # if field == key.name: + # form.set_readonly(field) + # break + # form.remove_field('uuid') + + self.set_row_labels(form) + + def validate_mobile_row_form(self, form): + controls = self.request.POST.items() + try: + self.form_deserialized = form.validate(controls) + except deform.ValidationFailure: + return False + return True + def preconfigure_mobile_fieldset(self, fieldset): self._preconfigure_fieldset(fieldset) @@ -619,14 +1093,6 @@ class MasterView(View): def get_mobile_row_data(self, parent): return self.get_row_data(parent) - def mobile_row_listitem_field(self): - """ - Must return a FormAlchemy field to be appended to row grid, or ``None`` - if none is desired. - """ - return fa.Field('listitem', value=lambda obj: obj, - renderer=self.mobile_row_listitem_renderer()) - def mobile_row_route_url(self, route_name, **kwargs): route_name = 'mobile.{}.{}_row'.format(self.get_route_prefix(), route_name) return self.request.route_url(route_name, **kwargs) @@ -818,18 +1284,10 @@ class MasterView(View): context['dform'] = form.make_deform_form() return self.render_to_response('edit', context) - def validate_form(self, form): - return form.validate() - - def validate_mobile_form(self, form): - return form.validate() - - def validate_row_form(self, form): - return form.validate() - def save_edit_form(self, form): - self.save_form(form) - self.after_edit(form.fieldset.model) + obj = self.objectify(form, self.form_deserialized) + self.after_edit(obj) + self.Session.flush() def redirect_after_edit(self, instance): return self.redirect(self.get_action_url('view', instance)) @@ -1623,45 +2081,151 @@ class MasterView(View): """ return six.text_type(instance) - def make_form(self, instance, **kwargs): + @classmethod + def get_form_factory(cls): """ - Make a FormAlchemy-based form for use with CRUD views. + Returns the grid factory or class which is to be used when creating new + grid instances. """ - # TODO: Some hacky stuff here, to accommodate old form cruft. Probably - # should refactor forms soon too, but trying to avoid it for the moment. + return getattr(cls, 'form_factory', forms.Form) - kwargs.setdefault('creating', self.creating) - kwargs.setdefault('editing', self.editing) + @classmethod + def get_mobile_form_factory(cls): + """ + Returns the factory or class which is to be used when creating new + mobile forms. + """ + return getattr(cls, 'mobile_form_factory', forms.Form) - fieldset = self.make_fieldset(instance) - self._preconfigure_fieldset(fieldset) - self.configure_fieldset(fieldset) - self._postconfigure_fieldset(fieldset) + @classmethod + def get_row_form_factory(cls): + """ + Returns the factory or class which is to be used when creating new row + forms. + """ + return getattr(cls, 'row_form_factory', forms.Form) - kwargs.setdefault('action_url', self.request.current_route_url(_query=None)) + @classmethod + def get_mobile_row_form_factory(cls): + """ + Returns the factory or class which is to be used when creating new + mobile row forms. + """ + return getattr(cls, 'mobile_row_form_factory', forms.Form) + + def render_file_field(self, path, url=None, filename=None): + """ + Convenience for rendering a file with optional download link + """ + if not filename: + filename = os.path.basename(path) + content = "{} ({})".format(filename, self.readable_size(path)) + if url: + return tags.link_to(content, url) + return content + + def readable_size(self, path): + # TODO: this was shamelessly copied from FormAlchemy ... + length = self.get_size(path) + if length == 0: + return '0 KB' + if length <= 1024: + return '1 KB' + if length > 1048576: + return '%0.02f MB' % (length / 1048576.0) + return '%0.02f KB' % (length / 1024.0) + + def get_size(self, path): + try: + return os.path.getsize(path) + except os.error: + return 0 + + def make_form(self, instance=None, factory=None, fields=None, schema=None, **kwargs): + """ + Creates a new form for the given model class/instance + """ + if factory is None: + factory = self.get_form_factory() + if fields is None: + fields = self.get_form_fields() + if schema is None: + schema = self.make_form_schema() + + # TODO: SQLAlchemy class instance is assumed *unless* we get a dict + # (seems like we should be smarter about this somehow) + # if not self.creating and not isinstance(instance, dict): + if not self.creating: + kwargs['model_instance'] = instance + kwargs = self.make_form_kwargs(**kwargs) + form = factory(fields, schema, **kwargs) + self.configure_form(form) + return form + + def get_form_fields(self): + if hasattr(self, 'form_fields'): + return self.form_fields + # TODO + # raise NotImplementedError + + def make_form_schema(self): + if not self.model_class: + # TODO + raise NotImplementedError + + def make_form_kwargs(self, **kwargs): + """ + Return a dictionary of kwargs to be passed to the factory when creating + new form instances. + """ + defaults = { + 'request': self.request, + 'readonly': self.viewing, + 'model_class': getattr(self, 'model_class', None), + 'action_url': self.request.current_route_url(_query=None), + } if self.creating: kwargs.setdefault('cancel_url', self.get_index_url()) else: + instance = kwargs['model_instance'] kwargs.setdefault('cancel_url', self.get_action_url('view', instance)) - factory = kwargs.pop('factory', forms.AlchemyForm) - kwargs.setdefault('session', self.Session()) - form = factory(self.request, fieldset, **kwargs) - form.readonly = self.viewing - return form + defaults.update(kwargs) + return defaults + + def configure_form(self, form): + """ + Configure the primary form. By default this just sets any primary key + fields to be readonly (if we have a :attr:`model_class`). + """ + if self.editing: + model_class = self.get_model_class(error=False) + if model_class: + mapper = orm.class_mapper(model_class) + for key in mapper.primary_key: + for field in form.fields: + if field == key.name: + form.set_readonly(field) + break + + form.remove_field('uuid') + + self.set_labels(form) + + def validate_form(self, form): + controls = self.request.POST.items() + try: + self.form_deserialized = form.validate(controls) + except deform.ValidationFailure: + return False + return True + + def objectify(self, form, data): + obj = form.schema.objectify(data, context=form.model_instance) + return obj def save_form(self, form): form.save() - def make_fieldset(self, instance, **kwargs): - """ - Make a FormAlchemy fieldset for the given model instance. - """ - kwargs.setdefault('session', self.Session()) - kwargs.setdefault('request', self.request) - fieldset = fa.FieldSet(instance, **kwargs) - fieldset.prettify = prettify - return fieldset - def _preconfigure_fieldset(self, fieldset): pass @@ -1756,8 +2320,19 @@ class MasterView(View): self.get_instance_title(parent)), 'form': form}) + # TODO: still need to verify this logic def save_create_row_form(self, form): - self.save_row_form(form) + # self.before_create(form) + # with self.Session().no_autoflush: + # obj = self.objectify(form, self.form_deserialized) + # self.before_create_flush(obj, form) + obj = self.objectify(form, self.form_deserialized) + self.Session.add(obj) + self.Session.flush() + return obj + + # def save_create_row_form(self, form): + # self.save_row_form(form) def before_create_row(self, form): pass @@ -1879,11 +2454,13 @@ class MasterView(View): mobile=True) def save_edit_row_form(self, form): - self.save_row_form(form) - self.after_edit_row(form.fieldset.model) + obj = self.objectify(form, self.form_deserialized) + self.after_edit_row(obj) + self.Session.flush() + return obj - def save_row_form(self, form): - form.save() + # def save_row_form(self, form): + # form.save() def after_edit_row(self, row): """ @@ -1926,38 +2503,86 @@ class MasterView(View): raise httpexceptions.HTTPNotFound() return instance - def make_row_form(self, instance, **kwargs): + def make_row_form(self, instance=None, factory=None, fields=None, schema=None, **kwargs): """ - Make a FormAlchemy form for use with CRUD views for a data *row*. + Creates a new row form for the given model class/instance. """ - # TODO: Some hacky stuff here, to accommodate old form cruft. Probably - # should refactor forms soon too, but trying to avoid it for the moment. + if factory is None: + factory = self.get_row_form_factory() + if fields is None: + fields = self.get_row_form_fields() + if schema is None: + schema = self.make_row_form_schema() - kwargs.setdefault('creating', self.creating) - kwargs.setdefault('editing', self.editing) - - fieldset = self.make_fieldset(instance) - self._preconfigure_row_fieldset(fieldset) - self.configure_row_fieldset(fieldset) - - kwargs.setdefault('action_url', self.request.current_route_url(_query=None)) - if 'cancel_url' not in kwargs: - if self.creating: - kwargs['cancel_url'] = self.get_action_url('view', self.get_parent(instance)) - else: - kwargs['cancel_url'] = self.get_row_action_url('view', instance) - - kwargs.setdefault('session', self.Session()) - form = forms.AlchemyForm(self.request, fieldset, **kwargs) - form.readonly = self.viewing + if not self.creating: + kwargs['model_instance'] = instance + kwargs = self.make_row_form_kwargs(**kwargs) + form = factory(fields, schema, **kwargs) + self.configure_row_form(form) return form + def get_row_form_fields(self): + if hasattr(self, 'row_form_fields'): + return self.row_form_fields + # TODO + # raise NotImplementedError + + def make_row_form_schema(self): + if not self.model_row_class: + # TODO + raise NotImplementedError + + def make_row_form_kwargs(self, **kwargs): + """ + Return a dictionary of kwargs to be passed to the factory when creating + new row forms. + """ + defaults = { + 'request': self.request, + 'readonly': self.viewing, + 'model_class': getattr(self, 'model_row_class', None), + 'action_url': self.request.current_route_url(_query=None), + } + if self.creating: + kwargs.setdefault('cancel_url', self.request.get_referrer()) + else: + instance = kwargs['model_instance'] + kwargs.setdefault('cancel_url', self.get_row_action_url('view', instance)) + defaults.update(kwargs) + return defaults + + def configure_row_form(self, form): + """ + Configure a row form. + """ + # TODO: is any of this stuff from configure_form() needed? + # if self.editing: + # model_class = self.get_model_class(error=False) + # if model_class: + # mapper = orm.class_mapper(model_class) + # for key in mapper.primary_key: + # for field in form.fields: + # if field == key.name: + # form.set_readonly(field) + # break + # form.remove_field('uuid') + + self.set_row_labels(form) + def _preconfigure_row_fieldset(self, fs): pass def configure_row_fieldset(self, fs): fs.configure() + def validate_row_form(self, form): + controls = self.request.POST.items() + try: + self.form_deserialized = form.validate(controls) + except deform.ValidationFailure: + return False + return True + def get_row_action_url(self, action, row, mobile=False): """ Generate a URL for the given action on the given row. diff --git a/tailbone/views/master2.py b/tailbone/views/master2.py deleted file mode 100644 index 233f5271..00000000 --- a/tailbone/views/master2.py +++ /dev/null @@ -1,422 +0,0 @@ -# -*- coding: utf-8; -*- -################################################################################ -# -# Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar -# -# This file is part of Rattail. -# -# Rattail is free software: you can redistribute it and/or modify it under the -# terms of the GNU General Public License as published by the Free Software -# Foundation, either version 3 of the License, or (at your option) any later -# version. -# -# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY -# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more -# details. -# -# You should have received a copy of the GNU General Public License along with -# Rattail. If not, see . -# -################################################################################ -""" -Master View -""" - -from __future__ import unicode_literals, absolute_import - -import sqlalchemy_continuum as continuum - -from tailbone import grids -from tailbone.views import MasterView - - -class MasterView2(MasterView): - """ - Base "master" view class. All model master views should derive from this. - """ - sortable = True - rows_pageable = True - mobile_pageable = True - labels = {'uuid': "UUID"} - - @classmethod - def get_grid_factory(cls): - """ - Returns the grid factory or class which is to be used when creating new - grid instances. - """ - return getattr(cls, 'grid_factory', grids.Grid) - - @classmethod - def get_row_grid_factory(cls): - """ - Returns the grid factory or class which is to be used when creating new - row grid instances. - """ - return getattr(cls, 'row_grid_factory', grids.Grid) - - @classmethod - def get_version_grid_factory(cls): - """ - Returns the grid factory or class which is to be used when creating new - version grid instances. - """ - return getattr(cls, 'version_grid_factory', grids.Grid) - - @classmethod - def get_mobile_grid_factory(cls): - """ - Must return a callable to be used when creating new mobile grid - instances. Instead of overriding this, you can set - :attr:`mobile_grid_factory`. Default factory is :class:`MobileGrid`. - """ - return getattr(cls, 'mobile_grid_factory', grids.MobileGrid) - - @classmethod - def get_mobile_row_grid_factory(cls): - """ - Must return a callable to be used when creating new mobile row grid - instances. Instead of overriding this, you can set - :attr:`mobile_row_grid_factory`. Default factory is :class:`MobileGrid`. - """ - return getattr(cls, 'mobile_row_grid_factory', grids.MobileGrid) - - def get_effective_data(self, session=None, **kwargs): - """ - Convenience method which returns the "effective" data for the master - grid, filtered and sorted to match what would show on the UI, but not - paged etc. - """ - if session is None: - session = self.Session() - kwargs.setdefault('pageable', False) - grid = self.make_grid(session=session, **kwargs) - return grid.make_visible_data() - - def make_grid(self, factory=None, key=None, data=None, columns=None, **kwargs): - """ - Creates a new grid instance - """ - if factory is None: - factory = self.get_grid_factory() - if key is None: - key = self.get_grid_key() - if data is None: - data = self.get_data(session=kwargs.get('session')) - if columns is None: - columns = self.get_grid_columns() - - kwargs.setdefault('request', self.request) - kwargs = self.make_grid_kwargs(**kwargs) - grid = factory(key, data, columns, **kwargs) - self.configure_grid(grid) - grid.load_settings() - return grid - - def make_row_grid(self, factory=None, key=None, data=None, columns=None, **kwargs): - """ - Make and return a new (configured) rows grid instance. - """ - instance = kwargs.pop('instance', None) - if not instance: - instance = self.get_instance() - - if factory is None: - factory = self.get_row_grid_factory() - if key is None: - key = self.get_row_grid_key() - if data is None: - data = self.get_row_data(instance) - if columns is None: - columns = self.get_row_grid_columns() - - kwargs.setdefault('request', self.request) - kwargs = self.make_row_grid_kwargs(**kwargs) - - grid = factory(key, data, columns, **kwargs) - self.configure_row_grid(grid) - grid.load_settings() - return grid - - def make_version_grid(self, factory=None, key=None, data=None, columns=None, **kwargs): - """ - Creates a new version grid instance - """ - instance = kwargs.pop('instance', None) - if not instance: - instance = self.get_instance() - - if factory is None: - factory = self.get_version_grid_factory() - if key is None: - key = self.get_version_grid_key() - if data is None: - data = self.get_version_data(instance) - if columns is None: - columns = self.get_version_grid_columns() - - kwargs.setdefault('request', self.request) - kwargs = self.make_version_grid_kwargs(**kwargs) - grid = factory(key, data, columns, **kwargs) - self.configure_version_grid(grid) - grid.load_settings() - return grid - - def make_mobile_grid(self, factory=None, key=None, data=None, columns=None, **kwargs): - """ - Creates a new mobile grid instance - """ - if factory is None: - factory = self.get_mobile_grid_factory() - if key is None: - key = self.get_mobile_grid_key() - if data is None: - data = self.get_mobile_data(session=kwargs.get('session')) - if columns is None: - columns = self.get_mobile_grid_columns() - - kwargs.setdefault('request', self.request) - kwargs.setdefault('mobile', True) - kwargs = self.make_mobile_grid_kwargs(**kwargs) - grid = factory(key, data, columns, **kwargs) - self.configure_mobile_grid(grid) - grid.load_settings() - return grid - - def make_mobile_row_grid(self, factory=None, key=None, data=None, columns=None, **kwargs): - """ - Make a new (configured) rows grid instance for mobile. - """ - instance = kwargs.pop('instance', self.get_instance()) - - if factory is None: - factory = self.get_mobile_row_grid_factory() - if key is None: - key = 'mobile.{}.{}'.format(self.get_grid_key(), self.request.matchdict[self.get_model_key()]) - if data is None: - data = self.get_mobile_row_data(instance) - if columns is None: - columns = self.get_mobile_row_grid_columns() - - kwargs.setdefault('request', self.request) - kwargs.setdefault('mobile', True) - kwargs = self.make_mobile_row_grid_kwargs(**kwargs) - grid = factory(key, data, columns, **kwargs) - self.configure_mobile_row_grid(grid) - grid.load_settings() - return grid - - def get_grid_columns(self): - if hasattr(self, 'grid_columns'): - return self.grid_columns - # TODO - raise NotImplementedError - - def get_row_grid_columns(self): - if hasattr(self, 'row_grid_columns'): - return self.row_grid_columns - # TODO - raise NotImplementedError - - def get_version_grid_columns(self): - if hasattr(self, 'version_grid_columns'): - return self.version_grid_columns - # TODO - return [ - 'issued_at', - 'user', - 'remote_addr', - 'comment', - ] - - def get_mobile_grid_columns(self): - if hasattr(self, 'mobile_grid_columns'): - return self.mobile_grid_columns - # TODO - return ['listitem'] - - def get_mobile_row_grid_columns(self): - if hasattr(self, 'mobile_row_grid_columns'): - return self.mobile_row_grid_columns - # TODO - return ['listitem'] - - def make_grid_kwargs(self, **kwargs): - """ - Return a dictionary of kwargs to be passed to the factory when creating - new grid instances. - """ - defaults = { - 'model_class': getattr(self, 'model_class', None), - 'width': 'full', - 'filterable': self.filterable, - 'sortable': self.sortable, - 'pageable': self.pageable, - 'extra_row_class': self.grid_extra_class, - 'url': lambda obj: self.get_action_url('view', obj), - 'checkboxes': self.checkboxes or ( - self.mergeable and self.request.has_perm('{}.merge'.format(self.get_permission_prefix()))), - 'checked': self.checked, - } - if 'main_actions' not in kwargs and 'more_actions' not in kwargs: - main, more = self.get_grid_actions() - defaults['main_actions'] = main - defaults['more_actions'] = more - defaults.update(kwargs) - return defaults - - def make_row_grid_kwargs(self, **kwargs): - """ - Return a dict of kwargs to be used when constructing a new rows grid. - """ - permission_prefix = self.get_permission_prefix() - - defaults = { - 'model_class': self.model_row_class, - 'width': 'full', - 'filterable': self.rows_filterable, - 'sortable': self.rows_sortable, - 'pageable': self.rows_pageable, - 'default_pagesize': self.rows_default_pagesize, - 'extra_row_class': self.row_grid_extra_class, - 'url': lambda obj: self.get_row_action_url('view', obj), - } - - if self.has_rows and 'main_actions' not in defaults: - actions = [] - - # view action - if self.rows_viewable: - view = lambda r, i: self.get_row_action_url('view', r) - actions.append(grids.GridAction('view', icon='zoomin', url=view)) - - # edit action - if self.rows_editable: - actions.append(grids.GridAction('edit', icon='pencil', url=self.row_edit_action_url)) - - # delete action - if self.rows_deletable and self.request.has_perm('{}.delete_row'.format(permission_prefix)): - actions.append(grids.GridAction('delete', icon='trash', url=self.row_delete_action_url)) - defaults['delete_speedbump'] = self.rows_deletable_speedbump - - defaults['main_actions'] = actions - - defaults.update(kwargs) - return defaults - - def make_version_grid_kwargs(self, **kwargs): - """ - Return a dictionary of kwargs to be passed to the factory when - constructing a new version grid. - """ - defaults = { - 'model_class': continuum.transaction_class(self.get_model_class()), - 'width': 'full', - 'pageable': True, - } - if 'main_actions' not in kwargs: - route = '{}.version'.format(self.get_route_prefix()) - instance = kwargs.get('instance') or self.get_instance() - url = lambda txn, i: self.request.route_url(route, uuid=instance.uuid, txnid=txn.id) - defaults['main_actions'] = [ - self.make_action('view', icon='zoomin', url=url), - ] - defaults.update(kwargs) - return defaults - - def make_mobile_grid_kwargs(self, **kwargs): - """ - Must return a dictionary of kwargs to be passed to the factory when - creating new mobile grid instances. - """ - defaults = { - 'model_class': getattr(self, 'model_class', None), - 'pageable': self.mobile_pageable, - 'sortable': False, - 'filterable': self.mobile_filterable, - 'renderers': self.make_mobile_grid_renderers(), - 'url': lambda obj: self.get_action_url('view', obj, mobile=True), - } - # TODO: this seems wrong.. - if self.mobile_filterable: - defaults['filters'] = self.make_mobile_filters() - defaults.update(kwargs) - return defaults - - def make_mobile_row_grid_kwargs(self, **kwargs): - """ - Must return a dictionary of kwargs to be passed to the factory when - creating new mobile *row* grid instances. - """ - defaults = { - 'model_class': self.model_row_class, - # TODO - 'pageable': self.pageable, - 'sortable': False, - 'filterable': self.mobile_rows_filterable, - 'renderers': self.make_mobile_row_grid_renderers(), - 'url': lambda obj: self.get_row_action_url('view', obj, mobile=True), - } - # TODO: this seems wrong.. - if self.mobile_rows_filterable: - defaults['filters'] = self.make_mobile_row_filters() - defaults.update(kwargs) - return defaults - - def make_mobile_grid_renderers(self): - return { - 'listitem': self.render_mobile_listitem, - } - - def render_mobile_listitem(self, obj, i): - return obj - - def make_mobile_row_grid_renderers(self): - return { - 'listitem': self.render_mobile_row_listitem, - } - - def render_mobile_row_listitem(self, obj, i): - return obj - - def grid_extra_class(self, obj, i): - """ - Returns string of extra class(es) for the table row corresponding to - the given object, or ``None``. - """ - - def row_grid_extra_class(self, obj, i): - """ - Returns string of extra class(es) for the table row corresponding to - the given row object, or ``None``. - """ - - def set_labels(self, obj): - for key, label in self.labels.items(): - obj.set_label(key, label) - - def configure_grid(self, grid): - self.set_labels(grid) - - def configure_row_grid(self, grid): - pass - - def configure_version_grid(self, g): - g.set_sort_defaults('issued_at', 'desc') - g.set_renderer('comment', self.render_version_comment) - g.set_label('issued_at', "Changed") - g.set_label('user', "Changed by") - g.set_label('remote_addr', "IP Address") - # TODO: why does this render '#' as url? - # g.set_link('issued_at') - - def render_version_comment(self, transaction, column): - return transaction.meta.get('comment', "") - - def configure_mobile_grid(self, grid): - pass - - def configure_mobile_row_grid(self, grid): - pass diff --git a/tailbone/views/master3.py b/tailbone/views/master3.py deleted file mode 100644 index 08bbabb7..00000000 --- a/tailbone/views/master3.py +++ /dev/null @@ -1,178 +0,0 @@ -# -*- coding: utf-8; -*- -################################################################################ -# -# Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar -# -# This file is part of Rattail. -# -# Rattail is free software: you can redistribute it and/or modify it under the -# terms of the GNU General Public License as published by the Free Software -# Foundation, either version 3 of the License, or (at your option) any later -# version. -# -# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY -# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more -# details. -# -# You should have received a copy of the GNU General Public License along with -# Rattail. If not, see . -# -################################################################################ -""" -Master View -""" - -from __future__ import unicode_literals, absolute_import - -import os - -from sqlalchemy import orm - -import deform -from webhelpers2.html import tags - -from tailbone import forms2 as forms -from tailbone.views import MasterView2 - - -class MasterView3(MasterView2): - """ - Base "master" view class. All model master views should derive from this. - """ - - @classmethod - def get_form_factory(cls): - """ - Returns the grid factory or class which is to be used when creating new - grid instances. - """ - return getattr(cls, 'form_factory', forms.Form) - - def make_form(self, instance=None, factory=None, fields=None, schema=None, **kwargs): - """ - Creates a new form for the given model class/instance - """ - if factory is None: - factory = self.get_form_factory() - if fields is None: - fields = self.get_form_fields() - if schema is None: - schema = self.make_form_schema() - - # TODO: SQLAlchemy class instance is assumed *unless* we get a dict - # (seems like we should be smarter about this somehow) - # if not self.creating and not isinstance(instance, dict): - if not self.creating: - kwargs['model_instance'] = instance - kwargs = self.make_form_kwargs(**kwargs) - form = factory(fields, schema, **kwargs) - self.configure_form(form) - return form - - def make_form_schema(self): - if not self.model_class: - # TODO - raise NotImplementedError - - def get_form_fields(self): - if hasattr(self, 'form_fields'): - return self.form_fields - # TODO - # raise NotImplementedError - - def make_form_kwargs(self, **kwargs): - """ - Return a dictionary of kwargs to be passed to the factory when creating - new form instances. - """ - defaults = { - 'request': self.request, - 'readonly': self.viewing, - 'model_class': getattr(self, 'model_class', None), - 'action_url': self.request.current_route_url(_query=None), - } - if self.creating: - kwargs.setdefault('cancel_url', self.get_index_url()) - else: - instance = kwargs['model_instance'] - kwargs.setdefault('cancel_url', self.get_action_url('view', instance)) - defaults.update(kwargs) - return defaults - - def configure_form(self, form): - """ - Configure the primary form. By default this just sets any primary key - fields to be readonly (if we have a :attr:`model_class`). - """ - if self.editing: - model_class = self.get_model_class(error=False) - if model_class: - mapper = orm.class_mapper(model_class) - for key in mapper.primary_key: - for field in form.fields: - if field == key.name: - form.set_readonly(field) - break - - form.remove_field('uuid') - - self.set_labels(form) - - def render_file_field(self, path, url=None, filename=None): - """ - Convenience for rendering a file with optional download link - """ - if not filename: - filename = os.path.basename(path) - content = "{} ({})".format(filename, self.readable_size(path)) - if url: - return tags.link_to(content, url) - return content - - def readable_size(self, path): - # TODO: this was shamelessly copied from FormAlchemy ... - length = self.get_size(path) - if length == 0: - return '0 KB' - if length <= 1024: - return '1 KB' - if length > 1048576: - return '%0.02f MB' % (length / 1048576.0) - return '%0.02f KB' % (length / 1024.0) - - def get_size(self, path): - try: - return os.path.getsize(path) - except os.error: - return 0 - - def validate_form(self, form): - controls = self.request.POST.items() - try: - self.form_deserialized = form.validate(controls) - except deform.ValidationFailure: - return False - return True - - def objectify(self, form, data): - obj = form.schema.objectify(data, context=form.model_instance) - return obj - - def save_create_form(self, form): - self.before_create(form) - with self.Session().no_autoflush: - obj = self.objectify(form, self.form_deserialized) - self.before_create_flush(obj, form) - self.Session.add(obj) - self.Session.flush() - return obj - - def before_create_flush(self, obj, form): - pass - - def save_edit_form(self, form): - obj = self.objectify(form, self.form_deserialized) - self.after_edit(obj) - self.Session.flush() diff --git a/tailbone/views/master4.py b/tailbone/views/master4.py deleted file mode 100644 index 07ae1247..00000000 --- a/tailbone/views/master4.py +++ /dev/null @@ -1,319 +0,0 @@ -# -*- coding: utf-8; -*- -################################################################################ -# -# Rattail -- Retail Software Framework -# Copyright © 2010-2018 Lance Edgar -# -# This file is part of Rattail. -# -# Rattail is free software: you can redistribute it and/or modify it under the -# terms of the GNU General Public License as published by the Free Software -# Foundation, either version 3 of the License, or (at your option) any later -# version. -# -# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY -# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more -# details. -# -# You should have received a copy of the GNU General Public License along with -# Rattail. If not, see . -# -################################################################################ -""" -Master View (v4) -""" - -from __future__ import unicode_literals, absolute_import - -import deform - -from tailbone import forms2 as forms -from tailbone.views import MasterView3 - - -class MasterView4(MasterView3): - """ - Base "master" view class. All model master views should derive from this. - """ - row_labels = {} - - def make_mobile_form(self, instance=None, factory=None, fields=None, schema=None, **kwargs): - """ - Creates a new mobile form for the given model class/instance. - """ - if factory is None: - factory = self.get_mobile_form_factory() - if fields is None: - fields = self.get_mobile_form_fields() - if schema is None: - schema = self.make_mobile_form_schema() - - if not self.creating: - kwargs['model_instance'] = instance - kwargs = self.make_mobile_form_kwargs(**kwargs) - form = factory(fields, schema, **kwargs) - self.configure_mobile_form(form) - return form - - def make_row_form(self, instance=None, factory=None, fields=None, schema=None, **kwargs): - """ - Creates a new row form for the given model class/instance. - """ - if factory is None: - factory = self.get_row_form_factory() - if fields is None: - fields = self.get_row_form_fields() - if schema is None: - schema = self.make_row_form_schema() - - if not self.creating: - kwargs['model_instance'] = instance - kwargs = self.make_row_form_kwargs(**kwargs) - form = factory(fields, schema, **kwargs) - self.configure_row_form(form) - return form - - def make_mobile_row_form(self, instance=None, factory=None, fields=None, schema=None, **kwargs): - """ - Creates a new mobile form for the given model class/instance. - """ - if factory is None: - factory = self.get_mobile_row_form_factory() - if fields is None: - fields = self.get_mobile_row_form_fields() - if schema is None: - schema = self.make_mobile_row_form_schema() - - if not self.creating: - kwargs['model_instance'] = instance - kwargs = self.make_mobile_row_form_kwargs(**kwargs) - form = factory(fields, schema, **kwargs) - self.configure_mobile_row_form(form) - return form - - @classmethod - def get_mobile_form_factory(cls): - """ - Returns the factory or class which is to be used when creating new - mobile forms. - """ - return getattr(cls, 'mobile_form_factory', forms.Form) - - @classmethod - def get_row_form_factory(cls): - """ - Returns the factory or class which is to be used when creating new row - forms. - """ - return getattr(cls, 'row_form_factory', forms.Form) - - @classmethod - def get_mobile_row_form_factory(cls): - """ - Returns the factory or class which is to be used when creating new - mobile row forms. - """ - return getattr(cls, 'mobile_row_form_factory', forms.Form) - - def make_mobile_form_schema(self): - if not self.model_class: - # TODO - raise NotImplementedError - - def make_row_form_schema(self): - if not self.model_row_class: - # TODO - raise NotImplementedError - - def make_mobile_row_form_schema(self): - if not self.model_row_class: - # TODO - raise NotImplementedError - - def get_mobile_form_fields(self): - if hasattr(self, 'mobile_form_fields'): - return self.mobile_form_fields - # TODO - # raise NotImplementedError - - def get_row_form_fields(self): - if hasattr(self, 'row_form_fields'): - return self.row_form_fields - # TODO - # raise NotImplementedError - - def get_mobile_row_form_fields(self): - if hasattr(self, 'mobile_row_form_fields'): - return self.mobile_row_form_fields - # TODO - # raise NotImplementedError - - def make_mobile_form_kwargs(self, **kwargs): - """ - Return a dictionary of kwargs to be passed to the factory when creating - new mobile forms. - """ - defaults = { - 'request': self.request, - 'readonly': self.viewing, - 'model_class': getattr(self, 'model_class', None), - 'action_url': self.request.current_route_url(_query=None), - } - if self.creating: - defaults['cancel_url'] = self.get_index_url(mobile=True) - else: - instance = kwargs['model_instance'] - defaults['cancel_url'] = self.get_action_url('view', instance, mobile=True) - defaults.update(kwargs) - return defaults - - def make_row_form_kwargs(self, **kwargs): - """ - Return a dictionary of kwargs to be passed to the factory when creating - new row forms. - """ - defaults = { - 'request': self.request, - 'readonly': self.viewing, - 'model_class': getattr(self, 'model_row_class', None), - 'action_url': self.request.current_route_url(_query=None), - } - if self.creating: - kwargs.setdefault('cancel_url', self.request.get_referrer()) - else: - instance = kwargs['model_instance'] - kwargs.setdefault('cancel_url', self.get_row_action_url('view', instance)) - defaults.update(kwargs) - return defaults - - def make_mobile_row_form_kwargs(self, **kwargs): - """ - Return a dictionary of kwargs to be passed to the factory when creating - new mobile row forms. - """ - defaults = { - 'request': self.request, - 'readonly': self.viewing, - 'model_class': getattr(self, 'model_row_class', None), - 'action_url': self.request.current_route_url(_query=None), - } - if self.creating: - defaults['cancel_url'] = self.request.get_referrer() - else: - instance = kwargs['model_instance'] - defaults['cancel_url'] = self.get_row_action_url('view', instance, mobile=True) - defaults.update(kwargs) - return defaults - - def configure_mobile_form(self, form): - """ - Configure the primary mobile form. - """ - # TODO: is any of this stuff from configure_form() needed? - # if self.editing: - # model_class = self.get_model_class(error=False) - # if model_class: - # mapper = orm.class_mapper(model_class) - # for key in mapper.primary_key: - # for field in form.fields: - # if field == key.name: - # form.set_readonly(field) - # break - # form.remove_field('uuid') - - self.set_labels(form) - - def configure_row_grid(self, grid): - super(MasterView4, self).configure_row_grid(grid) - self.set_row_labels(grid) - - def configure_row_form(self, form): - """ - Configure a row form. - """ - # TODO: is any of this stuff from configure_form() needed? - # if self.editing: - # model_class = self.get_model_class(error=False) - # if model_class: - # mapper = orm.class_mapper(model_class) - # for key in mapper.primary_key: - # for field in form.fields: - # if field == key.name: - # form.set_readonly(field) - # break - # form.remove_field('uuid') - - self.set_row_labels(form) - - def configure_mobile_row_form(self, form): - """ - Configure the mobile row form. - """ - # TODO: is any of this stuff from configure_form() needed? - # if self.editing: - # model_class = self.get_model_class(error=False) - # if model_class: - # mapper = orm.class_mapper(model_class) - # for key in mapper.primary_key: - # for field in form.fields: - # if field == key.name: - # form.set_readonly(field) - # break - # form.remove_field('uuid') - - self.set_row_labels(form) - - def set_row_labels(self, obj): - for key, label in self.row_labels.items(): - obj.set_label(key, label) - - def validate_mobile_form(self, form): - controls = self.request.POST.items() - try: - self.form_deserialized = form.validate(controls) - except deform.ValidationFailure: - return False - return True - - def validate_row_form(self, form): - controls = self.request.POST.items() - try: - self.form_deserialized = form.validate(controls) - except deform.ValidationFailure: - return False - return True - - def validate_mobile_row_form(self, form): - controls = self.request.POST.items() - try: - self.form_deserialized = form.validate(controls) - except deform.ValidationFailure: - return False - return True - - def save_mobile_create_form(self, form): - self.before_create(form) - with self.Session.no_autoflush: - obj = self.objectify(form, self.form_deserialized) - self.before_create_flush(obj, form) - self.Session.add(obj) - self.Session.flush() - return obj - - # TODO: still need to verify this logic - def save_create_row_form(self, form): - # self.before_create(form) - # with self.Session().no_autoflush: - # obj = self.objectify(form, self.form_deserialized) - # self.before_create_flush(obj, form) - obj = self.objectify(form, self.form_deserialized) - self.Session.add(obj) - self.Session.flush() - return obj - - def save_edit_row_form(self, form): - obj = self.objectify(form, self.form_deserialized) - self.after_edit_row(obj) - self.Session.flush() - return obj diff --git a/tailbone/views/messages.py b/tailbone/views/messages.py index d216e8ce..5d2060a5 100644 --- a/tailbone/views/messages.py +++ b/tailbone/views/messages.py @@ -37,7 +37,7 @@ from pyramid import httpexceptions from webhelpers2.html import tags, HTML from tailbone.db import Session -from tailbone.views import MasterView4 as MasterView +from tailbone.views import MasterView from tailbone.util import raw_datetime diff --git a/tailbone/views/people.py b/tailbone/views/people.py index 3a3f1773..051d19af 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -34,7 +34,7 @@ from rattail.db import model, api from pyramid.httpexceptions import HTTPFound, HTTPNotFound from webhelpers2.html import HTML, tags -from tailbone.views import MasterView4 as MasterView, AutocompleteView +from tailbone.views import MasterView, AutocompleteView class PeopleView(MasterView): diff --git a/tailbone/views/principal.py b/tailbone/views/principal.py index 494fda2d..dd82522b 100644 --- a/tailbone/views/principal.py +++ b/tailbone/views/principal.py @@ -35,7 +35,7 @@ import wtforms from webhelpers2.html import HTML from tailbone.db import Session -from tailbone.views import MasterView4 as MasterView +from tailbone.views import MasterView class PrincipalMasterView(MasterView): diff --git a/tailbone/views/products.py b/tailbone/views/products.py index f8e07a6d..48b445d7 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -47,7 +47,7 @@ from webhelpers2.html import tags, HTML from tailbone import forms2 as forms, grids from tailbone.db import Session -from tailbone.views import MasterView4 as MasterView, AutocompleteView +from tailbone.views import MasterView, AutocompleteView from tailbone.progress import SessionProgress from tailbone.util import raw_datetime diff --git a/tailbone/views/purchases/core.py b/tailbone/views/purchases/core.py index c8d0ddc8..d214b53a 100644 --- a/tailbone/views/purchases/core.py +++ b/tailbone/views/purchases/core.py @@ -31,7 +31,7 @@ from rattail.db import model from webhelpers2.html import HTML, tags from tailbone.db import Session -from tailbone.views import MasterView4 as MasterView +from tailbone.views import MasterView class PurchaseView(MasterView): diff --git a/tailbone/views/purchases/credits.py b/tailbone/views/purchases/credits.py index 2188df3d..0d4b46ad 100644 --- a/tailbone/views/purchases/credits.py +++ b/tailbone/views/purchases/credits.py @@ -31,7 +31,7 @@ from rattail.db import model from webhelpers2.html import tags from tailbone import grids -from tailbone.views import MasterView4 as MasterView +from tailbone.views import MasterView class PurchaseCreditView(MasterView): diff --git a/tailbone/views/purchasing/batch.py b/tailbone/views/purchasing/batch.py index 6a41cdca..d6699bd4 100644 --- a/tailbone/views/purchasing/batch.py +++ b/tailbone/views/purchasing/batch.py @@ -38,7 +38,7 @@ from webhelpers2.html import tags # from tailbone import forms from tailbone import forms2 -from tailbone.views.batch import BatchMasterView4 as BatchMasterView +from tailbone.views.batch import BatchMasterView class PurchasingBatchView(BatchMasterView): diff --git a/tailbone/views/reportcodes.py b/tailbone/views/reportcodes.py index db82a743..b50d138f 100644 --- a/tailbone/views/reportcodes.py +++ b/tailbone/views/reportcodes.py @@ -28,7 +28,7 @@ from __future__ import unicode_literals, absolute_import from rattail.db import model -from tailbone.views import MasterView4 as MasterView +from tailbone.views import MasterView class ReportCodesView(MasterView): diff --git a/tailbone/views/settings.py b/tailbone/views/settings.py index 5eff7d34..508fd9c6 100644 --- a/tailbone/views/settings.py +++ b/tailbone/views/settings.py @@ -32,7 +32,7 @@ from rattail.db import model import colander -from tailbone.views import MasterView4 as MasterView +from tailbone.views import MasterView class SettingsView(MasterView): diff --git a/tailbone/views/shifts/core.py b/tailbone/views/shifts/core.py index 0a15aa05..bb343143 100644 --- a/tailbone/views/shifts/core.py +++ b/tailbone/views/shifts/core.py @@ -33,7 +33,7 @@ import humanize from rattail.db import model from rattail.time import localtime -from tailbone.views import MasterView4 as MasterView +from tailbone.views import MasterView def render_shift_length(shift, field): diff --git a/tailbone/views/stores.py b/tailbone/views/stores.py index 7fd989b4..660e14e3 100644 --- a/tailbone/views/stores.py +++ b/tailbone/views/stores.py @@ -32,7 +32,7 @@ from rattail.db import model import colander -from tailbone.views import MasterView4 as MasterView +from tailbone.views import MasterView class StoresView(MasterView): diff --git a/tailbone/views/subdepartments.py b/tailbone/views/subdepartments.py index 31b6a43d..4550999f 100644 --- a/tailbone/views/subdepartments.py +++ b/tailbone/views/subdepartments.py @@ -29,7 +29,7 @@ from __future__ import unicode_literals, absolute_import from rattail.db import model from tailbone.db import Session -from tailbone.views import MasterView4 as MasterView +from tailbone.views import MasterView class SubdepartmentsView(MasterView): diff --git a/tailbone/views/tables.py b/tailbone/views/tables.py index e8ddfc77..3c373de1 100644 --- a/tailbone/views/tables.py +++ b/tailbone/views/tables.py @@ -26,7 +26,7 @@ Views with info about the underlying Rattail tables from __future__ import unicode_literals, absolute_import -from tailbone.views import MasterView4 as MasterView +from tailbone.views import MasterView class TablesView(MasterView): diff --git a/tailbone/views/taxes.py b/tailbone/views/taxes.py index 4a26dd35..99177dea 100644 --- a/tailbone/views/taxes.py +++ b/tailbone/views/taxes.py @@ -28,7 +28,7 @@ from __future__ import unicode_literals, absolute_import from rattail.db import model -from tailbone.views import MasterView4 as MasterView +from tailbone.views import MasterView class TaxesView(MasterView): diff --git a/tailbone/views/tempmon/core.py b/tailbone/views/tempmon/core.py index 11a8cb55..6b85944e 100644 --- a/tailbone/views/tempmon/core.py +++ b/tailbone/views/tempmon/core.py @@ -32,7 +32,7 @@ from tailbone import views from tailbone.db import TempmonSession -class MasterView(views.MasterView4): +class MasterView(views.MasterView): """ Base class for tempmon views. """ diff --git a/tailbone/views/trainwreck.py b/tailbone/views/trainwreck.py index 6c851524..2c8c2c62 100644 --- a/tailbone/views/trainwreck.py +++ b/tailbone/views/trainwreck.py @@ -31,7 +31,7 @@ import six from rattail.time import localtime from tailbone.db import TrainwreckSession -from tailbone.views import MasterView4 as MasterView +from tailbone.views import MasterView class TransactionView(MasterView): diff --git a/tailbone/views/upgrades.py b/tailbone/views/upgrades.py index 13447ff7..733be540 100644 --- a/tailbone/views/upgrades.py +++ b/tailbone/views/upgrades.py @@ -43,7 +43,7 @@ from rattail.upgrades import get_upgrade_handler from deform import widget as dfwidget from webhelpers2.html import tags, HTML -from tailbone.views import MasterView4 as MasterView +from tailbone.views import MasterView from tailbone.progress import SessionProgress, get_progress_session diff --git a/tailbone/views/users.py b/tailbone/views/users.py index 48e05315..0e00e5f2 100644 --- a/tailbone/views/users.py +++ b/tailbone/views/users.py @@ -40,7 +40,7 @@ from webhelpers2.html import HTML, tags from tailbone import forms2 as forms from tailbone.db import Session -from tailbone.views import MasterView4 as MasterView +from tailbone.views import MasterView from tailbone.views.principal import PrincipalMasterView, PermissionsRenderer diff --git a/tailbone/views/vendors/catalogs.py b/tailbone/views/vendors/catalogs.py index 16e8417a..a09110cf 100644 --- a/tailbone/views/vendors/catalogs.py +++ b/tailbone/views/vendors/catalogs.py @@ -39,7 +39,7 @@ from webhelpers2.html import tags from tailbone import forms2 as forms from tailbone.db import Session -from tailbone.views.batch import FileBatchMasterView4 as FileBatchMasterView +from tailbone.views.batch import FileBatchMasterView log = logging.getLogger(__name__) diff --git a/tailbone/views/vendors/core.py b/tailbone/views/vendors/core.py index c36bcb25..60e43682 100644 --- a/tailbone/views/vendors/core.py +++ b/tailbone/views/vendors/core.py @@ -32,7 +32,7 @@ from rattail.db import model from webhelpers2.html import tags -from tailbone.views import MasterView4 as MasterView, AutocompleteView +from tailbone.views import MasterView, AutocompleteView class VendorsView(MasterView): diff --git a/tailbone/views/vendors/invoices.py b/tailbone/views/vendors/invoices.py index ad7dee11..dfc9f78b 100644 --- a/tailbone/views/vendors/invoices.py +++ b/tailbone/views/vendors/invoices.py @@ -33,7 +33,7 @@ from rattail.vendors.invoices import iter_invoice_parsers, require_invoice_parse import colander from deform import widget as dfwidget -from tailbone.views.batch import FileBatchMasterView4 as FileBatchMasterView +from tailbone.views.batch import FileBatchMasterView class VendorInvoicesView(FileBatchMasterView): From 9387ef7116ad4d21d82e8231483257f6074c2469 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 6 Feb 2018 10:31:36 -0600 Subject: [PATCH 0697/3196] Fix missing import bug --- tailbone/views/batch/core.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index 2d066ae8..56f6cdda 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -31,6 +31,7 @@ import datetime import logging from cStringIO import StringIO +import six import sqlalchemy as sa from sqlalchemy import orm From 22236e2909030304a2343d795ef37c8eb503924a Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 6 Feb 2018 10:31:44 -0600 Subject: [PATCH 0698/3196] Add master view for `EmailAttempt` --- tailbone/views/email.py | 60 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 59 insertions(+), 1 deletion(-) diff --git a/tailbone/views/email.py b/tailbone/views/email.py index de700260..111589c5 100644 --- a/tailbone/views/email.py +++ b/tailbone/views/email.py @@ -29,7 +29,7 @@ from __future__ import unicode_literals, absolute_import import six from rattail import mail -from rattail.db import api +from rattail.db import api, model from rattail.config import parse_list import colander @@ -289,7 +289,65 @@ class EmailPreview(View): "Send preview email") +class EmailAttemptView(MasterView): + """ + Master view for email attempts. + """ + model_class = model.EmailAttempt + route_prefix = 'email_attempts' + url_prefix = '/email/attempts' + creatable = False + editable = False + deletable = False + + labels = { + 'status_code': "Status", + } + + grid_columns = [ + 'key', + 'sender', + 'subject', + 'to', + 'sent', + 'status_code', + ] + + form_fields = [ + 'key', + 'sender', + 'subject', + 'to', + 'cc', + 'bcc', + 'sent', + 'status_code', + 'status_text', + ] + + def configure_grid(self, g): + super(EmailAttemptView, self).configure_grid(g) + + # sent + g.set_sort_defaults('sent', 'desc') + + # status_code + g.set_enum('status_code', self.enum.EMAIL_ATTEMPT) + + # links + g.set_link('key') + g.set_link('sender') + g.set_link('subject') + g.set_link('to') + + def configure_form(self, f): + super(EmailAttemptView, self).configure_form(f) + + # status_code + f.set_enum('status_code', self.enum.EMAIL_ATTEMPT) + def includeme(config): ProfilesView.defaults(config) EmailPreview.defaults(config) + EmailAttemptView.defaults(config) From 5b4718fac46d9398eeaa286efedac9d5495cea31 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 6 Feb 2018 11:23:28 -0600 Subject: [PATCH 0699/3196] Avoid "auto disable" button logic for new message form --- tailbone/forms2/core.py | 4 ++++ tailbone/templates/forms2/deform.mako | 2 +- tailbone/views/messages.py | 3 +++ 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/tailbone/forms2/core.py b/tailbone/forms2/core.py index 938a40ef..263a791d 100644 --- a/tailbone/forms2/core.py +++ b/tailbone/forms2/core.py @@ -323,6 +323,7 @@ class Form(object): save_label = "Save" update_label = "Save" show_cancel = True + auto_disable = True def __init__(self, fields=None, schema=None, request=None, readonly=False, readonly_fields=[], model_instance=None, model_class=None, nodes={}, enums={}, labels={}, renderers=None, @@ -685,6 +686,9 @@ class Form(object): context = kwargs context['form'] = self context['dform'] = dform + context['form_kwargs'] = {} + if self.auto_disable: + context['form_kwargs']['class_'] = 'autodisable' context['request'] = self.request context['readonly_fields'] = self.readonly_fields context['render_field_readonly'] = self.render_field_readonly diff --git a/tailbone/templates/forms2/deform.mako b/tailbone/templates/forms2/deform.mako index ef33d686..3a2206b3 100644 --- a/tailbone/templates/forms2/deform.mako +++ b/tailbone/templates/forms2/deform.mako @@ -2,7 +2,7 @@ % if not readonly: <% _focus_rendered = False %> -${h.form(form.action_url, id=dform.formid, method='post', enctype='multipart/form-data', class_='autodisable')} +${h.form(form.action_url, id=dform.formid, method='post', enctype='multipart/form-data', **form_kwargs)} ${h.csrf_token(request)} % endif diff --git a/tailbone/views/messages.py b/tailbone/views/messages.py index 5d2060a5..9747a391 100644 --- a/tailbone/views/messages.py +++ b/tailbone/views/messages.py @@ -182,6 +182,9 @@ class MessagesView(MasterView): def configure_form(self, f): super(MessagesView, self).configure_form(f) + # we have custom logic to disable submit button + f.auto_disable = False + # TODO: A fair amount of this still seems hacky... f.set_renderer('sender', self.render_sender) From 44dec830e572141f3dd42d838f66b640b79a3739 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 6 Feb 2018 12:53:29 -0600 Subject: [PATCH 0700/3196] Add better UPC validation for mobile receiving --- tailbone/views/purchasing/receiving.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index 2e6c53e1..3e269d7b 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -255,12 +255,17 @@ class ReceivingBatchView(PurchasingBatchView): else: # if product not even in system, add to batch anyway.. - row = model.PurchaseBatchRow() - row.upc = provided # TODO: why not checked? how to know? - row.description = "(unknown product)" - batch.add_row(row) - self.handler.refresh_row(row) - self.handler.refresh_batch_status(batch) + # but only if it was a "sane" UPC + if len(upc) <= 14: + row = model.PurchaseBatchRow() + row.upc = provided # TODO: why not checked? how to know? + row.description = "(unknown product)" + batch.add_row(row) + self.handler.refresh_row(row) + self.handler.refresh_batch_status(batch) + else: + self.request.session.flash("Invalid UPC: {}".format(upc), 'error') + return self.redirect(self.get_action_url('view', batch, mobile=True)) self.Session.flush() return self.redirect(self.mobile_row_route_url('view', uuid=row.batch_uuid, row_uuid=row.uuid)) From 9ad8e5b546b6f2619fe7697160db7f608d19b677 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 6 Feb 2018 12:57:27 -0600 Subject: [PATCH 0701/3196] Add even better UPC validation for mobile receiving --- tailbone/views/purchasing/receiving.py | 79 +++++++++++++------------- 1 file changed, 40 insertions(+), 39 deletions(-) diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index 3e269d7b..723b51fc 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -223,49 +223,50 @@ class ReceivingBatchView(PurchasingBatchView): row = None upc = self.request.GET.get('upc', '').strip() upc = re.sub(r'\D', '', upc) - if upc: + if not upc: + self.request.session.flash("Invalid UPC: {}".format(self.request.GET.get('upc')), 'error') + return self.redirect(self.get_action_url('view', batch, mobile=True)) - # first try to locate existing batch row by UPC match - provided = GPC(upc, calc_check_digit=False) - checked = GPC(upc, calc_check_digit='upc') - rows = self.Session.query(model.PurchaseBatchRow)\ - .filter(model.PurchaseBatchRow.batch == batch)\ - .filter(model.PurchaseBatchRow.upc.in_((provided, checked)))\ - .filter(model.PurchaseBatchRow.removed == False)\ - .all() + # first try to locate existing batch row by UPC match + provided = GPC(upc, calc_check_digit=False) + checked = GPC(upc, calc_check_digit='upc') + rows = self.Session.query(model.PurchaseBatchRow)\ + .filter(model.PurchaseBatchRow.batch == batch)\ + .filter(model.PurchaseBatchRow.upc.in_((provided, checked)))\ + .filter(model.PurchaseBatchRow.removed == False)\ + .all() - if rows: - if len(rows) > 1: - log.warning("found multiple UPC matches for {} in batch {}: {}".format( - upc, batch.id_str, batch)) - row = rows[0] + if rows: + if len(rows) > 1: + log.warning("found multiple UPC matches for {} in batch {}: {}".format( + upc, batch.id_str, batch)) + row = rows[0] + else: + + # try to locate general product by UPC; add to batch if found + product = api.get_product_by_upc(self.Session(), provided) + if not product: + product = api.get_product_by_upc(self.Session(), checked) + if product: + row = model.PurchaseBatchRow() + row.product = product + batch.add_row(row) + self.handler.refresh_row(row) + + # check for "bad" upc + elif len(upc) > 14: + self.request.session.flash("Invalid UPC: {}".format(upc), 'error') + return self.redirect(self.get_action_url('view', batch, mobile=True)) + + # product in system, but sane upc, so add to batch anyway else: - - # try to locate general product by UPC; add to batch if found - product = api.get_product_by_upc(self.Session(), provided) - if not product: - product = api.get_product_by_upc(self.Session(), checked) - if product: - row = model.PurchaseBatchRow() - row.product = product - batch.add_row(row) - self.handler.refresh_row(row) - - else: - - # if product not even in system, add to batch anyway.. - # but only if it was a "sane" UPC - if len(upc) <= 14: - row = model.PurchaseBatchRow() - row.upc = provided # TODO: why not checked? how to know? - row.description = "(unknown product)" - batch.add_row(row) - self.handler.refresh_row(row) - self.handler.refresh_batch_status(batch) - else: - self.request.session.flash("Invalid UPC: {}".format(upc), 'error') - return self.redirect(self.get_action_url('view', batch, mobile=True)) + row = model.PurchaseBatchRow() + row.upc = provided # TODO: why not checked? how to know? + row.description = "(unknown product)" + batch.add_row(row) + self.handler.refresh_row(row) + self.handler.refresh_batch_status(batch) self.Session.flush() return self.redirect(self.mobile_row_route_url('view', uuid=row.batch_uuid, row_uuid=row.uuid)) From 9e9a5f9a6a3bf13c54d780afa12b83b350f7242b Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 7 Feb 2018 15:10:44 -0600 Subject: [PATCH 0702/3196] Update changelog --- CHANGES.rst | 28 ++++++++++++++++++++++++++++ tailbone/_version.py | 2 +- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 1f839cd7..883c2bfb 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,34 @@ CHANGELOG ========= +0.7.0 (2018-02-07) +------------------ + +* Coalesce all master views back to single base class. + +* Add ``append()`` and ``replace()`` methods for core Grid class. + +* Show year dropdown by default for jQuery UI date pickers. + +* Don't process file for new batch unless field is present. + +* Add setting for "force home" mobile behavior. + +* Add 'plain' and 'jquery' templates for deform select widget. + +* Add "hidden" concept for form fields. + +* Add ``Form.show_cancel`` flag, for hiding that button. + +* Let each form define its "save" button text. + +* Add master view for ``EmailAttempt``. + +* Avoid "auto disable" button logic for new message form. + +* Add better UPC validation for mobile receiving. + + 0.6.69 (2018-02-01) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 8a26a0e9..096f3feb 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.6.69' +__version__ = '0.7.0' From 00a3b8fc33646783b74f27daf61307dfba69e0ca Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 7 Feb 2018 19:28:54 -0600 Subject: [PATCH 0703/3196] Make it easier to hide buttons for a form --- tailbone/templates/forms2/deform.mako | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/templates/forms2/deform.mako b/tailbone/templates/forms2/deform.mako index 3a2206b3..ee70917e 100644 --- a/tailbone/templates/forms2/deform.mako +++ b/tailbone/templates/forms2/deform.mako @@ -69,7 +69,7 @@ ${h.csrf_token(request)} % if buttons: ${buttons|n} -% elif not readonly: +% elif not readonly and (buttons is Undefined or (buttons is not None and buttons is not False)):
      ## ${h.submit('create', form.create_label if form.creating else form.update_label)} ${h.submit('save', getattr(form, 'save_label', "Save"))} From c35bfa3e4e1dbd9a040c9a0d0e9b4f601d50dd76 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 7 Feb 2018 20:06:35 -0600 Subject: [PATCH 0704/3196] Let forms choose *not* to auto-disable their cancel button --- tailbone/forms2/core.py | 5 ++++- tailbone/templates/forms2/deform.mako | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/tailbone/forms2/core.py b/tailbone/forms2/core.py index 263a791d..faf2c7ca 100644 --- a/tailbone/forms2/core.py +++ b/tailbone/forms2/core.py @@ -324,6 +324,8 @@ class Form(object): update_label = "Save" show_cancel = True auto_disable = True + auto_disable_save = True + auto_disable_cancel = True def __init__(self, fields=None, schema=None, request=None, readonly=False, readonly_fields=[], model_instance=None, model_class=None, nodes={}, enums={}, labels={}, renderers=None, @@ -687,7 +689,8 @@ class Form(object): context['form'] = self context['dform'] = dform context['form_kwargs'] = {} - if self.auto_disable: + # TODO: deprecate / remove the latter option here + if self.auto_disable_save or self.auto_disable: context['form_kwargs']['class_'] = 'autodisable' context['request'] = self.request context['readonly_fields'] = self.readonly_fields diff --git a/tailbone/templates/forms2/deform.mako b/tailbone/templates/forms2/deform.mako index ee70917e..3dcbbfaf 100644 --- a/tailbone/templates/forms2/deform.mako +++ b/tailbone/templates/forms2/deform.mako @@ -77,7 +77,7 @@ ${h.csrf_token(request)} ## ${h.submit('create_and_continue', form.successive_create_label)} ## % endif % if getattr(form, 'show_cancel', True): - ${h.link_to("Cancel", form.cancel_url, class_='button autodisable')} + ${h.link_to("Cancel", form.cancel_url, class_='cancel button{}'.format(' autodisable' if form.auto_disable_cancel else ''))} % endif
      % endif From 035a7b2096346f8b95949769bcc2ff9509f5c6a3 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 9 Feb 2018 15:03:44 -0600 Subject: [PATCH 0705/3196] Add 'newstyle' behavior for `Form.validate()` --- tailbone/forms2/core.py | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/tailbone/forms2/core.py b/tailbone/forms2/core.py index faf2c7ca..2f53572c 100644 --- a/tailbone/forms2/core.py +++ b/tailbone/forms2/core.py @@ -789,13 +789,28 @@ class Form(object): return getattr(record, field_name, None) def validate(self, *args, **kwargs): - raise_error = kwargs.pop('raise_error', True) - form = self.make_deform_form() - try: - return form.validate(*args, **kwargs) - except deform.ValidationFailure: - if raise_error: - raise + if kwargs.pop('newstyle', False): + # yay, new behavior! + if hasattr(self, 'validated'): + del self.validated + if self.request.method != 'POST': + return False + controls = self.request.POST.items() + dform = self.make_deform_form() + try: + self.validated = dform.validate(controls) + return True + except deform.ValidationFailure: + return False + + else: # legacy behavior + raise_error = kwargs.pop('raise_error', True) + form = self.make_deform_form() + try: + return form.validate(*args, **kwargs) + except deform.ValidationFailure: + if raise_error: + raise class FieldList(list): From 4760295d6a275b0178a8cfb743a0120cbc04a351 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 9 Feb 2018 15:04:22 -0600 Subject: [PATCH 0706/3196] Add some basic ORM object field types for new forms --- tailbone/forms2/types.py | 62 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 61 insertions(+), 1 deletion(-) diff --git a/tailbone/forms2/types.py b/tailbone/forms2/types.py index 977b8bc6..e9fcdb59 100644 --- a/tailbone/forms2/types.py +++ b/tailbone/forms2/types.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar +# Copyright © 2010-2018 Lance Edgar # # This file is part of Rattail. # @@ -26,8 +26,12 @@ Form Schema Types from __future__ import unicode_literals, absolute_import +from rattail.db import model + import colander +from tailbone.db import Session + class JQueryTime(colander.Time): """ @@ -52,3 +56,59 @@ class JQueryTime(colander.Time): # re-try first format, for "better" error message return colander.timeparse(cstruct, formats[0]) + + +class ObjectType(colander.SchemaType): + """ + Custom schema type for scalar ORM relationship fields. + """ + model_class = None + + @property + def model_title(self): + self.model_class.get_model_title() + + @property + def session(self): + return Session() + + def serialize(self, node, appstruct): + if appstruct is colander.null: + return colander.null + return six.text_type(appstruct) + + def deserialize(self, node, cstruct): + if not cstruct: + return None + obj = self.session.query(self.model_class).get(cstruct) + if not obj: + raise colander.Invalid(node, "{} not found".format(self.model_title)) + return obj + + +class StoreType(ObjectType): + """ + Custom schema type for store field. + """ + model_class = model.Store + + +class CustomerType(ObjectType): + """ + Custom schema type for customer field. + """ + model_class = model.Customer + + +class ProductType(ObjectType): + """ + Custom schema type for product relationship field. + """ + model_class = model.Product + + +class UserType(ObjectType): + """ + Custom schema type for user field. + """ + model_class = model.User From a3b2fbadb725e6f17cd65b18cb160cfcb1257072 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 9 Feb 2018 15:04:57 -0600 Subject: [PATCH 0707/3196] Make sure each grid has unique set of actions --- tailbone/grids/core.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index 5cb8f8d2..182c1a5c 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -109,8 +109,8 @@ class Grid(object): self.checked = checked if self.checked is None: self.checked = lambda item: False - self.main_actions = main_actions - self.more_actions = more_actions + self.main_actions = main_actions or [] + self.more_actions = more_actions or [] self._whgrid_kwargs = kwargs From e2bfb31cb2413aa97979b11e4143b81822d452ae Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 9 Feb 2018 15:17:29 -0600 Subject: [PATCH 0708/3196] Add 'gridcore' jQuery plugin, for core behavior also add 'selected' status for checkbox grids, etc. --- tailbone/static/css/grids.css | 17 +++ tailbone/static/js/jquery.ui.tailbone.js | 155 +++++++++++++++-------- tailbone/templates/base.mako | 3 +- tailbone/templates/batch/edit.mako | 1 - tailbone/templates/batch/view.mako | 1 - tailbone/templates/master/index.mako | 50 +------- tailbone/templates/master/versions.mako | 1 - tailbone/templates/master/view.mako | 1 - 8 files changed, 127 insertions(+), 102 deletions(-) diff --git a/tailbone/static/css/grids.css b/tailbone/static/css/grids.css index 04cb867f..104e6e7a 100644 --- a/tailbone/static/css/grids.css +++ b/tailbone/static/css/grids.css @@ -240,6 +240,23 @@ text-align: center; } +.grid tr:not(.header).selected.odd { + background-color: #507aaa; +} + +.grid tr:not(.header).selected.even { + background-color: #628db6; +} + +.grid tr:not(.header).selected.hovering { + background-color: #3e5b76; + color: white; +} + +.grid tr:not(.header).selected a { + color: #cccccc; +} + /****************************** * main actions diff --git a/tailbone/static/js/jquery.ui.tailbone.js b/tailbone/static/js/jquery.ui.tailbone.js index 7f9b3dd6..e8c14294 100644 --- a/tailbone/static/js/jquery.ui.tailbone.js +++ b/tailbone/static/js/jquery.ui.tailbone.js @@ -1,8 +1,107 @@ -// -*- coding: utf-8 -*- + /********************************************************************** * jQuery UI plugins for Tailbone **********************************************************************/ +/********************************************************************** + * gridcore plugin + **********************************************************************/ + +(function($) { + + $.widget('tailbone.gridcore', { + + _create: function() { + + var that = this; + + // Add hover highlight effect to grid rows during mouse-over. + // this.element.on('mouseenter', 'tbody tr:not(.header)', function() { + this.element.on('mouseenter', 'tr:not(.header)', function() { + $(this).addClass('hovering'); + }); + // this.element.on('mouseleave', 'tbody tr:not(.header)', function() { + this.element.on('mouseleave', 'tr:not(.header)', function() { + $(this).removeClass('hovering'); + }); + + // do some extra stuff for grids with checkboxes + + // (un-)check all rows when clicking check-all box in header + if (this.element.find('tr.header td.checkbox :checkbox').length) { + this.element.on('click', 'tr.header td.checkbox :checkbox', function() { + var checked = $(this).prop('checked'); + var rows = that.element.find('tr:not(.header)'); + rows.find('td.checkbox :checkbox').prop('checked', checked); + if (checked) { + rows.addClass('selected'); + } else { + rows.removeClass('selected'); + } + that.element.trigger('gridchecked', that.count_selected()); + }); + } + + // when row with checkbox is clicked, toggle selected status, + // unless clicking checkbox (since that already toggles it) or a + // link (since that does something completely different) + this.element.on('click', 'tr:not(.header)', function(event) { + var el = $(event.target); + if (!el.is('a') && !el.is(':checkbox')) { + $(this).find('td.checkbox :checkbox').click(); + } + }); + + this.element.on('change', 'tr:not(.header) td.checkbox :checkbox', function() { + if (this.checked) { + $(this).parents('tr:first').addClass('selected'); + } else { + $(this).parents('tr:first').removeClass('selected'); + } + that.element.trigger('gridchecked', that.count_selected()); + }); + + // Show 'more' actions when user hovers over 'more' link. + this.element.on('mouseenter', '.actions a.more', function() { + that.element.find('.actions div.more').hide(); + $(this).siblings('div.more') + .show() + .position({my: 'left-5 top-4', at: 'left top', of: $(this)}); + }); + this.element.on('mouseleave', '.actions div.more', function() { + $(this).hide(); + }); + + // Add speed bump for "Delete Row" action, if grid is so configured. + if (this.element.data('delete-speedbump')) { + this.element.on('click', 'tr:not(.header) .actions a.delete', function() { + return confirm("Are you sure you wish to delete this object?"); + }); + } + }, + + count_selected: function() { + return this.element.find('tr:not(.header) td.checkbox input:checked').length; + }, + + // TODO: deprecate / remove this? + count_checked: function() { + return this.count_selected(); + }, + + selected_uuids: function() { + var uuids = []; + this.element.find('tr:not(.header) td.checkbox input:checked').each(function() { + uuids.push($(this).parents('tr:first').data('uuid')); + }); + return uuids; + } + + }); + +})( jQuery ); + + /********************************************************************** * gridwrapper plugin **********************************************************************/ @@ -25,6 +124,9 @@ this.save_defaults = this.filters.find('#save-defaults'); this.grid = this.element.find('.grid'); + // add standard grid behavior + this.grid.gridcore(); + // Enhance filters etc. this.filters.find('.filter').gridfilter(); this.apply_filters.button('option', 'icons', {primary: 'ui-icon-search'}); @@ -147,56 +249,6 @@ that.refresh(this.search.substring(1)); // remove leading '?' return false; }); - - // Add hover highlight effect to grid rows during mouse-over. - this.element.on('mouseenter', 'tbody tr:not(.header)', function() { - $(this).addClass('hovering'); - }); - this.element.on('mouseleave', 'tbody tr:not(.header)', function() { - $(this).removeClass('hovering'); - }); - - // do some extra stuff for grids with checkboxes - - // (un-)check all rows when clicking check-all box in header - if (this.grid.find('tr.header td.checkbox input').length) { - this.element.on('click', 'tr.header td.checkbox input', function() { - var checked = $(this).prop('checked'); - that.grid.find('tr:not(.header) td.checkbox input').prop('checked', checked); - }); - - } - - // Select current row when clicked, unless clicking checkbox - // (since that already does select the row) or a link (since - // that does something completely different). - this.element.on('click', '.grid tr:not(.header) td.checkbox input', function(event) { - event.stopPropagation(); - }); - this.element.on('click', '.grid tr:not(.header) a', function(event) { - event.stopPropagation(); - }); - this.element.on('click', '.grid tr:not(.header)', function() { - $(this).find('td.checkbox input').click(); - }); - - // Show 'more' actions when user hovers over 'more' link. - this.element.on('mouseenter', '.actions a.more', function() { - that.grid.find('.actions div.more').hide(); - $(this).siblings('div.more') - .show() - .position({my: 'left-5 top-4', at: 'left top', of: $(this)}); - }); - this.element.on('mouseleave', '.actions div.more', function() { - $(this).hide(); - }); - - // Add speed bump for "Delete Row" action, if grid is so configured. - if (this.grid.data('delete-speedbump')) { - this.element.on('click', 'tbody td.actions a.delete', function() { - return confirm("Are you sure you wish to delete this object?"); - }); - } }, // Refreshes the visible data within the grid, according to the given settings. @@ -206,6 +258,7 @@ $.get(this.grid.data('url'), settings, function(data) { that.grid.replaceWith(data); that.grid = that.element.find('.grid'); + that.grid.gridcore(); that.element.unmask(); }); } diff --git a/tailbone/templates/base.mako b/tailbone/templates/base.mako index 15d5974d..6a5ed524 100644 --- a/tailbone/templates/base.mako +++ b/tailbone/templates/base.mako @@ -155,7 +155,8 @@ var noop_url = '${request.route_url('noop')}'; ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.js') + '?ver={}'.format(tailbone.__version__))} - ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.feedback.js'))} + ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.feedback.js') + '?ver={}'.format(tailbone.__version__))} + ${h.javascript_link(request.static_url('tailbone:static/js/jquery.ui.tailbone.js') + '?ver={}'.format(tailbone.__version__))} <%def name="extra_javascript()"> diff --git a/tailbone/templates/batch/edit.mako b/tailbone/templates/batch/edit.mako index c0183535..f79ec2dd 100644 --- a/tailbone/templates/batch/edit.mako +++ b/tailbone/templates/batch/edit.mako @@ -3,7 +3,6 @@ <%def name="extra_javascript()"> ${parent.extra_javascript()} - ${h.javascript_link(request.static_url('tailbone:static/js/jquery.ui.tailbone.js'))} ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.batch.js'))} + + +
      diff --git a/tailbone/templates/shifts/base.mako b/tailbone/templates/shifts/base.mako index fd608d86..a76f8c36 100644 --- a/tailbone/templates/shifts/base.mako +++ b/tailbone/templates/shifts/base.mako @@ -41,6 +41,15 @@ return true; } + function date_selected(dateText, inst) { + if (confirm_leave()) { + $('#filter-form').submit(); + } else { + // revert date value + $('.week-picker input[name="date"]').val($('.week-picker').data('week')); + } + } + $(function() { $('#filter-form').submit(function() { @@ -69,17 +78,7 @@ $('.week-picker button.nav').click(function() { if (confirm_leave()) { - $('.week-picker #date').val($(this).data('date')); - $('#filter-form').submit(); - } - }); - - $('.week-picker #date').datepicker({ - dateFormat: 'mm/dd/yy', - changeYear: true, - changeMonth: true, - showButtonPanel: true, - onSelect: function(dateText, inst) { + $('.week-picker input[name="date"]').val($(this).data('date')); $('#filter-form').submit(); } }); @@ -134,8 +133,8 @@ <%def name="timesheet_wrapper(with_edit_form=False, change_employee=None)">
      - ${form.begin(id='filter-form')} - ${form.csrf_token()} + ${h.form(request.current_route_url(_query=False), id='filter-form')} + ${h.csrf_token(request)}
      ${day.strftime('%A')}
      ${day.strftime('%b %d')}
      Total
      Hours
      ${self.render_employee_total(emp)}
      ${self.render_employee_total(employee)}
      ${'X' if cost.preference == 1 else ''}${cost.vendor} + % if request.has_perm('vendors.view'): + ${h.link_to(cost.vendor, request.route_url('vendors.view', uuid=cost.vendor_uuid))} + % else: + ${cost.vendor} + % endif + ${cost.code or ''} ${h.pretty_quantity(cost.case_size)} ${'$ %0.2f' % cost.case_cost if cost.case_cost is not None else ''}${h.pretty_quantity(cost.case_size)} ${"LB" if cost.product.weighed else "EA"} ${cost.code or ''} ${'X' if cost.preference == 1 else ''}$${'{:0.2f}'.format(cost.unit_cost)} + % if cost.unit_cost is not None: + $${'{:0.2f}'.format(cost.unit_cost)} + % endif +
      ${item.upc.pretty()}${item.upc.pretty() if item.upc else item.item_id} ${item.vendor_code or ''} ${(item.brand_name or '')[:15]} ${item.description or ''}
      @@ -147,26 +146,31 @@
      - % if request.has_perm('{}.viewall'.format(permission_prefix)): - ${autocomplete('employee', url('employees.autocomplete'), - field_value=employee.uuid if employee else None, - field_display=unicode(employee or ''), - selected='employee_selected', - change_clicked=change_employee)} - % else: - ${form.hidden('employee', value=employee.uuid)} - ${employee} - % endif + ${dform['employee'].serialize(text=six.text_type(employee), selected_callback='employee_selected')|n}
      % endif % if store_options is not Undefined: - ${form.field_div('store', h.select('store', store.uuid if store else None, store_options))} +
      +
      + +
      + ${dform['store'].serialize()|n} +
      +
      +
      % endif % if department_options is not Undefined: - ${form.field_div('department', h.select('department', department.uuid if department else None, department_options))} +
      +
      + +
      + ${dform['department'].serialize()|n} +
      +
      +
      % endif
      @@ -190,11 +194,11 @@
      @@ -203,7 +207,7 @@
      -
      - - +
      + + - ${form.text('date', value=sunday.strftime('%m/%d/%Y'))} + ${dform['date'].serialize(extra_options={'showButtonPanel': True}, selected_callback='date_selected')|n}
      - ${form.end()} + ${h.end_form()} % if with_edit_form: ${self.edit_form()} diff --git a/tailbone/templates/shifts/schedule.mako b/tailbone/templates/shifts/schedule.mako index 61a69c32..ec2a136c 100644 --- a/tailbone/templates/shifts/schedule.mako +++ b/tailbone/templates/shifts/schedule.mako @@ -6,7 +6,11 @@
    • ${h.link_to("Edit Schedule", url('schedule.edit'))}
    • % endif % if request.has_perm('schedule.print'): -
    • ${h.link_to("Print Schedule", url('schedule.print'), target='_blank')}
    • + % if employee is Undefined: +
    • ${h.link_to("Print Schedule", url('schedule.print'), target='_blank')}
    • + % else: +
    • ${h.link_to("Print this Schedule", url('schedule.employee.print'), target='_blank')}
    • + % endif % endif % if request.has_perm('timesheet.view'):
    • ${h.link_to("View this Time Sheet", url('schedule.goto.timesheet'), class_='goto')}
    • diff --git a/tailbone/templates/shifts/schedule_print_employee.mako b/tailbone/templates/shifts/schedule_print_employee.mako new file mode 100644 index 00000000..0ceddd88 --- /dev/null +++ b/tailbone/templates/shifts/schedule_print_employee.mako @@ -0,0 +1,20 @@ +## -*- coding: utf-8; -*- +<%namespace file="/shifts/base.mako" import="timesheet" /> +<%namespace file="/shifts/schedule.mako" import="render_day" /> + + + ## TODO: this seems a little hacky..? + ${h.stylesheet_link(request.static_url('tailbone:static/css/normalize.css'), media='all')} + ${h.stylesheet_link(request.static_url('tailbone:static/css/base.css'), media='all')} + ${h.stylesheet_link(request.static_url('tailbone:static/css/grids.css'), media='all')} + ${h.stylesheet_link(request.static_url('tailbone:static/css/timesheet.css'), media='all')} + ${h.stylesheet_link(request.static_url('tailbone:static/css/schedule_print.css'), media='print')} + + +

      + ${employee} - + ${week_of} +

      + ${timesheet(render_day=render_day)} + + diff --git a/tailbone/views/shifts/lib.py b/tailbone/views/shifts/lib.py index 253abd05..a8c9dfae 100644 --- a/tailbone/views/shifts/lib.py +++ b/tailbone/views/shifts/lib.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar +# Copyright © 2010-2018 Lance Edgar # # This file is part of Rattail. # @@ -36,28 +36,29 @@ from rattail.db import model, api from rattail.time import localtime, make_utc, get_sunday from rattail.util import pretty_hours, hours_as_decimal -import formencode as fe -from pyramid_simpleform import Form +import colander +from deform import widget as dfwidget from webhelpers2.html import tags, HTML -from tailbone import forms +from tailbone import forms2 as forms from tailbone.db import Session from tailbone.views import View -class ShiftFilter(fe.Schema): - allow_extra_fields = True - filter_extra_fields = True - store = forms.validators.ValidStore() - department = forms.validators.ValidDepartment() - date = fe.validators.DateConverter() +class ShiftFilter(colander.Schema): + + store = colander.SchemaNode(forms.types.StoreType()) + + department = colander.SchemaNode(forms.types.DepartmentType()) + + date = colander.SchemaNode(colander.Date()) -class EmployeeShiftFilter(fe.Schema): - allow_extra_fields = True - filter_extra_fields = True - employee = forms.validators.ValidEmployee() - date = fe.validators.DateConverter() +class EmployeeShiftFilter(colander.Schema): + + employee = colander.SchemaNode(forms.types.EmployeeType()) + + date = colander.SchemaNode(colander.Date()) class TimeSheetView(View): @@ -166,47 +167,88 @@ class TimeSheetView(View): Process a "shift filter" form if one was in fact POST'ed. If it was then we store new context in session and redirect to display as normal. """ - if self.request.method == 'POST': - if form.validate(): - store = form.data['store'] - self.request.session['timesheet.{}.store'.format(self.key)] = store.uuid if store else None - department = form.data['department'] - self.request.session['timesheet.{}.department'.format(self.key)] = department.uuid if department else None - date = form.data['date'] - self.request.session['timesheet.{}.date'.format(self.key)] = date.strftime('%m/%d/%Y') if date else None - raise self.redirect(self.request.current_route_url()) + if form.validate(newstyle=True): + store = form.validated['store'] + self.request.session['timesheet.{}.store'.format(self.key)] = store.uuid if store else None + department = form.validated['department'] + self.request.session['timesheet.{}.department'.format(self.key)] = department.uuid if department else None + date = form.validated['date'] + self.request.session['timesheet.{}.date'.format(self.key)] = date.strftime('%m/%d/%Y') if date else None + raise self.redirect(self.request.current_route_url()) def process_employee_filter_form(self, form): """ Process an "employee shift filter" form if one was in fact POST'ed. If it was then we store new context in session and redirect to display as normal. """ - if self.request.method == 'POST': - if form.validate(): - employee = form.data['employee'] - self.request.session['timesheet.{}.employee'.format(self.key)] = employee.uuid if employee else None - date = form.data['date'] - self.request.session['timesheet.{}.employee.date'.format(self.key)] = date.strftime('%m/%d/%Y') if date else None - raise self.redirect(self.request.current_route_url()) + if form.validate(newstyle=True): + employee = form.validated['employee'] + self.request.session['timesheet.{}.employee'.format(self.key)] = employee.uuid if employee else None + date = form.validated['date'] + self.request.session['timesheet.{}.employee.date'.format(self.key)] = date.strftime('%m/%d/%Y') if date else None + raise self.redirect(self.request.current_route_url()) + + def make_full_filter_form(self, context): + form = forms.Form(schema=ShiftFilter(), request=self.request) + + stores = self.get_stores() + store_values = [(s.uuid, "{} - {}".format(s.id, s.name)) for s in stores] + store_values.insert(0, ('', "(all)")) + form.set_widget('store', forms.widgets.PlainSelectWidget(values=store_values)) + if context['store']: + form.set_default('store', context['store'].uuid) + + departments = self.get_departments() + department_values = [(d.uuid, d.name) for d in departments] + department_values.insert(0, ('', "(all)")) + form.set_widget('department', forms.widgets.PlainSelectWidget(values=department_values)) + if context['department']: + form.set_default('department', context['department'].uuid) + + form.set_type('date', 'date_jquery') + form.set_default('date', get_sunday(context['date'])) + return form def full(self): """ View a "full" timesheet/schedule, i.e. all employees but filterable by store and/or department. """ - form = Form(self.request, schema=ShiftFilter) - self.process_filter_form(form) context = self.get_timesheet_context() + form = self.make_full_filter_form(context) + self.process_filter_form(form) context['form'] = form return self.render_full(**context) + def make_employee_filter_form(self, context): + """ + View time sheet for single employee. + """ + permission_prefix = self.key + form = forms.Form(schema=EmployeeShiftFilter(), request=self.request) + + if self.request.has_perm('{}.viewall'.format(permission_prefix)): + employee_display = six.text_type(context['employee'] or '') + employees_url = self.request.route_url('employees.autocomplete') + form.set_widget('employee', forms.widgets.JQueryAutocompleteWidget( + field_display=employee_display, service_url=employees_url)) + if context['employee']: + form.set_default('employee', context['employee'].uuid) + else: + form.set_widget('employee', forms.widgets.ReadonlyWidget()) + form.set_default('employee', context['employee'].uuid) + + form.set_type('date', 'date_jquery') + form.set_default('date', get_sunday(context['date'])) + return form + def employee(self): """ View time sheet for single employee. """ - form = Form(self.request, schema=EmployeeShiftFilter) - self.process_employee_filter_form(form) context = self.get_employee_context() + form = self.make_employee_filter_form(context) + self.process_employee_filter_form(form) context['form'] = form return self.render_single(**context) @@ -280,7 +322,8 @@ class TimeSheetView(View): context = { 'page_title': self.get_title_full(), - 'form': forms.FormRenderer(form) if form else None, + 'form': form, + 'dform': form.make_deform_form() if form else None, 'employees': employees, 'stores': stores, 'store_options': store_options, @@ -326,7 +369,8 @@ class TimeSheetView(View): context = { 'single': True, 'page_title': "Employee {}".format(self.get_title()), - 'form': forms.FormRenderer(form) if form else None, + 'form': form, + 'dform': form.make_deform_form() if form else None, 'employee': employee, 'employees': [employee], 'week_of': week_of, diff --git a/tailbone/views/shifts/schedule.py b/tailbone/views/shifts/schedule.py index a8113f9f..393acf8d 100644 --- a/tailbone/views/shifts/schedule.py +++ b/tailbone/views/shifts/schedule.py @@ -1,8 +1,8 @@ -# -*- coding: utf-8 -*- +# -*- coding: utf-8; -*- ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar +# Copyright © 2010-2018 Lance Edgar # # This file is part of Rattail. # @@ -31,10 +31,8 @@ import datetime from rattail.db import model from rattail.time import localtime, make_utc, get_sunday -from pyramid_simpleform import Form - from tailbone.db import Session -from tailbone.views.shifts.lib import TimeSheetView, ShiftFilter +from tailbone.views.shifts.lib import TimeSheetView class ScheduleView(TimeSheetView): @@ -61,10 +59,9 @@ class ScheduleView(TimeSheetView): return self.redirect(self.request.route_url('schedule.edit')) # okay then, process filters; redirect if any were received - form = Form(self.request, schema=ShiftFilter) - self.process_filter_form(form) - context = self.get_timesheet_context() + form = self.make_full_filter_form(context) + self.process_filter_form(form) # okay then, maybe process saved shift data if self.request.method == 'POST': @@ -199,12 +196,20 @@ class ScheduleView(TimeSheetView): permission='schedule.edit') config.add_tailbone_permission('schedule', 'schedule.edit', "Edit full schedule") - # print schedule + # printing "any" schedule requires this permission + config.add_tailbone_permission('schedule', 'schedule.print', "Print schedule") + + # print full schedule config.add_route('schedule.print', '/schedule/print') config.add_view(cls, attr='full', route_name='schedule.print', renderer='/shifts/schedule_print.mako', permission='schedule.print') - config.add_tailbone_permission('schedule', 'schedule.print', "Print schedule") + + # print employee schedule + config.add_route('schedule.employee.print', '/schedule/employee/print') + config.add_view(cls, attr='employee', route_name='schedule.employee.print', + renderer='/shifts/schedule_print_employee.mako', + permission='schedule.print') def includeme(config): diff --git a/tailbone/views/shifts/timesheet.py b/tailbone/views/shifts/timesheet.py index b62b9bd4..5e8f9f51 100644 --- a/tailbone/views/shifts/timesheet.py +++ b/tailbone/views/shifts/timesheet.py @@ -1,8 +1,8 @@ -# -*- coding: utf-8 -*- +# -*- coding: utf-8; -*- ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar +# Copyright © 2010-2018 Lance Edgar # # This file is part of Rattail. # @@ -31,10 +31,8 @@ import datetime from rattail.db import model from rattail.time import make_utc, localtime -from pyramid_simpleform import Form - from tailbone.db import Session -from tailbone.views.shifts.lib import TimeSheetView as BaseTimeSheetView, EmployeeShiftFilter +from tailbone.views.shifts.lib import TimeSheetView as BaseTimeSheetView class TimeSheetView(BaseTimeSheetView): @@ -50,10 +48,9 @@ class TimeSheetView(BaseTimeSheetView): View for editing single employee's timesheet """ # process filters; redirect if any were received - form = Form(self.request, schema=EmployeeShiftFilter) - self.process_employee_filter_form(form) - context = self.get_employee_context() + form = self.make_employee_filter_form(context) + self.process_employee_filter_form(form) # okay then, maybe process saved shift data if self.request.method == 'POST': From 1c27efc8f103a450c6bd3f6ffddb8a19903fdc71 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 11 Feb 2018 16:05:56 -0600 Subject: [PATCH 0717/3196] Refactor feedback feature to use colander/deform --- tailbone/views/common.py | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/tailbone/views/common.py b/tailbone/views/common.py index 5bb43bd4..55909d33 100644 --- a/tailbone/views/common.py +++ b/tailbone/views/common.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar +# Copyright © 2010-2018 Lance Edgar # # This file is part of Rattail. # @@ -34,26 +34,27 @@ from rattail.mail import send_email from rattail.util import OrderedDict from rattail.files import resource_path -import formencode as fe +import colander from pyramid import httpexceptions from pyramid.response import Response -from pyramid_simpleform import Form import tailbone -from tailbone import forms +from tailbone import forms2 as forms from tailbone.db import Session from tailbone.views import View -class Feedback(fe.Schema): +class Feedback(colander.Schema): """ Form schema for user feedback. """ - allow_extra_fields = True - referrer = fe.validators.NotEmpty() - user = forms.validators.ValidUser() - user_name = fe.validators.NotEmpty() - message = fe.validators.NotEmpty() + referrer = colander.SchemaNode(colander.String()) + + user = colander.SchemaNode(forms.types.UserType()) + + user_name = colander.SchemaNode(colander.String()) + + message = colander.SchemaNode(colander.String()) class CommonView(View): @@ -117,9 +118,9 @@ class CommonView(View): """ Generic view to present/handle the user feedback form. """ - form = Form(self.request, schema=Feedback) - if form.validate(): - data = dict(form.data) + form = forms.Form(schema=Feedback(), request=self.request) + if form.validate(newstyle=True): + data = dict(form.validated) if data['user']: data['user_url'] = self.request.route_url('users.view', uuid=data['user'].uuid) send_email(self.rattail_config, 'user_feedback', data=data) From 2cbacd618750ef8d189989551870d8f65929354c Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 11 Feb 2018 16:15:48 -0600 Subject: [PATCH 0718/3196] Remove legacy fieldset configuration logic --- tailbone/views/batch/core.py | 161 ----------------------------------- tailbone/views/master.py | 33 ------- 2 files changed, 194 deletions(-) diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index 56f6cdda..8ea53d91 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -39,7 +39,6 @@ from rattail.db import model, Session as RattailSession from rattail.threads import Thread from rattail.util import load_object, prettify -import formalchemy as fa from pyramid import httpexceptions from pyramid.renderers import render_to_response from pyramid.response import FileResponse @@ -49,7 +48,6 @@ from webhelpers2.html import HTML, tags from tailbone import forms, grids from tailbone.db import Session from tailbone.views import MasterView -from tailbone.forms.renderers.batch import FileFieldRenderer from tailbone.progress import SessionProgress @@ -293,95 +291,6 @@ class BatchMasterView(MasterView): url = self.request.route_url('users.view', uuid=user.uuid) return tags.link_to(title, url) - def _preconfigure_fieldset(self, fs): - """ - Apply some commonly-useful pre-configuration to the main batch - fieldset. - """ - fs.id.set(label="Batch ID", readonly=True, renderer=forms.renderers.BatchIDFieldRenderer) - - fs.created.set(readonly=True) - fs.created_by.set(label="Created by", renderer=forms.renderers.UserFieldRenderer, - readonly=True) - fs.cognized_by.set(label="Cognized by", renderer=forms.renderers.UserFieldRenderer) - fs.rowcount.set(label="Row Count", readonly=True) - fs.status_code.set(label="Status", renderer=StatusRenderer(self.model_class.STATUS)) - fs.executed.set(readonly=True) - fs.executed_by.set(label="Executed by", renderer=forms.renderers.UserFieldRenderer, readonly=True) - fs.notes.set(renderer=fa.TextAreaFieldRenderer, size=(80, 10)) - - if self.creating and self.request.user: - batch = fs.model - batch.created_by_uuid = self.request.user.uuid - - def configure_fieldset(self, fs): - """ - Apply final configuration to the main batch fieldset. Custom batch - views are encouraged to override this method. - """ - if self.creating: - fs.configure() - - else: - batch = fs.model - if batch.executed: - fs.configure( - include=[ - fs.id, - fs.created, - fs.created_by, - fs.rowcount, - fs.executed, - fs.executed_by, - ]) - else: - fs.configure( - include=[ - fs.id, - fs.created, - fs.created_by, - fs.rowcount, - ]) - - def _postconfigure_fieldset(self, fs): - if self.creating: - unwanted = [ - 'id', - 'rowcount', - 'created', - 'created_by', - 'cognized', - 'cognized_by', - 'executed', - 'executed_by', - 'purge', - 'data_rows', - ] - for field in unwanted: - if field in fs.render_fields: - delattr(fs, field) - else: - batch = fs.model - if not batch.executed: - unwanted = [ - 'executed', - 'executed_by', - ] - for field in unwanted: - if field in fs.render_fields: - delattr(fs, field) - - def add_file_field(self, fs, name, **kwargs): - kwargs.setdefault('value', lambda b: getattr(b, 'filename_{}'.format(name))) - if 'renderer' not in kwargs: - batch = fs.model - storage_path = self.rattail_config.batch_filedir(self.handler.batch_key) - download_url = self.get_action_url('download', batch, _query={'file': name}) - kwargs['renderer'] = FileFieldRenderer.new(self, - storage_path=storage_path, - download_url=download_url) - fs.append(fa.Field(name, **kwargs)) - def configure_mobile_form(self, f): super(BatchMasterView, self).configure_mobile_form(f) batch = f.model_instance @@ -917,21 +826,6 @@ class BatchMasterView(MasterView): return True return False - def _preconfigure_row_fieldset(self, fs): - fs.sequence.set(readonly=True) - fs.status_code.set(renderer=StatusRenderer(self.model_row_class.STATUS), - label="Status", readonly=True) - fs.status_text.set(readonly=True) - fs.removed.set(readonly=True) - try: - fs.product.set(readonly=True, renderer=forms.renderers.ProductFieldRenderer) - except AttributeError: - pass - - def configure_row_fieldset(self, fs): - fs.configure() - del fs.batch - def template_kwargs_view_row(self, **kwargs): kwargs['batch_model_title'] = kwargs['parent_model_title'] return kwargs @@ -1252,44 +1146,6 @@ class FileBatchMasterView(BatchMasterView): url = self.get_action_url('download', batch) return self.render_file_field(path, url) - def _preconfigure_fieldset(self, fs): - super(FileBatchMasterView, self)._preconfigure_fieldset(fs) - fs.filename.set(label="Data File", renderer=FileFieldRenderer.new(self)) - if self.editing: - fs.filename.set(readonly=True) - - def configure_fieldset(self, fs): - """ - Apply final configuration to the main batch fieldset. Custom batch - views are encouraged to override this method. - """ - if self.creating: - fs.configure( - include=[ - fs.filename, - ]) - - else: - batch = fs.model - if batch.executed: - fs.configure( - include=[ - fs.id, - fs.created, - fs.created_by, - fs.filename, - fs.executed, - fs.executed_by, - ]) - else: - fs.configure( - include=[ - fs.id, - fs.created, - fs.created_by, - fs.filename, - ]) - def download(self): """ View for downloading the data file associated with a batch. @@ -1329,23 +1185,6 @@ class FileBatchMasterView(BatchMasterView): "Download existing {} data file".format(model_title)) -class StatusRenderer(forms.renderers.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 MobileBatchStatusFilter(grids.filters.MobileFilter): value_choices = ['pending', 'complete', 'executed', 'all'] diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 32be28f1..b7a40dd1 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -993,12 +993,6 @@ class MasterView(View): self.set_labels(form) - def preconfigure_mobile_row_fieldset(self, fieldset): - self._preconfigure_row_fieldset(fieldset) - - def configure_mobile_row_fieldset(self, fieldset): - self.configure_row_fieldset(fieldset) - def validate_mobile_form(self, form): controls = self.request.POST.items() try: @@ -1081,15 +1075,6 @@ class MasterView(View): return False return True - def preconfigure_mobile_fieldset(self, fieldset): - self._preconfigure_fieldset(fieldset) - - def configure_mobile_fieldset(self, fieldset): - """ - Configure the given mobile fieldset. - """ - self.configure_fieldset(fieldset) - def get_mobile_row_data(self, parent): return self.get_row_data(parent) @@ -2226,18 +2211,6 @@ class MasterView(View): def save_form(self, form): form.save() - def _preconfigure_fieldset(self, fieldset): - pass - - def configure_fieldset(self, fieldset): - """ - Configure the given fieldset. - """ - fieldset.configure() - - def _postconfigure_fieldset(self, fieldset): - pass - def before_create(self, form): """ Event hook, called just after the form to create a new instance has @@ -2570,12 +2543,6 @@ class MasterView(View): self.set_row_labels(form) - def _preconfigure_row_fieldset(self, fs): - pass - - def configure_row_fieldset(self, fs): - fs.configure() - def validate_row_form(self, form): controls = self.request.POST.items() try: From dd0445974826308ff8b9a75784bc9cd4f67d0fa3 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 11 Feb 2018 22:37:17 -0600 Subject: [PATCH 0719/3196] Refactor batch execution options to use colander/deform --- tailbone/forms2/core.py | 2 +- tailbone/static/js/jquery.ui.tailbone.js | 12 ++ tailbone/static/js/tailbone.batch.js | 2 - tailbone/templates/batch/edit.mako | 12 -- .../batch/handheld/exec_options.mako | 3 - tailbone/templates/batch/index.mako | 12 +- tailbone/templates/batch/view.mako | 11 +- tailbone/templates/master/index.mako | 7 +- tailbone/views/batch/core.py | 103 ++++++++---------- tailbone/views/handheld.py | 16 ++- tailbone/views/master.py | 1 + tailbone/views/vendors/catalogs.py | 5 +- 12 files changed, 87 insertions(+), 99 deletions(-) delete mode 100644 tailbone/templates/batch/handheld/exec_options.mako diff --git a/tailbone/forms2/core.py b/tailbone/forms2/core.py index d429fda0..1da56d7e 100644 --- a/tailbone/forms2/core.py +++ b/tailbone/forms2/core.py @@ -697,7 +697,7 @@ class Form(object): context = kwargs context['form'] = self context['dform'] = dform - context['form_kwargs'] = {} + context.setdefault('form_kwargs', {}) # TODO: deprecate / remove the latter option here if self.auto_disable_save or self.auto_disable: context['form_kwargs']['class_'] = 'autodisable' diff --git a/tailbone/static/js/jquery.ui.tailbone.js b/tailbone/static/js/jquery.ui.tailbone.js index e8c14294..c0b44f26 100644 --- a/tailbone/static/js/jquery.ui.tailbone.js +++ b/tailbone/static/js/jquery.ui.tailbone.js @@ -261,6 +261,18 @@ that.grid.gridcore(); that.element.unmask(); }); + }, + + results_count: function(as_text) { + var count = null; + var match = /showing \d+ thru \d+ of (\S+)/.exec(this.element.find('.pager .showing').text()); + if (match) { + count = match[1]; + if (!as_text) { + count = parseInt(count, 10); + } + } + return count; } }); diff --git a/tailbone/static/js/tailbone.batch.js b/tailbone/static/js/tailbone.batch.js index fa66f96b..2844c0b4 100644 --- a/tailbone/static/js/tailbone.batch.js +++ b/tailbone/static/js/tailbone.batch.js @@ -10,8 +10,6 @@ $(function() { - $('.grid-wrapper').gridwrapper(); - $('#execute-batch').click(function() { if (has_execution_options) { $('#execution-options-dialog').dialog({ diff --git a/tailbone/templates/batch/edit.mako b/tailbone/templates/batch/edit.mako index f79ec2dd..2431b578 100644 --- a/tailbone/templates/batch/edit.mako +++ b/tailbone/templates/batch/edit.mako @@ -6,8 +6,6 @@ ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.batch.js'))} diff --git a/tailbone/templates/forms/fieldset.mako b/tailbone/templates/forms/fieldset.mako deleted file mode 100644 index 98fc47d8..00000000 --- a/tailbone/templates/forms/fieldset.mako +++ /dev/null @@ -1,40 +0,0 @@ -## -*- coding: utf-8 -*- -<% _focus_rendered = False %> - -% for error in fieldset.errors.get(None, []): -
      ${error}
      -% endfor - -% for field in fieldset.render_fields.itervalues(): - - % if field.requires_label: -
      - % for error in field.errors: -
      ${error}
      - % endfor - ${field.label_tag()|n} -
      ${field.render()|n}
      - % if 'instructions' in field.metadata: - ${field.metadata['instructions']} - % endif -
      - - % if not _focus_rendered and (fieldset.focus is True or fieldset.focus is field): - % if not field.is_readonly() and getattr(field.renderer, 'needs_focus', True): - - <% _focus_rendered = True %> - % endif - % endif - % else: - ${field.render()|n} - % endif - -% endfor diff --git a/tailbone/templates/forms/fieldset_readonly.mako b/tailbone/templates/forms/fieldset_readonly.mako deleted file mode 100644 index 58aef14c..00000000 --- a/tailbone/templates/forms/fieldset_readonly.mako +++ /dev/null @@ -1,7 +0,0 @@ -## -*- coding: utf-8 -*- -<%namespace file="/forms/lib.mako" import="render_field_readonly" /> -
      - % for field in fieldset.render_fields.itervalues(): - ${render_field_readonly(field)} - % endfor -
      diff --git a/tailbone/templates/forms/form.mako b/tailbone/templates/forms/form.mako deleted file mode 100644 index 3baf812a..00000000 --- a/tailbone/templates/forms/form.mako +++ /dev/null @@ -1,21 +0,0 @@ -## -*- coding: utf-8 -*- -
      - ${h.form(form.action_url, id=form.id or None, method='post', enctype='multipart/form-data')} - ${form.csrf_token()} - - ${form.render_fields()|n} - - % 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 deleted file mode 100644 index c8aa0543..00000000 --- a/tailbone/templates/forms/form_readonly.mako +++ /dev/null @@ -1,7 +0,0 @@ -## -*- coding: utf-8 -*- -
      - ${form.render_fields()|n} - % if buttons: - ${buttons|n} - % endif -
      diff --git a/tailbone/templates/forms/lib.mako b/tailbone/templates/forms/lib.mako deleted file mode 100644 index 602d35c4..00000000 --- a/tailbone/templates/forms/lib.mako +++ /dev/null @@ -1,10 +0,0 @@ -## -*- coding: utf-8 -*- - -<%def name="render_field_readonly(field)"> - % if field.requires_label: -
      - ${field.label_tag()|n} -
      ${field.render_readonly()}
      -
      - % endif - From cdaf36f3463291c3f368e44aec60e8227d7a334c Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 11 Feb 2018 22:48:48 -0600 Subject: [PATCH 0722/3196] Rename 'forms2' package, templates to 'forms' --- tailbone/{forms2 => forms}/__init__.py | 0 tailbone/{forms2 => forms}/core.py | 8 ++++---- tailbone/{forms2 => forms}/types.py | 0 tailbone/{forms2 => forms}/widgets.py | 0 tailbone/templates/{forms2 => forms}/deform.mako | 0 tailbone/templates/{forms2 => forms}/form.mako | 0 tailbone/templates/{forms2 => forms}/form_readonly.mako | 0 tailbone/views/auth.py | 2 +- tailbone/views/batch/core.py | 2 +- tailbone/views/common.py | 2 +- tailbone/views/exports.py | 2 +- tailbone/views/handheld.py | 2 +- tailbone/views/inventory.py | 2 +- tailbone/views/master.py | 2 +- tailbone/views/products.py | 2 +- tailbone/views/purchasing/batch.py | 2 +- tailbone/views/purchasing/receiving.py | 2 +- tailbone/views/shifts/lib.py | 2 +- tailbone/views/users.py | 2 +- tailbone/views/vendors/catalogs.py | 2 +- 20 files changed, 17 insertions(+), 17 deletions(-) rename tailbone/{forms2 => forms}/__init__.py (100%) rename tailbone/{forms2 => forms}/core.py (99%) rename tailbone/{forms2 => forms}/types.py (100%) rename tailbone/{forms2 => forms}/widgets.py (100%) rename tailbone/templates/{forms2 => forms}/deform.mako (100%) rename tailbone/templates/{forms2 => forms}/form.mako (100%) rename tailbone/templates/{forms2 => forms}/form_readonly.mako (100%) diff --git a/tailbone/forms2/__init__.py b/tailbone/forms/__init__.py similarity index 100% rename from tailbone/forms2/__init__.py rename to tailbone/forms/__init__.py diff --git a/tailbone/forms2/core.py b/tailbone/forms/core.py similarity index 99% rename from tailbone/forms2/core.py rename to tailbone/forms/core.py index 1da56d7e..a6795c24 100644 --- a/tailbone/forms2/core.py +++ b/tailbone/forms/core.py @@ -646,9 +646,9 @@ class Form(object): def render(self, template=None, **kwargs): if not template: if self.readonly: - template = '/forms2/form_readonly.mako' + template = '/forms/form_readonly.mako' else: - template = '/forms2/form.mako' + template = '/forms/form.mako' context = kwargs context['form'] = self return render(template, context) @@ -686,7 +686,7 @@ class Form(object): return self.deform_form - def render_deform(self, dform=None, template='/forms2/deform.mako', **kwargs): + def render_deform(self, dform=None, template='/forms/deform.mako', **kwargs): if dform is None: dform = self.make_deform_form() @@ -704,7 +704,7 @@ class Form(object): context['request'] = self.request context['readonly_fields'] = self.readonly_fields context['render_field_readonly'] = self.render_field_readonly - return render('/forms2/deform.mako', context) + return render('/forms/deform.mako', context) def field_visible(self, field): if self.hidden and self.hidden.get(field): diff --git a/tailbone/forms2/types.py b/tailbone/forms/types.py similarity index 100% rename from tailbone/forms2/types.py rename to tailbone/forms/types.py diff --git a/tailbone/forms2/widgets.py b/tailbone/forms/widgets.py similarity index 100% rename from tailbone/forms2/widgets.py rename to tailbone/forms/widgets.py diff --git a/tailbone/templates/forms2/deform.mako b/tailbone/templates/forms/deform.mako similarity index 100% rename from tailbone/templates/forms2/deform.mako rename to tailbone/templates/forms/deform.mako diff --git a/tailbone/templates/forms2/form.mako b/tailbone/templates/forms/form.mako similarity index 100% rename from tailbone/templates/forms2/form.mako rename to tailbone/templates/forms/form.mako diff --git a/tailbone/templates/forms2/form_readonly.mako b/tailbone/templates/forms/form_readonly.mako similarity index 100% rename from tailbone/templates/forms2/form_readonly.mako rename to tailbone/templates/forms/form_readonly.mako diff --git a/tailbone/views/auth.py b/tailbone/views/auth.py index 2d2a79a5..1ea2ea0d 100644 --- a/tailbone/views/auth.py +++ b/tailbone/views/auth.py @@ -33,7 +33,7 @@ from deform import widget as dfwidget from pyramid.httpexceptions import HTTPForbidden from webhelpers2.html import tags, literal -from tailbone import forms2 as forms +from tailbone import forms from tailbone.db import Session from tailbone.views import View from tailbone.auth import login_user, logout_user diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index e482e4e2..aaab74be 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -49,7 +49,7 @@ from pyramid.response import FileResponse from pyramid_deform import SessionFileUploadTempStore from webhelpers2.html import HTML, tags -from tailbone import forms2 as forms, grids +from tailbone import forms, grids from tailbone.db import Session from tailbone.views import MasterView from tailbone.progress import SessionProgress diff --git a/tailbone/views/common.py b/tailbone/views/common.py index 55909d33..3cfaffa6 100644 --- a/tailbone/views/common.py +++ b/tailbone/views/common.py @@ -39,7 +39,7 @@ from pyramid import httpexceptions from pyramid.response import Response import tailbone -from tailbone import forms2 as forms +from tailbone import forms from tailbone.db import Session from tailbone.views import View diff --git a/tailbone/views/exports.py b/tailbone/views/exports.py index 9e842616..e35ea788 100644 --- a/tailbone/views/exports.py +++ b/tailbone/views/exports.py @@ -35,7 +35,7 @@ from rattail.db import model from pyramid.response import FileResponse from webhelpers2.html import HTML, tags -from tailbone import forms2 as forms +from tailbone import forms from tailbone.views import MasterView diff --git a/tailbone/views/handheld.py b/tailbone/views/handheld.py index 73c2efb1..87c5131b 100644 --- a/tailbone/views/handheld.py +++ b/tailbone/views/handheld.py @@ -34,7 +34,7 @@ from rattail.util import OrderedDict import colander from webhelpers2.html import tags -from tailbone import forms2 as forms +from tailbone import forms from tailbone.db import Session from tailbone.views.batch import FileBatchMasterView diff --git a/tailbone/views/inventory.py b/tailbone/views/inventory.py index ec879d54..e7da7f9a 100644 --- a/tailbone/views/inventory.py +++ b/tailbone/views/inventory.py @@ -40,7 +40,7 @@ import colander from deform import widget as dfwidget from webhelpers2.html import HTML, tags -from tailbone import forms2 as forms, grids +from tailbone import forms, grids from tailbone.views import MasterView from tailbone.views.batch import BatchMasterView diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 588976f8..9cc2ade0 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -50,7 +50,7 @@ from pyramid.renderers import get_renderer, render_to_response, render from pyramid.response import FileResponse from webhelpers2.html import HTML, tags -from tailbone import forms2 as forms, grids, diffs +from tailbone import forms, grids, diffs from tailbone.views import View from tailbone.progress import SessionProgress diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 48b445d7..f8e0124f 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -45,7 +45,7 @@ from deform import widget as dfwidget from pyramid import httpexceptions from webhelpers2.html import tags, HTML -from tailbone import forms2 as forms, grids +from tailbone import forms, grids from tailbone.db import Session from tailbone.views import MasterView, AutocompleteView from tailbone.progress import SessionProgress diff --git a/tailbone/views/purchasing/batch.py b/tailbone/views/purchasing/batch.py index 2e3378ff..4f5dd54c 100644 --- a/tailbone/views/purchasing/batch.py +++ b/tailbone/views/purchasing/batch.py @@ -36,7 +36,7 @@ from deform import widget as dfwidget from pyramid import httpexceptions from webhelpers2.html import tags -from tailbone import forms2 as forms +from tailbone import forms from tailbone.views.batch import BatchMasterView diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index 671dbee2..d240b20e 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -39,7 +39,7 @@ import colander import formencode as fe from webhelpers2.html import tags -from tailbone import forms2 as forms, grids +from tailbone import forms, grids from tailbone.views.purchasing import PurchasingBatchView diff --git a/tailbone/views/shifts/lib.py b/tailbone/views/shifts/lib.py index a8c9dfae..b8f5e9ee 100644 --- a/tailbone/views/shifts/lib.py +++ b/tailbone/views/shifts/lib.py @@ -40,7 +40,7 @@ import colander from deform import widget as dfwidget from webhelpers2.html import tags, HTML -from tailbone import forms2 as forms +from tailbone import forms from tailbone.db import Session from tailbone.views import View diff --git a/tailbone/views/users.py b/tailbone/views/users.py index 0e00e5f2..9be9a2f8 100644 --- a/tailbone/views/users.py +++ b/tailbone/views/users.py @@ -38,7 +38,7 @@ import colander from deform import widget as dfwidget from webhelpers2.html import HTML, tags -from tailbone import forms2 as forms +from tailbone import forms from tailbone.db import Session from tailbone.views import MasterView from tailbone.views.principal import PrincipalMasterView, PermissionsRenderer diff --git a/tailbone/views/vendors/catalogs.py b/tailbone/views/vendors/catalogs.py index 6c3ff3c1..dff5b095 100644 --- a/tailbone/views/vendors/catalogs.py +++ b/tailbone/views/vendors/catalogs.py @@ -37,7 +37,7 @@ import colander from deform import widget as dfwidget from webhelpers2.html import tags -from tailbone import forms2 as forms +from tailbone import forms from tailbone.db import Session from tailbone.views.batch import FileBatchMasterView From cb8db266cd1b9d27ea5db017574f1e5b96937a64 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 11 Feb 2018 23:19:30 -0600 Subject: [PATCH 0723/3196] Remove last references to any "fieldset" type things --- tailbone/templates/crud.mako | 21 ------------------- .../templates/purchases/batches/create.mako | 2 +- tailbone/templates/settings/crud.mako | 13 ------------ tailbone/views/master.py | 8 +++---- 4 files changed, 5 insertions(+), 39 deletions(-) delete mode 100644 tailbone/templates/crud.mako delete mode 100644 tailbone/templates/settings/crud.mako diff --git a/tailbone/templates/crud.mako b/tailbone/templates/crud.mako deleted file mode 100644 index b1afa0e5..00000000 --- a/tailbone/templates/crud.mako +++ /dev/null @@ -1,21 +0,0 @@ -## -*- 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)} - -<%def name="model_title()">${h.literal(unicode(form.fieldset.model))} - -<%def name="head_tags()"> - ${parent.head_tags()} - - - -${parent.body()} diff --git a/tailbone/templates/purchases/batches/create.mako b/tailbone/templates/purchases/batches/create.mako index 3a165f01..3b94f7d0 100644 --- a/tailbone/templates/purchases/batches/create.mako +++ b/tailbone/templates/purchases/batches/create.mako @@ -51,7 +51,7 @@ } }); - show_mode(${form.fieldset.model.mode or enum.PURCHASE_BATCH_MODE_ORDERING}); + show_mode(${batch.mode or enum.PURCHASE_BATCH_MODE_ORDERING}); }); diff --git a/tailbone/templates/settings/crud.mako b/tailbone/templates/settings/crud.mako deleted file mode 100644 index 84ce606e..00000000 --- a/tailbone/templates/settings/crud.mako +++ /dev/null @@ -1,13 +0,0 @@ -## -*- coding: utf-8 -*- -<%inherit file="/crud.mako" /> - -<%def name="context_menu_items()"> -
    • ${h.link_to("Back to Settings", url('settings'))}
    • - % if form.readonly: -
    • ${h.link_to("Edit this Setting", url('settings.edit', name=form.fieldset.model.name))}
    • - % elif form.updating: -
    • ${h.link_to("View this Setting", url('settings.view', name=form.fieldset.model.name))}
    • - % endif - - -${parent.body()} diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 9cc2ade0..3ceb0faf 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -604,7 +604,7 @@ class MasterView(View): if self.request.method == 'POST': if self.validate_form(form): # let save_create_form() return alternate object if necessary - obj = self.save_create_form(form) or form.fieldset.model + obj = self.save_create_form(form) self.after_create(obj) self.flash_after_create(obj) return self.redirect_after_create(obj) @@ -622,7 +622,7 @@ class MasterView(View): if self.request.method == 'POST': if self.validate_mobile_form(form): # let save_create_form() return alternate object if necessary - obj = self.save_mobile_create_form(form) or form.fieldset.model + obj = self.save_mobile_create_form(form) self.after_create(obj) self.flash_after_create(obj) return self.redirect_after_create(obj, mobile=True) @@ -2284,7 +2284,7 @@ class MasterView(View): if self.request.method == 'POST': if self.validate_row_form(form): self.before_create_row(form) - obj = self.save_create_row_form(form) or form.fieldset.model + obj = self.save_create_row_form(form) self.after_create_row(obj) return self.redirect_after_create_row(obj) return self.render_to_response('create_row', { @@ -2329,7 +2329,7 @@ class MasterView(View): if self.validate_mobile_row_form(form): self.before_create_row(form) # let save() return alternate object if necessary - obj = self.save_create_row_form(form) or form.fieldset.model + obj = self.save_create_row_form(form) self.after_create_row(obj) return self.redirect_after_create_row(obj, mobile=True) return self.render_to_response('create_row', { From 33e345f4ae1f3abb688de8968e822b3648d338c2 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 11 Feb 2018 23:25:54 -0600 Subject: [PATCH 0724/3196] Officially remove FormAlchemy dependency (yay!) --- docs/conf.py | 2 -- setup.py | 4 ---- tailbone/app.py | 1 - 3 files changed, 7 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 8cbe5273..7d3e5831 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -41,8 +41,6 @@ extensions = [ ] intersphinx_mapping = { - # TODO: Add this back, when the FA site is back online... - #'formalchemy': ('http://docs.formalchemy.org/formalchemy/', None), 'webhelpers2': ('https://webhelpers2.readthedocs.io/en/latest/', None), } diff --git a/setup.py b/setup.py index d720b60b..4defa3a3 100644 --- a/setup.py +++ b/setup.py @@ -70,10 +70,6 @@ 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 - # TODO: Pyramid 1.9 looks like it breaks us..? playing it safe for now.. 'pyramid<1.9', # 1.3b2 1.8.3 diff --git a/tailbone/app.py b/tailbone/app.py index 437d9808..60808cc9 100644 --- a/tailbone/app.py +++ b/tailbone/app.py @@ -37,7 +37,6 @@ from rattail.exceptions import ConfigurationError from rattail.db.config import get_engines, configure_versioning from rattail.db.types import GPCType -import formalchemy as fa from pyramid.config import Configurator from pyramid.authentication import SessionAuthenticationPolicy From f636b98cb38c69495bed34e9bbdbfebea138920c Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 11 Feb 2018 23:33:09 -0600 Subject: [PATCH 0725/3196] Officially remove FormEncode dependency --- setup.py | 6 ------ tailbone/views/purchasing/receiving.py | 11 ----------- 2 files changed, 17 deletions(-) diff --git a/setup.py b/setup.py index 4defa3a3..bbc93298 100644 --- a/setup.py +++ b/setup.py @@ -64,12 +64,6 @@ 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 - # TODO: Pyramid 1.9 looks like it breaks us..? playing it safe for now.. 'pyramid<1.9', # 1.3b2 1.8.3 diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index d240b20e..1f882f70 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -36,7 +36,6 @@ from rattail.gpc import GPC from rattail.util import pretty_quantity, prettify import colander -import formencode as fe from webhelpers2.html import tags from tailbone import forms, grids @@ -398,22 +397,12 @@ class ReceivingForm(colander.MappingSchema): # 'mispick', ])) - # product = forms.validators.ValidProduct() - # upc = forms.validators.ValidGPC() - # brand_name = fe.validators.String() - # description = fe.validators.String() - # size = fe.validators.String() - # case_quantity = fe.validators.Number() - cases = colander.SchemaNode(colander.Decimal(), missing=colander.null) units = colander.SchemaNode(colander.Decimal(), missing=colander.null) expiration_date = colander.SchemaNode(colander.Date(), missing=colander.null) - # trash = fe.validators.Bool() - # ordered_product = forms.validators.ValidProduct() - def includeme(config): ReceivingBatchView.defaults(config) From d9ff59afda86f8ab9701f72b42f3a0b13eec8e67 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 12 Feb 2018 12:15:07 -0600 Subject: [PATCH 0726/3196] Refactor grid filters to use colander/deform --- tailbone/grids/core.py | 6 ++- tailbone/grids/filters.py | 56 +++++++----------------- tailbone/static/js/jquery.ui.tailbone.js | 29 +++++++++++- tailbone/templates/grids/filters.mako | 20 ++++----- 4 files changed, 57 insertions(+), 54 deletions(-) diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index 83a10823..41b13a71 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -898,11 +898,13 @@ class Grid(object): data['{}.verb'.format(filtr.key)] = filtr.verb data[filtr.key] = filtr.value - form = gridfilters.GridFiltersForm(self.request, self.filters, defaults=data) + form = gridfilters.GridFiltersForm(self.filters, + request=self.request, + defaults=data) kwargs['request'] = self.request kwargs['grid'] = self - kwargs['form'] = gridfilters.GridFiltersFormRenderer(form) + kwargs['form'] = form return render(template, kwargs) def render_actions(self, row, i): diff --git a/tailbone/grids/filters.py b/tailbone/grids/filters.py index 8b66b090..2945c7df 100644 --- a/tailbone/grids/filters.py +++ b/tailbone/grids/filters.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar +# Copyright © 2010-2018 Lance Edgar # # This file is part of Rattail. # @@ -38,10 +38,11 @@ from rattail.core import UNSPECIFIED from rattail.time import localtime, make_utc from rattail.util import prettify -from pyramid_simpleform import Form -from pyramid_simpleform.renderers import FormRenderer +import colander from webhelpers2.html import HTML, tags +from tailbone import forms + log = logging.getLogger(__name__) @@ -736,58 +737,33 @@ class GridFilterSet(OrderedDict): """ -class GridFiltersForm(Form): +class GridFiltersForm(forms.Form): """ Form for grid filters. """ - def __init__(self, request, filters, *args, **kwargs): - super(GridFiltersForm, self).__init__(request, *args, **kwargs) + def __init__(self, filters, **kwargs): self.filters = filters + if 'schema' not in kwargs: + schema = colander.Schema() + for key, filtr in self.filters.items(): + node = colander.SchemaNode(colander.String(), name=key) + schema.add(node) + kwargs['schema'] = schema + super(GridFiltersForm, self).__init__(**kwargs) def iter_filters(self): - return self.filters.itervalues() - - -class GridFiltersFormRenderer(FormRenderer): - """ - Renderer for :class:`GridFiltersForm` instances. - """ - - @property - def filters(self): - return self.form.filters - - def iter_filters(self): - return self.form.iter_filters() - - def tag(self, *args, **kwargs): - """ - Convenience method which passes all args to the - :meth:`webhelpers2:webhelpers2.html.builder.HTMLBuilder.tag()` method. - """ - return HTML.tag(*args, **kwargs) - - # TODO: This seems hacky..? - def checkbox(self, name, checked=None, **kwargs): - """ - Custom checkbox implementation. - """ - if name.endswith('-active'): - return tags.checkbox(name, checked=checked, **kwargs) - if checked is None: - checked = False - return super(GridFiltersFormRenderer, self).checkbox(name, checked=checked, **kwargs) + return self.filters.values() def filter_verb(self, filtr): """ Render the verb selection dropdown for the given filter. """ - options = [(v, filtr.verb_labels.get(v, "unknown verb '{0}'".format(v))) + options = [tags.Option(filtr.verb_labels.get(v, "unknown verb '{}'".format(v)), v) for v in filtr.verbs] hide_values = [v for v in filtr.valueless_verbs if v in filtr.verbs] - return self.select('{0}.verb'.format(filtr.key), options, **{ + return tags.select('{}.verb'.format(filtr.key), filtr.verb, options, **{ 'class_': 'verb', 'data-hide-value-for': ' '.join(hide_values)}) diff --git a/tailbone/static/js/jquery.ui.tailbone.js b/tailbone/static/js/jquery.ui.tailbone.js index c0b44f26..bd1e2a6f 100644 --- a/tailbone/static/js/jquery.ui.tailbone.js +++ b/tailbone/static/js/jquery.ui.tailbone.js @@ -27,6 +27,11 @@ // do some extra stuff for grids with checkboxes + // mark rows selected on page load, as needed + this.element.find('tr:not(.header) td.checkbox :checkbox:checked').each(function() { + $(this).parents('tr:first').addClass('selected'); + }); + // (un-)check all rows when clicking check-all box in header if (this.element.find('tr.header td.checkbox :checkbox').length) { this.element.on('click', 'tr.header td.checkbox :checkbox', function() { @@ -81,7 +86,7 @@ }, count_selected: function() { - return this.element.find('tr:not(.header) td.checkbox input:checked').length; + return this.element.find('tr:not(.header) td.checkbox :checkbox:checked').length; }, // TODO: deprecate / remove this? @@ -89,9 +94,21 @@ return this.count_selected(); }, + selected_rows: function() { + return this.element.find('tr:not(.header) td.checkbox :checkbox:checked').parents('tr:first'); + }, + + all_uuids: function() { + var uuids = []; + this.element.find('tr:not(.header)').each(function() { + uuids.push($(this).data('uuid')); + }); + return uuids; + }, + selected_uuids: function() { var uuids = []; - this.element.find('tr:not(.header) td.checkbox input:checked').each(function() { + this.element.find('tr:not(.header) td.checkbox :checkbox:checked').each(function() { uuids.push($(this).parents('tr:first').data('uuid')); }); return uuids; @@ -273,6 +290,14 @@ } } return count; + }, + + all_uuids: function() { + return this.grid.gridcore('all_uuids'); + }, + + selected_uuids: function() { + return this.grid.gridcore('selected_uuids'); } }); diff --git a/tailbone/templates/grids/filters.mako b/tailbone/templates/grids/filters.mako index b841e52c..fc87d3a2 100644 --- a/tailbone/templates/grids/filters.mako +++ b/tailbone/templates/grids/filters.mako @@ -1,15 +1,15 @@ -## -*- coding: utf-8 -*- +## -*- coding: utf-8; -*-
      - ${form.begin(method='get')} - - + ${h.form(form.action_url, method='get')} + ${h.hidden('reset-to-default-filters', value='false')} + ${h.hidden('save-current-filters-as-defaults', value='false')}
      Filters % for filtr in form.iter_filters():
      - ${form.tag('button', type='submit', id='apply-filters', c="Apply Filters")} + - ${form.tag('button', type='button', id='default-filters', c="Default View")} - ${form.tag('button', type='button', id='clear-filters', c="No Filters")} + + % if allow_save_defaults and request.user: - ${form.tag('button', type='button', id='save-defaults', c="Save Defaults")} + % endif
      - ${form.end()} + ${h.end_form()}
      From 189bc1faa832bd49f9d51cd3dcbd322f51d4de23 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 12 Feb 2018 12:16:56 -0600 Subject: [PATCH 0727/3196] Officially remove pyramid_simpleform dependency --- setup.py | 1 - 1 file changed, 1 deletion(-) diff --git a/setup.py b/setup.py index bbc93298..787dc92d 100644 --- a/setup.py +++ b/setup.py @@ -86,7 +86,6 @@ requires = [ 'pyramid_deform', # 0.2 'pyramid_exclog', # 0.6 'pyramid_mako', # 1.0.2 - 'pyramid_simpleform', # 0.6.1 'rattail[db,auth,bouncer]', # 0.5.0 'six', # 1.10.0 'transaction', # 1.2.0 From ee35cc6f2215c3426c33770bcd3b4c2bdf1fbb21 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 12 Feb 2018 14:41:40 -0600 Subject: [PATCH 0728/3196] Misc. cleanup for Python 3 --- tailbone/grids/filters.py | 6 +++--- tailbone/templates/about.mako | 2 +- tailbone/templates/feedback.mako | 2 +- tailbone/templates/labels/profiles/printer.mako | 2 +- tailbone/templates/labels/profiles/view.mako | 2 +- tailbone/templates/messages/create.mako | 2 +- tailbone/templates/mobile/about.mako | 2 +- tailbone/templates/shifts/base.mako | 2 +- tailbone/tweens.py | 8 +++++--- tailbone/util.py | 5 +++-- tailbone/views/batch/core.py | 2 +- tailbone/views/bouncer.py | 4 +++- tailbone/views/core.py | 6 ++++-- tailbone/views/email.py | 2 +- tailbone/views/exports.py | 2 +- tailbone/views/handheld.py | 2 +- tailbone/views/master.py | 2 +- tailbone/views/products.py | 6 +++--- tailbone/views/purchases/core.py | 4 +++- tailbone/views/reports.py | 10 ++++++---- tailbone/views/shifts/schedule.py | 4 ++-- tailbone/views/shifts/timesheet.py | 2 +- tailbone/views/vendors/catalogs.py | 2 +- tailbone/views/vendors/invoices.py | 4 +++- 24 files changed, 49 insertions(+), 36 deletions(-) diff --git a/tailbone/grids/filters.py b/tailbone/grids/filters.py index 2945c7df..8ae73456 100644 --- a/tailbone/grids/filters.py +++ b/tailbone/grids/filters.py @@ -153,7 +153,7 @@ class GridFilter(object): self.default_active = default_active self.default_verb = default_verb self.default_value = default_value - for key, value in kwargs.iteritems(): + for key, value in kwargs.items(): setattr(self, key, value) def __repr__(self): @@ -369,7 +369,7 @@ class AlchemyByteStringFilter(AlchemyStringFilter): def get_value(self, value=UNSPECIFIED): value = super(AlchemyByteStringFilter, self).get_value(value) - if isinstance(value, unicode): + if isinstance(value, six.text_type): value = value.encode(self.value_encoding) return value @@ -415,7 +415,7 @@ class AlchemyNumericFilter(AlchemyGridFilter): # term for integer field... def value_invalid(self, value): - return bool(value and len(unicode(value)) > 8) + return bool(value and len(six.text_type(value)) > 8) def filter_equal(self, query, value): if self.value_invalid(value): diff --git a/tailbone/templates/about.mako b/tailbone/templates/about.mako index 20ac3428..83f8b3a6 100644 --- a/tailbone/templates/about.mako +++ b/tailbone/templates/about.mako @@ -5,7 +5,7 @@

      ${project_title} ${project_version}

      -% for name, version in packages.iteritems(): +% for name, version in packages.items():

      ${name} ${version}

      % endfor diff --git a/tailbone/templates/feedback.mako b/tailbone/templates/feedback.mako index d9964f37..e82a6068 100644 --- a/tailbone/templates/feedback.mako +++ b/tailbone/templates/feedback.mako @@ -39,7 +39,7 @@ ## % endif % if request.user: - ${form.field_div('user_name', form.hidden('user_name', value=unicode(request.user)) + unicode(request.user), label="Your Name")} + ${form.field_div('user_name', form.hidden('user_name', value=six.text_type(request.user)) + six.text_type(request.user), label="Your Name")} % else: ${form.field_div('user_name', form.text('user_name'), label="Your Name")} % endif diff --git a/tailbone/templates/labels/profiles/printer.mako b/tailbone/templates/labels/profiles/printer.mako index 3863f3ab..da557b61 100644 --- a/tailbone/templates/labels/profiles/printer.mako +++ b/tailbone/templates/labels/profiles/printer.mako @@ -30,7 +30,7 @@ ${h.form(request.current_route_url())} ${h.csrf_token(request)} - % for name, display in printer.required_settings.iteritems(): + % for name, display in printer.required_settings.items():
      diff --git a/tailbone/templates/labels/profiles/view.mako b/tailbone/templates/labels/profiles/view.mako index 35557fd8..49d1ffb7 100644 --- a/tailbone/templates/labels/profiles/view.mako +++ b/tailbone/templates/labels/profiles/view.mako @@ -31,7 +31,7 @@ ${parent.body()}

      Printer Settings

      - % for name, display in printer.required_settings.iteritems(): + % for name, display in printer.required_settings.items():
      ${instance.get_printer_setting(name) or ''}
      diff --git a/tailbone/templates/messages/create.mako b/tailbone/templates/messages/create.mako index 11c711f0..aa7d9833 100644 --- a/tailbone/templates/messages/create.mako +++ b/tailbone/templates/messages/create.mako @@ -8,7 +8,7 @@ var recipient_mappings = new Map([ <% last = len(available_recipients) %> - % for i, (uuid, entry) in enumerate(sorted(available_recipients.iteritems(), key=lambda r: r[1]), 1): + % for i, (uuid, entry) in enumerate(sorted(available_recipients.items(), key=lambda r: r[1]), 1): ['${uuid}', ${json.dumps(entry)|n}]${',' if i < last else ''} % endfor ]); diff --git a/tailbone/templates/mobile/about.mako b/tailbone/templates/mobile/about.mako index ca0e2612..595a1d2b 100644 --- a/tailbone/templates/mobile/about.mako +++ b/tailbone/templates/mobile/about.mako @@ -5,7 +5,7 @@

      ${project_title} ${project_version}

      -% for name, version in packages.iteritems(): +% for name, version in packages.items():

      ${name} ${version}

      % endfor diff --git a/tailbone/templates/shifts/base.mako b/tailbone/templates/shifts/base.mako index a76f8c36..7543712f 100644 --- a/tailbone/templates/shifts/base.mako +++ b/tailbone/templates/shifts/base.mako @@ -241,7 +241,7 @@ - % for emp in sorted(employees, key=unicode): + % for emp in sorted(employees, key=six.text_type): ## TODO: add link to single employee schedule / timesheet here... diff --git a/tailbone/tweens.py b/tailbone/tweens.py index bc6a3a93..6df4be16 100644 --- a/tailbone/tweens.py +++ b/tailbone/tweens.py @@ -1,8 +1,8 @@ -# -*- coding: utf-8 -*- +# -*- coding: utf-8; -*- ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar +# Copyright © 2010-2018 Lance Edgar # # This file is part of Rattail. # @@ -26,7 +26,9 @@ Tween Factories from __future__ import unicode_literals, absolute_import +import six from sqlalchemy.exc import OperationalError + from transaction.interfaces import TransientError @@ -51,7 +53,7 @@ def sqlerror_tween_factory(handler, registry): response = handler(request) except OperationalError as error: if error.connection_invalidated: - raise TransientError(str(error)) + raise TransientError(six.text_type(error)) raise return response diff --git a/tailbone/util.py b/tailbone/util.py index 0df88477..890cd778 100644 --- a/tailbone/util.py +++ b/tailbone/util.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar +# Copyright © 2010-2018 Lance Edgar # # This file is part of Rattail. # @@ -28,6 +28,7 @@ from __future__ import unicode_literals, absolute_import import datetime +import six import pytz import humanize @@ -107,7 +108,7 @@ def raw_datetime(config, value): if value.year >= 1900: kwargs['c'] = value.strftime('%Y-%m-%d %I:%M:%S %p') else: - kwargs['c'] = unicode(value) + kwargs['c'] = six.text_type(value) # Avoid humanize error when calculating huge time diff. if abs(time_ago.days) < 100000: diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index aaab74be..ea595e8e 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -1148,7 +1148,7 @@ class FileBatchMasterView(BatchMasterView): raise httpexceptions.HTTPNotFound() path = batch.filepath(self.rattail_config) response = FileResponse(path, request=self.request) - response.headers[b'Content-Length'] = str(os.path.getsize(path)) + response.headers[b'Content-Length'] = six.binary_type(os.path.getsize(path)) filename = os.path.basename(batch.filename).encode('ascii', 'replace') response.headers[b'Content-Disposition'] = b'attachment; filename="{}"'.format(filename) return response diff --git a/tailbone/views/bouncer.py b/tailbone/views/bouncer.py index 680bdef8..a4833962 100644 --- a/tailbone/views/bouncer.py +++ b/tailbone/views/bouncer.py @@ -29,6 +29,8 @@ from __future__ import unicode_literals, absolute_import import os import datetime +import six + from rattail.db import model from rattail.bouncer import get_handler from rattail.bouncer.config import get_profile_keys @@ -175,7 +177,7 @@ class EmailBouncesView(MasterView): 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-Length'] = six.binary_type(os.path.getsize(path)) response.headers[b'Content-Disposition'] = b'attachment; filename="bounce.eml"' return response diff --git a/tailbone/views/core.py b/tailbone/views/core.py index ba3d1f9b..42addd76 100644 --- a/tailbone/views/core.py +++ b/tailbone/views/core.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar +# Copyright © 2010-2018 Lance Edgar # # This file is part of Rattail. # @@ -28,6 +28,8 @@ from __future__ import unicode_literals, absolute_import import os +import six + from rattail.db import model from rattail.util import progress_loop @@ -107,7 +109,7 @@ class View(object): if not os.path.exists(path): return self.notfound() response = FileResponse(path, request=self.request) - response.headers[b'Content-Length'] = str(os.path.getsize(path)) + response.headers[b'Content-Length'] = six.binary_type(os.path.getsize(path)) filename = os.path.basename(path).encode('ascii', 'replace') response.headers[b'Content-Disposition'] = b'attachment; filename="{}"'.format(filename) return response diff --git a/tailbone/views/email.py b/tailbone/views/email.py index 111589c5..58ef43ed 100644 --- a/tailbone/views/email.py +++ b/tailbone/views/email.py @@ -248,7 +248,7 @@ class EmailPreview(View): def email_template(self): recipient = self.request.POST.get('recipient') if recipient: - keys = filter(lambda k: k.startswith('send_'), self.request.POST.iterkeys()) + keys = filter(lambda k: k.startswith('send_'), self.request.POST.keys()) key = keys[0][5:] if keys else None if key: email = mail.get_email(self.rattail_config, key) diff --git a/tailbone/views/exports.py b/tailbone/views/exports.py index e35ea788..3ff31229 100644 --- a/tailbone/views/exports.py +++ b/tailbone/views/exports.py @@ -144,7 +144,7 @@ class ExportMasterView(MasterView): export = self.get_instance() path = self.get_file_path(export) response = FileResponse(path, request=self.request) - response.headers[b'Content-Length'] = str(os.path.getsize(path)) + response.headers[b'Content-Length'] = six.binary_type(os.path.getsize(path)) response.headers[b'Content-Disposition'] = b'attachment; filename="{}"'.format(export.filename) return response diff --git a/tailbone/views/handheld.py b/tailbone/views/handheld.py index 87c5131b..cac91f38 100644 --- a/tailbone/views/handheld.py +++ b/tailbone/views/handheld.py @@ -199,7 +199,7 @@ class HandheldBatchView(FileBatchMasterView): return text def get_exec_options_kwargs(self, **kwargs): - kwargs['ACTION_OPTIONS'] = list(ACTION_OPTIONS.iteritems()) + kwargs['ACTION_OPTIONS'] = list(ACTION_OPTIONS.items()) return kwargs def get_execute_success_url(self, batch, result, **kwargs): diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 3ceb0faf..bce46bdb 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -1200,7 +1200,7 @@ class MasterView(View): """ try: index = int(self.request.GET['index']) - except KeyError, ValueError: + except (KeyError, ValueError): return self.redirect(self.get_index_url()) if index < 1: return self.redirect(self.get_index_url()) diff --git a/tailbone/views/products.py b/tailbone/views/products.py index f8e0124f..68594509 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -551,7 +551,7 @@ class ProductsView(MasterView): if product and (not product.deleted or self.request.has_perm('products.view_deleted')): data = { 'uuid': product.uuid, - 'upc': unicode(product.upc), + 'upc': six.text_type(product.upc), 'upc_pretty': product.upc.pretty(), 'full_description': product.full_description, 'image_url': pod.get_image_url(self.rattail_config, product.upc), @@ -770,8 +770,8 @@ def print_labels(request): try: printer.print_labels([(product, quantity, {})]) - except Exception, error: - return {'error': str(error)} + except Exception as error: + return {'error': six.text_type(error)} return {} diff --git a/tailbone/views/purchases/core.py b/tailbone/views/purchases/core.py index d214b53a..2f118801 100644 --- a/tailbone/views/purchases/core.py +++ b/tailbone/views/purchases/core.py @@ -26,6 +26,8 @@ Views for "true" purchase orders from __future__ import unicode_literals, absolute_import +import six + from rattail.db import model from webhelpers2.html import HTML, tags @@ -133,7 +135,7 @@ class PurchaseView(MasterView): if purchase.date_ordered: return "{} (ordered {})".format(purchase.vendor, purchase.date_ordered.strftime('%Y-%m-%d')) return "{} (ordered)".format(purchase.vendor) - return unicode(purchase) + return six.text_type(purchase) def configure_grid(self, g): super(PurchaseView, self).configure_grid(g) diff --git a/tailbone/views/reports.py b/tailbone/views/reports.py index bd498de3..d68f4f9c 100644 --- a/tailbone/views/reports.py +++ b/tailbone/views/reports.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar +# Copyright © 2010-2018 Lance Edgar # # This file is part of Rattail. # @@ -28,6 +28,8 @@ from __future__ import unicode_literals, absolute_import import re +import six + import rattail from rattail.db import model from rattail.files import resource_path @@ -50,13 +52,13 @@ def get_upc(product): UPC formatter. Strips PLUs to bare number, and adds "minus check digit" for non-PLU UPCs. """ - upc = unicode(product.upc) + upc = six.text_type(product.upc) m = plu_upc_pattern.match(upc) if m: - return unicode(int(m.group(1))) + return six.text_type(int(m.group(1))) m = weighted_upc_pattern.match(upc) if m: - return unicode(int(m.group(1))) + return six.text_type(int(m.group(1))) return '{0}-{1}'.format(upc[:-1], upc[-1]) diff --git a/tailbone/views/shifts/schedule.py b/tailbone/views/shifts/schedule.py index 393acf8d..efaf4e33 100644 --- a/tailbone/views/shifts/schedule.py +++ b/tailbone/views/shifts/schedule.py @@ -79,7 +79,7 @@ class ScheduleView(TimeSheetView): # apply delete operations deleted = [] - for uuid, value in data['delete'].iteritems(): + for uuid, value in data['delete'].items(): if value == 'delete': shift = Session.query(model.ScheduledShift).get(uuid) if shift: @@ -90,7 +90,7 @@ class ScheduleView(TimeSheetView): created = {} updated = {} time_format = '%a %d %b %Y %I:%M %p' - for uuid, employee_uuid in data['start_time'].iteritems(): + for uuid, employee_uuid in data['start_time'].items(): if uuid in deleted: continue if uuid.startswith('new-'): diff --git a/tailbone/views/shifts/timesheet.py b/tailbone/views/shifts/timesheet.py index 5e8f9f51..a5e06d1a 100644 --- a/tailbone/views/shifts/timesheet.py +++ b/tailbone/views/shifts/timesheet.py @@ -81,7 +81,7 @@ class TimeSheetView(BaseTimeSheetView): created = {} updated = {} time_format = '%a %d %b %Y %I:%M %p' - for uuid, time in data['start_time'].iteritems(): + for uuid, time in data['start_time'].items(): if uuid in deleted: continue if uuid.startswith('new-'): diff --git a/tailbone/views/vendors/catalogs.py b/tailbone/views/vendors/catalogs.py index dff5b095..971e17c4 100644 --- a/tailbone/views/vendors/catalogs.py +++ b/tailbone/views/vendors/catalogs.py @@ -106,7 +106,7 @@ class VendorCatalogsView(FileBatchMasterView): g.sorters['vendor'] = g.make_sorter(model.Vendor.name) def get_instance_title(self, batch): - return unicode(batch.vendor) + return six.text_type(batch.vendor) def configure_form(self, f): super(VendorCatalogsView, self).configure_form(f) diff --git a/tailbone/views/vendors/invoices.py b/tailbone/views/vendors/invoices.py index dfc9f78b..54a9fae4 100644 --- a/tailbone/views/vendors/invoices.py +++ b/tailbone/views/vendors/invoices.py @@ -26,6 +26,8 @@ Views for maintaining vendor invoices from __future__ import unicode_literals, absolute_import +import six + from rattail.db import model, api from rattail.vendors.invoices import iter_invoice_parsers, require_invoice_parser @@ -83,7 +85,7 @@ class VendorInvoicesView(FileBatchMasterView): ] def get_instance_title(self, batch): - return unicode(batch.vendor) + return six.text_type(batch.vendor) def configure_grid(self, g): super(VendorInvoicesView, self).configure_grid(g) From b0821e8011f6ff8ce25978bf98e76e4c36d15c96 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 12 Feb 2018 15:32:54 -0600 Subject: [PATCH 0729/3196] More tweaks for python 3 --- tailbone/views/batch/core.py | 2 +- tailbone/views/principal.py | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index ea595e8e..5b5511da 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -30,7 +30,7 @@ import os import datetime import logging import tempfile -from cStringIO import StringIO +from six import StringIO import six import sqlalchemy as sa diff --git a/tailbone/views/principal.py b/tailbone/views/principal.py index dd82522b..23ad27cb 100644 --- a/tailbone/views/principal.py +++ b/tailbone/views/principal.py @@ -48,6 +48,10 @@ class PrincipalMasterView(MasterView): '/principal/{}.mako'.format(template), ] + super(PrincipalMasterView, self).get_fallback_templates(template, mobile=mobile) + def perm_sortkey(self, item): + key, value = item + return value['label'].lower() + def find_by_perm(self): """ View for finding all users who have been granted a given permission @@ -55,9 +59,9 @@ class PrincipalMasterView(MasterView): permissions = copy.deepcopy(self.request.registry.settings.get('tailbone_permissions', {})) # sort groups, and permissions for each group, for UI's sake - sorted_perms = sorted(permissions.items(), key=lambda (k, v): v['label'].lower()) + sorted_perms = sorted(permissions.items(), key=self.perm_sortkey) for key, group in sorted_perms: - group['perms'] = sorted(group['perms'].items(), key=lambda (k, v): v['label'].lower()) + group['perms'] = sorted(group['perms'].items(), key=self.perm_sortkey) # group options are stable, permission options may depend on submitted group group_choices = [(gkey, group['label']) for gkey, group in sorted_perms] From 17d99e16b9d3181f6ff48f7a90b94260ff40b7ad Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 12 Feb 2018 19:22:05 -0600 Subject: [PATCH 0730/3196] More tweaks for python 3 --- tailbone/forms/core.py | 4 ++-- tailbone/forms/widgets.py | 1 + tailbone/grids/core.py | 14 +++++++++----- tailbone/grids/filters.py | 2 +- tailbone/views/master.py | 18 ++++++++++++++---- tailbone/views/people.py | 2 +- tailbone/views/principal.py | 4 ++-- tailbone/views/users.py | 7 +++++-- 8 files changed, 35 insertions(+), 17 deletions(-) diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index a6795c24..e8bc369f 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.py @@ -714,8 +714,8 @@ class Form(object): def render_field_readonly(self, field_name, **kwargs): label = HTML.tag('label', self.get_label(field_name), for_=field_name) field = self.render_field_value(field_name) or '' - field_div = HTML.tag('div', class_='field', c=field) - return HTML.tag('div', class_='field-wrapper {}'.format(field_name), c=label + field_div) + field_div = HTML.tag('div', class_='field', c=[field]) + return HTML.tag('div', class_='field-wrapper {}'.format(field_name), c=[label, field_div]) def render_field_value(self, field_name): record = self.model_instance diff --git a/tailbone/forms/widgets.py b/tailbone/forms/widgets.py index 666d9f41..d0b13f22 100644 --- a/tailbone/forms/widgets.py +++ b/tailbone/forms/widgets.py @@ -83,6 +83,7 @@ class JQueryDateWidget(dfwidget.DateInputWidget): ) options.update(kw.get('extra_options', {})) kw.setdefault('options_json', json.dumps(options)) + kw.setdefault('selected_callback', None) values = self.get_template_values(field, cstruct, kw) return field.renderer(template, **values) diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index 41b13a71..60374577 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar +# Copyright © 2010-2018 Lance Edgar # # This file is part of Rattail. # @@ -27,7 +27,7 @@ Core Grid Classes from __future__ import unicode_literals, absolute_import import datetime -import urllib +from six.moves import urllib import six import sqlalchemy as sa @@ -911,8 +911,12 @@ class Grid(object): """ Returns the rendered contents of the 'actions' column for a given row. """ - main_actions = filter(None, [self.render_action(a, row, i) for a in self.main_actions]) - more_actions = filter(None, [self.render_action(a, row, i) for a in self.more_actions]) + main_actions = [self.render_action(a, row, i) + for a in self.main_actions] + main_actions = [a for a in main_actions if a] + more_actions = [self.render_action(a, row, i) + for a in self.more_actions] + more_actions = [a for a in more_actions if a] if more_actions: icon = HTML.tag('span', class_='ui-icon ui-icon-carat-1-e') link = tags.link_to("More" + icon, '#', class_='more') @@ -1072,5 +1076,5 @@ class URLMaker(object): params = self.request.GET.copy() params["page"] = page params["partial"] = "1" - qs = urllib.urlencode(params, True) + qs = urllib.parse.urlencode(params, True) return '{}?{}'.format(self.request.path, qs) diff --git a/tailbone/grids/filters.py b/tailbone/grids/filters.py index 8ae73456..37719275 100644 --- a/tailbone/grids/filters.py +++ b/tailbone/grids/filters.py @@ -773,4 +773,4 @@ class GridFiltersForm(forms.Form): """ style = 'display: none;' if filtr.verb in filtr.valueless_verbs else None return HTML.tag('div', class_='value', style=style, - c=filtr.render_value(**kwargs)) + c=[filtr.render_value(**kwargs)]) diff --git a/tailbone/views/master.py b/tailbone/views/master.py index bce46bdb..8048e3ea 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -216,7 +216,10 @@ class MasterView(View): # Return grid only, if partial page was requested. if self.request.params.get('partial'): - self.request.response.content_type = b'text/html' + if six.PY3: + self.request.response.content_type = 'text/html' + else: + self.request.response.content_type = b'text/html' self.request.response.text = grid.render_grid() return self.request.response @@ -1227,9 +1230,16 @@ class MasterView(View): response.content_length = os.path.getsize(path) content_type = self.download_content_type(path, filename) if content_type: - response.content_type = six.binary_type(content_type) - filename = os.path.basename(path).encode('ascii', 'replace') - response.content_disposition = b'attachment; filename={}'.format(filename) + if six.PY3: + response.content_type = content_type + else: + response.content_type = six.binary_type(content_type) + if six.PY3: + filename = os.path.basename(path) + response.content_disposition = 'attachment; filename={}'.format(filename) + else: + filename = os.path.basename(path).encode('ascii', 'replace') + response.content_disposition = b'attachment; filename={}'.format(filename) return response def download_content_type(self, path, filename): diff --git a/tailbone/views/people.py b/tailbone/views/people.py index 051d19af..0fbacbc9 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -178,7 +178,7 @@ class PeopleView(MasterView): for user in users: text = user.username url = self.request.route_url('users.view', uuid=user.uuid) - items.append(HTML.tag('li', c=tags.link_to(text, url))) + items.append(HTML.tag('li', c=[tags.link_to(text, url)])) if items: return HTML.tag('ul', c=items) elif self.request.has_perm('users.create'): diff --git a/tailbone/views/principal.py b/tailbone/views/principal.py index 23ad27cb..d879c696 100644 --- a/tailbone/views/principal.py +++ b/tailbone/views/principal.py @@ -129,8 +129,8 @@ class PermissionsRenderer(Object): if checked: label = perms[key]['label'] span = HTML.tag('span', c="[X]" if checked else "[ ]") - inner += HTML.tag('p', class_='perm', c=span + ' ' + label) + inner += HTML.tag('p', class_='perm', c=[span, HTML(' '), label]) rendered = True if rendered: - html += HTML.tag('div', class_='group', c=inner) + html += HTML.tag('div', class_='group', c=[inner]) return html or "(none granted)" diff --git a/tailbone/views/users.py b/tailbone/views/users.py index 9be9a2f8..86167de5 100644 --- a/tailbone/views/users.py +++ b/tailbone/views/users.py @@ -169,8 +169,11 @@ class UsersView(PrincipalMasterView): roles = self.get_possible_roles().all() role_values = [(s.uuid, six.text_type(s)) for s in roles] f.set_node('roles', colander.Set()) + size = len(roles) + if size < 3: + size = 3 f.set_widget('roles', dfwidget.SelectWidget(multiple=True, - size=len(roles), + size=size, values=role_values)) if self.editing: f.set_default('roles', [r.uuid for r in user.roles]) @@ -234,7 +237,7 @@ class UsersView(PrincipalMasterView): for role in roles: text = role.name url = self.request.route_url('roles.view', uuid=role.uuid) - items.append(HTML.tag('li', c=tags.link_to(text, url))) + items.append(HTML.tag('li', c=[tags.link_to(text, url)])) return HTML.tag('ul', c=items) def editable_instance(self, user): From 585db147ac3b6c8201add562a81bbcd0d95c389b Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 12 Feb 2018 19:39:04 -0600 Subject: [PATCH 0731/3196] Tweak dependencies per rattail changes --- setup.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 787dc92d..682b15df 100644 --- a/setup.py +++ b/setup.py @@ -81,12 +81,13 @@ requires = [ 'openpyxl', # 2.4.7 'paginate', # 0.5.6 'paginate_sqlalchemy', # 0.2.0 + 'passlib', # 1.7.1 'pyramid_beaker>=0.6', # 0.6.1 'pyramid_debugtoolbar', # 1.0 'pyramid_deform', # 0.2 'pyramid_exclog', # 0.6 'pyramid_mako', # 1.0.2 - 'rattail[db,auth,bouncer]', # 0.5.0 + 'rattail[db,bouncer]', # 0.5.0 'six', # 1.10.0 'transaction', # 1.2.0 'waitress', # 0.8.1 From f411dcde240c22271d54f148664cd0fa90de04bc Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 12 Feb 2018 21:25:58 -0600 Subject: [PATCH 0732/3196] Remove pyramid_debugtoolbar dependency --- setup.py | 1 - 1 file changed, 1 deletion(-) diff --git a/setup.py b/setup.py index 682b15df..41156302 100644 --- a/setup.py +++ b/setup.py @@ -83,7 +83,6 @@ requires = [ 'paginate_sqlalchemy', # 0.2.0 'passlib', # 1.7.1 'pyramid_beaker>=0.6', # 0.6.1 - 'pyramid_debugtoolbar', # 1.0 'pyramid_deform', # 0.2 'pyramid_exclog', # 0.6 'pyramid_mako', # 1.0.2 From 2ab00bfd78fc28ab92558a171d61f0b5bb3fa733 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 12 Feb 2018 22:17:38 -0600 Subject: [PATCH 0733/3196] More python 3 tweaks --- tailbone/views/batch/core.py | 2 +- tailbone/views/master.py | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index 5b5511da..617f38ed 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -531,7 +531,7 @@ class BatchMasterView(MasterView): def make_batch_row_grid_tools(self, batch): if self.rows_bulk_deletable and not batch.executed and self.request.has_perm('{}.delete_rows'.format(self.get_permission_prefix())): url = self.request.route_url('{}.delete_rows'.format(self.get_route_prefix()), uuid=batch.uuid) - return HTML.tag('p', c=tags.link_to("Delete all rows matching current search", url)) + return HTML.tag('p', c=[tags.link_to("Delete all rows matching current search", url)]) def make_row_grid_tools(self, batch): return (self.make_default_row_grid_tools(batch) or '') + (self.make_batch_row_grid_tools(batch) or '') diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 8048e3ea..5a36bcb9 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -746,7 +746,10 @@ class MasterView(View): return self.redirect(self.request.current_route_url(_query=None)) if self.request.params.get('partial'): - self.request.response.content_type = b'text/html' + if six.PY3: + self.request.response.content_type = 'text/html' + else: + self.request.response.content_type = b'text/html' self.request.response.text = grid.render_grid() return self.request.response From a0d9b5ddf41b531336962c71a4b4e2a03872f51c Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 12 Feb 2018 22:17:46 -0600 Subject: [PATCH 0734/3196] Add generic 'login_as_home' setting i.e. redirect anonymous users to login instead of showing home page --- tailbone/views/common.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tailbone/views/common.py b/tailbone/views/common.py index 3cfaffa6..e5710f2b 100644 --- a/tailbone/views/common.py +++ b/tailbone/views/common.py @@ -69,6 +69,10 @@ class CommonView(View): """ Home page view. """ + if not mobile and not self.request.user: + if self.rattail_config.getbool('tailbone', 'login_is_home', default=True): + raise self.redirect(self.request.route_url('login')) + image_url = self.rattail_config.get( 'tailbone', 'main_image_url', default=self.request.static_url('tailbone:static/img/home_logo.png')) From 0a16cc2ded0e98aaf79d7c8082f268f02a036ed1 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 13 Feb 2018 00:10:32 -0600 Subject: [PATCH 0735/3196] Add tailbone version to base stylesheet URLs hopefully this forces clients to refresh after upgrade? --- tailbone/templates/base.mako | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/tailbone/templates/base.mako b/tailbone/templates/base.mako index 6a5ed524..cf348b3f 100644 --- a/tailbone/templates/base.mako +++ b/tailbone/templates/base.mako @@ -167,13 +167,13 @@ ${h.stylesheet_link(request.static_url('tailbone:static/css/jquery.ui.menubar.css'))} ${h.stylesheet_link(request.static_url('tailbone:static/css/jquery.loadmask.css'))} ${h.stylesheet_link(request.static_url('tailbone:static/css/jquery.ui.timepicker.css'))} - ${h.stylesheet_link(request.static_url('tailbone:static/css/jquery.ui.tailbone.css'))} - ${h.stylesheet_link(request.static_url('tailbone:static/css/base.css'))} - ${h.stylesheet_link(request.static_url('tailbone:static/css/layout.css'))} - ${h.stylesheet_link(request.static_url('tailbone:static/css/grids.css'))} - ${h.stylesheet_link(request.static_url('tailbone:static/css/filters.css'))} - ${h.stylesheet_link(request.static_url('tailbone:static/css/forms.css'))} - ${h.stylesheet_link(request.static_url('tailbone:static/css/diffs.css'))} + ${h.stylesheet_link(request.static_url('tailbone:static/css/jquery.ui.tailbone.css') + '?ver={}'.format(tailbone.__version__))} + ${h.stylesheet_link(request.static_url('tailbone:static/css/base.css') + '?ver={}'.format(tailbone.__version__))} + ${h.stylesheet_link(request.static_url('tailbone:static/css/layout.css') + '?ver={}'.format(tailbone.__version__))} + ${h.stylesheet_link(request.static_url('tailbone:static/css/grids.css') + '?ver={}'.format(tailbone.__version__))} + ${h.stylesheet_link(request.static_url('tailbone:static/css/filters.css') + '?ver={}'.format(tailbone.__version__))} + ${h.stylesheet_link(request.static_url('tailbone:static/css/forms.css') + '?ver={}'.format(tailbone.__version__))} + ${h.stylesheet_link(request.static_url('tailbone:static/css/diffs.css') + '?ver={}'.format(tailbone.__version__))} <%def name="jquery_theme()"> From cfb6cf5ab43c40881fd0c0a8976f8ca991e058df Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 14 Feb 2018 09:52:19 -0600 Subject: [PATCH 0736/3196] Tweak rendering for python 3 --- tailbone/views/tempmon/clients.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/views/tempmon/clients.py b/tailbone/views/tempmon/clients.py index ef6d453a..41a0d8be 100644 --- a/tailbone/views/tempmon/clients.py +++ b/tailbone/views/tempmon/clients.py @@ -114,7 +114,7 @@ class TempmonClientView(MasterView): for probe in probes: text = six.text_type(probe) url = self.request.route_url('tempmon.probes.view', uuid=probe.uuid) - items.append(HTML.tag('li', c=tags.link_to(text, url))) + items.append(HTML.tag('li', c=[tags.link_to(text, url)])) return HTML.tag('ul', c=items) def delete_instance(self, client): From cb2234cef56bb2de557ebdaad0dce8bed6501e0b Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 14 Feb 2018 10:31:04 -0600 Subject: [PATCH 0737/3196] Fix encoding for robots.txt view response --- tailbone/views/common.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tailbone/views/common.py b/tailbone/views/common.py index e5710f2b..f48bdbe4 100644 --- a/tailbone/views/common.py +++ b/tailbone/views/common.py @@ -84,7 +84,14 @@ class CommonView(View): """ with open(self.robots_txt_path, 'rt') as f: content = f.read() - return Response(content_type=six.binary_type('text/plain'), body=content) + response = self.request.response + if six.PY3: + response.text = content + response.content_type = 'text/plain' + else: + response.body = content + response.content_type = b'text/plain' + return response def mobile_home(self): """ From 79634d402eec24cc5be10f06de28e2439e4dc1d4 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 14 Feb 2018 14:18:38 -0600 Subject: [PATCH 0738/3196] 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 7f9c0c11..3f7b9edc 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,22 @@ CHANGELOG ========= +0.7.2 (2018-02-14) +------------------ + +* Refactor all remaining forms to use colander/deform. + +* Coalesce 'forms2' => 'forms' package. + +* Remove dependencies: FormAlchemy, FormEncode, pyramid_simpleform, pyramid_debugtoolbar + +* Misc. cleanup for Python 3. + +* Add generic 'login_as_home' setting. + +* Add tailbone version to base stylesheet URLs. + + 0.7.1 (2018-02-10) ------------------ diff --git a/tailbone/_version.py b/tailbone/_version.py index 1fe8c93e..714dbe43 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.7.1' +__version__ = '0.7.2' From 135e98cde119c305200af96173ee4a38039ec3dc Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 14 Feb 2018 15:27:55 -0600 Subject: [PATCH 0739/3196] Fix encoding bug for python 3, when downloading CSV results --- tailbone/views/master.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 5a36bcb9..68ae7615 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -1935,11 +1935,16 @@ class MasterView(View): for obj in results: writer.writerow(self.get_csv_row(obj, fields)) response = self.request.response - response.body = data.getvalue() + if six.PY3: + response.text = data.getvalue() + response.content_type = 'text/csv' + response.content_disposition = 'attachment; filename={}.csv'.format(self.get_grid_key()) + else: + response.body = data.getvalue() + response.content_type = b'text/csv' + response.content_disposition = b'attachment; filename={}.csv'.format(self.get_grid_key()) data.close() response.content_length = len(response.body) - response.content_type = b'text/csv' - response.content_disposition = b'attachment; filename={}.csv'.format(self.get_grid_key()) return response def results_xlsx(self): From 5c1008a0df9405e6b60e9e54f10b1fd64f429dfb Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 15 Feb 2018 12:48:14 -0600 Subject: [PATCH 0740/3196] More tweaks for python 3 --- tailbone/templates/batch/view.mako | 7 ------- tailbone/templates/messages/create.mako | 3 ++- tailbone/views/employees.py | 8 ++++---- tailbone/views/exports.py | 8 ++++++-- tailbone/views/master.py | 13 +++++++++---- tailbone/views/messages.py | 10 +++++++++- tailbone/views/shifts/core.py | 3 +++ 7 files changed, 33 insertions(+), 19 deletions(-) diff --git a/tailbone/templates/batch/view.mako b/tailbone/templates/batch/view.mako index f71e49d0..86710cf0 100644 --- a/tailbone/templates/batch/view.mako +++ b/tailbone/templates/batch/view.mako @@ -39,13 +39,6 @@ -<%def name="context_menu_items()"> - ${parent.context_menu_items()} - % if master.cloneable and request.has_perm('{}.clone'.format(permission_prefix)): -
    • ${h.link_to("Clone as new batch", url('{}.clone'.format(route_prefix), uuid=batch.uuid))}
    • - % endif - - <%def name="buttons()">
      ${self.leading_buttons()} diff --git a/tailbone/templates/messages/create.mako b/tailbone/templates/messages/create.mako index aa7d9833..dc83f46d 100644 --- a/tailbone/templates/messages/create.mako +++ b/tailbone/templates/messages/create.mako @@ -8,7 +8,8 @@ var recipient_mappings = new Map([ <% last = len(available_recipients) %> - % for i, (uuid, entry) in enumerate(sorted(available_recipients.items(), key=lambda r: r[1]), 1): + % for i, recip in enumerate(available_recipients, 1): + <% uuid, entry = recip %> ['${uuid}', ${json.dumps(entry)|n}]${',' if i < last else ''} % endfor ]); diff --git a/tailbone/views/employees.py b/tailbone/views/employees.py index 89d7e014..d98f6e61 100644 --- a/tailbone/views/employees.py +++ b/tailbone/views/employees.py @@ -239,18 +239,18 @@ class EmployeesView(MasterView): stores = employee.stores if employee else None if not stores: return "" - items = HTML.literal('') + items = [] for store in sorted(stores, key=six.text_type): - items += HTML.tag('li', c=six.text_type(store)) + items.append(HTML.tag('li', c=six.text_type(store))) return HTML.tag('ul', c=items) def render_departments(self, employee, field): departments = employee.departments if employee else None if not departments: return "" - items = HTML.literal('') + items = [] for department in sorted(departments, key=six.text_type): - items += HTML.tag('li', c=six.text_type(department)) + items.append(HTML.tag('li', c=six.text_type(department))) return HTML.tag('ul', c=items) def get_version_child_classes(self): diff --git a/tailbone/views/exports.py b/tailbone/views/exports.py index 3ff31229..542199cc 100644 --- a/tailbone/views/exports.py +++ b/tailbone/views/exports.py @@ -144,8 +144,12 @@ class ExportMasterView(MasterView): export = self.get_instance() path = self.get_file_path(export) response = FileResponse(path, request=self.request) - response.headers[b'Content-Length'] = six.binary_type(os.path.getsize(path)) - response.headers[b'Content-Disposition'] = b'attachment; filename="{}"'.format(export.filename) + if six.PY3: + response.headers['Content-Length'] = str(os.path.getsize(path)) + response.headers['Content-Disposition'] = 'attachment; filename="{}"'.format(export.filename) + else: + response.headers[b'Content-Length'] = six.binary_type(os.path.getsize(path)) + response.headers[b'Content-Disposition'] = b'attachment; filename="{}"'.format(export.filename) return response def delete_instance(self, export): diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 68ae7615..1fa8f739 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -2010,12 +2010,17 @@ class MasterView(View): for row in self.get_effective_row_data(sort=True): writer.writerow(self.get_row_csv_row(row, fields)) response = self.request.response - response.body = data.getvalue() + filename = self.get_row_results_csv_filename(obj) + if six.PY3: + response.text = data.getvalue() + response.content_type = 'text/csv' + response.content_disposition = 'attachment; filename={}'.format(filename) + else: + response.body = data.getvalue() + response.content_type = b'text/csv' + response.content_disposition = b'attachment; filename={}'.format(filename) data.close() response.content_length = len(response.body) - response.content_type = b'text/csv' - filename = self.get_row_results_csv_filename(obj) - response.content_disposition = b'attachment; filename={}'.format(filename) return response def get_row_results_csv_filename(self, instance): diff --git a/tailbone/views/messages.py b/tailbone/views/messages.py index 9747a391..7d72aecb 100644 --- a/tailbone/views/messages.py +++ b/tailbone/views/messages.py @@ -286,11 +286,19 @@ class MessagesView(MasterView): return recipient def template_kwargs_create(self, **kwargs): - kwargs['available_recipients'] = self.get_available_recipients() + recips = list(self.get_available_recipients().items()) + recips.sort(key=self.recipient_sortkey) + kwargs['available_recipients'] = recips if self.replying: kwargs['original_message'] = self.get_instance() return kwargs + def recipient_sortkey(self, recip): + uuid, entry = recip + if isinstance(entry, dict): + return entry['name'] + return entry + def get_available_recipients(self): """ Return the full mapping of recipients which may be included in a diff --git a/tailbone/views/shifts/core.py b/tailbone/views/shifts/core.py index bb343143..9e439cfa 100644 --- a/tailbone/views/shifts/core.py +++ b/tailbone/views/shifts/core.py @@ -28,11 +28,14 @@ from __future__ import unicode_literals, absolute_import import datetime +import six import humanize from rattail.db import model from rattail.time import localtime +from webhelpers2.html import tags + from tailbone.views import MasterView From e93e1b91a956bb42c9b8c0416f2a05442fe38a28 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 15 Feb 2018 18:49:16 -0600 Subject: [PATCH 0741/3196] 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 3f7b9edc..093bd818 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.7.3 (2018-02-15) +------------------ + +* More tweaks for python 3. + + 0.7.2 (2018-02-14) ------------------ diff --git a/tailbone/_version.py b/tailbone/_version.py index 714dbe43..45f6959e 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.7.2' +__version__ = '0.7.3' From 12dd6ae6b09936c93a198cbe6269874c6b3252ce Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 19 Feb 2018 15:31:02 -0600 Subject: [PATCH 0742/3196] Use all "normal" product form fields, for mobile view --- tailbone/views/products.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 68594509..00872e86 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -79,6 +79,10 @@ class ProductsView(MasterView): supports_mobile = True has_versions = True + labels = { + 'status_code': "Status", + } + grid_columns = [ 'upc', 'brand', @@ -131,9 +135,7 @@ class ProductsView(MasterView): 'inventory_on_order', ] - labels = { - 'status_code': "Status", - } + mobile_form_fields = form_fields # These aliases enable the grid queries to filter products which may be # purchased from *any* vendor, and yet sort by only the "preferred" vendor From b529a005d8063aa25ec6816fbe05430c0626fff1 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 19 Feb 2018 17:09:12 -0600 Subject: [PATCH 0743/3196] Remove some redundant / unused code --- tailbone/templates/master/create.mako | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/tailbone/templates/master/create.mako b/tailbone/templates/master/create.mako index 4340ae6c..a6ea2a3e 100644 --- a/tailbone/templates/master/create.mako +++ b/tailbone/templates/master/create.mako @@ -3,27 +3,6 @@ <%def name="title()">New ${model_title_plural if master.creates_multiple else model_title} -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - ${self.disable_button_js()} - - -<%def name="disable_button_js()"> - - - <%def name="context_menu_items()">
        From 1b059c5293f165459ea2b1194b048b0b7d14416e Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 19 Feb 2018 18:19:19 -0600 Subject: [PATCH 0744/3196] Refactor ordering worksheet to use shared logic --- tailbone/templates/ordering/view.mako | 12 +++------- .../{order_form.mako => worksheet.mako} | 2 +- tailbone/views/purchases/core.py | 2 +- tailbone/views/purchasing/ordering.py | 24 ++++++------------- 4 files changed, 12 insertions(+), 28 deletions(-) rename tailbone/templates/ordering/{order_form.mako => worksheet.mako} (98%) diff --git a/tailbone/templates/ordering/view.mako b/tailbone/templates/ordering/view.mako index 4d12e643..39bb350b 100644 --- a/tailbone/templates/ordering/view.mako +++ b/tailbone/templates/ordering/view.mako @@ -6,8 +6,8 @@ + From ff7341d272f9b4713ac2bd94d8701642fb21b767 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 22 Feb 2018 11:12:51 -0600 Subject: [PATCH 0748/3196] Add `Form.mobile` flag and set link button styles accordingly --- tailbone/forms/core.py | 3 ++- tailbone/templates/forms/deform.mako | 6 +++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index e8bc369f..6a4dc21d 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.py @@ -327,7 +327,7 @@ class Form(object): auto_disable_save = True auto_disable_cancel = True - def __init__(self, fields=None, schema=None, request=None, readonly=False, readonly_fields=[], + def __init__(self, fields=None, schema=None, request=None, mobile=False, readonly=False, readonly_fields=[], model_instance=None, model_class=None, nodes={}, enums={}, labels={}, renderers=None, hidden={}, widgets={}, defaults={}, validators={}, required={}, helptext={}, action_url=None, cancel_url=None): @@ -339,6 +339,7 @@ class Form(object): if self.fields is None and self.schema: self.set_fields([f.name for f in self.schema]) self.request = request + self.mobile = mobile self.readonly = readonly self.readonly_fields = set(readonly_fields or []) self.model_instance = model_instance diff --git a/tailbone/templates/forms/deform.mako b/tailbone/templates/forms/deform.mako index ed7b3928..54652190 100644 --- a/tailbone/templates/forms/deform.mako +++ b/tailbone/templates/forms/deform.mako @@ -80,7 +80,11 @@ ${h.csrf_token(request)} % endif % if getattr(form, 'show_cancel', True): - ${h.link_to("Cancel", form.cancel_url, class_='cancel button{}'.format(' autodisable' if form.auto_disable_cancel else ''))} + % if form.mobile: + ${h.link_to("Cancel", form.cancel_url, class_='ui-btn ui-corner-all')} + % else: + ${h.link_to("Cancel", form.cancel_url, class_='cancel button{}'.format(' autodisable' if form.auto_disable_cancel else ''))} + % endif % endif
      % endif From e1a9da0716264441d970c2bef2bff14618922cf5 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 22 Feb 2018 11:13:29 -0600 Subject: [PATCH 0749/3196] Always show flash-error-style message when form has errors probably will regret this and change it back soon, we'll see --- tailbone/templates/forms/deform.mako | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/tailbone/templates/forms/deform.mako b/tailbone/templates/forms/deform.mako index 54652190..1b5b02cf 100644 --- a/tailbone/templates/forms/deform.mako +++ b/tailbone/templates/forms/deform.mako @@ -6,9 +6,18 @@ ${h.form(form.action_url, id=dform.formid, method='post', enctype='multipart/for ${h.csrf_token(request)} % endif -## % for error in fieldset.errors.get(None, []): -##
      ${error}
      -## % endfor +% if dform.error: +
      +
      + + Please see errors below. +
      +
      + + ${dform.error} +
      +
      +% endif % for field in form.fields: From 37a788a141cd472092745ef425d82d8071697ad5 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 22 Feb 2018 11:14:21 -0600 Subject: [PATCH 0750/3196] Use `Form.submit_label` if present, or fall back to `save_label` latter should probably be deprecated / removed at some point --- tailbone/templates/forms/deform.mako | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/templates/forms/deform.mako b/tailbone/templates/forms/deform.mako index 1b5b02cf..80f31a64 100644 --- a/tailbone/templates/forms/deform.mako +++ b/tailbone/templates/forms/deform.mako @@ -81,7 +81,7 @@ ${h.csrf_token(request)} % elif not readonly and (buttons is Undefined or (buttons is not None and buttons is not False)):
      ## ${h.submit('create', form.create_label if form.creating else form.update_label)} - ${h.submit('save', getattr(form, 'save_label', "Save"))} + ${h.submit('save', getattr(form, 'submit_label', getattr(form, 'save_label', "Submit")))} ## % if form.creating and form.allow_successive_creates: ## ${h.submit('create_and_continue', form.successive_create_label)} ## % endif From d75fe88c442f27f063fcbffda2ac2f6ed76c20cd Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 22 Feb 2018 11:16:33 -0600 Subject: [PATCH 0751/3196] Expose `ship_method` and `notes_to_vendor` for purchase, ordering batch --- tailbone/views/purchases/core.py | 2 ++ tailbone/views/purchasing/ordering.py | 6 ++++++ 2 files changed, 8 insertions(+) diff --git a/tailbone/views/purchases/core.py b/tailbone/views/purchases/core.py index 2df0b1de..ee532fcd 100644 --- a/tailbone/views/purchases/core.py +++ b/tailbone/views/purchases/core.py @@ -69,6 +69,8 @@ class PurchaseView(MasterView): 'date_received', 'po_number', 'po_total', + 'ship_method', + 'notes_to_vendor', 'invoice_date', 'invoice_number', 'invoice_total', diff --git a/tailbone/views/purchasing/ordering.py b/tailbone/views/purchasing/ordering.py index 0d94bbc2..69346928 100644 --- a/tailbone/views/purchasing/ordering.py +++ b/tailbone/views/purchasing/ordering.py @@ -107,6 +107,12 @@ class OrderingBatchView(PurchasingBatchView): # purchase f.remove_field('purchase') + def get_batch_kwargs(self, batch, mobile=False): + kwargs = super(OrderingBatchView, self).get_batch_kwargs(batch, mobile=mobile) + kwargs['ship_method'] = batch.ship_method + kwargs['notes_to_vendor'] = batch.notes_to_vendor + return kwargs + def worksheet(self): """ View for editing batch row data as an order form worksheet. From 2a2ff721c11777ac6cefbfd5d1d060e5c62b4551 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 22 Feb 2018 11:18:11 -0600 Subject: [PATCH 0752/3196] Bind batch to its execution options schema, when applicable so the batch can provide default values, etc. this also tweaks logic for using defaults from session storage, so that they don't take priority over batch values --- tailbone/views/batch/core.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index 1d539dd2..b872e736 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -603,8 +603,8 @@ class BatchMasterView(MasterView): # TODO execution_options_schema = None - def make_execute_schema(self): - return self.execution_options_schema() + def make_execute_schema(self, batch): + return self.execution_options_schema().bind(batch=batch) def make_execute_form(self, batch=None, **kwargs): """ @@ -616,11 +616,15 @@ class BatchMasterView(MasterView): if self.has_execution_options(batch): if batch is None: batch = self.model_class - schema = self.make_execute_schema() + schema = self.make_execute_schema(batch) for field in schema: - key = 'batch.{}.execute_option.{}'.format(batch.batch_key, field.name) - if key in self.request.session: - defaults[field.name] = self.request.session[key] + + # if field does not yet have a default, maybe provide one from session storage + if field.default is colander.null: + key = 'batch.{}.execute_option.{}'.format(batch.batch_key, field.name) + if key in self.request.session: + defaults[field.name] = self.request.session[key] + else: schema = colander.Schema() From 0a165c5b93b29495ac4e4fb3dab2a7c5fb69b1ae Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 22 Feb 2018 11:19:33 -0600 Subject: [PATCH 0753/3196] Don't set order date for new ordering batch when created via mobile that really should be set upon batch execution instead --- tailbone/views/purchasing/ordering.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tailbone/views/purchasing/ordering.py b/tailbone/views/purchasing/ordering.py index 69346928..332003d1 100644 --- a/tailbone/views/purchasing/ordering.py +++ b/tailbone/views/purchasing/ordering.py @@ -301,7 +301,6 @@ class OrderingBatchView(PurchasingBatchView): batch.vendor = vendor batch.store = store batch.buyer = self.request.user.employee - batch.date_ordered = localtime(self.rattail_config).date() batch.created_by = self.request.user batch.po_total = 0 kwargs = self.get_batch_kwargs(batch, mobile=True) From 3d79f9fd7d605f65deb4b0ff64d2250d5cc266d2 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 22 Feb 2018 11:20:12 -0600 Subject: [PATCH 0754/3196] Add support for executing batch with options, via mobile --- tailbone/templates/mobile/batch/execute.mako | 10 +++++++ tailbone/templates/mobile/batch/view.mako | 12 +++++--- tailbone/views/batch/core.py | 29 ++++++++++++++------ 3 files changed, 38 insertions(+), 13 deletions(-) create mode 100644 tailbone/templates/mobile/batch/execute.mako diff --git a/tailbone/templates/mobile/batch/execute.mako b/tailbone/templates/mobile/batch/execute.mako new file mode 100644 index 00000000..a6c7c6ef --- /dev/null +++ b/tailbone/templates/mobile/batch/execute.mako @@ -0,0 +1,10 @@ +## -*- coding: utf-8; -*- +<%inherit file="/mobile/base.mako" /> + +<%def name="title()">${index_title} » ${instance_title} » Execute + +<%def name="page_title()">${h.link_to(index_title, index_url)} » ${h.link_to(instance_title, instance_url)} » Execute + +
      + ${form.render()|n} +
      diff --git a/tailbone/templates/mobile/batch/view.mako b/tailbone/templates/mobile/batch/view.mako index 9a24b21e..9e64c4bf 100644 --- a/tailbone/templates/mobile/batch/view.mako +++ b/tailbone/templates/mobile/batch/view.mako @@ -28,9 +28,13 @@ ${parent.body()} % endif % endif % if batch.complete and master.mobile_executable and request.has_perm('{}.execute'.format(permission_prefix)): - ${h.form(url('mobile.{}.execute'.format(route_prefix), uuid=batch.uuid))} - ${h.csrf_token(request)} - ${h.submit('submit', "Execute Batch")} - ${h.end_form()} + % if master.has_execution_options(batch): + ${h.link_to("Execute Batch", url('mobile.{}.execute'.format(route_prefix), uuid=batch.uuid), class_='ui-btn ui-corner-all')} + % else: + ${h.form(url('mobile.{}.execute'.format(route_prefix), uuid=batch.uuid))} + ${h.csrf_token(request)} + ${h.submit('submit', "Execute Batch")} + ${h.end_form()} + % endif % endif % endif diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index b872e736..4bac53e4 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -898,9 +898,14 @@ class BatchMasterView(MasterView): def mobile_execute(self): """ - Execute a batch via mobile, i.e. (for now) blocking with no progress bar. + Mobile view which can prompt user for execution options if applicable, + and/or execute a batch. For now this is done in a "blocking" fashion, + i.e. no progress bar. """ batch = self.get_instance() + model_title = self.get_model_title() + instance_title = self.get_instance_title(batch) + view_url = self.get_action_url('view', batch, mobile=True) self.executing = True form = self.make_execute_form(batch) if form.validate(newstyle=True): @@ -913,20 +918,26 @@ class BatchMasterView(MasterView): try: result = self.handler.execute(batch, user=self.request.user, **kwargs) except Exception as err: - log.exception("failed to execute batch %s: %s", batch.id_str, batch) - self.request.session.flash("Failed to execute batch: {}".format(err), 'error') + log.exception("failed to execute %s %s", model_title, batch.id_str) + self.request.session.flash(self.execute_error_message(err), 'error') else: if result: batch.executed = datetime.datetime.utcnow() batch.executed_by = self.request.user - self.request.session.flash("Batch was executed: {}".format(batch)) + self.request.session.flash("{} was executed: {}".format(model_title, instance_title)) else: - log.error("not sure why, but failed to execute batch %s: %s", batch.id_str, batch) - self.request.session.flash("Failed to execute batch: {}".format(err), 'error') - return self.redirect(self.get_action_url('view', batch, mobile=True)) + log.error("not sure why, but failed to execute %s %s: %s", model_title, batch.id_str, batch) + self.request.session.flash("Failed to execute {}: {}".format(model_title, err), 'error') + return self.redirect(view_url) - self.request.session.flash("Invalid request: {}".format(form.make_deform_form().error), 'error') - return self.redirect(self.get_action_url('view', batch, mobile=True)) + form.mobile = True + form.submit_label = "Execute" + form.cancel_url = view_url + return self.render_to_response('execute', { + 'form': form, + 'instance_title': instance_title, + 'instance_url': view_url, + }, mobile=True) def execute_error_message(self, error): return "Batch execution failed: {}: {}".format(type(error).__name__, error) From 630ffe0cf83fff526e927c7b4499c4f6dc0cb5b4 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 22 Feb 2018 12:26:21 -0600 Subject: [PATCH 0755/3196] Don't allow row deletion if batch is marked complete --- tailbone/views/batch/core.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index 4bac53e4..15a9c496 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -818,18 +818,18 @@ class BatchMasterView(MasterView): def row_editable(self, row): """ - Batch rows are editable only until batch has been executed. + Batch rows are editable only until batch is complete or executed. """ batch = self.get_parent(row) return self.rows_editable and not batch.executed and not batch.complete def row_deletable(self, row): """ - Batch rows are deletable only until batch has been executed. + Batch rows are deletable only until batch is complete or executed. """ if self.rows_deletable: batch = self.get_parent(row) - if not batch.executed: + if not batch.executed and not batch.complete: return True return False From f2a60f683c930cfdb698c37ba7809138aefe9b15 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 22 Feb 2018 12:27:08 -0600 Subject: [PATCH 0756/3196] Add logic for editing default phone/email in base master view and refactor customer, vendor views to use it --- tailbone/views/customers.py | 30 +++-------------- tailbone/views/master.py | 26 +++++++++++++++ tailbone/views/vendors/core.py | 61 +++++++++++++++++++++++++++++----- 3 files changed, 82 insertions(+), 35 deletions(-) diff --git a/tailbone/views/customers.py b/tailbone/views/customers.py index c6b64379..aab47181 100644 --- a/tailbone/views/customers.py +++ b/tailbone/views/customers.py @@ -209,32 +209,10 @@ class CustomersView(MasterView): f.set_renderer('groups', self.render_groups) f.set_readonly('groups') - # TODO: something like this should be supported for default_email, default_phone - # def after_edit(self, customer): - # - # if not self.is_readonly(): - # address = self._deserialize() - # contact = self.parent.model - # if contact.emails: - # if address: - # email = contact.emails[0] - # email.address = address - # else: - # contact.emails.pop(0) - # elif address: - # email = contact.add_email_address(address) - # - # if not self.is_readonly(): - # number = self._deserialize() - # contact = self.parent.model - # if contact.phones: - # if number: - # phone = contact.phones[0] - # phone.number = number - # else: - # contact.phones.pop(0) - # elif number: - # phone = contact.add_phone_number(number) + def objectify(self, form, data): + customer = super(CustomersView, self).objectify(form, data) + customer = self.objectify_contact(customer, data) + return customer def render_default_email(self, customer, field): if customer.emails: diff --git a/tailbone/views/master.py b/tailbone/views/master.py index f3d49514..59b6d9b7 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -2233,6 +2233,32 @@ class MasterView(View): obj = form.schema.objectify(data, context=form.model_instance) return obj + def objectify_contact(self, contact, data): + + if 'default_email' in data: + address = data['default_email'] + if contact.emails: + if address: + email = contact.emails[0] + email.address = address + else: + contact.emails.pop(0) + elif address: + contact.add_email_address(address) + + if 'default_phone' in data: + number = data['default_phone'] + if contact.phones: + if number: + phone = contact.phones[0] + phone.number = number + else: + contact.phones.pop(0) + elif number: + contact.add_phone_number(number) + + return contact + def save_form(self, form): form.save() diff --git a/tailbone/views/vendors/core.py b/tailbone/views/vendors/core.py index 60e43682..59034faa 100644 --- a/tailbone/views/vendors/core.py +++ b/tailbone/views/vendors/core.py @@ -42,6 +42,12 @@ class VendorsView(MasterView): model_class = model.Vendor has_versions = True + labels = { + 'id': "ID", + 'default_phone': "Phone Number", + 'default_email': "Default Email", + } + grid_columns = [ 'id', 'name', @@ -56,8 +62,9 @@ class VendorsView(MasterView): 'special_discount', 'lead_time_days', 'order_interval_days', - 'phone', - 'email', + 'default_phone', + 'default_email', + 'orders_email', 'contact', ] @@ -68,7 +75,6 @@ class VendorsView(MasterView): g.filters['name'].default_verb = 'contains' g.set_sort_defaults('name') - g.set_label('id', "ID") g.set_label('phone', "Phone Number") g.set_label('email', "Email Address") @@ -77,22 +83,59 @@ class VendorsView(MasterView): def configure_form(self, f): super(VendorsView, self).configure_form(f) - - f.set_label('id', "ID") + vendor = f.model_instance f.set_label('lead_time_days', "Lead Time in Days") f.set_label('order_interval', "Order Interval in Days") - f.set_readonly('phone') - f.set_label('phone', "Phone Number") + # default_phone + f.set_renderer('default_phone', self.render_default_phone) + if not self.creating and vendor.phones: + f.set_default('default_phone', vendor.phones[0].number) - f.set_readonly('email') - f.set_label('email', "Email Address") + # default_email + f.set_renderer('default_email', self.render_default_email) + if not self.creating and vendor.emails: + f.set_default('default_email', vendor.emails[0].address) + + # orders_email + f.set_renderer('orders_email', self.render_orders_email) + if not self.creating and vendor.emails: + f.set_default('orders_email', vendor.get_email_address(type_='Orders') or '') f.set_readonly('contact') f.set_renderer('contact', self.render_contact) + def objectify(self, form, data): + vendor = super(VendorsView, self).objectify(form, data) + vendor = self.objectify_contact(vendor, data) + + if 'orders_email' in data: + address = data['orders_email'] + email = vendor.get_email(type_='Orders') + if address: + if email: + if email.address != address: + email.address = address + else: + vendor.add_email_address(address, type='Orders') + elif email: + vendor.emails.remove(email) + + return vendor + + def render_default_email(self, vendor, field): + if vendor.emails: + return vendor.emails[0].address + + def render_orders_email(self, vendor, field): + return vendor.get_email_address(type_='Orders') + + def render_default_phone(self, vendor, field): + if vendor.phones: + return vendor.phones[0].number + def render_contact(self, vendor, field): person = vendor.contact if not person: From 2c2df9f01ee9d3f9185c0f3d54513e5f924f444d Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 22 Feb 2018 13:25:20 -0600 Subject: [PATCH 0757/3196] Fix bug in users view when person field not present --- tailbone/views/users.py | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/tailbone/views/users.py b/tailbone/views/users.py index 86167de5..07d1c254 100644 --- a/tailbone/views/users.py +++ b/tailbone/views/users.py @@ -142,20 +142,21 @@ class UsersView(PrincipalMasterView): # person f.set_renderer('person', self.render_person) if self.creating or self.editing: - f.replace('person', 'person_uuid') - f.set_node('person_uuid', colander.String(), missing=colander.null) - person_display = "" - if self.request.method == 'POST': - if self.request.POST.get('person_uuid'): - person = self.Session.query(model.Person).get(self.request.POST['person_uuid']) - if person: - person_display = six.text_type(person) - elif self.editing: - person_display = six.text_type(user.person or '') - people_url = self.request.route_url('people.autocomplete') - f.set_widget('person_uuid', forms.widgets.JQueryAutocompleteWidget( - field_display=person_display, service_url=people_url)) - f.set_label('person_uuid', "Person") + if 'person' in f.fields: + f.replace('person', 'person_uuid') + f.set_node('person_uuid', colander.String(), missing=colander.null) + person_display = "" + if self.request.method == 'POST': + if self.request.POST.get('person_uuid'): + person = self.Session.query(model.Person).get(self.request.POST['person_uuid']) + if person: + person_display = six.text_type(person) + elif self.editing: + person_display = six.text_type(user.person or '') + people_url = self.request.route_url('people.autocomplete') + f.set_widget('person_uuid', forms.widgets.JQueryAutocompleteWidget( + field_display=person_display, service_url=people_url)) + f.set_label('person_uuid', "Person") # password f.set_widget('password', dfwidget.CheckedPasswordWidget()) From 021848524af5499d365757a4e9c1e1f562d45577 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 22 Feb 2018 21:04:00 -0600 Subject: [PATCH 0758/3196] Fix field type for Trainwreck view --- tailbone/views/trainwreck.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tailbone/views/trainwreck.py b/tailbone/views/trainwreck.py index 2c8c2c62..34147383 100644 --- a/tailbone/views/trainwreck.py +++ b/tailbone/views/trainwreck.py @@ -187,6 +187,7 @@ class TransactionView(MasterView): f.set_type('unit_quantity', 'quantity') # currency fields + f.set_type('unit_price', 'currency') f.set_type('subtotal', 'currency') f.set_type('discounted_subtotal', 'currency') f.set_type('tax', 'currency') From 73f73e023686b62bf9c60a482865e5219eff10b3 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 24 Feb 2018 17:15:49 -0600 Subject: [PATCH 0759/3196] Add python 3.5 support for tox; run all tests before a release --- fabfile.py => tasks.py | 14 ++++++++------ tox.ini | 2 +- 2 files changed, 9 insertions(+), 7 deletions(-) rename fabfile.py => tasks.py (83%) diff --git a/fabfile.py b/tasks.py similarity index 83% rename from fabfile.py rename to tasks.py index cc2c7b71..a363bf34 100644 --- a/fabfile.py +++ b/tasks.py @@ -1,8 +1,8 @@ -# -*- coding: utf-8 -*- +# -*- coding: utf-8; -*- ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar +# Copyright © 2010-2018 Lance Edgar # # This file is part of Rattail. # @@ -21,19 +21,21 @@ # ################################################################################ """ -Fabric script for Tailbone +Tasks for Tailbone """ from __future__ import unicode_literals, absolute_import import shutil -from fabric.api import task, local + +from invoke import task @task -def release(): +def release(ctx): """ Release a new version of 'Tailbone'. """ + ctx.run('tox') shutil.rmtree('Tailbone.egg-info') - local('python setup.py sdist --formats=gztar upload') + ctx.run('python setup.py sdist --formats=gztar upload') diff --git a/tox.ini b/tox.ini index 56a07f56..75cef075 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py26, py27 +envlist = py27, py35 [testenv] deps = From c7c241fe7a2e27c80d174cd123349217ea27599d Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 25 Feb 2018 21:25:52 -0600 Subject: [PATCH 0760/3196] Update trove classifiers to reflect python 3, beta status --- setup.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index 41156302..2bac6df5 100644 --- a/setup.py +++ b/setup.py @@ -129,7 +129,7 @@ setup( long_description = README, classifiers = [ - 'Development Status :: 3 - Alpha', + 'Development Status :: 4 - Beta', 'Environment :: Web Environment', 'Framework :: Pyramid', 'Intended Audience :: Developers', @@ -137,8 +137,9 @@ setup( 'Natural Language :: English', 'Operating System :: OS Independent', 'Programming Language :: Python', - 'Programming Language :: Python :: 2.6', 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.5', 'Topic :: Internet :: WWW/HTTP', 'Topic :: Office/Business', 'Topic :: Software Development :: Libraries :: Python Modules', From bd4583339574a61a7e6655b9a08e6b6fda35f58a Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 26 Feb 2018 00:49:21 -0600 Subject: [PATCH 0761/3196] Test commit --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 2bac6df5..98cb4b9f 100644 --- a/setup.py +++ b/setup.py @@ -143,7 +143,7 @@ setup( 'Topic :: Internet :: WWW/HTTP', 'Topic :: Office/Business', 'Topic :: Software Development :: Libraries :: Python Modules', - ], + ], install_requires = requires, extras_require = extras, From 52e971728884511172b2bdb4a137c4438a630ee1 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 27 Feb 2018 19:12:46 -0600 Subject: [PATCH 0762/3196] Update changelog --- CHANGES.rst | 32 ++++++++++++++++++++++++++++++++ tailbone/_version.py | 2 +- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 093bd818..b3241f7d 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,38 @@ CHANGELOG ========= +0.7.4 (2018-02-27) +------------------ + +* Use all "normal" product form fields, for mobile view. + +* Refactor ordering worksheet to use shared logic. + +* Add download path for batch master views. + +* Add basic mobile support for executing batches (with options). + +* Add ``NumberInputWidget`` for ````. + +* Add ``Form.mobile`` flag and set link button styles accordingly. + +* Always show flash-error-style message when form has errors. + +* Use ``Form.submit_label`` if present, or fall back to ``save_label``. + +* Expose ``ship_method`` and ``notes_to_vendor`` for purchase, ordering batch. + +* Bind batch to its execution options schema, when applicable. + +* Don't set order date for new ordering batch when created via mobile. + +* Don't allow row deletion if batch is marked complete. + +* Add logic for editing default phone/email in base master view. + +* Fix bug in users view when person field not present. + + 0.7.3 (2018-02-15) ------------------ diff --git a/tailbone/_version.py b/tailbone/_version.py index 45f6959e..f3ebeb49 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.7.3' +__version__ = '0.7.4' From 91bb38573bdcb4e6efa413712db9b8776d407f17 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 28 Feb 2018 21:53:39 -0600 Subject: [PATCH 0763/3196] Add desktop support for creating inventory batches with a workflow form of sorts --- tailbone/forms/core.py | 7 +- tailbone/forms/types.py | 25 ++ .../batch/inventory/desktop_form.mako | 258 ++++++++++++++++++ tailbone/views/inventory.py | 154 ++++++++++- 4 files changed, 433 insertions(+), 11 deletions(-) create mode 100644 tailbone/templates/batch/inventory/desktop_form.mako diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index 6a4dc21d..82b21fd4 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.py @@ -585,11 +585,14 @@ class Form(object): else: raise ValueError("unknown type for '{}' field: {}".format(key, type_)) - def set_enum(self, key, enum): + def set_enum(self, key, enum, empty=None): if enum: self.enums[key] = enum self.set_type(key, 'enum') - self.set_widget(key, dfwidget.SelectWidget(values=list(enum.items()))) + values = list(enum.items()) + if empty: + values.insert(0, empty) + self.set_widget(key, dfwidget.SelectWidget(values=values)) else: self.enums.pop(key, None) diff --git a/tailbone/forms/types.py b/tailbone/forms/types.py index d45b957e..68168915 100644 --- a/tailbone/forms/types.py +++ b/tailbone/forms/types.py @@ -26,9 +26,12 @@ Form Schema Types from __future__ import unicode_literals, absolute_import +import re + import six from rattail.db import model +from rattail.gpc import GPC import colander @@ -60,6 +63,28 @@ class JQueryTime(colander.Time): return colander.timeparse(cstruct, formats[0]) +class GPCType(colander.SchemaType): + """ + Schema type for product GPC data. + """ + + def serialize(self, node, appstruct): + if appstruct is colander.null: + return colander.null + return six.text_type(appstruct) + + def deserialize(self, node, cstruct): + if not cstruct: + return None + digits = re.sub(r'\D', '', cstruct) + if not digits: + return None + try: + return GPC(digits) + except Exception as err: + raise colander.Invalid(node, six.text_type(err)) + + class ModelType(colander.SchemaType): """ Custom schema type for scalar ORM relationship fields. diff --git a/tailbone/templates/batch/inventory/desktop_form.mako b/tailbone/templates/batch/inventory/desktop_form.mako new file mode 100644 index 00000000..5d09e896 --- /dev/null +++ b/tailbone/templates/batch/inventory/desktop_form.mako @@ -0,0 +1,258 @@ +## -*- coding: utf-8; -*- +<%inherit file="/base.mako" /> + +<%def name="title()">Inventory Form + +<%def name="extra_javascript()"> + ${parent.extra_javascript()} + ${h.javascript_link(request.static_url('tailbone:static/js/numeric.js'))} + + + +<%def name="extra_styles()"> + ${parent.extra_styles()} + + + + +<%def name="context_menu_items()"> +
    • ${h.link_to("Back to Inventory Batch", url('batch.inventory.view', uuid=batch.uuid))}
    • + + + +
        + ${self.context_menu_items()} +
      + +
      + ${h.form(form.action_url, id='inventory-form')} + ${h.csrf_token(request)} + ${h.hidden('mode')} + +
      + +
      + ${h.hidden('product')} +
      ${h.text('upc', autocomplete='off')}
      +
      +

      please ENTER a scancode

      +
      +
      please confirm UPC and provide more details
      +
      +
      +
      + + + +
      + +
      ${h.text('cases', autocomplete='off')}
      +
      + +
      + +
      ${h.text('units', autocomplete='off')}
      +
      + +
      + + +
      + + ${h.end_form()} +
      diff --git a/tailbone/views/inventory.py b/tailbone/views/inventory.py index e7da7f9a..a118938b 100644 --- a/tailbone/views/inventory.py +++ b/tailbone/views/inventory.py @@ -27,11 +27,13 @@ Views for inventory batches from __future__ import unicode_literals, absolute_import import re +import logging import six from rattail import pod from rattail.db import model, api +from rattail.db.util import make_full_description from rattail.time import localtime from rattail.gpc import GPC from rattail.util import pretty_quantity @@ -45,6 +47,9 @@ from tailbone.views import MasterView from tailbone.views.batch import BatchMasterView +log = logging.getLogger(__name__) + + class InventoryAdjustmentReasonsView(MasterView): """ Master view for inventory adjustment reasons. @@ -84,7 +89,7 @@ class InventoryBatchView(BatchMasterView): route_prefix = 'batch.inventory' url_prefix = '/batch/inventory' index_title = "Inventory" - creatable = False + rows_creatable = True results_executable = True mobile_creatable = True mobile_rows_creatable = True @@ -108,6 +113,7 @@ class InventoryBatchView(BatchMasterView): form_fields = [ 'id', 'description', + 'notes', 'created', 'created_by', 'handheld_batches', @@ -225,8 +231,15 @@ class InventoryBatchView(BatchMasterView): f.set_type('total_cost', 'currency') # handheld_batches - f.set_readonly('handheld_batches') - f.set_renderer('handheld_batches', self.render_handheld_batches) + if self.creating: + f.remove_field('handheld_batches') + else: + f.set_readonly('handheld_batches') + f.set_renderer('handheld_batches', self.render_handheld_batches) + + # complete + if self.creating: + f.remove_field('complete') def render_handheld_batches(self, inventory_batch, field): items = '' @@ -250,7 +263,7 @@ class InventoryBatchView(BatchMasterView): return super(InventoryBatchView, self).save_edit_row_form(form) def delete_row(self): - row = self.Session.query(model.InventoryBatchRow).get(self.request.matchdict['uuid']) + row = self.Session.query(model.InventoryBatchRow).get(self.request.matchdict['row_uuid']) if not row: raise self.notfound() batch = row.batch @@ -258,6 +271,99 @@ class InventoryBatchView(BatchMasterView): batch.total_cost -= row.total_cost return super(InventoryBatchView, self).delete_row() + def create_row(self): + """ + Desktop workflow view for adding items to inventory batch. + """ + batch = self.get_instance() + if batch.executed: + return self.redirect(self.get_action_url('view', batch)) + + form = forms.Form(schema=DesktopForm(), request=self.request) + if form.validate(newstyle=True): + + mode = form.validated['mode'] + product = self.Session.merge(form.validated['product']) + row = model.InventoryBatchRow() + row.product = product + row.upc = form.validated['upc'] + row.brand_name = form.validated['brand_name'] + row.description = form.validated['description'] + row.size = form.validated['size'] + row.case_quantity = form.validated['case_quantity'] + + cases = form.validated['cases'] + units = form.validated['units'] + if mode == 'add': + row.cases = cases + row.units = units + else: + assert mode == 'subtract' + row.cases = (0 - cases) if cases else None + row.units = (0 - units) if units else None + + self.handler.add_row(batch, row) + description = make_full_description(form.validated['brand_name'], + form.validated['description'], + form.validated['size']) + self.request.session.flash("({}) {} cases, {} units: {} {}".format( + form.validated['mode'], form.validated['cases'] or 0, form.validated['units'] or 0, + form.validated['upc'].pretty(), description)) + return self.redirect(self.request.current_route_url()) + + title = self.get_instance_title(batch) + return self.render_to_response('desktop_form', { + 'batch': batch, + 'instance': batch, + 'instance_title': title, + 'index_title': "{}: {}".format(self.get_model_title(), title), + 'index_url': self.get_action_url('view', batch), + 'form': form, + 'dform': form.make_deform_form(), + }) + + def desktop_lookup(self): + """ + Try to locate a product by UPC, and validate it in the context of + current batch, returning some data for client JS. + """ + batch = self.get_instance() + if batch.executed: + return { + 'error': "Current batch has already been executed", + 'redirect': self.get_action_url('view', batch), + } + data = {} + upc = self.request.GET.get('upc', '').strip() + upc = re.sub(r'\D', '', upc) + if upc: + + # first try to locate existing batch row by UPC match + provided = GPC(upc, calc_check_digit=False) + checked = GPC(upc, calc_check_digit='upc') + product = api.get_product_by_upc(self.Session(), provided) + if not product: + product = api.get_product_by_upc(self.Session(), checked) + if product and (not product.deleted or self.request.has_perm('products.view_deleted')): + data['uuid'] = product.uuid + data['upc'] = six.text_type(product.upc) + data['upc_pretty'] = product.upc.pretty() + data['full_description'] = product.full_description + data['brand_name'] = six.text_type(product.brand or '') + data['description'] = product.description + data['size'] = product.size + data['case_quantity'] = 1 # default + data['cost_found'] = False + data['image_url'] = pod.get_image_url(self.rattail_config, product.upc) + + result = {'product': data or None, 'upc': None} + if not data and upc: + upc = GPC(upc) + result['upc'] = unicode(upc) + result['upc_pretty'] = upc.pretty() + result['image_url'] = pod.get_image_url(self.rattail_config, upc) + return result + def configure_mobile_form(self, f): super(InventoryBatchView, self).configure_mobile_form(f) batch = f.model_instance @@ -466,17 +572,22 @@ class InventoryBatchView(BatchMasterView): url_prefix = cls.get_url_prefix() permission_prefix = cls.get_permission_prefix() - # mobile - make new row from UPC - config.add_route('mobile.{}.row_from_upc'.format(route_prefix), '/mobile{}/{{{}}}/row-from-upc'.format(url_prefix, model_key)) - config.add_view(cls, attr='mobile_row_from_upc', route_name='mobile.{}.row_from_upc'.format(route_prefix), - permission='{}.create_row'.format(permission_prefix)) - # extra perms for creating batches per "mode" config.add_tailbone_permission(permission_prefix, '{}.create.replace'.format(permission_prefix), "Create new {} with 'replace' mode".format(model_title)) config.add_tailbone_permission(permission_prefix, '{}.create.zero'.format(permission_prefix), "Create new {} with 'zero' mode".format(model_title)) + # row UPC lookup, for desktop + config.add_route('{}.desktop_lookup'.format(route_prefix), '{}/{{{}}}/desktop-form/lookup'.format(url_prefix, model_key)) + config.add_view(cls, attr='desktop_lookup', route_name='{}.desktop_lookup'.format(route_prefix), + renderer='json', permission='{}.create_row'.format(permission_prefix)) + + # mobile - make new row from UPC + config.add_route('mobile.{}.row_from_upc'.format(route_prefix), '/mobile{}/{{{}}}/row-from-upc'.format(url_prefix, model_key)) + config.add_view(cls, attr='mobile_row_from_upc', route_name='mobile.{}.row_from_upc'.format(route_prefix), + permission='{}.create_row'.format(permission_prefix)) + class InventoryBatchRowType(forms.types.ObjectType): model_class = model.InventoryBatchRow @@ -497,6 +608,31 @@ class InventoryForm(colander.MappingSchema): units = colander.SchemaNode(colander.Decimal(), missing=colander.null) +class DesktopForm(colander.Schema): + + mode = colander.SchemaNode(colander.String(), + validator=colander.OneOf(['add', + 'subtract'])) + + product = colander.SchemaNode(forms.types.ProductType()) + + upc = colander.SchemaNode(forms.types.GPCType()) + + brand_name = colander.SchemaNode(colander.String()) + + description = colander.SchemaNode(colander.String()) + + size = colander.SchemaNode(colander.String()) + + case_quantity = colander.SchemaNode(colander.Decimal()) + + cases = colander.SchemaNode(colander.Decimal(), + missing=None) + + units = colander.SchemaNode(colander.Decimal(), + missing=None) + + def includeme(config): InventoryAdjustmentReasonsView.defaults(config) InventoryBatchView.defaults(config) From 90f0fcfea6d718a3c9387a0f82439f7d765a2c2a Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 1 Mar 2018 15:16:40 -0600 Subject: [PATCH 0764/3196] Expose vendor item code for purchase credits also, fix some issues with mobile receiving logic --- tailbone/views/purchases/credits.py | 2 ++ tailbone/views/purchasing/receiving.py | 15 ++++----------- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/tailbone/views/purchases/credits.py b/tailbone/views/purchases/credits.py index 0d4b46ad..ef6cd497 100644 --- a/tailbone/views/purchases/credits.py +++ b/tailbone/views/purchases/credits.py @@ -50,6 +50,7 @@ class PurchaseCreditView(MasterView): 'invoice_number', 'invoice_date', 'upc', + 'vendor_item_code', 'brand_name', 'description', 'size', @@ -81,6 +82,7 @@ class PurchaseCreditView(MasterView): g.set_label('invoice_number', "Invoice No.") g.set_label('upc', "UPC") + g.set_label('vendor_item_code', "Item Code") g.set_label('brand_name', "Brand") g.set_label('cases_shorted', "Cases") g.set_label('units_shorted', "Units") diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index 1f882f70..97ff6cbe 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -116,14 +116,6 @@ class ReceivingBatchView(PurchasingBatchView): 'status_code', ] - row_form_fields = [ - 'vendor', - 'department', - 'complete', - 'executed', - 'executed_by', - ] - @property def batch_mode(self): return self.enum.PURCHASE_BATCH_MODE_RECEIVING @@ -291,7 +283,7 @@ class ReceivingBatchView(PurchasingBatchView): if self.request.has_perm('{}.create_row'.format(self.get_permission_prefix())): update_form = forms.Form(schema=ReceivingForm(), request=self.request) if update_form.validate(newstyle=True): - row = update_form.validated['row'] + row = self.Session.merge(update_form.validated['row']) mode = update_form.validated['mode'] cases = update_form.validated['cases'] units = update_form.validated['units'] @@ -334,6 +326,7 @@ class ReceivingBatchView(PurchasingBatchView): credit.invoice_date = batch.invoice_date credit.product = row.product credit.upc = row.upc + credit.vendor_item_code = row.vendor_code credit.brand_name = row.brand_name credit.description = row.description credit.size = row.size @@ -397,9 +390,9 @@ class ReceivingForm(colander.MappingSchema): # 'mispick', ])) - cases = colander.SchemaNode(colander.Decimal(), missing=colander.null) + cases = colander.SchemaNode(colander.Decimal(), missing=None) - units = colander.SchemaNode(colander.Decimal(), missing=colander.null) + units = colander.SchemaNode(colander.Decimal(), missing=None) expiration_date = colander.SchemaNode(colander.Date(), missing=colander.null) From aeccf5c5f6138e11913bffbf4c350d85717254df Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 5 Mar 2018 20:20:35 -0600 Subject: [PATCH 0765/3196] Fix default create logic for vendors, products online demo triggered errors for this. might as well have basic support --- tailbone/views/products.py | 154 +++++++++++++++++++++++++++++---- tailbone/views/vendors/core.py | 8 +- 2 files changed, 143 insertions(+), 19 deletions(-) diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 00872e86..554a3f8b 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -364,13 +364,115 @@ class ProductsView(MasterView): f.set_label('upc', "UPC") # department - f.set_renderer('department', self.render_department) + if self.creating or self.editing: + if 'department' in f.fields: + f.replace('department', 'department_uuid') + departments = self.Session.query(model.Department)\ + .order_by(model.Department.number) + dept_values = [(d.uuid, "{} {}".format(d.number, d.name)) + for d in departments] + f.set_widget('department_uuid', dfwidget.SelectWidget(values=dept_values)) + f.set_label('department_uuid', "Department") + else: + f.set_readonly('department') + f.set_renderer('department', self.render_department) # subdepartment - f.set_renderer('subdepartment', self.render_subdepartment) + if self.creating or self.editing: + if 'subdepartment' in f.fields: + f.replace('subdepartment', 'subdepartment_uuid') + subdepartments = self.Session.query(model.Subdepartment)\ + .order_by(model.Subdepartment.number) + subdept_values = [(s.uuid, "{} {}".format(s.number, s.name)) + for s in subdepartments] + f.set_widget('subdepartment_uuid', dfwidget.SelectWidget(values=subdept_values)) + f.set_label('subdepartment_uuid', "Subdepartment") + else: + f.set_readonly('subdepartment') + f.set_renderer('subdepartment', self.render_subdepartment) # category - f.set_renderer('category', self.render_category) + if self.creating or self.editing: + if 'category' in f.fields: + f.replace('category', 'category_uuid') + categories = self.Session.query(model.Category)\ + .order_by(model.Category.code) + category_values = [(c.uuid, "{} {}".format(c.code, c.name)) + for c in categories] + f.set_widget('category_uuid', dfwidget.SelectWidget(values=category_values)) + f.set_label('category_uuid', "Category") + else: + f.set_readonly('category') + f.set_renderer('category', self.render_category) + + # family + if self.creating or self.editing: + if 'family' in f.fields: + f.replace('family', 'family_uuid') + families = self.Session.query(model.Family)\ + .order_by(model.Family.name) + family_values = [(f.uuid, f.name) for f in families] + f.set_widget('family_uuid', dfwidget.SelectWidget(values=family_values)) + f.set_label('family_uuid', "Family") + else: + f.set_readonly('family') + # f.set_renderer('family', self.render_family) + + # report_code + if self.creating or self.editing: + if 'report_code' in f.fields: + f.replace('report_code', 'report_code_uuid') + report_codes = self.Session.query(model.ReportCode)\ + .order_by(model.ReportCode.code) + report_code_values = [(rc.uuid, "{} {}".format(rc.code, rc.name)) + for rc in report_codes] + f.set_widget('report_code_uuid', dfwidget.SelectWidget(values=report_code_values)) + f.set_label('report_code_uuid', "Report_Code") + else: + f.set_readonly('report_code') + # f.set_renderer('report_code', self.render_report_code) + + # deposit_link + if self.creating or self.editing: + if 'deposit_link' in f.fields: + f.replace('deposit_link', 'deposit_link_uuid') + deposit_links = self.Session.query(model.DepositLink)\ + .order_by(model.DepositLink.code) + deposit_link_values = [(dl.uuid, "{} {}".format(dl.code, dl.description)) + for dl in deposit_links] + f.set_widget('deposit_link_uuid', dfwidget.SelectWidget(values=deposit_link_values)) + f.set_label('deposit_link_uuid', "Deposit_Link") + else: + f.set_readonly('deposit_link') + # f.set_renderer('deposit_link', self.render_deposit_link) + + # tax + if self.creating or self.editing: + if 'tax' in f.fields: + f.replace('tax', 'tax_uuid') + taxes = self.Session.query(model.Tax)\ + .order_by(model.Tax.code) + tax_values = [(tax.uuid, "{} {}".format(tax.code, tax.description)) + for tax in taxes] + f.set_widget('tax_uuid', dfwidget.SelectWidget(values=tax_values)) + f.set_label('tax_uuid', "Tax") + else: + f.set_readonly('tax') + # f.set_renderer('tax', self.render_tax) + + # brand + if self.creating: + f.replace('brand', 'brand_uuid') + brand_display = "" + if self.request.method == 'POST': + if self.request.POST.get('brand_uuid'): + brand = self.Session.query(model.Brand).get(self.request.POST['brand_uuid']) + if brand: + brand_display = six.text_type(brand) + brands_url = self.request.route_url('brands.autocomplete') + f.set_widget('brand_uuid', forms.widgets.JQueryAutocompleteWidget( + field_display=brand_display, service_url=brands_url)) + f.set_label('brand_uuid', "Brand") # unit_size f.set_type('unit_size', 'quantity') @@ -380,19 +482,28 @@ class ProductsView(MasterView): f.set_label('unit_of_measure', "Unit of Measure") # unit - f.set_renderer('unit', self.render_unit) - f.set_label('unit', "Unit Item") + if self.creating: + f.remove_field('unit') + else: + f.set_renderer('unit', self.render_unit) + f.set_label('unit', "Unit Item") # pack_size f.set_type('pack_size', 'quantity') # regular_price - f.set_readonly('regular_price') - f.set_renderer('regular_price', self.render_price) + if self.creating: + f.remove_field('regular_price') + else: + f.set_readonly('regular_price') + f.set_renderer('regular_price', self.render_price) # current_price - f.set_readonly('current_price') - f.set_renderer('current_price', self.render_price) + if self.creating: + f.remove_field('current_price') + else: + f.set_readonly('current_price') + f.set_renderer('current_price', self.render_price) # last_sold f.set_readonly('last_sold') @@ -404,18 +515,27 @@ class ProductsView(MasterView): f.set_widget('notes', dfwidget.TextAreaWidget(cols=80, rows=10)) # current_price_ends - f.set_readonly('current_price_ends') - f.set_renderer('current_price_ends', self.render_current_price_ends) + if self.creating: + f.remove_field('current_price_ends') + else: + f.set_readonly('current_price_ends') + f.set_renderer('current_price_ends', self.render_current_price_ends) # inventory_on_hand - f.set_readonly('inventory_on_hand') - f.set_renderer('inventory_on_hand', self.render_inventory_on_hand) - f.set_label('inventory_on_hand', "On Hand") + if self.creating: + f.remove_field('inventory_on_hand') + else: + f.set_readonly('inventory_on_hand') + f.set_renderer('inventory_on_hand', self.render_inventory_on_hand) + f.set_label('inventory_on_hand', "On Hand") # inventory_on_order - f.set_readonly('inventory_on_order') - f.set_renderer('inventory_on_order', self.render_inventory_on_order) - f.set_label('inventory_on_order', "On Order") + if self.creating: + f.remove_field('inventory_on_order') + else: + f.set_readonly('inventory_on_order') + f.set_renderer('inventory_on_order', self.render_inventory_on_order) + f.set_label('inventory_on_order', "On Order") if not self.request.has_perm('products.view_deleted'): f.remove('deleted') diff --git a/tailbone/views/vendors/core.py b/tailbone/views/vendors/core.py index 59034faa..755946f1 100644 --- a/tailbone/views/vendors/core.py +++ b/tailbone/views/vendors/core.py @@ -104,8 +104,12 @@ class VendorsView(MasterView): if not self.creating and vendor.emails: f.set_default('orders_email', vendor.get_email_address(type_='Orders') or '') - f.set_readonly('contact') - f.set_renderer('contact', self.render_contact) + # contact + if self.creating: + f.remove_field('contact') + else: + f.set_readonly('contact') + f.set_renderer('contact', self.render_contact) def objectify(self, form, data): vendor = super(VendorsView, self).objectify(form, data) From 5765533491d54292ae636463b4de0780a2ea8da4 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 5 Mar 2018 20:26:22 -0600 Subject: [PATCH 0766/3196] Add changelog link for rattail-tempmon in upgrade diff --- tailbone/views/upgrades.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tailbone/views/upgrades.py b/tailbone/views/upgrades.py index 733be540..e20ba05e 100644 --- a/tailbone/views/upgrades.py +++ b/tailbone/views/upgrades.py @@ -241,6 +241,7 @@ class UpgradeView(MasterView): def get_changelog_url(self, project, old_version, new_version): projects = { 'rattail': 'rattail', + 'rattail-tempmon': 'rattail-tempmon', 'Tailbone': 'tailbone', } if project not in projects: From 802f4bfd6bfcad186e1702534c8fe97b74a192e2 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 6 Mar 2018 16:25:54 -0600 Subject: [PATCH 0767/3196] Add `disable_submit_button()` global JS function managed to find another use for this, so split it out --- tailbone/static/js/tailbone.js | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/tailbone/static/js/tailbone.js b/tailbone/static/js/tailbone.js index 67f9a409..5de153fc 100644 --- a/tailbone/static/js/tailbone.js +++ b/tailbone/static/js/tailbone.js @@ -40,6 +40,20 @@ function disable_button(button, label) { } +function disable_submit_button(form, label) { + // for some reason chrome requires us to do things this way... + // https://stackoverflow.com/questions/16867080/onclick-javascript-stops-form-submit-in-chrome + // https://stackoverflow.com/questions/5691054/disable-submit-button-on-form-submit + var submit = $(form).find('input[type="submit"]'); + if (! submit.length) { + submit = $(form).find('button[type="submit"]'); + } + if (submit.length) { + disable_button(submit, label); + } +} + + /* * Load next / previous page of results to grid. This function is called on * the click event from the pager links, via inline script code. @@ -208,18 +222,7 @@ $(function() { $('a.button.autodisable').click(function() { disable_button(this); }); - // for some reason chrome requires us to do things this way... - // https://stackoverflow.com/questions/16867080/onclick-javascript-stops-form-submit-in-chrome - // https://stackoverflow.com/questions/5691054/disable-submit-button-on-form-submit - $('form.autodisable').submit(function() { - var submit = $(this).find('input[type="submit"]'); - if (! submit.length) { - submit = $(this).find('button[type="submit"]'); - } - if (submit.length) { - disable_button(submit); - } - }); + $('form.autodisable').submit(disable_submit_button); /* * enhance dropdowns From 6ec0ddb94ec5479284532eb8caa169e609ca06b4 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 6 Mar 2018 16:26:53 -0600 Subject: [PATCH 0768/3196] Remove the "add vs. subtract" mode for desktop inventory workflow form hopefully we can always assume the "mode" based on other things --- .../batch/inventory/desktop_form.mako | 26 ++++--------------- tailbone/views/inventory.py | 22 +++------------- 2 files changed, 9 insertions(+), 39 deletions(-) diff --git a/tailbone/templates/batch/inventory/desktop_form.mako b/tailbone/templates/batch/inventory/desktop_form.mako index 5d09e896..ad69aef4 100644 --- a/tailbone/templates/batch/inventory/desktop_form.mako +++ b/tailbone/templates/batch/inventory/desktop_form.mako @@ -130,25 +130,11 @@ return false; }); - $('#add').click(function() { - if (! assert_quantity()) { - return; - } - $(this).button('disable').button('option', 'label', "Working..."); - $('#mode').val('add'); - $('#inventory-form').submit(); - }); - - $('#subtract').click(function() { - if (! assert_quantity()) { - return; - } - $(this).button('disable').button('option', 'label', "Working..."); - $('#mode').val('subtract'); - $('#inventory-form').submit(); - }); - $('#inventory-form').submit(function() { + if (! assert_quantity()) { + return false; + } + disable_submit_button(this); $(this).mask("Working..."); }); @@ -200,7 +186,6 @@
      ${h.form(form.action_url, id='inventory-form')} ${h.csrf_token(request)} - ${h.hidden('mode')}
      @@ -250,8 +235,7 @@
      - - + ${h.submit('submit', "Submit")}
      ${h.end_form()} diff --git a/tailbone/views/inventory.py b/tailbone/views/inventory.py index a118938b..3b785612 100644 --- a/tailbone/views/inventory.py +++ b/tailbone/views/inventory.py @@ -282,7 +282,6 @@ class InventoryBatchView(BatchMasterView): form = forms.Form(schema=DesktopForm(), request=self.request) if form.validate(newstyle=True): - mode = form.validated['mode'] product = self.Session.merge(form.validated['product']) row = model.InventoryBatchRow() row.product = product @@ -291,23 +290,14 @@ class InventoryBatchView(BatchMasterView): row.description = form.validated['description'] row.size = form.validated['size'] row.case_quantity = form.validated['case_quantity'] - - cases = form.validated['cases'] - units = form.validated['units'] - if mode == 'add': - row.cases = cases - row.units = units - else: - assert mode == 'subtract' - row.cases = (0 - cases) if cases else None - row.units = (0 - units) if units else None - + row.cases = form.validated['cases'] + row.units = form.validated['units'] self.handler.add_row(batch, row) description = make_full_description(form.validated['brand_name'], form.validated['description'], form.validated['size']) - self.request.session.flash("({}) {} cases, {} units: {} {}".format( - form.validated['mode'], form.validated['cases'] or 0, form.validated['units'] or 0, + self.request.session.flash("{} cases, {} units: {} {}".format( + form.validated['cases'] or 0, form.validated['units'] or 0, form.validated['upc'].pretty(), description)) return self.redirect(self.request.current_route_url()) @@ -610,10 +600,6 @@ class InventoryForm(colander.MappingSchema): class DesktopForm(colander.Schema): - mode = colander.SchemaNode(colander.String(), - validator=colander.OneOf(['add', - 'subtract'])) - product = colander.SchemaNode(forms.types.ProductType()) upc = colander.SchemaNode(forms.types.GPCType()) From 652f51d48445692d5648d1cde604414ad2aed140 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 6 Mar 2018 19:29:15 -0600 Subject: [PATCH 0769/3196] Add support for making new product on-the-fly during mobile ordering let's face it, that will be necessary sometimes. this feature still needs some work before can be called complete though... --- tailbone/static/css/forms.css | 4 +- tailbone/static/css/mobile.css | 13 ++++- tailbone/templates/forms/deform.mako | 2 +- .../templates/mobile/master/edit_row.mako | 14 ++--- .../mobile/ordering/new_product.mako | 6 +++ tailbone/views/master.py | 1 + tailbone/views/purchasing/batch.py | 53 +++++++++++++++++++ 7 files changed, 83 insertions(+), 10 deletions(-) create mode 100644 tailbone/templates/mobile/ordering/new_product.mako diff --git a/tailbone/static/css/forms.css b/tailbone/static/css/forms.css index fdbace95..aee68e59 100644 --- a/tailbone/static/css/forms.css +++ b/tailbone/static/css/forms.css @@ -52,7 +52,7 @@ div.fieldset { margin: 15px; } -.field-wrapper.error { +.field-wrapper.with-error { background-color: #ddcccc; border: 2px solid #dd6666; padding-bottom: 1em; @@ -71,7 +71,7 @@ div.fieldset { white-space: nowrap; } -.field-wrapper.error label { +.field-wrapper.with-error label { padding-left: 1em; } diff --git a/tailbone/static/css/mobile.css b/tailbone/static/css/mobile.css index a3493f12..9ebfbc8b 100644 --- a/tailbone/static/css/mobile.css +++ b/tailbone/static/css/mobile.css @@ -21,7 +21,8 @@ } /* error flash messages */ -.error { +.error, +.error-messages { color: red; margin-bottom: 1em; } @@ -35,11 +36,21 @@ display: none; } +.field-wrapper.with-error { + background-color: #ddcccc; + border: 2px solid #dd6666; + margin-bottom: 1em; +} + .field-wrapper label { font-weight: bold; margin-top: 1em; } +.field-error .error-msg { + color: Red; +} + /* make sure space comes between simple filter and "grid" list */ .simple-filter { margin-bottom: 1.5em; diff --git a/tailbone/templates/forms/deform.mako b/tailbone/templates/forms/deform.mako index 80f31a64..36d53644 100644 --- a/tailbone/templates/forms/deform.mako +++ b/tailbone/templates/forms/deform.mako @@ -30,7 +30,7 @@ ${h.csrf_token(request)} <% field = dform[field] %> % if form.field_visible(field.name): -
      +
      % if field.error:
      % for msg in field.error.messages(): diff --git a/tailbone/templates/mobile/master/edit_row.mako b/tailbone/templates/mobile/master/edit_row.mako index 31b0156d..0576989e 100644 --- a/tailbone/templates/mobile/master/edit_row.mako +++ b/tailbone/templates/mobile/master/edit_row.mako @@ -5,12 +5,14 @@ <%def name="page_title()">${h.link_to(index_title, index_url)} » ${h.link_to(parent_title, parent_url)} » ${h.link_to(instance_title, instance_url)} » Edit -<%def name="buttons()"> -
      - ${h.submit('create', form.update_label)} - Cancel - +## TODO: this should not be necessary, correct? +## <%def name="buttons()"> +##
      +## ${h.submit('create', form.update_label)} +## ${h.link_to("Cancel", form.cancel_url, class_='ui-btn ui-corner-all')} +##
      - ${form.render(buttons=capture(self.buttons))|n} +## ${form.render(buttons=capture(self.buttons))|n} + ${form.render()|n}
      diff --git a/tailbone/templates/mobile/ordering/new_product.mako b/tailbone/templates/mobile/ordering/new_product.mako new file mode 100644 index 00000000..79d83630 --- /dev/null +++ b/tailbone/templates/mobile/ordering/new_product.mako @@ -0,0 +1,6 @@ +## -*- coding: utf-8; -*- +<%inherit file="/mobile/master/create_row.mako" /> + +<%def name="page_title()">${h.link_to(index_title, index_url)} » ${h.link_to(instance_title, instance_url)} » Add Item + +${parent.body()} diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 59b6d9b7..b727e426 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -1045,6 +1045,7 @@ class MasterView(View): """ defaults = { 'request': self.request, + 'mobile': True, 'readonly': self.viewing, 'model_class': getattr(self, 'model_row_class', None), 'action_url': self.request.current_route_url(_query=None), diff --git a/tailbone/views/purchasing/batch.py b/tailbone/views/purchasing/batch.py index 4f5dd54c..fa73b96f 100644 --- a/tailbone/views/purchasing/batch.py +++ b/tailbone/views/purchasing/batch.py @@ -47,6 +47,7 @@ class PurchasingBatchView(BatchMasterView): model_class = model.PurchaseBatch model_row_class = model.PurchaseBatchRow default_handler_spec = 'rattail.batch.purchase:PurchaseBatchHandler' + supports_new_product = False grid_columns = [ 'id', @@ -701,6 +702,42 @@ class PurchasingBatchView(BatchMasterView): # else: # f.remove_field('product') + def mobile_new_product(self): + """ + View which allows user to create a new Product and add a row for it to + the Purchasing Batch. + """ + batch = self.get_instance() + batch_url = self.get_action_url('view', batch, mobile=True) + form = forms.Form(schema=self.make_new_product_schema(), + request=self.request, + mobile=True, + cancel_url=batch_url) + + if form.validate(newstyle=True): + product = model.Product() + product.item_id = form.validated['item_id'] + product.description = form.validated['description'] + row = self.model_row_class() + row.product = product + self.handler.add_row(batch, row) + self.Session.flush() + return self.redirect(self.get_row_action_url('edit', row, mobile=True)) + + return self.render_to_response('new_product', { + 'form': form, + 'dform': form.make_deform_form(), + 'instance_title': self.get_instance_title(batch), + 'instance_url': batch_url, + }, mobile=True) + + def make_new_product_schema(self): + """ + Must return a ``colander.Schema`` instance for use with the form in the + :meth:`mobile_new_product()` view. + """ + return NewProduct() + # def item_lookup(self, value, field=None): # """ # Try to locate a single product using ``value`` as a lookup code. @@ -785,8 +822,24 @@ class PurchasingBatchView(BatchMasterView): config.add_view(cls, attr='eligible_purchases', route_name='{}.eligible_purchases'.format(route_prefix), renderer='json', permission='{}.view'.format(permission_prefix)) + # add new product + if cls.supports_new_product: + config.add_tailbone_permission(permission_prefix, '{}.new_product'.format(permission_prefix), + "Create new Product when adding row to {}".format(model_title)) + config.add_route('mobile.{}.new_product'.format(route_prefix), '{}/{{{}}}/new-product'.format(url_prefix, model_key)) + config.add_view(cls, attr='mobile_new_product', route_name='mobile.{}.new_product'.format(route_prefix), + permission='{}.new_product'.format(permission_prefix)) + + @classmethod def defaults(cls, config): cls._purchasing_defaults(config) cls._batch_defaults(config) cls._defaults(config) + + +class NewProduct(colander.Schema): + + item_id = colander.SchemaNode(colander.String()) + + description = colander.SchemaNode(colander.String()) From 85f108d10e354da0741473a17d451c16683348bd Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 12 Mar 2018 17:58:41 -0500 Subject: [PATCH 0770/3196] 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 b3241f7d..7b2a83e0 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,22 @@ CHANGELOG ========= +0.7.5 (2018-03-12) +------------------ + +* Add desktop support for creating inventory batches. + +* Expose vendor item code for purchase credits. + +* Fix default create logic for vendors, products. + +* Add changelog link for rattail-tempmon in upgrade diff. + +* Add ``disable_submit_button()`` global JS function. + +* Add basic support for making new product on-the-fly during mobile ordering. + + 0.7.4 (2018-02-27) ------------------ diff --git a/tailbone/_version.py b/tailbone/_version.py index f3ebeb49..8b25d126 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.7.4' +__version__ = '0.7.5' From 69f04beb6d43cc9a668a4fc388b39080cfe1c4d2 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 12 Mar 2018 18:27:50 -0500 Subject: [PATCH 0771/3196] Fix text area behavior for email recipient fields --- tailbone/views/email.py | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/tailbone/views/email.py b/tailbone/views/email.py index 58ef43ed..6e12e972 100644 --- a/tailbone/views/email.py +++ b/tailbone/views/email.py @@ -205,6 +205,28 @@ class ProfilesView(MasterView): return kwargs +class RecipientsType(colander.String): + """ + Custom schema type for email recipients. This is used to present the + recipients as a "list" within the text area, i.e. one recipient per line. + Then the list is collapsed to a comma-delimited string for storage. + """ + + def serialize(self, node, appstruct): + if appstruct is colander.null: + return colander.null + recips = parse_list(appstruct) + return '\n'.join(recips) + + def deserialize(self, node, cstruct): + if cstruct == '' and self.allow_empty: + return '' + if not cstruct: + return colander.null + recips = parse_list(cstruct) + return ', '.join(recips) + + class EmailProfileSchema(colander.MappingSchema): prefix = colander.SchemaNode(colander.String()) @@ -215,11 +237,11 @@ class EmailProfileSchema(colander.MappingSchema): replyto = colander.SchemaNode(colander.String(), missing='') - to = colander.SchemaNode(colander.String()) + to = colander.SchemaNode(RecipientsType()) - cc = colander.SchemaNode(colander.String(), missing='') + cc = colander.SchemaNode(RecipientsType(), missing='') - bcc = colander.SchemaNode(colander.String(), missing='') + bcc = colander.SchemaNode(RecipientsType(), missing='') enabled = colander.SchemaNode(colander.Boolean()) From bce6662eae1550adb17419b1a14c116ebcf7458b Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 15 Mar 2018 15:54:16 -0500 Subject: [PATCH 0772/3196] Fix autodisable button bug for forms marked as such --- tailbone/static/js/tailbone.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tailbone/static/js/tailbone.js b/tailbone/static/js/tailbone.js index 5de153fc..075b90ab 100644 --- a/tailbone/static/js/tailbone.js +++ b/tailbone/static/js/tailbone.js @@ -222,7 +222,9 @@ $(function() { $('a.button.autodisable').click(function() { disable_button(this); }); - $('form.autodisable').submit(disable_submit_button); + $('form.autodisable').submit(function() { + disable_submit_button(this); + }); /* * enhance dropdowns From 79b1502920c3664cb55789987b8e5eaeb122f714 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 15 Mar 2018 16:39:24 -0500 Subject: [PATCH 0773/3196] 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 7b2a83e0..5b0ee547 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,14 @@ CHANGELOG ========= +0.7.6 (2018-03-15) +------------------ + +* Fix text area behavior for email recipient fields. + +* Fix autodisable button bug for forms marked as such. + + 0.7.5 (2018-03-12) ------------------ diff --git a/tailbone/_version.py b/tailbone/_version.py index 8b25d126..759ca205 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.7.5' +__version__ = '0.7.6' From fde5398455f26ffacaa05e4bf6695b886c27d456 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 21 Mar 2018 10:42:50 -0500 Subject: [PATCH 0774/3196] Use 'today' as fallback date for ordering worksheet --- tailbone/templates/ordering/worksheet.mako | 4 ++-- tailbone/views/purchasing/ordering.py | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/tailbone/templates/ordering/worksheet.mako b/tailbone/templates/ordering/worksheet.mako index 988f7983..214e29a2 100644 --- a/tailbone/templates/ordering/worksheet.mako +++ b/tailbone/templates/ordering/worksheet.mako @@ -224,12 +224,12 @@ ${h.end_form()} % endfor % if not ignore_cases: - ${batch.date_ordered.strftime('%m/%d')}
      + ${order_date.strftime('%m/%d')}
      Cases % endif - ${batch.date_ordered.strftime('%m/%d')}
      + ${order_date.strftime('%m/%d')}
      Units PO Total diff --git a/tailbone/views/purchasing/ordering.py b/tailbone/views/purchasing/ordering.py index 332003d1..8d72ffe4 100644 --- a/tailbone/views/purchasing/ordering.py +++ b/tailbone/views/purchasing/ordering.py @@ -170,8 +170,12 @@ class OrderingBatchView(PurchasingBatchView): history = list(reversed(history)) title = self.get_instance_title(batch) + order_date = batch.date_ordered + if not order_date: + order_date = localtime(self.rattail_config).date() return self.render_to_response('worksheet', { 'batch': batch, + 'order_date': order_date, 'instance': batch, 'instance_title': title, 'instance_url': self.get_action_url('view', batch), From 42982a69ea8cd39384b7075d7138c67e86b32549 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 21 Mar 2018 10:52:30 -0500 Subject: [PATCH 0775/3196] Treat unknown UPC as "product not found" for inventory batch i.e. as opposed to collecting info about the product --- .../batch/inventory/desktop_form.mako | 31 ++++++++++--------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/tailbone/templates/batch/inventory/desktop_form.mako b/tailbone/templates/batch/inventory/desktop_form.mako index ad69aef4..a1adcf4e 100644 --- a/tailbone/templates/batch/inventory/desktop_form.mako +++ b/tailbone/templates/batch/inventory/desktop_form.mako @@ -104,23 +104,24 @@ $('.buttons button').button('enable'); $('#cases').focus().select(); - } else if (data.upc) { - $('#upc').val(data.upc_pretty); - $('#product-info p').text("product not found in our system"); - $('#product-info img').attr('src', data.image_url).show(); + // TODO: this is maybe useful if "new products" may be added via inventory batch + // } else if (data.upc) { + // $('#upc').val(data.upc_pretty); + // $('#product-info p').text("product not found in our system"); + // $('#product-info img').attr('src', data.image_url).show(); - $('#product').val(''); - $('#brand_name').val(''); - $('#description').val(''); - $('#size').val(''); - $('#case_quantity').val(''); + // $('#product').val(''); + // $('#brand_name').val(''); + // $('#description').val(''); + // $('#size').val(''); + // $('#case_quantity').val(''); - $('#product-info .warning.notfound').show(); - $('.product-fields').show(); - $('#brand_name').focus(); - $('.field-wrapper.cases input').prop('disabled', false); - $('.field-wrapper.units input').prop('disabled', false); - $('.buttons button').button('enable'); + // $('#product-info .warning.notfound').show(); + // $('.product-fields').show(); + // $('#brand_name').focus(); + // $('.field-wrapper.cases input').prop('disabled', false); + // $('.field-wrapper.units input').prop('disabled', false); + // $('.buttons button').button('enable'); } else { invalid_product('product not found'); From e9322628cb965801f5874a3daca070cdfb916fef Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 21 Mar 2018 11:30:14 -0500 Subject: [PATCH 0776/3196] Refactor inventory batch desktop lookup, to allow for Type 2 UPC logic for now though, such logic must be provided by custom app --- .../batch/inventory/desktop_form.mako | 9 ++++- tailbone/views/inventory.py | 33 +++++++++++-------- 2 files changed, 27 insertions(+), 15 deletions(-) diff --git a/tailbone/templates/batch/inventory/desktop_form.mako b/tailbone/templates/batch/inventory/desktop_form.mako index a1adcf4e..828ab33a 100644 --- a/tailbone/templates/batch/inventory/desktop_form.mako +++ b/tailbone/templates/batch/inventory/desktop_form.mako @@ -91,6 +91,9 @@ $('#description').val(data.product.description); $('#size').val(data.product.size); $('#case_quantity').val(data.product.case_quantity); + if (data.product.type2) { + $('#units').val(data.product.units); + } $('#product-info p').text(data.product.full_description); $('#product-info img').attr('src', data.product.image_url).show(); @@ -102,7 +105,11 @@ $('.field-wrapper.cases input').prop('disabled', false); $('.field-wrapper.units input').prop('disabled', false); $('.buttons button').button('enable'); - $('#cases').focus().select(); + if (data.product.type2) { + $('#units').focus().select(); + } else { + $('#cases').focus().select(); + } // TODO: this is maybe useful if "new products" may be added via inventory batch // } else if (data.upc) { diff --git a/tailbone/views/inventory.py b/tailbone/views/inventory.py index 3b785612..bff6c048 100644 --- a/tailbone/views/inventory.py +++ b/tailbone/views/inventory.py @@ -334,26 +334,31 @@ class InventoryBatchView(BatchMasterView): product = api.get_product_by_upc(self.Session(), provided) if not product: product = api.get_product_by_upc(self.Session(), checked) - if product and (not product.deleted or self.request.has_perm('products.view_deleted')): - data['uuid'] = product.uuid - data['upc'] = six.text_type(product.upc) - data['upc_pretty'] = product.upc.pretty() - data['full_description'] = product.full_description - data['brand_name'] = six.text_type(product.brand or '') - data['description'] = product.description - data['size'] = product.size - data['case_quantity'] = 1 # default - data['cost_found'] = False - data['image_url'] = pod.get_image_url(self.rattail_config, product.upc) + data = self.product_info(product) - result = {'product': data or None, 'upc': None} + result = {'product': data or None, 'upc_raw': upc, 'upc': None} if not data and upc: upc = GPC(upc) - result['upc'] = unicode(upc) + result['upc'] = six.text_type(upc) result['upc_pretty'] = upc.pretty() result['image_url'] = pod.get_image_url(self.rattail_config, upc) return result + def product_info(self, product): + data = {} + if product and (not product.deleted or self.request.has_perm('products.view_deleted')): + data['uuid'] = product.uuid + data['upc'] = six.text_type(product.upc) + data['upc_pretty'] = product.upc.pretty() + data['full_description'] = product.full_description + data['brand_name'] = six.text_type(product.brand or '') + data['description'] = product.description + data['size'] = product.size + data['case_quantity'] = 1 # default + data['cost_found'] = False + data['image_url'] = pod.get_image_url(self.rattail_config, product.upc) + return data + def configure_mobile_form(self, f): super(InventoryBatchView, self).configure_mobile_form(f) batch = f.model_instance @@ -608,7 +613,7 @@ class DesktopForm(colander.Schema): description = colander.SchemaNode(colander.String()) - size = colander.SchemaNode(colander.String()) + size = colander.SchemaNode(colander.String(), missing=colander.null) case_quantity = colander.SchemaNode(colander.Decimal()) From d550efbf8fb922eab16fd91250a8da5e7f9a1e1b Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 21 Mar 2018 13:55:21 -0500 Subject: [PATCH 0777/3196] Fix default selection bug for store/department time sheet filters --- tailbone/views/shifts/lib.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tailbone/views/shifts/lib.py b/tailbone/views/shifts/lib.py index b8f5e9ee..7690bfec 100644 --- a/tailbone/views/shifts/lib.py +++ b/tailbone/views/shifts/lib.py @@ -197,6 +197,11 @@ class TimeSheetView(View): form.set_widget('store', forms.widgets.PlainSelectWidget(values=store_values)) if context['store']: form.set_default('store', context['store'].uuid) + else: + # TODO: why is this necessary? somehow the previous store is being + # preserved as the "default" when switching from single store view + # to "all stores" view + form.set_default('store', '') departments = self.get_departments() department_values = [(d.uuid, d.name) for d in departments] @@ -204,6 +209,11 @@ class TimeSheetView(View): form.set_widget('department', forms.widgets.PlainSelectWidget(values=department_values)) if context['department']: form.set_default('department', context['department'].uuid) + else: + # TODO: why is this necessary? somehow the previous dept is being + # preserved as the "default" when switching from single dept view + # to "all depts" view + form.set_default('department', '') form.set_type('date', 'date_jquery') form.set_default('date', get_sunday(context['date'])) From eb45b9f8d9704de617a906d861fe1b4ad4fd1c3f Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 23 Mar 2018 10:33:56 -0500 Subject: [PATCH 0778/3196] 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 5b0ee547..00a4ae0d 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,18 @@ CHANGELOG ========= +0.7.7 (2018-03-23) +------------------ + +* Use 'today' as fallback order date for ordering worksheet. + +* Treat unknown UPC as "product not found" for inventory batch. + +* Refactor inventory batch desktop lookup, to allow for Type 2 UPC logic. + +* Fix default selection bug for store/department time sheet filters. + + 0.7.6 (2018-03-15) ------------------ diff --git a/tailbone/_version.py b/tailbone/_version.py index 759ca205..bcf01998 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.7.6' +__version__ = '0.7.7' From 8c211df633c29725af56add42ddb91f7e0579677 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 1 Apr 2018 17:14:00 -0700 Subject: [PATCH 0779/3196] Add awareness for `Email.dynamic_to` flag in config UI i.e. show help text and do not allow edit, when relevant --- tailbone/views/email.py | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/tailbone/views/email.py b/tailbone/views/email.py index 6e12e972..d95be94d 100644 --- a/tailbone/views/email.py +++ b/tailbone/views/email.py @@ -87,19 +87,23 @@ class ProfilesView(MasterView): g.sorters['key'] = g.make_simple_sorter('key', foldcase=True) g.sorters['prefix'] = g.make_simple_sorter('prefix', foldcase=True) g.sorters['subject'] = g.make_simple_sorter('subject', foldcase=True) - g.sorters['to'] = g.make_simple_sorter('to', foldcase=True) g.sorters['enabled'] = g.make_simple_sorter('enabled') g.set_sort_defaults('key') g.set_type('enabled', 'boolean') - g.set_renderer('to', self.render_to) g.set_link('key') g.set_link('subject') - # Make edit link visible by default, no "More" actions. - if g.more_actions: - g.main_actions.append(g.more_actions.pop()) + # to + g.set_renderer('to', self.render_to_short) + g.sorters['to'] = g.make_simple_sorter('to', foldcase=True) + + def render_to_short(self, email, column): + profile = email['_email'] + if self.rattail_config.production(): + if profile.dynamic_to: + if profile.dynamic_to_help: + return profile.dynamic_to_help - def render_to(self, email, column): value = email['to'] if not value: return "" @@ -148,6 +152,7 @@ class ProfilesView(MasterView): def configure_form(self, f): super(ProfilesView, self).configure_form(f) + profile = f.model_instance['_email'] # key f.set_readonly('key') @@ -172,6 +177,11 @@ class ProfilesView(MasterView): # to f.set_widget('to', dfwidget.TextAreaWidget(cols=60, rows=6)) + if self.rattail_config.production(): + if profile.dynamic_to: + f.set_readonly('to') + if profile.dynamic_to_help: + f.model_instance['to'] = profile.dynamic_to_help # cc f.set_widget('cc', dfwidget.TextAreaWidget(cols=60, rows=2)) From 7443b31a93ceaf6d213182538d121ab6f67cf2d8 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 6 Apr 2018 09:50:37 -0700 Subject: [PATCH 0780/3196] Add new vendor catalog row status, render product with hyperlink --- tailbone/views/vendors/catalogs.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/tailbone/views/vendors/catalogs.py b/tailbone/views/vendors/catalogs.py index 971e17c4..662a9462 100644 --- a/tailbone/views/vendors/catalogs.py +++ b/tailbone/views/vendors/catalogs.py @@ -177,9 +177,25 @@ class VendorCatalogsView(FileBatchMasterView): def row_grid_extra_class(self, row, i): if row.status_code == row.STATUS_PRODUCT_NOT_FOUND: return 'warning' - if row.status_code in (row.STATUS_NEW_COST, row.STATUS_UPDATE_COST): + if row.status_code in (row.STATUS_NEW_COST, + row.STATUS_UPDATE_COST, # TODO: deprecate/remove this one + row.STATUS_CHANGE_VENDOR_ITEM_CODE, + row.STATUS_CHANGE_CASE_SIZE, + row.STATUS_CHANGE_COST): return 'notice' + def configure_row_form(self, f): + super(VendorCatalogsView, self).configure_row_form(f) + f.set_renderer('product', self.render_product) + + def render_product(self, row, field): + product = row.product + if not product: + return "" + text = six.text_type(product) + url = self.request.route_url('products.view', uuid=product.uuid) + return tags.link_to(text, url) + def template_kwargs_create(self, **kwargs): parsers = self.get_parsers() for parser in parsers: From 8ea769d0e5aed3649ed2af2811507837633f5a31 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 9 Apr 2018 13:17:45 -0700 Subject: [PATCH 0781/3196] 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 00a4ae0d..68fe722b 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,14 @@ CHANGELOG ========= +0.7.8 (2018-04-09) +------------------ + +* Add awareness for ``Email.dynamic_to`` flag in config UI. + +* Add new vendor catalog row status, render product with hyperlink. + + 0.7.7 (2018-03-23) ------------------ diff --git a/tailbone/_version.py b/tailbone/_version.py index bcf01998..b7bc3945 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.7.7' +__version__ = '0.7.8' From 8c8d539266cc3f90a5e0827e57005538581e6a0e Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 10 Apr 2018 09:07:57 -0700 Subject: [PATCH 0782/3196] Add future mode for vendor catalog batch --- tailbone/templates/products/view.mako | 2 +- tailbone/views/vendors/catalogs.py | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/tailbone/templates/products/view.mako b/tailbone/templates/products/view.mako index a6e157d1..8bfc9acc 100644 --- a/tailbone/templates/products/view.mako +++ b/tailbone/templates/products/view.mako @@ -181,7 +181,7 @@ Pref. Vendor - Code + Order Code Case Size Case Cost Unit Cost diff --git a/tailbone/views/vendors/catalogs.py b/tailbone/views/vendors/catalogs.py index 662a9462..a3d0c43e 100644 --- a/tailbone/views/vendors/catalogs.py +++ b/tailbone/views/vendors/catalogs.py @@ -72,6 +72,7 @@ class VendorCatalogsView(FileBatchMasterView): 'id', 'vendor', 'filename', + 'future', 'effective', 'created', 'created_by', @@ -90,6 +91,7 @@ class VendorCatalogsView(FileBatchMasterView): 'old_unit_cost', 'unit_cost', 'unit_cost_diff', + 'starts', 'status_code', ] @@ -138,6 +140,7 @@ class VendorCatalogsView(FileBatchMasterView): 'filename', 'parser_key', 'vendor_uuid', + 'future', ]) parser_values = [(p.key, p.display) for p in self.get_parsers()] @@ -164,10 +167,17 @@ class VendorCatalogsView(FileBatchMasterView): kwargs['vendor'] = batch.vendor elif batch.vendor_uuid: kwargs['vendor_uuid'] = batch.vendor_uuid + kwargs['future'] = batch.future return kwargs def configure_row_grid(self, g): super(VendorCatalogsView, self).configure_row_grid(g) + batch = self.get_instance() + + # starts + if not batch.future: + g.hide_column('starts') + g.set_label('upc', "UPC") g.set_label('brand_name', "Brand") g.set_label('old_unit_cost', "Old Cost") From 7f567dec3aba6185eea0805d34f7560ed138a2d5 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 12 Apr 2018 19:31:45 -0700 Subject: [PATCH 0783/3196] 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 68fe722b..8ca590ac 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.7.9 (2018-04-12) +------------------ + +* Add future mode for vendor catalog batch. + + 0.7.8 (2018-04-09) ------------------ diff --git a/tailbone/_version.py b/tailbone/_version.py index b7bc3945..34a030fc 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.7.8' +__version__ = '0.7.9' From c869238678561106490dfa3cf9952c6a86c4d8a6 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 2 May 2018 10:37:17 -0500 Subject: [PATCH 0784/3196] Add sort/filter for department name, for Categories grid --- tailbone/views/categories.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tailbone/views/categories.py b/tailbone/views/categories.py index 057e6d6a..607a2fee 100644 --- a/tailbone/views/categories.py +++ b/tailbone/views/categories.py @@ -58,6 +58,11 @@ class CategoriesView(MasterView): super(CategoriesView, self).configure_grid(g) g.filters['name'].default_active = True g.filters['name'].default_verb = 'contains' + + g.set_joiner('department', lambda q: q.outerjoin(model.Department)) + g.set_sorter('department', model.Department.name) + g.set_filter('department', model.Department.name) + g.set_sort_defaults('code') g.set_link('code') g.set_link('number') From 497c80161d1ddab7d45206530076b8c413d37bd0 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 2 May 2018 10:38:02 -0500 Subject: [PATCH 0785/3196] 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 8ca590ac..fe46f50d 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.7.10 (2018-05-02) +------------------ + +* Add sort/filter for department name, for Categories grid. + + 0.7.9 (2018-04-12) ------------------ diff --git a/tailbone/_version.py b/tailbone/_version.py index 34a030fc..c902f5cf 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.7.9' +__version__ = '0.7.10' From e6144ea08b4a89a9a84a3de2d0c4d88cdc094f16 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 3 May 2018 10:54:40 -0500 Subject: [PATCH 0786/3196] Add `Form.__contains__()` method for testing if a field is contained in the form --- tailbone/forms/core.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index 82b21fd4..68140b26 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.py @@ -364,6 +364,9 @@ class Form(object): self.action_url = action_url self.cancel_url = cancel_url + def __contains__(self, item): + return item in self.fields + def set_fields(self, fields): self.fields = FieldList(fields) From a5d1eece719407ee77891bafaefeadfc0fb03219 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 3 May 2018 18:15:13 -0500 Subject: [PATCH 0787/3196] Improve default behavior for receiving a purchase batch only targeting desktop so far, mobile is next... --- tailbone/views/purchases/core.py | 11 +++ tailbone/views/purchasing/batch.py | 115 +++++++++++++++++-------- tailbone/views/purchasing/receiving.py | 67 +++++++++++++- 3 files changed, 151 insertions(+), 42 deletions(-) diff --git a/tailbone/views/purchases/core.py b/tailbone/views/purchases/core.py index ee532fcd..6fae86fd 100644 --- a/tailbone/views/purchases/core.py +++ b/tailbone/views/purchases/core.py @@ -310,6 +310,17 @@ class PurchaseView(MasterView): # department f.set_renderer('department', self.render_row_department) + # product + f.set_renderer('product', self.render_row_product) + + def render_row_product(self, row, field): + product = row.product + if not product: + return "" + text = six.text_type(product) + url = self.request.route_url('products.view', uuid=product.uuid) + return tags.link_to(text, url) + def render_row_department(self, row, field): return "{} {}".format(row.department_number, row.department_name) diff --git a/tailbone/views/purchasing/batch.py b/tailbone/views/purchasing/batch.py index fa73b96f..805dc407 100644 --- a/tailbone/views/purchasing/batch.py +++ b/tailbone/views/purchasing/batch.py @@ -221,15 +221,24 @@ class PurchasingBatchView(BatchMasterView): # mode f.set_enum('mode', self.enum.PURCHASE_BATCH_MODE) - # TODO: this hardly seems complete... # store + single_store = self.rattail_config.single_store() if self.creating: f.replace('store', 'store_uuid') - f.set_widget('store_uuid', dfwidget.SelectWidget(values=self.get_store_values())) - f.set_label('store_uuid', "Store") + if single_store: + store = self.rattail_config.get_store(self.Session()) + f.set_widget('store_uuid', forms.widgets.ReadonlyWidget()) + f.set_default('store_uuid', store.uuid) + f.set_hidden('store_uuid') + else: + f.set_widget('store_uuid', dfwidget.SelectWidget(values=self.get_store_values())) + f.set_label('store_uuid', "Store") else: - f.set_readonly('store') - f.set_renderer('store', self.render_store) + if single_store: + f.remove_field('store') + else: + f.set_readonly('store') + f.set_renderer('store', self.render_store) # purchase f.set_renderer('purchase', self.render_purchase) @@ -243,17 +252,27 @@ class PurchasingBatchView(BatchMasterView): f.set_renderer('vendor', self.render_vendor) if self.creating: f.replace('vendor', 'vendor_uuid') - f.set_node('vendor_uuid', colander.String()) - vendor_display = "" - if self.request.method == 'POST': - if self.request.POST.get('vendor_uuid'): - vendor = self.Session.query(model.Vendor).get(self.request.POST['vendor_uuid']) - if vendor: - vendor_display = six.text_type(vendor) - vendors_url = self.request.route_url('vendors.autocomplete') - f.set_widget('vendor_uuid', forms.widgets.JQueryAutocompleteWidget( - field_display=vendor_display, service_url=vendors_url)) f.set_label('vendor_uuid', "Vendor") + widget_type = self.rattail_config.get('tailbone', 'default_widget.vendor', + default='autocomplete') + if widget_type == 'autocomplete': + vendor_display = "" + if self.request.method == 'POST': + if self.request.POST.get('vendor_uuid'): + vendor = self.Session.query(model.Vendor).get(self.request.POST['vendor_uuid']) + if vendor: + vendor_display = six.text_type(vendor) + vendors_url = self.request.route_url('vendors.autocomplete') + f.set_widget('vendor_uuid', forms.widgets.JQueryAutocompleteWidget( + field_display=vendor_display, service_url=vendors_url)) + elif widget_type == 'dropdown': + vendors = self.Session.query(model.Vendor)\ + .order_by(model.Vendor.id) + vendor_values = [(vendor.uuid, "({}) {}".format(vendor.id, vendor.name)) + for vendor in vendors] + f.set_widget('vendor_uuid', dfwidget.SelectWidget(values=vendor_values)) + else: + raise NotImplementedError("Unsupported vendor widget type: {}".format(widget_type)) elif self.editing: f.set_readonly('vendor') @@ -271,26 +290,27 @@ class PurchasingBatchView(BatchMasterView): f.set_readonly('department') # buyer - f.set_renderer('buyer', self.render_buyer) - if self.creating or self.editing: - f.replace('buyer', 'buyer_uuid') - f.set_node('buyer_uuid', colander.String(), missing=colander.null) - buyer_display = "" - if self.request.method == 'POST': - if self.request.POST.get('buyer_uuid'): - buyer = self.Session.query(model.Employee).get(self.request.POST['buyer_uuid']) - if buyer: - buyer_display = six.text_type(buyer) - elif self.creating: - buyer = self.request.user.employee - buyer_display = six.text_type(buyer) - f.set_default('buyer_uuid', buyer.uuid) - elif self.editing: - buyer_display = six.text_type(batch.buyer or '') - buyers_url = self.request.route_url('employees.autocomplete') - f.set_widget('buyer_uuid', forms.widgets.JQueryAutocompleteWidget( - field_display=buyer_display, service_url=buyers_url)) - f.set_label('buyer_uuid', "Buyer") + if 'buyer' in f: + f.set_renderer('buyer', self.render_buyer) + if self.creating or self.editing: + f.replace('buyer', 'buyer_uuid') + f.set_node('buyer_uuid', colander.String(), missing=colander.null) + buyer_display = "" + if self.request.method == 'POST': + if self.request.POST.get('buyer_uuid'): + buyer = self.Session.query(model.Employee).get(self.request.POST['buyer_uuid']) + if buyer: + buyer_display = six.text_type(buyer) + elif self.creating: + buyer = self.request.user.employee + buyer_display = six.text_type(buyer) + f.set_default('buyer_uuid', buyer.uuid) + elif self.editing: + buyer_display = six.text_type(batch.buyer or '') + buyers_url = self.request.route_url('employees.autocomplete') + f.set_widget('buyer_uuid', forms.widgets.JQueryAutocompleteWidget( + field_display=buyer_display, service_url=buyers_url)) + f.set_label('buyer_uuid', "Buyer") # date_ordered f.set_type('date_ordered', 'date_jquery') @@ -629,19 +649,33 @@ class PurchasingBatchView(BatchMasterView): elif self.editing: f.set_readonly('upc') + f.set_readonly('item_id') f.set_readonly('product') - f.remove_fields('po_total', - 'invoice_total', - 'status_code') + f.set_renderer('product', self.render_product) + + # TODO: what's up with this again? + # f.remove_fields('po_total', + # 'invoice_total', + # 'status_code') elif self.viewing: if row.product: f.remove_fields('brand_name', 'description', 'size') + f.set_renderer('product', self.render_row_product) else: f.remove_field('product') + + def render_row_product(self, row, field): + product = row.product + if not product: + return "" + text = six.text_type(product) + url = self.request.route_url('products.view', uuid=product.uuid) + return tags.link_to(text, url) + def configure_mobile_row_form(self, f): super(PurchasingBatchView, self).configure_mobile_row_form(f) # row = f.model_instance @@ -786,6 +820,11 @@ class PurchasingBatchView(BatchMasterView): # self.request.session.flash("Added item: {} {}".format(row.upc.pretty(), row.product)) # return self.redirect(self.request.current_route_url()) + # TODO: seems like this should be master behavior, controlled by setting? + def redirect_after_edit_row(self, row, mobile=False): + parent = self.get_parent(row) + return self.redirect(self.get_action_url('view', parent, mobile=mobile)) + def delete_row(self): """ Update the batch totals in addition to marking row as removed. diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index 97ff6cbe..6d5038ec 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -89,11 +89,38 @@ class ReceivingBatchView(PurchasingBatchView): model_title_plural = "Receiving Batches" index_title = "Receiving" creatable = False + rows_editable = True rows_deletable = False mobile_creatable = True mobile_rows_filterable = True mobile_rows_creatable = True + form_fields = [ + 'id', + 'store', + 'vendor', + 'department', + 'purchase', + 'vendor_email', + 'vendor_fax', + 'vendor_contact', + 'vendor_phone', + 'date_ordered', + 'date_received', + 'po_number', + 'po_total', + 'invoice_date', + 'invoice_number', + 'invoice_total', + 'notes', + 'created', + 'created_by', + 'status_code', + 'complete', + 'executed', + 'executed_by', + ] + mobile_form_fields = [ 'vendor', 'department', @@ -116,6 +143,34 @@ class ReceivingBatchView(PurchasingBatchView): 'status_code', ] + row_form_fields = [ + 'upc', + 'item_id', + 'product', + 'brand_name', + 'description', + 'size', + 'case_quantity', + 'cases_ordered', + 'units_ordered', + 'cases_received', + 'units_received', + 'cases_damaged', + 'units_damaged', + 'cases_expired', + 'units_expired', + 'cases_mispick', + 'units_mispick', + 'po_line_number', + 'po_unit_cost', + 'po_total', + 'invoice_line_number', + 'invoice_unit_cost', + 'invoice_total', + 'status_code', + 'credits', + ] + @property def batch_mode(self): return self.enum.PURCHASE_BATCH_MODE_RECEIVING @@ -177,8 +232,6 @@ class ReceivingBatchView(PurchasingBatchView): if mobile: purchase = self.get_purchase(self.request.POST['purchase']) - kwargs['sms_transaction_number'] = purchase.F1032 - numbers = [d.F03 for d in purchase.details] if numbers: number = max(set(numbers), key=numbers.count) @@ -186,8 +239,6 @@ class ReceivingBatchView(PurchasingBatchView): .filter(model.Department.number == number)\ .one() - else: - kwargs['sms_transaction_number'] = batch.sms_transaction_number return kwargs def configure_mobile_form(self, f): @@ -199,6 +250,14 @@ class ReceivingBatchView(PurchasingBatchView): # department # fs.department.with_renderer(fa.TextFieldRenderer), + def configure_row_form(self, f): + super(ReceivingBatchView, self).configure_row_form(f) + f.set_readonly('cases_ordered') + f.set_readonly('units_ordered') + f.set_readonly('po_unit_cost') + f.set_readonly('po_total') + f.set_readonly('invoice_total') + def render_mobile_row_listitem(self, row, i): description = row.product.full_description if row.product else row.description return "({}) {}".format(row.upc.pretty(), description) From 4ee30feb0f0d67df4bf7649927c16a81ceb973dc Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 3 May 2018 18:20:38 -0500 Subject: [PATCH 0788/3196] Fix bug for purchase batch --- tailbone/views/purchasing/batch.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tailbone/views/purchasing/batch.py b/tailbone/views/purchasing/batch.py index 805dc407..0f172928 100644 --- a/tailbone/views/purchasing/batch.py +++ b/tailbone/views/purchasing/batch.py @@ -651,7 +651,7 @@ class PurchasingBatchView(BatchMasterView): f.set_readonly('upc') f.set_readonly('item_id') f.set_readonly('product') - f.set_renderer('product', self.render_product) + f.set_renderer('product', self.render_row_product) # TODO: what's up with this again? # f.remove_fields('po_total', @@ -667,7 +667,6 @@ class PurchasingBatchView(BatchMasterView): else: f.remove_field('product') - def render_row_product(self, row, field): product = row.product if not product: From 177d9d2e3d982233beb4bfbc3b8b11545453dad7 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 9 May 2018 15:12:44 -0500 Subject: [PATCH 0789/3196] Fix label profile type field when editing label batch row --- tailbone/views/labels/batch.py | 14 ++++++++++++++ tailbone/views/master.py | 4 +++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/tailbone/views/labels/batch.py b/tailbone/views/labels/batch.py index acf33d9e..414811f4 100644 --- a/tailbone/views/labels/batch.py +++ b/tailbone/views/labels/batch.py @@ -26,10 +26,13 @@ Views for label batches from __future__ import unicode_literals, absolute_import +import six + from rattail.db import model from webhelpers2.html import HTML, tags +from tailbone import forms from tailbone.views.batch import BatchMasterView @@ -181,6 +184,17 @@ class LabelBatchView(BatchMasterView): else: f.remove_field('product') + # label_profile + if self.editing: + f.replace('label_profile', 'label_profile_uuid') + f.set_label('label_profile_uuid', "Label Type") + profiles = self.Session.query(model.LabelProfile)\ + .filter(model.LabelProfile.visible == True)\ + .order_by(model.LabelProfile.ordinal) + profile_values = [(p.uuid, six.text_type(p)) + for p in profiles] + f.set_widget('label_profile_uuid', forms.widgets.JQuerySelectWidget(values=profile_values)) + def includeme(config): LabelBatchView.defaults(config) diff --git a/tailbone/views/master.py b/tailbone/views/master.py index b727e426..c6e8db19 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -2450,7 +2450,9 @@ class MasterView(View): 'parent_instance': parent, 'instance_title': self.get_row_instance_title(row), 'instance_deletable': self.row_deletable(row), - 'form': form}) + 'form': form, + 'dform': form.make_deform_form(), + }) def mobile_edit_row(self): """ From b515331e483b8aa04db48a9368c6b3193bca480e Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 9 May 2018 15:58:09 -0500 Subject: [PATCH 0790/3196] Allow lookup of inventory item by alternate code i.e. in addition to UPC. but only if so configured --- tailbone/views/inventory.py | 38 +++++++++++++++++++++++++------------ 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/tailbone/views/inventory.py b/tailbone/views/inventory.py index bff6c048..b10ade6f 100644 --- a/tailbone/views/inventory.py +++ b/tailbone/views/inventory.py @@ -324,25 +324,39 @@ class InventoryBatchView(BatchMasterView): 'redirect': self.get_action_url('view', batch), } data = {} - upc = self.request.GET.get('upc', '').strip() - upc = re.sub(r'\D', '', upc) + entry = self.request.GET.get('upc', '') + product = self.find_product(entry) + data = self.product_info(product) + + result = {'product': data or None, 'upc_raw': entry, 'upc': None} + if not data: + upc = re.sub(r'\D', '', entry.strip()) + if upc: + upc = GPC(upc) + result['upc'] = six.text_type(upc) + result['upc_pretty'] = upc.pretty() + result['image_url'] = pod.get_image_url(self.rattail_config, upc) + return result + + def find_product(self, entry): + upc = re.sub(r'\D', '', entry.strip()) if upc: # first try to locate existing batch row by UPC match provided = GPC(upc, calc_check_digit=False) checked = GPC(upc, calc_check_digit='upc') product = api.get_product_by_upc(self.Session(), provided) - if not product: - product = api.get_product_by_upc(self.Session(), checked) - data = self.product_info(product) + if product: + return product + product = api.get_product_by_upc(self.Session(), checked) + if product: + return product - result = {'product': data or None, 'upc_raw': upc, 'upc': None} - if not data and upc: - upc = GPC(upc) - result['upc'] = six.text_type(upc) - result['upc_pretty'] = upc.pretty() - result['image_url'] = pod.get_image_url(self.rattail_config, upc) - return result + # maybe try to locate product by alternate code + if self.rattail_config.getbool('tailbone', 'inventory.lookup_by_code', default=False): + product = api.get_product_by_code(self.Session(), entry) + if product: + return product def product_info(self, product): data = {} From 9ed501a8cc8b313508993d23ad31ecc57b02f192 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 16 May 2018 09:15:52 -0500 Subject: [PATCH 0791/3196] Add initial support for receiving truck dump batch via mobile i.e. just the initial truck dump, but secondary invoice batches are not yet supported. also this maybe breaks other things..we'll see --- tailbone/forms/types.py | 7 + .../static/js/jquery.ui.tailbone.mobile.js | 5 +- .../static/js/tailbone.mobile.receiving.js | 34 ++++ tailbone/templates/batch/view.mako | 2 +- tailbone/templates/mobile/base.mako | 1 + .../templates/mobile/receiving/create.mako | 21 +- .../templates/mobile/receiving/view_row.mako | 23 ++- tailbone/views/purchasing/batch.py | 1 + tailbone/views/purchasing/receiving.py | 182 +++++++++++++----- 9 files changed, 214 insertions(+), 62 deletions(-) create mode 100644 tailbone/static/js/tailbone.mobile.receiving.js diff --git a/tailbone/forms/types.py b/tailbone/forms/types.py index 68168915..de7e117c 100644 --- a/tailbone/forms/types.py +++ b/tailbone/forms/types.py @@ -153,6 +153,13 @@ class EmployeeType(ModelType): model_class = model.Employee +class VendorType(ModelType): + """ + Custom schema type for vendor relationship field. + """ + model_class = model.Vendor + + class ProductType(ModelType): """ Custom schema type for product relationship field. diff --git a/tailbone/static/js/jquery.ui.tailbone.mobile.js b/tailbone/static/js/jquery.ui.tailbone.mobile.js index 33a2a7be..79eecb9a 100644 --- a/tailbone/static/js/jquery.ui.tailbone.mobile.js +++ b/tailbone/static/js/jquery.ui.tailbone.mobile.js @@ -56,10 +56,12 @@ // when user clicks autocomplete result, hide search etc. this.ul.on('click', 'li', function() { var $li = $(this); + var uuid = $li.data('uuid'); that.search.hide(); - that.hidden_field.val($li.data('uuid')); + that.hidden_field.val(uuid); that.button.text($li.text()).show(); that.ul.hide(); + that.element.trigger('autocompleteitemselected', uuid); }); // when user clicks "change" button, show search etc. @@ -69,6 +71,7 @@ that.hidden_field.val(''); that.search.show(); that.text_field.focus(); + that.element.trigger('autocompleteitemcleared'); }); } diff --git a/tailbone/static/js/tailbone.mobile.receiving.js b/tailbone/static/js/tailbone.mobile.receiving.js new file mode 100644 index 00000000..45341a55 --- /dev/null +++ b/tailbone/static/js/tailbone.mobile.receiving.js @@ -0,0 +1,34 @@ + +/************************************************************ + * + * tailbone.mobile.receiving.js + * + * Global logic for mobile receiving feature + * + ************************************************************/ + + +// TODO: this is really just for receiving; should change form name? +$(document).on('autocompleteitemselected', 'form[name="new-purchasing-batch"] .vendor', function(event, uuid) { + $('#new-receiving-types').show(); +}); + + +// TODO: this is really just for receiving; should change form name? +$(document).on('autocompleteitemcleared', 'form[name="new-purchasing-batch"] .vendor', function(event) { + $('#new-receiving-types').hide(); +}); + + +$(document).on('click', 'form[name="new-purchasing-batch"] #receive-truck-dump', function() { + var form = $(this).parents('form'); + form.find('input[name="workflow"]').val('truck_dump'); + form.submit(); +}); + + +$(document).on('click', 'form.receiving-update #delete-receiving-row', function() { + var form = $(this).parents('form'); + form.find('input[name="delete_row"]').val('true'); + form.submit(); +}); diff --git a/tailbone/templates/batch/view.mako b/tailbone/templates/batch/view.mako index 86710cf0..31f4c88e 100644 --- a/tailbone/templates/batch/view.mako +++ b/tailbone/templates/batch/view.mako @@ -75,7 +75,7 @@ ${rows_grid|n} -% if not batch.executed: +% if master.handler.executable(batch) and not batch.executed: diff --git a/tailbone/templates/mobile/base.mako b/tailbone/templates/mobile/base.mako index 06b27910..8bf656bb 100644 --- a/tailbone/templates/mobile/base.mako +++ b/tailbone/templates/mobile/base.mako @@ -10,6 +10,7 @@ ${h.javascript_link('https://code.jquery.com/mobile/1.4.5/jquery.mobile-1.4.5.min.js')} ${h.javascript_link(request.static_url('tailbone:static/js/jquery.ui.tailbone.mobile.js'))} ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.mobile.js'))} + ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.mobile.receiving.js'))} ${self.extra_javascript()} ## since jquery mobile will "utterly cache" the first page which is loaded diff --git a/tailbone/templates/mobile/receiving/create.mako b/tailbone/templates/mobile/receiving/create.mako index b8c3d3e1..2d23a29d 100644 --- a/tailbone/templates/mobile/receiving/create.mako +++ b/tailbone/templates/mobile/receiving/create.mako @@ -20,8 +20,25 @@ ${h.csrf_token(request)}

      - ${h.submit('submit', "Find purchase orders")} - ## + + % else: ## vendor is known diff --git a/tailbone/templates/mobile/receiving/view_row.mako b/tailbone/templates/mobile/receiving/view_row.mako index 4bc1fe34..3d6aafdc 100644 --- a/tailbone/templates/mobile/receiving/view_row.mako +++ b/tailbone/templates/mobile/receiving/view_row.mako @@ -2,9 +2,9 @@ <%inherit file="/mobile/master/view_row.mako" /> <%namespace file="/mobile/keypad.mako" import="keypad" /> -<%def name="title()">Receiving » ${instance.batch.id_str} » ${row.upc.pretty()} +<%def name="title()">Receiving » ${batch.id_str} » ${row.upc.pretty()} -<%def name="page_title()">${h.link_to("Receiving", url('mobile.receiving'))} » ${h.link_to(instance.batch.id_str, url('mobile.receiving.view', uuid=instance.batch_uuid))} » ${row.upc.pretty()} +<%def name="page_title()">${h.link_to("Receiving", url('mobile.receiving'))} » ${h.link_to(batch.id_str, url('mobile.receiving.view', uuid=batch.uuid))} » ${row.upc.pretty()} <% unit_uom = 'LB' if row.product and row.product.weighed else 'EA' @@ -20,7 +20,7 @@ % if instance.product:

      ${instance.brand_name or ""}

      ${instance.description} ${instance.size}

      -

      ${h.pretty_quantity(row.case_quantity)} ${unit_uom} per CS

      +

      1 CS = ${h.pretty_quantity(row.case_quantity)} ${unit_uom}

      % else:

      ${instance.description}

      % endif @@ -32,10 +32,12 @@ - - - - + % if not batch.truck_dump: + + + + + % endif @@ -57,7 +59,7 @@ % endfor % endif -% if not instance.batch.executed and not instance.batch.complete: +% if not batch.executed and not batch.complete: ${h.form(request.current_route_url(), class_='receiving-update')} ${h.csrf_token(request)} @@ -98,5 +100,10 @@
      ordered${h.pretty_quantity(row.cases_ordered or 0)} / ${h.pretty_quantity(row.units_ordered or 0)}
      ordered${h.pretty_quantity(row.cases_ordered or 0)} / ${h.pretty_quantity(row.units_ordered or 0)}
      received ${h.pretty_quantity(row.cases_received or 0)} / ${h.pretty_quantity(row.units_received or 0)}
      + ${h.hidden('delete_row', value='false')} + % if request.has_perm('{}.delete_row'.format(permission_prefix)): + + % endif + ${h.end_form()} % endif diff --git a/tailbone/views/purchasing/batch.py b/tailbone/views/purchasing/batch.py index 0f172928..fc5d1e37 100644 --- a/tailbone/views/purchasing/batch.py +++ b/tailbone/views/purchasing/batch.py @@ -512,6 +512,7 @@ class PurchasingBatchView(BatchMasterView): def get_batch_kwargs(self, batch, mobile=False): kwargs = super(PurchasingBatchView, self).get_batch_kwargs(batch, mobile=mobile) kwargs['mode'] = self.batch_mode + kwargs['truck_dump'] = batch.truck_dump if batch.store: kwargs['store'] = batch.store elif batch.store_uuid: diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index 6d5038ec..039b32ed 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -36,6 +36,8 @@ from rattail.gpc import GPC from rattail.util import pretty_quantity, prettify import colander +from deform import widget as dfwidget +from pyramid import httpexceptions from webhelpers2.html import tags from tailbone import forms, grids @@ -48,6 +50,12 @@ class MobileItemStatusFilter(grids.filters.MobileFilter): def filter_equal(self, query, value): + # NOTE: this is only relevant for truck dump + if value == 'received': + return query.filter(sa.or_( + model.PurchaseBatchRow.cases_received != 0, + model.PurchaseBatchRow.units_received != 0)) + # TODO: is this accurate (enough) ? if value == 'incomplete': return query.filter(sa.or_(model.PurchaseBatchRow.cases_ordered != 0, model.PurchaseBatchRow.units_ordered != 0))\ @@ -95,10 +103,29 @@ class ReceivingBatchView(PurchasingBatchView): mobile_rows_filterable = True mobile_rows_creatable = True + allow_from_po = False + allow_from_scratch = True + allow_truck_dump = False + + grid_columns = [ + 'id', + 'vendor', + 'truck_dump', + 'department', + 'buyer', + 'date_ordered', + 'created', + 'created_by', + 'rowcount', + 'status_code', + 'executed', + ] + form_fields = [ 'id', 'store', 'vendor', + 'truck_dump', 'department', 'purchase', 'vendor_email', @@ -123,6 +150,7 @@ class ReceivingBatchView(PurchasingBatchView): mobile_form_fields = [ 'vendor', + 'truck_dump', 'department', ] @@ -175,6 +203,13 @@ class ReceivingBatchView(PurchasingBatchView): def batch_mode(self): return self.enum.PURCHASE_BATCH_MODE_RECEIVING + def configure_form(self, f): + super(ReceivingBatchView, self).configure_form(f) + + # truck_dump + if self.editing: + f.set_readonly('truck_dump') + def render_mobile_listitem(self, batch, i): title = "({}) {} for ${:0,.2f} - {}, {}".format( batch.id_str, @@ -188,8 +223,17 @@ class ReceivingBatchView(PurchasingBatchView): """ Returns a set of filters for the mobile row grid. """ + batch = self.get_instance() filters = grids.filters.GridFilterSet() - filters['status'] = MobileItemStatusFilter('status', default_value='incomplete') + if batch.truck_dump: + value_choices = ['received', 'damaged', 'expired', 'all'] + default_status = 'all' + else: + value_choices = ['incomplete', 'unexpected', 'damaged', 'expired', 'all'] + default_status = 'incomplete' + filters['status'] = MobileItemStatusFilter('status', + value_choices=value_choices, + default_value=default_status) return filters def mobile_create(self): @@ -199,6 +243,25 @@ class ReceivingBatchView(PurchasingBatchView): mode = self.batch_mode data = {'mode': mode} + form = forms.Form(schema=MobileNewReceivingBatch(), request=self.request) + if form.validate(newstyle=True): + + if form.validated['workflow'] == 'truck_dump': + if not self.allow_truck_dump: + raise NotImplementedError("Requested workflow not supported: truck_dump") + batch = self.model_class() + batch.store = self.rattail_config.get_store(self.Session()) + batch.mode = mode + batch.truck_dump = True + batch.vendor = self.Session.merge(form.validated['vendor']) + batch.created_by = self.request.user + kwargs = self.get_batch_kwargs(batch, mobile=True) + batch = self.handler.make_batch(self.Session(), **kwargs) + return self.redirect(self.request.route_url('mobile.receiving.view', uuid=batch.uuid)) + + else: + raise NotImplementedError("Requested workflow not supported: {}".format(form.validated['workflow'])) + vendor = None if self.request.method == 'POST' and self.request.POST.get('vendor'): vendor = self.Session.query(model.Vendor).get(self.request.POST['vendor']) @@ -227,28 +290,19 @@ class ReceivingBatchView(PurchasingBatchView): data['purchases'] = [(p['key'], p['display']) for p in purchases['purchases']] return self.render_to_response('create', data, mobile=True) - def get_batch_kwargs(self, batch, mobile=False): - kwargs = super(ReceivingBatchView, self).get_batch_kwargs(batch, mobile=mobile) - if mobile: - - purchase = self.get_purchase(self.request.POST['purchase']) - numbers = [d.F03 for d in purchase.details] - if numbers: - number = max(set(numbers), key=numbers.count) - kwargs['department'] = self.Session.query(model.Department)\ - .filter(model.Department.number == number)\ - .one() - - return kwargs - def configure_mobile_form(self, f): super(ReceivingBatchView, self).configure_mobile_form(f) + batch = f.model_instance - # vendor - # fs.vendor.with_renderer(fa.TextFieldRenderer), + # truck_dump + if not self.creating: + if not batch.truck_dump: + f.remove_field('truck_dump') # department - # fs.department.with_renderer(fa.TextFieldRenderer), + if not self.creating: + if batch.truck_dump: + f.remove_field('department') def configure_row_form(self, f): super(ReceivingBatchView, self).configure_row_form(f) @@ -302,8 +356,7 @@ class ReceivingBatchView(PurchasingBatchView): if product: row = model.PurchaseBatchRow() row.product = product - batch.add_row(row) - self.handler.refresh_row(row) + self.handler.add_row(batch, row) # check for "bad" upc elif len(upc) > 14: @@ -329,9 +382,12 @@ class ReceivingBatchView(PurchasingBatchView): """ self.viewing = True row = self.get_row_instance() + batch = row.batch + permission_prefix = self.get_permission_prefix() form = self.make_mobile_row_form(row) context = { 'row': row, + 'batch': batch, 'instance': row, 'instance_title': self.get_row_instance_title(row), 'parent_model_title': self.get_model_title(), @@ -339,36 +395,45 @@ class ReceivingBatchView(PurchasingBatchView): 'form': form, } - if self.request.has_perm('{}.create_row'.format(self.get_permission_prefix())): - update_form = forms.Form(schema=ReceivingForm(), request=self.request) + if self.request.has_perm('{}.create_row'.format(permission_prefix)): + update_form = forms.Form(schema=MobileReceivingForm(), request=self.request) if update_form.validate(newstyle=True): row = self.Session.merge(update_form.validated['row']) - mode = update_form.validated['mode'] - cases = update_form.validated['cases'] - units = update_form.validated['units'] - if cases: - setattr(row, 'cases_{}'.format(mode), - (getattr(row, 'cases_{}'.format(mode)) or 0) + cases) - if units: - setattr(row, 'units_{}'.format(mode), - (getattr(row, 'units_{}'.format(mode)) or 0) + units) - # if mode in ('damaged', 'expired', 'mispick'): - if mode in ('damaged', 'expired'): - self.attach_credit(row, mode, cases, units, - expiration_date=update_form.validated['expiration_date'], - # discarded=update_form.data['trash'], - # mispick_product=shipped_product) - ) + # TODO: surely this (delete_row) should be split out to a separate view + if update_form.validated['delete_row']: + if not self.request.has_perm('{}.delete_row'.format(permission_prefix)): + raise httpexceptions.HTTPForbidden() + self.handler.remove_row(row) + return self.redirect(self.get_action_url('view', batch, mobile=True)) - # first undo any totals previously in effect for the row, then refresh - if row.invoice_total: - row.batch.invoice_total -= row.invoice_total - self.handler.refresh_row(row) + else: # not delete_row + mode = update_form.validated['mode'] + cases = update_form.validated['cases'] + units = update_form.validated['units'] + if cases: + setattr(row, 'cases_{}'.format(mode), + (getattr(row, 'cases_{}'.format(mode)) or 0) + cases) + if units: + setattr(row, 'units_{}'.format(mode), + (getattr(row, 'units_{}'.format(mode)) or 0) + units) - return self.redirect(self.request.route_url('mobile.{}.view'.format(self.get_route_prefix()), uuid=row.batch_uuid)) + # if mode in ('damaged', 'expired', 'mispick'): + if mode in ('damaged', 'expired'): + self.attach_credit(row, mode, cases, units, + expiration_date=update_form.validated['expiration_date'], + # discarded=update_form.data['trash'], + # mispick_product=shipped_product) + ) - if not row.cases_ordered and not row.units_ordered: + # first undo any totals previously in effect for the row, then refresh + if row.invoice_total: + batch.invoice_total -= row.invoice_total + self.handler.refresh_row(row) + + return self.redirect(self.get_action_url('view', batch, mobile=True)) + + if not row.cases_ordered and not row.units_ordered and not batch.truck_dump: self.request.session.flash("This item was NOT on the original purchase order.", 'receiving-warning') return self.render_to_response('view_row', context, mobile=True) @@ -438,22 +503,39 @@ class PurchaseBatchRowType(forms.types.ObjectType): return row -class ReceivingForm(colander.MappingSchema): +class MobileNewReceivingBatch(colander.MappingSchema): + + vendor = colander.SchemaNode(forms.types.VendorType()) + + workflow = colander.SchemaNode(colander.String(), + validator=colander.OneOf([ + 'from_po', + 'from_scratch', + 'truck_dump', + ])) + + +class MobileReceivingForm(colander.MappingSchema): row = colander.SchemaNode(PurchaseBatchRowType()) mode = colander.SchemaNode(colander.String(), - validator=colander.OneOf(['received', - 'damaged', - 'expired', - # 'mispick', + validator=colander.OneOf([ + 'received', + 'damaged', + 'expired', + # 'mispick', ])) cases = colander.SchemaNode(colander.Decimal(), missing=None) units = colander.SchemaNode(colander.Decimal(), missing=None) - expiration_date = colander.SchemaNode(colander.Date(), missing=colander.null) + expiration_date = colander.SchemaNode(colander.Date(), + widget=dfwidget.TextInputWidget(), + missing=colander.null) + + delete_row = colander.SchemaNode(colander.Boolean()) def includeme(config): From 805a1afa3fad4cfd24583713c31c21cf116c4667 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 16 May 2018 09:44:16 -0500 Subject: [PATCH 0792/3196] Fix rowcount bug when first row added via ordering worksheet --- tailbone/views/purchasing/ordering.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/views/purchasing/ordering.py b/tailbone/views/purchasing/ordering.py index 8d72ffe4..d562f6e9 100644 --- a/tailbone/views/purchasing/ordering.py +++ b/tailbone/views/purchasing/ordering.py @@ -272,7 +272,7 @@ class OrderingBatchView(PurchasingBatchView): row.cases_ordered = cases_ordered or None row.units_ordered = units_ordered or None self.handler.refresh_row(row) - batch.rowcount += 1 + batch.rowcount = (batch.rowcount or 0) + 1 return { 'row_cases_ordered': '' if not row or row.removed else int(row.cases_ordered or 0), From cd7922f204b32484f8c050d4cb3d71ecaf407162 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 18 May 2018 15:51:47 -0500 Subject: [PATCH 0793/3196] Add "most of" support for truck dump receiving still not complete, but conceptually it sort of is... --- tailbone/forms/core.py | 6 + tailbone/templates/batch/view.mako | 8 +- tailbone/templates/receiving/create.mako | 67 +++++++++ tailbone/views/batch/core.py | 126 ++++++++-------- tailbone/views/master.py | 36 +++++ tailbone/views/purchases/credits.py | 7 +- tailbone/views/purchasing/batch.py | 16 +- tailbone/views/purchasing/receiving.py | 178 ++++++++++++++++++++++- 8 files changed, 368 insertions(+), 76 deletions(-) create mode 100644 tailbone/templates/receiving/create.mako diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index 68140b26..4ffc73d3 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.py @@ -42,6 +42,7 @@ import deform from colanderalchemy import SQLAlchemySchemaNode from colanderalchemy.schema import _creation_order from deform import widget as dfwidget +from pyramid_deform import SessionFileUploadTempStore from pyramid.renderers import render from webhelpers2.html import tags, HTML @@ -585,6 +586,11 @@ class Form(object): self.set_widget(key, dfwidget.TextAreaWidget(cols=80, rows=8)) elif type_ == 'text': self.set_widget(key, dfwidget.TextAreaWidget(cols=80, rows=8)) + elif type_ == 'file': + tmpstore = SessionFileUploadTempStore(self.request) + self.set_node(key, colander.SchemaNode(deform.FileData(), + widget=dfwidget.FileUploadWidget(tmpstore), + title=self.get_label(key))) else: raise ValueError("unknown type for '{}' field: {}".format(key, type_)) diff --git a/tailbone/templates/batch/view.mako b/tailbone/templates/batch/view.mako index 31f4c88e..bdc63e6a 100644 --- a/tailbone/templates/batch/view.mako +++ b/tailbone/templates/batch/view.mako @@ -61,7 +61,13 @@ <%def name="execute_button()"> % if not batch.executed and request.has_perm('{}.execute'.format(permission_prefix)): - + % if execute_enabled: + + % elif why_not_execute: + + % else: + + % endif % endif diff --git a/tailbone/templates/receiving/create.mako b/tailbone/templates/receiving/create.mako new file mode 100644 index 00000000..d05634b9 --- /dev/null +++ b/tailbone/templates/receiving/create.mako @@ -0,0 +1,67 @@ +## -*- coding: utf-8; -*- +<%inherit file="/batch/create.mako" /> + +<%def name="extra_javascript()"> + ${parent.extra_javascript()} + ${self.func_show_batch_type()} + + + +<%def name="func_show_batch_type()"> + + + +${parent.body()} diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index 15a9c496..cf236c36 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -42,11 +42,9 @@ from rattail.util import load_object, prettify import colander import deform -from deform import widget as dfwidget from pyramid import httpexceptions from pyramid.renderers import render_to_response from pyramid.response import FileResponse -from pyramid_deform import SessionFileUploadTempStore from webhelpers2.html import HTML, tags from tailbone import forms, grids @@ -131,6 +129,9 @@ class BatchMasterView(MasterView): return load_object(spec)(self.rattail_config) return self.batch_handler_class(self.rattail_config) + def download_path(self, batch, filename): + return self.rattail_config.batch_filepath(batch.batch_key, batch.uuid, filename) + def template_kwargs_view(self, **kwargs): batch = kwargs['instance'] kwargs['batch'] = batch @@ -140,6 +141,8 @@ class BatchMasterView(MasterView): if kwargs['execute_enabled']: url = self.get_action_url('execute', batch) kwargs['execute_form'] = self.make_execute_form(batch, action_url=url) + else: + kwargs['why_not_execute'] = self.handler.why_not_execute(batch) return kwargs def allow_worksheet(self, batch): @@ -278,9 +281,6 @@ class BatchMasterView(MasterView): return status_code_text return render_status - def download_path(self, batch, filename): - return self.rattail_config.batch_filepath(batch.batch_key, batch.uuid, filename) - def render_user(self, batch, field): user = getattr(batch, field) if not user: @@ -312,6 +312,7 @@ class BatchMasterView(MasterView): f.remove_field('complete') def save_create_form(self, form): + uploads = self.normalize_uploads(form, skip=['filename']) self.before_create(form) session = self.Session() @@ -346,17 +347,15 @@ class BatchMasterView(MasterView): batch = self.handler.make_batch(session, **kwargs) self.Session.flush() - - # TODO: this needs work yet surely... - # if batch has input data file, let handler properly establish that - if 'filename' in form.schema: - if filedict: - self.handler.set_input_file(batch, filepath) - os.remove(filepath) - os.rmdir(tempdir) - + self.process_uploads(batch, form, uploads) return batch + def process_uploads(self, batch, form, uploads): + for key, upload in six.iteritems(uploads): + self.handler.set_input_file(batch, upload['temp_path'], attr=key) + os.remove(upload['temp_path']) + os.rmdir(upload['tempdir']) + def save_mobile_create_form(self, form): self.before_create(form) session = self.Session() @@ -536,6 +535,39 @@ class BatchMasterView(MasterView): url = self.request.route_url('{}.delete_rows'.format(self.get_route_prefix()), uuid=batch.uuid) return HTML.tag('p', c=[tags.link_to("Delete all rows matching current search", url)]) + def make_row_grid_kwargs(self, **kwargs): + """ + Whether or not rows may be edited or deleted will depend partially on + whether the parent batch has been executed. + """ + batch = self.get_instance() + + # TODO: most of this logic is copied from MasterView, should refactor/merge somehow... + if 'main_actions' not in kwargs: + actions = [] + + # view action + if self.rows_viewable: + view = lambda r, i: self.get_row_action_url('view', r) + actions.append(grids.GridAction('view', icon='zoomin', url=view)) + + # edit and delete are NOT allowed after execution, or if batch is "complete" + if not batch.executed and not batch.complete: + + # edit action + if self.rows_editable: + actions.append(grids.GridAction('edit', icon='pencil', url=self.row_edit_action_url)) + + # delete action + permission_prefix = self.get_permission_prefix() + if self.rows_deletable and self.request.has_perm('{}.delete_row'.format(permission_prefix)): + actions.append(grids.GridAction('delete', icon='trash', url=self.row_delete_action_url)) + kwargs.setdefault('delete_speedbump', self.rows_deletable_speedbump) + + kwargs['main_actions'] = actions + + return super(BatchMasterView, self).make_row_grid_kwargs(**kwargs) + def make_row_grid_tools(self, batch): return (self.make_default_row_grid_tools(batch) or '') + (self.make_batch_row_grid_tools(batch) or '') @@ -555,10 +587,7 @@ class BatchMasterView(MasterView): """ Delete all data (files etc.) for the batch. """ - if hasattr(batch, 'delete_data'): - batch.delete_data(self.rattail_config) - if hasattr(batch, 'data_rows'): - del batch.data_rows[:] + self.handler.delete(batch) super(BatchMasterView, self).delete_instance(batch) def get_fallback_templates(self, template, mobile=False): @@ -1153,6 +1182,7 @@ class FileBatchMasterView(BatchMasterView): """ Base class for all file-based "batch master" views. """ + downloadable = True @property def upload_dir(self): @@ -1171,62 +1201,26 @@ class FileBatchMasterView(BatchMasterView): def configure_form(self, f): super(FileBatchMasterView, self).configure_form(f) + batch = f.model_instance # filename - f.set_renderer('filename', self.render_filename) - f.set_label('filename', "Data File") - if self.editing: - f.set_readonly('filename') - if self.creating: - if 'filename' not in f.fields: - f.fields.insert(0, 'filename') - tmpstore = SessionFileUploadTempStore(self.request) - f.set_node('filename', colander.SchemaNode(deform.FileData(), widget=dfwidget.FileUploadWidget(tmpstore))) + # TODO: what's up with this re-insertion again..? + # if 'filename' not in f.fields: + # f.fields.insert(0, 'filename') + f.set_type('filename', 'file') + else: + f.set_readonly('filename') + f.set_renderer('filename', self.render_filename) def render_filename(self, batch, field): - path = batch.filepath(self.rattail_config, filename=batch.filename) + filename = getattr(batch, field) + if not filename: + return "" + path = batch.filepath(self.rattail_config, filename=filename) url = self.get_action_url('download', batch) return self.render_file_field(path, url) - def download(self): - """ - View for downloading the data file associated with a batch. - """ - batch = self.get_instance() - if not batch: - raise httpexceptions.HTTPNotFound() - path = batch.filepath(self.rattail_config) - response = FileResponse(path, request=self.request) - response.headers[b'Content-Length'] = six.binary_type(os.path.getsize(path)) - filename = os.path.basename(batch.filename).encode('ascii', 'replace') - response.headers[b'Content-Disposition'] = b'attachment; filename="{}"'.format(filename) - return response - - @classmethod - def defaults(cls, config): - cls._filebatch_defaults(config) - cls._batch_defaults(config) - cls._defaults(config) - - @classmethod - def _filebatch_defaults(cls, config): - route_prefix = cls.get_route_prefix() - url_prefix = cls.get_url_prefix() - permission_prefix = cls.get_permission_prefix() - model_title = cls.get_model_title() - model_title_plural = cls.get_model_title_plural() - - # fix permission group title - config.add_tailbone_permission_group(permission_prefix, model_title_plural) - - # download batch data file - config.add_route('{}.download'.format(route_prefix), '{}/{{uuid}}/download'.format(url_prefix)) - config.add_view(cls, attr='download', route_name='{}.download'.format(route_prefix), - permission='{}.download'.format(permission_prefix)) - config.add_tailbone_permission(permission_prefix, '{}.download'.format(permission_prefix), - "Download existing {} data file".format(model_title)) - class MobileBatchStatusFilter(grids.filters.MobileFilter): diff --git a/tailbone/views/master.py b/tailbone/views/master.py index c6e8db19..fbd160ee 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -27,6 +27,7 @@ Model Master View from __future__ import unicode_literals, absolute_import import os +import tempfile import logging import six @@ -633,14 +634,39 @@ class MasterView(View): return self.render_to_response('create', {'form': form}, mobile=True) def save_create_form(self, form): + uploads = self.normalize_uploads(form) self.before_create(form) with self.Session().no_autoflush: obj = self.objectify(form, self.form_deserialized) self.before_create_flush(obj, form) self.Session.add(obj) self.Session.flush() + self.process_uploads(obj, form, uploads) return obj + def normalize_uploads(self, form, skip=None): + uploads = {} + for node in form.schema: + if isinstance(node.typ, deform.FileData): + if skip and node.name in skip: + continue + filedict = self.form_deserialized.get(node.name) + if filedict: + tempdir = tempfile.mkdtemp() + filepath = os.path.join(tempdir, filedict['filename']) + tmpinfo = form.deform_form[node.name].widget.tmpstore.get(filedict['uid']) + tmpdata = tmpinfo['fp'].read() + with open(filepath, 'wb') as f: + f.write(tmpdata) + uploads[node.name] = { + 'tempdir': tempdir, + 'temp_path': filepath, + } + return uploads + + def process_uploads(self, obj, form, uploads): + pass + def before_create_flush(self, obj, form): pass @@ -1230,6 +1256,8 @@ class MasterView(View): """ obj = self.get_instance() filename = self.request.GET.get('filename', None) + if not filename: + raise self.notfound() path = self.download_path(obj, filename) response = FileResponse(path, request=self.request) response.content_length = os.path.getsize(path) @@ -2124,6 +2152,14 @@ class MasterView(View): """ return getattr(cls, 'mobile_row_form_factory', forms.Form) + def render_downloadable_file(self, obj, field): + filename = getattr(obj, field) + if not filename: + return "" + path = self.download_path(obj, filename) + url = self.get_action_url('download', obj, _query={'filename': filename}) + return self.render_file_field(path, url) + def render_file_field(self, path, url=None, filename=None): """ Convenience for rendering a file with optional download link diff --git a/tailbone/views/purchases/credits.py b/tailbone/views/purchases/credits.py index ef6cd497..c8b6f684 100644 --- a/tailbone/views/purchases/credits.py +++ b/tailbone/views/purchases/credits.py @@ -26,6 +26,8 @@ Views for "true" purchase credits from __future__ import unicode_literals, absolute_import +import six + from rattail.db import model from webhelpers2.html import tags @@ -70,12 +72,13 @@ class PurchaseCreditView(MasterView): g.set_sort_defaults('date_received', 'desc') + g.set_enum('status', self.enum.PURCHASE_CREDIT_STATUS) g.filters['status'].set_value_renderer(grids.filters.EnumValueRenderer(self.enum.PURCHASE_CREDIT_STATUS)) g.filters['status'].default_active = True g.filters['status'].default_verb = 'not_equal' - g.filters['status'].default_value = self.enum.PURCHASE_CREDIT_STATUS_SATISFIED + # TODO: should not have to convert value to string! + g.filters['status'].default_value = six.text_type(self.enum.PURCHASE_CREDIT_STATUS_SATISFIED) - g.set_enum('status', self.enum.PURCHASE_CREDIT_STATUS) # g.set_type('upc', 'gpc') g.set_type('cases_shorted', 'quantity') g.set_type('units_shorted', 'quantity') diff --git a/tailbone/views/purchasing/batch.py b/tailbone/views/purchasing/batch.py index fc5d1e37..b4156edf 100644 --- a/tailbone/views/purchasing/batch.py +++ b/tailbone/views/purchasing/batch.py @@ -48,6 +48,7 @@ class PurchasingBatchView(BatchMasterView): model_row_class = model.PurchaseBatchRow default_handler_spec = 'rattail.batch.purchase:PurchaseBatchHandler' supports_new_product = False + cloneable = True grid_columns = [ 'id', @@ -513,22 +514,33 @@ class PurchasingBatchView(BatchMasterView): kwargs = super(PurchasingBatchView, self).get_batch_kwargs(batch, mobile=mobile) kwargs['mode'] = self.batch_mode kwargs['truck_dump'] = batch.truck_dump + kwargs['invoice_parser_key'] = batch.invoice_parser_key + if batch.store: kwargs['store'] = batch.store elif batch.store_uuid: kwargs['store_uuid'] = batch.store_uuid + + if batch.truck_dump_batch: + kwargs['truck_dump_batch'] = batch.truck_dump_batch + elif batch.truck_dump_batch_uuid: + kwargs['truck_dump_batch_uuid'] = batch.truck_dump_batch_uuid + if batch.vendor: kwargs['vendor'] = batch.vendor elif batch.vendor_uuid: kwargs['vendor_uuid'] = batch.vendor_uuid + if batch.department: kwargs['department'] = batch.department elif batch.department_uuid: kwargs['department_uuid'] = batch.department_uuid + if batch.buyer: kwargs['buyer'] = batch.buyer elif batch.buyer_uuid: kwargs['buyer_uuid'] = batch.buyer_uuid + kwargs['po_number'] = batch.po_number kwargs['po_total'] = batch.po_total @@ -600,7 +612,9 @@ class PurchasingBatchView(BatchMasterView): def row_grid_extra_class(self, row, i): if row.status_code == row.STATUS_PRODUCT_NOT_FOUND: return 'warning' - if row.status_code in (row.STATUS_INCOMPLETE, row.STATUS_ORDERED_RECEIVED_DIFFER): + if row.status_code in (row.STATUS_INCOMPLETE, + row.STATUS_ORDERED_RECEIVED_DIFFER, + row.STATUS_TRUCKDUMP_UNCLAIMED): return 'notice' def configure_row_form(self, f): diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index 039b32ed..55e7d60c 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -28,17 +28,19 @@ from __future__ import unicode_literals, absolute_import import re +import six import sqlalchemy as sa from rattail import pod from rattail.db import model, api from rattail.gpc import GPC from rattail.util import pretty_quantity, prettify +from rattail.vendors.invoices import iter_invoice_parsers, require_invoice_parser import colander from deform import widget as dfwidget from pyramid import httpexceptions -from webhelpers2.html import tags +from webhelpers2.html import tags, HTML from tailbone import forms, grids from tailbone.views.purchasing import PurchasingBatchView @@ -96,9 +98,8 @@ class ReceivingBatchView(PurchasingBatchView): model_title = "Receiving Batch" model_title_plural = "Receiving Batches" index_title = "Receiving" - creatable = False + downloadable = True rows_editable = True - rows_deletable = False mobile_creatable = True mobile_rows_filterable = True mobile_rows_creatable = True @@ -107,6 +108,11 @@ class ReceivingBatchView(PurchasingBatchView): allow_from_scratch = True allow_truck_dump = False + labels = { + 'truck_dump_batch': "Truck Dump Parent", + 'invoice_parser_key': "Invoice Parser", + } + grid_columns = [ 'id', 'vendor', @@ -123,9 +129,14 @@ class ReceivingBatchView(PurchasingBatchView): form_fields = [ 'id', + 'batch_type', 'store', 'vendor', 'truck_dump', + 'truck_dump_children', + 'truck_dump_batch', + 'invoice_file', + 'invoice_parser_key', 'department', 'purchase', 'vendor_email', @@ -143,6 +154,7 @@ class ReceivingBatchView(PurchasingBatchView): 'created', 'created_by', 'status_code', + 'rowcount', 'complete', 'executed', 'executed_by', @@ -203,12 +215,166 @@ class ReceivingBatchView(PurchasingBatchView): def batch_mode(self): return self.enum.PURCHASE_BATCH_MODE_RECEIVING + def row_editable(self, row): + batch = row.batch + if batch.truck_dump_batch: + return False + return True + + def row_deletable(self, row): + batch = row.batch + if batch.truck_dump: + return True + return False + def configure_form(self, f): super(ReceivingBatchView, self).configure_form(f) + batch = f.model_instance - # truck_dump - if self.editing: - f.set_readonly('truck_dump') + # batch_type + if self.creating: + batch_type_values = [ + ('from_scratch', "New from Scratch"), + ] + if self.allow_truck_dump: + batch_type_values.append(('truck_dump', "Invoice for Truck Dump")) + f.set_widget('batch_type', forms.widgets.JQuerySelectWidget(values=batch_type_values)) + else: + f.remove_field('batch_type') + + # truck_dump* + if self.allow_truck_dump: + + # truck_dump + if self.creating: + f.remove_field('truck_dump') + elif batch.truck_dump_batch: + f.remove_field('truck_dump') + else: + f.set_readonly('truck_dump') + + # truck_dump_children + if self.viewing: + if batch.truck_dump: + f.set_renderer('truck_dump_children', self.render_truck_dump_children) + else: + f.remove_field('truck_dump_children') + else: + f.remove_field('truck_dump_children') + + # truck_dump_batch + if self.creating: + f.replace('truck_dump_batch', 'truck_dump_batch_uuid') + batches = self.Session.query(model.PurchaseBatch)\ + .filter(model.PurchaseBatch.mode == self.enum.PURCHASE_BATCH_MODE_RECEIVING)\ + .filter(model.PurchaseBatch.truck_dump == True)\ + .filter(model.PurchaseBatch.complete == True)\ + .filter(model.PurchaseBatch.executed == None)\ + .order_by(model.PurchaseBatch.id) + batch_values = [(b.uuid, six.text_type(b)) for b in batches] + batch_values.insert(0, ('', "(please choose)")) + f.set_widget('truck_dump_batch_uuid', forms.widgets.JQuerySelectWidget(values=batch_values)) + f.set_label('truck_dump_batch_uuid', "Truck Dump Parent") + elif batch.truck_dump: + f.remove_field('truck_dump_batch') + elif batch.truck_dump_batch: + f.set_readonly('truck_dump_batch') + f.set_renderer('truck_dump_batch', self.render_truck_dump_batch) + else: + f.remove_field('truck_dump_batch') + + else: + f.remove_fields('truck_dump', + 'truck_dump_children', + 'truck_dump_batch') + + # invoice_file + if self.creating: + f.set_type('invoice_file', 'file') + else: + f.set_readonly('invoice_file') + f.set_renderer('invoice_file', self.render_downloadable_file) + + # invoice_parser_key + if self.creating: + parsers = sorted(iter_invoice_parsers(), key=lambda p: p.display) + parser_values = [(p.key, p.display) for p in parsers] + parser_values.insert(0, ('', "(please choose)")) + f.set_widget('invoice_parser_key', forms.widgets.JQuerySelectWidget(values=parser_values)) + else: + f.remove_field('invoice_parser_key') + + # store + if self.creating: + store = self.rattail_config.get_store(self.Session()) + f.set_widget('store_uuid', forms.widgets.ReadonlyWidget()) + f.set_default('store_uuid', store.uuid) + f.set_hidden('store_uuid') + + # purchase + if self.creating: + f.remove_field('purchase') + + # department + if self.creating: + f.remove_field('department_uuid') + + def template_kwargs_create(self, **kwargs): + kwargs = super(ReceivingBatchView, self).template_kwargs_create(**kwargs) + if self.allow_truck_dump: + vmap = {} + batches = self.Session.query(model.PurchaseBatch)\ + .filter(model.PurchaseBatch.mode == self.enum.PURCHASE_BATCH_MODE_RECEIVING)\ + .filter(model.PurchaseBatch.truck_dump == True)\ + .filter(model.PurchaseBatch.complete == True) + for batch in batches: + vmap[batch.uuid] = batch.vendor_uuid + kwargs['batch_vendor_map'] = vmap + return kwargs + + def get_batch_kwargs(self, batch, mobile=False): + kwargs = super(ReceivingBatchView, self).get_batch_kwargs(batch, mobile=mobile) + if not mobile: + batch_type = self.request.POST['batch_type'] + if batch_type == 'from_scratch': + kwargs.pop('truck_dump_batch', None) + kwargs.pop('truck_dump_batch_uuid', None) + elif batch_type == 'truck_dump': + pass + else: + raise NotImplementedError + return kwargs + + def delete_instance(self, batch): + """ + Delete all data (files etc.) for the batch. + """ + truck_dump = batch.truck_dump_batch + if batch.truck_dump: + for child in batch.truck_dump_children: + self.delete_instance(child) + super(ReceivingBatchView, self).delete_instance(batch) + if truck_dump: + self.handler.refresh(truck_dump) + + def render_truck_dump_batch(self, batch, field): + truck_dump = batch.truck_dump_batch + if not truck_dump: + return "" + text = six.text_type(truck_dump) + url = self.request.route_url('receiving.view', uuid=truck_dump.uuid) + return tags.link_to(text, url) + + def render_truck_dump_children(self, batch, field): + children = batch.truck_dump_children + if not children: + return "" + items = [] + for child in children: + text = six.text_type(child) + url = self.request.route_url('receiving.view', uuid=child.uuid) + items.append(HTML.tag('li', c=[tags.link_to(text, url)])) + return HTML.tag('ul', c=items) def render_mobile_listitem(self, batch, i): title = "({}) {} for ${:0,.2f} - {}, {}".format( From e5ffe3025ba52d40b8b0da06b97ebcfac9538220 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 18 May 2018 17:21:01 -0500 Subject: [PATCH 0794/3196] Set received date for new truck dump batches, show when choosing parent --- tailbone/views/purchasing/receiving.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index 55e7d60c..6c754df8 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -34,6 +34,7 @@ import sqlalchemy as sa from rattail import pod from rattail.db import model, api from rattail.gpc import GPC +from rattail.time import localtime from rattail.util import pretty_quantity, prettify from rattail.vendors.invoices import iter_invoice_parsers, require_invoice_parser @@ -271,7 +272,8 @@ class ReceivingBatchView(PurchasingBatchView): .filter(model.PurchaseBatch.complete == True)\ .filter(model.PurchaseBatch.executed == None)\ .order_by(model.PurchaseBatch.id) - batch_values = [(b.uuid, six.text_type(b)) for b in batches] + batch_values = [(b.uuid, "({}) {}, {}".format(b.id_str, b.date_received, b.vendor)) + for b in batches] batch_values.insert(0, ('', "(please choose)")) f.set_widget('truck_dump_batch_uuid', forms.widgets.JQuerySelectWidget(values=batch_values)) f.set_label('truck_dump_batch_uuid', "Truck Dump Parent") @@ -421,6 +423,7 @@ class ReceivingBatchView(PurchasingBatchView): batch.truck_dump = True batch.vendor = self.Session.merge(form.validated['vendor']) batch.created_by = self.request.user + batch.date_received = localtime(self.rattail_config).date() kwargs = self.get_batch_kwargs(batch, mobile=True) batch = self.handler.make_batch(self.Session(), **kwargs) return self.redirect(self.request.route_url('mobile.receiving.view', uuid=batch.uuid)) From db25a5bfd08a37f6ee925bbaefa273f95b64841c Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 21 May 2018 15:27:22 -0500 Subject: [PATCH 0795/3196] Add docs for `MasterView.help_url` and `get_help_url()` --- docs/api/views/master.rst | 25 ++++++++++++++++++------- tailbone/views/master.py | 14 +++++++++++++- 2 files changed, 31 insertions(+), 8 deletions(-) diff --git a/docs/api/views/master.rst b/docs/api/views/master.rst index b953fafa..f741ccc3 100644 --- a/docs/api/views/master.rst +++ b/docs/api/views/master.rst @@ -68,10 +68,21 @@ override when defining your subclass. Factory callable to be used when creating new grid instances; defaults to :class:`tailbone.grids.Grid`. -.. Methods to Override -.. ------------------- -.. -.. The following is a list of methods which you can override when defining your -.. subclass. -.. -.. .. automethod:: MasterView.get_settings + .. attribute:: MasterView.help_url + + If set, this defines the "default" help URL for all views provided by the + master. Default value for this is simply ``None`` which would mean the + Help button is not shown at all. Note that the master may choose to + override this for certain views, if so that should be done within + :meth:`get_help_url()`. + + +Methods to Override +------------------- + +The following is a list of methods which you can override when defining your +subclass. + + .. .. automethod:: MasterView.get_settings + + .. automethod:: MasterView.get_help_url diff --git a/tailbone/views/master.py b/tailbone/views/master.py index fbd160ee..8f08c4db 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -107,6 +107,7 @@ class MasterView(View): use_index_links = False has_versions = False + help_url = None labels = {'uuid': "UUID"} @@ -1693,7 +1694,18 @@ class MasterView(View): return self.request.route_url('{}.{}'.format(route_prefix, action), **kw) def get_help_url(self): - return getattr(self, 'help_url', None) + """ + May return a "help URL" if applicable. Default behavior is to simply + return the value of :attr:`help_url` (regardless of which view is in + effect), which in turn defaults to ``None``. If an actual URL is + returned, then a Help button will be shown in the page header; + otherwise it is not shown. + + This method is invoked whenever a template is rendered for a response, + so if you like you can return a different help URL depending on which + type of CRUD view is in effect, etc. + """ + return self.help_url def render_to_response(self, template, data, mobile=False): """ From 210508480eac09a3550cc033bd5d6d941f0deef0 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 21 May 2018 16:16:12 -0500 Subject: [PATCH 0796/3196] Add "Receive 1 CS" button for better efficiency in mobile receiving --- tailbone/static/js/tailbone.mobile.js | 34 --------------- .../static/js/tailbone.mobile.receiving.js | 42 +++++++++++++++++++ .../templates/mobile/receiving/view_row.mako | 2 + 3 files changed, 44 insertions(+), 34 deletions(-) diff --git a/tailbone/static/js/tailbone.mobile.js b/tailbone/static/js/tailbone.mobile.js index 04c1f64e..2423c9f6 100644 --- a/tailbone/static/js/tailbone.mobile.js +++ b/tailbone/static/js/tailbone.mobile.js @@ -185,40 +185,6 @@ $(document).on('change', 'fieldset.receiving-mode input[name="mode"]', function( }); -// handle receiving action buttons -$(document).on('click', '.receiving-actions button', function() { - var action = $(this).data('action'); - var form = $(this).parents('form:first'); - var uom = form.find('[name="keypad-uom"]:checked').val(); - var mode = form.find('[name="mode"]:checked').val(); - var qty = form.find('.keypad-quantity').text(); - if (action == 'add' || action == 'subtract') { - if (qty != '0') { - if (action == 'subtract') { - qty = '-' + qty; - } - - if (uom == 'CS') { - form.find('[name="cases"]').val(qty); - } else { // units - form.find('[name="units"]').val(qty); - } - - if (action == 'add' && mode == 'expired') { - var expiry = form.find('input[name="expiration_date"]'); - if (! /^\d{4}-\d{2}-\d{2}$/.test(expiry.val())) { - alert("Please enter a valid expiration date."); - expiry.focus(); - return; - } - } - - form.submit(); - } - } -}); - - // handle inventory save button $(document).on('click', '.inventory-actions button.save', function() { var form = $(this).parents('form:first'); diff --git a/tailbone/static/js/tailbone.mobile.receiving.js b/tailbone/static/js/tailbone.mobile.receiving.js index 45341a55..3786d29f 100644 --- a/tailbone/static/js/tailbone.mobile.receiving.js +++ b/tailbone/static/js/tailbone.mobile.receiving.js @@ -32,3 +32,45 @@ $(document).on('click', 'form.receiving-update #delete-receiving-row', function( form.find('input[name="delete_row"]').val('true'); form.submit(); }); + + +// handle receiving action buttons +$(document).on('click', 'form.receiving-update .receiving-actions button', function() { + var action = $(this).data('action'); + var form = $(this).parents('form:first'); + var uom = form.find('[name="keypad-uom"]:checked').val(); + var mode = form.find('[name="mode"]:checked').val(); + var qty = form.find('.keypad-quantity').text(); + if (action == 'add' || action == 'subtract') { + if (qty != '0') { + if (action == 'subtract') { + qty = '-' + qty; + } + + if (uom == 'CS') { + form.find('[name="cases"]').val(qty); + } else { // units + form.find('[name="units"]').val(qty); + } + + if (action == 'add' && mode == 'expired') { + var expiry = form.find('input[name="expiration_date"]'); + if (! /^\d{4}-\d{2}-\d{2}$/.test(expiry.val())) { + alert("Please enter a valid expiration date."); + expiry.focus(); + return; + } + } + + form.submit(); + } + } +}); + + +$(document).on('click', 'form.receiving-update .receive-one-case', function() { + var form = $(this).parents('form:first'); + form.find('[name="mode"]').val('received'); + form.find('[name="cases"]').val('1'); + form.submit(); +}); diff --git a/tailbone/templates/mobile/receiving/view_row.mako b/tailbone/templates/mobile/receiving/view_row.mako index 3d6aafdc..74b6faac 100644 --- a/tailbone/templates/mobile/receiving/view_row.mako +++ b/tailbone/templates/mobile/receiving/view_row.mako @@ -67,6 +67,8 @@ ${h.hidden('cases')} ${h.hidden('units')} + + ${keypad(unit_uom, uom)} From b0e8f7d9851d6aa32df12e4749c7104bbfd7feab Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 22 May 2018 13:54:50 -0500 Subject: [PATCH 0797/3196] Various changes to support current receiving workflows i.e. for sake of truck dump, adding child from invoice etc. --- tailbone/forms/core.py | 7 ++ tailbone/templates/master/delete.mako | 2 +- tailbone/templates/master/edit.mako | 2 +- tailbone/templates/master/index.mako | 2 +- tailbone/templates/master/view.mako | 2 +- tailbone/views/master.py | 26 +++-- tailbone/views/purchasing/receiving.py | 156 ++++++++++++++++++++----- 7 files changed, 153 insertions(+), 44 deletions(-) diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index 4ffc73d3..8401729a 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.py @@ -605,6 +605,9 @@ class Form(object): else: self.enums.pop(key, None) + def get_enum(self, key): + return self.enums.get(key) + def set_renderer(self, key, renderer): if renderer is None: if key in self.renderers: @@ -810,6 +813,10 @@ class Form(object): except TypeError: return getattr(record, field_name, None) + # TODO: is this always safe to do? + elif self.defaults and field_name in self.defaults: + return self.defaults[field_name] + def validate(self, *args, **kwargs): if kwargs.pop('newstyle', False): # yay, new behavior! diff --git a/tailbone/templates/master/delete.mako b/tailbone/templates/master/delete.mako index 85892e35..da247e5c 100644 --- a/tailbone/templates/master/delete.mako +++ b/tailbone/templates/master/delete.mako @@ -25,7 +25,7 @@ % if master.editable and request.has_perm('{}.edit'.format(permission_prefix)):
    • ${h.link_to("Edit this {}".format(model_title), action_url('edit', instance))}
    • % endif - % if master.creatable and request.has_perm('{}.create'.format(permission_prefix)): + % if master.creatable and master.show_create_link and request.has_perm('{}.create'.format(permission_prefix)):
    • ${h.link_to("Create a new {}".format(model_title), url('{}.create'.format(route_prefix)))}
    • % endif diff --git a/tailbone/templates/master/edit.mako b/tailbone/templates/master/edit.mako index 6d4a9a60..4a93f8a7 100644 --- a/tailbone/templates/master/edit.mako +++ b/tailbone/templates/master/edit.mako @@ -27,7 +27,7 @@ % if master.deletable and instance_deletable and request.has_perm('{}.delete'.format(permission_prefix)):
    • ${h.link_to("Delete this {}".format(model_title), action_url('delete', instance))}
    • % endif - % if master.creatable and request.has_perm('{}.create'.format(permission_prefix)): + % if master.creatable and master.show_create_link and request.has_perm('{}.create'.format(permission_prefix)):
    • ${h.link_to("Create a new {}".format(model_title), url('{}.create'.format(route_prefix)))}
    • % endif diff --git a/tailbone/templates/master/index.mako b/tailbone/templates/master/index.mako index 8a9a9d7a..56f8c4f6 100644 --- a/tailbone/templates/master/index.mako +++ b/tailbone/templates/master/index.mako @@ -67,7 +67,7 @@ % if master.results_downloadable_xlsx and request.has_perm('{}.results_xlsx'.format(permission_prefix)):
    • ${h.link_to("Download results as XLSX", url('{}.results_xlsx'.format(route_prefix)))}
    • % endif - % if master.creatable and request.has_perm('{}.create'.format(permission_prefix)): + % if master.creatable and master.show_create_link and request.has_perm('{}.create'.format(permission_prefix)): % if master.creates_multiple:
    • ${h.link_to("Create new {}".format(model_title_plural), url('{}.create'.format(route_prefix)))}
    • % else: diff --git a/tailbone/templates/master/view.mako b/tailbone/templates/master/view.mako index 7456c9a3..a584bed5 100644 --- a/tailbone/templates/master/view.mako +++ b/tailbone/templates/master/view.mako @@ -54,7 +54,7 @@ % if master.deletable and instance_deletable and request.has_perm('{}.delete'.format(permission_prefix)):
    • ${h.link_to("Delete this {}".format(model_title), action_url('delete', instance), class_='delete-instance')}
    • % endif - % if master.creatable and request.has_perm('{}.create'.format(permission_prefix)): + % if master.creatable and master.show_create_link and request.has_perm('{}.create'.format(permission_prefix)): % if master.creates_multiple:
    • ${h.link_to("Create new {}".format(model_title_plural), url('{}.create'.format(route_prefix)))}
    • % else: diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 8f08c4db..e1c93f39 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -72,6 +72,7 @@ class MasterView(View): results_downloadable_csv = False results_downloadable_xlsx = False creatable = True + show_create_link = True viewable = True editable = True deletable = True @@ -601,12 +602,13 @@ class MasterView(View): def render_mobile_row_listitem(self, obj, i): return obj - def create(self): + def create(self, form=None): """ View for creating a new model record. """ self.creating = True - form = self.make_form(self.get_model_class()) + if form is None: + form = self.make_form(self.get_model_class()) if self.request.method == 'POST': if self.validate_form(form): # let save_create_form() return alternate object if necessary @@ -2200,7 +2202,7 @@ class MasterView(View): except os.error: return 0 - def make_form(self, instance=None, factory=None, fields=None, schema=None, **kwargs): + def make_form(self, instance=None, factory=None, fields=None, schema=None, make_kwargs=None, configure=None, **kwargs): """ Creates a new form for the given model class/instance """ @@ -2210,15 +2212,19 @@ class MasterView(View): fields = self.get_form_fields() if schema is None: schema = self.make_form_schema() + if make_kwargs is None: + make_kwargs = self.make_form_kwargs + if configure is None: + configure = self.configure_form # TODO: SQLAlchemy class instance is assumed *unless* we get a dict # (seems like we should be smarter about this somehow) # if not self.creating and not isinstance(instance, dict): if not self.creating: kwargs['model_instance'] = instance - kwargs = self.make_form_kwargs(**kwargs) + kwargs = make_kwargs(**kwargs) form = factory(fields, schema, **kwargs) - self.configure_form(form) + configure(form) return form def get_form_fields(self): @@ -2271,12 +2277,10 @@ class MasterView(View): self.set_labels(form) def validate_form(self, form): - controls = self.request.POST.items() - try: - self.form_deserialized = form.validate(controls) - except deform.ValidationFailure: - return False - return True + if form.validate(newstyle=True): + self.form_deserialized = form.validated + return True + return False def objectify(self, form, data): obj = form.schema.objectify(data, context=form.model_instance) diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index 6c754df8..7350cf79 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -35,7 +35,7 @@ from rattail import pod from rattail.db import model, api from rattail.gpc import GPC from rattail.time import localtime -from rattail.util import pretty_quantity, prettify +from rattail.util import pretty_quantity, prettify, OrderedDict from rattail.vendors.invoices import iter_invoice_parsers, require_invoice_parser import colander @@ -131,6 +131,7 @@ class ReceivingBatchView(PurchasingBatchView): form_fields = [ 'id', 'batch_type', + 'description', 'store', 'vendor', 'truck_dump', @@ -234,12 +235,9 @@ class ReceivingBatchView(PurchasingBatchView): # batch_type if self.creating: - batch_type_values = [ + f.set_enum('batch_type', OrderedDict([ ('from_scratch', "New from Scratch"), - ] - if self.allow_truck_dump: - batch_type_values.append(('truck_dump', "Invoice for Truck Dump")) - f.set_widget('batch_type', forms.widgets.JQuerySelectWidget(values=batch_type_values)) + ])) else: f.remove_field('batch_type') @@ -285,6 +283,12 @@ class ReceivingBatchView(PurchasingBatchView): else: f.remove_field('truck_dump_batch') + # truck_dump_vendor + if self.creating: + f.set_label('truck_dump_vendor', "Vendor") + f.set_readonly('truck_dump_vendor') + f.set_renderer('truck_dump_vendor', self.render_truck_dump_vendor) + else: f.remove_fields('truck_dump', 'truck_dump_children', @@ -341,8 +345,11 @@ class ReceivingBatchView(PurchasingBatchView): if batch_type == 'from_scratch': kwargs.pop('truck_dump_batch', None) kwargs.pop('truck_dump_batch_uuid', None) - elif batch_type == 'truck_dump': - pass + elif batch_type.startswith('truck_dump_child'): + truck_dump = self.get_instance() + kwargs['store'] = truck_dump.store + kwargs['vendor'] = truck_dump.vendor + kwargs['truck_dump_batch'] = truck_dump else: raise NotImplementedError return kwargs @@ -367,16 +374,91 @@ class ReceivingBatchView(PurchasingBatchView): url = self.request.route_url('receiving.view', uuid=truck_dump.uuid) return tags.link_to(text, url) + def render_truck_dump_vendor(self, batch, field): + truck_dump = self.get_instance() + vendor = truck_dump.vendor + text = "({}) {}".format(vendor.id, vendor.name) + url = self.request.route_url('vendors.view', uuid=vendor.uuid) + return tags.link_to(text, url) + def render_truck_dump_children(self, batch, field): + contents = [] children = batch.truck_dump_children - if not children: + if children: + items = [] + for child in children: + text = six.text_type(child) + url = self.request.route_url('receiving.view', uuid=child.uuid) + items.append(HTML.tag('li', c=[tags.link_to(text, url)])) + contents.append(HTML.tag('ul', c=items)) + if batch.complete and not batch.executed: + buttons = self.make_truck_dump_child_buttons(batch) + if buttons: + buttons = HTML.literal(' ').join(buttons) + contents.append(HTML.tag('div', class_='buttons', c=[buttons])) + if not contents: return "" - items = [] - for child in children: - text = six.text_type(child) - url = self.request.route_url('receiving.view', uuid=child.uuid) - items.append(HTML.tag('li', c=[tags.link_to(text, url)])) - return HTML.tag('ul', c=items) + return HTML.tag('div', c=contents) + + def make_truck_dump_child_buttons(self, batch): + return [ + tags.link_to("Add from Invoice File", self.get_action_url('add_child_from_invoice', batch), class_='button autodisable'), + ] + + def add_child_from_invoice(self): + """ + View for adding a child batch to a truck dump, from invoice file. + """ + batch = self.get_instance() + if not batch.truck_dump: + self.request.session.flash("Batch is not a truck dump: {}".format(batch)) + return self.redirect(self.get_action_url('view', batch)) + if batch.executed: + self.request.session.flash("Batch has already been executed: {}".format(batch)) + return self.redirect(self.get_action_url('view', batch)) + if not batch.complete: + self.request.session.flash("Batch is not marked as complete: {}".format(batch)) + return self.redirect(self.get_action_url('view', batch)) + self.creating = True + form = self.make_child_from_invoice_form(self.get_model_class()) + return self.create(form=form) + + def make_child_from_invoice_form(self, instance, **kwargs): + """ + Creates a new form for the given model class/instance + """ + kwargs['configure'] = self.configure_child_from_invoice_form + return self.make_form(instance=instance, **kwargs) + + def configure_child_from_invoice_form(self, f): + assert self.creating + truck_dump = self.get_instance() + + self.configure_form(f) + + f.set_fields([ + 'batch_type', + 'truck_dump_parent', + 'truck_dump_vendor', + 'invoice_file', + 'invoice_parser_key', + 'description', + 'notes', + ]) + + # batch_type + f.set_widget('batch_type', forms.widgets.ReadonlyWidget()) + f.set_default('batch_type', 'truck_dump_child_from_invoice') + + # truck_dump_batch_uuid + f.set_readonly('truck_dump_parent') + f.set_renderer('truck_dump_parent', self.render_truck_dump_parent) + + def render_truck_dump_parent(self, batch, field): + truck_dump = self.get_instance() + text = six.text_type(truck_dump) + url = self.request.route_url('receiving.view', uuid=truck_dump.uuid) + return tags.link_to(text, url) def render_mobile_listitem(self, batch, i): title = "({}) {} for ${:0,.2f} - {}, {}".format( @@ -565,9 +647,10 @@ class ReceivingBatchView(PurchasingBatchView): } if self.request.has_perm('{}.create_row'.format(permission_prefix)): - update_form = forms.Form(schema=MobileReceivingForm(), request=self.request) + schema = MobileReceivingForm().bind(session=self.Session()) + update_form = forms.Form(schema=schema, request=self.request) if update_form.validate(newstyle=True): - row = self.Session.merge(update_form.validated['row']) + row = self.Session.query(model.PurchaseBatchRow).get(update_form.validated['row']) # TODO: surely this (delete_row) should be split out to a separate view if update_form.validated['delete_row']: @@ -646,7 +729,7 @@ class ReceivingBatchView(PurchasingBatchView): return credit @classmethod - def defaults(cls, config): + def _receiving_defaults(cls, config): route_prefix = cls.get_route_prefix() url_prefix = cls.get_url_prefix() model_key = cls.get_model_key() @@ -657,21 +740,19 @@ class ReceivingBatchView(PurchasingBatchView): config.add_view(cls, attr='mobile_lookup', route_name='mobile.{}.lookup'.format(route_prefix), renderer='json', permission='{}.create_row'.format(permission_prefix)) + if cls.allow_truck_dump: + config.add_route('{}.add_child_from_invoice'.format(route_prefix), '{}/{{{}}}/add-child-from-invoice'.format(url_prefix, model_key)) + config.add_view(cls, attr='add_child_from_invoice', route_name='{}.add_child_from_invoice'.format(route_prefix), + permission='{}.create'.format(permission_prefix)) + + @classmethod + def defaults(cls, config): + cls._receiving_defaults(config) cls._purchasing_defaults(config) cls._batch_defaults(config) cls._defaults(config) -class PurchaseBatchRowType(forms.types.ObjectType): - model_class = model.PurchaseBatchRow - - def deserialize(self, node, cstruct): - row = super(PurchaseBatchRowType, self).deserialize(node, cstruct) - if row and row.batch.executed: - raise colander.Invalid(node, "Batch has already been executed") - return row - - class MobileNewReceivingBatch(colander.MappingSchema): vendor = colander.SchemaNode(forms.types.VendorType()) @@ -684,9 +765,26 @@ class MobileNewReceivingBatch(colander.MappingSchema): ])) +# TODO: this is a stopgap measure to fix an obvious bug, which exists when the +# session is not provided by the view at runtime (i.e. when it was instead +# being provided by the type instance, which was created upon app startup). +@colander.deferred +def valid_purchase_batch_row(node, kw): + session = kw['session'] + def validate(node, value): + row = session.query(model.PurchaseBatchRow).get(value) + if not row: + raise colander.Invalid(node, "Batch row not found") + if row.batch.executed: + raise colander.Invalid(node, "Batch has already been executed") + return row.uuid + return validate + + class MobileReceivingForm(colander.MappingSchema): - row = colander.SchemaNode(PurchaseBatchRowType()) + row = colander.SchemaNode(colander.String(), + validator=valid_purchase_batch_row) mode = colander.SchemaNode(colander.String(), validator=colander.OneOf([ From ecf7acc8007f131f4a60b1fc432d16860268d011 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 22 May 2018 15:31:31 -0500 Subject: [PATCH 0798/3196] Fix handling of 'filename' field when making new batch --- tailbone/views/batch/core.py | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index cf236c36..bcf33479 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -312,7 +312,7 @@ class BatchMasterView(MasterView): f.remove_field('complete') def save_create_form(self, form): - uploads = self.normalize_uploads(form, skip=['filename']) + uploads = self.normalize_uploads(form) self.before_create(form) session = self.Session() @@ -327,18 +327,10 @@ class BatchMasterView(MasterView): # obtain kwargs for making batch via handler, below kwargs = self.get_batch_kwargs(batch) - # TODO: this needs work yet surely... - if 'filename' in form.schema: - filedict = kwargs.pop('filename', None) - filepath = None - if filedict: - kwargs['filename'] = '' # null not allowed - tempdir = tempfile.mkdtemp() - filepath = os.path.join(tempdir, filedict['filename']) - tmpinfo = form.deform_form['filename'].widget.tmpstore.get(filedict['uid']) - tmpdata = tmpinfo['fp'].read() - with open(filepath, 'wb') as f: - f.write(tmpdata) + # TODO: this needs work yet surely...why is this an issue? + # treat 'filename' field specially, for some reason it can be a filedict? + if 'filename' in kwargs and not isinstance(kwargs['filename'], six.string_types): + kwargs['filename'] = '' # null not allowed # TODO: is this still necessary with colander? # destroy initial batch and re-make using handler From 37a21d93a19404c76ee897f220323f5773ff5745 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 22 May 2018 17:52:04 -0500 Subject: [PATCH 0799/3196] Add category name filter for products grid --- tailbone/views/products.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 554a3f8b..dad50a99 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -207,7 +207,6 @@ class ProductsView(MasterView): ProductCostCodeAny.product_uuid == model.Product.uuid) g.joiners['brand'] = lambda q: q.outerjoin(model.Brand) - g.joiners['family'] = lambda q: q.outerjoin(model.Family) g.joiners['department'] = lambda q: q.outerjoin(model.Department, model.Department.uuid == model.Product.department_uuid) g.joiners['subdepartment'] = lambda q: q.outerjoin(model.Subdepartment, @@ -231,7 +230,6 @@ class ProductsView(MasterView): g.filters['description'].default_verb = 'contains' g.filters['brand'] = g.make_filter('brand', model.Brand.name, default_active=True, default_verb='contains') - g.filters['family'] = g.make_filter('family', model.Family.name) g.filters['department'] = g.make_filter('department', model.Department.name, default_active=True, default_verb='contains') g.filters['subdepartment'] = g.make_filter('subdepartment', model.Subdepartment.name) @@ -242,6 +240,14 @@ class ProductsView(MasterView): g.filters['vendor_code'] = g.make_filter('vendor_code', ProductCostCode.code) g.filters['vendor_code_any'] = g.make_filter('vendor_code_any', ProductCostCodeAny.code) + # category + g.set_joiner('category', lambda q: q.outerjoin(model.Category)) + g.set_filter('category', model.Category.name) + + # family + g.set_joiner('family', lambda q: q.outerjoin(model.Family)) + g.set_filter('family', model.Family.name) + g.set_label('regular_price', "Reg. Price") g.set_joiner('regular_price', lambda q: q.outerjoin( self.RegularPrice, self.RegularPrice.uuid == model.Product.regular_price_uuid)) From 961e0e801d44d6a0094e80c515805c8a3599a510 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 22 May 2018 18:35:00 -0500 Subject: [PATCH 0800/3196] Increase allowed width for form labels --- 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 aee68e59..950ca82d 100644 --- a/tailbone/static/css/forms.css +++ b/tailbone/static/css/forms.css @@ -65,7 +65,7 @@ div.fieldset { .field-wrapper label { display: table-cell; vertical-align: top; - width: 15em; + width: 18em; font-weight: bold; padding-top: 2px; white-space: nowrap; From c9eeabecba2e5c0d2ddf580ae5e79f5610b04611 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 22 May 2018 20:18:47 -0500 Subject: [PATCH 0801/3196] Add `allow_zero_all` flag for inventory batch master defaults to true, but setting to false should disable "zero all" count mode --- tailbone/views/inventory.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tailbone/views/inventory.py b/tailbone/views/inventory.py index b10ade6f..f3252d57 100644 --- a/tailbone/views/inventory.py +++ b/tailbone/views/inventory.py @@ -94,6 +94,9 @@ class InventoryBatchView(BatchMasterView): mobile_creatable = True mobile_rows_creatable = True + # set to False to disable "zero all" batch count mode + allow_zero_all = True + labels = { 'mode': "Count Mode", } @@ -208,7 +211,7 @@ class InventoryBatchView(BatchMasterView): modes.pop(self.enum.INVENTORY_MODE_REPLACE, None) if hasattr(self.enum, 'INVENTORY_MODE_REPLACE_ADJUST'): modes.pop(self.enum.INVENTORY_MODE_REPLACE_ADJUST, None) - if not self.request.has_perm('{}.create.zero'.format(permission_prefix)): + if not self.allow_zero_all or not self.request.has_perm('{}.create.zero'.format(permission_prefix)): if hasattr(self.enum, 'INVENTORY_MODE_ZERO_ALL'): modes.pop(self.enum.INVENTORY_MODE_ZERO_ALL, None) return modes @@ -584,8 +587,9 @@ class InventoryBatchView(BatchMasterView): # extra perms for creating batches per "mode" config.add_tailbone_permission(permission_prefix, '{}.create.replace'.format(permission_prefix), "Create new {} with 'replace' mode".format(model_title)) - config.add_tailbone_permission(permission_prefix, '{}.create.zero'.format(permission_prefix), - "Create new {} with 'zero' mode".format(model_title)) + if cls.allow_zero_all: + config.add_tailbone_permission(permission_prefix, '{}.create.zero'.format(permission_prefix), + "Create new {} with 'zero' mode".format(model_title)) # row UPC lookup, for desktop config.add_route('{}.desktop_lookup'.format(route_prefix), '{}/{{{}}}/desktop-form/lookup'.format(url_prefix, model_key)) From a4095b30f75429a604e5be93dc55af63d489d74e Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 22 May 2018 20:29:07 -0500 Subject: [PATCH 0802/3196] Add basic forms API doc --- docs/api/forms.rst | 9 +++++++++ docs/index.rst | 1 + 2 files changed, 10 insertions(+) create mode 100644 docs/api/forms.rst diff --git a/docs/api/forms.rst b/docs/api/forms.rst new file mode 100644 index 00000000..bdeb5cf6 --- /dev/null +++ b/docs/api/forms.rst @@ -0,0 +1,9 @@ + +``tailbone.forms`` +================== + +.. automodule:: tailbone.forms + :members: + +.. autoclass:: tailbone.forms.Form + :members: diff --git a/docs/index.rst b/docs/index.rst index ebfad998..f26d4019 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -42,6 +42,7 @@ Package API: .. toctree:: :maxdepth: 1 + api/forms api/grids api/subscribers api/views/batch From 218ac221e518710fd86a977cf2b3a155fbfb1d0e Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 23 May 2018 13:06:49 -0500 Subject: [PATCH 0803/3196] Add buttons to toggle batch 'complete' flag when viewing batch --- tailbone/templates/batch/view.mako | 4 +++ tailbone/views/batch/core.py | 46 ++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/tailbone/templates/batch/view.mako b/tailbone/templates/batch/view.mako index bdc63e6a..f67117b8 100644 --- a/tailbone/templates/batch/view.mako +++ b/tailbone/templates/batch/view.mako @@ -35,6 +35,10 @@ .grid-wrapper { margin-top: 10px; } + + .complete form { + display: inline; + } diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index bcf33479..59f44fcf 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -51,6 +51,7 @@ from tailbone import forms, grids from tailbone.db import Session from tailbone.views import MasterView from tailbone.progress import SessionProgress +from tailbone.util import csrf_token log = logging.getLogger(__name__) @@ -240,6 +241,9 @@ class BatchMasterView(MasterView): f.set_renderer('status_code', self.make_status_renderer(self.model_class.STATUS)) f.set_label('status_code', "Status") + # complete + f.set_renderer('complete', self.render_complete) + # executed f.set_readonly('executed') f.set_readonly('executed_by') @@ -281,6 +285,30 @@ class BatchMasterView(MasterView): return status_code_text return render_status + def render_complete(self, batch, field): + content = [HTML.literal("Yes" if batch.complete else "No")] + + if not batch.executed: + if self.request.has_perm('{}.edit'.format(self.get_permission_prefix())): + + if batch.complete: + label = "Mark as NOT Complete" + value = 'false' + else: + label = "Mark as Complete" + value = 'true' + + content.extend([ + HTML.literal('   '), + tags.form(self.get_action_url('toggle_complete', batch), class_='autodisable'), + csrf_token(self.request), + tags.hidden('complete', value=value), + tags.submit('submit', label), + tags.end_form(), + ]) + + return HTML.tag('div', c=content) + def render_user(self, batch, field): user = getattr(batch, field) if not user: @@ -413,6 +441,14 @@ class BatchMasterView(MasterView): kwargs['batch'] = batch return kwargs + def toggle_complete(self): + batch = self.get_instance() + if not batch.executed: + form = forms.Form(schema=ToggleComplete(), request=self.request) + if form.validate(newstyle=True): + batch.complete = form.validated['complete'] + return self.redirect(self.get_action_url('view', batch)) + def mobile_mark_complete(self): batch = self.get_instance() batch.complete = True @@ -1151,6 +1187,11 @@ class BatchMasterView(MasterView): config.add_tailbone_permission(permission_prefix, '{}.delete_rows'.format(permission_prefix), "Bulk-delete data rows from {}".format(model_title)) + # toggle complete + config.add_route('{}.toggle_complete'.format(route_prefix), '{}/{{{}}}/toggle-complete'.format(url_prefix, model_key)) + config.add_view(cls, attr='toggle_complete', route_name='{}.toggle_complete'.format(route_prefix), + permission='{}.edit'.format(permission_prefix)) + # mobile mark complete config.add_route('mobile.{}.mark_complete'.format(route_prefix), '/mobile{}/{{{}}}/mark-complete'.format(url_prefix, model_key)) config.add_view(cls, attr='mobile_mark_complete', route_name='mobile.{}.mark_complete'.format(route_prefix), @@ -1214,6 +1255,11 @@ class FileBatchMasterView(BatchMasterView): return self.render_file_field(path, url) +class ToggleComplete(colander.MappingSchema): + + complete = colander.SchemaNode(colander.Boolean()) + + class MobileBatchStatusFilter(grids.filters.MobileFilter): value_choices = ['pending', 'complete', 'executed', 'all'] From 6d27d0cfba6c3089d53f2b0b644107747743d600 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 23 May 2018 13:11:32 -0500 Subject: [PATCH 0804/3196] Hide "create new row" link for batches which are marked complete --- tailbone/views/batch/core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index 59f44fcf..b798fdc6 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -551,7 +551,7 @@ class BatchMasterView(MasterView): f.set_label('status_code', "Status") def make_default_row_grid_tools(self, batch): - if self.rows_creatable and not batch.executed: + if self.rows_creatable and not batch.executed and not batch.complete: permission_prefix = self.get_permission_prefix() if self.request.has_perm('{}.create_row'.format(permission_prefix)): link = tags.link_to("Create a new {}".format(self.get_row_model_title()), From 62dca3d0b06cdbc58f974772e3728d75d80d8349 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 23 May 2018 13:28:11 -0500 Subject: [PATCH 0805/3196] Only show "toggle complete" buttons when viewing batch i.e. just show simple value for e.g. delete batch page --- tailbone/views/batch/core.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index b798fdc6..5158624f 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -242,7 +242,8 @@ class BatchMasterView(MasterView): f.set_label('status_code', "Status") # complete - f.set_renderer('complete', self.render_complete) + if self.viewing: + f.set_renderer('complete', self.render_complete) # executed f.set_readonly('executed') @@ -289,7 +290,8 @@ class BatchMasterView(MasterView): content = [HTML.literal("Yes" if batch.complete else "No")] if not batch.executed: - if self.request.has_perm('{}.edit'.format(self.get_permission_prefix())): + permission_prefix = self.get_permission_prefix() + if self.request.has_perm('{}.edit'.format(permission_prefix)): if batch.complete: label = "Mark as NOT Complete" From 57c2a7981f6ec24466b954e6ff5f990d9b4a8c8a Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 23 May 2018 14:13:28 -0500 Subject: [PATCH 0806/3196] Fix some things for inventory batch views --- tailbone/views/batch/core.py | 4 +++- tailbone/views/inventory.py | 3 --- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index 5158624f..d7c3794c 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -445,7 +445,9 @@ class BatchMasterView(MasterView): def toggle_complete(self): batch = self.get_instance() - if not batch.executed: + if batch.executed: + self.request.session.flash("Request ignored, since batch has already been executed") + else: form = forms.Form(schema=ToggleComplete(), request=self.request) if form.validate(newstyle=True): batch.complete = form.validated['complete'] diff --git a/tailbone/views/inventory.py b/tailbone/views/inventory.py index f3252d57..976ed823 100644 --- a/tailbone/views/inventory.py +++ b/tailbone/views/inventory.py @@ -194,9 +194,6 @@ class InventoryBatchView(BatchMasterView): batch.created_by, localtime(self.request.rattail_config, batch.created, from_utc=True).strftime('%Y-%m-%d')) - def editable_instance(self, batch): - return True - def mutable_batch(self, batch): return not batch.executed and not batch.complete and batch.mode != self.enum.INVENTORY_MODE_ZERO_ALL From 54bfafdbfeb4c3a6bf8bb761f23c18f9eecbec63 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 23 May 2018 14:48:17 -0500 Subject: [PATCH 0807/3196] Add way to prevent "case" entries for inventory adjustment batch --- .../batch/inventory/desktop_form.mako | 28 +++++++-- .../mobile/batch/inventory/view_row.mako | 21 ++++--- tailbone/templates/mobile/keypad.mako | 4 +- tailbone/views/inventory.py | 57 ++++++++++++++----- 4 files changed, 80 insertions(+), 30 deletions(-) diff --git a/tailbone/templates/batch/inventory/desktop_form.mako b/tailbone/templates/batch/inventory/desktop_form.mako index 828ab33a..78740003 100644 --- a/tailbone/templates/batch/inventory/desktop_form.mako +++ b/tailbone/templates/batch/inventory/desktop_form.mako @@ -9,14 +9,20 @@ @@ -60,9 +62,11 @@
      + % if can_cancel: + % endif @@ -86,10 +90,14 @@ } else if (data.complete || data.maximum) { $('#message').html(data.message); $('#total').html('('+data.maximum_display+' total)'); + % if can_cancel: $('#cancel button').show(); + % endif if (data.complete) { clearInterval(updater); + % if can_cancel: $('#cancel button').hide(); + % endif $('#total').html('done!'); $('#complete').css('width', '100%'); $('#remaining').hide(); diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index d7c3794c..150dd560 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -27,18 +27,24 @@ Base views for maintaining "new-style" batches. from __future__ import unicode_literals, absolute_import import os +import sys import datetime import logging +import socket +import subprocess import tempfile from six import StringIO +import json import six import sqlalchemy as sa from sqlalchemy import orm from rattail.db import model, Session as RattailSession +from rattail.db.util import short_session from rattail.threads import Thread from rattail.util import load_object, prettify +from rattail.progress import SocketProgress import colander import deform @@ -57,6 +63,10 @@ from tailbone.util import csrf_token log = logging.getLogger(__name__) +class EverythingComplete(Exception): + pass + + class BatchMasterView(MasterView): """ Base class for all "batch master" views. @@ -696,43 +706,225 @@ class BatchMasterView(MasterView): return self.handler.get_execute_title(batch) return "Execute Batch" + def handler_action(self, batch, action, **kwargs): + """ + View which will attempt to refresh all data for the batch. What + exactly this means will depend on the type of batch etc. + """ + route_prefix = self.get_route_prefix() + permission_prefix = self.get_permission_prefix() + + user = self.request.user + user_uuid = user.uuid if user else None + username = user.username if user else None + + key = '{}.{}'.format(self.model_class.__tablename__, action) + progress = SessionProgress(self.request, key) + + # must ensure versioning is *disabled* during action, if handler says so + allow_versioning = self.handler.allow_versioning(action) + if not allow_versioning and self.rattail_config.versioning_enabled(): + can_cancel = False + + # make socket for progress thread to listen to action thread + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.bind(('127.0.0.1', 0)) + sock.listen(1) + port = sock.getsockname()[1] + + # launch thread to monitor progress + success_url = self.get_action_url('view', batch) + thread = Thread(target=self.progress_thread, args=(sock, success_url, progress)) + thread.start() + + # launch thread to invoke handler action + thread = Thread(target=self.action_subprocess_thread, args=(batch.uuid, port, username, action, progress)) + thread.start() + + else: # either versioning is disabled, or handler doesn't mind + can_cancel = True + + # launch thread to populate batch; that will update session progress directly + target = getattr(self, '{}_thread'.format(action)) + thread = Thread(target=target, args=(batch.uuid, user_uuid, progress), kwargs=kwargs) + thread.start() + + return self.render_progress(progress, { + 'can_cancel': can_cancel, + 'cancel_url': self.get_action_url('view', batch), + 'cancel_msg': "{} of batch was canceled.".format(action.capitalize()), + }) + + def progress_thread(self, sock, success_url, progress): + """ + This method is meant to be used as a thread target. Its job is to read + progress data from ``connection`` and update the session progress + accordingly. When a final "process complete" indication is read, the + socket will be closed and the thread will end. + """ + while True: + try: + self.process_progress(sock, progress) + except EverythingComplete: + break + + # close server socket + sock.close() + + # finalize session progress + progress.session.load() + progress.session['complete'] = True + if callable(success_url): + success_url = success_url() + progress.session['success_url'] = success_url + progress.session.save() + + def process_progress(self, sock, progress): + """ + This method will accept a client connection on the given socket, and + then update the given progress object according to data written by the + client. + """ + connection, client_address = sock.accept() + active_progress = None + + # TODO: make this configurable? + suffix = "\n\n.".encode('utf_8') + data = b'' + + # listen for progress info, update session progress as needed + while True: + + # accumulate data bytestring until we see the suffix + byte = connection.recv(1) + data += byte + if data.endswith(suffix): + + # strip suffix, interpret data as JSON + data = data[:-len(suffix)] + data = json.loads(data) + + if data.get('everything_complete'): + if active_progress: + active_progress.finish() + raise EverythingComplete + + elif data.get('process_complete'): + active_progress.finish() + active_progress = None + break + + elif 'value' in data: + if not active_progress: + active_progress = progress(data['message'], data['maximum']) + active_progress.update(data['value']) + + # reset data buffer + data = b'' + + # close client connection + connection.close() + + def launch_subprocess(self, port=None, username=None, + command='rattail', command_args=None, + subcommand=None, subcommand_args=None): + + # construct command + cmd = [os.path.join(sys.prefix, 'bin/{}'.format(command))] + for path in self.rattail_config.files_read: + cmd.extend(['--config', path]) + if username: + cmd.extend(['--runas', username]) + if command_args: + cmd.extend(command_args) + cmd.extend([ + '--progress', + '--progress-socket', '127.0.0.1:{}'.format(port), + subcommand, + ]) + if subcommand_args: + cmd.extend(subcommand_args) + + # run command in subprocess + subprocess.check_call(cmd) + + def action_subprocess_thread(self, batch_uuid, port, username, action, progress): + """ + This method is sort of an alternative thread target for batch actions, + to be used in the event versioning is enabled for the main process but + the handler says versioning must be avoided during the action. It must + launch a separate process with versioning disabled in order to act on + the batch. + """ + # invoke command to act on batch in separate process + try: + self.launch_subprocess(port=port, username=username, + command='rattail', + command_args=[ + '--no-versioning', + ], + subcommand='{}-batch'.format(action), + subcommand_args=[ + self.handler.batch_key, + batch_uuid, + ]) + except Exception as error: + log.warning("%s of '%s' batch failed: %s", action, self.handler.batch_key, batch_uuid, exc_info=True) + + # TODO: write error info to socket + + # if progress: + # progress.session.load() + # progress.session['error'] = True + # progress.session['error_msg'] = "Batch population failed: {} - {}".format(error.__class__.__name__, error) + # progress.session.save() + + return + + models = getattr(self.handler, 'version_catchup_{}'.format(action), None) + if models: + self.catchup_versions(port, batch_uuid, username, *models) + + suffix = "\n\n.".encode('utf_8') + cxn = socket.create_connection(('127.0.0.1', port)) + cxn.send(json.dumps({ + 'everything_complete': True, + })) + cxn.send(suffix) + cxn.close() + + def catchup_versions(self, port, batch_uuid, username, *models): + with short_session() as s: + batch = s.query(self.model_class).get(batch_uuid) + batch_id = batch.id_str + description = six.text_type(batch) + + self.launch_subprocess( + port=port, username=username, + command='rattail', + subcommand='import-versions', + subcommand_args=[ + '--comment', + "version catch-up for '{}' batch {}: {}".format(self.handler.batch_key, batch_id, description), + ] + list(models)) + def prefill(self): """ View which will attempt to prefill all data for the batch. What exactly this means will depend on the type of batch etc. """ batch = self.get_instance() - route_prefix = self.get_route_prefix() - permission_prefix = self.get_permission_prefix() + return self.handler_action(batch, 'populate') - # showing progress requires a separate thread; start that first - key = '{}.prefill'.format(route_prefix) - progress = SessionProgress(self.request, key) - thread = Thread(target=self.prefill_thread, args=(batch.uuid, progress)) - thread.start() - - # Send user to progress page. - kwargs = { - 'cancel_url': self.get_action_url('view', batch), - 'cancel_msg': "Batch prefill was canceled.", - } - - # TODO: This seems hacky...it exists for (only) one specific scenario. - if not self.request.has_perm('{}.view'.format(permission_prefix)): - kwargs['cancel_url'] = self.request.route_url('{}.create'.format(route_prefix)) - - return self.render_progress(progress, kwargs) - - def prefill_thread(self, batch_uuid, progress): + def populate_thread(self, batch_uuid, user_uuid, progress, **kwargs): """ - Thread target for prefilling batch data with progress indicator. + Thread target for populating batch data with progress indicator. """ # mustn't use tailbone web session here session = RattailSession() batch = session.query(self.model_class).get(batch_uuid) try: - self.handler.populate(batch, progress=progress) - self.handler.refresh_batch_status(batch) + self.handler.do_populate(batch, progress=progress) except Exception as error: session.rollback() log.warning("batch population failed: %s", batch, exc_info=True) @@ -761,56 +953,9 @@ class BatchMasterView(MasterView): exactly this means will depend on the type of batch etc. """ batch = self.get_instance() - route_prefix = self.get_route_prefix() - permission_prefix = self.get_permission_prefix() + return self.handler_action(batch, 'refresh') - # TODO: deprecate / remove this - cognizer = self.request.user - if not cognizer: - uuid = self.request.session.pop('late_login_user', None) - cognizer = Session.query(model.User).get(uuid) if uuid else None - - # TODO: refresh should probably always imply/use progress - # If handler doesn't declare the need for progress indicator, things - # are nice and simple. - if not getattr(self.handler, 'show_progress', True): - self.refresh_data(Session, batch, cognizer=cognizer) - self.request.session.flash("Batch data has been refreshed.") - - # TODO: This seems hacky...it exists for (only) one specific scenario. - if not self.request.has_perm('{}.view'.format(permission_prefix)): - return self.redirect(self.request.route_url('{}.create'.format(route_prefix))) - - return self.redirect(self.get_action_url('view', batch)) - - # Showing progress requires a separate thread; start that first. - key = '{}.refresh'.format(self.model_class.__tablename__) - progress = SessionProgress(self.request, key) - # success_url = self.request.route_url('vendors.scangenius.create') if not self.request.user else None - - # TODO: This seems hacky...it exists for (only) one specific scenario. - success_url = None - if not self.request.user: - success_url = self.request.route_url('{}.create'.format(route_prefix)) - - thread = Thread(target=self.refresh_thread, args=(batch.uuid, progress, - cognizer.uuid if cognizer else None, - success_url)) - thread.start() - - # Send user to progress page. - kwargs = { - 'cancel_url': self.get_action_url('view', batch), - 'cancel_msg': "Batch refresh was canceled.", - } - - # TODO: This seems hacky...it exists for (only) one specific scenario. - if not self.request.has_perm('{}.view'.format(permission_prefix)): - kwargs['cancel_url'] = self.request.route_url('{}.create'.format(route_prefix)) - - return self.render_progress(progress, kwargs) - - def refresh_data(self, session, batch, cognizer=None, progress=None): + def refresh_data(self, session, batch, user, progress=None): """ Instruct the batch handler to refresh all data for the batch. """ @@ -821,9 +966,10 @@ class BatchMasterView(MasterView): batch.cognized_by = cognizer or session.merge(self.request.user) else: # the future - self.handler.refresh(batch, progress=progress) + user = user or session.merge(self.request.user) + self.handler.do_refresh(batch, user, progress=progress) - def refresh_thread(self, batch_uuid, progress=None, cognizer_uuid=None, success_url=None): + def refresh_thread(self, batch_uuid, user_uuid, progress, **kwargs): """ Thread target for refreshing batch data with progress indicator. """ @@ -832,9 +978,9 @@ class BatchMasterView(MasterView): # transaction binding etc. session = RattailSession() batch = session.query(self.model_class).get(batch_uuid) - cognizer = session.query(model.User).get(cognizer_uuid) if cognizer_uuid else None + cognizer = session.query(model.User).get(user_uuid) if user_uuid else None try: - self.refresh_data(session, batch, cognizer=cognizer, progress=progress) + self.refresh_data(session, batch, cognizer, progress=progress) except Exception as error: session.rollback() log.warning("refreshing data for batch failed: {}".format(batch), exc_info=True) @@ -854,7 +1000,7 @@ class BatchMasterView(MasterView): if progress: progress.session.load() progress.session['complete'] = True - progress.session['success_url'] = success_url or self.get_action_url('view', batch) + progress.session['success_url'] = self.get_action_url('view', batch) progress.session.save() ######################################## @@ -943,16 +1089,7 @@ class BatchMasterView(MasterView): for key, value in form.validated.items(): self.request.session['batch.{}.execute_option.{}'.format(batch.batch_key, key)] = value - key = '{}.execute'.format(self.model_class.__tablename__) - progress = SessionProgress(self.request, key) - kwargs['progress'] = progress - thread = Thread(target=self.execute_thread, args=(batch.uuid, self.request.user.uuid), kwargs=kwargs) - thread.start() - - return self.render_progress(progress, { - 'cancel_url': self.get_action_url('view', batch), - 'cancel_msg': "Batch execution was canceled.", - }) + return self.handler_action(batch, 'execute', **kwargs) self.request.session.flash("Invalid request: {}".format(form.make_deform_form().error), 'error') return self.redirect(self.get_action_url('view', batch)) @@ -1003,7 +1140,7 @@ class BatchMasterView(MasterView): def execute_error_message(self, error): return "Batch execution failed: {}: {}".format(type(error).__name__, error) - def execute_thread(self, batch_uuid, user_uuid, progress=None, **kwargs): + def execute_thread(self, batch_uuid, user_uuid, progress, **kwargs): """ Thread target for executing a batch with progress indicator. """ @@ -1014,7 +1151,7 @@ class BatchMasterView(MasterView): batch = session.query(self.model_class).get(batch_uuid) user = session.query(model.User).get(user_uuid) try: - result = self.handler.execute(batch, user=user, progress=progress, **kwargs) + result = self.handler.do_execute(batch, user=user, progress=progress, **kwargs) # If anything goes wrong, rollback and log the error etc. except Exception as error: @@ -1030,8 +1167,6 @@ class BatchMasterView(MasterView): # If no error, check result flag (false means user canceled). else: if result: - batch.executed = datetime.datetime.utcnow() - batch.executed_by = user session.commit() # TODO: this doesn't always work...? self.request.session.flash("{} has been executed: {}".format( @@ -1159,7 +1294,7 @@ class BatchMasterView(MasterView): # else the perm group label will not display correctly... config.add_tailbone_permission_group(permission_prefix, model_title_plural, overwrite=False) - # prefill row data + # populate row data config.add_route('{}.prefill'.format(route_prefix), '{}/{{uuid}}/prefill'.format(url_prefix)) config.add_view(cls, attr='prefill', route_name='{}.prefill'.format(route_prefix), permission='{}.create'.format(permission_prefix)) diff --git a/tailbone/views/core.py b/tailbone/views/core.py index 42addd76..daa6fc59 100644 --- a/tailbone/views/core.py +++ b/tailbone/views/core.py @@ -100,6 +100,7 @@ class View(object): if not template: template = '/progress.mako' kwargs['progress'] = progress + kwargs.setdefault('can_cancel', True) return render_to_response(template, kwargs, request=self.request) def file_response(self, path): From bac82f47d805432ddac132b9a3d4776114285ac7 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 7 Jun 2018 14:33:13 -0500 Subject: [PATCH 0829/3196] 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 e01d2176..5471b9c5 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.7.16 (2018-06-07) +------------------- + +* Add versioning workaround support for batch actions. + + 0.7.15 (2018-06-05) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 19c2f5a3..9751bfdf 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.7.15' +__version__ = '0.7.16' From e608c0b4286df2490d268871ac2ed6fde8778fa0 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 7 Jun 2018 16:03:17 -0500 Subject: [PATCH 0830/3196] Allow products view to set some labels in costs grid --- tailbone/templates/products/view.mako | 8 ++++---- tailbone/views/products.py | 4 ++++ 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/tailbone/templates/products/view.mako b/tailbone/templates/products/view.mako index 8bfc9acc..1aa7858d 100644 --- a/tailbone/templates/products/view.mako +++ b/tailbone/templates/products/view.mako @@ -179,10 +179,10 @@
      - - - - + + + + diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 3a6d83ad..4425b071 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -619,6 +619,10 @@ class ProductsView(MasterView): if product.upc: kwargs['image_url'] = pod.get_image_url(self.rattail_config, product.upc) kwargs['image_path'] = pod.get_image_path(self.rattail_config, product.upc) + kwargs['costs_label_preferred'] = "Pref." + kwargs['costs_label_vendor'] = "Vendor" + kwargs['costs_label_code'] = "Order Code" + kwargs['costs_label_case_size'] = "Case Size" return kwargs def edit(self): From df9141ec4e97ad193bfaa943c08ddd4568647327 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 8 Jun 2018 11:41:40 -0500 Subject: [PATCH 0831/3196] Let config override sys.prefix when launching batch commands in subprocess --- tailbone/views/batch/core.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index 150dd560..e8e84353 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -830,7 +830,9 @@ class BatchMasterView(MasterView): subcommand=None, subcommand_args=None): # construct command - cmd = [os.path.join(sys.prefix, 'bin/{}'.format(command))] + prefix = self.rattail_config.get('rattail', 'command_prefix', + default=sys.prefix) + cmd = [os.path.join(prefix, 'bin/{}'.format(command))] for path in self.rattail_config.files_read: cmd.extend(['--config', path]) if username: @@ -846,6 +848,7 @@ class BatchMasterView(MasterView): cmd.extend(subcommand_args) # run command in subprocess + log.debug("launching command in subprocess: %s", cmd) subprocess.check_call(cmd) def action_subprocess_thread(self, batch_uuid, port, username, action, progress): From 51ff56eb4fbe0c167f544bd11e4add54ffb5c8df Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 9 Jun 2018 16:59:36 -0500 Subject: [PATCH 0832/3196] 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 5471b9c5..b6992a28 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,14 @@ CHANGELOG ========= +0.7.17 (2018-06-09) +------------------- + +* Allow products view to set some labels in costs grid. + +* Let config override ``sys.prefix`` when launching batch commands in subprocess. + + 0.7.16 (2018-06-07) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 9751bfdf..f38b5f1c 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.7.16' +__version__ = '0.7.17' From b1b4e7e4efe6ab04d776e622a30c64b8d8fb491a Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 13 Jun 2018 21:00:11 -0500 Subject: [PATCH 0833/3196] Auto-size columns for Excel results download --- tailbone/views/master.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tailbone/views/master.py b/tailbone/views/master.py index bff7c2b7..fa976c6e 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -2014,6 +2014,7 @@ class MasterView(View): writer.write_rows(rows) writer.auto_freeze() writer.auto_filter() + writer.auto_resize() writer.save() response = self.request.response From 7c46f10dd12d3b269b101200a023ad7a791c7d20 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 13 Jun 2018 21:00:33 -0500 Subject: [PATCH 0834/3196] Add Excel results download for categories, report codes also fix department field widget for categories --- tailbone/views/categories.py | 44 ++++++++++++++++++++++++++++++++--- tailbone/views/reportcodes.py | 1 + 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/tailbone/views/categories.py b/tailbone/views/categories.py index 607a2fee..649ecfeb 100644 --- a/tailbone/views/categories.py +++ b/tailbone/views/categories.py @@ -28,6 +28,7 @@ from __future__ import unicode_literals, absolute_import from rattail.db import model +from tailbone import forms from tailbone.views import MasterView @@ -39,17 +40,16 @@ class CategoriesView(MasterView): model_title_plural = "Categories" route_prefix = 'categories' has_versions = True + results_downloadable_xlsx = True grid_columns = [ 'code', - 'number', 'name', 'department', ] form_fields = [ 'code', - 'number', 'name', 'department', ] @@ -65,9 +65,47 @@ class CategoriesView(MasterView): g.set_sort_defaults('code') g.set_link('code') - g.set_link('number') g.set_link('name') + def get_xlsx_fields(self): + fields = super(CategoriesView, self).get_xlsx_fields() + fields.extend([ + 'department_number', + 'department_name', + ]) + return fields + + def get_xlsx_row(self, category, fields): + row = super(CategoriesView, self).get_xlsx_row(category, fields) + dept = category.department + if dept: + row['department_number'] = dept.number + row['department_name'] = dept.name + else: + row['department_number'] = None + row['department_name'] = None + return row + + def configure_form(self, f): + super(CategoriesView, self).configure_form(f) + + # department + if self.creating or self.editing: + if 'department' in f: + f.replace('department', 'department_uuid') + f.set_label('department_uuid', "Department") + dept_values = self.get_department_values() + dept_values.insert(0, ('', "(none)")) + f.set_widget('department_uuid', forms.widgets.JQuerySelectWidget(values=dept_values)) + else: + f.set_readonly('department') + + def get_department_values(self): + departments = self.Session.query(model.Department)\ + .order_by(model.Department.number) + return [(dept.uuid, "{} {}".format(dept.number, dept.name)) + for dept in departments] + def includeme(config): CategoriesView.defaults(config) diff --git a/tailbone/views/reportcodes.py b/tailbone/views/reportcodes.py index b50d138f..63044c3b 100644 --- a/tailbone/views/reportcodes.py +++ b/tailbone/views/reportcodes.py @@ -38,6 +38,7 @@ class ReportCodesView(MasterView): model_class = model.ReportCode model_title = "Report Code" has_versions = True + results_downloadable_xlsx = True grid_columns = [ 'code', From 8428790001334955bf8cf3d33a72c8c3f1052fd8 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 14 Jun 2018 12:04:35 -0500 Subject: [PATCH 0835/3196] Use "known" label if possible when making new grid filters --- tailbone/grids/core.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index 7bcebb3d..66705160 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -159,6 +159,8 @@ class Grid(object): if len(args) == 1 and args[0] is None: self.remove_filter(key) else: + if 'label' not in kwargs and key in self.labels: + kwargs['label'] = self.labels[key] self.filters[key] = self.make_filter(key, *args, **kwargs) def remove_filter(self, key): From baeb9a558e575821a50038431f31c1e965561041 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 14 Jun 2018 12:04:50 -0500 Subject: [PATCH 0836/3196] Expose new `exempt_from_gross_sales` flags --- tailbone/views/departments.py | 1 + tailbone/views/trainwreck.py | 2 ++ 2 files changed, 3 insertions(+) diff --git a/tailbone/views/departments.py b/tailbone/views/departments.py index b0eba5be..d5b49506 100644 --- a/tailbone/views/departments.py +++ b/tailbone/views/departments.py @@ -48,6 +48,7 @@ class DepartmentsView(MasterView): 'name', 'product', 'personnel', + 'exempt_from_gross_sales', ] def configure_grid(self, g): diff --git a/tailbone/views/trainwreck.py b/tailbone/views/trainwreck.py index 34147383..dcb7a4a0 100644 --- a/tailbone/views/trainwreck.py +++ b/tailbone/views/trainwreck.py @@ -113,11 +113,13 @@ class TransactionView(MasterView): 'item_scancode', 'item_id', 'department_number', + 'department_name', 'description', 'unit_quantity', 'subtotal', 'tax', 'total', + 'exempt_from_gross_sales', 'void', ] From eb1bb02dc54bd8871238240cfcc94fc967b5027b Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 14 Jun 2018 12:20:36 -0500 Subject: [PATCH 0837/3196] 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 b6992a28..e8c9280d 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,18 @@ CHANGELOG ========= +0.7.18 (2018-06-14) +------------------- + +* Auto-size columns for Excel results download. + +* Add Excel results download for categories, report codes. + +* Use "known" label if possible when making new grid filters. + +* Expose new ``exempt_from_gross_sales`` flags. + + 0.7.17 (2018-06-09) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index f38b5f1c..cac2624a 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.7.17' +__version__ = '0.7.18' From 93b3a5dab6751c298abbc2771fdda1d82d42aada Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 14 Jun 2018 19:37:50 -0500 Subject: [PATCH 0838/3196] Change how date fields are handled within grid filters don't set type="date" b/c that can trigger native browser datepicker --- tailbone/grids/filters.py | 3 ++- tailbone/static/js/jquery.ui.tailbone.js | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/tailbone/grids/filters.py b/tailbone/grids/filters.py index 7d355300..3b621159 100644 --- a/tailbone/grids/filters.py +++ b/tailbone/grids/filters.py @@ -86,7 +86,8 @@ class DateValueRenderer(FilterValueRenderer): """ def render(self, value=None, **kwargs): - return tags.text(self.name, value=value, type='date', **kwargs) + kwargs['data-datepicker'] = 'true' + return tags.text(self.name, value=value, **kwargs) class ChoiceValueRenderer(FilterValueRenderer): diff --git a/tailbone/static/js/jquery.ui.tailbone.js b/tailbone/static/js/jquery.ui.tailbone.js index bd1e2a6f..06904e59 100644 --- a/tailbone/static/js/jquery.ui.tailbone.js +++ b/tailbone/static/js/jquery.ui.tailbone.js @@ -357,7 +357,7 @@ }); // Enhance any date values with datepicker widget. - this.inputs.find('.value input[type="date"]').datepicker({ + this.inputs.find('.value input[data-datepicker="true"]').datepicker({ dateFormat: 'yy-mm-dd', changeYear: true, changeMonth: true From 8387129eda4a1a051711145048061355910d9817 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 14 Jun 2018 19:57:15 -0500 Subject: [PATCH 0839/3196] Add workaround for using pip 10.0 "internal" API in upgrades view --- tailbone/views/upgrades.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/tailbone/views/upgrades.py b/tailbone/views/upgrades.py index e20ba05e..bf59711f 100644 --- a/tailbone/views/upgrades.py +++ b/tailbone/views/upgrades.py @@ -31,10 +31,18 @@ import re import logging import six -from pip.download import PipSession -from pip.req import parse_requirements from sqlalchemy import orm +# TODO: pip has declared these to be "not public API" so we should find another way.. +try: + # this works for now, with pip 10.0.1 + from pip._internal.download import PipSession + from pip._internal.req import parse_requirements +except ImportError: + # this should work with pip < 10.0 + from pip.download import PipSession + from pip.req import parse_requirements + from rattail.db import model, Session as RattailSession from rattail.time import make_utc from rattail.threads import Thread From ea8e52377c87bc697f9f2330d6620b8d4aec170f Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 14 Jun 2018 20:21:19 -0500 Subject: [PATCH 0840/3196] 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 e8c9280d..a38febfb 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,14 @@ CHANGELOG ========= +0.7.19 (2018-06-14) +------------------- + +* Change how date fields are handled within grid filters. + +* Add workaround for using pip 10.0 "internal" API in upgrades view. + + 0.7.18 (2018-06-14) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index cac2624a..763ce9cf 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.7.18' +__version__ = '0.7.19' From f92123c398f8dfd36c6c963cb7e428f27bf7f20d Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 14 Jun 2018 20:24:14 -0500 Subject: [PATCH 0841/3196] Tweak pip and "upgrade strategy" for tox --- tox.ini | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tox.ini b/tox.ini index c3abb79d..18df2205 100644 --- a/tox.ini +++ b/tox.ini @@ -8,15 +8,15 @@ deps = mock nose commands = - pip install 'pip<10.0' - pip install --upgrade Tailbone rattail[auth,bouncer] rattail-tempmon + pip install --upgrade pip + pip install --upgrade --upgrade-strategy eager Tailbone rattail[auth,bouncer] rattail-tempmon nosetests {posargs} [testenv:coverage] basepython = python commands = - pip install 'pip<10.0' - pip install --upgrade Tailbone rattail[auth,bouncer] rattail-tempmon + pip install --upgrade pip + pip install --upgrade --upgrade-strategy eager Tailbone rattail[auth,bouncer] rattail-tempmon nosetests {posargs:--with-coverage --cover-html-dir={envtmpdir}/coverage} [testenv:docs] @@ -26,6 +26,6 @@ deps = sphinx-rtd-theme changedir = docs commands = - pip install 'pip<10.0' - pip install --upgrade Tailbone rattail[auth,bouncer] rattail-tempmon + pip install --upgrade pip + pip install --upgrade --upgrade-strategy eager Tailbone rattail[auth,bouncer] rattail-tempmon sphinx-build -b html -d {envtmpdir}/doctrees -W -T . {envtmpdir}/docs From 0c653b5ee3b70c848c72bd2d1390920379b02117 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 27 Jun 2018 10:26:37 -0500 Subject: [PATCH 0842/3196] Fix input validation for integer grid filter sometimes a default is provided as int --- tailbone/grids/filters.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tailbone/grids/filters.py b/tailbone/grids/filters.py index 3b621159..84ab6e61 100644 --- a/tailbone/grids/filters.py +++ b/tailbone/grids/filters.py @@ -468,6 +468,8 @@ class AlchemyIntegerFilter(AlchemyNumericFilter): def value_invalid(self, value): if value: + if isinstance(value, int): + return True if not value.isdigit(): return True return False From 88a89228330e161fa031ebc0bbba55cc2ea12ed1 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 27 Jun 2018 10:27:14 -0500 Subject: [PATCH 0843/3196] 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 a38febfb..973f37e8 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.7.20 (2018-06-27) +------------------- + +* Fix input validation for integer grid filter. + + 0.7.19 (2018-06-14) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 763ce9cf..86dcf8c0 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.7.19' +__version__ = '0.7.20' From edbf7e6723279afdd48a8a3878490f919b06eac6 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 27 Jun 2018 12:19:34 -0500 Subject: [PATCH 0844/3196] Fix bug when populating new batch --- tailbone/views/batch/core.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index e8e84353..a7d9c9ad 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -926,8 +926,9 @@ class BatchMasterView(MasterView): # mustn't use tailbone web session here session = RattailSession() batch = session.query(self.model_class).get(batch_uuid) + user = session.query(model.User).get(user_uuid) try: - self.handler.do_populate(batch, progress=progress) + self.handler.do_populate(batch, user, progress=progress) except Exception as error: session.rollback() log.warning("batch population failed: %s", batch, exc_info=True) From c1e2c5551ced9ddaf870c1559a0ce3f50e330e96 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 27 Jun 2018 13:34:48 -0500 Subject: [PATCH 0845/3196] Allow zero quantity for inventory batch desktop entry form --- tailbone/templates/batch/inventory/desktop_form.mako | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tailbone/templates/batch/inventory/desktop_form.mako b/tailbone/templates/batch/inventory/desktop_form.mako index 1b1ef0f2..720084f6 100644 --- a/tailbone/templates/batch/inventory/desktop_form.mako +++ b/tailbone/templates/batch/inventory/desktop_form.mako @@ -10,11 +10,13 @@ function assert_quantity() { % if allow_cases: - if ($('#cases').val() && parseFloat($('#cases').val())) { + var cases = parseFloat($('#cases').val()); + if (!isNaN(cases)) { return true; } % endif - if ($('#units').val() && parseFloat($('#units').val())) { + var units = parseFloat($('#units').val()); + if (!isNaN(units)) { return true; } alert("Please provide case and/or unit quantity"); From 076d3d81895b89ef3e520ef58e0699058492f969 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 27 Jun 2018 13:43:03 -0500 Subject: [PATCH 0846/3196] Add support for zero quantity for mobile inventory batch rows --- .../templates/mobile/batch/inventory/view_row.mako | 12 +++++++++++- tailbone/templates/mobile/keypad.mako | 2 +- tailbone/views/inventory.py | 6 ++++-- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/tailbone/templates/mobile/batch/inventory/view_row.mako b/tailbone/templates/mobile/batch/inventory/view_row.mako index d4d49f75..8643da1e 100644 --- a/tailbone/templates/mobile/batch/inventory/view_row.mako +++ b/tailbone/templates/mobile/batch/inventory/view_row.mako @@ -53,7 +53,17 @@ % endif ${h.hidden('units')} - ${keypad(unit_uom, uom, quantity=(row.cases or row.units or 1) if allow_cases else (row.units or 1), allow_cases=allow_cases)} + <% + quantity = 1 + if allow_cases: + if row.cases is not None: + quantity = row.cases + elif row.units is not None: + quantity = row.units + elif row.units is not None: + quantity = row.units + %> + ${keypad(unit_uom, uom, quantity=quantity, allow_cases=allow_cases)}
      diff --git a/tailbone/templates/mobile/keypad.mako b/tailbone/templates/mobile/keypad.mako index 8f0f131d..38cb03da 100644 --- a/tailbone/templates/mobile/keypad.mako +++ b/tailbone/templates/mobile/keypad.mako @@ -29,7 +29,7 @@
      Pref.VendorOrder CodeCase Size${costs_label_preferred}${costs_label_vendor}${costs_label_code}${costs_label_case_size} Case Cost Unit Cost Status
      - + % if allow_cases: ${h.radio('keypad-uom', value='CS', checked=selected_uom == 'CS', label="CS")} diff --git a/tailbone/views/inventory.py b/tailbone/views/inventory.py index 6c0a16c2..57dcae36 100644 --- a/tailbone/views/inventory.py +++ b/tailbone/views/inventory.py @@ -588,12 +588,14 @@ class InventoryBatchView(BatchMasterView): row = self.Session.query(model.InventoryBatchRow).get(update_form.validated['row']) cases = update_form.validated['cases'] units = update_form.validated['units'] - if cases: + if cases is not colander.null: row.cases = cases row.units = None - elif units: + elif units is not colander.null: row.cases = None row.units = units + else: + raise NotImplementedError self.handler.refresh_row(row) route_prefix = self.get_route_prefix() return self.redirect(self.request.route_url('mobile.{}.view'.format(route_prefix), uuid=batch.uuid)) From ee1065bfdbd5500e9a714eb15071bdb2e8899d27 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 27 Jun 2018 13:56:20 -0500 Subject: [PATCH 0847/3196] Allow editing of unit cost for inventory batch row --- tailbone/views/inventory.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tailbone/views/inventory.py b/tailbone/views/inventory.py index 57dcae36..eb1105f5 100644 --- a/tailbone/views/inventory.py +++ b/tailbone/views/inventory.py @@ -653,7 +653,6 @@ class InventoryBatchView(BatchMasterView): f.set_readonly('previous_units_on_hand') f.set_readonly('case_quantity') f.set_readonly('variance') - f.set_readonly('unit_cost') f.set_readonly('total_cost') # quantity fields From 440a88aa0f0d28cc9371b61a13916d9c6d81c8fb Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 27 Jun 2018 14:52:55 -0500 Subject: [PATCH 0848/3196] Add overflow validation for cases/units in inventory batch desktop form --- tailbone/templates/batch/inventory/desktop_form.mako | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tailbone/templates/batch/inventory/desktop_form.mako b/tailbone/templates/batch/inventory/desktop_form.mako index 720084f6..77d573dd 100644 --- a/tailbone/templates/batch/inventory/desktop_form.mako +++ b/tailbone/templates/batch/inventory/desktop_form.mako @@ -12,11 +12,21 @@ % if allow_cases: var cases = parseFloat($('#cases').val()); if (!isNaN(cases)) { + if (cases > 999999) { + alert("Case amount is invalid!"); + $('#cases').select().focus(); + return false; + } return true; } % endif var units = parseFloat($('#units').val()); if (!isNaN(units)) { + if (units > 999999) { + alert("Unit amount is invalid!"); + $('#units').select().focus(); + return false; + } return true; } alert("Please provide case and/or unit quantity"); From b66af5903b33a591e0b851e16e417a7d5a9a64f0 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 27 Jun 2018 15:08:53 -0500 Subject: [PATCH 0849/3196] Add `invoice_total` column for purchase credits grid that probably isn't quite right, but at least is something --- tailbone/views/purchases/credits.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tailbone/views/purchases/credits.py b/tailbone/views/purchases/credits.py index c8b6f684..9b4e0255 100644 --- a/tailbone/views/purchases/credits.py +++ b/tailbone/views/purchases/credits.py @@ -58,6 +58,7 @@ class PurchaseCreditView(MasterView): 'size', 'cases_shorted', 'units_shorted', + 'invoice_total', 'credit_type', 'mispick_upc', 'date_received', @@ -82,6 +83,7 @@ class PurchaseCreditView(MasterView): # g.set_type('upc', 'gpc') g.set_type('cases_shorted', 'quantity') g.set_type('units_shorted', 'quantity') + g.set_type('invoice_total', 'currency') g.set_label('invoice_number', "Invoice No.") g.set_label('upc', "UPC") From da10c6503cf68a7a89e14944dac4f54773ff281b Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 27 Jun 2018 15:20:20 -0500 Subject: [PATCH 0850/3196] Add support for new `credit_total` field for purchase credits --- tailbone/views/purchases/credits.py | 4 ++-- tailbone/views/purchasing/receiving.py | 8 ++++++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/tailbone/views/purchases/credits.py b/tailbone/views/purchases/credits.py index 9b4e0255..d812fbe7 100644 --- a/tailbone/views/purchases/credits.py +++ b/tailbone/views/purchases/credits.py @@ -58,7 +58,7 @@ class PurchaseCreditView(MasterView): 'size', 'cases_shorted', 'units_shorted', - 'invoice_total', + 'credit_total', 'credit_type', 'mispick_upc', 'date_received', @@ -83,7 +83,7 @@ class PurchaseCreditView(MasterView): # g.set_type('upc', 'gpc') g.set_type('cases_shorted', 'quantity') g.set_type('units_shorted', 'quantity') - g.set_type('invoice_total', 'currency') + g.set_type('credit_total', 'currency') g.set_label('invoice_number', "Invoice No.") g.set_label('upc', "UPC") diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index 7350cf79..36f71189 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -715,6 +715,14 @@ class ReceivingBatchView(PurchasingBatchView): credit.invoice_case_cost = row.invoice_case_cost credit.invoice_unit_cost = row.invoice_unit_cost credit.invoice_total = row.invoice_total + + # calculate credit total + # TODO: should this leverage case cost if present? + credit_units = self.handler.get_units(credit.cases_shorted, + credit.units_shorted, + credit.case_quantity) + credit.credit_total = credit_units * (credit.invoice_unit_cost or 0) + credit.product_discarded = discarded if credit_type == 'expired': credit.expiration_date = expiration_date From 0ccb6883f838666567087726f780185ff5ea0d4a Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 27 Jun 2018 17:26:38 -0500 Subject: [PATCH 0851/3196] Don't aggregate product for mobile truck dump receiving also sort batch rows by most recent, for receiver convenience --- tailbone/views/purchasing/batch.py | 4 ++++ tailbone/views/purchasing/receiving.py | 18 ++++++++++++++---- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/tailbone/views/purchasing/batch.py b/tailbone/views/purchasing/batch.py index b4156edf..bf03fcda 100644 --- a/tailbone/views/purchasing/batch.py +++ b/tailbone/views/purchasing/batch.py @@ -364,6 +364,10 @@ class PurchasingBatchView(BatchMasterView): 'vendor_contact', 'status_code') + def get_mobile_row_data(self, parent): + query = self.get_row_data(parent) + return query.order_by(model.PurchaseBatchRow.sequence.desc()) + def configure_mobile_form(self, f): super(PurchasingBatchView, self).configure_mobile_form(f) diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index 36f71189..9dcacaf3 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -593,10 +593,20 @@ class ReceivingBatchView(PurchasingBatchView): .all() if rows: - if len(rows) > 1: - log.warning("found multiple UPC matches for {} in batch {}: {}".format( - upc, batch.id_str, batch)) - row = rows[0] + aggregate_products = not bool(batch.truck_dump) # TODO: make this configurable? + if aggregate_products: + if len(rows) > 1: + log.warning("found multiple UPC matches for {} in batch {}: {}".format( + upc, batch.id_str, batch)) + row = rows[0] + + else: + other_row = rows[0] + row = model.PurchaseBatchRow() + row.product = other_row.product + self.handler.add_row(batch, row) + # TODO: is this necessary here? is so, then what about further below? + # self.handler.refresh_batch_status(batch) else: From eeba784c3226dc71c251b5099d14de7db1b76367 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 27 Jun 2018 17:29:31 -0500 Subject: [PATCH 0852/3196] Be smarter about when we sort receiving batch by most recent (for mobile) i.e. only do so when *not* aggregating products, since that probably needs a closer look first --- tailbone/views/purchasing/batch.py | 4 ---- tailbone/views/purchasing/receiving.py | 7 +++++++ 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/tailbone/views/purchasing/batch.py b/tailbone/views/purchasing/batch.py index bf03fcda..b4156edf 100644 --- a/tailbone/views/purchasing/batch.py +++ b/tailbone/views/purchasing/batch.py @@ -364,10 +364,6 @@ class PurchasingBatchView(BatchMasterView): 'vendor_contact', 'status_code') - def get_mobile_row_data(self, parent): - query = self.get_row_data(parent) - return query.order_by(model.PurchaseBatchRow.sequence.desc()) - def configure_mobile_form(self, f): super(PurchasingBatchView, self).configure_mobile_form(f) diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index 9dcacaf3..212a5073 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -563,6 +563,13 @@ class ReceivingBatchView(PurchasingBatchView): f.set_readonly('po_total') f.set_readonly('invoice_total') + def get_mobile_row_data(self, parent): + query = self.get_row_data(parent) + aggregate_products = not bool(parent.truck_dump) # TODO: make this configurable? + if not aggregate_products: + query = query.order_by(model.PurchaseBatchRow.sequence.desc()) + return query + def render_mobile_row_listitem(self, row, i): description = row.product.full_description if row.product else row.description return "({}) {}".format(row.upc.pretty(), description) From 49f241a4b98c25322f9d1f25674959e70feca0c3 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 27 Jun 2018 18:00:28 -0500 Subject: [PATCH 0853/3196] Accept invoice number when adding truck dump child from invoice file --- tailbone/views/purchasing/receiving.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index 212a5073..884dd291 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -442,6 +442,7 @@ class ReceivingBatchView(PurchasingBatchView): 'truck_dump_vendor', 'invoice_file', 'invoice_parser_key', + 'invoice_number', 'description', 'notes', ]) From 6b01a7e888016503c1a68271c5bd70fab5191e49 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 27 Jun 2018 18:40:22 -0500 Subject: [PATCH 0854/3196] Add highlight for "cost not found" rows in purchasing batch --- tailbone/views/purchasing/batch.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tailbone/views/purchasing/batch.py b/tailbone/views/purchasing/batch.py index b4156edf..1baafafd 100644 --- a/tailbone/views/purchasing/batch.py +++ b/tailbone/views/purchasing/batch.py @@ -612,7 +612,8 @@ class PurchasingBatchView(BatchMasterView): def row_grid_extra_class(self, row, i): if row.status_code == row.STATUS_PRODUCT_NOT_FOUND: return 'warning' - if row.status_code in (row.STATUS_INCOMPLETE, + if row.status_code in (row.STATUS_COST_NOT_FOUND, + row.STATUS_INCOMPLETE, row.STATUS_ORDERED_RECEIVED_DIFFER, row.STATUS_TRUCKDUMP_UNCLAIMED): return 'notice' From b9d699df849dae0d2279f1d4587dd0d33d3f7df0 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 28 Jun 2018 12:25:44 -0500 Subject: [PATCH 0855/3196] Fix email preview logic per python 3 can't use filter() anymore --- tailbone/views/email.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tailbone/views/email.py b/tailbone/views/email.py index d95be94d..b6f7b946 100644 --- a/tailbone/views/email.py +++ b/tailbone/views/email.py @@ -280,7 +280,8 @@ class EmailPreview(View): def email_template(self): recipient = self.request.POST.get('recipient') if recipient: - keys = filter(lambda k: k.startswith('send_'), self.request.POST.keys()) + keys = [key for key in self.request.POST.keys() + if key.startswith('send_')] key = keys[0][5:] if keys else None if key: email = mail.get_email(self.rattail_config, key) From 1342d67746537f80ecc0ae71073b66a0d74cd9da Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 28 Jun 2018 12:26:22 -0500 Subject: [PATCH 0856/3196] Improve basic support for adding new product --- tailbone/views/products.py | 31 +++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 4425b071..56fa0ec9 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -377,6 +377,9 @@ class ProductsView(MasterView): .order_by(model.Department.number) dept_values = [(d.uuid, "{} {}".format(d.number, d.name)) for d in departments] + require_department = False + if not require_department: + dept_values.insert(0, ('', "(none)")) f.set_widget('department_uuid', dfwidget.SelectWidget(values=dept_values)) f.set_label('department_uuid', "Department") else: @@ -391,6 +394,9 @@ class ProductsView(MasterView): .order_by(model.Subdepartment.number) subdept_values = [(s.uuid, "{} {}".format(s.number, s.name)) for s in subdepartments] + require_subdepartment = False + if not require_subdepartment: + subdept_values.insert(0, ('', "(none)")) f.set_widget('subdepartment_uuid', dfwidget.SelectWidget(values=subdept_values)) f.set_label('subdepartment_uuid', "Subdepartment") else: @@ -405,6 +411,9 @@ class ProductsView(MasterView): .order_by(model.Category.code) category_values = [(c.uuid, "{} {}".format(c.code, c.name)) for c in categories] + require_category = False + if not require_category: + category_values.insert(0, ('', "(none)")) f.set_widget('category_uuid', dfwidget.SelectWidget(values=category_values)) f.set_label('category_uuid', "Category") else: @@ -417,7 +426,10 @@ class ProductsView(MasterView): f.replace('family', 'family_uuid') families = self.Session.query(model.Family)\ .order_by(model.Family.name) - family_values = [(f.uuid, f.name) for f in families] + family_values = [(fam.uuid, fam.name) for fam in families] + require_family = False + if not require_family: + family_values.insert(0, ('', "(none)")) f.set_widget('family_uuid', dfwidget.SelectWidget(values=family_values)) f.set_label('family_uuid', "Family") else: @@ -432,6 +444,9 @@ class ProductsView(MasterView): .order_by(model.ReportCode.code) report_code_values = [(rc.uuid, "{} {}".format(rc.code, rc.name)) for rc in report_codes] + require_report_code = False + if not require_report_code: + report_code_values.insert(0, ('', "(none)")) f.set_widget('report_code_uuid', dfwidget.SelectWidget(values=report_code_values)) f.set_label('report_code_uuid', "Report_Code") else: @@ -446,6 +461,9 @@ class ProductsView(MasterView): .order_by(model.DepositLink.code) deposit_link_values = [(dl.uuid, "{} {}".format(dl.code, dl.description)) for dl in deposit_links] + require_deposit_link = False + if not require_deposit_link: + deposit_link_values.insert(0, ('', "(none)")) f.set_widget('deposit_link_uuid', dfwidget.SelectWidget(values=deposit_link_values)) f.set_label('deposit_link_uuid', "Deposit_Link") else: @@ -460,6 +478,9 @@ class ProductsView(MasterView): .order_by(model.Tax.code) tax_values = [(tax.uuid, "{} {}".format(tax.code, tax.description)) for tax in taxes] + require_tax = False + if not require_tax: + tax_values.insert(0, ('', "(none)")) f.set_widget('tax_uuid', dfwidget.SelectWidget(values=tax_values)) f.set_label('tax_uuid', "Tax") else: @@ -512,11 +533,17 @@ class ProductsView(MasterView): f.set_renderer('current_price', self.render_price) # last_sold - f.set_readonly('last_sold') + if self.creating: + f.remove_field('last_sold') + else: + f.set_readonly('last_sold') # status_code f.set_label('status_code', "Status") + # ingredients + f.set_widget('ingredients', dfwidget.TextAreaWidget(cols=80, rows=10)) + # notes f.set_widget('notes', dfwidget.TextAreaWidget(cols=80, rows=10)) From 350e901c2a979ac6e3d342dd9fe6cde71d86d4eb Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 28 Jun 2018 12:27:04 -0500 Subject: [PATCH 0857/3196] Highlight "cost not found" as warning, for purchasing batch --- tailbone/views/purchasing/batch.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tailbone/views/purchasing/batch.py b/tailbone/views/purchasing/batch.py index 1baafafd..1e029fb1 100644 --- a/tailbone/views/purchasing/batch.py +++ b/tailbone/views/purchasing/batch.py @@ -610,10 +610,10 @@ class PurchasingBatchView(BatchMasterView): return self.make_default_row_grid_tools(batch) def row_grid_extra_class(self, row, i): - if row.status_code == row.STATUS_PRODUCT_NOT_FOUND: + if row.status_code in (row.STATUS_PRODUCT_NOT_FOUND, + row.STATUS_COST_NOT_FOUND): return 'warning' - if row.status_code in (row.STATUS_COST_NOT_FOUND, - row.STATUS_INCOMPLETE, + if row.status_code in (row.STATUS_INCOMPLETE, row.STATUS_ORDERED_RECEIVED_DIFFER, row.STATUS_TRUCKDUMP_UNCLAIMED): return 'notice' From 8d0dfd631b69e52714eb33be2097b40bfc267cde Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 28 Jun 2018 12:27:30 -0500 Subject: [PATCH 0858/3196] Show department column for receiving batch rows --- tailbone/views/purchasing/receiving.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index 884dd291..7af5536a 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -175,6 +175,7 @@ class ReceivingBatchView(PurchasingBatchView): 'brand_name', 'description', 'size', + 'department_name', 'cases_ordered', 'units_ordered', 'cases_received', @@ -556,6 +557,10 @@ class ReceivingBatchView(PurchasingBatchView): if batch.truck_dump: f.remove_field('department') + def configure_row_grid(self, g): + super(ReceivingBatchView, self).configure_row_grid(g) + g.set_label('department_name', "Department") + def configure_row_form(self, f): super(ReceivingBatchView, self).configure_row_form(f) f.set_readonly('cases_ordered') From 2ffb930f7fe8acdad89709591b503f913cd1e1e0 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 28 Jun 2018 12:27:40 -0500 Subject: [PATCH 0859/3196] Fix how "unknown product" row is added to receiving batch --- tailbone/views/purchasing/receiving.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index 7af5536a..452a8a75 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -642,8 +642,7 @@ class ReceivingBatchView(PurchasingBatchView): row = model.PurchaseBatchRow() row.upc = provided # TODO: why not checked? how to know? row.description = "(unknown product)" - batch.add_row(row) - self.handler.refresh_row(row) + self.handler.add_row(batch, row) self.handler.refresh_batch_status(batch) self.Session.flush() From 4ffd0df7c1fc2a0b7d6947bc188f17463978b4fb Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 28 Jun 2018 15:17:44 -0500 Subject: [PATCH 0860/3196] Update changelog --- CHANGES.rst | 30 ++++++++++++++++++++++++++++++ tailbone/_version.py | 2 +- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 973f37e8..73a551e9 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,36 @@ CHANGELOG ========= +0.7.21 (2018-06-28) +------------------- + +* Fix bug when populating new batch. + +* Allow zero quantity for inventory batch rows. + +* Allow editing of unit cost for inventory batch row. + +* Add overflow validation for cases/units in inventory batch desktop form. + +* Add ``credit_total`` column for purchase credits grid. + +* Don't aggregate product for mobile truck dump receiving. + +* Be smarter about when we sort receiving batch by most recent (for mobile). + +* Accept invoice number when adding truck dump child from invoice file. + +* Add highlight for "cost not found" rows in purchasing batch. + +* Fix email preview logic per python 3. + +* Improve basic support for adding new product. + +* Show department column for receiving batch rows. + +* Fix how "unknown product" row is added to receiving batch. + + 0.7.20 (2018-06-27) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 86dcf8c0..f0c954ad 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.7.20' +__version__ = '0.7.21' From 944e8961962d521ef608006e116c9c995d062f4f Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 29 Jun 2018 12:56:22 -0500 Subject: [PATCH 0861/3196] Consider any integer greater than PG allows, to be invalid grid filter value this feels pretty hacky...would be nice to come up with a better way --- tailbone/grids/filters.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tailbone/grids/filters.py b/tailbone/grids/filters.py index 84ab6e61..93c0e669 100644 --- a/tailbone/grids/filters.py +++ b/tailbone/grids/filters.py @@ -472,6 +472,10 @@ class AlchemyIntegerFilter(AlchemyNumericFilter): return True if not value.isdigit(): return True + # TODO: this one is to avoid DataError from PG, but perhaps that + # isn't a good enough reason to make this global logic? + if int(value) > 2147483647: + return True return False From 4c2f1aa4ed1e8eea3d690007ce3e58d98bf2e371 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 29 Jun 2018 14:23:29 -0500 Subject: [PATCH 0862/3196] 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 73a551e9..cf5bf160 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.7.22 (2018-06-29) +------------------- + +* Consider any integer greater than PG allows, to be invalid grid filter value. + + 0.7.21 (2018-06-28) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index f0c954ad..41e65b69 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.7.21' +__version__ = '0.7.22' From 6febd01e76aaec1b97327f4ca8be20b594b0d0e7 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 2 Jul 2018 12:06:09 -0500 Subject: [PATCH 0863/3196] Don't read upgrade progress file if size hasn't changed apparently that is possible sometimes? or perhaps just an issue on python 3? --- tailbone/views/upgrades.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/tailbone/views/upgrades.py b/tailbone/views/upgrades.py index bf59711f..a62b3815 100644 --- a/tailbone/views/upgrades.py +++ b/tailbone/views/upgrades.py @@ -343,12 +343,13 @@ class UpgradeView(MasterView): offset = session.get('stdout.offset', 0) if os.path.exists(path): size = os.path.getsize(path) - offset - with open(path, 'rb') as f: - f.seek(offset) - chunk = f.read(size) - data['stdout'] = chunk.decode('utf8').replace('\n', '
      ') - session['stdout.offset'] = offset + size - session.save() + if size: + with open(path, 'rb') as f: + f.seek(offset) + chunk = f.read(size) + data['stdout'] = chunk.decode('utf8').replace('\n', '
      ') + session['stdout.offset'] = offset + size + session.save() return data From ac5a6c011b9f06105727f28e97e1fa4ce1c52e3d Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 3 Jul 2018 18:25:34 -0500 Subject: [PATCH 0864/3196] Fix batch file download link URL --- tailbone/views/batch/core.py | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index a7d9c9ad..681aa51a 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -1387,15 +1387,7 @@ class FileBatchMasterView(BatchMasterView): f.set_type('filename', 'file') else: f.set_readonly('filename') - f.set_renderer('filename', self.render_filename) - - def render_filename(self, batch, field): - filename = getattr(batch, field) - if not filename: - return "" - path = batch.filepath(self.rattail_config, filename=filename) - url = self.get_action_url('download', batch) - return self.render_file_field(path, url) + f.set_renderer('filename', self.render_downloadable_file) class ToggleComplete(colander.MappingSchema): From 3cc789dda90fb08c4dd12434caa0375511ed4c1c Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 3 Jul 2018 18:32:03 -0500 Subject: [PATCH 0865/3196] Fix batch action kwargs, so 'action' can be a handler kwarg i.e. at least the handheld batch handler, accepts an 'action' kwarg for its execute() method. we had apparently broken that --- tailbone/views/batch/core.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index 681aa51a..570da23f 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -706,7 +706,7 @@ class BatchMasterView(MasterView): return self.handler.get_execute_title(batch) return "Execute Batch" - def handler_action(self, batch, action, **kwargs): + def handler_action(self, batch, batch_action, **kwargs): """ View which will attempt to refresh all data for the batch. What exactly this means will depend on the type of batch etc. @@ -718,11 +718,11 @@ class BatchMasterView(MasterView): user_uuid = user.uuid if user else None username = user.username if user else None - key = '{}.{}'.format(self.model_class.__tablename__, action) + key = '{}.{}'.format(self.model_class.__tablename__, batch_action) progress = SessionProgress(self.request, key) # must ensure versioning is *disabled* during action, if handler says so - allow_versioning = self.handler.allow_versioning(action) + allow_versioning = self.handler.allow_versioning(batch_action) if not allow_versioning and self.rattail_config.versioning_enabled(): can_cancel = False @@ -738,21 +738,21 @@ class BatchMasterView(MasterView): thread.start() # launch thread to invoke handler action - thread = Thread(target=self.action_subprocess_thread, args=(batch.uuid, port, username, action, progress)) + thread = Thread(target=self.action_subprocess_thread, args=(batch.uuid, port, username, batch_action, progress)) thread.start() else: # either versioning is disabled, or handler doesn't mind can_cancel = True # launch thread to populate batch; that will update session progress directly - target = getattr(self, '{}_thread'.format(action)) + target = getattr(self, '{}_thread'.format(batch_action)) thread = Thread(target=target, args=(batch.uuid, user_uuid, progress), kwargs=kwargs) thread.start() return self.render_progress(progress, { 'can_cancel': can_cancel, 'cancel_url': self.get_action_url('view', batch), - 'cancel_msg': "{} of batch was canceled.".format(action.capitalize()), + 'cancel_msg': "{} of batch was canceled.".format(batch_action.capitalize()), }) def progress_thread(self, sock, success_url, progress): From ad5444d270d382b05a70d70dd9adaf28551f020d Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 3 Jul 2018 18:57:34 -0500 Subject: [PATCH 0866/3196] Update changelog --- CHANGES.rst | 10 ++++++++++ tailbone/_version.py | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index cf5bf160..f8a57ee1 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,16 @@ CHANGELOG ========= +0.7.23 (2018-07-03) +------------------- + +* Don't read upgrade progress file if size hasn't changed. + +* Fix batch file download link URL. + +* Fix batch action kwargs, so 'action' can be a handler kwarg. + + 0.7.22 (2018-06-29) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 41e65b69..25b413a9 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.7.22' +__version__ = '0.7.23' From 9a0a280d7d79692cd982c307b24aa238593b3e7c Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 3 Jul 2018 20:47:32 -0500 Subject: [PATCH 0867/3196] Tweak how some "pack item" fields are displayed when viewing product --- tailbone/templates/products/view.mako | 7 +++++-- tailbone/views/products.py | 13 ++++++++++++- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/tailbone/templates/products/view.mako b/tailbone/templates/products/view.mako index 1aa7858d..349ee348 100644 --- a/tailbone/templates/products/view.mako +++ b/tailbone/templates/products/view.mako @@ -68,8 +68,11 @@ ${form.render_field_readonly('size')} ${form.render_field_readonly('unit_size')} ${form.render_field_readonly('unit_of_measure')} - ${form.render_field_readonly('unit')} - ${form.render_field_readonly('pack_size')} + % if instance.is_pack_item(): + ${form.render_field_readonly('unit')} + ${form.render_field_readonly('pack_size')} + ${form.render_field_readonly('default_pack')} + % endif ${form.render_field_readonly('case_size')} ${self.extra_main_fields(form)} diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 56fa0ec9..12c1e8d1 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -104,6 +104,7 @@ class ProductsView(MasterView): 'size', 'unit', 'pack_size', + 'default_pack', 'case_size', 'weighed', 'department', @@ -364,6 +365,7 @@ class ProductsView(MasterView): def configure_form(self, f): super(ProductsView, self).configure_form(f) + product = f.model_instance # upc f.set_type('upc', 'gpc') @@ -511,12 +513,21 @@ class ProductsView(MasterView): # unit if self.creating: f.remove_field('unit') + elif self.viewing and not product.is_pack_item(): + f.remove_field('unit') else: f.set_renderer('unit', self.render_unit) f.set_label('unit', "Unit Item") # pack_size - f.set_type('pack_size', 'quantity') + if self.viewing and not product.is_pack_item(): + f.remove_field('pack_size') + else: + f.set_type('pack_size', 'quantity') + + # default_pack + if self.viewing and not product.is_pack_item(): + f.remove_field('default_pack') # regular_price if self.creating: From 00a0e6fb110879d8a3daaf50d90560f388c3b142 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 3 Jul 2018 20:55:11 -0500 Subject: [PATCH 0868/3196] 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 f8a57ee1..d02cc326 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.7.24 (2018-07-03) +------------------- + +* Tweak how some "pack item" fields are displayed when viewing product. + + 0.7.23 (2018-07-03) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 25b413a9..20145910 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.7.23' +__version__ = '0.7.24' From 8cadec9a1693b4d7f43a8a66eaf1279f90e468ca Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 6 Jul 2018 14:15:33 -0500 Subject: [PATCH 0869/3196] Fix enum values for customer email preference grid filter --- tailbone/views/customers.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tailbone/views/customers.py b/tailbone/views/customers.py index aab47181..07226870 100644 --- a/tailbone/views/customers.py +++ b/tailbone/views/customers.py @@ -120,6 +120,9 @@ class CustomersView(MasterView): g.sorters['email'] = lambda q, d: q.order_by(getattr(model.CustomerEmailAddress.address, d)()) g.sorters['phone'] = lambda q, d: q.order_by(getattr(model.CustomerPhoneNumber.number, d)()) + # email_preference + g.set_enum('email_preference', self.enum.EMAIL_PREFERENCE) + g.set_sort_defaults('name') g.set_label('id', "ID") From b464db5722bd4ed02380b0ea74966eecb4777818 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 6 Jul 2018 14:17:33 -0500 Subject: [PATCH 0870/3196] Change field ordering for customer form so that default_email comes next to email_preference --- tailbone/views/customers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/views/customers.py b/tailbone/views/customers.py index 07226870..051f97d2 100644 --- a/tailbone/views/customers.py +++ b/tailbone/views/customers.py @@ -74,8 +74,8 @@ class CustomersView(MasterView): 'id', 'name', 'default_phone', - 'default_email', 'default_address', + 'default_email', 'email_preference', 'active_in_pos', 'active_in_pos_sticky', From 9149902c7817703696a998a00f138c2c2bc520bc Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 7 Jul 2018 20:43:17 -0500 Subject: [PATCH 0871/3196] Remove deprecated "edbob" settings --- tailbone/app.py | 17 ++--------------- 1 file changed, 2 insertions(+), 15 deletions(-) diff --git a/tailbone/app.py b/tailbone/app.py index 60808cc9..128a70a2 100644 --- a/tailbone/app.py +++ b/tailbone/app.py @@ -55,13 +55,8 @@ def make_rattail_config(settings): # available for web requests later path = settings.get('rattail.config') if not path or not os.path.exists(path): - path = settings.get('edbob.config') - if not path or not os.path.exists(path): - raise ConfigurationError("Please set 'rattail.config' in [app:main] section of config " - "to the path of your config file. Lame, but necessary.") - warnings.warn("[app:main] setting 'edbob.config' is deprecated; " - "please use 'rattail.config' setting instead", - DeprecationWarning) + raise ConfigurationError("Please set 'rattail.config' in [app:main] section of config " + "to the path of your config file. Lame, but necessary.") rattail_config = make_config(path) settings['rattail_config'] = rattail_config rattail_config.configure_logging() @@ -146,14 +141,6 @@ def make_pyramid_config(settings, configure_csrf=True): config.add_directive('add_tailbone_permission_group', 'tailbone.auth.add_permission_group') config.add_directive('add_tailbone_permission', 'tailbone.auth.add_permission') - # TODO: This can finally be removed once all CRUD/index views have been - # converted to use the new master view etc. - for label, perms in settings.get('edbob.permissions', []): - groupkey = label.lower().replace(' ', '_') - config.add_tailbone_permission_group(groupkey, label) - for key, label in perms: - config.add_tailbone_permission(groupkey, key, label) - return config From 3dfdb26502c05eb59c5808fd4a897c1d87c03e86 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 8 Jul 2018 00:01:14 -0500 Subject: [PATCH 0872/3196] Improve basic support for unit/pack info when viewing product details --- tailbone/forms/core.py | 2 + tailbone/templates/products/view.mako | 12 +++--- tailbone/views/products.py | 55 ++++++++++++++++++++++----- 3 files changed, 55 insertions(+), 14 deletions(-) diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index 8401729a..4ce15b14 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.py @@ -728,6 +728,8 @@ class Form(object): return True def render_field_readonly(self, field_name, **kwargs): + if field_name not in self.fields: + return '' label = HTML.tag('label', self.get_label(field_name), for_=field_name) field = self.render_field_value(field_name) or '' field_div = HTML.tag('div', class_='field', c=[field]) diff --git a/tailbone/templates/products/view.mako b/tailbone/templates/products/view.mako index 349ee348..d1efcb0b 100644 --- a/tailbone/templates/products/view.mako +++ b/tailbone/templates/products/view.mako @@ -68,12 +68,14 @@ ${form.render_field_readonly('size')} ${form.render_field_readonly('unit_size')} ${form.render_field_readonly('unit_of_measure')} - % if instance.is_pack_item(): - ${form.render_field_readonly('unit')} - ${form.render_field_readonly('pack_size')} - ${form.render_field_readonly('default_pack')} - % endif ${form.render_field_readonly('case_size')} + % if instance.is_pack_item(): + ${form.render_field_readonly('pack_size')} + ${form.render_field_readonly('unit')} + ${form.render_field_readonly('default_pack')} + % elif instance.packs: + ${form.render_field_readonly('packs')} + % endif ${self.extra_main_fields(form)} diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 12c1e8d1..34716a29 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -102,8 +102,9 @@ class ProductsView(MasterView): 'unit_size', 'unit_of_measure', 'size', - 'unit', + 'packs', 'pack_size', + 'unit', 'default_pack', 'case_size', 'weighed', @@ -510,14 +511,23 @@ class ProductsView(MasterView): f.set_enum('unit_of_measure', self.enum.UNIT_OF_MEASURE) f.set_label('unit_of_measure', "Unit of Measure") + # packs + if self.creating: + f.remove_field('packs') + elif self.viewing and product.packs: + f.set_renderer('packs', self.render_packs) + f.set_label('packs', "Pack Items") + else: + f.remove_field('packs') + # unit if self.creating: f.remove_field('unit') - elif self.viewing and not product.is_pack_item(): - f.remove_field('unit') - else: + elif self.viewing and product.is_pack_item(): f.set_renderer('unit', self.render_unit) f.set_label('unit', "Unit Item") + else: + f.remove_field('unit') # pack_size if self.viewing and not product.is_pack_item(): @@ -619,12 +629,39 @@ class ProductsView(MasterView): url = self.request.route_url('categories.view', uuid=category.uuid) return tags.link_to(text, url) - def render_unit(self, product, field): - product = product.unit - if not product: + def render_packs(self, product, field): + if product.is_pack_item(): return "" - text = product.full_description - url = self.request.route_url('products.view', uuid=product.uuid) + + links = [] + for pack in product.packs: + if pack.upc: + code = pack.upc.pretty() + elif pack.scancode: + code = pack.scancode + else: + code = pack.item_id + text = "({}) {}".format(code, pack.full_description) + url = self.get_action_url('view', pack) + links.append(tags.link_to(text, url)) + + items = [HTML.tag('li', c=[link]) for link in links] + return HTML.tag('ul', c=items) + + def render_unit(self, product, field): + unit = product.unit + if not unit: + return "" + + if unit.upc: + code = unit.upc.pretty() + elif unit.scancode: + code = unit.scancode + else: + code = unit.item_id + + text = "({}) {}".format(code, unit.full_description) + url = self.get_action_url('view', unit) return tags.link_to(text, url) def render_current_price_ends(self, product, field): From 469f9cf015453bf793f6e3b652190ec303059139 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 9 Jul 2018 14:29:34 -0500 Subject: [PATCH 0873/3196] 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 d02cc326..3fe7e6d0 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,18 @@ CHANGELOG ========= +0.7.25 (2018-07-09) +------------------- + +* Fix enum values for customer email preference grid filter. + +* Tweak field ordering for customer form. + +* Remove deprecated "edbob" settings. + +* Improve basic support for unit/pack info when viewing product details. + + 0.7.24 (2018-07-03) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 20145910..2a317daa 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.7.24' +__version__ = '0.7.25' From c88d060fe052d66eb6ef76dcd2a65e47e9c06ef2 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 9 Jul 2018 15:50:28 -0500 Subject: [PATCH 0874/3196] Force user to count "units" and not "packs" for inventory batch at least until we come up with something smarter... --- .../templates/batch/inventory/desktop_form.mako | 5 +++++ tailbone/views/inventory.py | 13 ++++++++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/tailbone/templates/batch/inventory/desktop_form.mako b/tailbone/templates/batch/inventory/desktop_form.mako index 77d573dd..22ca68d7 100644 --- a/tailbone/templates/batch/inventory/desktop_form.mako +++ b/tailbone/templates/batch/inventory/desktop_form.mako @@ -114,6 +114,10 @@ $('#size').val(data.product.size); $('#case_quantity').val(data.product.case_quantity); + if (data.force_unit_item) { + $('#product-info .warning.force-unit').show(); + } + if (data.already_present_in_batch) { $('#product-info .warning.present').show(); $('#cases').val(data.cases); @@ -248,6 +252,7 @@
      please confirm UPC and provide more details
      product already exists in batch, please confirm count
      +
      pack item scanned, but must count units instead
      diff --git a/tailbone/views/inventory.py b/tailbone/views/inventory.py index eb1105f5..18312e56 100644 --- a/tailbone/views/inventory.py +++ b/tailbone/views/inventory.py @@ -380,6 +380,12 @@ class InventoryBatchView(BatchMasterView): else: product = self.find_product(entry) + force_unit_item = True # TODO: make configurable? + unit_forced = False + if force_unit_item and product.is_pack_item(): + product = product.unit + unit_forced = True + data = self.product_info(product) if type2: data['type2'] = True @@ -389,7 +395,7 @@ class InventoryBatchView(BatchMasterView): else: data['units'] = float((price / product.regular_price.price).quantize(decimal.Decimal('0.01'))) - result = {'product': data, 'upc_raw': entry, 'upc': None} + result = {'product': data, 'upc_raw': entry, 'upc': None, 'force_unit_item': unit_forced} if not data: upc = re.sub(r'\D', '', entry.strip()) if upc: @@ -513,6 +519,11 @@ class InventoryBatchView(BatchMasterView): product = self.find_product(entry) if product: + force_unit_item = True # TODO: make configurable? + if force_unit_item and product.is_pack_item(): + product = product.unit + self.request.session.flash("You scanned a pack item, but must count the units instead.", 'error') + aggregate = self.should_aggregate_products(batch) if aggregate: row = self.find_row_for_product(batch, product) From 44663fe548cefe9318ea5755302c509709ff0fac Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 9 Jul 2018 21:28:36 -0500 Subject: [PATCH 0875/3196] Fix bug for inventory batch when product not found --- tailbone/views/inventory.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/views/inventory.py b/tailbone/views/inventory.py index 18312e56..ba52e051 100644 --- a/tailbone/views/inventory.py +++ b/tailbone/views/inventory.py @@ -382,7 +382,7 @@ class InventoryBatchView(BatchMasterView): force_unit_item = True # TODO: make configurable? unit_forced = False - if force_unit_item and product.is_pack_item(): + if force_unit_item and product and product.is_pack_item(): product = product.unit unit_forced = True From 053fc4eb5523fb14b751ab41ba06dbc54de5802b Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 10 Jul 2018 09:06:22 -0500 Subject: [PATCH 0876/3196] Sort mobile receiving rows by last modified instead of sequence because we now prefer to aggregate rows for that, at least by default --- tailbone/views/purchasing/receiving.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index 452a8a75..f1b5d4eb 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -571,15 +571,22 @@ class ReceivingBatchView(PurchasingBatchView): def get_mobile_row_data(self, parent): query = self.get_row_data(parent) - aggregate_products = not bool(parent.truck_dump) # TODO: make this configurable? - if not aggregate_products: - query = query.order_by(model.PurchaseBatchRow.sequence.desc()) - return query + return self.sort_mobile_row_data(query) + + def sort_mobile_row_data(self, query): + return query.order_by(model.PurchaseBatchRow.modified.desc()) def render_mobile_row_listitem(self, row, i): description = row.product.full_description if row.product else row.description return "({}) {}".format(row.upc.pretty(), description) + def should_aggregate_products(self, batch): + """ + Must return a boolean indicating whether rows should be aggregated by + product for the given batch. + """ + return True + # TODO: this view can create new rows, with only a GET query. that should # probably be changed to require POST; for now we just require the "create # batch row" perm and call it good.. @@ -606,8 +613,7 @@ class ReceivingBatchView(PurchasingBatchView): .all() if rows: - aggregate_products = not bool(batch.truck_dump) # TODO: make this configurable? - if aggregate_products: + if self.should_aggregate_products(batch): if len(rows) > 1: log.warning("found multiple UPC matches for {} in batch {}: {}".format( upc, batch.id_str, batch)) From 9dd6f8ef7d097cf953805668c3fcc1c33d2a077e Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 10 Jul 2018 11:39:00 -0500 Subject: [PATCH 0877/3196] Tweak default page title for master view --- tailbone/templates/master/view.mako | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/templates/master/view.mako b/tailbone/templates/master/view.mako index a584bed5..3b0240f2 100644 --- a/tailbone/templates/master/view.mako +++ b/tailbone/templates/master/view.mako @@ -1,7 +1,7 @@ ## -*- coding: utf-8; -*- <%inherit file="/base.mako" /> -<%def name="title()">${model_title_plural} » ${instance_title} +<%def name="title()">${index_title} » ${instance_title} <%def name="extra_javascript()"> ${parent.extra_javascript()} From ed6f2f27cc635d66c73aef37c16f58c5683aa4b5 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 10 Jul 2018 11:39:22 -0500 Subject: [PATCH 0878/3196] Show "truck dump" info for applicable receiving batch page title --- tailbone/views/purchasing/receiving.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index f1b5d4eb..f905c1f7 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -230,6 +230,14 @@ class ReceivingBatchView(PurchasingBatchView): return True return False + def get_instance_title(self, batch): + title = super(ReceivingBatchView, self).get_instance_title(batch) + if batch.truck_dump: + title = "{} (TRUCK DUMP PARENT)".format(title) + elif batch.truck_dump_batch: + title = "{} (TRUCK DUMP CHILD)".format(title) + return title + def configure_form(self, f): super(ReceivingBatchView, self).configure_form(f) batch = f.model_instance From 2983ff7ba0889df287e9f6c337e8c8dcc8fd4c0d Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 10 Jul 2018 12:38:58 -0500 Subject: [PATCH 0879/3196] Highlight purchasing batch rows with "case quantity differs" status --- tailbone/views/purchasing/batch.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tailbone/views/purchasing/batch.py b/tailbone/views/purchasing/batch.py index 1e029fb1..2741bd10 100644 --- a/tailbone/views/purchasing/batch.py +++ b/tailbone/views/purchasing/batch.py @@ -614,6 +614,7 @@ class PurchasingBatchView(BatchMasterView): row.STATUS_COST_NOT_FOUND): return 'warning' if row.status_code in (row.STATUS_INCOMPLETE, + row.STATUS_CASE_QUANTITY_DIFFERS, row.STATUS_ORDERED_RECEIVED_DIFFER, row.STATUS_TRUCKDUMP_UNCLAIMED): return 'notice' From 147c65afe66ff4c053846ef2aec317fbdf26d165 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 10 Jul 2018 13:36:28 -0500 Subject: [PATCH 0880/3196] Try to be smart about how we update cases/units for receiving batch row e.g. if you receive 1 CS (@ 12/CS) and then subtract 4 EA then you should wind up with 8 EA for the row --- tailbone/views/purchasing/receiving.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index f905c1f7..1a9f8184 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -699,12 +699,15 @@ class ReceivingBatchView(PurchasingBatchView): mode = update_form.validated['mode'] cases = update_form.validated['cases'] units = update_form.validated['units'] - if cases: - setattr(row, 'cases_{}'.format(mode), - (getattr(row, 'cases_{}'.format(mode)) or 0) + cases) - if units: - setattr(row, 'units_{}'.format(mode), - (getattr(row, 'units_{}'.format(mode)) or 0) + units) + + # try to be smart about how we update cases and units for row + existing = getattr(self.handler, 'get_units_{}'.format(mode))(row) + proposed = existing + self.handler.get_units(cases, units, row.case_quantity) + new_cases, new_units = self.handler.calc_best_fit(proposed, row.case_quantity) + if getattr(row, 'cases_{}'.format(mode)) != new_cases: + setattr(row, 'cases_{}'.format(mode), new_cases) + if getattr(row, 'units_{}'.format(mode)) != new_units: + setattr(row, 'units_{}'.format(mode), new_units) # if mode in ('damaged', 'expired', 'mispick'): if mode in ('damaged', 'expired'): From 477a34cfa75a4a373635da9ac83011d7d872f7f6 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 10 Jul 2018 14:24:12 -0500 Subject: [PATCH 0881/3196] Improve how cases/units, uom are handled for mobile receiving last-used uom should be more or less sticky, etc. --- .../static/js/tailbone.mobile.receiving.js | 2 + .../templates/mobile/receiving/view_row.mako | 10 +---- tailbone/views/purchasing/receiving.py | 42 ++++++++++++++++++- 3 files changed, 44 insertions(+), 10 deletions(-) diff --git a/tailbone/static/js/tailbone.mobile.receiving.js b/tailbone/static/js/tailbone.mobile.receiving.js index 3786d29f..2d5f9ab6 100644 --- a/tailbone/static/js/tailbone.mobile.receiving.js +++ b/tailbone/static/js/tailbone.mobile.receiving.js @@ -68,9 +68,11 @@ $(document).on('click', 'form.receiving-update .receiving-actions button', funct }); +// quick-receive (1 CS) $(document).on('click', 'form.receiving-update .receive-one-case', function() { var form = $(this).parents('form:first'); form.find('[name="mode"]').val('received'); form.find('[name="cases"]').val('1'); + form.find('input[name="quick_receive"]').val('true'); form.submit(); }); diff --git a/tailbone/templates/mobile/receiving/view_row.mako b/tailbone/templates/mobile/receiving/view_row.mako index 74b6faac..966b939b 100644 --- a/tailbone/templates/mobile/receiving/view_row.mako +++ b/tailbone/templates/mobile/receiving/view_row.mako @@ -6,14 +6,6 @@ <%def name="page_title()">${h.link_to("Receiving", url('mobile.receiving'))} » ${h.link_to(batch.id_str, url('mobile.receiving.view', uuid=batch.uuid))} » ${row.upc.pretty()} -<% - unit_uom = 'LB' if row.product and row.product.weighed else 'EA' - - uom = 'CS' - if row.units_ordered and not row.cases_ordered: - uom = 'EA' -%> -
      @@ -102,6 +94,8 @@ + ${h.hidden('quick_receive', value='false')} + ${h.hidden('delete_row', value='false')} % if request.has_perm('{}.delete_row'.format(permission_prefix)): diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index 1a9f8184..9316c2a9 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -109,6 +109,8 @@ class ReceivingBatchView(PurchasingBatchView): allow_from_scratch = True allow_truck_dump = False + default_uom_is_case = True + labels = { 'truck_dump_batch': "Truck Dump Parent", 'invoice_parser_key': "Invoice Parser", @@ -704,10 +706,18 @@ class ReceivingBatchView(PurchasingBatchView): existing = getattr(self.handler, 'get_units_{}'.format(mode))(row) proposed = existing + self.handler.get_units(cases, units, row.case_quantity) new_cases, new_units = self.handler.calc_best_fit(proposed, row.case_quantity) - if getattr(row, 'cases_{}'.format(mode)) != new_cases: + + old_cases = getattr(row, 'cases_{}'.format(mode)) + if new_cases and old_cases != new_cases: setattr(row, 'cases_{}'.format(mode), new_cases) - if getattr(row, 'units_{}'.format(mode)) != new_units: + elif old_cases and not new_cases: + setattr(row, 'cases_{}'.format(mode), None) + + old_units = getattr(row, 'units_{}'.format(mode)) + if new_units and old_units != new_units: setattr(row, 'units_{}'.format(mode), new_units) + elif old_units and not new_units: + setattr(row, 'units_{}'.format(mode), None) # if mode in ('damaged', 'expired', 'mispick'): if mode in ('damaged', 'expired'): @@ -722,8 +732,34 @@ class ReceivingBatchView(PurchasingBatchView): batch.invoice_total -= row.invoice_total self.handler.refresh_row(row) + # keep track of last-used uom, although we just track + # whether or not it was 'CS' since the unit_uom can vary + sticky_case = None + if not update_form.validated['quick_receive']: + if cases and not units: + sticky_case = True + elif units and not cases: + sticky_case = False + if sticky_case is not None: + self.request.session['tailbone.mobile.receiving.sticky_uom_is_case'] = sticky_case + return self.redirect(self.get_action_url('view', batch, mobile=True)) + # unit_uom can vary by product + context['unit_uom'] = 'LB' if row.product and row.product.weighed else 'EA' + + # effective uom can vary in a few ways...the basic default is 'CS' if + # self.default_uom_is_case is true, otherwise whatever unit_uom is. + sticky_case = self.request.session.get('tailbone.mobile.receiving.sticky_uom_is_case') + if sticky_case is None: + context['uom'] = 'CS' if self.default_uom_is_case else context['unit_uom'] + elif sticky_case: + context['uom'] = 'CS' + else: + context['uom'] = context['unit_uom'] + if context['uom'] == 'CS' and row.units_ordered and not row.cases_ordered: + context['uom'] = context['unit_uom'] + if not row.cases_ordered and not row.units_ordered and not batch.truck_dump: self.request.session.flash("This item was NOT on the original purchase order.", 'receiving-warning') return self.render_to_response('view_row', context, mobile=True) @@ -849,6 +885,8 @@ class MobileReceivingForm(colander.MappingSchema): widget=dfwidget.TextInputWidget(), missing=colander.null) + quick_receive = colander.SchemaNode(colander.Boolean()) + delete_row = colander.SchemaNode(colander.Boolean()) From 16ab8b6ffa5c392f1ecaad84698c425e2645973b Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 10 Jul 2018 16:43:21 -0500 Subject: [PATCH 0882/3196] Stop trying to be smart about "best fit" cases/units for receiving i.e. just record amounts as provided by the user. sometimes it is necessary for the user to avoid "cases" altogether if they detect the "case quantity" to be incorrect --- tailbone/views/purchasing/receiving.py | 27 +++++++++++--------------- 1 file changed, 11 insertions(+), 16 deletions(-) diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index 9316c2a9..711daac1 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -702,22 +702,17 @@ class ReceivingBatchView(PurchasingBatchView): cases = update_form.validated['cases'] units = update_form.validated['units'] - # try to be smart about how we update cases and units for row - existing = getattr(self.handler, 'get_units_{}'.format(mode))(row) - proposed = existing + self.handler.get_units(cases, units, row.case_quantity) - new_cases, new_units = self.handler.calc_best_fit(proposed, row.case_quantity) - - old_cases = getattr(row, 'cases_{}'.format(mode)) - if new_cases and old_cases != new_cases: - setattr(row, 'cases_{}'.format(mode), new_cases) - elif old_cases and not new_cases: - setattr(row, 'cases_{}'.format(mode), None) - - old_units = getattr(row, 'units_{}'.format(mode)) - if new_units and old_units != new_units: - setattr(row, 'units_{}'.format(mode), new_units) - elif old_units and not new_units: - setattr(row, 'units_{}'.format(mode), None) + # add values as-is to existing case/unit amounts. note + # that this can sometimes give us negative values! e.g. if + # user scans 1 CS and then subtracts 2 EA, then we would + # have 1 / -2 for our counts. but we consider that to be + # expected, and other logic must allow for the possibility + if cases: + setattr(row, 'cases_{}'.format(mode), + (getattr(row, 'cases_{}'.format(mode)) or 0) + cases) + if units: + setattr(row, 'units_{}'.format(mode), + (getattr(row, 'units_{}'.format(mode)) or 0) + units) # if mode in ('damaged', 'expired', 'mispick'): if mode in ('damaged', 'expired'): From 699536b1ab3f578249b1d54b4f7b4d3eb255730b Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 10 Jul 2018 17:45:33 -0500 Subject: [PATCH 0883/3196] Add "?" for daily time sheet total if partial shift present --- tailbone/views/shifts/lib.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tailbone/views/shifts/lib.py b/tailbone/views/shifts/lib.py index 7690bfec..2706b10e 100644 --- a/tailbone/views/shifts/lib.py +++ b/tailbone/views/shifts/lib.py @@ -442,6 +442,7 @@ class TimeSheetView(View): '{}_shifts'.format(shift_type): [], '{}_hours'.format(shift_type): datetime.timedelta(0), '{}_hours_display'.format(shift_type): '', + 'hours_incomplete': False, } while employee_shifts: @@ -457,6 +458,7 @@ class TimeSheetView(View): getattr(employee, '{}_hours'.format(shift_type)) + shift.length) else: hours_incomplete = True + empday['hours_incomplete'] = True del employee_shifts[0] else: break @@ -464,9 +466,12 @@ class TimeSheetView(View): hours = empday['{}_hours'.format(shift_type)] if hours: if hours_style == 'pretty': - empday['{}_hours_display'.format(shift_type)] = pretty_hours(hours) + display = pretty_hours(hours) else: # decimal - empday['{}_hours_display'.format(shift_type)] = six.text_type(hours_as_decimal(hours)) + display = six.text_type(hours_as_decimal(hours)) + if empday['hours_incomplete']: + display = '{} ?'.format(display) + empday['{}_hours_display'.format(shift_type)] = display employee.weekdays[i].update(empday) hours = getattr(employee, '{}_hours'.format(shift_type)) From 8039af1c060303d4b3eff08577604cf7766b9002 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 11 Jul 2018 10:10:06 -0500 Subject: [PATCH 0884/3196] Fix cancel button for progress page i.e. should actually cancel when clicked... --- tailbone/templates/progress.mako | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/templates/progress.mako b/tailbone/templates/progress.mako index 2df195ff..ce118f9e 100644 --- a/tailbone/templates/progress.mako +++ b/tailbone/templates/progress.mako @@ -28,7 +28,7 @@ clearInterval(updater); $(this).button('disable').button('option', 'label', "Canceling, please wait..."); $.ajax({ - url: '${url('progress', key=progress.key)}?sessiontype=${progress.session.type}', + url: '${url('progress.cancel', key=progress.key)}?sessiontype=${progress.session.type}', data: { 'cancel_msg': '${cancel_msg}', }, From 9caf0e2e1fd7648b84f0766167aa7f790114b528 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 11 Jul 2018 10:29:40 -0500 Subject: [PATCH 0885/3196] Update changelog --- CHANGES.rst | 22 ++++++++++++++++++++++ tailbone/_version.py | 2 +- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 3fe7e6d0..118acbfc 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,28 @@ CHANGELOG ========= +0.7.26 (2018-07-11) +------------------- + +* Force user to count "units" and not "packs" for inventory batch. + +* Fix bug for inventory batch when product not found. + +* Sort mobile receiving rows by last modified instead of sequence. + +* Tweak default page title for master view. + +* Show "truck dump" info for applicable receiving batch page title. + +* Highlight purchasing batch rows with "case quantity differs" status. + +* Improve how cases/units, uom are handled for mobile receiving. + +* Add "?" for daily time sheet total if partial shift present. + +* Fix cancel button for progress page. + + 0.7.25 (2018-07-09) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 2a317daa..8d608f1d 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.7.25' +__version__ = '0.7.26' From aa6e540abd94ef6444d550012a41db5914feb666 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 11 Jul 2018 13:30:48 -0500 Subject: [PATCH 0886/3196] Use upload time as default filter/sort for Trainwreck transactions also show end time, upload time as grid columns --- tailbone/views/trainwreck.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/tailbone/views/trainwreck.py b/tailbone/views/trainwreck.py index dcb7a4a0..e96952dc 100644 --- a/tailbone/views/trainwreck.py +++ b/tailbone/views/trainwreck.py @@ -54,6 +54,8 @@ class TransactionView(MasterView): grid_columns = [ 'start_time', + 'end_time', + 'upload_time', 'system', 'terminal_id', 'receipt_number', @@ -127,10 +129,10 @@ class TransactionView(MasterView): super(TransactionView, self).configure_grid(g) g.filters['receipt_number'].default_active = True g.filters['receipt_number'].default_verb = 'equal' - g.filters['start_time'].default_active = True - g.filters['start_time'].default_verb = 'equal' - g.filters['start_time'].default_value = six.text_type(localtime(self.rattail_config).date()) - g.set_sort_defaults('start_time', 'desc') + g.filters['upload_time'].default_active = True + g.filters['upload_time'].default_verb = 'equal' + g.filters['upload_time'].default_value = six.text_type(localtime(self.rattail_config).date()) + g.set_sort_defaults('upload_time', 'desc') g.set_enum('system', self.enum.TRAINWRECK_SYSTEM) g.set_type('total', 'currency') @@ -139,6 +141,8 @@ class TransactionView(MasterView): g.set_label('customer_id', "Customer ID") g.set_link('start_time') + g.set_link('end_time') + g.set_link('upload_time') g.set_link('receipt_number') g.set_link('customer_id') g.set_link('customer_name') From 68bd3047c41ae7cb4ddeda13b70c38c906e08593 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 12 Jul 2018 22:53:29 -0500 Subject: [PATCH 0887/3196] Add initial support for mobile "quick row" feature, for ordering at least for now, ordering only, but hopefully much more soon... --- tailbone/static/js/tailbone.mobile.js | 18 +++++ tailbone/templates/mobile/base.mako | 8 +- tailbone/templates/mobile/batch/view.mako | 8 -- tailbone/templates/mobile/master/view.mako | 19 +++++ .../templates/mobile/master/view_row.mako | 2 +- tailbone/views/batch/core.py | 15 +++- tailbone/views/master.py | 80 ++++++++++++++++++- tailbone/views/purchasing/batch.py | 7 ++ tailbone/views/purchasing/ordering.py | 2 + tailbone/views/purchasing/receiving.py | 7 -- 10 files changed, 143 insertions(+), 23 deletions(-) diff --git a/tailbone/static/js/tailbone.mobile.js b/tailbone/static/js/tailbone.mobile.js index 2423c9f6..728185d7 100644 --- a/tailbone/static/js/tailbone.mobile.js +++ b/tailbone/static/js/tailbone.mobile.js @@ -98,6 +98,7 @@ $(document).on('click', '#datasync-restart', function() { }); +// TODO: this should go away in favor of quick_row approach // handle global keypress on product batch "row" page, for sake of scanner wedge var product_batch_routes = [ 'mobile.batch.inventory.view', @@ -134,6 +135,23 @@ $(document).on('keypress', function(event) { }); +// handle ENTER press for quick_row forms +$(document).on('keypress', function(event) { + var quick_row = $('.ui-page-active #quick_row_entry'); + if (quick_row.length) { + if (quick_row.is(':focus')) { + if (event.which == 13) { // ENTER + if (quick_row.val()) { + var form = quick_row.parents('form:first'); + form.submit(); + return false; + } + } + } + } +}); + + // when numeric keypad button is clicked, update quantity accordingly $(document).on('click', '.quantity-keypad-thingy .keypad-button', function() { var keypad = $(this).parents('.quantity-keypad-thingy'); diff --git a/tailbone/templates/mobile/base.mako b/tailbone/templates/mobile/base.mako index 8bf656bb..10d365c8 100644 --- a/tailbone/templates/mobile/base.mako +++ b/tailbone/templates/mobile/base.mako @@ -8,9 +8,9 @@ ${h.javascript_link('https://code.jquery.com/jquery-1.12.4.min.js')} ${h.javascript_link('https://code.jquery.com/mobile/1.4.5/jquery.mobile-1.4.5.min.js')} - ${h.javascript_link(request.static_url('tailbone:static/js/jquery.ui.tailbone.mobile.js'))} - ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.mobile.js'))} - ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.mobile.receiving.js'))} + ${h.javascript_link(request.static_url('tailbone:static/js/jquery.ui.tailbone.mobile.js') + '?ver={}'.format(tailbone.__version__))} + ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.mobile.js') + '?ver={}'.format(tailbone.__version__))} + ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.mobile.receiving.js') + '?ver={}'.format(tailbone.__version__))} ${self.extra_javascript()} ## since jquery mobile will "utterly cache" the first page which is loaded @@ -33,7 +33,7 @@ % endif ${h.stylesheet_link('https://code.jquery.com/mobile/1.4.5/jquery.mobile-1.4.5.min.css')} - ${h.stylesheet_link(request.static_url('tailbone:static/css/mobile.css'))} + ${h.stylesheet_link(request.static_url('tailbone:static/css/mobile.css') + '?ver={}'.format(tailbone.__version__))} % if not request.rattail_config.production(): + + +
      + ${h.form(form.action_url, id=dform.formid, method='post', class_='autodisable')} + ${h.csrf_token(request)} + + % if dform.error: +
      +
      + + Please see errors below. +
      +
      + + ${dform.error} +
      +
      + % endif + +
      +
      + +
      + ${h.select('settings-group', current_group, group_options, **{'auto-enhance': 'true'})} + ## ${h.select('settings-group', current_group, group_options)} +
      +
      +
      + + % for group in groups: +
      +

      ${group}

      +
      + + % for setting in settings: + % if setting.group == group: + <% field = dform[setting.node_name] %> + +
      + % if field.error: +
      + % for msg in field.error.messages(): + ${msg} + % endfor +
      + % endif +
      + +
      + ${field.serialize()|n} +
      +
      + % if form.has_helptext(field.name): + ${form.render_helptext(field.name)} + % endif +
      + % endif + % endfor + +
      +
      + % endfor + +
      + ${h.submit('save', getattr(form, 'submit_label', getattr(form, 'save_label', "Submit")))} + ${h.link_to("Cancel", form.cancel_url, class_='cancel button{}'.format(' autodisable' if form.auto_disable_cancel else ''))} +
      + + ${h.end_form()} +
      diff --git a/tailbone/templates/base.mako b/tailbone/templates/base.mako index cf348b3f..f4a18475 100644 --- a/tailbone/templates/base.mako +++ b/tailbone/templates/base.mako @@ -51,6 +51,9 @@ ${grid_index_nav()} % endif % endif + % elif index_title: + » + ${index_title} % endif % endif
      - +
      ${field.serialize()|n}
      diff --git a/tailbone/views/settings.py b/tailbone/views/settings.py index 4bc135a7..ee412bac 100644 --- a/tailbone/views/settings.py +++ b/tailbone/views/settings.py @@ -28,11 +28,18 @@ from __future__ import unicode_literals, absolute_import import re -from rattail.db import model +import six + +from rattail.db import model, api +from rattail.settings import Setting +from rattail.util import import_module_path import colander +from webhelpers2.html import tags -from tailbone.views import MasterView +from tailbone import forms +from tailbone.db import Session +from tailbone.views import MasterView, View class SettingsView(MasterView): @@ -77,5 +84,116 @@ class SettingsView(MasterView): return True +class AppSettingsForm(forms.Form): + + def get_label(self, key): + return self.labels.get(key, key) + + +class AppSettingsView(View): + """ + Core view which exposes "app settings" - aka. admin-friendly settings with + descriptions and type-specific form controls etc. + """ + + def __call__(self): + settings = sorted(self.iter_known_settings(), + key=lambda setting: (setting.group, + setting.namespace, + setting.name)) + groups = sorted(set([setting.group for setting in settings])) + current_group = None + + form = self.make_form(settings) + form.cancel_url = self.request.current_route_url() + if form.validate(newstyle=True): + self.save_form(form) + group = self.request.POST.get('settings-group') + if group: + self.request.session['appsettings.current_group'] = group + self.request.session.flash("App Settings have been saved.") + return self.redirect(self.request.current_route_url()) + + if self.request.method == 'POST': + current_group = self.request.POST.get('settings-group') + + if not current_group: + current_group = self.request.session.get('appsettings.current_group') + + group_options = [tags.Option(group, group) for group in groups] + group_options.insert(0, tags.Option("(All)", "(All)")) + return { + 'index_title': "App Settings", + 'form': form, + 'dform': form.make_deform_form(), + 'groups': groups, + 'group_options': group_options, + 'current_group': current_group, + 'settings': settings, + } + + def make_form(self, known_settings): + schema = colander.MappingSchema() + helptext = {} + for setting in known_settings: + kwargs = { + 'name': setting.node_name, + 'default': self.get_setting_value(setting), + } + if setting.choices: + kwargs['validator'] = colander.OneOf(setting.choices) + kwargs['widget'] = forms.widgets.JQuerySelectWidget( + values=[(val, val) for val in setting.choices]) + schema.add(colander.SchemaNode(self.get_node_type(setting), **kwargs)) + helptext[setting.node_name] = setting.__doc__.strip() + return AppSettingsForm(schema=schema, request=self.request, helptext=helptext) + + def get_node_type(self, setting): + if setting.data_type is bool: + return colander.Bool() + return colander.String() + + def save_form(self, form): + for setting in self.iter_known_settings(): + value = form.validated[setting.node_name] + self.save_setting_value(setting, value) + + def iter_known_settings(self): + """ + Iterate over all known settings. + """ + for module in self.rattail_config.getlist('rattail', 'settings', default=['rattail.settings']): + module = import_module_path(module) + for name in dir(module): + obj = getattr(module, name) + if isinstance(obj, type) and issubclass(obj, Setting) and obj is not Setting: + # NOTE: we set this here, and reference it elsewhere + obj.node_name = self.get_node_name(obj) + yield obj + + def get_node_name(self, setting): + return '[{}] {}'.format(setting.namespace, setting.name) + + def get_setting_value(self, setting): + if setting.data_type is bool: + return self.rattail_config.getbool(setting.namespace, setting.name) + return self.rattail_config.get(setting.namespace, setting.name, default='') + + def save_setting_value(self, setting, value): + legacy_name = '{}.{}'.format(setting.namespace, setting.name) + if setting.data_type is bool: + api.save_setting(Session(), legacy_name, 'true' if value else 'false') + else: + api.save_setting(Session(), legacy_name, six.text_type(value)) + + @classmethod + def defaults(cls, config): + config.add_route('appsettings', '/settings/app/') + config.add_view(cls, route_name='appsettings', + renderer='/appsettings.mako', + permission='settings.edit') + + def includeme(config): + AppSettingsView.defaults(config) SettingsView.defaults(config) From 117e52df23b6822afca01746874303c02bbf7ff9 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 18 Jul 2018 13:24:51 -0500 Subject: [PATCH 0901/3196] Remove unwanted line --- tailbone/templates/appsettings.mako | 1 - 1 file changed, 1 deletion(-) diff --git a/tailbone/templates/appsettings.mako b/tailbone/templates/appsettings.mako index b52d3b5c..b2ce2a44 100644 --- a/tailbone/templates/appsettings.mako +++ b/tailbone/templates/appsettings.mako @@ -51,7 +51,6 @@
      ${h.select('settings-group', current_group, group_options, **{'auto-enhance': 'true'})} - ## ${h.select('settings-group', current_group, group_options)}
      From c2968fbe52c380e8fb9fc288b2a44b9de3a09a4e Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 18 Jul 2018 13:50:32 -0500 Subject: [PATCH 0902/3196] Don't save any App Settings for which value would not change that lets us avoid writing "redundant" values to the database, whereas in fact the underlying value may be coming from config file --- tailbone/templates/appsettings.mako | 2 +- tailbone/views/settings.py | 12 +++++++----- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/tailbone/templates/appsettings.mako b/tailbone/templates/appsettings.mako index b2ce2a44..97cc97ae 100644 --- a/tailbone/templates/appsettings.mako +++ b/tailbone/templates/appsettings.mako @@ -1,7 +1,7 @@ ## -*- coding: utf-8; -*- <%inherit file="/base.mako" /> -<%def name="title()">${self.app_title()} App Settings +<%def name="title()">App Settings <%def name="content_title()"> diff --git a/tailbone/views/settings.py b/tailbone/views/settings.py index ee412bac..a5da2d6e 100644 --- a/tailbone/views/settings.py +++ b/tailbone/views/settings.py @@ -180,11 +180,13 @@ class AppSettingsView(View): return self.rattail_config.get(setting.namespace, setting.name, default='') def save_setting_value(self, setting, value): - legacy_name = '{}.{}'.format(setting.namespace, setting.name) - if setting.data_type is bool: - api.save_setting(Session(), legacy_name, 'true' if value else 'false') - else: - api.save_setting(Session(), legacy_name, six.text_type(value)) + existing = self.get_setting_value(setting) + if existing != value: + legacy_name = '{}.{}'.format(setting.namespace, setting.name) + if setting.data_type is bool: + api.save_setting(Session(), legacy_name, 'true' if value else 'false') + else: + api.save_setting(Session(), legacy_name, six.text_type(value)) @classmethod def defaults(cls, config): From 87ba8026e5c1f127c7c20e6ca0397c0e1a84c47e Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 18 Jul 2018 13:53:24 -0500 Subject: [PATCH 0903/3196] Don't use empty string as default setting value should just fall back to None as per usual --- tailbone/views/settings.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tailbone/views/settings.py b/tailbone/views/settings.py index a5da2d6e..c82351fc 100644 --- a/tailbone/views/settings.py +++ b/tailbone/views/settings.py @@ -177,16 +177,17 @@ class AppSettingsView(View): def get_setting_value(self, setting): if setting.data_type is bool: return self.rattail_config.getbool(setting.namespace, setting.name) - return self.rattail_config.get(setting.namespace, setting.name, default='') + return self.rattail_config.get(setting.namespace, setting.name) def save_setting_value(self, setting, value): existing = self.get_setting_value(setting) if existing != value: legacy_name = '{}.{}'.format(setting.namespace, setting.name) if setting.data_type is bool: - api.save_setting(Session(), legacy_name, 'true' if value else 'false') + value = 'true' if value else 'false' else: - api.save_setting(Session(), legacy_name, six.text_type(value)) + value = six.text_type(value) + api.save_setting(Session(), legacy_name, value) @classmethod def defaults(cls, config): From 34bdd2ac84e7aed32ef76a395cb3c66dfa02aa16 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 18 Jul 2018 16:25:54 -0500 Subject: [PATCH 0904/3196] Add (restore?) basic support for mobile receiving from PO --- tailbone/static/js/tailbone.mobile.js | 12 +- .../static/js/tailbone.mobile.receiving.js | 22 ++-- .../templates/mobile/receiving/create.mako | 35 ++++-- tailbone/views/purchasing/batch.py | 4 +- tailbone/views/purchasing/receiving.py | 116 +++++++++++++----- 5 files changed, 122 insertions(+), 67 deletions(-) diff --git a/tailbone/static/js/tailbone.mobile.js b/tailbone/static/js/tailbone.mobile.js index 0c8ebee0..c168543a 100644 --- a/tailbone/static/js/tailbone.mobile.js +++ b/tailbone/static/js/tailbone.mobile.js @@ -57,7 +57,7 @@ function setfocus() { var queries = [ '#username', '#new-purchasing-batch-vendor-text', - // '.receiving-upc-search', + '#new-receiving-batch-vendor-text', ]; $.each(queries, function(i, query) { el = $(query); @@ -92,16 +92,6 @@ $(document).on('click', 'form[name="new-purchasing-batch"] input[type="submit"]' } }); -// submit new purchasing batch form on Purchase click -$(document).on('click', 'form[name="new-purchasing-batch"] [data-role="listview"] a', function() { - var $form = $(this).parents('form'); - var $field = $form.find('[name="purchase"]'); - var uuid = $(this).parents('li').data('uuid'); - $field.val(uuid); - $form.submit(); - return false; -}); - // disable datasync restart button when clicked $(document).on('click', '#datasync-restart', function() { diff --git a/tailbone/static/js/tailbone.mobile.receiving.js b/tailbone/static/js/tailbone.mobile.receiving.js index 3075e00a..452a99f9 100644 --- a/tailbone/static/js/tailbone.mobile.receiving.js +++ b/tailbone/static/js/tailbone.mobile.receiving.js @@ -8,29 +8,31 @@ ************************************************************/ -// TODO: this is really just for receiving; should change form name? -$(document).on('autocompleteitemselected', 'form[name="new-purchasing-batch"] .vendor', function(event, uuid) { +// toggle visibility of "Receive" type buttons based on whether vendor is set +$(document).on('autocompleteitemselected', 'form[name="new-receiving-batch"] .vendor', function(event, uuid) { $('#new-receiving-types').show(); }); - - -// TODO: this is really just for receiving; should change form name? -$(document).on('autocompleteitemcleared', 'form[name="new-purchasing-batch"] .vendor', function(event) { +$(document).on('autocompleteitemcleared', 'form[name="new-receiving-batch"] .vendor', function(event) { $('#new-receiving-types').hide(); }); -$(document).on('click', 'form[name="new-purchasing-batch"] #receive-truck-dump', function() { +// submit new receiving batch form when user clicks "Receive" type button +$(document).on('click', 'form[name="new-receiving-batch"] .start-receiving', function() { var form = $(this).parents('form'); - form.find('input[name="workflow"]').val('truck_dump'); + form.find('input[name="workflow"]').val($(this).data('workflow')); form.submit(); }); -$(document).on('click', 'form[name="new-purchasing-batch"] #receive-from-scratch', function() { +// submit new receiving batch form when user clicks Purchase Order option +$(document).on('click', 'form[name="new-receiving-batch"] [data-role="listview"] a', function() { var form = $(this).parents('form'); - form.find('input[name="workflow"]').val('from_scratch'); + var key = $(this).parents('li').data('key'); + form.find('[name="workflow"]').val('from_po'); + form.find('.purchase-order-field').val(key); form.submit(); + return false; }); diff --git a/tailbone/templates/mobile/receiving/create.mako b/tailbone/templates/mobile/receiving/create.mako index d5e49b1b..668ab69f 100644 --- a/tailbone/templates/mobile/receiving/create.mako +++ b/tailbone/templates/mobile/receiving/create.mako @@ -5,16 +5,16 @@ <%def name="page_title()">${h.link_to("Receiving", url('mobile.receiving'))} » New Batch -${h.form(request.current_route_url(), class_='ui-filterable', name='new-purchasing-batch')} +${h.form(form.action_url, class_='ui-filterable', name='new-receiving-batch')} ${h.csrf_token(request)} -% if vendor is Undefined: +% if phase == 1:
      ${h.hidden('vendor')} - ${h.text('new-purchasing-batch-vendor-text', placeholder="Vendor name", autocomplete='off', **{'data-type': 'search'})} -
        + ${h.text('new-receiving-batch-vendor-text', placeholder="Vendor name", autocomplete='off', **{'data-type': 'search'})} +
          @@ -24,25 +24,29 @@ ${h.csrf_token(request)} -% else: ## vendor is known +% else: ## phase 2 + + ${h.hidden('workflow')} + ${h.hidden('phase', value='2')}
          +
          ${h.hidden('vendor', value=vendor.uuid)} ${vendor} @@ -50,17 +54,22 @@ ${h.csrf_token(request)}
          % if purchases: - ${h.hidden('purchase')} + ${h.hidden(purchase_order_fieldname, class_='purchase-order-field')} +

          Please choose a Purchase Order to receive:

            - % for uuid, purchase in purchases: -
          • ${h.link_to(purchase, '#')}
          • + % for key, purchase in purchases: +
          • ${h.link_to(purchase, '#')}
          • % endfor
          % else:

          (no eligible purchases found)

          % endif - ## ${h.link_to("Receive from scratch for {}".format(vendor), '#', class_='ui-btn ui-corner-all')} + % if master.allow_from_scratch: + + % endif + + ${h.link_to("Cancel", url('mobile.{}'.format(route_prefix)), class_='ui-btn ui-corner-all')} % endif diff --git a/tailbone/views/purchasing/batch.py b/tailbone/views/purchasing/batch.py index 1c711854..8941de1c 100644 --- a/tailbone/views/purchasing/batch.py +++ b/tailbone/views/purchasing/batch.py @@ -560,9 +560,11 @@ class PurchasingBatchView(BatchMasterView): if self.batch_mode in (self.enum.PURCHASE_BATCH_MODE_RECEIVING, self.enum.PURCHASE_BATCH_MODE_COSTING): - if batch.purchase_uuid: + purchase = batch.purchase + if not purchase and batch.purchase_uuid: purchase = self.Session.query(model.Purchase).get(batch.purchase_uuid) assert purchase + if purchase: kwargs['purchase'] = purchase kwargs['buyer'] = purchase.buyer kwargs['buyer_uuid'] = purchase.buyer_uuid diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index 589e27d0..c27f3d78 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -66,8 +66,19 @@ class MobileItemStatusFilter(grids.filters.MobileFilter): # TODO: is this accurate (enough) ? if value == 'incomplete': - return query.filter(sa.or_(model.PurchaseBatchRow.cases_ordered != 0, model.PurchaseBatchRow.units_ordered != 0))\ - .filter(model.PurchaseBatchRow.status_code != model.PurchaseBatchRow.STATUS_OK) + return query.filter(sa.or_(model.PurchaseBatchRow.cases_ordered != 0, + model.PurchaseBatchRow.units_ordered != 0))\ + .filter(~model.PurchaseBatchRow.status_code.in_(( + model.PurchaseBatchRow.STATUS_OK, + model.PurchaseBatchRow.STATUS_PRODUCT_NOT_FOUND))) + + if value == 'invalid': + return query.filter(model.PurchaseBatchRow.status_code.in_(( + model.PurchaseBatchRow.STATUS_PRODUCT_NOT_FOUND, + model.PurchaseBatchRow.STATUS_COST_NOT_FOUND, + model.PurchaseBatchRow.STATUS_CASE_QUANTITY_UNKNOWN, + model.PurchaseBatchRow.STATUS_CASE_QUANTITY_DIFFERS, + ))) if value == 'unexpected': return query.filter(sa.and_( @@ -118,6 +129,8 @@ class ReceivingBatchView(PurchasingBatchView): default_uom_is_case = True + purchase_order_fieldname = 'purchase' + labels = { 'truck_dump_batch': "Truck Dump Parent", 'invoice_parser_key': "Invoice Parser", @@ -363,18 +376,7 @@ class ReceivingBatchView(PurchasingBatchView): def get_batch_kwargs(self, batch, mobile=False): kwargs = super(ReceivingBatchView, self).get_batch_kwargs(batch, mobile=mobile) - - if mobile: - if 'purchase' in self.request.POST: - purchase = self.get_purchase(self.request.POST['purchase']) - if isinstance(purchase, model.Purchase): - kwargs['purchase'] = purchase - - department = self.department_for_purchase(purchase) - if department: - kwargs['department'] = department - - else: # not mobile + if not mobile: batch_type = self.request.POST['batch_type'] if batch_type == 'from_scratch': kwargs.pop('truck_dump_batch', None) @@ -516,10 +518,10 @@ class ReceivingBatchView(PurchasingBatchView): # visible filter options will depend on whether batch came from purchase if batch.order_quantities_known: - value_choices = ['incomplete', 'unexpected', 'damaged', 'expired', 'all'] + value_choices = ['incomplete', 'unexpected', 'damaged', 'expired', 'invalid', 'all'] default_status = 'incomplete' else: - value_choices = ['received', 'damaged', 'expired', 'all'] + value_choices = ['received', 'damaged', 'expired', 'invalid', 'all'] default_status = 'all' # remove 'expired' filter option if not relevant @@ -540,10 +542,12 @@ class ReceivingBatchView(PurchasingBatchView): """ mode = self.batch_mode data = {'mode': mode} + phase = 1 schema = MobileNewReceivingBatch().bind(session=self.Session()) form = forms.Form(schema=schema, request=self.request) if form.validate(newstyle=True): + phase = form.validated['phase'] if form.validated['workflow'] == 'from_scratch': if not self.allow_from_scratch: @@ -556,7 +560,7 @@ class ReceivingBatchView(PurchasingBatchView): batch.date_received = localtime(self.rattail_config).date() kwargs = self.get_batch_kwargs(batch, mobile=True) batch = self.handler.make_batch(self.Session(), **kwargs) - return self.redirect(self.request.route_url('mobile.receiving.view', uuid=batch.uuid)) + return self.redirect(self.get_action_url('view', batch, mobile=True)) elif form.validated['workflow'] == 'truck_dump': if not self.allow_truck_dump: @@ -565,44 +569,85 @@ class ReceivingBatchView(PurchasingBatchView): batch.store = self.rattail_config.get_store(self.Session()) batch.mode = mode batch.truck_dump = True - batch.vendor = self.Session.merge(form.validated['vendor']) + batch.vendor = self.Session.query(model.Vendor).get(form.validated['vendor']) batch.created_by = self.request.user batch.date_received = localtime(self.rattail_config).date() kwargs = self.get_batch_kwargs(batch, mobile=True) batch = self.handler.make_batch(self.Session(), **kwargs) - return self.redirect(self.request.route_url('mobile.receiving.view', uuid=batch.uuid)) + return self.redirect(self.get_action_url('view', batch, mobile=True)) - else: - raise NotImplementedError("Requested workflow not supported: {}".format(form.validated['workflow'])) + elif form.validated['workflow'] == 'from_po': + if not self.allow_from_po: + raise NotImplementedError("Requested workflow not supported: from_po") - vendor = None - if self.request.method == 'POST' and self.request.POST.get('vendor'): - vendor = self.Session.query(model.Vendor).get(self.request.POST['vendor']) - if vendor: + vendor = self.Session.query(model.Vendor).get(form.validated['vendor']) data['vendor'] = vendor - if self.request.POST.get('purchase'): - purchase = self.get_purchase(self.request.POST['purchase']) - if purchase: - + schema = self.make_mobile_receiving_from_po_schema() + po_form = forms.Form(schema=schema, request=self.request) + if phase == 2: + if po_form.validate(newstyle=True): batch = self.model_class() + batch.store = self.rattail_config.get_store(self.Session()) batch.mode = mode batch.vendor = vendor - batch.store = self.rattail_config.get_store(self.Session()) batch.buyer = self.request.user.employee batch.created_by = self.request.user + batch.date_received = localtime(self.rattail_config).date() + self.assign_purchase_order(batch, po_form) kwargs = self.get_batch_kwargs(batch, mobile=True) batch = self.handler.make_batch(self.Session(), **kwargs) if self.handler.should_populate(batch): self.handler.populate(batch) - return self.redirect(self.request.route_url('mobile.receiving.view', uuid=batch.uuid)) + return self.redirect(self.get_action_url('view', batch, mobile=True)) + else: + phase = 2 + + else: + raise NotImplementedError("Requested workflow not supported: {}".format(form.validated['workflow'])) + + data['form'] = form + data['dform'] = form.make_deform_form() data['mode_title'] = self.enum.PURCHASE_BATCH_MODE[mode].capitalize() - if vendor: + data['phase'] = phase + if phase == 2: purchases = self.eligible_purchases(vendor.uuid, mode=mode) data['purchases'] = [(p['key'], p['display']) for p in purchases['purchases']] + data['purchase_order_fieldname'] = self.purchase_order_fieldname return self.render_to_response('create', data, mobile=True) + def make_mobile_receiving_from_po_schema(self): + schema = colander.MappingSchema() + schema.add(colander.SchemaNode(colander.String(), + name=self.purchase_order_fieldname, + validator=self.validate_purchase)) + return schema.bind(session=self.Session()) + + @staticmethod + @colander.deferred + def validate_purchase(node, kw): + session = kw['session'] + def validate(node, value): + purchase = session.query(model.Purchase).get(value) + if not purchase: + raise colander.Invalid(node, "Purchase not found") + return purchase.uuid + return validate + + def assign_purchase_order(self, batch, po_form): + """ + Assign the original purchase order to the given batch. Default + behavior assumes a Rattail Purchase object is what we're after. + """ + purchase = self.get_purchase(po_form.validated[self.purchase_order_fieldname]) + if isinstance(purchase, model.Purchase): + batch.purchase = purchase + + department = self.department_for_purchase(purchase) + if department: + batch.department = department + def configure_mobile_form(self, f): super(ReceivingBatchView, self).configure_mobile_form(f) batch = f.model_instance @@ -950,6 +995,13 @@ class MobileNewReceivingBatch(colander.MappingSchema): 'truck_dump', ])) + phase = colander.SchemaNode(colander.Int()) + + +class MobileNewReceivingFromPO(colander.MappingSchema): + + purchase = colander.SchemaNode(colander.String()) + # TODO: this is a stopgap measure to fix an obvious bug, which exists when the # session is not provided by the view at runtime (i.e. when it was instead From 5db7d3776ae2675b1a30a98da61d8d4355839d00 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 18 Jul 2018 21:06:07 -0500 Subject: [PATCH 0905/3196] Expose status etc. when editing upgrade, rename Email Settings i.e. latter is renamed from Email Profiles, but within UI only for now --- .../profiles => settings/email}/view.mako | 0 tailbone/templates/upgrades/view.mako | 2 +- tailbone/views/email.py | 4 ++-- tailbone/views/upgrades.py | 18 +++++++++++++----- 4 files changed, 16 insertions(+), 8 deletions(-) rename tailbone/templates/{email/profiles => settings/email}/view.mako (100%) diff --git a/tailbone/templates/email/profiles/view.mako b/tailbone/templates/settings/email/view.mako similarity index 100% rename from tailbone/templates/email/profiles/view.mako rename to tailbone/templates/settings/email/view.mako diff --git a/tailbone/templates/upgrades/view.mako b/tailbone/templates/upgrades/view.mako index 1c9b3ec5..dfc1646a 100644 --- a/tailbone/templates/upgrades/view.mako +++ b/tailbone/templates/upgrades/view.mako @@ -38,7 +38,7 @@ ${parent.body()} -% if not instance.executed and request.has_perm('{}.execute'.format(permission_prefix)): +% if not instance.executed and instance.status_code == enum.UPGRADE_STATUS_PENDING and request.has_perm('{}.execute'.format(permission_prefix)):
          % if instance.enabled and not instance.executing: ${h.form(url('{}.execute'.format(route_prefix), uuid=instance.uuid), class_='autodisable')} diff --git a/tailbone/views/email.py b/tailbone/views/email.py index b6f7b946..f9a3516d 100644 --- a/tailbone/views/email.py +++ b/tailbone/views/email.py @@ -45,9 +45,9 @@ class ProfilesView(MasterView): Master view for email admin (settings/preview). """ normalized_model_name = 'emailprofile' - model_title = "Email Profile" + model_title = "Email Setting" model_key = 'key' - url_prefix = '/email/profiles' + url_prefix = '/settings/email' filterable = False pageable = False creatable = False diff --git a/tailbone/views/upgrades.py b/tailbone/views/upgrades.py index 2bb58006..04df5e79 100644 --- a/tailbone/views/upgrades.py +++ b/tailbone/views/upgrades.py @@ -95,6 +95,7 @@ class UpgradeView(MasterView): 'created', 'created_by', 'enabled', + 'executing', 'executed', 'executed_by', 'status_code', @@ -153,7 +154,18 @@ class UpgradeView(MasterView): def configure_form(self, f): super(UpgradeView, self).configure_form(f) - f.set_enum('status_code', self.enum.UPGRADE_STATUS) + + # status_code + if self.creating: + f.remove_field('status_code') + else: + f.set_enum('status_code', self.enum.UPGRADE_STATUS) + # f.set_readonly('status_code') + + # executing + if not self.editing: + f.remove('executing') + f.set_type('created', 'datetime') f.set_type('enabled', 'boolean') f.set_type('executed', 'datetime') @@ -172,10 +184,6 @@ class UpgradeView(MasterView): f.remove_field('created_by') f.remove_field('stdout_file') f.remove_field('stderr_file') - if self.creating: - f.remove_field('status_code') - else: - f.set_readonly('status_code') if self.creating or not upgrade.executed: f.remove_field('executed') f.remove_field('executed_by') From fba7c5f9788d6eb7946efc2111b09f47261139e1 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 19 Jul 2018 17:39:29 -0500 Subject: [PATCH 0906/3196] Update changelog --- CHANGES.rst | 34 ++++++++++++++++++++++++++++++++++ tailbone/_version.py | 2 +- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 118acbfc..c2962efa 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,40 @@ CHANGELOG ========= +0.7.27 (2018-07-19) +------------------- + +* Use upload time as default filter/sort for Trainwreck transactions. + +* Add initial support for mobile "quick row" feature, for ordering. + +* Add product grid filters for "on hand", "on order". + +* Don't make customer ID readonly when editing. + +* Fix Person.customers readonly field for python 3. + +* Traverse master class hierarchy to collect all defined labels. + +* Add 'person' column for customers grid. + +* Fix how we check file size when reading stdout for upgrade. + +* Add runtime ``mobile`` flag for ``MasterView``. + +* Improve basic mobile views for customers, people. + +* Refactor mobile receiving to use "quick row" feature. + +* Improve support for "receive from scratch" workflow, esp. for mobile. + +* Add (admin-friendly!) view to manage some App Settings. + +* Add (restore?) basic support for mobile receiving from PO. + +* Expose status etc. when editing upgrade; rename Email Settings. + + 0.7.26 (2018-07-11) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 8d608f1d..eee3cb50 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.7.26' +__version__ = '0.7.27' From 6b3e645c12c842c8a7ad33246617578ab11841d2 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 19 Jul 2018 17:41:17 -0500 Subject: [PATCH 0907/3196] Allow skipping of tests during release sometimes ya just gotta do it --- tasks.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tasks.py b/tasks.py index a363bf34..c9f5c4a2 100644 --- a/tasks.py +++ b/tasks.py @@ -32,10 +32,12 @@ from invoke import task @task -def release(ctx): +def release(ctx, skip_tests=False): """ Release a new version of 'Tailbone'. """ - ctx.run('tox') + if not skip_tests: + ctx.run('tox') + shutil.rmtree('Tailbone.egg-info') ctx.run('python setup.py sdist --formats=gztar upload') From 634a93061bfa66eb3cdfba17302e4d135c747372 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 24 Jul 2018 21:29:52 -0500 Subject: [PATCH 0908/3196] Let mobile form declare if/how to auto-focus a field and for mobile ordering, auto-focus the "units" field when editing a row --- tailbone/forms/core.py | 5 ++++- tailbone/static/js/tailbone.mobile.js | 18 +++++++++++++++++- tailbone/templates/mobile/master/edit_row.mako | 8 -------- tailbone/views/purchasing/ordering.py | 6 ++++++ 4 files changed, 27 insertions(+), 10 deletions(-) diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index 4ce15b14..0f7211c4 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.py @@ -330,7 +330,7 @@ class Form(object): def __init__(self, fields=None, schema=None, request=None, mobile=False, readonly=False, readonly_fields=[], model_instance=None, model_class=None, nodes={}, enums={}, labels={}, renderers=None, - hidden={}, widgets={}, defaults={}, validators={}, required={}, helptext={}, + hidden={}, widgets={}, defaults={}, validators={}, required={}, helptext={}, focus_spec=None, action_url=None, cancel_url=None): self.fields = None @@ -362,6 +362,7 @@ class Form(object): self.validators = validators or {} self.required = required or {} self.helptext = helptext or {} + self.focus_spec = focus_spec self.action_url = action_url self.cancel_url = cancel_url @@ -717,6 +718,8 @@ class Form(object): # TODO: deprecate / remove the latter option here if self.auto_disable_save or self.auto_disable: context['form_kwargs']['class_'] = 'autodisable' + if self.focus_spec: + context['form_kwargs']['data-focus'] = self.focus_spec context['request'] = self.request context['readonly_fields'] = self.readonly_fields context['render_field_readonly'] = self.render_field_readonly diff --git a/tailbone/static/js/tailbone.mobile.js b/tailbone/static/js/tailbone.mobile.js index c168543a..6ebbac06 100644 --- a/tailbone/static/js/tailbone.mobile.js +++ b/tailbone/static/js/tailbone.mobile.js @@ -50,7 +50,8 @@ $(document).on('autocompleteitemselected', function(event, uuid) { /** * Automatically set focus to certain fields, on various pages - * TODO: this should accept selector params instead of hard-coding..? + * TODO: should be letting the form declare a "focus spec" instead, to avoid + * hard-coding these field names below! */ function setfocus() { var el = null; @@ -73,6 +74,21 @@ $(document).on('pageshow', function() { setfocus(); + // if current page has form, which has declared a "focus spec", then try to + // set focus accordingly + var form = $('.ui-page-active form'); + if (form) { + var spec = form.data('focus'); + if (spec) { + var input = $(spec); + if (input) { + if (input.is(':visible')) { + input.focus(); + } + } + } + } + }); diff --git a/tailbone/templates/mobile/master/edit_row.mako b/tailbone/templates/mobile/master/edit_row.mako index 3c81eb55..93eb12e3 100644 --- a/tailbone/templates/mobile/master/edit_row.mako +++ b/tailbone/templates/mobile/master/edit_row.mako @@ -5,15 +5,7 @@ <%def name="page_title()">${h.link_to(index_title, index_url)} » ${h.link_to(parent_title, parent_url)} » ${h.link_to(instance_title, instance_url)} » Edit -## TODO: this should not be necessary, correct? -## <%def name="buttons()"> -##
          -## ${h.submit('create', form.update_label)} -## ${h.link_to("Cancel", form.cancel_url, class_='ui-btn ui-corner-all')} -## -
          -## ${form.render(buttons=capture(self.buttons))|n} ${form.render()|n}
          diff --git a/tailbone/views/purchasing/ordering.py b/tailbone/views/purchasing/ordering.py index e3b624af..b3c06aeb 100644 --- a/tailbone/views/purchasing/ordering.py +++ b/tailbone/views/purchasing/ordering.py @@ -321,6 +321,12 @@ class OrderingBatchView(PurchasingBatchView): data['mode_title'] = self.enum.PURCHASE_BATCH_MODE[mode].capitalize() return self.render_to_response('create', data, mobile=True) + def configure_mobile_row_form(self, f): + super(OrderingBatchView, self).configure_mobile_row_form(f) + if self.editing: + # TODO: probably should take `allow_cases` into account here... + f.focus_spec = '[name="units_ordered"]' + def download_excel(self): """ Download ordering batch as Excel spreadsheet. From d145ce5f6d3a1b5813dc641f8fd8c45d4bd30339 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 25 Jul 2018 12:46:51 -0500 Subject: [PATCH 0909/3196] Assign purchase to new receiving batch via uuid instead of object ref the latter was apparently causing session flush and would create the "dummy" batch in addition to the "real" one... --- tailbone/views/purchasing/receiving.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index c27f3d78..b6e24dd8 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -642,11 +642,11 @@ class ReceivingBatchView(PurchasingBatchView): """ purchase = self.get_purchase(po_form.validated[self.purchase_order_fieldname]) if isinstance(purchase, model.Purchase): - batch.purchase = purchase + batch.purchase_uuid = purchase.uuid department = self.department_for_purchase(purchase) if department: - batch.department = department + batch.department_uuid = department.uuid def configure_mobile_form(self, f): super(ReceivingBatchView, self).configure_mobile_form(f) From 6af9440ed7793429dd849feb090a8b1f16fb0dcb Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 25 Jul 2018 16:23:10 -0500 Subject: [PATCH 0910/3196] Fix permission group label for Ordering Batches a minor annoyance, but consistency surely is better... --- tailbone/views/purchasing/ordering.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/tailbone/views/purchasing/ordering.py b/tailbone/views/purchasing/ordering.py index b3c06aeb..65398ade 100644 --- a/tailbone/views/purchasing/ordering.py +++ b/tailbone/views/purchasing/ordering.py @@ -360,17 +360,15 @@ class OrderingBatchView(PurchasingBatchView): return response @classmethod - def defaults(cls, config): + def _ordering_defaults(cls, config): route_prefix = cls.get_route_prefix() url_prefix = cls.get_url_prefix() permission_prefix = cls.get_permission_prefix() - model_key = cls.get_model_key() model_title = cls.get_model_title() + model_title_plural = cls.get_model_title_plural() - # defaults - cls._purchasing_defaults(config) - cls._batch_defaults(config) - cls._defaults(config) + # fix permission group label + config.add_tailbone_permission_group(permission_prefix, model_title_plural) # download as Excel config.add_route('{}.download_excel'.format(route_prefix), '{}/{{uuid}}/excel'.format(url_prefix)) @@ -379,6 +377,13 @@ class OrderingBatchView(PurchasingBatchView): config.add_tailbone_permission(permission_prefix, '{}.download_excel'.format(permission_prefix), "Download {} as Excel".format(model_title)) + @classmethod + def defaults(cls, config): + cls._ordering_defaults(config) + cls._purchasing_defaults(config) + cls._batch_defaults(config) + cls._defaults(config) + def includeme(config): OrderingBatchView.defaults(config) From f6712a66866116fa6c2e2596267b7d67ec158512 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 26 Jul 2018 13:33:21 -0500 Subject: [PATCH 0911/3196] Redirect to "view parent" after deleting a row not sure why that was redirecting to "edit parent" before...weird --- tailbone/views/master.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/views/master.py b/tailbone/views/master.py index a642f636..84ea376d 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -2721,7 +2721,7 @@ class MasterView(View): if not row: raise self.notfound() self.delete_row_object(row) - return self.redirect(self.get_action_url('edit', self.get_parent(row))) + return self.redirect(self.get_action_url('view', self.get_parent(row))) def mobile_delete_row(self): """ From 8d77111b061cc5bed177f6ab1ffbfb0eeec917b5 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 26 Jul 2018 13:50:15 -0500 Subject: [PATCH 0912/3196] 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 c2962efa..f985a80a 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,18 @@ CHANGELOG ========= +0.7.28 (2018-07-26) +------------------- + +* Let mobile form declare if/how to auto-focus a field. + +* Assign purchase to new receiving batch via uuid instead of object ref. + +* Fix permission group label for Ordering Batches. + +* Redirect to "view parent" after deleting a row. + + 0.7.27 (2018-07-19) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index eee3cb50..7eae0f36 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.7.27' +__version__ = '0.7.28' From e43f713a664de00b7b4371f5b40a90d9a276ebd1 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 26 Jul 2018 21:35:15 -0500 Subject: [PATCH 0913/3196] Various tweaks for arbitrary model view with "rows" just needed these for a particular feature... --- tailbone/forms/core.py | 4 ++-- tailbone/views/master.py | 16 +++++++++------- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index 0f7211c4..e9c33b11 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.py @@ -839,9 +839,9 @@ class Form(object): else: # legacy behavior raise_error = kwargs.pop('raise_error', True) - form = self.make_deform_form() + dform = self.make_deform_form() try: - return form.validate(*args, **kwargs) + return dform.validate(*args, **kwargs) except deform.ValidationFailure: if raise_error: raise diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 84ea376d..01e48776 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -1343,6 +1343,8 @@ class MasterView(View): @classmethod def get_row_model_title_plural(cls): + if hasattr(cls, 'row_model_title_plural'): + return cls.row_model_title_plural return "{} Rows".format(cls.get_model_title()) def view_index(self): @@ -2395,7 +2397,9 @@ class MasterView(View): return True return False - def objectify(self, form, data): + def objectify(self, form, data=None): + if data is None: + data = form.validated obj = form.schema.objectify(data, context=form.model_instance) return obj @@ -2822,12 +2826,10 @@ class MasterView(View): self.set_row_labels(form) def validate_row_form(self, form): - controls = self.request.POST.items() - try: - self.form_deserialized = form.validate(controls) - except deform.ValidationFailure: - return False - return True + if form.validate(newstyle=True): + self.form_deserialized = form.validated + return True + return False def get_row_action_url(self, action, row, mobile=False): """ From de6401c5db3a0b77d509109d075c782ff834bb0c Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 30 Jul 2018 11:53:40 -0500 Subject: [PATCH 0914/3196] 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 f985a80a..b18ccabf 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.7.29 (2018-07-30) +------------------- + +* Various tweaks for arbitrary model view with "rows". + + 0.7.28 (2018-07-26) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 7eae0f36..9b29c240 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.7.28' +__version__ = '0.7.29' From cefadc7c274d5702dcc6d4b2ee0a1dd682dcacdb Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 31 Jul 2018 14:08:38 -0500 Subject: [PATCH 0915/3196] Don't configure versioning when making the app that is now happening as part of the `make_config()` call --- tailbone/app.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tailbone/app.py b/tailbone/app.py index 128a70a2..0d29c14b 100644 --- a/tailbone/app.py +++ b/tailbone/app.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar +# Copyright © 2010-2018 Lance Edgar # # This file is part of Rattail. # @@ -34,7 +34,7 @@ import sqlalchemy as sa import rattail.db from rattail.config import make_config from rattail.exceptions import ConfigurationError -from rattail.db.config import get_engines, configure_versioning +from rattail.db.config import get_engines from rattail.db.types import GPCType from pyramid.config import Configurator @@ -87,8 +87,6 @@ def make_rattail_config(settings): # unnecessary connections (and pooling limits). rattail_config._session_factory = lambda: (tailbone.db.Session(), False) - # Configure (or not) Continuum versioning. - configure_versioning(rattail_config) return rattail_config From e0f7ba827fd43388cdb26ae1082da5397fbf87cc Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 31 Jul 2018 14:11:42 -0500 Subject: [PATCH 0916/3196] 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 b18ccabf..1c92e616 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.7.30 (2018-07-31) +------------------- + +* Don't configure versioning when making the app. + + 0.7.29 (2018-07-30) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 9b29c240..c78e337f 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.7.29' +__version__ = '0.7.30' From a24076f0ce6b4b02934a372eb8ad22ced89a334a Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 2 Aug 2018 16:58:38 -0500 Subject: [PATCH 0917/3196] Make sure we refresh batch status when adding a new row b/c whether or not it has a product will affect batch status. this also changes how we interpret UPC for unknown product, i.e. by default we now assume it does *not* have a check digit and that we should calculate that. probably just a matter of time before someone needs the opposite though.. --- tailbone/views/purchasing/receiving.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index b6e24dd8..7d21abb2 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -742,6 +742,7 @@ class ReceivingBatchView(PurchasingBatchView): row.product = other_row.product self.handler.add_row(batch, row) self.Session.flush() + self.handler.refresh_batch_status(batch) return row # try to locate product by uuid before other, more specific key @@ -751,6 +752,7 @@ class ReceivingBatchView(PurchasingBatchView): row.product = product self.handler.add_row(batch, row) self.Session.flush() + self.handler.refresh_batch_status(batch) return row key = self.rattail_config.product_key() @@ -767,6 +769,7 @@ class ReceivingBatchView(PurchasingBatchView): row.product = product self.handler.add_row(batch, row) self.Session.flush() + self.handler.refresh_batch_status(batch) return row # check for "bad" upc @@ -775,11 +778,16 @@ class ReceivingBatchView(PurchasingBatchView): # product not in system, but presumably sane upc, so add to batch anyway row = model.PurchaseBatchRow() - row.upc = provided # TODO: why not checked? how to know? + add_check_digit = True # TODO: make this dynamic, of course + if add_check_digit: + row.upc = checked + else: + row.upc = provided row.item_id = entry row.description = "(unknown product)" self.handler.add_row(batch, row) self.Session.flush() + self.handler.refresh_batch_status(batch) return row elif key == 'item_id': @@ -791,6 +799,7 @@ class ReceivingBatchView(PurchasingBatchView): row.product = product self.handler.add_row(batch, row) self.Session.flush() + self.handler.refresh_batch_status(batch) return row # check for "too long" item_id @@ -803,6 +812,7 @@ class ReceivingBatchView(PurchasingBatchView): row.description = "(unknown product)" self.handler.add_row(batch, row) self.Session.flush() + self.handler.refresh_batch_status(batch) return row else: From a348755be2bbc4c731ffaff0a3e0b1779606189b Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 7 Aug 2018 13:09:13 -0500 Subject: [PATCH 0918/3196] Hide 'ordered' columns for truck dump parent row grid since that batch type is only concerned with receiving --- tailbone/views/purchasing/receiving.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index 7d21abb2..7b602523 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -666,6 +666,13 @@ class ReceivingBatchView(PurchasingBatchView): super(ReceivingBatchView, self).configure_row_grid(g) g.set_label('department_name', "Department") + # hide 'ordered' columns for truck dump parent, since that batch type + # is only concerned with receiving + batch = self.get_instance() + if batch.is_truck_dump_parent(): + g.hide_column('cases_ordered') + g.hide_column('units_ordered') + def configure_row_form(self, f): super(ReceivingBatchView, self).configure_row_form(f) f.set_readonly('cases_ordered') From ac451757b416429da3c6cc922e228e440a243793 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 7 Aug 2018 15:19:38 -0500 Subject: [PATCH 0919/3196] Add support for editing "claim" quantities for truck dump child row at least i think this gets it all...guess we'll see --- tailbone/views/purchasing/receiving.py | 257 ++++++++++++++++++++++++- 1 file changed, 250 insertions(+), 7 deletions(-) diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index 7b602523..e819be2c 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -237,19 +237,23 @@ class ReceivingBatchView(PurchasingBatchView): 'credits', ] + # convenience list of all quantity attributes involved for a truck dump claim + claim_keys = [ + 'cases_received', + 'units_received', + 'cases_damaged', + 'units_damaged', + 'cases_expired', + 'units_expired', + ] + @property def batch_mode(self): return self.enum.PURCHASE_BATCH_MODE_RECEIVING - def row_editable(self, row): - batch = row.batch - if batch.truck_dump_batch: - return False - return True - def row_deletable(self, row): batch = row.batch - if batch.truck_dump: + if batch.is_truck_dump_parent(): return True return False @@ -681,6 +685,245 @@ class ReceivingBatchView(PurchasingBatchView): f.set_readonly('po_total') f.set_readonly('invoice_total') + def validate_row_form(self, form): + + # if normal validation fails, stop there + if not super(ReceivingBatchView, self).validate_row_form(form): + return False + + # if user is editing row from truck dump child, then we must further + # validate the form to ensure whatever new amounts they've requested + # would in fact fall within the bounds of what is available from the + # truck dump parent batch... + if self.editing: + batch = self.get_instance() + if batch.is_truck_dump_child(): + old_row = self.get_row_instance() + case_quantity = old_row.case_quantity + + # get all "existing" (old) claim amounts + old_claims = {} + for claim in old_row.truck_dump_claims: + for key in self.claim_keys: + amount = getattr(claim, key) + if amount is not None: + old_claims[key] = old_claims.get(key, 0) + amount + + # get all "proposed" (new) claim amounts + new_claims = {} + for key in self.claim_keys: + amount = form.validated[key] + if amount is not colander.null and amount is not None: + # do not allow user to request a negative claim amount + if amount < 0: + self.request.session.flash("Cannot claim a negative amount for: {}".format(key), 'error') + return False + new_claims[key] = amount + + # figure out what changes are actually being requested + claim_diff = {} + for key in new_claims: + if key not in old_claims: + claim_diff[key] = new_claims[key] + elif new_claims[key] != old_claims[key]: + claim_diff[key] = new_claims[key] - old_claims[key] + # do not allow user to request a negative claim amount + if claim_diff[key] < (0 - old_claims[key]): + self.request.session.flash("Cannot claim a negative amount for: {}".format(key), 'error') + return False + for key in old_claims: + if key not in new_claims: + claim_diff[key] = 0 - old_claims[key] + + # find all rows from truck dump parent which "may" pertain to child row + # TODO: perhaps would need to do a more "loose" match on UPC also? + if not old_row.product_uuid: + raise NotImplementedError("Don't (yet) know how to handle edit for row with no product") + parent_rows = [row for row in batch.truck_dump_batch.active_rows() + if row.product_uuid == old_row.product_uuid] + + # get existing "confirmed" and "claimed" amounts for all + # (possibly related) truck dump parent rows + confirmed = {} + claimed = {} + for parent_row in parent_rows: + for key in self.claim_keys: + amount = getattr(parent_row, key) + if amount is not None: + confirmed[key] = confirmed.get(key, 0) + amount + for claim in parent_row.claims: + for key in self.claim_keys: + amount = getattr(claim, key) + if amount is not None: + claimed[key] = claimed.get(key, 0) + amount + + # now to see if user's request is possible, given what is + # available... + + # first we must (pretend to) "relinquish" any claims which are + # to be reduced or eliminated, according to our diff + for key, amount in claim_diff.items(): + if amount < 0: + amount = abs(amount) # make positive, for more readable math + if key not in claimed or claimed[key] < amount: + self.request.session.flash("Cannot relinquish more claims than the " + "parent batch has to offer.", 'error') + return False + claimed[key] -= amount + + # next we must determine if any "new" requests would increase + # the claim(s) beyond what is available + for key, amount in claim_diff.items(): + if amount > 0: + claimed[key] = claimed.get(key, 0) + amount + if key not in confirmed or confirmed[key] < claimed[key]: + self.request.session.flash("Cannot request to claim more product than " + "is available in Truck Dump Parent batch", 'error') + return False + + # looks like the claim diff is all good, so let's attach that + # to the form now and then pick this up again in save() + form._claim_diff = claim_diff + + # all validation went ok + return True + + def save_edit_row_form(self, form): + batch = self.get_instance() + row = self.objectify(form) + + # editing a row for truck dump child batch can be complicated... + if batch.is_truck_dump_child(): + + # grab the claim diff which we attached to the form during validation + claim_diff = form._claim_diff + + # first we must "relinquish" any claims which are to be reduced or + # eliminated, according to our diff + for key, amount in claim_diff.items(): + if amount < 0: + amount = abs(amount) # make positive, for more readable math + + # we'd prefer to find an exact match, i.e. there was a 1CS + # claim and our diff said to reduce by 1CS + matches = [claim for claim in row.truck_dump_claims + if getattr(claim, key) == amount] + if matches: + claim = matches[0] + setattr(claim, key, None) + + else: + # but if no exact match(es) then we'll just whittle + # away at whatever (smallest) claims we do find + possible = [claim for claim in row.truck_dump_claims + if getattr(claim, key) is not None] + for claim in sorted(possible, key=lambda claim: getattr(claim, key)): + previous = getattr(claim, key) + if previous: + if previous >= amount: + if (previous - amount): + setattr(claim, key, previous - amount) + else: + setattr(claim, key, None) + amount = 0 + break + else: + setattr(claim, key, None) + amount -= previous + + if amount: + raise NotImplementedError("Had leftover amount when \"relinquishing\" claim(s)") + + # next we must stake all new claim(s) as requested, per our diff + for key, amount in claim_diff.items(): + if amount > 0: + + # if possible, we'd prefer to add to an existing claim + # which already has an amount for this key + existing = [claim for claim in row.truck_dump_claims + if getattr(claim, key) is not None] + if existing: + claim = existing[0] + setattr(claim, key, getattr(claim, key) + amount) + + # next we'd prefer to add to an existing claim, of any kind + elif row.truck_dump_claims: + claim = row.truck_dump_claims[0] + setattr(claim, key, getattr(claim, key) + amount) + + else: + # otherwise we must create a new claim... + + # find all rows from truck dump parent which "may" pertain to child row + # TODO: perhaps would need to do a more "loose" match on UPC also? + if not row.product_uuid: + raise NotImplementedError("Don't (yet) know how to handle edit for row with no product") + parent_rows = [parent_row for parent_row in batch.truck_dump_batch.active_rows() + if parent_row.product_uuid == row.product_uuid] + + # remove any parent rows which are fully claimed + # TODO: should perhaps leverage actual amounts for this, instead + parent_rows = [parent_row for parent_row in parent_rows + if parent_row.status_code != parent_row.STATUS_TRUCKDUMP_CLAIMED] + + # try to find a parent row which is exact match on claim amount + matches = [parent_row for parent_row in parent_rows + if getattr(parent_row, key) == amount] + if matches: + + # make the claim against first matching parent row + claim = model.PurchaseBatchRowClaim() + claim.claimed_row = parent_rows[0] + setattr(claim, key, amount) + row.truck_dump_claims.append(claim) + + else: + # but if no exact match(es) then we'll just whittle + # away at whatever (smallest) parent rows we do find + for parent_row in sorted(parent_rows, lambda prow: getattr(prow, key)): + + available = getattr(parent_row, key) - sum([getattr(claim, key) for claim in parent_row.claims]) + if available: + if available >= amount: + # make claim against this parent row, making it fully claimed + claim = model.PurchaseBatchRowClaim() + claim.claimed_row = parent_row + setattr(claim, key, amount) + row.truck_dump_claims.append(claim) + amount = 0 + break + else: + # make partial claim against this parent row + claim = model.PurchaseBatchRowClaim() + claim.claimed_row = parent_row + setattr(claim, key, available) + row.truck_dump_claims.append(claim) + amount -= available + + if amount: + raise NotImplementedError("Had leftover amount when \"staking\" claim(s)") + + # now we must be sure to refresh all truck dump parent batch rows + # which were affected. but along with that we also should purge + # any empty claims, i.e. those which were fully relinquished + pending_refresh = set() + for claim in list(row.truck_dump_claims): + parent_row = claim.claimed_row + if claim.is_empty(): + row.truck_dump_claims.remove(claim) + self.Session.flush() + pending_refresh.add(parent_row) + for parent_row in pending_refresh: + self.handler.refresh_row(parent_row) + self.handler.refresh_batch_status(batch.truck_dump_batch) + + self.after_edit_row(row) + self.Session.flush() + return row + + def redirect_after_edit_row(self, row, mobile=False): + return self.redirect(self.get_row_action_url('view', row, mobile=mobile)) + def render_mobile_row_listitem(self, row, i): key = self.render_product_key_value(row) description = row.product.full_description if row.product else row.description From 6ef5677dc5aa63a2a679590820ee08af59e3116a Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 7 Aug 2018 21:41:43 -0500 Subject: [PATCH 0920/3196] Use invoice total, PO total as fallback, for mobile receiving list --- tailbone/views/purchasing/receiving.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index e819be2c..2bcae43b 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -508,7 +508,7 @@ class ReceivingBatchView(PurchasingBatchView): title = "({}) {} for ${:0,.2f} - {}, {}".format( batch.id_str, batch.vendor, - batch.po_total or 0, + batch.invoice_total or batch.po_total or 0, batch.department, batch.created_by) return title From 5e879a2d92a3402e984603b99f82691ef6433b48 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 7 Aug 2018 22:42:48 -0500 Subject: [PATCH 0921/3196] Remove some unused code for ordering worksheets --- tailbone/templates/batch/view.mako | 2 +- tailbone/templates/ordering/view.mako | 21 --------- .../templates/purchases/batches/view.mako | 43 ------------------- 3 files changed, 1 insertion(+), 65 deletions(-) delete mode 100644 tailbone/templates/purchases/batches/view.mako diff --git a/tailbone/templates/batch/view.mako b/tailbone/templates/batch/view.mako index f67117b8..b145cab0 100644 --- a/tailbone/templates/batch/view.mako +++ b/tailbone/templates/batch/view.mako @@ -11,7 +11,7 @@ $(function() { % if master.has_worksheet: $('.load-worksheet').click(function() { - $(this).button('disable').button('option', 'label', "Working, please wait..."); + disable_button(this); location.href = '${url('{}.worksheet'.format(route_prefix), uuid=batch.uuid)}'; }); % endif diff --git a/tailbone/templates/ordering/view.mako b/tailbone/templates/ordering/view.mako index 39bb350b..9d2b7247 100644 --- a/tailbone/templates/ordering/view.mako +++ b/tailbone/templates/ordering/view.mako @@ -1,27 +1,6 @@ ## -*- coding: utf-8; -*- <%inherit file="/batch/view.mako" /> -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - - - <%def name="extra_styles()"> ${parent.extra_styles()} ${h.stylesheet_link(request.static_url('tailbone:static/css/purchases.css'))} diff --git a/tailbone/templates/purchases/batches/view.mako b/tailbone/templates/purchases/batches/view.mako deleted file mode 100644 index f7870fb1..00000000 --- a/tailbone/templates/purchases/batches/view.mako +++ /dev/null @@ -1,43 +0,0 @@ -## -*- coding: utf-8; -*- -<%inherit file="/batch/view.mako" /> - -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - - - -<%def name="extra_styles()"> - ${parent.extra_styles()} - ${h.stylesheet_link(request.static_url('tailbone:static/css/purchases.css'))} - - -<%def name="leading_buttons()"> - % if batch.mode == enum.PURCHASE_BATCH_MODE_ORDERING and not batch.complete and not batch.executed and request.has_perm('purchases.batch.order_form'): - - % elif batch.mode == enum.PURCHASE_BATCH_MODE_RECEIVING and not batch.complete and not batch.executed and request.has_perm('purchases.batch.receiving_form'): - - % endif - - -${parent.body()} From 21740ea2fd662ac714c79cf17a0c2eae4293e43c Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 9 Aug 2018 15:59:57 -0500 Subject: [PATCH 0922/3196] Show links to claiming rows for truck dump parent row --- tailbone/views/purchasing/receiving.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index 2bcae43b..0a8991f0 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -234,6 +234,7 @@ class ReceivingBatchView(PurchasingBatchView): 'invoice_unit_cost', 'invoice_total', 'status_code', + 'claims', 'credits', ] @@ -679,12 +680,30 @@ class ReceivingBatchView(PurchasingBatchView): def configure_row_form(self, f): super(ReceivingBatchView, self).configure_row_form(f) + batch = self.get_instance() + f.set_readonly('cases_ordered') f.set_readonly('units_ordered') f.set_readonly('po_unit_cost') f.set_readonly('po_total') f.set_readonly('invoice_total') + # claims + if batch.is_truck_dump_parent(): + f.set_renderer('claims', self.render_row_claims) + else: + f.remove_field('claims') + + def render_row_claims(self, row, field): + items = [] + for claim in row.claims: + child_row = claim.claiming_row + child_batch = child_row.batch + text = child_batch.id_str + url = self.get_row_action_url('view', child_row) + items.append(HTML.tag('li', c=[tags.link_to(text, url)])) + return HTML.tag('ul', c=items) + def validate_row_form(self, form): # if normal validation fails, stop there From 950af8b5e03b0973806268a53dd5a82bfda78f9c Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 9 Aug 2018 22:11:44 -0500 Subject: [PATCH 0923/3196] Add "quick lookup" for mobile Products page only if enabled, otherwise just shows the normal grid --- tailbone/static/js/tailbone.mobile.js | 22 ++++++------- tailbone/templates/mobile/master/view.mako | 6 ++-- tailbone/templates/mobile/products/index.mako | 17 ++++++++++ tailbone/views/batch/core.py | 2 +- tailbone/views/master.py | 4 +-- tailbone/views/products.py | 33 +++++++++++++++++++ tailbone/views/purchasing/receiving.py | 2 +- 7 files changed, 68 insertions(+), 18 deletions(-) create mode 100644 tailbone/templates/mobile/products/index.mako diff --git a/tailbone/static/js/tailbone.mobile.js b/tailbone/static/js/tailbone.mobile.js index 6ebbac06..15ac0cd4 100644 --- a/tailbone/static/js/tailbone.mobile.js +++ b/tailbone/static/js/tailbone.mobile.js @@ -42,7 +42,7 @@ $(document).on('autocompleteitemselected', function(event, uuid) { var field = $(event.target); if (field.hasClass('quick-row')) { var form = field.parents('form:first'); - form.find('[name="quick_row_entry"]').val(uuid); + form.find('[name="quick_entry"]').val(uuid); form.submit(); } }); @@ -151,16 +151,16 @@ $(document).on('keypress', function(event) { }); -// handle various keypress events for quick row forms +// handle various keypress events for quick entry forms $(document).on('keypress', function(event) { - var quick_row = $('.ui-page-active #quick_row_entry'); - if (quick_row.length) { + var quick_entry = $('.ui-page-active #quick_entry'); + if (quick_entry.length) { // if user hits enter with quick row input focused, submit form - if (quick_row.is(':focus')) { + if (quick_entry.is(':focus')) { if (event.which == 13) { // ENTER - if (quick_row.val()) { - var form = quick_row.parents('form:first'); + if (quick_entry.val()) { + var form = quick_entry.parents('form:first'); form.submit(); return false; } @@ -169,11 +169,11 @@ $(document).on('keypress', function(event) { } else { // quick row input not focused // mimic keyboard wedge if we're so instructed - if (quick_row.data('wedge')) { + if (quick_entry.data('wedge')) { if (event.which >= 48 && event.which <= 57) { // numeric (qwerty) if (!event.altKey && !event.ctrlKey && !event.metaKey) { - quick_row.val(quick_row.val() + event.key); + quick_entry.val(quick_entry.val() + event.key); return false; } @@ -183,8 +183,8 @@ $(document).on('keypress', function(event) { } else if (event.which == 13) { // ENTER // submit form when ENTER is received via keyboard "wedge" - if (quick_row.val()) { - var form = quick_row.parents('form:first'); + if (quick_entry.val()) { + var form = quick_entry.parents('form:first'); form.submit(); return false; } diff --git a/tailbone/templates/mobile/master/view.mako b/tailbone/templates/mobile/master/view.mako index 9bc18ce2..fc503c76 100644 --- a/tailbone/templates/mobile/master/view.mako +++ b/tailbone/templates/mobile/master/view.mako @@ -23,18 +23,18 @@ ${form.render()|n} % endif % endif % if master.mobile_rows_quickable and master.rows_quickable_for(instance): - <% placeholder = '' if quick_row_entry_placeholder is Undefined else quick_row_entry_placeholder %> + <% placeholder = '' if quick_entry_placeholder is Undefined else quick_entry_placeholder %> ${h.form(url('mobile.{}.quick_row'.format(route_prefix), uuid=instance.uuid))} ${h.csrf_token(request)} % if quick_row_autocomplete:
          - ${h.hidden('quick_row_entry')} + ${h.hidden('quick_entry')} ${h.text('quick_row_autocomplete_text', placeholder=placeholder, autocomplete='off', data_type='search')}
            % else: - ${h.text('quick_row_entry', placeholder=placeholder, autocomplete='off', **{'data-type': 'search', 'data-url': url('mobile.{}.quick_row'.format(route_prefix), uuid=instance.uuid), 'data-wedge': 'true' if quick_row_keyboard_wedge else 'false'})} + ${h.text('quick_entry', placeholder=placeholder, autocomplete='off', **{'data-type': 'search', 'data-url': url('mobile.{}.quick_row'.format(route_prefix), uuid=instance.uuid), 'data-wedge': 'true' if quick_row_keyboard_wedge else 'false'})} % endif ${h.end_form()} % endif diff --git a/tailbone/templates/mobile/products/index.mako b/tailbone/templates/mobile/products/index.mako new file mode 100644 index 00000000..01cb8320 --- /dev/null +++ b/tailbone/templates/mobile/products/index.mako @@ -0,0 +1,17 @@ +## -*- coding: utf-8; -*- +<%inherit file="/mobile/master/index.mako" /> + +% if master.mobile_creatable and request.has_perm('{}.create'.format(permission_prefix)): + ${h.link_to("New {}".format(model_title), url('mobile.{}.create'.format(route_prefix)), class_='ui-btn ui-corner-all')} +% endif + +% if quick_lookup: + + ${h.form(url('mobile.{}.quick_lookup'.format(route_prefix)))} + ${h.csrf_token(request)} + ${h.text('quick_entry', placeholder=placeholder, autocomplete='off', **{'data-type': 'search', 'data-url': url('mobile.{}.quick_lookup'.format(route_prefix)), 'data-wedge': 'true' if quick_lookup_keyboard_wedge else 'false'})} + ${h.end_form()} + +% else: ## not quick_only + ${grid.render_complete()|n} +% endif diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index 5589cc41..f320ee2a 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -152,7 +152,7 @@ class BatchMasterView(MasterView): if self.mobile_rows_creatable: kwargs.setdefault('add_item_title', "Add Item") if self.mobile_rows_quickable: - kwargs.setdefault('quick_row_entry_placeholder', "Enter {}".format( + kwargs.setdefault('quick_entry_placeholder', "Enter {}".format( self.rattail_config.product_key_title())) if kwargs['execute_enabled']: url = self.get_action_url('execute', batch) diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 01e48776..cbeba251 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -1151,7 +1151,7 @@ class MasterView(View): def make_quick_row_form_schema(self, mobile=False): schema = colander.MappingSchema() - schema.add(colander.SchemaNode(colander.String(), name='quick_row_entry')) + schema.add(colander.SchemaNode(colander.String(), name='quick_entry')) return schema def make_quick_row_form_kwargs(self, **kwargs): @@ -2572,7 +2572,7 @@ class MasterView(View): row = self.save_quick_row_form(form) if not row: self.request.session.flash("Could not locate/create row for entry: " - "{}".format(form.validated['quick_row_entry']), + "{}".format(form.validated['quick_entry']), 'error') return self.redirect(parent_url) return self.redirect_after_quick_row(row, mobile=True) diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 24b87dd3..e614b736 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -725,6 +725,35 @@ class ProductsView(MasterView): 'instance_title': self.get_instance_title(instance), 'form': form}) + def mobile_index(self): + """ + Mobile "home" page for products + """ + self.mobile = True + context = { + 'quick_lookup': False, + 'placeholder': "Enter {}".format(self.rattail_config.product_key_title()), + 'quick_lookup_keyboard_wedge': True, + } + if self.rattail_config.getbool('rattail', 'products.mobile.quick_lookup', default=False): + context['quick_lookup'] = True + else: + self.listing = True + grid = self.make_mobile_grid() + context['grid'] = grid + return self.render_to_response('index', context, mobile=True) + + def mobile_quick_lookup(self): + entry = self.request.POST['quick_entry'] + provided = GPC(entry, calc_check_digit=False) + product = api.get_product_by_upc(self.Session(), provided) + if not product: + checked = GPC(entry, calc_check_digit='upc') + product = api.get_product_by_upc(self.Session(), checked) + if not product: + raise self.notfound() + return self.redirect(self.get_action_url('view', product, mobile=True)) + def get_version_child_classes(self): return [ (model.ProductCode, 'product_uuid'), @@ -938,6 +967,10 @@ class ProductsView(MasterView): config.add_route('products.image', '/products/{uuid}/image') config.add_view(cls, attr='image', route_name='products.image') + # mobile quick lookup + config.add_route('mobile.products.quick_lookup', '/mobile/products/quick-lookup') + config.add_view(cls, attr='mobile_quick_lookup', route_name='mobile.products.quick_lookup') + class ProductsAutocomplete(AutocompleteView): """ diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index 0a8991f0..9679676e 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -991,7 +991,7 @@ class ReceivingBatchView(PurchasingBatchView): def save_quick_row_form(self, form): batch = self.get_instance() - entry = form.validated['quick_row_entry'] + entry = form.validated['quick_entry'] # maybe try to locate existing row first rows = self.quick_locate_rows(batch, entry) From d4b2cf9943c83b42f3922dc7c18c30eb24820ff9 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 14 Aug 2018 17:06:24 -0500 Subject: [PATCH 0924/3196] 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 1c92e616..cbc3ab49 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,22 @@ CHANGELOG ========= +0.7.31 (2018-08-14) +------------------- + +* Make sure we refresh batch status when adding a new row. + +* Hide 'ordered' columns for truck dump parent row grid. + +* Add support for editing "claim" quantities for truck dump child row. + +* Use invoice total, PO total as fallback, for mobile receiving list. + +* Show links to claiming rows for truck dump parent row. + +* Add "quick lookup" for mobile Products page. + + 0.7.30 (2018-07-31) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index c78e337f..29758068 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.7.30' +__version__ = '0.7.31' From 56392ccdd029a8a58ff30e3648a3f32ae2b7aaa3 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 16 Aug 2018 22:21:58 -0500 Subject: [PATCH 0925/3196] Add "quick receive all" support for mobile receiving i.e. quick receive button can now receive all/remainder of the ordered qty --- .../static/js/tailbone.mobile.receiving.js | 5 ++-- .../templates/mobile/receiving/view_row.mako | 12 +++++--- tailbone/views/purchasing/receiving.py | 28 +++++++++++++++++++ 3 files changed, 39 insertions(+), 6 deletions(-) diff --git a/tailbone/static/js/tailbone.mobile.receiving.js b/tailbone/static/js/tailbone.mobile.receiving.js index 452a99f9..c82a7924 100644 --- a/tailbone/static/js/tailbone.mobile.receiving.js +++ b/tailbone/static/js/tailbone.mobile.receiving.js @@ -74,10 +74,11 @@ $(document).on('click', 'form.receiving-update .receiving-actions button', funct $(document).on('click', 'form.receiving-update .quick-receive', function() { var form = $(this).parents('form:first'); form.find('[name="mode"]').val('received'); + var quantity = $(this).data('quantity'); if ($(this).data('uom') == 'CS') { - form.find('[name="cases"]').val('1'); + form.find('[name="cases"]').val(quantity); } else { - form.find('[name="units"]').val('1'); + form.find('[name="units"]').val(quantity); } form.find('input[name="quick_receive"]').val('true'); form.submit(); diff --git a/tailbone/templates/mobile/receiving/view_row.mako b/tailbone/templates/mobile/receiving/view_row.mako index 6bc9dec2..e693b855 100644 --- a/tailbone/templates/mobile/receiving/view_row.mako +++ b/tailbone/templates/mobile/receiving/view_row.mako @@ -85,10 +85,14 @@ ${h.hidden('cases')} ${h.hidden('units')} - % if allow_cases: - - % else: - + % if quick_receive: + % if quick_receive_all: + + % elif allow_cases: + + % else: + + % endif % endif ${keypad(unit_uom, uom, allow_cases=allow_cases)} diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index 9679676e..59ad47f6 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -1113,6 +1113,8 @@ class ReceivingBatchView(PurchasingBatchView): 'form': form, 'allow_expired': self.handler.allow_expired_credits(), 'allow_cases': self.handler.allow_cases(), + 'quick_receive': self.rattail_config.getbool('rattail.batch', 'purchase.mobile_quick_receive', default=True), + 'quick_receive_all': self.rattail_config.getbool('rattail.batch', 'purchase.mobile_quick_receive_all', default=False) } if self.request.has_perm('{}.create_row'.format(permission_prefix)): @@ -1165,6 +1167,32 @@ class ReceivingBatchView(PurchasingBatchView): # unit_uom can vary by product context['unit_uom'] = 'LB' if row.product and row.product.weighed else 'EA' + if context['quick_receive'] and context['quick_receive_all']: + if context['allow_cases']: + context['quick_receive_uom'] = 'CS' + raise NotImplementedError("TODO: add CS support for quick_receive_all") + else: + context['quick_receive_uom'] = context['unit_uom'] + accounted_for = self.handler.get_units_accounted_for(row) + remainder = self.handler.get_units_ordered(row) - accounted_for + + if accounted_for: + # some product accounted for; button should receive "remainder" only + if remainder: + remainder = pretty_quantity(remainder) + context['quick_receive_quantity'] = remainder + context['quick_receive_text'] = "Receive Remainder ({} {})".format(remainder, context['unit_uom']) + else: + # unless there is no remainder, in which case disable it + context['quick_receive'] = False + + else: # nothing yet accounted for, button should receive "all" + if not remainder: + raise ValueError("why is remainder empty?") + remainder = pretty_quantity(remainder) + context['quick_receive_quantity'] = remainder + context['quick_receive_text'] = "Receive ALL ({} {})".format(remainder, context['unit_uom']) + # effective uom can vary in a few ways...the basic default is 'CS' if # self.default_uom_is_case is true, otherwise whatever unit_uom is. sticky_case = self.request.session.get('tailbone.mobile.receiving.sticky_uom_is_case') From 528c0f962249a1492bd21ba3c8d1fad23e28a4c6 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 17 Aug 2018 00:04:59 -0500 Subject: [PATCH 0926/3196] Refactor sqlerror tween to add support for pyramid_retry hopefully this doesn't break anything else.. --- setup.py | 9 ++------- tailbone/tweens.py | 30 ++++++++++++++++++++++-------- 2 files changed, 24 insertions(+), 15 deletions(-) diff --git a/setup.py b/setup.py index 98cb4b9f..da99d2e4 100644 --- a/setup.py +++ b/setup.py @@ -64,13 +64,6 @@ requires = [ # # package # low high - # TODO: Pyramid 1.9 looks like it breaks us..? playing it safe for now.. - 'pyramid<1.9', # 1.3b2 1.8.3 - - # apparently 2.0 removes the retry support, in which case we then need - # pyramid_retry .. but that requires pyramid 1.9 ... - 'pyramid_tm<2.0', # 0.3 1.1.1 - # TODO: why do we need to cap this? breaks tailbone.db zope stuff somehow 'zope.sqlalchemy<1.0', # 0.7 0.7.7 @@ -82,10 +75,12 @@ requires = [ 'paginate', # 0.5.6 'paginate_sqlalchemy', # 0.2.0 'passlib', # 1.7.1 + 'pyramid', # 1.3b2 'pyramid_beaker>=0.6', # 0.6.1 'pyramid_deform', # 0.2 'pyramid_exclog', # 0.6 'pyramid_mako', # 1.0.2 + 'pyramid_tm', # 0.3 'rattail[db,bouncer]', # 0.5.0 'six', # 1.10.0 'transaction', # 1.2.0 diff --git a/tailbone/tweens.py b/tailbone/tweens.py index 6df4be16..f944a66f 100644 --- a/tailbone/tweens.py +++ b/tailbone/tweens.py @@ -29,32 +29,46 @@ from __future__ import unicode_literals, absolute_import import six from sqlalchemy.exc import OperationalError -from transaction.interfaces import TransientError - def sqlerror_tween_factory(handler, registry): """ Produces a tween which will convert ``sqlalchemy.exc.OperationalError`` - instances (caused by database server restart) into a retryable - ``transaction.interfaces.TransientError`` instance, so that a second - attempt may be made to connect to the database before really giving up. + instances (caused by database server restart) into a retryable error + instance, so that a second attempt may be made to connect to the database + before really giving up. .. note:: This tween alone is not enough to cause the transaction to be retried; it only marks the error as being *retryable*. If you wish more than one - attempt to be made, you must define the ``tm.attempts`` setting within - your Pyramid app configuration. For more info see `Retrying`_. + attempt to be made, you must define the ``retry.attempts`` (or + ``tm.attempts`` if running pyramid<1.9) setting within your Pyramid app + configuration. For more info see `Retrying`_. .. _Retrying: http://docs.pylonsproject.org/projects/pyramid_tm/en/latest/#retrying """ def sqlerror_tween(request): + try: + from pyramid_retry import mark_error_retryable + except ImportError: + mark_error_retryable = None + from transaction.interfaces import TransientError + try: response = handler(request) except OperationalError as error: + + # if connection is invalid, allow retry if error.connection_invalidated: - raise TransientError(six.text_type(error)) + if mark_error_retryable: + mark_error_retryable(error) + raise error + else: + raise TransientError(six.text_type(error)) + + # if connection was *not* invalid, raise original error raise + return response return sqlerror_tween From 4d0223e3051b5a1bc7e445e1633aafa42386948e Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 17 Aug 2018 00:21:01 -0500 Subject: [PATCH 0927/3196] Try to retry `InvalidRequestError` from sqlalchemy not sure if this is a good idea, hopefully can find out in a moment --- tailbone/tweens.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tailbone/tweens.py b/tailbone/tweens.py index f944a66f..0684d3fa 100644 --- a/tailbone/tweens.py +++ b/tailbone/tweens.py @@ -27,7 +27,7 @@ Tween Factories from __future__ import unicode_literals, absolute_import import six -from sqlalchemy.exc import OperationalError +from sqlalchemy.exc import OperationalError, InvalidRequestError def sqlerror_tween_factory(handler, registry): @@ -56,7 +56,7 @@ def sqlerror_tween_factory(handler, registry): try: response = handler(request) - except OperationalError as error: + except (OperationalError, InvalidRequestError) as error: # if connection is invalid, allow retry if error.connection_invalidated: From f0d8f79676b846c304e5f06b358cc1984d2df8c8 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 17 Aug 2018 00:24:51 -0500 Subject: [PATCH 0928/3196] Revert "Try to retry `InvalidRequestError` from sqlalchemy" This reverts commit 4d0223e3051b5a1bc7e445e1633aafa42386948e. well, that didn't work --- tailbone/tweens.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tailbone/tweens.py b/tailbone/tweens.py index 0684d3fa..f944a66f 100644 --- a/tailbone/tweens.py +++ b/tailbone/tweens.py @@ -27,7 +27,7 @@ Tween Factories from __future__ import unicode_literals, absolute_import import six -from sqlalchemy.exc import OperationalError, InvalidRequestError +from sqlalchemy.exc import OperationalError def sqlerror_tween_factory(handler, registry): @@ -56,7 +56,7 @@ def sqlerror_tween_factory(handler, registry): try: response = handler(request) - except (OperationalError, InvalidRequestError) as error: + except OperationalError as error: # if connection is invalid, allow retry if error.connection_invalidated: From a6a7d22ec12c2d99e66f51f0a59477cc59ab1e66 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 17 Aug 2018 12:41:48 -0500 Subject: [PATCH 0929/3196] Honor view logic when displaying Delete Row button for mobile receiving also do not allow quick receive if receiving from scratch --- .../templates/mobile/receiving/view_row.mako | 2 +- tailbone/views/purchasing/receiving.py | 24 +++++++++++++++++-- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/tailbone/templates/mobile/receiving/view_row.mako b/tailbone/templates/mobile/receiving/view_row.mako index e693b855..fe2aacc9 100644 --- a/tailbone/templates/mobile/receiving/view_row.mako +++ b/tailbone/templates/mobile/receiving/view_row.mako @@ -133,7 +133,7 @@ ${h.hidden('quick_receive', value='false')} ${h.end_form()} - % if master.mobile_rows_deletable and request.has_perm('{}.delete_row'.format(permission_prefix)): + % if master.mobile_rows_deletable and master.row_deletable(row) and request.has_perm('{}.delete_row'.format(permission_prefix)): ${h.form(url('mobile.{}.delete_row'.format(route_prefix), uuid=batch.uuid, row_uuid=row.uuid), class_='receiving-update')} ${h.csrf_token(request)} ${h.submit('submit', "Delete this Row")} diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index 59ad47f6..88acc16c 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -254,8 +254,22 @@ class ReceivingBatchView(PurchasingBatchView): def row_deletable(self, row): batch = row.batch + + # can always delete rows from truck dump parent if batch.is_truck_dump_parent(): return True + + # can never delete rows from truck dump child + elif batch.is_truck_dump_child(): + return False + + else: # okay, normal batch + if batch.order_quantities_known: + return False + else: # allow delete if receiving rom scratch + return True + + # cannot delete row by default return False def get_instance_title(self, batch): @@ -1113,10 +1127,16 @@ class ReceivingBatchView(PurchasingBatchView): 'form': form, 'allow_expired': self.handler.allow_expired_credits(), 'allow_cases': self.handler.allow_cases(), - 'quick_receive': self.rattail_config.getbool('rattail.batch', 'purchase.mobile_quick_receive', default=True), - 'quick_receive_all': self.rattail_config.getbool('rattail.batch', 'purchase.mobile_quick_receive_all', default=False) + 'quick_receive': False, + 'quick_receive_all': False, } + if batch.order_quantities_known: + context['quick_receive'] = self.rattail_config.getbool('rattail.batch', 'purchase.mobile_quick_receive', + default=True) + context['quick_receive_all'] = self.rattail_config.getbool('rattail.batch', 'purchase.mobile_quick_receive_all', + default=False) + if self.request.has_perm('{}.create_row'.format(permission_prefix)): schema = MobileReceivingForm().bind(session=self.Session()) update_form = forms.Form(schema=schema, request=self.request) From 06b5f6c97ca945f1a0388ec3f1d13aa98688b770 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 24 Aug 2018 13:42:06 -0500 Subject: [PATCH 0930/3196] Update changelog --- CHANGES.rst | 10 ++++++++++ tailbone/_version.py | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index cbc3ab49..6289814c 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,16 @@ CHANGELOG ========= +0.7.32 (2018-08-24) +------------------- + +* Add "quick receive all" support for mobile receiving. + +* Refactor sqlerror tween to add support for pyramid_retry. + +* Honor view logic when displaying Delete Row button for mobile receiving. + + 0.7.31 (2018-08-14) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 29758068..9f549e8a 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.7.31' +__version__ = '0.7.32' From db0eee707aaa30f1b19ee62eb83fc2847e8f4e09 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 6 Sep 2018 20:36:08 -0500 Subject: [PATCH 0931/3196] Fix default (status) filter for Employees grid --- tailbone/views/employees.py | 32 ++++++++++++++++++-------------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/tailbone/views/employees.py b/tailbone/views/employees.py index d98f6e61..9ea2cb6a 100644 --- a/tailbone/views/employees.py +++ b/tailbone/views/employees.py @@ -35,7 +35,6 @@ import colander from deform import widget as dfwidget from webhelpers2.html import tags, HTML -from tailbone import grids from tailbone.db import Session from tailbone.views import MasterView, AutocompleteView @@ -47,6 +46,10 @@ class EmployeesView(MasterView): model_class = model.Employee has_versions = True + labels = { + 'id': "ID", + } + grid_columns = [ 'id', 'first_name', @@ -73,6 +76,7 @@ class EmployeesView(MasterView): def configure_grid(self, g): super(EmployeesView, self).configure_grid(g) + route_prefix = self.get_route_prefix() g.joiners['phone'] = lambda q: q.outerjoin(model.EmployeePhoneNumber, sa.and_( model.EmployeePhoneNumber.parent_uuid == model.Employee.uuid, @@ -89,13 +93,22 @@ class EmployeesView(MasterView): g.filters['phone'] = g.make_filter('phone', model.EmployeePhoneNumber.number, label="Phone Number") - if self.request.has_perm('employees.edit'): + # id + if self.request.has_perm('{}.edit'.format(route_prefix)): + g.set_link('id') + else: + g.hide_column('id') + del g.filters['id'] + + # status + if self.request.has_perm('{}.edit'.format(route_prefix)): + g.set_enum('status', self.enum.EMPLOYEE_STATUS) g.filters['status'].default_active = True g.filters['status'].default_verb = 'equal' - g.filters['status'].default_value = self.enum.EMPLOYEE_STATUS_CURRENT - g.filters['status'].set_value_renderer(grids.filters.EnumValueRenderer(self.enum.EMPLOYEE_STATUS)) + # TODO: why must we set unicode string value here? + g.filters['status'].default_value = six.text_type(self.enum.EMPLOYEE_STATUS_CURRENT) else: - del g.filters['id'] + g.hide_column('status') del g.filters['status'] g.filters['first_name'].default_active = True @@ -112,20 +125,12 @@ class EmployeesView(MasterView): g.set_sort_defaults('first_name') - g.set_enum('status', self.enum.EMPLOYEE_STATUS) - - g.set_label('id', "ID") g.set_label('phone', "Phone Number") g.set_label('email', "Email Address") - g.set_link('id') g.set_link('first_name') g.set_link('last_name') - if not self.request.has_perm('employees.edit'): - g.hide_column('id') - g.hide_column('status') - def query(self, session): q = session.query(model.Employee).join(model.Person) if not self.request.has_perm('employees.edit'): @@ -186,7 +191,6 @@ class EmployeesView(MasterView): f.set_label('display_name', "Short Name") f.set_label('phone', "Phone Number") f.set_label('email', "Email Address") - f.set_label('id', "ID") if not self.viewing: f.remove_fields('first_name', 'last_name') From 84db66a60c2e38b673b246c11a869c1d29d017c6 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 10 Sep 2018 18:55:55 -0500 Subject: [PATCH 0932/3196] 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 6289814c..fd7c233f 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.7.33 (2018-09-10) +------------------- + +* Fix default (status) filter for Employees grid. + + 0.7.32 (2018-08-24) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 9f549e8a..c5bf1d5b 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.7.32' +__version__ = '0.7.33' From c5fef6b954864bdeafa65415a1e58adadc09c96f Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 19 Sep 2018 10:00:59 -0500 Subject: [PATCH 0933/3196] Add unique check for "name" when creating new Role --- tailbone/views/roles.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/tailbone/views/roles.py b/tailbone/views/roles.py index d43e37ad..19c91276 100644 --- a/tailbone/views/roles.py +++ b/tailbone/views/roles.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar +# Copyright © 2010-2018 Lance Edgar # # This file is part of Rattail. # @@ -65,10 +65,22 @@ class RolesView(PrincipalMasterView): g.set_sort_defaults('name') g.set_link('name') + def unique_name(self, node, value): + query = self.Session.query(model.Role)\ + .filter(model.Role.name == value) + if self.editing: + role = self.get_instance() + query = query.filter(model.Role.uuid != role.uuid) + if query.count(): + raise colander.Invalid(node, "Name must be unique") + def configure_form(self, f): super(RolesView, self).configure_form(f) role = f.model_instance + # name + f.set_validator('name', self.unique_name) + # permissions self.tailbone_permissions = self.request.registry.settings.get('tailbone_permissions', {}) f.set_renderer('permissions', PermissionsRenderer(permissions=self.tailbone_permissions)) From 5b2f4127ea68cc715d8fd23d7a77ca3aec8aefc3 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 19 Sep 2018 11:00:10 -0500 Subject: [PATCH 0934/3196] Fix bug when editing truck dump child batch row quantities sometimes we need to "add" to an existing claim which has qty None --- tailbone/views/purchasing/batch.py | 3 +++ tailbone/views/purchasing/receiving.py | 5 ++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/tailbone/views/purchasing/batch.py b/tailbone/views/purchasing/batch.py index 8941de1c..746ae4d3 100644 --- a/tailbone/views/purchasing/batch.py +++ b/tailbone/views/purchasing/batch.py @@ -662,6 +662,9 @@ class PurchasingBatchView(BatchMasterView): f.set_type('invoice_unit_cost', 'currency') f.set_type('invoice_total', 'currency') + # upc + f.set_type('upc', 'gpc') + if self.creating: f.remove_fields( 'upc', diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index 88acc16c..b61c53af 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -775,6 +775,9 @@ class ReceivingBatchView(PurchasingBatchView): parent_rows = [row for row in batch.truck_dump_batch.active_rows() if row.product_uuid == old_row.product_uuid] + # NOTE: "confirmed" are the proper amounts which exist in the + # parent batch. "claimed" are the amounts claimed by this row. + # get existing "confirmed" and "claimed" amounts for all # (possibly related) truck dump parent rows confirmed = {} @@ -882,7 +885,7 @@ class ReceivingBatchView(PurchasingBatchView): # next we'd prefer to add to an existing claim, of any kind elif row.truck_dump_claims: claim = row.truck_dump_claims[0] - setattr(claim, key, getattr(claim, key) + amount) + setattr(claim, key, (getattr(claim, key) or 0) + amount) else: # otherwise we must create a new claim... From 6fb78c5dde7455bdac1bd116773f0b4aea379959 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 19 Sep 2018 16:42:50 -0500 Subject: [PATCH 0935/3196] Add setting to show/hide product image for mobile purchasing/receiving --- tailbone/templates/mobile/receiving/view_row.mako | 12 ++++++------ tailbone/views/purchasing/receiving.py | 7 ++++++- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/tailbone/templates/mobile/receiving/view_row.mako b/tailbone/templates/mobile/receiving/view_row.mako index fe2aacc9..f4978aa6 100644 --- a/tailbone/templates/mobile/receiving/view_row.mako +++ b/tailbone/templates/mobile/receiving/view_row.mako @@ -7,7 +7,7 @@ <%def name="page_title()">${h.link_to("Receiving", url('mobile.receiving'))} » ${h.link_to(batch.id_str, url('mobile.receiving.view', uuid=batch.uuid))} » ${master.render_product_key_value(row)} -
            +
            % if instance.product:

            ${instance.brand_name or ""}

            @@ -19,11 +19,11 @@

            ${instance.description}

            % endif
            -
            - % if product_image_url: - ${h.image(product_image_url, "product image")} - % endif -
            + % if product_image_url: +
            + ${h.image(product_image_url, "product image")} +
            + % endif
            diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index b61c53af..62048038 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -1109,11 +1109,16 @@ class ReceivingBatchView(PurchasingBatchView): return self.redirect(self.get_row_action_url('view', row, mobile=mobile)) return super(ReceivingBatchView, self).redirect_after_quick_row(row, mobile=mobile) + def get_row_image_url(self, row): + if self.rattail_config.getbool('rattail.batch', 'purchase.mobile_images', default=True): + return pod.get_image_url(self.rattail_config, row.upc) + def mobile_view_row(self): """ Mobile view for receiving batch row items. Note that this also handles updating a row. """ + self.mobile = True self.viewing = True row = self.get_row_instance() batch = row.batch @@ -1126,7 +1131,7 @@ class ReceivingBatchView(PurchasingBatchView): 'instance': row, 'instance_title': self.get_row_instance_title(row), 'parent_model_title': self.get_model_title(), - 'product_image_url': pod.get_image_url(self.rattail_config, row.upc), + 'product_image_url': self.get_row_image_url(row), 'form': form, 'allow_expired': self.handler.allow_expired_credits(), 'allow_cases': self.handler.allow_cases(), From 2939b534677be5b5b503fa3a1fe2e9fd91647675 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 19 Sep 2018 17:00:45 -0500 Subject: [PATCH 0936/3196] Show red background for mobile receiving if product not found --- tailbone/templates/mobile/receiving/view_row.mako | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tailbone/templates/mobile/receiving/view_row.mako b/tailbone/templates/mobile/receiving/view_row.mako index f4978aa6..9a791ae4 100644 --- a/tailbone/templates/mobile/receiving/view_row.mako +++ b/tailbone/templates/mobile/receiving/view_row.mako @@ -8,7 +8,7 @@ -
            +
            % if instance.product:

            ${instance.brand_name or ""}

            ${instance.description} ${instance.size or ''}

            @@ -26,7 +26,7 @@ % endif
            -
            + % if batch.order_quantities_known: From be49ca6967ae7088e2ff0a1068f4600faedb05e0 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 19 Sep 2018 17:11:16 -0500 Subject: [PATCH 0937/3196] Add quick-receive 1EA, 3EA, 6EA for mobile receiving but only when cases are allowed. at least for now...should surely be more configurable than we have it now --- tailbone/templates/mobile/receiving/view_row.mako | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tailbone/templates/mobile/receiving/view_row.mako b/tailbone/templates/mobile/receiving/view_row.mako index 9a791ae4..f09fec34 100644 --- a/tailbone/templates/mobile/receiving/view_row.mako +++ b/tailbone/templates/mobile/receiving/view_row.mako @@ -90,6 +90,13 @@ % elif allow_cases: +
            + ## TODO: probably should make these optional / configurable + + + +
            +
            % else: % endif From acd8c97afcc81b5d9a8a9c5feb0a734d1f95fbb8 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 19 Sep 2018 17:16:15 -0500 Subject: [PATCH 0938/3196] Fix how we check config for mobile "quick receive" feature at least hopefully this fixes it, and doesn't break anybody.. --- tailbone/views/purchasing/receiving.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index 62048038..f5d8882e 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -1139,9 +1139,9 @@ class ReceivingBatchView(PurchasingBatchView): 'quick_receive_all': False, } + context['quick_receive'] = self.rattail_config.getbool('rattail.batch', 'purchase.mobile_quick_receive', + default=True) if batch.order_quantities_known: - context['quick_receive'] = self.rattail_config.getbool('rattail.batch', 'purchase.mobile_quick_receive', - default=True) context['quick_receive_all'] = self.rattail_config.getbool('rattail.batch', 'purchase.mobile_quick_receive_all', default=False) From 66f1ed0e41a64ca8475d7bf461fc89cc68f26e61 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 19 Sep 2018 18:22:59 -0500 Subject: [PATCH 0939/3196] Do quick lookup by vendor item code, alt code for mobile receiving at least until we have to make that configurable etc. --- tailbone/views/purchasing/receiving.py | 73 +++++++++++++++++--------- 1 file changed, 47 insertions(+), 26 deletions(-) diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index f5d8882e..664d7b03 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -1006,6 +1006,47 @@ class ReceivingBatchView(PurchasingBatchView): if row.item_id == entry] return rows + def quick_locate_product(self, batch, entry): + + # try to locate product by uuid before other, more specific key + product = self.Session.query(model.Product).get(entry) + if product: + return product + + key = self.rattail_config.product_key() + if key == 'upc': + + # we first assume the user entry *does* include check digit + provided = GPC(entry, calc_check_digit=False) + product = api.get_product_by_upc(self.Session(), provided) + if product: + return product + + # but we can also calculate a check digit and try that + checked = GPC(entry, calc_check_digit='upc') + product = api.get_product_by_upc(self.Session(), checked) + if product: + return product + + elif key == 'item_id': + + # try to locate product by item_id + product = api.get_product_by_item_id(self.Session(), entry) + if product: + return product + + # if we made it this far, lookup by product key failed. + + # now we'll attempt lookup by vendor item code + product = api.get_product_by_vendor_code(self.Session(), entry, vendor=batch.vendor) + if product: + return product + + # okay then, let's attempt lookup by "alternate" code + product = api.get_product_by_code(self.Session(), entry) + if product: + return product + def save_quick_row_form(self, form): batch = self.get_instance() entry = form.validated['quick_entry'] @@ -1031,8 +1072,9 @@ class ReceivingBatchView(PurchasingBatchView): self.handler.refresh_batch_status(batch) return row - # try to locate product by uuid before other, more specific key - product = self.Session.query(model.Product).get(entry) + # if product is easily located, add new row for it + product = self.quick_locate_product(batch, entry) + # TODO: probably should be smarter about how we handle deleted? if product and not product.deleted: row = model.PurchaseBatchRow() row.product = product @@ -1044,24 +1086,13 @@ class ReceivingBatchView(PurchasingBatchView): key = self.rattail_config.product_key() if key == 'upc': - # try to locate product by upc - provided = GPC(entry, calc_check_digit=False) - checked = GPC(entry, calc_check_digit='upc') - product = api.get_product_by_upc(self.Session(), provided) - if not product: - product = api.get_product_by_upc(self.Session(), checked) - if product: - row = model.PurchaseBatchRow() - row.product = product - self.handler.add_row(batch, row) - self.Session.flush() - self.handler.refresh_batch_status(batch) - return row - # check for "bad" upc if len(entry) > 14: return + provided = GPC(entry, calc_check_digit=False) + checked = GPC(entry, calc_check_digit='upc') + # product not in system, but presumably sane upc, so add to batch anyway row = model.PurchaseBatchRow() add_check_digit = True # TODO: make this dynamic, of course @@ -1078,16 +1109,6 @@ class ReceivingBatchView(PurchasingBatchView): elif key == 'item_id': - # try to locate product by item_id - product = api.get_product_by_item_id(self.Session(), entry) - if product: - row = model.PurchaseBatchRow() - row.product = product - self.handler.add_row(batch, row) - self.Session.flush() - self.handler.refresh_batch_status(batch) - return row - # check for "too long" item_id if len(entry) > maxlen(model.PurchaseBatchRow.item_id): return From 3a91ab6bec6dec4d8ab8cec3a99bb74dd05eca6e Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 19 Sep 2018 19:11:59 -0500 Subject: [PATCH 0940/3196] Fix price fields, add pref. vendor/cost fields for mobile product view --- tailbone/views/products.py | 70 ++++++++++++++++++++++++++------------ 1 file changed, 49 insertions(+), 21 deletions(-) diff --git a/tailbone/views/products.py b/tailbone/views/products.py index e614b736..6754e2ff 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -135,6 +135,8 @@ class ProductsView(MasterView): 'last_sold', 'inventory_on_hand', 'inventory_on_order', + 'vendor', + 'cost', ] mobile_form_fields = form_fields @@ -303,6 +305,53 @@ class ProductsView(MasterView): g.set_label('vendor_any', "Vendor (any)") g.set_label('vendor', "Pref. Vendor") + def configure_common_form(self, f): + super(ProductsView, self).configure_common_form(f) + + # regular_price + if self.creating: + f.remove_field('regular_price') + else: + f.set_readonly('regular_price') + f.set_renderer('regular_price', self.render_price) + + # current_price + if self.creating: + f.remove_field('current_price') + else: + f.set_readonly('current_price') + f.set_renderer('current_price', self.render_price) + + # current_price_ends + if self.creating: + f.remove_field('current_price_ends') + else: + f.set_readonly('current_price_ends') + f.set_renderer('current_price_ends', self.render_current_price_ends) + + # vendor + if self.creating: + f.remove_field('vendor') + else: + f.set_readonly('vendor') + f.set_label('vendor', "Preferred Vendor") + + # cost + if self.creating: + f.remove_field('cost') + else: + f.set_readonly('cost') + f.set_label('cost', "Preferred Unit Cost") + f.set_renderer('cost', self.render_cost) + + def render_cost(self, product, field): + cost = getattr(product, field) + if cost: + if cost.unit_cost: + return "$ {:0.2f}".format(cost.unit_cost) + else: + return "TODO: does this item have a cost?" + def render_price(self, product, column): price = product[column] if price: @@ -545,20 +594,6 @@ class ProductsView(MasterView): if self.viewing and not product.is_pack_item(): f.remove_field('default_pack') - # regular_price - if self.creating: - f.remove_field('regular_price') - else: - f.set_readonly('regular_price') - f.set_renderer('regular_price', self.render_price) - - # current_price - if self.creating: - f.remove_field('current_price') - else: - f.set_readonly('current_price') - f.set_renderer('current_price', self.render_price) - # last_sold if self.creating: f.remove_field('last_sold') @@ -574,13 +609,6 @@ class ProductsView(MasterView): # notes f.set_widget('notes', dfwidget.TextAreaWidget(cols=80, rows=10)) - # current_price_ends - if self.creating: - f.remove_field('current_price_ends') - else: - f.set_readonly('current_price_ends') - f.set_renderer('current_price_ends', self.render_current_price_ends) - # inventory_on_hand if self.creating: f.remove_field('inventory_on_hand') From 3b0292029d1d02f97491c77116562cd5da3644b8 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 19 Sep 2018 19:28:29 -0500 Subject: [PATCH 0941/3196] More basic field tweaks for mobile "view product" page --- tailbone/views/products.py | 136 +++++++++++++++++++------------------ 1 file changed, 70 insertions(+), 66 deletions(-) diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 6754e2ff..684c49fc 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -80,6 +80,7 @@ class ProductsView(MasterView): has_versions = True labels = { + 'upc': "UPC", 'status_code': "Status", } @@ -116,6 +117,8 @@ class ProductsView(MasterView): 'regular_price', 'current_price', 'current_price_ends', + 'vendor', + 'cost', 'deposit_link', 'tax', 'organic', @@ -135,8 +138,6 @@ class ProductsView(MasterView): 'last_sold', 'inventory_on_hand', 'inventory_on_order', - 'vendor', - 'cost', ] mobile_form_fields = form_fields @@ -300,13 +301,51 @@ class ProductsView(MasterView): g.set_link('item_id') g.set_link('description') - g.set_label('upc', "UPC") g.set_label('vendor', "Vendor (preferred)") g.set_label('vendor_any', "Vendor (any)") g.set_label('vendor', "Pref. Vendor") def configure_common_form(self, f): super(ProductsView, self).configure_common_form(f) + product = f.model_instance + + # upc + f.set_type('upc', 'gpc') + + # unit_size + f.set_type('unit_size', 'quantity') + + # unit_of_measure + f.set_enum('unit_of_measure', self.enum.UNIT_OF_MEASURE) + f.set_label('unit_of_measure', "Unit of Measure") + + # packs + if self.creating: + f.remove_field('packs') + elif self.viewing and product.packs: + f.set_renderer('packs', self.render_packs) + f.set_label('packs', "Pack Items") + else: + f.remove_field('packs') + + # pack_size + if self.viewing and not product.is_pack_item(): + f.remove_field('pack_size') + else: + f.set_type('pack_size', 'quantity') + + # default_pack + if self.viewing and not product.is_pack_item(): + f.remove_field('default_pack') + + # unit + if self.creating: + f.remove_field('unit') + elif self.viewing and product.is_pack_item(): + f.set_renderer('unit', self.render_unit) + f.set_label('unit', "Unit Item") + else: + f.remove_field('unit') # regular_price if self.creating: @@ -344,6 +383,29 @@ class ProductsView(MasterView): f.set_label('cost', "Preferred Unit Cost") f.set_renderer('cost', self.render_cost) + # last_sold + if self.creating: + f.remove_field('last_sold') + else: + f.set_readonly('last_sold') + f.set_renderer('last_sold', self.render_last_sold) + + # inventory_on_hand + if self.creating: + f.remove_field('inventory_on_hand') + else: + f.set_readonly('inventory_on_hand') + f.set_renderer('inventory_on_hand', self.render_inventory_on_hand) + f.set_label('inventory_on_hand', "On Hand") + + # inventory_on_order + if self.creating: + f.remove_field('inventory_on_order') + else: + f.set_readonly('inventory_on_order') + f.set_renderer('inventory_on_order', self.render_inventory_on_order) + f.set_label('inventory_on_order', "On Order") + def render_cost(self, product, field): cost = getattr(product, field) if cost: @@ -423,10 +485,6 @@ class ProductsView(MasterView): super(ProductsView, self).configure_form(f) product = f.model_instance - # upc - f.set_type('upc', 'gpc') - f.set_label('upc', "UPC") - # department if self.creating or self.editing: if 'department' in f.fields: @@ -559,47 +617,6 @@ class ProductsView(MasterView): field_display=brand_display, service_url=brands_url)) f.set_label('brand_uuid', "Brand") - # unit_size - f.set_type('unit_size', 'quantity') - - # unit_of_measure - f.set_enum('unit_of_measure', self.enum.UNIT_OF_MEASURE) - f.set_label('unit_of_measure', "Unit of Measure") - - # packs - if self.creating: - f.remove_field('packs') - elif self.viewing and product.packs: - f.set_renderer('packs', self.render_packs) - f.set_label('packs', "Pack Items") - else: - f.remove_field('packs') - - # unit - if self.creating: - f.remove_field('unit') - elif self.viewing and product.is_pack_item(): - f.set_renderer('unit', self.render_unit) - f.set_label('unit', "Unit Item") - else: - f.remove_field('unit') - - # pack_size - if self.viewing and not product.is_pack_item(): - f.remove_field('pack_size') - else: - f.set_type('pack_size', 'quantity') - - # default_pack - if self.viewing and not product.is_pack_item(): - f.remove_field('default_pack') - - # last_sold - if self.creating: - f.remove_field('last_sold') - else: - f.set_readonly('last_sold') - # status_code f.set_label('status_code', "Status") @@ -609,22 +626,6 @@ class ProductsView(MasterView): # notes f.set_widget('notes', dfwidget.TextAreaWidget(cols=80, rows=10)) - # inventory_on_hand - if self.creating: - f.remove_field('inventory_on_hand') - else: - f.set_readonly('inventory_on_hand') - f.set_renderer('inventory_on_hand', self.render_inventory_on_hand) - f.set_label('inventory_on_hand', "On Hand") - - # inventory_on_order - if self.creating: - f.remove_field('inventory_on_order') - else: - f.set_readonly('inventory_on_order') - f.set_renderer('inventory_on_order', self.render_inventory_on_order) - f.set_label('inventory_on_order', "On Order") - if not self.request.has_perm('products.view_deleted'): f.remove('deleted') @@ -676,7 +677,7 @@ class ProductsView(MasterView): else: code = pack.item_id text = "({}) {}".format(code, pack.full_description) - url = self.get_action_url('view', pack) + url = self.get_action_url('view', pack, mobile=self.mobile) links.append(tags.link_to(text, url)) items = [HTML.tag('li', c=[link]) for link in links] @@ -695,7 +696,7 @@ class ProductsView(MasterView): code = unit.item_id text = "({}) {}".format(code, unit.full_description) - url = self.get_action_url('view', unit) + url = self.get_action_url('view', unit, mobile=self.mobile) return tags.link_to(text, url) def render_current_price_ends(self, product, field): @@ -706,6 +707,9 @@ class ProductsView(MasterView): return "" return raw_datetime(self.request.rattail_config, value) + def render_last_sold(self, product, field): + return "TODO: add default renderer for last sold" + def render_inventory_on_hand(self, product, field): if not product.inventory: return "" From 0b9fe2dfe720a053c29a93276208560d50e0bb6a Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 20 Sep 2018 15:58:45 -0500 Subject: [PATCH 0942/3196] Add simple row status breakdown when viewing batch --- tailbone/templates/batch/view.mako | 34 ++++++++++++++++++++++++++++++ tailbone/views/batch/core.py | 21 ++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/tailbone/templates/batch/view.mako b/tailbone/templates/batch/view.mako index b145cab0..f535ce4a 100644 --- a/tailbone/templates/batch/view.mako +++ b/tailbone/templates/batch/view.mako @@ -40,6 +40,18 @@ display: inline; } + .batch-helper { + border: 1px solid black; + float: right; + margin-top: 1em; + padding: 1em; + width: 20em; + } + + .batch-helper-content { + margin-top: 1em; + } + @@ -79,6 +91,28 @@ ${self.context_menu_items()} +% if status_breakdown is not Undefined: +
            +

            Row Status Breakdown

            +
            + % if status_breakdown: +
            +
            + % for i, (status, count) in enumerate(status_breakdown): + + + + + % endfor +
            ${status}${count}
            +
            + % else: +

            Nothing to report yet.

            + % endif +
            +
            +% endif +
            ${form.render(form_id='batch-form', buttons=capture(buttons))|n}
            diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index f320ee2a..f29df6e3 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -159,8 +159,29 @@ class BatchMasterView(MasterView): kwargs['execute_form'] = self.make_execute_form(batch, action_url=url) else: kwargs['why_not_execute'] = self.handler.why_not_execute(batch) + kwargs['status_breakdown'] = self.make_status_breakdown(batch) return kwargs + def make_status_breakdown(self, batch): + """ + Returns a simple list of 2-tuples, each of which has the status display + title as first member, and number of rows with that status as second + member. + """ + breakdown = {} + for row in batch.active_rows(): + if row.status_code not in breakdown: + breakdown[row.status_code] = { + 'code': row.status_code, + 'title': row.STATUS[row.status_code], + 'count': 0, + } + breakdown[row.status_code]['count'] += 1 + breakdown = [ + (status['title'], status['count']) + for code, status in six.iteritems(breakdown)] + return breakdown + def allow_worksheet(self, batch): return not batch.executed and not batch.complete From 8c26b632fec4d6ec2e7a3ce8de1b9298b4a6826a Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 20 Sep 2018 16:15:45 -0500 Subject: [PATCH 0943/3196] Only show mobile "quick receive" buttons if product is identifiable --- tailbone/templates/mobile/receiving/view_row.mako | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tailbone/templates/mobile/receiving/view_row.mako b/tailbone/templates/mobile/receiving/view_row.mako index f09fec34..53d8820f 100644 --- a/tailbone/templates/mobile/receiving/view_row.mako +++ b/tailbone/templates/mobile/receiving/view_row.mako @@ -85,7 +85,8 @@ ${h.hidden('cases')} ${h.hidden('units')} - % if quick_receive: + ## only show quick-receive if we have an identifiable product + % if quick_receive and instance.product: % if quick_receive_all: % elif allow_cases: From c3637bc4167fd4239fd29e01c1e003844ff8d22a Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 20 Sep 2018 16:20:49 -0500 Subject: [PATCH 0944/3196] 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 fd7c233f..9443c7fa 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,30 @@ CHANGELOG ========= +0.7.34 (2018-09-20) +------------------- + +* Add unique check for "name" when creating new Role. + +* Fix bug when editing truck dump child batch row quantities. + +* Add setting to show/hide product image for mobile purchasing/receiving. + +* Show red background for mobile receiving if product not found. + +* Add quick-receive 1EA, 3EA, 6EA for mobile receiving. + +* Fix how we check config for mobile "quick receive" feature. + +* Do quick lookup by vendor item code, alt code for mobile receiving. + +* Fix price fields, add pref. vendor/cost fields for mobile product view. + +* Add simple row status breakdown when viewing batch. + +* Only show mobile "quick receive" buttons if product is identifiable. + + 0.7.33 (2018-09-10) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index c5bf1d5b..e82ecde6 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.7.33' +__version__ = '0.7.34' From fb3105c099b4d8d8a729afde15ab6590f7009e28 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 20 Sep 2018 18:22:36 -0500 Subject: [PATCH 0945/3196] Fix batch row status breakdown, for rows with no status --- tailbone/views/batch/core.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index f29df6e3..402619b2 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -170,13 +170,14 @@ class BatchMasterView(MasterView): """ breakdown = {} for row in batch.active_rows(): - if row.status_code not in breakdown: - breakdown[row.status_code] = { - 'code': row.status_code, - 'title': row.STATUS[row.status_code], - 'count': 0, - } - breakdown[row.status_code]['count'] += 1 + if row.status_code is not None: + if row.status_code not in breakdown: + breakdown[row.status_code] = { + 'code': row.status_code, + 'title': row.STATUS[row.status_code], + 'count': 0, + } + breakdown[row.status_code]['count'] += 1 breakdown = [ (status['title'], status['count']) for code, status in six.iteritems(breakdown)] From 99688c1c7778276dedd0ff1e99c51e6e3220540f Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 20 Sep 2018 18:23:09 -0500 Subject: [PATCH 0946/3196] 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 9443c7fa..ba0a7951 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.7.35 (2018-09-20) +------------------- + +* Fix batch row status breakdown, for rows with no status. + + 0.7.34 (2018-09-20) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index e82ecde6..b3fac4cf 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.7.34' +__version__ = '0.7.35' From 255485296cf11ccef72300eb4002a7b1d8d4396e Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 21 Sep 2018 19:58:08 -0500 Subject: [PATCH 0947/3196] Leverage alternate code also, for mobile product quick lookup --- tailbone/views/products.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 684c49fc..ff816be4 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -776,12 +776,14 @@ class ProductsView(MasterView): return self.render_to_response('index', context, mobile=True) def mobile_quick_lookup(self): - entry = self.request.POST['quick_entry'] + entry = self.request.POST['quick_entry'].strip() provided = GPC(entry, calc_check_digit=False) product = api.get_product_by_upc(self.Session(), provided) if not product: checked = GPC(entry, calc_check_digit='upc') product = api.get_product_by_upc(self.Session(), checked) + if not product: + product = api.get_product_by_code(self.Session(), entry) if not product: raise self.notfound() return self.redirect(self.get_action_url('view', product, mobile=True)) From 4a610ba2e64d973db56b632aa8219ed5be50791d Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 22 Sep 2018 18:33:01 -0500 Subject: [PATCH 0948/3196] Misc. UI improvements for truck dump receiving on desktop links back and forth between parent/child rows, a little help text etc. --- tailbone/forms/core.py | 16 +++++++++++++- tailbone/views/purchasing/batch.py | 3 ++- tailbone/views/purchasing/receiving.py | 29 ++++++++++++++++++++++---- 3 files changed, 42 insertions(+), 6 deletions(-) diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index e9c33b11..5e079085 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.py @@ -731,12 +731,26 @@ class Form(object): return True def render_field_readonly(self, field_name, **kwargs): + """ + Render the given field completely, but in read-only fashion. + + Note that this method will generate the wrapper div and label, as well + as the field value. + """ if field_name not in self.fields: return '' + + # TODO: fair bit of duplication here, should merge with deform.mako label = HTML.tag('label', self.get_label(field_name), for_=field_name) field = self.render_field_value(field_name) or '' field_div = HTML.tag('div', class_='field', c=[field]) - return HTML.tag('div', class_='field-wrapper {}'.format(field_name), c=[label, field_div]) + contents = [label, field_div] + + if self.has_helptext(field_name): + contents.append(HTML.tag('span', class_='instructions', + c=[self.render_helptext(field_name)])) + + return HTML.tag('div', class_='field-wrapper {}'.format(field_name), c=contents) def render_field_value(self, field_name): record = self.model_instance diff --git a/tailbone/views/purchasing/batch.py b/tailbone/views/purchasing/batch.py index 746ae4d3..ff7cec6b 100644 --- a/tailbone/views/purchasing/batch.py +++ b/tailbone/views/purchasing/batch.py @@ -628,7 +628,8 @@ class PurchasingBatchView(BatchMasterView): if row.status_code in (row.STATUS_INCOMPLETE, row.STATUS_CASE_QUANTITY_DIFFERS, row.STATUS_ORDERED_RECEIVED_DIFFER, - row.STATUS_TRUCKDUMP_UNCLAIMED): + row.STATUS_TRUCKDUMP_UNCLAIMED, + row.STATUS_TRUCKDUMP_PARTCLAIMED): return 'notice' def configure_row_form(self, f): diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index 664d7b03..4bbf9cc2 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -140,6 +140,7 @@ class ReceivingBatchView(PurchasingBatchView): 'id', 'vendor', 'truck_dump', + 'description', 'department', 'buyer', 'date_ordered', @@ -428,7 +429,7 @@ class ReceivingBatchView(PurchasingBatchView): truck_dump = batch.truck_dump_batch if not truck_dump: return "" - text = six.text_type(truck_dump) + text = "({}) {}".format(truck_dump.id_str, truck_dump.description or '') url = self.request.route_url('receiving.view', uuid=truck_dump.uuid) return tags.link_to(text, url) @@ -445,7 +446,7 @@ class ReceivingBatchView(PurchasingBatchView): if children: items = [] for child in children: - text = six.text_type(child) + text = "({}) {}".format(child.id_str, child.description or '') url = self.request.route_url('receiving.view', uuid=child.uuid) items.append(HTML.tag('li', c=[tags.link_to(text, url)])) contents.append(HTML.tag('ul', c=items)) @@ -704,20 +705,40 @@ class ReceivingBatchView(PurchasingBatchView): # claims if batch.is_truck_dump_parent(): - f.set_renderer('claims', self.render_row_claims) + f.set_renderer('claims', self.render_parent_row_claims) + f.set_helptext('claims', "Parent row is claimed by these child rows.") + elif batch.is_truck_dump_child(): + f.set_renderer('claims', self.render_child_row_claims) + f.set_helptext('claims', "Child row makes claims against these parent rows.") else: f.remove_field('claims') - def render_row_claims(self, row, field): + def render_parent_row_claims(self, row, field): items = [] for claim in row.claims: child_row = claim.claiming_row child_batch = child_row.batch text = child_batch.id_str + if child_batch.description: + text = "{} ({})".format(text, child_batch.description) + text = "{}, row {}".format(text, child_row.sequence) url = self.get_row_action_url('view', child_row) items.append(HTML.tag('li', c=[tags.link_to(text, url)])) return HTML.tag('ul', c=items) + def render_child_row_claims(self, row, field): + items = [] + for claim in row.truck_dump_claims: + parent_row = claim.claimed_row + parent_batch = parent_row.batch + text = parent_batch.id_str + if parent_batch.description: + text = "{} ({})".format(text, parent_batch.description) + text = "{}, row {}".format(text, parent_row.sequence) + url = self.get_row_action_url('view', parent_row) + items.append(HTML.tag('li', c=[tags.link_to(text, url)])) + return HTML.tag('ul', c=items) + def validate_row_form(self, form): # if normal validation fails, stop there From d7863c257269bbeac9cb26213810bc71ca72038e Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 22 Sep 2018 19:27:17 -0500 Subject: [PATCH 0949/3196] Add speedbump by default when deleting any "row" record also, allow deleting rows for truck dump child batch --- tailbone/grids/core.py | 3 ++- tailbone/templates/grids/grid.mako | 2 +- tailbone/views/master.py | 2 +- tailbone/views/purchasing/receiving.py | 4 ++-- 4 files changed, 6 insertions(+), 5 deletions(-) diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index 66705160..d686b355 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -72,7 +72,7 @@ class Grid(object): joiners={}, filterable=False, filters={}, use_byte_string_filters=False, sortable=False, sorters={}, default_sortkey=None, default_sortdir='asc', pageable=False, default_pagesize=20, default_page=1, - checkboxes=False, checked=None, main_actions=[], more_actions=[], + checkboxes=False, checked=None, main_actions=[], more_actions=[], delete_speedbump=False, **kwargs): self.key = key @@ -112,6 +112,7 @@ class Grid(object): self.checked = lambda item: False self.main_actions = main_actions or [] self.more_actions = more_actions or [] + self.delete_speedbump = delete_speedbump self._whgrid_kwargs = kwargs diff --git a/tailbone/templates/grids/grid.mako b/tailbone/templates/grids/grid.mako index 0b4c1d39..146fcab6 100644 --- a/tailbone/templates/grids/grid.mako +++ b/tailbone/templates/grids/grid.mako @@ -1,5 +1,5 @@ ## -*- coding: utf-8; -*- -
            +
            ${grid.make_webhelpers_grid()}
            diff --git a/tailbone/views/master.py b/tailbone/views/master.py index cbeba251..e57a6104 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -128,7 +128,7 @@ class MasterView(View): rows_creatable = False rows_editable = False rows_deletable = False - rows_deletable_speedbump = False + rows_deletable_speedbump = True rows_bulk_deletable = False rows_default_pagesize = 20 rows_downloadable_csv = False diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index 4bbf9cc2..88885ca4 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -260,9 +260,9 @@ class ReceivingBatchView(PurchasingBatchView): if batch.is_truck_dump_parent(): return True - # can never delete rows from truck dump child + # can always delete rows from truck dump child elif batch.is_truck_dump_child(): - return False + return True else: # okay, normal batch if batch.order_quantities_known: From ed5455089e9138476fb912a8a16df2ce08d1bf21 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 22 Sep 2018 20:18:52 -0500 Subject: [PATCH 0950/3196] Expose `item_entry` field for receiving batch row --- tailbone/views/purchasing/receiving.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index 88885ca4..9831d9e2 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -211,6 +211,7 @@ class ReceivingBatchView(PurchasingBatchView): ] row_form_fields = [ + 'item_entry', 'upc', 'item_id', 'product', From 878486cdaba739e03e7f9f36b9ccc831626fdae6 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 25 Sep 2018 17:50:16 -0500 Subject: [PATCH 0951/3196] Capture user input for mobile receiving, and move some lookup logic i.e. most of the logic responsible for looking up an item from e.g. scanner entry, now lives in the handler for easier customization --- tailbone/views/purchasing/receiving.py | 33 ++++++-------------------- 1 file changed, 7 insertions(+), 26 deletions(-) diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index 9831d9e2..38be04d9 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -1030,35 +1030,12 @@ class ReceivingBatchView(PurchasingBatchView): def quick_locate_product(self, batch, entry): - # try to locate product by uuid before other, more specific key - product = self.Session.query(model.Product).get(entry) + # first let the handler attempt lookup on product key (only) + product = self.handler.locate_product_for_entry(self.Session(), entry, + lookup_by_code=False) if product: return product - key = self.rattail_config.product_key() - if key == 'upc': - - # we first assume the user entry *does* include check digit - provided = GPC(entry, calc_check_digit=False) - product = api.get_product_by_upc(self.Session(), provided) - if product: - return product - - # but we can also calculate a check digit and try that - checked = GPC(entry, calc_check_digit='upc') - product = api.get_product_by_upc(self.Session(), checked) - if product: - return product - - elif key == 'item_id': - - # try to locate product by item_id - product = api.get_product_by_item_id(self.Session(), entry) - if product: - return product - - # if we made it this far, lookup by product key failed. - # now we'll attempt lookup by vendor item code product = api.get_product_by_vendor_code(self.Session(), entry, vendor=batch.vendor) if product: @@ -1088,6 +1065,7 @@ class ReceivingBatchView(PurchasingBatchView): else: # borrow product from matching row, but make new row other_row = rows[0] row = model.PurchaseBatchRow() + row.item_entry = entry row.product = other_row.product self.handler.add_row(batch, row) self.Session.flush() @@ -1099,6 +1077,7 @@ class ReceivingBatchView(PurchasingBatchView): # TODO: probably should be smarter about how we handle deleted? if product and not product.deleted: row = model.PurchaseBatchRow() + row.item_entry = entry row.product = product self.handler.add_row(batch, row) self.Session.flush() @@ -1117,6 +1096,7 @@ class ReceivingBatchView(PurchasingBatchView): # product not in system, but presumably sane upc, so add to batch anyway row = model.PurchaseBatchRow() + row.item_entry = entry add_check_digit = True # TODO: make this dynamic, of course if add_check_digit: row.upc = checked @@ -1137,6 +1117,7 @@ class ReceivingBatchView(PurchasingBatchView): # product not in system, but presumably sane item_id, so add to batch anyway row = model.PurchaseBatchRow() + row.item_entry = entry row.item_id = entry row.description = "(unknown product)" self.handler.add_row(batch, row) From 27d5a92fee6370c6e16ce4f5c3d5ea98ae582479 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 25 Sep 2018 19:12:19 -0500 Subject: [PATCH 0952/3196] Tweak purchasing / receiving UI a bit rows with 'out of stock' status are yellow; improve some row filter labels --- tailbone/views/purchasing/batch.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/tailbone/views/purchasing/batch.py b/tailbone/views/purchasing/batch.py index ff7cec6b..ef105df7 100644 --- a/tailbone/views/purchasing/batch.py +++ b/tailbone/views/purchasing/batch.py @@ -610,10 +610,17 @@ class PurchasingBatchView(BatchMasterView): g.set_type('invoice_total', 'currency') g.set_type('credits', 'boolean') + # we only want the grid column to have abbreviated label, but *not* the filter + # TODO: would be nice to somehow make this simpler g.set_label('cases_ordered', "Cases Ord.") + g.filters['cases_ordered'].label = "Cases Ordered" g.set_label('units_ordered', "Units Ord.") + g.filters['units_ordered'].label = "Units Ordered" g.set_label('cases_received', "Cases Rec.") + g.filters['cases_received'].label = "Cases Received" g.set_label('units_received', "Units Rec.") + g.filters['units_received'].label = "Units Received" + g.set_label('po_total', "Total") g.set_label('invoice_total', "Total") g.set_label('credits', "Credits?") @@ -629,7 +636,8 @@ class PurchasingBatchView(BatchMasterView): row.STATUS_CASE_QUANTITY_DIFFERS, row.STATUS_ORDERED_RECEIVED_DIFFER, row.STATUS_TRUCKDUMP_UNCLAIMED, - row.STATUS_TRUCKDUMP_PARTCLAIMED): + row.STATUS_TRUCKDUMP_PARTCLAIMED, + row.STATUS_OUT_OF_STOCK): return 'notice' def configure_row_form(self, f): From 6c309705a0762595fbb44f8ddd76696066644170 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 26 Sep 2018 17:02:11 -0500 Subject: [PATCH 0953/3196] 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 ba0a7951..e66dc20e 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,20 @@ CHANGELOG ========= +0.7.36 (2018-09-26) +------------------- + +* Leverage alternate code also, for mobile product quick lookup. + +* Misc. UI improvements for truck dump receiving on desktop. + +* Add speedbump by default when deleting any "row" record. + +* Expose ``item_entry`` field for receiving batch row. + +* Capture user input for mobile receiving, and move some lookup logic. + + 0.7.35 (2018-09-20) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index b3fac4cf..3ee1e4dc 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.7.35' +__version__ = '0.7.36' From d458d699e6086c91698a84bc0a10d0e1665ff58f Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 27 Sep 2018 21:10:36 -0500 Subject: [PATCH 0954/3196] Restrict (temporarily I hope) webhelpers2_grid to 0.1 until we can figure out what happened in their 0.9 release --- setup.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/setup.py b/setup.py index da99d2e4..50d9f1f6 100644 --- a/setup.py +++ b/setup.py @@ -67,6 +67,9 @@ requires = [ # TODO: why do we need to cap this? breaks tailbone.db zope stuff somehow 'zope.sqlalchemy<1.0', # 0.7 0.7.7 + # TODO: apparently they jumped from 0.1 to 0.9 and that broke us...must investigate + 'webhelpers2_grid==0.1', # 0.1 + 'ColanderAlchemy', # 0.3.3 'deform', # 2.0.4 'humanize', # 0.5.1 @@ -86,7 +89,6 @@ requires = [ 'transaction', # 1.2.0 'waitress', # 0.8.1 'WebHelpers2', # 2.0 - 'webhelpers2_grid', # 0.1 'WTForms', # 2.1 ] From c1f05bf014593bfae1602cdf1a71dbd71d97e85b Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 27 Sep 2018 21:11:27 -0500 Subject: [PATCH 0955/3196] 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 e66dc20e..93ca917f 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.7.37 (2018-09-27) +------------------- + +* Restrict (temporarily I hope) webhelpers2_grid to 0.1. + + 0.7.36 (2018-09-26) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 3ee1e4dc..d4652d0d 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.7.36' +__version__ = '0.7.37' From 66807a801b2061769bfd2ef858e4ea6511492d79 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 28 Sep 2018 12:22:43 -0500 Subject: [PATCH 0956/3196] Add support for "archived" flag in Tempmon Client views --- tailbone/views/tempmon/clients.py | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/tailbone/views/tempmon/clients.py b/tailbone/views/tempmon/clients.py index 41a0d8be..eda6f5f4 100644 --- a/tailbone/views/tempmon/clients.py +++ b/tailbone/views/tempmon/clients.py @@ -55,6 +55,7 @@ class TempmonClientView(MasterView): 'delay', 'enabled', 'online', + 'archived', ] form_fields = [ @@ -65,25 +66,31 @@ class TempmonClientView(MasterView): 'probes', 'enabled', 'online', + 'archived', ] def configure_grid(self, g): super(TempmonClientView, self).configure_grid(g) + + # config_key + g.set_label('config_key', "Key") + g.set_sort_defaults('config_key') + g.set_link('config_key') + + # hostname g.filters['hostname'].default_active = True g.filters['hostname'].default_verb = 'contains' + g.set_link('hostname') + + # location g.filters['location'].default_active = True g.filters['location'].default_verb = 'contains' - g.set_sort_defaults('config_key') - - g.set_type('enabled', 'boolean') - g.set_type('online', 'boolean') - - g.set_label('config_key', "Key") - - g.set_link('config_key') - g.set_link('hostname') g.set_link('location') + # archived + g.filters['archived'].default_active = True + g.filters['archived'].default_verb = 'is_false' + def configure_form(self, f): super(TempmonClientView, self).configure_form(f) From 6c1d67c9663906b867cd169347809d4ec3707e8a Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 28 Sep 2018 12:27:08 -0500 Subject: [PATCH 0957/3196] Expose notes field for tempmon client and probe views --- tailbone/views/tempmon/clients.py | 4 ++++ tailbone/views/tempmon/probes.py | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/tailbone/views/tempmon/clients.py b/tailbone/views/tempmon/clients.py index eda6f5f4..59cc4fdf 100644 --- a/tailbone/views/tempmon/clients.py +++ b/tailbone/views/tempmon/clients.py @@ -64,6 +64,7 @@ class TempmonClientView(MasterView): 'location', 'delay', 'probes', + 'notes', 'enabled', 'online', 'archived', @@ -100,6 +101,9 @@ class TempmonClientView(MasterView): # probes f.set_renderer('probes', self.render_probes) + # notes + f.set_type('notes', 'text') + if self.creating or self.editing: f.remove_fields('probes', 'online') diff --git a/tailbone/views/tempmon/probes.py b/tailbone/views/tempmon/probes.py index 48176900..dda7192f 100644 --- a/tailbone/views/tempmon/probes.py +++ b/tailbone/views/tempmon/probes.py @@ -69,6 +69,7 @@ class TempmonProbeView(MasterView): 'critical_temp_max', 'therm_status_timeout', 'status_alert_timeout', + 'notes', 'enabled', 'status', ] @@ -112,6 +113,9 @@ class TempmonProbeView(MasterView): # appliance_type f.set_enum('appliance_type', self.enum.TEMPMON_APPLIANCE_TYPE) + # notes + f.set_type('notes', 'text') + # status f.set_enum('status', self.enum.TEMPMON_PROBE_STATUS) if self.creating or self.editing: From 5e49c2709b476abdff03f6598bd3e8f2789fd1ce Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 28 Sep 2018 19:15:33 -0500 Subject: [PATCH 0958/3196] Expose new `disk_type` field for tempmon client views --- tailbone/views/tempmon/clients.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tailbone/views/tempmon/clients.py b/tailbone/views/tempmon/clients.py index 59cc4fdf..3fd41723 100644 --- a/tailbone/views/tempmon/clients.py +++ b/tailbone/views/tempmon/clients.py @@ -62,6 +62,7 @@ class TempmonClientView(MasterView): 'config_key', 'hostname', 'location', + 'disk_type', 'delay', 'probes', 'notes', @@ -88,6 +89,9 @@ class TempmonClientView(MasterView): g.filters['location'].default_verb = 'contains' g.set_link('location') + # disk_type + g.set_enum('disk_type', self.enum.TEMPMON_DISK_TYPE) + # archived g.filters['archived'].default_active = True g.filters['archived'].default_verb = 'is_false' @@ -98,6 +102,10 @@ class TempmonClientView(MasterView): # config_key f.set_validator('config_key', self.unique_config_key) + # disk_type + f.set_enum('disk_type', self.enum.TEMPMON_DISK_TYPE) + f.widgets['disk_type'].values.insert(0, ('', "(unknown)")) + # probes f.set_renderer('probes', self.render_probes) From 848b727b113055e8589b0d47abc99e5800fa0fc9 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 29 Sep 2018 14:24:03 -0500 Subject: [PATCH 0959/3196] Tweak how receiving rows are looked up when adding to the batch i.e. locate the product first, and then try to find an existing row to match. previously we looked for a row based on product key match only, and it could cause new rows to be created for a product we already had in the batch (i.e. if the product was located via some secondary lookup other than product key) --- tailbone/views/purchasing/receiving.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index 38be04d9..19aab266 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -705,6 +705,7 @@ class ReceivingBatchView(PurchasingBatchView): f.set_readonly('invoice_total') # claims + f.set_readonly('claims') if batch.is_truck_dump_parent(): f.set_renderer('claims', self.render_parent_row_claims) f.set_helptext('claims', "Parent row is claimed by these child rows.") @@ -994,11 +995,10 @@ class ReceivingBatchView(PurchasingBatchView): """ return True - def quick_locate_rows(self, batch, entry): + def quick_locate_rows(self, batch, entry, product): rows = [] # try to locate rows by product uuid match before other key - product = self.Session.query(model.Product).get(entry) if product: rows = [row for row in batch.active_rows() if row.product_uuid == product.uuid] @@ -1050,8 +1050,11 @@ class ReceivingBatchView(PurchasingBatchView): batch = self.get_instance() entry = form.validated['quick_entry'] - # maybe try to locate existing row first - rows = self.quick_locate_rows(batch, entry) + # first try to locate the product based on quick entry + product = self.quick_locate_product(batch, entry) + + # then try to locate existing row(s) which match product/entry + rows = self.quick_locate_rows(batch, entry, product) if rows: # if aggregating, just re-use matching row @@ -1072,8 +1075,7 @@ class ReceivingBatchView(PurchasingBatchView): self.handler.refresh_batch_status(batch) return row - # if product is easily located, add new row for it - product = self.quick_locate_product(batch, entry) + # matching row(s) not found; add new row if product was identified # TODO: probably should be smarter about how we handle deleted? if product and not product.deleted: row = model.PurchaseBatchRow() From d50d3678ecdac0d01a872045cec554f45541e413 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 3 Oct 2018 14:20:10 -0500 Subject: [PATCH 0960/3196] 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 93ca917f..3d2ad125 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,18 @@ CHANGELOG ========= +0.7.38 (2018-10-03) +------------------- + +* Add support for "archived" flag in Tempmon Client views. + +* Expose notes field for tempmon client and probe views. + +* Expose new ``disk_type`` field for tempmon client views. + +* Tweak how receiving rows are looked up when adding to the batch. + + 0.7.37 (2018-09-27) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index d4652d0d..d2884f89 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.7.37' +__version__ = '0.7.38' From 7650064b648b326772c35a2e4d0d87ca8cf79f24 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 3 Oct 2018 15:54:28 -0500 Subject: [PATCH 0961/3196] Fix bug when non-numeric entry given for mobile inventory "quick row" --- tailbone/views/inventory.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tailbone/views/inventory.py b/tailbone/views/inventory.py index ba52e051..1414e730 100644 --- a/tailbone/views/inventory.py +++ b/tailbone/views/inventory.py @@ -491,7 +491,8 @@ class InventoryBatchView(BatchMasterView): """ batch = self.get_instance() row = None - entry = self.request.GET.get('upc', '').strip() + raw_entry = self.request.GET.get('upc', '') + entry = raw_entry.strip() entry = re.sub(r'\D', '', entry) if entry: @@ -505,6 +506,10 @@ class InventoryBatchView(BatchMasterView): self.request.session.flash("UPC has too many digits ({}): {}".format(len(entry), entry), 'error') return self.redirect(self.get_action_url('view', batch, mobile=True)) + else: + self.request.session.flash("Product not found: {}".format(raw_entry), 'error') + return self.redirect(self.get_action_url('view', batch, mobile=True)) + self.Session.flush() return self.redirect(self.mobile_row_route_url('view', uuid=row.batch_uuid, row_uuid=row.uuid)) From 29e023096b9b36a02168c0939464d604c4b89359 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 5 Oct 2018 19:29:26 -0500 Subject: [PATCH 0962/3196] Show tempmon readings when viewing client or probe also make the probes list more helpful when viewing client --- tailbone/static/css/forms.css | 17 ++++ tailbone/templates/batch/view.mako | 16 +--- tailbone/templates/tempmon/clients/view.mako | 23 ++++-- tailbone/views/tempmon/clients.py | 84 +++++++++++++++++--- tailbone/views/tempmon/probes.py | 32 +++++++- 5 files changed, 140 insertions(+), 32 deletions(-) diff --git a/tailbone/static/css/forms.css b/tailbone/static/css/forms.css index 950ca82d..dc85c46b 100644 --- a/tailbone/static/css/forms.css +++ b/tailbone/static/css/forms.css @@ -24,6 +24,23 @@ div.form-wrapper ul.context-menu li { } +/****************************** + * "object helper" panel + ******************************/ + +.object-helper { + border: 1px solid black; + float: right; + margin-top: 1em; + padding: 1em; + width: 20em; +} + +.object-helper-content { + margin-top: 1em; +} + + /****************************** * Forms ******************************/ diff --git a/tailbone/templates/batch/view.mako b/tailbone/templates/batch/view.mako index f535ce4a..4874d7ef 100644 --- a/tailbone/templates/batch/view.mako +++ b/tailbone/templates/batch/view.mako @@ -40,18 +40,6 @@ display: inline; } - .batch-helper { - border: 1px solid black; - float: right; - margin-top: 1em; - padding: 1em; - width: 20em; - } - - .batch-helper-content { - margin-top: 1em; - } - @@ -92,9 +80,9 @@ % if status_breakdown is not Undefined: -
            +

            Row Status Breakdown

            -
            +
            % if status_breakdown:
            diff --git a/tailbone/templates/tempmon/clients/view.mako b/tailbone/templates/tempmon/clients/view.mako index 2a508f73..f77b0663 100644 --- a/tailbone/templates/tempmon/clients/view.mako +++ b/tailbone/templates/tempmon/clients/view.mako @@ -1,4 +1,4 @@ -## -*- coding: utf-8 -*- +## -*- coding: utf-8; -*- <%inherit file="/master/view.mako" /> <%def name="head_tags()"> @@ -13,10 +13,23 @@ -${parent.body()} +
              + ${self.context_menu_items()} +
            -% if instance.enabled and master.restartable_client(instance) and request.has_perm('tempmon.clients.restart'): -
            - +% if instance.enabled and master.restartable_client(instance) and request.has_perm('{}.restart'.format(route_prefix)): +
            +

            Client Tools

            +
            + +
            % endif + +
            + ${form.render()|n} +
            + +% if master.has_rows: + ${rows_grid|n} +% endif diff --git a/tailbone/views/tempmon/clients.py b/tailbone/views/tempmon/clients.py index 3fd41723..1b9fd37d 100644 --- a/tailbone/views/tempmon/clients.py +++ b/tailbone/views/tempmon/clients.py @@ -35,6 +35,7 @@ from rattail_tempmon.db import model as tempmon import colander from webhelpers2.html import HTML, tags +from tailbone import grids from tailbone.views.tempmon import MasterView @@ -48,6 +49,9 @@ class TempmonClientView(MasterView): route_prefix = 'tempmon.clients' url_prefix = '/tempmon/clients' + has_rows = True + model_row_class = tempmon.Reading + grid_columns = [ 'config_key', 'hostname', @@ -71,6 +75,12 @@ class TempmonClientView(MasterView): 'archived', ] + row_grid_columns = [ + 'probe', + 'taken', + 'degrees_f', + ] + def configure_grid(self, g): super(TempmonClientView, self).configure_grid(g) @@ -106,15 +116,21 @@ class TempmonClientView(MasterView): f.set_enum('disk_type', self.enum.TEMPMON_DISK_TYPE) f.widgets['disk_type'].values.insert(0, ('', "(unknown)")) + # delay + f.set_helptext('delay', tempmon.Client.delay.__doc__) + # probes - f.set_renderer('probes', self.render_probes) + if self.viewing: + f.set_renderer('probes', self.render_probes) + else: + f.remove_field('probes') # notes f.set_type('notes', 'text') + # online if self.creating or self.editing: - f.remove_fields('probes', - 'online') + f.remove_field('online') def unique_config_key(self, node, value): query = self.Session.query(tempmon.Client)\ @@ -126,15 +142,43 @@ class TempmonClientView(MasterView): raise colander.Invalid(node, "Config key must be unique") def render_probes(self, client, field): - probes = client.probes - if not probes: + if not client.probes: return "" - items = [] - for probe in probes: - text = six.text_type(probe) - url = self.request.route_url('tempmon.probes.view', uuid=probe.uuid) - items.append(HTML.tag('li', c=[tags.link_to(text, url)])) - return HTML.tag('ul', c=items) + + route_prefix = self.get_route_prefix() + view_url = lambda p, i: self.request.route_url('tempmon.probes.view', uuid=p.uuid) + actions = [ + grids.GridAction('view', icon='zoomin', url=view_url), + ] + if self.request.has_perm('tempmon.probes.edit'): + url = lambda p, i: self.request.route_url('tempmon.probes.edit', uuid=p.uuid) + actions.append(grids.GridAction('edit', icon='pencil', url=url)) + + g = grids.Grid( + key='{}.probes'.format(route_prefix), + data=client.probes, + columns=[ + 'description', + 'critical_temp_min', + 'good_temp_min', + 'good_temp_max', + 'critical_temp_max', + 'status', + 'enabled', + ], + labels={ + 'critical_temp_min': "Crit. Min", + 'good_temp_min': "Good Min", + 'good_temp_max': "Good Max", + 'critical_temp_max': "Crit. Max", + }, + url=lambda p: self.request.route_url('tempmon.probes.view', uuid=p.uuid), + linked_columns=['description'], + main_actions=actions, + ) + g.set_enum('status', self.enum.TEMPMON_PROBE_STATUS) + g.set_type('enabled', 'boolean') + return HTML.literal(g.render_grid()) def delete_instance(self, client): # bulk-delete all readings first @@ -149,6 +193,24 @@ class TempmonClientView(MasterView): self.Session.delete(client) self.Session.flush() + def get_row_data(self, client): + query = self.Session.query(tempmon.Reading)\ + .join(tempmon.Probe)\ + .filter(tempmon.Reading.client == client) + return query + + def get_parent(self, reading): + return reading.client + + def configure_row_grid(self, g): + super(TempmonClientView, self).configure_row_grid(g) + + # probe + g.set_filter('probe', tempmon.Probe.description) + g.set_sorter('probe', tempmon.Probe.description) + + g.set_sort_defaults('taken', 'desc') + def restartable_client(self, client): return True diff --git a/tailbone/views/tempmon/probes.py b/tailbone/views/tempmon/probes.py index dda7192f..2320fa34 100644 --- a/tailbone/views/tempmon/probes.py +++ b/tailbone/views/tempmon/probes.py @@ -34,6 +34,7 @@ import colander from deform import widget as dfwidget from webhelpers2.html import tags +from tailbone import grids from tailbone.views.tempmon import MasterView @@ -47,6 +48,9 @@ class TempmonProbeView(MasterView): route_prefix = 'tempmon.probes' url_prefix = '/tempmon/probes' + has_rows = True + model_row_class = tempmon.Reading + grid_columns = [ 'client', 'config_key', @@ -74,6 +78,11 @@ class TempmonProbeView(MasterView): 'status', ] + row_grid_columns = [ + 'taken', + 'degrees_f', + ] + def configure_grid(self, g): super(TempmonProbeView, self).configure_grid(g) @@ -103,8 +112,10 @@ class TempmonProbeView(MasterView): f.set_label('client', "Tempmon Client") if self.creating or self.editing: f.replace('client', 'client_uuid') - clients = self.Session.query(tempmon.Client)\ - .order_by(tempmon.Client.config_key) + clients = self.Session.query(tempmon.Client) + if self.creating: + clients = clients.filter(tempmon.Client.archived == False) + clients = clients.order_by(tempmon.Client.config_key) client_values = [(client.uuid, "{} ({})".format(client.config_key, client.hostname)) for client in clients] f.set_widget('client_uuid', dfwidget.SelectWidget(values=client_values)) @@ -151,6 +162,23 @@ class TempmonProbeView(MasterView): self.Session.delete(probe) self.Session.flush() + def get_row_data(self, probe): + query = self.Session.query(tempmon.Reading)\ + .filter(tempmon.Reading.probe == probe) + return query + + def get_parent(self, reading): + return reading.client + + def configure_row_grid(self, g): + super(TempmonProbeView, self).configure_row_grid(g) + + # # probe + # g.set_filter('probe', tempmon.Probe.description) + # g.set_sorter('probe', tempmon.Probe.description) + + g.set_sort_defaults('taken', 'desc') + def includeme(config): TempmonProbeView.defaults(config) From f17d7355e093b1fbe9f2d842b842dc0c6eaa35ed Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 5 Oct 2018 19:58:58 -0500 Subject: [PATCH 0963/3196] Auto-disable button when sending email preview --- tailbone/templates/settings/email/view.mako | 5 +++-- tailbone/views/email.py | 4 +--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/tailbone/templates/settings/email/view.mako b/tailbone/templates/settings/email/view.mako index 2e9f4325..fa3cb142 100644 --- a/tailbone/templates/settings/email/view.mako +++ b/tailbone/templates/settings/email/view.mako @@ -1,4 +1,4 @@ -## -*- coding: utf-8 -*- +## -*- coding: utf-8; -*- <%inherit file="/master/view.mako" /> <%def name="head_tags()"> @@ -21,8 +21,9 @@ ${parent.body()} -${h.form(url('email.preview'), name='send-email-preview')} +${h.form(url('email.preview'), name='send-email-preview', class_='autodisable')} ${h.csrf_token(request)} + ${h.hidden('email_key', value=instance['key'])} ${h.link_to("Preview HTML", '{}?key={}&type=html'.format(url('email.preview'), instance['key']), id='preview-html', class_='button', target='_blank')} ${h.link_to("Preview TXT", '{}?key={}&type=txt'.format(url('email.preview'), instance['key']), id='preview-txt', class_='button', target='_blank')} or diff --git a/tailbone/views/email.py b/tailbone/views/email.py index f9a3516d..a2fd52cd 100644 --- a/tailbone/views/email.py +++ b/tailbone/views/email.py @@ -280,9 +280,7 @@ class EmailPreview(View): def email_template(self): recipient = self.request.POST.get('recipient') if recipient: - keys = [key for key in self.request.POST.keys() - if key.startswith('send_')] - key = keys[0][5:] if keys else None + key = self.request.POST.get('email_key') if key: email = mail.get_email(self.rattail_config, key) data = email.sample_data(self.request) From e05a58bdee595f4bc9e2cd69ceafc9049f707266 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 6 Oct 2018 17:41:33 -0500 Subject: [PATCH 0964/3196] Add some helptext for various tempmon fields --- tailbone/templates/tempmon/clients/view.mako | 2 +- tailbone/views/tempmon/clients.py | 10 +++++++++- tailbone/views/tempmon/probes.py | 8 +++++++- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/tailbone/templates/tempmon/clients/view.mako b/tailbone/templates/tempmon/clients/view.mako index f77b0663..362bc5dc 100644 --- a/tailbone/templates/tempmon/clients/view.mako +++ b/tailbone/templates/tempmon/clients/view.mako @@ -6,7 +6,7 @@ % endif - ${h.stylesheet_link('https://code.jquery.com/mobile/1.4.5/jquery.mobile-1.4.5.min.css')} + ${self.jquery_theme()} ${h.stylesheet_link(request.static_url('tailbone:static/css/mobile.css') + '?ver={}'.format(tailbone.__version__))} % if not request.rattail_config.production(): diff --git a/tailbone/templates/tempmon/probes/view.mako b/tailbone/templates/tempmon/probes/view.mako new file mode 100644 index 00000000..386cc4db --- /dev/null +++ b/tailbone/templates/tempmon/probes/view.mako @@ -0,0 +1,88 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/view.mako" /> + +############################## +## page body +############################## + +
              + ${self.context_menu_items()} +
            + +
            + +
            +

            General

            +
            +
            + ${self.render_main_fields(form)} +
            +## % if image_url: +## ${h.image(image_url, "Probe Image", id='probe-image', width=150, height=150)} +## % endif +
            +
            + +
            + ${self.left_column()} +
            + +
            + ${self.right_column()} +
            + +
            + +% if master.has_rows: + ${rows_grid|n} +% endif + +############################## +## rendering methods +############################## + +<%def name="render_main_fields(form)"> + ${form.render_field_readonly('client')} + ${form.render_field_readonly('config_key')} + ${form.render_field_readonly('appliance_type')} + ${form.render_field_readonly('description')} + ${form.render_field_readonly('device_path')} + ${form.render_field_readonly('notes')} + ${form.render_field_readonly('enabled')} + ${form.render_field_readonly('status')} + ${form.render_field_readonly('therm_status_timeout')} + ${form.render_field_readonly('status_alert_timeout')} + + +<%def name="left_column()"> +
            +

            Temperatures

            +
            + ${self.render_temperature_fields(form)} +
            +
            + + +<%def name="right_column()"> +
            +

            Timeouts

            +
            + ${self.render_timeout_fields(form)} +
            +
            + + +<%def name="render_temperature_fields(form)"> + ${form.render_field_readonly('critical_temp_max')} + ${form.render_field_readonly('good_temp_max')} + ${form.render_field_readonly('good_temp_min')} + ${form.render_field_readonly('critical_temp_min')} + + +<%def name="render_timeout_fields(form)"> + ${form.render_field_readonly('critical_max_timeout')} + ${form.render_field_readonly('good_max_timeout')} + ${form.render_field_readonly('good_min_timeout')} + ${form.render_field_readonly('critical_min_timeout')} + ${form.render_field_readonly('error_timeout')} + diff --git a/tailbone/views/tempmon/probes.py b/tailbone/views/tempmon/probes.py index 22a8d309..25542b4c 100644 --- a/tailbone/views/tempmon/probes.py +++ b/tailbone/views/tempmon/probes.py @@ -51,6 +51,13 @@ class TempmonProbeView(MasterView): has_rows = True model_row_class = tempmon.Reading + labels = { + 'critical_max_timeout': "Critical High Timeout", + 'good_max_timeout': "High Timeout", + 'good_min_timeout': "Low Timeout", + 'critical_min_timeout': "Critical Low Timeout", + } + grid_columns = [ 'client', 'config_key', From 40a8761feb3964de9ebd23e28202f662b832faf0 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 19 Oct 2018 17:55:23 -0500 Subject: [PATCH 0982/3196] Add support for new Tempmon Appliance table, etc. --- tailbone/templates/tempmon/probes/view.mako | 2 + tailbone/views/tempmon/__init__.py | 1 + tailbone/views/tempmon/appliances.py | 93 +++++++++++++++++++++ tailbone/views/tempmon/clients.py | 57 ++++--------- tailbone/views/tempmon/core.py | 46 +++++++++- tailbone/views/tempmon/probes.py | 23 +++++ 6 files changed, 182 insertions(+), 40 deletions(-) create mode 100644 tailbone/views/tempmon/appliances.py diff --git a/tailbone/templates/tempmon/probes/view.mako b/tailbone/templates/tempmon/probes/view.mako index 386cc4db..8acdbfbe 100644 --- a/tailbone/templates/tempmon/probes/view.mako +++ b/tailbone/templates/tempmon/probes/view.mako @@ -44,8 +44,10 @@ <%def name="render_main_fields(form)"> ${form.render_field_readonly('client')} ${form.render_field_readonly('config_key')} + ${form.render_field_readonly('appliance')} ${form.render_field_readonly('appliance_type')} ${form.render_field_readonly('description')} + ${form.render_field_readonly('location')} ${form.render_field_readonly('device_path')} ${form.render_field_readonly('notes')} ${form.render_field_readonly('enabled')} diff --git a/tailbone/views/tempmon/__init__.py b/tailbone/views/tempmon/__init__.py index 5f26a065..61ce6bf0 100644 --- a/tailbone/views/tempmon/__init__.py +++ b/tailbone/views/tempmon/__init__.py @@ -30,6 +30,7 @@ from .core import MasterView def includeme(config): + config.include('tailbone.views.tempmon.appliances') config.include('tailbone.views.tempmon.clients') config.include('tailbone.views.tempmon.probes') config.include('tailbone.views.tempmon.readings') diff --git a/tailbone/views/tempmon/appliances.py b/tailbone/views/tempmon/appliances.py new file mode 100644 index 00000000..1da74dd0 --- /dev/null +++ b/tailbone/views/tempmon/appliances.py @@ -0,0 +1,93 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2018 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see . +# +################################################################################ +""" +Views for tempmon appliances +""" + +from __future__ import unicode_literals, absolute_import + +from rattail_tempmon.db import model as tempmon + +from webhelpers2.html import HTML, tags + +from tailbone.views.tempmon import MasterView + + +class TempmonApplianceView(MasterView): + """ + Master view for tempmon appliances. + """ + model_class = tempmon.Appliance + model_title = "TempMon Appliance" + model_title_plural = "TempMon Appliances" + route_prefix = 'tempmon.appliances' + url_prefix = '/tempmon/appliances' + + grid_columns = [ + 'name', + 'location', + ] + + form_fields = [ + 'name', + 'location', + 'clients', + 'probes', + ] + + def configure_grid(self, g): + super(TempmonApplianceView, self).configure_grid(g) + g.set_sort_defaults('name') + + def configure_form(self, f): + super(TempmonApplianceView, self).configure_form(f) + + # clients + if self.viewing: + f.set_renderer('clients', self.render_clients) + else: + f.remove_field('clients') + + # probes + if self.viewing: + f.set_renderer('probes', self.render_probes) + elif self.creating or self.editing: + f.remove_field('probes') + + def render_clients(self, appliance, field): + clients = {} + for probe in appliance.probes: + if probe.client.uuid not in clients: + clients[probe.client.uuid] = probe.client + + if not clients: + return "" + + clients = sorted(clients.values(), key=lambda client: client.hostname) + items = [HTML.tag('li', c=[tags.link_to(client.hostname, self.request.route_url('tempmon.clients.view', uuid=client.uuid))]) + for client in clients] + return HTML.tag('ul', c=items) + + +def includeme(config): + TempmonApplianceView.defaults(config) diff --git a/tailbone/views/tempmon/clients.py b/tailbone/views/tempmon/clients.py index 560525f1..dc5d54ce 100644 --- a/tailbone/views/tempmon/clients.py +++ b/tailbone/views/tempmon/clients.py @@ -28,14 +28,11 @@ from __future__ import unicode_literals, absolute_import import subprocess -import six - from rattail_tempmon.db import model as tempmon import colander from webhelpers2.html import HTML, tags -from tailbone import grids from tailbone.views.tempmon import MasterView @@ -68,6 +65,7 @@ class TempmonClientView(MasterView): 'location', 'disk_type', 'delay', + 'appliances', 'probes', 'notes', 'enabled', @@ -119,6 +117,12 @@ class TempmonClientView(MasterView): # delay f.set_helptext('delay', tempmon.Client.delay.__doc__) + # appliances + if self.viewing: + f.set_renderer('appliances', self.render_appliances) + else: + f.remove_field('appliances') + # probes if self.viewing: f.set_renderer('probes', self.render_probes) @@ -149,44 +153,19 @@ class TempmonClientView(MasterView): if query.count(): raise colander.Invalid(node, "Config key must be unique") - def render_probes(self, client, field): - if not client.probes: + def render_appliances(self, client, field): + appliances = {} + for probe in client.probes: + if probe.appliance and probe.appliance.uuid not in appliances: + appliances[probe.appliance.uuid] = probe.appliance + + if not appliances: return "" - route_prefix = self.get_route_prefix() - view_url = lambda p, i: self.request.route_url('tempmon.probes.view', uuid=p.uuid) - actions = [ - grids.GridAction('view', icon='zoomin', url=view_url), - ] - if self.request.has_perm('tempmon.probes.edit'): - url = lambda p, i: self.request.route_url('tempmon.probes.edit', uuid=p.uuid) - actions.append(grids.GridAction('edit', icon='pencil', url=url)) - - g = grids.Grid( - key='{}.probes'.format(route_prefix), - data=client.probes, - columns=[ - 'description', - 'critical_temp_min', - 'good_temp_min', - 'good_temp_max', - 'critical_temp_max', - 'status', - 'enabled', - ], - labels={ - 'critical_temp_min': "Crit. Min", - 'good_temp_min': "Good Min", - 'good_temp_max': "Good Max", - 'critical_temp_max': "Crit. Max", - }, - url=lambda p: self.request.route_url('tempmon.probes.view', uuid=p.uuid), - linked_columns=['description'], - main_actions=actions, - ) - g.set_enum('status', self.enum.TEMPMON_PROBE_STATUS) - g.set_type('enabled', 'boolean') - return HTML.literal(g.render_grid()) + appliances = sorted(appliances.values(), key=lambda a: a.name) + items = [HTML.tag('li', c=[tags.link_to(a.name, self.request.route_url('tempmon.appliances.view', uuid=a.uuid))]) + for a in appliances] + return HTML.tag('ul', c=items) def delete_instance(self, client): # bulk-delete all readings first diff --git a/tailbone/views/tempmon/core.py b/tailbone/views/tempmon/core.py index 6b85944e..03c4b9f1 100644 --- a/tailbone/views/tempmon/core.py +++ b/tailbone/views/tempmon/core.py @@ -28,7 +28,9 @@ from __future__ import unicode_literals, absolute_import from rattail_tempmon.db import Session as RawTempmonSession -from tailbone import views +from webhelpers2.html import HTML + +from tailbone import views, grids from tailbone.db import TempmonSession @@ -40,3 +42,45 @@ class MasterView(views.MasterView): def get_bulk_delete_session(self): return RawTempmonSession() + + def render_probes(self, obj, field): + """ + This method is used by Appliance and Client views. + """ + if not obj.probes: + return "" + + route_prefix = self.get_route_prefix() + view_url = lambda p, i: self.request.route_url('tempmon.probes.view', uuid=p.uuid) + actions = [ + grids.GridAction('view', icon='zoomin', url=view_url), + ] + if self.request.has_perm('tempmon.probes.edit'): + url = lambda p, i: self.request.route_url('tempmon.probes.edit', uuid=p.uuid) + actions.append(grids.GridAction('edit', icon='pencil', url=url)) + + g = grids.Grid( + key='{}.probes'.format(route_prefix), + data=obj.probes, + columns=[ + 'description', + 'critical_temp_min', + 'good_temp_min', + 'good_temp_max', + 'critical_temp_max', + 'status', + 'enabled', + ], + labels={ + 'critical_temp_min': "Crit. Min", + 'good_temp_min': "Good Min", + 'good_temp_max': "Good Max", + 'critical_temp_max': "Crit. Max", + }, + url=lambda p: self.request.route_url('tempmon.probes.view', uuid=p.uuid), + linked_columns=['description'], + main_actions=actions, + ) + g.set_enum('status', self.enum.TEMPMON_PROBE_STATUS) + g.set_type('enabled', 'boolean') + return HTML.literal(g.render_grid()) diff --git a/tailbone/views/tempmon/probes.py b/tailbone/views/tempmon/probes.py index 25542b4c..7256a0a3 100644 --- a/tailbone/views/tempmon/probes.py +++ b/tailbone/views/tempmon/probes.py @@ -61,6 +61,7 @@ class TempmonProbeView(MasterView): grid_columns = [ 'client', 'config_key', + 'appliance', 'appliance_type', 'description', 'device_path', @@ -71,8 +72,10 @@ class TempmonProbeView(MasterView): form_fields = [ 'client', 'config_key', + 'appliance', 'appliance_type', 'description', + 'location', 'device_path', 'critical_temp_max', 'critical_max_timeout', @@ -133,6 +136,18 @@ class TempmonProbeView(MasterView): f.set_widget('client_uuid', dfwidget.SelectWidget(values=client_values)) f.set_label('client_uuid', "Tempmon Client") + # appliance + f.set_renderer('appliance', self.render_appliance) + if self.creating or self.editing: + f.replace('appliance', 'appliance_uuid') + appliances = self.Session.query(tempmon.Appliance)\ + .order_by(tempmon.Appliance.name) + appliance_values = [(appliance.uuid, appliance.name) + for appliance in appliances] + appliance_values.insert(0, ('', "(none)")) + f.set_widget('appliance_uuid', dfwidget.SelectWidget(values=appliance_values)) + f.set_label('appliance_uuid', "Appliance") + # appliance_type f.set_enum('appliance_type', self.enum.TEMPMON_APPLIANCE_TYPE) @@ -167,6 +182,14 @@ class TempmonProbeView(MasterView): url = self.request.route_url('tempmon.clients.view', uuid=client.uuid) return tags.link_to(text, url) + def render_appliance(self, probe, field): + appliance = probe.appliance + if not appliance: + return "" + text = six.text_type(appliance) + url = self.request.route_url('tempmon.appliances.view', uuid=appliance.uuid) + return tags.link_to(text, url) + def delete_instance(self, probe): # bulk-delete all readings first readings = self.Session.query(tempmon.Reading)\ From 4aa8f43a7eb8b79853fbde6f329d8f8e78c39eba Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 19 Oct 2018 19:20:20 -0500 Subject: [PATCH 0983/3196] Add basic image upload support for tempmon appliances --- setup.py | 1 + tailbone/views/master.py | 25 ++++++++++ tailbone/views/tempmon/appliances.py | 68 ++++++++++++++++++++++++++++ 3 files changed, 94 insertions(+) diff --git a/setup.py b/setup.py index 50d9f1f6..3dad1fed 100644 --- a/setup.py +++ b/setup.py @@ -78,6 +78,7 @@ requires = [ 'paginate', # 0.5.6 'paginate_sqlalchemy', # 0.2.0 'passlib', # 1.7.1 + 'Pillow', # 5.3.0 'pyramid', # 1.3b2 'pyramid_beaker>=0.6', # 0.6.1 'pyramid_deform', # 0.2 diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 5534a966..dcba102a 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -105,6 +105,7 @@ class MasterView(View): deleting = False executing = False has_pk_fields = False + has_image = False row_attrs = {} cell_attrs = {} @@ -854,6 +855,22 @@ class MasterView(View): tools=self.make_row_grid_tools(instance)) return self.render_to_response('view', context) + def image(self): + """ + View which renders the object's image as a response. + """ + obj = self.get_instance() + image_bytes = self.get_image_bytes(obj) + if not image_bytes: + raise self.notfound() + # TODO: how to properly detect image type? + self.request.response.content_type = str('image/jpeg') + self.request.response.body = image_bytes + return self.request.response + + def get_image_bytes(self, obj): + raise NotImplementedError + def clone(self): """ View for cloning an object's data into a new object. @@ -1433,7 +1450,9 @@ class MasterView(View): return self.render_to_response('edit', context) def save_edit_form(self, form): + uploads = self.normalize_uploads(form) obj = self.objectify(form, self.form_deserialized) + self.process_uploads(obj, form, uploads) self.after_edit(obj) self.Session.flush() @@ -2977,6 +2996,12 @@ class MasterView(View): config.add_view(cls, attr='view_version', route_name='{}.version'.format(route_prefix), permission='{}.versions'.format(permission_prefix)) + # image + if cls.has_image: + config.add_route('{}.image'.format(route_prefix), '{}/{{{}}}/image'.format(url_prefix, model_key)) + config.add_view(cls, attr='image', route_name='{}.image'.format(route_prefix), + permission='{}.view'.format(permission_prefix)) + # clone if cls.cloneable: config.add_tailbone_permission(permission_prefix, '{}.clone'.format(permission_prefix), diff --git a/tailbone/views/tempmon/appliances.py b/tailbone/views/tempmon/appliances.py index 1da74dd0..515b4777 100644 --- a/tailbone/views/tempmon/appliances.py +++ b/tailbone/views/tempmon/appliances.py @@ -26,8 +26,14 @@ Views for tempmon appliances from __future__ import unicode_literals, absolute_import +import os + +import six +from PIL import Image + from rattail_tempmon.db import model as tempmon +import colander from webhelpers2.html import HTML, tags from tailbone.views.tempmon import MasterView @@ -42,6 +48,7 @@ class TempmonApplianceView(MasterView): model_title_plural = "TempMon Appliances" route_prefix = 'tempmon.appliances' url_prefix = '/tempmon/appliances' + has_image = True grid_columns = [ 'name', @@ -53,15 +60,28 @@ class TempmonApplianceView(MasterView): 'location', 'clients', 'probes', + 'image', ] def configure_grid(self, g): super(TempmonApplianceView, self).configure_grid(g) g.set_sort_defaults('name') + g.set_link('name') + g.set_link('location') def configure_form(self, f): super(TempmonApplianceView, self).configure_form(f) + # name + f.set_validator('name', self.unique_name) + + # image + if self.creating or self.editing: + f.set_type('image', 'file') + f.set_required('image', False) + else: + f.set_renderer('image', self.render_image) + # clients if self.viewing: f.set_renderer('clients', self.render_clients) @@ -74,6 +94,23 @@ class TempmonApplianceView(MasterView): elif self.creating or self.editing: f.remove_field('probes') + def unique_name(self, node, value): + query = self.Session.query(tempmon.Appliance)\ + .filter(tempmon.Appliance.name == value) + if self.editing: + appliance = self.get_instance() + query = query.filter(tempmon.Appliance.uuid != appliance.uuid) + if query.count(): + raise colander.Invalid(node, "Name must be unique") + + def get_image_bytes(self, appliance): + return appliance.image_normal or appliance.image_raw + + def render_image(self, appliance, field): + route_prefix = self.get_route_prefix() + url = self.request.route_url('{}.image'.format(route_prefix), uuid=appliance.uuid) + return tags.image(url, "Appliance Image", id='appliance-image') #, width=500) #, height=500) + def render_clients(self, appliance, field): clients = {} for probe in appliance.probes: @@ -88,6 +125,37 @@ class TempmonApplianceView(MasterView): for client in clients] return HTML.tag('ul', c=items) + def process_uploads(self, appliance, form, uploads): + image = uploads.pop('image', None) + if image: + + # capture raw image as-is (note, this assumes jpeg) + with open(image['temp_path'], 'rb') as f: + appliance.image_raw = f.read() + + # resize image and store as separate attributes + with open(image['temp_path'], 'rb') as f: + im = Image.open(f) + + im.thumbnail((600, 600), Image.ANTIALIAS) + data = six.BytesIO() + im.save(data, 'JPEG') + appliance.image_normal = data.getvalue() + data.close() + + im.thumbnail((150, 150), Image.ANTIALIAS) + data = six.BytesIO() + im.save(data, 'JPEG') + appliance.image_thumbnail = data.getvalue() + data.close() + + # cleanup temp files + os.remove(image['temp_path']) + os.rmdir(image['tempdir']) + + if uploads: + raise NotImplementedError("too many uploads?") + def includeme(config): TempmonApplianceView.defaults(config) From 78941ec8d9e98b170dcad32d3c597e71e0df739d Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 19 Oct 2018 19:47:00 -0500 Subject: [PATCH 0984/3196] Add thumbnail images to Appliances grid guess we'll see how folks like this --- .../templates/tempmon/appliances/index.mako | 30 +++++++++++++++++++ tailbone/views/master.py | 23 ++++++++++++++ tailbone/views/tempmon/appliances.py | 14 +++++++++ 3 files changed, 67 insertions(+) create mode 100644 tailbone/templates/tempmon/appliances/index.mako diff --git a/tailbone/templates/tempmon/appliances/index.mako b/tailbone/templates/tempmon/appliances/index.mako new file mode 100644 index 00000000..68334aa8 --- /dev/null +++ b/tailbone/templates/tempmon/appliances/index.mako @@ -0,0 +1,30 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/index.mako" /> + +<%def name="extra_styles()"> + ${parent.extra_styles()} + + + +${parent.body()} diff --git a/tailbone/views/master.py b/tailbone/views/master.py index dcba102a..9308f7f7 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -106,6 +106,7 @@ class MasterView(View): executing = False has_pk_fields = False has_image = False + has_thumbnail = False row_attrs = {} cell_attrs = {} @@ -871,6 +872,22 @@ class MasterView(View): def get_image_bytes(self, obj): raise NotImplementedError + def thumbnail(self): + """ + View which renders the object's thumbnail image as a response. + """ + obj = self.get_instance() + image_bytes = self.get_thumbnail_bytes(obj) + if not image_bytes: + raise self.notfound() + # TODO: how to properly detect image type? + self.request.response.content_type = str('image/jpeg') + self.request.response.body = image_bytes + return self.request.response + + def get_thumbnail_bytes(self, obj): + raise NotImplementedError + def clone(self): """ View for cloning an object's data into a new object. @@ -3002,6 +3019,12 @@ class MasterView(View): config.add_view(cls, attr='image', route_name='{}.image'.format(route_prefix), permission='{}.view'.format(permission_prefix)) + # thumbnail + if cls.has_thumbnail: + config.add_route('{}.thumbnail'.format(route_prefix), '{}/{{{}}}/thumbnail'.format(url_prefix, model_key)) + config.add_view(cls, attr='thumbnail', route_name='{}.thumbnail'.format(route_prefix), + permission='{}.view'.format(permission_prefix)) + # clone if cls.cloneable: config.add_tailbone_permission(permission_prefix, '{}.clone'.format(permission_prefix), diff --git a/tailbone/views/tempmon/appliances.py b/tailbone/views/tempmon/appliances.py index 515b4777..cf0e8493 100644 --- a/tailbone/views/tempmon/appliances.py +++ b/tailbone/views/tempmon/appliances.py @@ -49,10 +49,12 @@ class TempmonApplianceView(MasterView): route_prefix = 'tempmon.appliances' url_prefix = '/tempmon/appliances' has_image = True + has_thumbnail = True grid_columns = [ 'name', 'location', + 'image', ] form_fields = [ @@ -69,6 +71,15 @@ class TempmonApplianceView(MasterView): g.set_link('name') g.set_link('location') + g.set_renderer('image', self.render_grid_thumbnail) + + def render_grid_thumbnail(self, appliance, field): + route_prefix = self.get_route_prefix() + url = self.request.route_url('{}.thumbnail'.format(route_prefix), uuid=appliance.uuid) + image = tags.image(url, "") + helper = HTML.tag('span', class_='image-helper') + return HTML.tag('div', class_='image-frame', c=[helper, image]) + def configure_form(self, f): super(TempmonApplianceView, self).configure_form(f) @@ -106,6 +117,9 @@ class TempmonApplianceView(MasterView): def get_image_bytes(self, appliance): return appliance.image_normal or appliance.image_raw + def get_thumbnail_bytes(self, appliance): + return appliance.image_thumbnail + def render_image(self, appliance, field): route_prefix = self.get_route_prefix() url = self.request.route_url('{}.image'.format(route_prefix), uuid=appliance.uuid) From e277a19f7120fa858c8a36bc9f82c15ffa13d1e9 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 19 Oct 2018 20:26:04 -0500 Subject: [PATCH 0985/3196] Hopefully, let the Grid class generate a default list of columns --- tailbone/views/master.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 9308f7f7..a01454f4 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -309,10 +309,12 @@ class MasterView(View): return grid.make_visible_data() def get_grid_columns(self): + """ + Returns the default list of grid column names. This may return + ``None``, in which case the grid will generate its own default list. + """ if hasattr(self, 'grid_columns'): return self.grid_columns - # TODO - raise NotImplementedError def make_grid_kwargs(self, **kwargs): """ From 7e28619e9d486d8a627c1c95137bc5cfdc80af87 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 19 Oct 2018 20:26:33 -0500 Subject: [PATCH 0986/3196] Don't include grid filters for LargeBinary columns --- tailbone/grids/core.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index d686b355..59149d55 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -395,8 +395,16 @@ class Grid(object): if self.model_class: mapper = orm.class_mapper(self.model_class) for prop in mapper.iterate_properties: - if isinstance(prop, orm.ColumnProperty) and not prop.key.endswith('uuid'): - filters[prop.key] = self.make_filter(prop.key, prop.columns[0]) + if not isinstance(prop, orm.ColumnProperty): + continue + if prop.key.endswith('uuid'): + continue + if len(prop.columns) != 1: + continue + column = prop.columns[0] + if isinstance(column.type, sa.LargeBinary): + continue + filters[prop.key] = self.make_filter(prop.key, column) return filters def make_filters(self, filters=None): From fe2905e9df6169176bb91889915c32145eca7b0e Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 19 Oct 2018 20:27:04 -0500 Subject: [PATCH 0987/3196] Add support for "appliance type" --- tailbone/views/tempmon/appliances.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tailbone/views/tempmon/appliances.py b/tailbone/views/tempmon/appliances.py index cf0e8493..eeb22882 100644 --- a/tailbone/views/tempmon/appliances.py +++ b/tailbone/views/tempmon/appliances.py @@ -53,12 +53,14 @@ class TempmonApplianceView(MasterView): grid_columns = [ 'name', + 'appliance_type', 'location', 'image', ] form_fields = [ 'name', + 'appliance_type', 'location', 'clients', 'probes', @@ -67,11 +69,22 @@ class TempmonApplianceView(MasterView): def configure_grid(self, g): super(TempmonApplianceView, self).configure_grid(g) + + # name g.set_sort_defaults('name') g.set_link('name') + + # appliance_type + g.set_enum('appliance_type', self.enum.TEMPMON_APPLIANCE_TYPE) + g.set_label('appliance_type', "Type") + g.filters['appliance_type'].label = "Appliance Type" + + # location g.set_link('location') + # image g.set_renderer('image', self.render_grid_thumbnail) + g.set_link('image') def render_grid_thumbnail(self, appliance, field): route_prefix = self.get_route_prefix() @@ -86,6 +99,9 @@ class TempmonApplianceView(MasterView): # name f.set_validator('name', self.unique_name) + # appliance_type + f.set_enum('appliance_type', self.enum.TEMPMON_APPLIANCE_TYPE) + # image if self.creating or self.editing: f.set_type('image', 'file') From ed9f8a269c3bd6f6448dc654bb6f1be6f77be719 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 19 Oct 2018 20:33:13 -0500 Subject: [PATCH 0988/3196] 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 277d59ec..79cfe793 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,24 @@ CHANGELOG ========= +0.7.43 (2018-10-19) +------------------- + +* Add new timeout fields for tempmon probe. + +* Customize template for viewing probe details. + +* Add support for new Tempmon Appliance table, etc. + +* Add basic image upload support for tempmon appliances. + +* Add thumbnail images to Appliances grid. + +* Hopefully, let the Grid class generate a default list of columns. + +* Don't include grid filters for LargeBinary columns. + + 0.7.42 (2018-10-18) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 450b48ad..64739720 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.7.42' +__version__ = '0.7.43' From f26f42427f6f36090bc923a41398c90b4526c626 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 19 Oct 2018 21:17:50 -0500 Subject: [PATCH 0989/3196] Don't include LargeBinary properties in default colander schema actually, exclude any found in secondary properties...i.e. from relationship --- tailbone/forms/core.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index 5e079085..04633bbb 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.py @@ -213,9 +213,17 @@ class CustomSchemaNode(SQLAlchemySchemaNode): excludes = [] if isinstance(prop, orm.RelationshipProperty): for next_prop in prop.mapper.iterate_properties: + + # don't include secondary relationships if isinstance(next_prop, orm.RelationshipProperty): excludes.append(next_prop.key) + # don't include fields of binary type + elif isinstance(next_prop, orm.ColumnProperty): + for column in next_prop.columns: + if isinstance(column.type, sa.LargeBinary): + excludes.append(next_prop.key) + if excludes: overrides['excludes'] = excludes From c6b2f831e5206a66da50826cb50ef1cb06d5417a Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 19 Oct 2018 21:18:53 -0500 Subject: [PATCH 0990/3196] 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 79cfe793..f8fa6ec9 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.7.44 (2018-10-19) +------------------- + +* Don't include LargeBinary properties in default colander schema. + + 0.7.43 (2018-10-19) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 64739720..a1ec6181 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.7.43' +__version__ = '0.7.44' From 0e13e5606ad88dd27fc5768943aad3b7dca6a113 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 19 Oct 2018 23:00:43 -0500 Subject: [PATCH 0991/3196] Add very basic support for viewing probe readings as graph can only view the last hour of readings, so far --- tailbone/templates/tempmon/probes/graph.mako | 66 ++++++++++++++++++++ tailbone/templates/tempmon/probes/view.mako | 5 ++ tailbone/views/tempmon/probes.py | 43 +++++++++++++ 3 files changed, 114 insertions(+) create mode 100644 tailbone/templates/tempmon/probes/graph.mako diff --git a/tailbone/templates/tempmon/probes/graph.mako b/tailbone/templates/tempmon/probes/graph.mako new file mode 100644 index 00000000..128fe5ce --- /dev/null +++ b/tailbone/templates/tempmon/probes/graph.mako @@ -0,0 +1,66 @@ +## -*- coding: utf-8; -*- +<%inherit file="/base.mako" /> + +<%def name="title()">Temperature Graph + +<%def name="extra_javascript()"> + ${parent.extra_javascript()} + + + + +
            + +
            + +
            + % if probe.appliance: + ${probe.appliance} + % endif +
            +
            + +
            + +
            ${probe.location}
            +
            + +
            + +
            + +
            +
            + +
            + + diff --git a/tailbone/templates/tempmon/probes/view.mako b/tailbone/templates/tempmon/probes/view.mako index 8acdbfbe..8bf3fd2e 100644 --- a/tailbone/templates/tempmon/probes/view.mako +++ b/tailbone/templates/tempmon/probes/view.mako @@ -41,6 +41,11 @@ ## rendering methods ############################## +<%def name="context_menu_items()"> + ${parent.context_menu_items()} +
          • ${h.link_to("View Readings as Graph", action_url('graph', instance))}
          • + + <%def name="render_main_fields(form)"> ${form.render_field_readonly('client')} ${form.render_field_readonly('config_key')} diff --git a/tailbone/views/tempmon/probes.py b/tailbone/views/tempmon/probes.py index 7256a0a3..dbb47d78 100644 --- a/tailbone/views/tempmon/probes.py +++ b/tailbone/views/tempmon/probes.py @@ -26,8 +26,11 @@ Views for tempmon probes from __future__ import unicode_literals, absolute_import +import datetime + import six +from rattail.time import make_utc, localtime from rattail_tempmon.db import model as tempmon import colander @@ -220,6 +223,46 @@ class TempmonProbeView(MasterView): g.set_sort_defaults('taken', 'desc') + def graph(self): + probe = self.get_instance() + + # figure out which readings we need to graph + # TODO: make this dynamic somehow + cutoff = make_utc() - datetime.timedelta(seconds=3600) # last hour + readings = self.Session.query(tempmon.Reading)\ + .filter(tempmon.Reading.probe == probe)\ + .filter(tempmon.Reading.taken >= cutoff)\ + .order_by(tempmon.Reading.taken)\ + .all() + + # convert readings to data for scatter plot + data = [{ + 'x': localtime(self.rattail_config, reading.taken, from_utc=True).isoformat(), + 'y': float(reading.degrees_f), + } for reading in readings] + + context = { + 'probe': probe, + 'parent_title': six.text_type(probe), + 'parent_url': self.get_action_url('view', probe), + 'readings_data': data, + } + return self.render_to_response('graph', context) + + @classmethod + def defaults(cls, config): + route_prefix = cls.get_route_prefix() + url_prefix = cls.get_url_prefix() + model_key = cls.get_model_key() + permission_prefix = cls.get_permission_prefix() + model_title_plural = cls.get_model_title_plural() + + config.add_route('{}.graph'.format(route_prefix), '{}/{{{}}}/graph'.format(url_prefix, model_key)) + config.add_view(cls, attr='graph', route_name='{}.graph'.format(route_prefix), + permission='{}.view'.format(permission_prefix)) + + cls._defaults(config) + def includeme(config): TempmonProbeView.defaults(config) From f1eba6a40498f0d9f94ff9389c827e06b39c3b57 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 19 Oct 2018 23:01:54 -0500 Subject: [PATCH 0992/3196] 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 f8fa6ec9..a3ead3c7 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.7.45 (2018-10-19) +------------------- + +* Add very basic support for viewing tempmon probe readings as graph. + + 0.7.44 (2018-10-19) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index a1ec6181..3262f307 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.7.44' +__version__ = '0.7.45' From b9da7e1b12d15529650169ec47b62c9e955873fd Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 23 Oct 2018 10:25:57 -0500 Subject: [PATCH 0993/3196] Allow individual App Settings to not be required; allow null hopefully this does the right thing also, not saving null to the db when that isn't needed etc. --- tailbone/views/settings.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tailbone/views/settings.py b/tailbone/views/settings.py index c82351fc..65a8066b 100644 --- a/tailbone/views/settings.py +++ b/tailbone/views/settings.py @@ -140,6 +140,10 @@ class AppSettingsView(View): 'name': setting.node_name, 'default': self.get_setting_value(setting), } + if kwargs['default'] is None: + kwargs['default'] = colander.null + if not setting.required: + kwargs['missing'] = colander.null if setting.choices: kwargs['validator'] = colander.OneOf(setting.choices) kwargs['widget'] = forms.widgets.JQuerySelectWidget( @@ -151,11 +155,15 @@ class AppSettingsView(View): def get_node_type(self, setting): if setting.data_type is bool: return colander.Bool() + elif setting.data_type is int: + return colander.Integer() return colander.String() def save_form(self, form): for setting in self.iter_known_settings(): value = form.validated[setting.node_name] + if value is colander.null: + value = None self.save_setting_value(setting, value) def iter_known_settings(self): From 2bd107056cc9f67f5a3ce722fe73cef3d38c20d5 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 23 Oct 2018 17:20:47 -0500 Subject: [PATCH 0994/3196] Add `MasterView.render_product()`, fix edit for pricing batch row --- tailbone/views/batch/core.py | 3 +++ tailbone/views/batch/pricing.py | 13 +++++++++++++ tailbone/views/custorders/items.py | 11 +++-------- tailbone/views/master.py | 8 ++++++++ tailbone/views/purchases/core.py | 10 +--------- tailbone/views/purchasing/batch.py | 12 ++---------- tailbone/views/vendors/catalogs.py | 8 -------- 7 files changed, 30 insertions(+), 35 deletions(-) diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index 402619b2..51ca6634 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -595,6 +595,9 @@ class BatchMasterView(MasterView): f.set_readonly('status_code') f.set_label('status_code', "Status") + # status text + f.set_readonly('status_text') + def configure_mobile_row_form(self, f): super(BatchMasterView, self).configure_mobile_row_form(f) diff --git a/tailbone/views/batch/pricing.py b/tailbone/views/batch/pricing.py index e871c3df..4ab42e70 100644 --- a/tailbone/views/batch/pricing.py +++ b/tailbone/views/batch/pricing.py @@ -138,6 +138,19 @@ class PricingBatchView(BatchMasterView): def configure_row_form(self, f): super(PricingBatchView, self).configure_row_form(f) + # readonly fields + f.set_readonly('product') + f.set_readonly('upc') + f.set_readonly('brand_name') + f.set_readonly('description') + f.set_readonly('size') + f.set_readonly('department_number') + f.set_readonly('department_name') + f.set_readonly('vendor') + + # product + f.set_renderer('product', self.render_product) + # currency fields f.set_type('old_price', 'currency') f.set_type('new_price', 'currency') diff --git a/tailbone/views/custorders/items.py b/tailbone/views/custorders/items.py index f4c540f0..da56e7de 100644 --- a/tailbone/views/custorders/items.py +++ b/tailbone/views/custorders/items.py @@ -28,11 +28,14 @@ from __future__ import unicode_literals, absolute_import import datetime +import six from sqlalchemy import orm from rattail.db import model from rattail.time import localtime +from webhelpers2.html import tags + from tailbone.views import MasterView from tailbone.util import raw_datetime @@ -166,14 +169,6 @@ class CustomerOrderItemsView(MasterView): url = self.request.route_url('custorders.view', uuid=order.uuid) return tags.link_to(text, url) - def render_product(self, order, field): - product = order.product - if not product: - return "" - text = six.text_type(product) - url = self.request.route_url('products.view', uuid=product.uuid) - return tags.link_to(text, url) - def get_row_data(self, item): return self.Session.query(model.CustomerOrderItemEvent)\ .filter(model.CustomerOrderItemEvent.item == item)\ diff --git a/tailbone/views/master.py b/tailbone/views/master.py index a01454f4..06470529 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -731,6 +731,14 @@ class MasterView(View): return obj.upc.pretty() if obj.upc else '' return getattr(obj, product_key) + def render_product(self, obj, field): + product = getattr(obj, field) + if not product: + return "" + text = six.text_type(product) + url = self.request.route_url('products.view', uuid=product.uuid) + return tags.link_to(text, url) + def before_create_flush(self, obj, form): pass diff --git a/tailbone/views/purchases/core.py b/tailbone/views/purchases/core.py index 2878a461..bc39fc3e 100644 --- a/tailbone/views/purchases/core.py +++ b/tailbone/views/purchases/core.py @@ -318,15 +318,7 @@ class PurchaseView(MasterView): f.set_renderer('department', self.render_row_department) # product - f.set_renderer('product', self.render_row_product) - - def render_row_product(self, row, field): - product = row.product - if not product: - return "" - text = six.text_type(product) - url = self.request.route_url('products.view', uuid=product.uuid) - return tags.link_to(text, url) + f.set_renderer('product', self.render_product) def render_row_department(self, row, field): return "{} {}".format(row.department_number, row.department_name) diff --git a/tailbone/views/purchasing/batch.py b/tailbone/views/purchasing/batch.py index ef105df7..e2b9e379 100644 --- a/tailbone/views/purchasing/batch.py +++ b/tailbone/views/purchasing/batch.py @@ -692,7 +692,7 @@ class PurchasingBatchView(BatchMasterView): f.set_readonly('upc') f.set_readonly('item_id') f.set_readonly('product') - f.set_renderer('product', self.render_row_product) + f.set_renderer('product', self.render_product) # TODO: what's up with this again? # f.remove_fields('po_total', @@ -704,18 +704,10 @@ class PurchasingBatchView(BatchMasterView): f.remove_fields('brand_name', 'description', 'size') - f.set_renderer('product', self.render_row_product) + f.set_renderer('product', self.render_product) else: f.remove_field('product') - def render_row_product(self, row, field): - product = row.product - if not product: - return "" - text = six.text_type(product) - url = self.request.route_url('products.view', uuid=product.uuid) - return tags.link_to(text, url) - def configure_mobile_row_form(self, f): super(PurchasingBatchView, self).configure_mobile_row_form(f) # row = f.model_instance diff --git a/tailbone/views/vendors/catalogs.py b/tailbone/views/vendors/catalogs.py index a3d0c43e..c8c5e30c 100644 --- a/tailbone/views/vendors/catalogs.py +++ b/tailbone/views/vendors/catalogs.py @@ -198,14 +198,6 @@ class VendorCatalogsView(FileBatchMasterView): super(VendorCatalogsView, self).configure_row_form(f) f.set_renderer('product', self.render_product) - def render_product(self, row, field): - product = row.product - if not product: - return "" - text = six.text_type(product) - url = self.request.route_url('products.view', uuid=product.uuid) - return tags.link_to(text, url) - def template_kwargs_create(self, **kwargs): parsers = self.get_parsers() for parser in parsers: From 05c33a4b347095b1244c5ef8ddfe46f5ce7bd8af Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 24 Oct 2018 18:52:49 -0500 Subject: [PATCH 0995/3196] Add ability to "transform" TD parent row from pack to unit item to make "claiming" more straightforward --- tailbone/static/css/diffs.css | 4 + .../receiving/transform_unit_row.mako | 16 ++++ tailbone/templates/receiving/view.mako | 68 +++++++++++++++++ tailbone/views/purchasing/receiving.py | 76 +++++++++++++++++++ 4 files changed, 164 insertions(+) create mode 100644 tailbone/templates/receiving/transform_unit_row.mako create mode 100644 tailbone/templates/receiving/view.mako diff --git a/tailbone/static/css/diffs.css b/tailbone/static/css/diffs.css index 9af7710c..662aae07 100644 --- a/tailbone/static/css/diffs.css +++ b/tailbone/static/css/diffs.css @@ -10,6 +10,10 @@ table.diff { min-width: 80%; } +.ui-dialog-content table.diff { + color: black; +} + table.diff th, table.diff td { border-bottom: 1px solid Black; diff --git a/tailbone/templates/receiving/transform_unit_row.mako b/tailbone/templates/receiving/transform_unit_row.mako new file mode 100644 index 00000000..d003228c --- /dev/null +++ b/tailbone/templates/receiving/transform_unit_row.mako @@ -0,0 +1,16 @@ +## -*- coding: utf-8; -*- + +

            + This row is associated with a "pack" item, but you may transform it, so it + associates with the "unit" item instead: +

            + +
            + +${diff.render_html()} + +
            +

            + Transforming to the unit item may help with "claiming" between Truck Dump + parent and child rows. +

            diff --git a/tailbone/templates/receiving/view.mako b/tailbone/templates/receiving/view.mako new file mode 100644 index 00000000..95750bda --- /dev/null +++ b/tailbone/templates/receiving/view.mako @@ -0,0 +1,68 @@ +## -*- coding: utf-8; -*- +<%inherit file="/batch/view.mako" /> + +<%def name="extra_javascript()"> + ${parent.extra_javascript()} + % if request.has_perm('{}.edit_row'.format(permission_prefix)): + + % endif + + +${parent.body()} + +% if request.has_perm('{}.edit_row'.format(permission_prefix)): + ${h.form(url('{}.transform_unit_row'.format(route_prefix), uuid=batch.uuid), name='transform-unit-form')} + ${h.csrf_token(request)} + ${h.hidden('row_uuid')} + ${h.end_form()} + + +% endif diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index 19aab266..b5332f2d 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -694,6 +694,75 @@ class ReceivingBatchView(PurchasingBatchView): g.hide_column('cases_ordered') g.hide_column('units_ordered') + # add "Transform to Unit" action, if appropriate + if batch.is_truck_dump_parent(): + permission_prefix = self.get_permission_prefix() + if self.request.has_perm('{}.edit_row'.format(permission_prefix)): + transform = grids.GridAction('transform', + icon='shuffle', + label="Transform to Unit", + url=self.transform_unit_url) + g.more_actions.append(transform) + if g.main_actions and g.main_actions[-1].key == 'delete': + delete = g.main_actions.pop() + g.more_actions.append(delete) + + def transform_unit_url(self, row, i): + # grid action is shown only when we return a URL here + if self.row_editable(row): + if row.batch.is_truck_dump_parent(): + if row.product and row.product.is_pack_item(): + return self.get_row_action_url('transform_unit', row) + + def transform_unit_row(self): + """ + View which transforms the given row, which is assumed to associate with + a "pack" item, such that it instead associates with the "unit" item, + with quantities adjusted accordingly. + """ + batch = self.get_instance() + + row_uuid = self.request.params.get('row_uuid') + row = self.Session.query(model.PurchaseBatchRow).get(row_uuid) if row_uuid else None + if row and row.batch is batch and not row.removed: + pass # we're good + else: + if self.request.method == 'POST': + raise self.notfound() + return {'error': "Row not found."} + + def normalize(product): + data = { + 'upc': product.upc, + 'item_id': product.item_id, + 'description': product.description, + 'size': product.size, + 'case_quantity': None, + 'cases_received': row.cases_received, + } + cost = product.cost_for_vendor(batch.vendor) + if cost: + data['case_quantity'] = cost.case_size + return data + + if self.request.method == 'POST': + self.handler.transform_pack_to_unit(row) + self.request.session.flash("Transformed pack to unit item for: {}".format(row.product)) + return self.redirect(self.get_action_url('view', batch)) + + pack_data = normalize(row.product) + pack_data['units_received'] = row.units_received + unit_data = normalize(row.product.unit) + unit_data['units_received'] = None + if row.units_received: + unit_data['units_received'] = row.units_received * row.product.pack_size + diff = self.make_diff(pack_data, unit_data, monospace=True) + return self.render_to_response('transform_unit_row', { + 'batch': batch, + 'row': row, + 'diff': diff, + }) + def configure_row_form(self, f): super(ReceivingBatchView, self).configure_row_form(f) batch = self.get_instance() @@ -1318,10 +1387,17 @@ class ReceivingBatchView(PurchasingBatchView): permission_prefix = cls.get_permission_prefix() if cls.allow_truck_dump: + + # add TD child batch, from invoice file config.add_route('{}.add_child_from_invoice'.format(route_prefix), '{}/{{{}}}/add-child-from-invoice'.format(url_prefix, model_key)) config.add_view(cls, attr='add_child_from_invoice', route_name='{}.add_child_from_invoice'.format(route_prefix), permission='{}.create'.format(permission_prefix)) + # transform TD parent row from "pack" to "unit" item + config.add_route('{}.transform_unit_row'.format(route_prefix), '{}/{{{}}}/transform-unit'.format(url_prefix, model_key)) + config.add_view(cls, attr='transform_unit_row', route_name='{}.transform_unit_row'.format(route_prefix), + permission='{}.edit_row'.format(permission_prefix), renderer='json') + @classmethod def defaults(cls, config): cls._receiving_defaults(config) From 3df14070732029d1fddda37c31ca647b020173f6 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 24 Oct 2018 19:22:27 -0500 Subject: [PATCH 0996/3196] Update changelog --- CHANGES.rst | 10 ++++++++++ tailbone/_version.py | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index a3ead3c7..a1a5e9ba 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,16 @@ CHANGELOG ========= +0.7.46 (2018-10-24) +------------------- + +* Allow individual App Settings to not be required; allow null. + +* Add ``MasterView.render_product()``; fix edit for pricing batch row. + +* Add ability to "transform" TD parent row from pack to unit item. + + 0.7.45 (2018-10-19) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 3262f307..968301b3 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.7.45' +__version__ = '0.7.46' From 92c1b165fb8e33cb9c79762f92db8e45725525e0 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 25 Oct 2018 14:33:28 -0500 Subject: [PATCH 0997/3196] Try to configure the 'pyramid_retry' package, if available this is used (as of pyramid 1.9) for gracefully handling postgres restarts --- tailbone/app.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/tailbone/app.py b/tailbone/app.py index 0d29c14b..d8dfa937 100644 --- a/tailbone/app.py +++ b/tailbone/app.py @@ -96,7 +96,12 @@ def provide_postgresql_settings(settings): this enables retrying transactions a second time, in an attempt to gracefully handle database restarts. """ - settings.setdefault('tm.attempts', 2) + try: + import pyramid_retry + except ImportError: + settings.setdefault('tm.attempts', 2) + else: + settings.setdefault('retry.attempts', 2) class Root(dict): @@ -135,6 +140,15 @@ def make_pyramid_config(settings, configure_csrf=True): config.include('pyramid_mako') config.include('pyramid_tm') + # bring in the pyramid_retry logic, if available + # TODO: pretty soon we can require this package, hopefully.. + try: + import pyramid_retry + except ImportError: + pass + else: + config.include('pyramid_retry') + # Add some permissions magic. config.add_directive('add_tailbone_permission_group', 'tailbone.auth.add_permission_group') config.add_directive('add_tailbone_permission', 'tailbone.auth.add_permission') From f086a2aa38fef87c65f7f6da4d756f85a3c8016e Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 25 Oct 2018 15:57:25 -0500 Subject: [PATCH 0998/3196] Add more time range options for viewing tempmon probe readings as graph --- tailbone/templates/tempmon/probes/graph.mako | 73 ++++++++++++++------ tailbone/views/tempmon/probes.py | 63 ++++++++++++++--- 2 files changed, 105 insertions(+), 31 deletions(-) diff --git a/tailbone/templates/tempmon/probes/graph.mako b/tailbone/templates/tempmon/probes/graph.mako index 128fe5ce..9654a040 100644 --- a/tailbone/templates/tempmon/probes/graph.mako +++ b/tailbone/templates/tempmon/probes/graph.mako @@ -8,29 +8,64 @@ @@ -55,9 +90,7 @@
            - + ${time_range}
            diff --git a/tailbone/views/tempmon/probes.py b/tailbone/views/tempmon/probes.py index dbb47d78..479d6c31 100644 --- a/tailbone/views/tempmon/probes.py +++ b/tailbone/views/tempmon/probes.py @@ -226,9 +226,49 @@ class TempmonProbeView(MasterView): def graph(self): probe = self.get_instance() + key = 'tempmon.probe.{}.graph_time_range'.format(probe.uuid) + selected = self.request.params.get('time-range') + if not selected: + selected = self.request.session.get(key, 'last hour') + self.request.session[key] = selected + + time_range = tags.select('time-range', selected, tags.Options([ + tags.Option("Last Hour", 'last hour'), + tags.Option("Last 6 Hours", 'last 6 hours'), + tags.Option("Last Day", 'last day'), + tags.Option("Last Week", 'last week'), + ])) + + context = { + 'probe': probe, + 'parent_title': six.text_type(probe), + 'parent_url': self.get_action_url('view', probe), + 'time_range': time_range, + } + return self.render_to_response('graph', context) + + def graph_readings(self): + probe = self.get_instance() + + key = 'tempmon.probe.{}.graph_time_range'.format(probe.uuid) + selected = self.request.params['time-range'] + assert selected + self.request.session[key] = selected + + # figure out what our window of time is + if selected == 'last hour': + cutoff = 60 * 60 # seconds x minutes + elif selected == 'last 6 hours': + cutoff = 60 * 60 * 6 # hour x 6 + elif selected == 'last day': + cutoff = 60 * 60 * 24 # hour x 24 + elif selected == 'last week': + cutoff = 60 * 60 * 24 * 7 # day x 7 + else: + raise NotImplementedError("Unknown time range: {}".format(selected)) + # figure out which readings we need to graph - # TODO: make this dynamic somehow - cutoff = make_utc() - datetime.timedelta(seconds=3600) # last hour + cutoff = make_utc() - datetime.timedelta(seconds=cutoff) readings = self.Session.query(tempmon.Reading)\ .filter(tempmon.Reading.probe == probe)\ .filter(tempmon.Reading.taken >= cutoff)\ @@ -240,14 +280,7 @@ class TempmonProbeView(MasterView): 'x': localtime(self.rattail_config, reading.taken, from_utc=True).isoformat(), 'y': float(reading.degrees_f), } for reading in readings] - - context = { - 'probe': probe, - 'parent_title': six.text_type(probe), - 'parent_url': self.get_action_url('view', probe), - 'readings_data': data, - } - return self.render_to_response('graph', context) + return data @classmethod def defaults(cls, config): @@ -257,10 +290,18 @@ class TempmonProbeView(MasterView): permission_prefix = cls.get_permission_prefix() model_title_plural = cls.get_model_title_plural() - config.add_route('{}.graph'.format(route_prefix), '{}/{{{}}}/graph'.format(url_prefix, model_key)) + # graph + config.add_route('{}.graph'.format(route_prefix), '{}/{{{}}}/graph'.format(url_prefix, model_key), + request_method='GET') config.add_view(cls, attr='graph', route_name='{}.graph'.format(route_prefix), permission='{}.view'.format(permission_prefix)) + # graph_readings + config.add_route('{}.graph_readings'.format(route_prefix), '{}/{{{}}}/graph-readings'.format(url_prefix, model_key), + request_method='GET') + config.add_view(cls, attr='graph_readings', route_name='{}.graph_readings'.format(route_prefix), + permission='{}.view'.format(permission_prefix), renderer='json') + cls._defaults(config) From fc8391c6d1f229d11039b944cad25018b71a4d12 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 25 Oct 2018 16:00:15 -0500 Subject: [PATCH 0999/3196] Use load mask even for first data fetch, for probe readings graph --- tailbone/templates/tempmon/probes/graph.mako | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/templates/tempmon/probes/graph.mako b/tailbone/templates/tempmon/probes/graph.mako index 9654a040..5da9717f 100644 --- a/tailbone/templates/tempmon/probes/graph.mako +++ b/tailbone/templates/tempmon/probes/graph.mako @@ -25,8 +25,8 @@ timeUnit = 'day'; } + $('.form-wrapper').mask("Fetching data"); if (chart) { - $('.form-wrapper').mask("Fetching data"); chart.destroy(); } From 2131ea65cb07833869fac66ae3f9e34e0f84d7c4 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 25 Oct 2018 17:47:43 -0500 Subject: [PATCH 1000/3196] Add button for restarting filemon although this button shows up only on the datasync page, for now.. --- tailbone/static/js/tailbone.js | 5 +- .../templates/datasync/changes/index.mako | 34 +++++----- tailbone/views/datasync.py | 6 +- tailbone/views/filemon.py | 67 +++++++++++++++++++ 4 files changed, 89 insertions(+), 23 deletions(-) create mode 100644 tailbone/views/filemon.py diff --git a/tailbone/static/js/tailbone.js b/tailbone/static/js/tailbone.js index 075b90ab..bedd4ee4 100644 --- a/tailbone/static/js/tailbone.js +++ b/tailbone/static/js/tailbone.js @@ -32,9 +32,12 @@ function disable_filter_options() { function disable_button(button, label) { $(button).button('disable'); if (label === undefined) { - label = "Working, please wait..."; + label = $(button).data('working-label') || "Working, please wait..."; } if (label) { + if (label.slice(-3) != '...') { + label += '...'; + } $(button).button('option', 'label', label); } } diff --git a/tailbone/templates/datasync/changes/index.mako b/tailbone/templates/datasync/changes/index.mako index 43fdfcc0..1a746144 100644 --- a/tailbone/templates/datasync/changes/index.mako +++ b/tailbone/templates/datasync/changes/index.mako @@ -1,27 +1,23 @@ ## -*- coding: utf-8; -*- <%inherit file="/master/index.mako" /> -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - - - <%def name="grid_tools()"> ${parent.grid_tools()} - ${h.form(url('datasync.restart'), name='restart-datasync')} - ${h.csrf_token(request)} - - ${h.end_form()} + + % if request.has_perm('datasync.restart'): + ${h.form(url('datasync.restart'), name='restart-datasync', class_='autodisable')} + ${h.csrf_token(request)} + ${h.submit('submit', "Restart DataSync", data_working_label="Restarting DataSync")} + ${h.end_form()} + % endif + + % if request.has_perm('filemon.restart'): + ${h.form(url('filemon.restart'), name='restart-filemon', class_='autodisable')} + ${h.csrf_token(request)} + ${h.submit('submit', "Restart FileMon", data_working_label="Restarting FileMon")} + ${h.end_form()} + % endif + ${parent.body()} diff --git a/tailbone/views/datasync.py b/tailbone/views/datasync.py index def5ee5f..11a98599 100644 --- a/tailbone/views/datasync.py +++ b/tailbone/views/datasync.py @@ -84,11 +84,11 @@ class DataSyncChangesView(MasterView): # fix permission group title config.add_tailbone_permission_group('datasync', label="DataSync") - # restart daemon + # restart datasync + config.add_tailbone_permission('datasync', 'datasync.restart', label="Restart DataSync Daemon") + # desktop config.add_route('datasync.restart', '/datasync/restart') config.add_view(cls, attr='restart', route_name='datasync.restart', permission='datasync.restart') - config.add_tailbone_permission('datasync', 'datasync.restart', label="Restart DataSync Daemon") - # mobile config.add_route('datasync.mobile', '/mobile/datasync/') config.add_view(cls, attr='mobile_index', route_name='datasync.mobile', diff --git a/tailbone/views/filemon.py b/tailbone/views/filemon.py new file mode 100644 index 00000000..1d164c83 --- /dev/null +++ b/tailbone/views/filemon.py @@ -0,0 +1,67 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2018 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see . +# +################################################################################ +""" +FileMon Views +""" + +from __future__ import unicode_literals, absolute_import + +import subprocess +import logging + +from tailbone.views import View + + +log = logging.getLogger(__name__) + + +class FilemonView(View): + """ + Misc. views for Filemon...(for now) + """ + + def restart(self): + cmd = self.rattail_config.getlist('tailbone', 'filemon.restart', default='/bin/sleep 3') # simulate by default + log.debug("attempting filemon restart with command: %s", cmd) + try: + subprocess.check_call(cmd) + except Except as error: + self.request.session.flash("FileMon daemon could not be restarted: {}".format(error), 'error') + else: + self.request.session.flash("FileMon daemon has been restarted.") + return self.redirect(self.request.get_referrer(default=self.request.route_url('datasyncchanges'))) + + @classmethod + def defaults(cls, config): + + # fix permission group title + config.add_tailbone_permission_group('filemon', label="FileMon") + + # restart filemon + config.add_tailbone_permission('filemon', 'filemon.restart', label="Restart FileMon Daemon") + config.add_route('filemon.restart', '/filemon/restart', request_method='POST') + config.add_view(cls, attr='restart', route_name='filemon.restart', permission='filemon.restart') + + +def includeme(config): + FilemonView.defaults(config) From 1123cbb728d1cb6d6226310d557ab67e85706262 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 25 Oct 2018 17:52:00 -0500 Subject: [PATCH 1001/3196] Only show Restart Filemon button if so configured otherwise everyone would need to include that view in their config --- tailbone/templates/datasync/changes/index.mako | 2 +- tailbone/views/datasync.py | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/tailbone/templates/datasync/changes/index.mako b/tailbone/templates/datasync/changes/index.mako index 1a746144..442bef22 100644 --- a/tailbone/templates/datasync/changes/index.mako +++ b/tailbone/templates/datasync/changes/index.mako @@ -11,7 +11,7 @@ ${h.end_form()} % endif - % if request.has_perm('filemon.restart'): + % if allow_filemon_restart and request.has_perm('filemon.restart'): ${h.form(url('filemon.restart'), name='restart-filemon', class_='autodisable')} ${h.csrf_token(request)} ${h.submit('submit', "Restart FileMon", data_working_label="Restarting FileMon")} diff --git a/tailbone/views/datasync.py b/tailbone/views/datasync.py index 11a98599..1d47fb20 100644 --- a/tailbone/views/datasync.py +++ b/tailbone/views/datasync.py @@ -63,6 +63,10 @@ class DataSyncChangesView(MasterView): g.set_sort_defaults('obtained') g.set_type('obtained', 'datetime') + def template_kwargs_index(self, **kwargs): + kwargs['allow_filemon_restart'] = bool(self.rattail_config.get('tailbone', 'filemon.restart')) + return kwargs + def restart(self): # TODO: Add better validation (e.g. CSRF) here? if self.request.method == 'POST': From 5222f44904d729205d406a708c8fd599de9babd9 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 25 Oct 2018 21:20:37 -0500 Subject: [PATCH 1002/3196] Update changelog --- CHANGES.rst | 10 ++++++++++ tailbone/_version.py | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index a1a5e9ba..ddf34c26 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,16 @@ CHANGELOG ========= +0.7.47 (2018-10-25) +------------------- + +* Try to configure the 'pyramid_retry' package, if available. + +* Add more time range options for viewing tempmon probe readings as graph. + +* Add button for restarting filemon. + + 0.7.46 (2018-10-24) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 968301b3..97fadac5 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.7.46' +__version__ = '0.7.47' From f43b6db427ce1289a8c163f65ab6c67ff0e1b69a Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 27 Oct 2018 15:17:48 -0500 Subject: [PATCH 1003/3196] Add initial `tailbone.api` subpackage, with basic auth API views lots more to do here! but hopefully this is a solid start --- tailbone/api/__init__.py | 33 ++++++++++++ tailbone/api/auth.py | 107 +++++++++++++++++++++++++++++++++++++++ tailbone/api/core.py | 65 ++++++++++++++++++++++++ 3 files changed, 205 insertions(+) create mode 100644 tailbone/api/__init__.py create mode 100644 tailbone/api/auth.py create mode 100644 tailbone/api/core.py diff --git a/tailbone/api/__init__.py b/tailbone/api/__init__.py new file mode 100644 index 00000000..6c310fa7 --- /dev/null +++ b/tailbone/api/__init__.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2018 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see . +# +################################################################################ +""" +Tailbone Web API +""" + +from __future__ import unicode_literals, absolute_import + +from .core import APIView, api + + +def includeme(config): + config.include('tailbone.api.auth') diff --git a/tailbone/api/auth.py b/tailbone/api/auth.py new file mode 100644 index 00000000..0664405a --- /dev/null +++ b/tailbone/api/auth.py @@ -0,0 +1,107 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2018 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see . +# +################################################################################ +""" +Tailbone Web API - Auth Views +""" + +from __future__ import unicode_literals, absolute_import + +from rattail.db.auth import authenticate_user + +from tailbone.api import APIView, api +from tailbone.db import Session +from tailbone.auth import login_user, logout_user + + +class AuthenticationView(APIView): + + def user_info(self, user): + return { + 'ok': True, + 'user': { + 'uuid': user.uuid, + 'username': user.username, + 'display_name': user.display_name, + }, + } + + @api + def check_session(self): + """ + View to serve as "no-op" / ping action to check current user's session. + This will establish a server-side web session for the user if none + exists. Note that this also resets the user's session timer. + """ + if self.request.user: + return self.user_info(self.request.user) + return {} + + @api + def login(self): + """ + API login view. + """ + if self.request.method == 'OPTIONS': + return self.request.response + + username = self.request.json.get('username') + password = self.request.json.get('password') + if not (username and password): + return {'error': "Invalid username or password"} + + user = authenticate_user(Session(), username, password) + if not user: + return {'error': "Invalid username or password"} + + login_user(self.request, user) + return self.user_info(user) + + @api + def logout(self): + """ + API logout view. + """ + if self.request.method == 'OPTIONS': + return self.request.response + + logout_user(self.request) + return {'ok': True} + + @classmethod + def defaults(cls, config): + + # session + config.add_route('api.session', '/api/session', request_method='GET') + config.add_view(cls, attr='check_session', route_name='api.session', renderer='json') + + # login + config.add_route('api.login', '/api/login', request_method=('OPTIONS', 'POST')) + config.add_view(cls, attr='login', route_name='api.login', renderer='json') + + # logout + config.add_route('api.logout', '/api/logout', request_method=('OPTIONS', 'POST')) + config.add_view(cls, attr='logout', route_name='api.logout', renderer='json') + + +def includeme(config): + AuthenticationView.defaults(config) diff --git a/tailbone/api/core.py b/tailbone/api/core.py new file mode 100644 index 00000000..c8855161 --- /dev/null +++ b/tailbone/api/core.py @@ -0,0 +1,65 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2018 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see . +# +################################################################################ +""" +Tailbone Web API - Core Views +""" + +from __future__ import unicode_literals, absolute_import + +from tailbone.views import View + + +def api(view_meth): + """ + Common decorator for all API views. Ideally this would not be needed..but + for now, alas, it is. + """ + def wrapped(view, *args, **kwargs): + + # TODO: why doesn't this work here...? (instead we have to repeat this + # code in lots of other places) + # if view.request.method == 'OPTIONS': + # return view.request.response + + # invoke the view logic first, since presumably it may involve a + # redirect in which case we don't really need to add the CSRF token. + # main known use case for this is the /logout endpoint - if that gets + # hit then the "current" (old) session will be destroyed, in which case + # we can't use the token from that, but instead must generate a new one. + result = view_meth(view, *args, **kwargs) + + # explicitly set CSRF token cookie, unless OPTIONS request + # TODO: why doesn't pyramid do this for us again? + if view.request.method != 'OPTIONS': + view.request.response.set_cookie(name='XSRF-TOKEN', + value=view.request.session.get_csrf_token()) + + return result + + return wrapped + + +class APIView(View): + """ + Base class for all API views. + """ From 0c41395cfcc5114186f8011ee0ff99b6e27a6f3c Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 29 Oct 2018 20:16:14 -0500 Subject: [PATCH 1004/3196] Add very basic API views to expose customer, user tables just so we can populate an "index grid table" in the UI, for now.. --- tailbone/api/__init__.py | 3 ++ tailbone/api/customers.py | 46 +++++++++++++++++ tailbone/api/master.py | 101 ++++++++++++++++++++++++++++++++++++++ tailbone/api/users.py | 48 ++++++++++++++++++ 4 files changed, 198 insertions(+) create mode 100644 tailbone/api/customers.py create mode 100644 tailbone/api/master.py create mode 100644 tailbone/api/users.py diff --git a/tailbone/api/__init__.py b/tailbone/api/__init__.py index 6c310fa7..d09c669a 100644 --- a/tailbone/api/__init__.py +++ b/tailbone/api/__init__.py @@ -27,7 +27,10 @@ Tailbone Web API from __future__ import unicode_literals, absolute_import from .core import APIView, api +from .master import APIMasterView def includeme(config): config.include('tailbone.api.auth') + config.include('tailbone.api.customers') + config.include('tailbone.api.users') diff --git a/tailbone/api/customers.py b/tailbone/api/customers.py new file mode 100644 index 00000000..75f1b438 --- /dev/null +++ b/tailbone/api/customers.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2018 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see . +# +################################################################################ +""" +Tailbone Web API - Customer Views +""" + +from __future__ import unicode_literals, absolute_import + +from rattail.db import model + +from tailbone.api import APIMasterView + + +class CustomerView(APIMasterView): + + model_class = model.Customer + + def normalize(self, customer): + return { + 'id': customer.id, + 'name': customer.name, + } + + +def includeme(config): + CustomerView.defaults(config) diff --git a/tailbone/api/master.py b/tailbone/api/master.py new file mode 100644 index 00000000..ff3261c9 --- /dev/null +++ b/tailbone/api/master.py @@ -0,0 +1,101 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2018 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see . +# +################################################################################ +""" +Tailbone Web API - Master View +""" + +from __future__ import unicode_literals, absolute_import + +from tailbone.api import APIView, api +from tailbone.db import Session + + +class APIMasterView(APIView): + + @classmethod + def get_model_class(cls): + if hasattr(cls, 'model_class'): + return cls.model_class + raise NotImplementedError("must set `model_class` for {}".format(cls.__name__)) + + @classmethod + def get_model_key(cls): + if hasattr(cls, 'model_key'): + return cls.model_name + return cls.get_model_class().__name__.lower() + + @classmethod + def get_model_key_plural(cls): + if hasattr(cls, 'model_key_plural'): + return cls.model_key_plural + return '{}s'.format(cls.get_model_key()) + + @classmethod + def get_route_prefix(cls): + if hasattr(cls, 'route_prefix'): + return cls.route_prefix + return 'api.{}'.format(cls.get_model_key_plural()) + + @classmethod + def get_permission_prefix(cls): + if hasattr(cls, 'permission_prefix'): + return cls.permission_prefix + return cls.get_model_key_plural() + + @classmethod + def get_url_prefix(cls): + if hasattr(cls, 'url_prefix'): + return cls.url_prefix + return '/api/{}'.format(cls.get_model_key_plural()) + + @property + def Session(self): + return Session + + @api + def index(self): + objects = self.Session.query(self.model_class) + + sort = self.request.params.get('sort') + if sort: + # TODO: this is fragile, but what to do if bad params? + sortkey, sortdir = sort.split('|') + sortkey = getattr(self.model_class, sortkey) + objects = objects.order_by(getattr(sortkey, sortdir)()) + + data = [self.normalize(obj) for obj in objects] + return data + + def normalize(self, obj): + raise NotImplementedError("must implement `normalize()` method for: {}".format(self.__class__.__name__)) + + @classmethod + def defaults(cls, config): + route_prefix = cls.get_route_prefix() + url_prefix = cls.get_url_prefix() + permission_prefix = cls.get_permission_prefix() + + # index + config.add_route(route_prefix, '{}/'.format(url_prefix), request_method='GET') + config.add_view(cls, attr='index', route_name=route_prefix, + renderer='json', permission='{}.list'.format(permission_prefix)) diff --git a/tailbone/api/users.py b/tailbone/api/users.py new file mode 100644 index 00000000..56cf17fd --- /dev/null +++ b/tailbone/api/users.py @@ -0,0 +1,48 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2018 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see . +# +################################################################################ +""" +Tailbone Web API - User Views +""" + +from __future__ import unicode_literals, absolute_import + +import six + +from rattail.db import model + +from tailbone.api import APIMasterView + + +class UserView(APIMasterView): + + model_class = model.User + + def normalize(self, user): + return { + 'username': user.username, + 'person': six.text_type(user.person or ''), + } + + +def includeme(config): + UserView.defaults(config) From b8fdce378f116d1ed0d89b0e66970d073f632ce7 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 1 Nov 2018 00:34:28 -0500 Subject: [PATCH 1005/3196] Add basic API endpoint for upgrades, partial pagination support latter is still broken, but this much is a starting point i think --- tailbone/api/__init__.py | 1 + tailbone/api/master.py | 11 +++++++++ tailbone/api/upgrades.py | 51 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 63 insertions(+) create mode 100644 tailbone/api/upgrades.py diff --git a/tailbone/api/__init__.py b/tailbone/api/__init__.py index d09c669a..572ff44d 100644 --- a/tailbone/api/__init__.py +++ b/tailbone/api/__init__.py @@ -33,4 +33,5 @@ from .master import APIMasterView def includeme(config): config.include('tailbone.api.auth') config.include('tailbone.api.customers') + config.include('tailbone.api.upgrades') config.include('tailbone.api.users') diff --git a/tailbone/api/master.py b/tailbone/api/master.py index ff3261c9..c4ffb2b6 100644 --- a/tailbone/api/master.py +++ b/tailbone/api/master.py @@ -26,6 +26,8 @@ Tailbone Web API - Master View from __future__ import unicode_literals, absolute_import +from paginate_sqlalchemy import SqlalchemyOrmPage + from tailbone.api import APIView, api from tailbone.db import Session @@ -83,6 +85,15 @@ class APIMasterView(APIView): sortkey = getattr(self.model_class, sortkey) objects = objects.order_by(getattr(sortkey, sortdir)()) + # NOTE: we only page results if sorting is in effect, otherwise + # record sequence is "non-determinant" (is that the word?) + page = self.request.params.get('page') + per_page = self.request.params.get('per_page') + if page.isdigit() and per_page.isdigit(): + page = int(page) + per_page = int(per_page) + objects = SqlalchemyOrmPage(objects, items_per_page=per_page, page=page) + data = [self.normalize(obj) for obj in objects] return data diff --git a/tailbone/api/upgrades.py b/tailbone/api/upgrades.py new file mode 100644 index 00000000..34415002 --- /dev/null +++ b/tailbone/api/upgrades.py @@ -0,0 +1,51 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2018 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see . +# +################################################################################ +""" +Tailbone Web API - Upgrade Views +""" + +from __future__ import unicode_literals, absolute_import + +from rattail.db import model + +from tailbone.api import APIMasterView + + +class UpgradeView(APIMasterView): + + model_class = model.Upgrade + + def normalize(self, upgrade): + return { + 'created': upgrade.created.isoformat(), + 'description': upgrade.description, + 'enabled': upgrade.enabled, + # 'status_code': upgrade.status_code, + 'status_code': self.enum.UPGRADE_STATUS[upgrade.status_code], + 'executed': upgrade.executed.isoformat() if upgrade.executed else None, + # 'executed_by': + } + + +def includeme(config): + UpgradeView.defaults(config) From e4a518c4441c7c9dae485215631aec94a7affe2b Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 2 Nov 2018 18:59:46 -0500 Subject: [PATCH 1006/3196] Remove some unwanted row grid labels doing it that way makes customization harder..still need to revisit how best to do that i guess --- tailbone/views/batch/pricing.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tailbone/views/batch/pricing.py b/tailbone/views/batch/pricing.py index 4ab42e70..63221339 100644 --- a/tailbone/views/batch/pricing.py +++ b/tailbone/views/batch/pricing.py @@ -121,10 +121,8 @@ class PricingBatchView(BatchMasterView): g.set_type('new_price', 'currency') g.set_type('price_diff', 'currency') - g.set_label('upc', "UPC") g.set_label('brand_name', "Brand") g.set_label('regular_unit_cost', "Reg. Cost") - g.set_label('price_margin', "Margin") g.set_label('price_markup', "Markup") g.set_label('price_diff', "Diff") g.set_label('manually_priced', "Manual") From 22ef6aad7b8f6c635f1758c074e7a06bc4888a9d Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 2 Nov 2018 21:38:06 -0500 Subject: [PATCH 1007/3196] Fix bug in upgrades API view, when upgrade has no status code --- tailbone/api/upgrades.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tailbone/api/upgrades.py b/tailbone/api/upgrades.py index 34415002..28bea59c 100644 --- a/tailbone/api/upgrades.py +++ b/tailbone/api/upgrades.py @@ -26,6 +26,8 @@ Tailbone Web API - Upgrade Views from __future__ import unicode_literals, absolute_import +import six + from rattail.db import model from tailbone.api import APIMasterView @@ -40,8 +42,8 @@ class UpgradeView(APIMasterView): 'created': upgrade.created.isoformat(), 'description': upgrade.description, 'enabled': upgrade.enabled, - # 'status_code': upgrade.status_code, - 'status_code': self.enum.UPGRADE_STATUS[upgrade.status_code], + 'status_code': self.enum.UPGRADE_STATUS.get(upgrade.status_code, + six.text_type(upgrade.status_code)), 'executed': upgrade.executed.isoformat() if upgrade.executed else None, # 'executed_by': } From 31ae5eacd57669126627232a81a0de6bf0d2e662 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 2 Nov 2018 21:43:56 -0500 Subject: [PATCH 1008/3196] Tweak status code rendering for upgrades API view --- tailbone/api/upgrades.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tailbone/api/upgrades.py b/tailbone/api/upgrades.py index 28bea59c..87eda224 100644 --- a/tailbone/api/upgrades.py +++ b/tailbone/api/upgrades.py @@ -38,15 +38,19 @@ class UpgradeView(APIMasterView): model_class = model.Upgrade def normalize(self, upgrade): - return { + data = { 'created': upgrade.created.isoformat(), 'description': upgrade.description, 'enabled': upgrade.enabled, - 'status_code': self.enum.UPGRADE_STATUS.get(upgrade.status_code, - six.text_type(upgrade.status_code)), 'executed': upgrade.executed.isoformat() if upgrade.executed else None, # 'executed_by': } + if upgrade.status_code is None: + data['status_code'] = None + else: + data['status_code'] = self.enum.UPGRADE_STATUS.get(upgrade.status_code, + six.text_type(upgrade.status_code)) + return data def includeme(config): From ad35481234cd71f2b988e05f92bc12e336afa6a0 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 3 Nov 2018 17:13:08 -0500 Subject: [PATCH 1009/3196] Use Cornice for REST API viws still very experimental at this point --- tailbone/api/customers.py | 5 ++- tailbone/api/master.py | 80 +++++++++++++++++---------------------- tailbone/api/upgrades.py | 11 ++++-- tailbone/api/users.py | 5 ++- tailbone/views/core.py | 2 +- 5 files changed, 51 insertions(+), 52 deletions(-) diff --git a/tailbone/api/customers.py b/tailbone/api/customers.py index 75f1b438..625154c8 100644 --- a/tailbone/api/customers.py +++ b/tailbone/api/customers.py @@ -28,9 +28,12 @@ from __future__ import unicode_literals, absolute_import from rattail.db import model +from cornice.resource import resource + from tailbone.api import APIMasterView +@resource(collection_path='/api/customers', path='/api/customer/{uuid}') class CustomerView(APIMasterView): model_class = model.Customer @@ -43,4 +46,4 @@ class CustomerView(APIMasterView): def includeme(config): - CustomerView.defaults(config) + config.scan(__name__) diff --git a/tailbone/api/master.py b/tailbone/api/master.py index c4ffb2b6..68bdb016 100644 --- a/tailbone/api/master.py +++ b/tailbone/api/master.py @@ -33,6 +33,15 @@ from tailbone.db import Session class APIMasterView(APIView): + """ + Base class for data model REST API views. + """ + allow_get = True + allow_collection_get = True + + @property + def Session(self): + return Session @classmethod def get_model_class(cls): @@ -41,48 +50,34 @@ class APIMasterView(APIView): raise NotImplementedError("must set `model_class` for {}".format(cls.__name__)) @classmethod - def get_model_key(cls): - if hasattr(cls, 'model_key'): - return cls.model_name + def get_normalized_model_name(cls): + if hasattr(cls, 'normalized_model_name'): + return cls.normalized_model_name return cls.get_model_class().__name__.lower() @classmethod - def get_model_key_plural(cls): - if hasattr(cls, 'model_key_plural'): - return cls.model_key_plural - return '{}s'.format(cls.get_model_key()) + def get_object_key(cls): + if hasattr(cls, 'object_key'): + return cls.object_key + return cls.get_normalized_model_name() + # raise NotImplementedError("must set `object_key` for {}".format(cls.__name__)) @classmethod - def get_route_prefix(cls): - if hasattr(cls, 'route_prefix'): - return cls.route_prefix - return 'api.{}'.format(cls.get_model_key_plural()) + def get_collection_key(cls): + if hasattr(cls, 'collection_key'): + return cls.collection_key + return '{}s'.format(cls.get_object_key()) + # raise NotImplementedError("must set `collection_key` for {}".format(cls.__name__)) - @classmethod - def get_permission_prefix(cls): - if hasattr(cls, 'permission_prefix'): - return cls.permission_prefix - return cls.get_model_key_plural() - - @classmethod - def get_url_prefix(cls): - if hasattr(cls, 'url_prefix'): - return cls.url_prefix - return '/api/{}'.format(cls.get_model_key_plural()) - - @property - def Session(self): - return Session - - @api - def index(self): - objects = self.Session.query(self.model_class) + def collection_get(self): + cls = self.get_model_class() + objects = self.Session.query(cls) sort = self.request.params.get('sort') if sort: # TODO: this is fragile, but what to do if bad params? sortkey, sortdir = sort.split('|') - sortkey = getattr(self.model_class, sortkey) + sortkey = getattr(cls, sortkey) objects = objects.order_by(getattr(sortkey, sortdir)()) # NOTE: we only page results if sorting is in effect, otherwise @@ -94,19 +89,12 @@ class APIMasterView(APIView): per_page = int(per_page) objects = SqlalchemyOrmPage(objects, items_per_page=per_page, page=page) - data = [self.normalize(obj) for obj in objects] - return data + objects = [self.normalize(obj) for obj in objects] + return {self.get_collection_key(): objects} - def normalize(self, obj): - raise NotImplementedError("must implement `normalize()` method for: {}".format(self.__class__.__name__)) - - @classmethod - def defaults(cls, config): - route_prefix = cls.get_route_prefix() - url_prefix = cls.get_url_prefix() - permission_prefix = cls.get_permission_prefix() - - # index - config.add_route(route_prefix, '{}/'.format(url_prefix), request_method='GET') - config.add_view(cls, attr='index', route_name=route_prefix, - renderer='json', permission='{}.list'.format(permission_prefix)) + def get(self): + uuid = self.request.matchdict['uuid'] + obj = self.Session.query(self.get_model_class()).get(uuid) + if not obj: + raise self.notfound() + return {self.get_object_key(): self.normalize(obj)} diff --git a/tailbone/api/upgrades.py b/tailbone/api/upgrades.py index 87eda224..e2a12a0b 100644 --- a/tailbone/api/upgrades.py +++ b/tailbone/api/upgrades.py @@ -30,11 +30,16 @@ import six from rattail.db import model +from cornice.resource import resource + from tailbone.api import APIMasterView -class UpgradeView(APIMasterView): - +@resource(collection_path='/api/upgrades', path='/api/upgrades/{uuid}') +class UpgradeAPIView(APIMasterView): + """ + REST API views for Upgrade model. + """ model_class = model.Upgrade def normalize(self, upgrade): @@ -54,4 +59,4 @@ class UpgradeView(APIMasterView): def includeme(config): - UpgradeView.defaults(config) + config.scan(__name__) diff --git a/tailbone/api/users.py b/tailbone/api/users.py index 56cf17fd..5b6786dc 100644 --- a/tailbone/api/users.py +++ b/tailbone/api/users.py @@ -30,9 +30,12 @@ import six from rattail.db import model +from cornice.resource import resource + from tailbone.api import APIMasterView +@resource(collection_path='/api/users', path='/api/users/{uuid}') class UserView(APIMasterView): model_class = model.User @@ -45,4 +48,4 @@ class UserView(APIMasterView): def includeme(config): - UserView.defaults(config) + config.scan(__name__) diff --git a/tailbone/views/core.py b/tailbone/views/core.py index daa6fc59..a3ec7e2c 100644 --- a/tailbone/views/core.py +++ b/tailbone/views/core.py @@ -46,7 +46,7 @@ class View(object): Base class for all class-based views. """ - def __init__(self, request): + def __init__(self, request, context=None): self.request = request # if user becomes inactive while logged in, log them out From 9b61b05155c8682d5cf4e116ab2884a3a2c5ba96 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 3 Nov 2018 17:15:23 -0500 Subject: [PATCH 1010/3196] Add dependency for cornice --- setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.py b/setup.py index 3dad1fed..1ded0190 100644 --- a/setup.py +++ b/setup.py @@ -71,6 +71,7 @@ requires = [ 'webhelpers2_grid==0.1', # 0.1 'ColanderAlchemy', # 0.3.3 + 'cornice', # 3.4.2 'deform', # 2.0.4 'humanize', # 0.5.1 'Mako', # 0.6.2 From fec8ba28e2ac1e207c863d6ef73dc43688f7aa7e Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 3 Nov 2018 18:55:26 -0500 Subject: [PATCH 1011/3196] Refactor API views a bit for sake of running as separate service also add "proper" (sic) permission checks --- tailbone/api/auth.py | 6 +++--- tailbone/api/customers.py | 12 ++++++++++-- tailbone/api/master.py | 8 ++------ tailbone/api/upgrades.py | 12 ++++++++++-- tailbone/api/users.py | 12 ++++++++++-- 5 files changed, 35 insertions(+), 15 deletions(-) diff --git a/tailbone/api/auth.py b/tailbone/api/auth.py index 0664405a..59ac2ee8 100644 --- a/tailbone/api/auth.py +++ b/tailbone/api/auth.py @@ -91,15 +91,15 @@ class AuthenticationView(APIView): def defaults(cls, config): # session - config.add_route('api.session', '/api/session', request_method='GET') + config.add_route('api.session', '/session', request_method='GET') config.add_view(cls, attr='check_session', route_name='api.session', renderer='json') # login - config.add_route('api.login', '/api/login', request_method=('OPTIONS', 'POST')) + config.add_route('api.login', '/login', request_method=('OPTIONS', 'POST')) config.add_view(cls, attr='login', route_name='api.login', renderer='json') # logout - config.add_route('api.logout', '/api/logout', request_method=('OPTIONS', 'POST')) + config.add_route('api.logout', '/logout', request_method=('OPTIONS', 'POST')) config.add_view(cls, attr='logout', route_name='api.logout', renderer='json') diff --git a/tailbone/api/customers.py b/tailbone/api/customers.py index 625154c8..da15c2e5 100644 --- a/tailbone/api/customers.py +++ b/tailbone/api/customers.py @@ -28,12 +28,12 @@ from __future__ import unicode_literals, absolute_import from rattail.db import model -from cornice.resource import resource +from cornice.resource import resource, view from tailbone.api import APIMasterView -@resource(collection_path='/api/customers', path='/api/customer/{uuid}') +@resource(collection_path='/customers', path='/customer/{uuid}') class CustomerView(APIMasterView): model_class = model.Customer @@ -44,6 +44,14 @@ class CustomerView(APIMasterView): 'name': customer.name, } + @view(permission='customers.list') + def collection_get(self): + return self._collection_get() + + @view(permission='customers.view') + def get(self): + return self._get() + def includeme(config): config.scan(__name__) diff --git a/tailbone/api/master.py b/tailbone/api/master.py index 68bdb016..1d4bafa3 100644 --- a/tailbone/api/master.py +++ b/tailbone/api/master.py @@ -36,8 +36,6 @@ class APIMasterView(APIView): """ Base class for data model REST API views. """ - allow_get = True - allow_collection_get = True @property def Session(self): @@ -60,16 +58,14 @@ class APIMasterView(APIView): if hasattr(cls, 'object_key'): return cls.object_key return cls.get_normalized_model_name() - # raise NotImplementedError("must set `object_key` for {}".format(cls.__name__)) @classmethod def get_collection_key(cls): if hasattr(cls, 'collection_key'): return cls.collection_key return '{}s'.format(cls.get_object_key()) - # raise NotImplementedError("must set `collection_key` for {}".format(cls.__name__)) - def collection_get(self): + def _collection_get(self): cls = self.get_model_class() objects = self.Session.query(cls) @@ -92,7 +88,7 @@ class APIMasterView(APIView): objects = [self.normalize(obj) for obj in objects] return {self.get_collection_key(): objects} - def get(self): + def _get(self): uuid = self.request.matchdict['uuid'] obj = self.Session.query(self.get_model_class()).get(uuid) if not obj: diff --git a/tailbone/api/upgrades.py b/tailbone/api/upgrades.py index e2a12a0b..620ed4f8 100644 --- a/tailbone/api/upgrades.py +++ b/tailbone/api/upgrades.py @@ -30,12 +30,12 @@ import six from rattail.db import model -from cornice.resource import resource +from cornice.resource import resource, view from tailbone.api import APIMasterView -@resource(collection_path='/api/upgrades', path='/api/upgrades/{uuid}') +@resource(collection_path='/upgrades', path='/upgrades/{uuid}') class UpgradeAPIView(APIMasterView): """ REST API views for Upgrade model. @@ -57,6 +57,14 @@ class UpgradeAPIView(APIMasterView): six.text_type(upgrade.status_code)) return data + @view(permission='upgrades.list') + def collection_get(self): + return self._collection_get() + + @view(permission='upgrades.view') + def get(self): + return self._get() + def includeme(config): config.scan(__name__) diff --git a/tailbone/api/users.py b/tailbone/api/users.py index 5b6786dc..f237c885 100644 --- a/tailbone/api/users.py +++ b/tailbone/api/users.py @@ -30,12 +30,12 @@ import six from rattail.db import model -from cornice.resource import resource +from cornice.resource import resource, view from tailbone.api import APIMasterView -@resource(collection_path='/api/users', path='/api/users/{uuid}') +@resource(collection_path='/users', path='/users/{uuid}') class UserView(APIMasterView): model_class = model.User @@ -46,6 +46,14 @@ class UserView(APIMasterView): 'person': six.text_type(user.person or ''), } + @view(permission='users.list') + def collection_get(self): + return self._collection_get() + + @view(permission='users.view') + def get(self): + return self._get() + def includeme(config): config.scan(__name__) From d1980aeed8d97db44e46fd3099d969bd18e5e0bf Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 5 Nov 2018 11:24:03 -0600 Subject: [PATCH 1012/3196] Add client IP address to user feedback email --- tailbone/views/common.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tailbone/views/common.py b/tailbone/views/common.py index f48bdbe4..22401a04 100644 --- a/tailbone/views/common.py +++ b/tailbone/views/common.py @@ -134,6 +134,7 @@ class CommonView(View): data = dict(form.validated) if data['user']: data['user_url'] = self.request.route_url('users.view', uuid=data['user'].uuid) + data['client_ip'] = self.request.client_addr send_email(self.rattail_config, 'user_feedback', data=data) return {'ok': True} return {'error': "Form did not validate!"} From 23a94ebfad0fa69762130dd2a4f24c62014114f1 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 7 Nov 2018 10:11:11 -0600 Subject: [PATCH 1013/3196] Update changelog --- CHANGES.rst | 10 ++++++++++ tailbone/_version.py | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index ddf34c26..22b244e2 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,16 @@ CHANGELOG ========= +0.7.48 (2018-11-07) +------------------- + +* Add initial ``tailbone.api`` subpackage, with some basic API views. Note + that this API is meant to be ran as a separate app so we can better leverage + Cornice features. + +* Add client IP address to user feedback email. + + 0.7.47 (2018-10-25) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 97fadac5..11c9d274 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.7.47' +__version__ = '0.7.48' From 9daefed9b35c966e19cc168030671afe62eac591 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 7 Nov 2018 13:08:59 -0600 Subject: [PATCH 1014/3196] Detect non-numeric entry when locating row for purchase batch i.e. don't try to convert to GPC if non-numeric --- tailbone/views/purchasing/receiving.py | 31 +++++++++++++++----------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index b5332f2d..8c935587 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -1077,20 +1077,22 @@ class ReceivingBatchView(PurchasingBatchView): key = self.rattail_config.product_key() if key == 'upc': - # we prefer "exact" UPC matches, i.e. those which assumed the entry - # already contained the check digit. - provided = GPC(entry, calc_check_digit=False) - rows = [row for row in batch.active_rows() - if row.upc == provided] - if rows: - return rows + if entry.isdigit(): - # if no "exact" UPC matches, we'll settle for those (UPC matches) - # which assume the entry lacked a check digit. - checked = GPC(entry, calc_check_digit='upc') - rows = [row for row in batch.active_rows() - if row.upc == checked] - return rows + # we prefer "exact" UPC matches, i.e. those which assumed the entry + # already contained the check digit. + provided = GPC(entry, calc_check_digit=False) + rows = [row for row in batch.active_rows() + if row.upc == provided] + if rows: + return rows + + # if no "exact" UPC matches, we'll settle for those (UPC matches) + # which assume the entry lacked a check digit. + checked = GPC(entry, calc_check_digit='upc') + rows = [row for row in batch.active_rows() + if row.upc == checked] + return rows elif key == 'item_id': rows = [row for row in batch.active_rows() @@ -1162,6 +1164,9 @@ class ReceivingBatchView(PurchasingBatchView): if len(entry) > 14: return + if not entry.isdigit(): + return + provided = GPC(entry, calc_check_digit=False) checked = GPC(entry, calc_check_digit='upc') From 21014c5013fcb6c0fc32b6e3d3e759f9a2a5a83d Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 7 Nov 2018 16:46:55 -0600 Subject: [PATCH 1015/3196] Remove unwanted style for "email setting description" field not sure why that was in there, but it broke some other pages sure enough. will have to revisit whenever i see the "problem" on email settings page again --- tailbone/static/css/forms.css | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/tailbone/static/css/forms.css b/tailbone/static/css/forms.css index dc85c46b..92983d9e 100644 --- a/tailbone/static/css/forms.css +++ b/tailbone/static/css/forms.css @@ -132,14 +132,3 @@ div.buttons { clear: both; margin: 10px 0px; } - - -/****************************** - * Email Profile forms - ******************************/ - -.field-wrapper.description .field { - /* NOTE: This was added specifically for email settings (description), who - knows what else it breaks...hopefully nothing. */ - width: 800px; -} From 37b9a81344b2f391be9f6ab10b4bf6e33f642011 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 7 Nov 2018 16:47:41 -0600 Subject: [PATCH 1016/3196] Add `Grid.hide_columns()` convenience method --- tailbone/grids/core.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index 59149d55..31c36907 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -130,6 +130,10 @@ class Grid(object): if key in self.columns: self.columns.remove(key) + def hide_columns(self, *keys): + for key in keys: + self.hide_column(key) + def append(self, field): self.columns.append(field) From bdbb8e2a7d4de16052ef303068b6a2a3017d1701 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 7 Nov 2018 16:47:51 -0600 Subject: [PATCH 1017/3196] Make sure status field is readonly when creating new batch --- tailbone/views/batch/core.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index 51ca6634..65221233 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -274,9 +274,12 @@ class BatchMasterView(MasterView): f.set_label('rowcount', "Row Count") # status_code - f.set_readonly('status_code') - f.set_renderer('status_code', self.make_status_renderer(self.model_class.STATUS)) - f.set_label('status_code', "Status") + if self.creating: + f.remove_field('status_code') + else: + f.set_readonly('status_code') + f.set_renderer('status_code', self.make_status_renderer(self.model_class.STATUS)) + f.set_label('status_code', "Status") # complete if self.viewing: From 20e654ddeabc1eb15d246bc8449a4198b16748f9 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 8 Nov 2018 09:57:27 -0600 Subject: [PATCH 1018/3196] Display "suggested price" when viewing product details very basic support here... --- tailbone/templates/products/view.mako | 1 + tailbone/views/products.py | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/tailbone/templates/products/view.mako b/tailbone/templates/products/view.mako index e6c12355..d734bb41 100644 --- a/tailbone/templates/products/view.mako +++ b/tailbone/templates/products/view.mako @@ -124,6 +124,7 @@ ${form.render_field_readonly('regular_price')} ${form.render_field_readonly('current_price')} ${form.render_field_readonly('current_price_ends')} + ${form.render_field_readonly('suggested_price')} ${form.render_field_readonly('deposit_link')} ${form.render_field_readonly('tax')} diff --git a/tailbone/views/products.py b/tailbone/views/products.py index e99ca2fb..c5ea6de7 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -115,6 +115,7 @@ class ProductsView(MasterView): 'category', 'family', 'report_code', + 'suggested_price', 'regular_price', 'current_price', 'current_price_ends', @@ -348,6 +349,13 @@ class ProductsView(MasterView): else: f.remove_field('unit') + # suggested_price + if self.creating: + f.remove_field('suggested_price') + else: + f.set_readonly('suggested_price') + f.set_renderer('suggested_price', self.render_price) + # regular_price if self.creating: f.remove_field('regular_price') From a9b60b3d4a52e4020e807ff42bfb255f1cd22b4c Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 8 Nov 2018 15:48:38 -0600 Subject: [PATCH 1019/3196] 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 22b244e2..ff198883 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,20 @@ CHANGELOG ========= +0.7.49 (2018-11-08) +------------------- + +* Detect non-numeric entry when locating row for purchase batch. + +* Remove unwanted style for "email setting description" field. + +* Add ``Grid.hide_columns()`` convenience method. + +* Make sure status field is readonly when creating new batch. + +* Display "suggested price" when viewing product details. + + 0.7.48 (2018-11-07) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 11c9d274..a663931a 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.7.48' +__version__ = '0.7.49' From b33c2fd0d0237313c1d8f082cf450a005bce01ef Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 14 Nov 2018 10:33:39 -0600 Subject: [PATCH 1020/3196] Add simple price fields for product XLSX results download --- tailbone/views/products.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tailbone/views/products.py b/tailbone/views/products.py index c5ea6de7..a8ba6447 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -507,6 +507,15 @@ class ProductsView(MasterView): i = fields.index('brand_uuid') fields.insert(i + 1, 'brand_name') + i = fields.index('suggested_price_uuid') + fields.insert(i + 1, 'suggested_price') + + i = fields.index('regular_price_uuid') + fields.insert(i + 1, 'regular_price') + + i = fields.index('current_price_uuid') + fields.insert(i + 1, 'current_price') + return fields def get_xlsx_row(self, product, fields): @@ -539,6 +548,15 @@ class ProductsView(MasterView): if 'brand_name' in fields: row['brand_name'] = product.brand.name if product.brand else None + if 'suggested_price' in fields: + row['suggested_price'] = product.suggested_price.price if product.suggested_price else None + + if 'regular_price' in fields: + row['regular_price'] = product.regular_price.price if product.regular_price else None + + if 'current_price' in fields: + row['current_price'] = product.current_price.price if product.current_price else None + return row def get_instance(self): From fed42d4898244b9d43d147e315b50e01196cbe0b Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 14 Nov 2018 10:38:08 -0600 Subject: [PATCH 1021/3196] Add "200 per page" option for UI table grids --- 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 31c36907..88952dde 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -980,7 +980,7 @@ class Grid(object): def get_pagesize_options(self): # TODO: Make configurable or something... - return [5, 10, 20, 50, 100] + return [5, 10, 20, 50, 100, 200] class CustomWebhelpersGrid(webhelpers2_grid.Grid): From e3afb2c52a40475ee1aedc01760806a0a20a4643 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 14 Nov 2018 10:46:23 -0600 Subject: [PATCH 1022/3196] Add department, subdepartment "name" columns for products XLSX download --- tailbone/views/products.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tailbone/views/products.py b/tailbone/views/products.py index a8ba6447..0d49401b 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -485,9 +485,11 @@ class ProductsView(MasterView): i = fields.index('department_uuid') fields.insert(i + 1, 'department_number') + fields.insert(i + 2, 'department_name') i = fields.index('subdepartment_uuid') fields.insert(i + 1, 'subdepartment_number') + fields.insert(i + 2, 'subdepartment_name') i = fields.index('category_uuid') fields.insert(i + 1, 'category_code') @@ -526,9 +528,13 @@ class ProductsView(MasterView): if 'department_number' in fields: row['department_number'] = product.department.number if product.department else None + if 'department_name' in fields: + row['department_name'] = product.department.name if product.department else None if 'subdepartment_number' in fields: row['subdepartment_number'] = product.subdepartment.number if product.subdepartment else None + if 'subdepartment_name' in fields: + row['subdepartment_name'] = product.subdepartment.name if product.subdepartment else None if 'category_code' in fields: row['category_code'] = product.category.code if product.category else None From e27debd452670bdc08b478389351db70e10b430e Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 17 Nov 2018 18:23:07 -0600 Subject: [PATCH 1023/3196] Allow override of template for custom create views --- tailbone/views/master.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 06470529..e4c267b3 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -653,7 +653,7 @@ class MasterView(View): def render_mobile_row_listitem(self, obj, i): return obj - def create(self, form=None): + def create(self, form=None, template='create'): """ View for creating a new model record. """ @@ -670,7 +670,7 @@ class MasterView(View): context = {'form': form} if hasattr(form, 'make_deform_form'): context['dform'] = form.make_deform_form() - return self.render_to_response('create', context) + return self.render_to_response(template, context) def mobile_create(self): """ From f7e549b5fde0be4ca96e5a8995c1ec5493840dfe Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 17 Nov 2018 19:26:13 -0600 Subject: [PATCH 1024/3196] Expose new `Customer.wholesale` flag --- tailbone/views/customers.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tailbone/views/customers.py b/tailbone/views/customers.py index 79bc7cd0..f29ce474 100644 --- a/tailbone/views/customers.py +++ b/tailbone/views/customers.py @@ -77,6 +77,7 @@ class CustomersView(MasterView): 'default_address', 'default_email', 'email_preference', + 'wholesale', 'active_in_pos', 'active_in_pos_sticky', 'people', @@ -90,6 +91,7 @@ class CustomersView(MasterView): 'default_email', 'default_address', 'email_preference', + 'wholesale', 'active_in_pos', 'active_in_pos_sticky', 'people', From de6275003eda66641aeea52f9751647b0f911b8c Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 18 Nov 2018 19:36:28 -0600 Subject: [PATCH 1025/3196] Add vendor id, name to row CSV download for pricing batch --- tailbone/views/batch/core.py | 2 +- tailbone/views/batch/pricing.py | 25 +++++++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index 65221233..5a786c82 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -1312,7 +1312,7 @@ class BatchMasterView(MasterView): def get_row_csv_fields(self): fields = super(BatchMasterView, self).get_row_csv_fields() fields = [field for field in fields - if field != 'removed' and not field.endswith('uuid')] + if field not in ('uuid', 'batch_uuid', 'removed')] return fields def get_row_results_csv_filename(self, batch): diff --git a/tailbone/views/batch/pricing.py b/tailbone/views/batch/pricing.py index 63221339..e569a001 100644 --- a/tailbone/views/batch/pricing.py +++ b/tailbone/views/batch/pricing.py @@ -165,6 +165,31 @@ class PricingBatchView(BatchMasterView): url = self.request.route_url('vendors.view', uuid=vendor.uuid) return tags.link_to(text, url) + def get_row_csv_fields(self): + fields = super(PricingBatchView, self).get_row_csv_fields() + + if 'vendor_uuid' in fields: + i = fields.index('vendor_uuid') + fields.insert(i + 1, 'vendor_id') + fields.insert(i + 2, 'vendor_abbreviation') + fields.insert(i + 3, 'vendor_name') + else: + fields.append('vendor_id') + fields.append('vendor_abbreviation') + fields.append('vendor_name') + + return fields + + def get_row_csv_row(self, row, fields): + csvrow = super(PricingBatchView, self).get_row_csv_row(row, fields) + + vendor = row.vendor + csvrow['vendor_id'] = vendor.id if vendor else None + csvrow['vendor_abbreviation'] = vendor.abbreviation if vendor else None + csvrow['vendor_name'] = vendor.name if vendor else None + + return csvrow + def includeme(config): PricingBatchView.defaults(config) From fded97d58679200e3b5ac3b66e020023912cd522 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 18 Nov 2018 20:02:14 -0600 Subject: [PATCH 1026/3196] Don't add values to CSV row for undefined fields --- tailbone/views/batch/pricing.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tailbone/views/batch/pricing.py b/tailbone/views/batch/pricing.py index e569a001..f5c29c31 100644 --- a/tailbone/views/batch/pricing.py +++ b/tailbone/views/batch/pricing.py @@ -184,9 +184,12 @@ class PricingBatchView(BatchMasterView): csvrow = super(PricingBatchView, self).get_row_csv_row(row, fields) vendor = row.vendor - csvrow['vendor_id'] = vendor.id if vendor else None - csvrow['vendor_abbreviation'] = vendor.abbreviation if vendor else None - csvrow['vendor_name'] = vendor.name if vendor else None + if 'vendor_id' in fields: + csvrow['vendor_id'] = vendor.id if vendor else None + if 'vendor_abbreviation' in fields: + csvrow['vendor_abbreviation'] = vendor.abbreviation if vendor else None + if 'vendor_name' in fields: + csvrow['vendor_name'] = vendor.name if vendor else None return csvrow From 4a36ab827cfbb27f843f7055683e67ac9fc3f706 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 18 Nov 2018 20:02:43 -0600 Subject: [PATCH 1027/3196] Expose "suggested price" for pricing batch row view --- tailbone/views/batch/pricing.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tailbone/views/batch/pricing.py b/tailbone/views/batch/pricing.py index f5c29c31..728420fc 100644 --- a/tailbone/views/batch/pricing.py +++ b/tailbone/views/batch/pricing.py @@ -105,6 +105,7 @@ class PricingBatchView(BatchMasterView): 'vendor', 'regular_unit_cost', 'discounted_unit_cost', + 'suggested_price', 'old_price', 'new_price', 'price_diff', @@ -150,6 +151,7 @@ class PricingBatchView(BatchMasterView): f.set_renderer('product', self.render_product) # currency fields + f.set_type('suggested_price', 'currency') f.set_type('old_price', 'currency') f.set_type('new_price', 'currency') f.set_type('price_diff', 'currency') From 342c7c38540a4edcee94b005d503ea4b1adbdf34 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 18 Nov 2018 20:47:24 -0600 Subject: [PATCH 1028/3196] Move some label definitions for pricing batch rows --- tailbone/views/batch/pricing.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/tailbone/views/batch/pricing.py b/tailbone/views/batch/pricing.py index 728420fc..e8db8bc0 100644 --- a/tailbone/views/batch/pricing.py +++ b/tailbone/views/batch/pricing.py @@ -76,6 +76,11 @@ class PricingBatchView(BatchMasterView): row_labels = { 'upc': "UPC", + 'regular_unit_cost': "Reg. Cost", + 'price_diff': "$ Diff", + 'brand_name': "Brand", + 'price_markup': "Markup", + 'manually_priced': "Manual", } row_grid_columns = [ @@ -122,12 +127,6 @@ class PricingBatchView(BatchMasterView): g.set_type('new_price', 'currency') g.set_type('price_diff', 'currency') - g.set_label('brand_name', "Brand") - g.set_label('regular_unit_cost', "Reg. Cost") - g.set_label('price_markup', "Markup") - g.set_label('price_diff', "Diff") - g.set_label('manually_priced', "Manual") - def row_grid_extra_class(self, row, i): if row.status_code == row.STATUS_CANNOT_CALCULATE_PRICE: return 'warning' From 4806d7e5fe38cc7212928153e9f42075ea42c868 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 18 Nov 2018 21:12:08 -0600 Subject: [PATCH 1029/3196] Expose `price_diff_percent`, `margin_diff` for pricing batch row --- tailbone/views/batch/pricing.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tailbone/views/batch/pricing.py b/tailbone/views/batch/pricing.py index e8db8bc0..a0fc4011 100644 --- a/tailbone/views/batch/pricing.py +++ b/tailbone/views/batch/pricing.py @@ -78,6 +78,7 @@ class PricingBatchView(BatchMasterView): 'upc': "UPC", 'regular_unit_cost': "Reg. Cost", 'price_diff': "$ Diff", + 'price_diff_percent': "% Diff", 'brand_name': "Brand", 'price_markup': "Markup", 'manually_priced': "Manual", @@ -94,6 +95,7 @@ class PricingBatchView(BatchMasterView): 'new_price', 'price_margin', 'price_diff', + 'price_diff_percent', 'manually_priced', 'status_code', ] @@ -114,8 +116,10 @@ class PricingBatchView(BatchMasterView): 'old_price', 'new_price', 'price_diff', + 'price_diff_percent', 'price_margin', 'price_markup', + 'margin_diff', 'status_code', 'status_text', ] From 3e8d6a27f1fb20f9c194eea02b910156d8e21f1d Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 19 Nov 2018 14:15:48 -0600 Subject: [PATCH 1030/3196] Update changelog --- CHANGES.rst | 19 +++++++++++++++++++ tailbone/_version.py | 2 +- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index ff198883..aa571939 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,25 @@ CHANGELOG ========= +0.7.50 (2018-11-19) +------------------- + +* Add simple price fields for product XLSX results download. + +* Add "200 per page" option for UI table grids. + +* Add department, subdepartment "name" columns for products XLSX download. + +* Allow override of template for custom create views. + +* Expose new ``Customer.wholesale`` flag. + +* Add vendor id, name to row CSV download for pricing batch. + +* Expose ``suggested_price``, ``price_diff_percent``, ``margin_diff`` for + pricing batch row. + + 0.7.49 (2018-11-08) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index a663931a..2d6c96e6 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.7.49' +__version__ = '0.7.50' From 46501b7caab1ba9c810cef926f14ede9f7755bc3 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 19 Nov 2018 23:56:42 -0600 Subject: [PATCH 1031/3196] Use sqlalchemy-filters package for REST API collection_get just sorting and pagination so far though, no actual filters yet --- setup.py | 1 + tailbone/api/master.py | 41 +++++++++++++++++++++++++++++++++-------- 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/setup.py b/setup.py index 1ded0190..7cb688bd 100644 --- a/setup.py +++ b/setup.py @@ -88,6 +88,7 @@ requires = [ 'pyramid_tm', # 0.3 'rattail[db,bouncer]', # 0.5.0 'six', # 1.10.0 + 'sqlalchemy-filters', # 0.8.0 'transaction', # 1.2.0 'waitress', # 0.8.1 'WebHelpers2', # 2.0 diff --git a/tailbone/api/master.py b/tailbone/api/master.py index 1d4bafa3..8b4d5165 100644 --- a/tailbone/api/master.py +++ b/tailbone/api/master.py @@ -26,7 +26,7 @@ Tailbone Web API - Master View from __future__ import unicode_literals, absolute_import -from paginate_sqlalchemy import SqlalchemyOrmPage +from sqlalchemy_filters import apply_sort, apply_pagination from tailbone.api import APIView, api from tailbone.db import Session @@ -67,26 +67,51 @@ class APIMasterView(APIView): def _collection_get(self): cls = self.get_model_class() - objects = self.Session.query(cls) + query = self.Session.query(cls) + context = {} + # TODO: should vuetable (etc.) be sending us valid sort_spec directly? sort = self.request.params.get('sort') if sort: # TODO: this is fragile, but what to do if bad params? sortkey, sortdir = sort.split('|') - sortkey = getattr(cls, sortkey) - objects = objects.order_by(getattr(sortkey, sortdir)()) + if sortdir != 'desc': + sortdir = 'asc' - # NOTE: we only page results if sorting is in effect, otherwise + sort_spec = [ + { + # 'model': self.model_class.__name__, + 'field': sortkey, + 'direction': sortdir, + }, + ] + query = apply_sort(query, sort_spec) + + # NOTE: we only paginate results if sorting is in effect, otherwise # record sequence is "non-determinant" (is that the word?) page = self.request.params.get('page') per_page = self.request.params.get('per_page') if page.isdigit() and per_page.isdigit(): page = int(page) per_page = int(per_page) - objects = SqlalchemyOrmPage(objects, items_per_page=per_page, page=page) + query, pagination = apply_pagination(query, page_number=page, page_size=per_page) - objects = [self.normalize(obj) for obj in objects] - return {self.get_collection_key(): objects} + # these pagination values are based on 'vuetable-2' + # https://www.vuetable.com/guide/pagination.html#how-the-pagination-component-works + context['total'] = pagination.total_results + context['per_page'] = pagination.page_size + context['current_page'] = pagination.page_number + context['last_page'] = pagination.num_pages + context['from'] = pagination.page_size * (pagination.page_number - 1) + 1 + to = pagination.page_size * (pagination.page_number - 1) + pagination.page_size + if to > pagination.total_results: + context['to'] = pagination.total_results + else: + context['to'] = to + + objects = [self.normalize(obj) for obj in query] + context[self.get_collection_key()] = objects + return context def _get(self): uuid = self.request.matchdict['uuid'] From 81db564e3404e88cfc2062d243cbd9e32a53b162 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 20 Nov 2018 17:07:28 -0600 Subject: [PATCH 1032/3196] Delay import of sqlalchemy_filters project since apparently we can't use this (and hence our new API) unless python3 --- tailbone/api/master.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tailbone/api/master.py b/tailbone/api/master.py index 8b4d5165..6d3861d6 100644 --- a/tailbone/api/master.py +++ b/tailbone/api/master.py @@ -26,8 +26,6 @@ Tailbone Web API - Master View from __future__ import unicode_literals, absolute_import -from sqlalchemy_filters import apply_sort, apply_pagination - from tailbone.api import APIView, api from tailbone.db import Session @@ -66,6 +64,8 @@ class APIMasterView(APIView): return '{}s'.format(cls.get_object_key()) def _collection_get(self): + from sqlalchemy_filters import apply_sort, apply_pagination + cls = self.get_model_class() query = self.Session.query(cls) context = {} From 5c66eb5f4f2446adb47d7d1a1134734f06239808 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 20 Nov 2018 20:49:13 -0600 Subject: [PATCH 1033/3196] Refactor API collection_get to work with vue-tables-2 https://github.com/matfish2/vue-tables-2 --- tailbone/api/master.py | 94 +++++++++++++++++++++++++++++++----------- 1 file changed, 70 insertions(+), 24 deletions(-) diff --git a/tailbone/api/master.py b/tailbone/api/master.py index 6d3861d6..e8b5b938 100644 --- a/tailbone/api/master.py +++ b/tailbone/api/master.py @@ -26,6 +26,8 @@ Tailbone Web API - Master View from __future__ import unicode_literals, absolute_import +from rattail.config import parse_bool + from tailbone.api import APIView, api from tailbone.db import Session @@ -63,6 +65,52 @@ class APIMasterView(APIView): return cls.collection_key return '{}s'.format(cls.get_object_key()) + def make_sort_spec(self): + + # these params are based on 'vuetable-2' + # https://www.vuetable.com/guide/sorting.html#initial-sorting-order + if 'sort' in self.request.params: + sort = self.request.params['sort'] + sortkey, sortdir = sort.split('|') + if sortdir != 'desc': + sortdir = 'asc' + return [ + { + # 'model': self.model_class.__name__, + 'field': sortkey, + 'direction': sortdir, + }, + ] + + # these params are based on 'vue-tables-2' + # https://github.com/matfish2/vue-tables-2#server-side + if 'orderBy' in self.request.params and 'ascending' in self.request.params: + return [ + { + # 'model': self.model_class.__name__, + 'field': self.request.params['orderBy'], + 'direction': 'asc' if parse_bool(self.request.params['ascending']) else 'desc', + }, + ] + + def make_pagination_spec(self): + + # these params are based on 'vuetable-2' + # https://github.com/ratiw/vuetable-2-tutorial/wiki/prerequisite#sample-api-endpoint + if 'page' in self.request.params and 'per_page' in self.request.params: + page = self.request.params['page'] + per_page = self.request.params['per_page'] + if page.isdigit() and per_page.isdigit(): + return int(page), int(per_page) + + # these params are based on 'vue-tables-2' + # https://github.com/matfish2/vue-tables-2#server-side + if 'page' in self.request.params and 'limit' in self.request.params: + page = self.request.params['page'] + limit = self.request.params['limit'] + if page.isdigit() and limit.isdigit(): + return int(page), int(limit) + def _collection_get(self): from sqlalchemy_filters import apply_sort, apply_pagination @@ -70,33 +118,18 @@ class APIMasterView(APIView): query = self.Session.query(cls) context = {} - # TODO: should vuetable (etc.) be sending us valid sort_spec directly? - sort = self.request.params.get('sort') - if sort: - # TODO: this is fragile, but what to do if bad params? - sortkey, sortdir = sort.split('|') - if sortdir != 'desc': - sortdir = 'asc' - - sort_spec = [ - { - # 'model': self.model_class.__name__, - 'field': sortkey, - 'direction': sortdir, - }, - ] + # maybe sort query + sort_spec = self.make_sort_spec() + if sort_spec: query = apply_sort(query, sort_spec) - # NOTE: we only paginate results if sorting is in effect, otherwise - # record sequence is "non-determinant" (is that the word?) - page = self.request.params.get('page') - per_page = self.request.params.get('per_page') - if page.isdigit() and per_page.isdigit(): - page = int(page) - per_page = int(per_page) - query, pagination = apply_pagination(query, page_number=page, page_size=per_page) + # maybe paginate query + pagination_spec = self.make_pagination_spec() + if pagination_spec: + number, size = pagination_spec + query, pagination = apply_pagination(query, page_number=number, page_size=size) - # these pagination values are based on 'vuetable-2' + # these properties are based on 'vuetable-2' # https://www.vuetable.com/guide/pagination.html#how-the-pagination-component-works context['total'] = pagination.total_results context['per_page'] = pagination.page_size @@ -109,8 +142,21 @@ class APIMasterView(APIView): else: context['to'] = to + # these properties are based on 'vue-tables-2' + # https://github.com/matfish2/vue-tables-2#server-side + context['count'] = pagination.total_results + objects = [self.normalize(obj) for obj in query] + + # TODO: test this for ratbob! context[self.get_collection_key()] = objects + + # these properties are based on 'vue-tables-2' + # https://github.com/matfish2/vue-tables-2#server-side + context['data'] = objects + if 'count' not in context: + context['count'] = len(objects) + return context def _get(self): From 6b7631013d53c67769cacab89ae0a6a1e08eded2 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 21 Nov 2018 19:56:01 -0600 Subject: [PATCH 1034/3196] Remove some relationship fields when creating new Person --- tailbone/views/people.py | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/tailbone/views/people.py b/tailbone/views/people.py index bab450d4..e5b96ef1 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -153,14 +153,26 @@ class PeopleView(MasterView): f.set_readonly('address') f.set_label('address', "Mailing Address") - f.set_readonly('employee') - f.set_renderer('employee', self.render_employee) + # employee + if self.creating: + f.remove_field('employee') + else: + f.set_readonly('employee') + f.set_renderer('employee', self.render_employee) - f.set_readonly('customers') - f.set_renderer('customers', self.render_customers) + # customers + if self.creating: + f.remove_field('customers') + else: + f.set_readonly('customers') + f.set_renderer('customers', self.render_customers) - f.set_readonly('users') - f.set_renderer('users', self.render_users) + # users + if self.creating: + f.remove_field('users') + else: + f.set_readonly('users') + f.set_renderer('users', self.render_users) def render_employee(self, person, field): employee = person.employee From de788423e1805793464665e86e4ccfa643c32892 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 21 Nov 2018 21:19:02 -0600 Subject: [PATCH 1035/3196] Update comments per frozen webhelpers2_grid dependency not sure yet if it's worth refactoring to new version? probably is though.. --- setup.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 7cb688bd..9c9aa907 100644 --- a/setup.py +++ b/setup.py @@ -67,7 +67,10 @@ requires = [ # TODO: why do we need to cap this? breaks tailbone.db zope stuff somehow 'zope.sqlalchemy<1.0', # 0.7 0.7.7 - # TODO: apparently they jumped from 0.1 to 0.9 and that broke us...must investigate + # TODO: apparently they jumped from 0.1 to 0.9 and that broke us... + # (0.1 was released on 2014-09-14 and then 0.9 came out on 2018-09-27) + # (i've cached 0.1 at pypi.rattailproject.org just in case it disappears) + # (still, probably a better idea is to refactor so we can use 0.9) 'webhelpers2_grid==0.1', # 0.1 'ColanderAlchemy', # 0.3.3 From 4ad958b9d27be5cd82ae9c5c9a09ffc809ddd5d9 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 21 Nov 2018 23:03:07 -0600 Subject: [PATCH 1036/3196] Fix bug in receiving template when truck dump not enabled --- tailbone/templates/receiving/view.mako | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/templates/receiving/view.mako b/tailbone/templates/receiving/view.mako index 95750bda..2204cd77 100644 --- a/tailbone/templates/receiving/view.mako +++ b/tailbone/templates/receiving/view.mako @@ -56,7 +56,7 @@ ${parent.body()} -% if request.has_perm('{}.edit_row'.format(permission_prefix)): +% if master.allow_truck_dump and request.has_perm('{}.edit_row'.format(permission_prefix)): ${h.form(url('{}.transform_unit_row'.format(route_prefix), uuid=batch.uuid), name='transform-unit-form')} ${h.csrf_token(request)} ${h.hidden('row_uuid')} From 0375d66b9112f4a169c7c0df567300cbeeeb49bb Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 22 Nov 2018 11:12:31 -0600 Subject: [PATCH 1037/3196] Tweak default "model title" logic for master view i.e. if view class doesn't declare one --- tailbone/views/master.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tailbone/views/master.py b/tailbone/views/master.py index e4c267b3..2283faee 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -1780,7 +1780,14 @@ class MasterView(View): """ if hasattr(cls, 'model_title'): return cls.model_title - return cls.get_model_class().get_model_title() + + # model class itself may provide title + model_class = cls.get_model_class() + if hasattr(model_class, 'get_model_title'): + return model_class.get_model_title() + + # otherwise just use model class name + return model_class.__name__ @classmethod def get_model_title_plural(cls): From 4fa9ab3c6ea40bb3bbe8bfb0b219836a94afece9 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 22 Nov 2018 20:26:28 -0600 Subject: [PATCH 1038/3196] Add better support for "make import batch from file" pattern --- tailbone/forms/__init__.py | 4 +- tailbone/forms/core.py | 18 +++++++++ tailbone/templates/batch/view.mako | 2 +- tailbone/templates/master/import_file.mako | 6 +++ tailbone/views/batch/importer.py | 12 +++++- tailbone/views/master.py | 44 +++++++++++++++++++++- 6 files changed, 80 insertions(+), 6 deletions(-) create mode 100644 tailbone/templates/master/import_file.mako diff --git a/tailbone/forms/__init__.py b/tailbone/forms/__init__.py index 7b412dca..a368f2d1 100644 --- a/tailbone/forms/__init__.py +++ b/tailbone/forms/__init__.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar +# Copyright © 2010-2018 Lance Edgar # # This file is part of Rattail. # @@ -28,4 +28,4 @@ from __future__ import unicode_literals, absolute_import from . import types from . import widgets -from .core import Form +from .core import Form, SimpleFileImport diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index 04633bbb..dd242041 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.py @@ -881,3 +881,21 @@ class FieldList(list): def insert_after(self, field, newfield): i = self.index(field) self.insert(i + 1, newfield) + + +@colander.deferred +def upload_widget(node, kw): + request = kw['request'] + tmpstore = SessionFileUploadTempStore(request) + return dfwidget.FileUploadWidget(tmpstore) + + +class SimpleFileImport(colander.Schema): + """ + Schema for simple file import. Note that you must bind your ``request`` + object to this schema, i.e.:: + + schema = SimpleFileImport().bind(request=request) + """ + filename = colander.SchemaNode(deform.FileData(), + widget=upload_widget) diff --git a/tailbone/templates/batch/view.mako b/tailbone/templates/batch/view.mako index 4874d7ef..34c6b406 100644 --- a/tailbone/templates/batch/view.mako +++ b/tailbone/templates/batch/view.mako @@ -79,7 +79,7 @@ ${self.context_menu_items()} -% if status_breakdown is not Undefined: +% if status_breakdown is not Undefined and status_breakdown is not None:

            Row Status Breakdown

            diff --git a/tailbone/templates/master/import_file.mako b/tailbone/templates/master/import_file.mako new file mode 100644 index 00000000..0dd03754 --- /dev/null +++ b/tailbone/templates/master/import_file.mako @@ -0,0 +1,6 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/create.mako" /> + +<%def name="title()">Import ${model_title_plural} from ${importer_host_title} + +${parent.body()} diff --git a/tailbone/views/batch/importer.py b/tailbone/views/batch/importer.py index 0aba98c1..b2803183 100644 --- a/tailbone/views/batch/importer.py +++ b/tailbone/views/batch/importer.py @@ -103,15 +103,23 @@ class ImporterBatchView(BatchMasterView): f.set_readonly('importer_key') f.set_readonly('row_table') + def make_status_breakdown(self, batch): + # TODO: should implement this, just can't use batch.data_rows apparently + pass + def delete_instance(self, batch): self.make_row_table(batch.row_table) - self.current_row_table.drop() + if self.current_row_table is not None: + self.current_row_table.drop() super(ImporterBatchView, self).delete_instance(batch) def make_row_table(self, name): if not hasattr(self, 'current_row_table'): metadata = sa.MetaData(schema='batch', bind=self.Session.bind) - self.current_row_table = sa.Table(name, metadata, autoload=True) + try: + self.current_row_table = sa.Table(name, metadata, autoload=True) + except sa.exc.NoSuchTableError: + self.current_row_table = None def get_row_data(self, batch): self.make_row_table(batch.row_table) diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 2283faee..8d3af454 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -89,6 +89,7 @@ class MasterView(View): execute_progress_template = None execute_progress_initial_msg = None supports_prev_next = False + supports_import_batch_from_file = False supports_mobile = False mobile_creatable = False @@ -705,7 +706,11 @@ class MasterView(View): if isinstance(node.typ, deform.FileData): if skip and node.name in skip: continue - filedict = self.form_deserialized.get(node.name) + # TODO: does form ever *not* have 'validated' attr here? + if hasattr(form, 'validated'): + filedict = form.validated.get(node.name) + else: + filedict = self.form_deserialized.get(node.name) if filedict: tempdir = tempfile.mkdtemp() filepath = os.path.join(tempdir, filedict['filename']) @@ -722,6 +727,38 @@ class MasterView(View): def process_uploads(self, obj, form, uploads): pass + def import_batch_from_file(self, handler_factory, model_name, + delete=False, schema=None, importer_host_title=None): + + handler = handler_factory(self.rattail_config) + + if not schema: + schema = forms.SimpleFileImport().bind(request=self.request) + form = forms.Form(schema=schema, request=self.request) + form.save_label = "Upload" + form.cancel_url = self.get_index_url() + if form.validate(newstyle=True): + + uploads = self.normalize_uploads(form) + filepath = uploads['filename']['temp_path'] + batches = handler.make_batches(model_name, + delete=delete, + # tdc_input_path=filepath, + # source_csv_path=filepath, + source_data_path=filepath, + runas_user=self.request.user) + batch = batches[0] + return self.redirect(self.request.route_url('batch.importer.view', uuid=batch.uuid)) + + if not importer_host_title: + importer_host_title = handler.host_title + + return self.render_to_response('import_file', { + 'form': form, + 'dform': form.make_deform_form(), + 'importer_host_title': importer_host_title, + }) + def render_product_key_value(self, obj): """ Render the "canonical" product key value for the given object. @@ -3087,6 +3124,11 @@ class MasterView(View): config.add_tailbone_permission(permission_prefix, '{0}.delete'.format(permission_prefix), "Delete {0}".format(model_title)) + # import batch from file + if cls.supports_import_batch_from_file: + config.add_tailbone_permission(permission_prefix, '{}.import_file'.format(permission_prefix), + "Create a new import batch from data file") + ### sub-rows stuff follows # download row results as CSV From d9e5eff23d7c9190e3defd82a55a9372b547cb3b Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 25 Nov 2018 17:27:31 -0600 Subject: [PATCH 1039/3196] Fix download filename when it contains spaces --- tailbone/views/master.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 8d3af454..58b283ce 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -1467,12 +1467,13 @@ class MasterView(View): response.content_type = content_type else: response.content_type = six.binary_type(content_type) - if six.PY3: - filename = os.path.basename(path) - response.content_disposition = 'attachment; filename={}'.format(filename) - else: - filename = os.path.basename(path).encode('ascii', 'replace') - response.content_disposition = b'attachment; filename={}'.format(filename) + + # content-disposition + filename = os.path.basename(path) + if six.PY2: + filename = filename.encode('ascii', 'replace') + response.content_disposition = str('attachment; filename="{}"'.format(filename)) + return response def download_content_type(self, path, filename): From 3b54ab3e0b680ec42d28b023debf9dbf7ffd5a53 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 25 Nov 2018 20:14:49 -0600 Subject: [PATCH 1040/3196] Add "min % diff" option for pricing batch from products query refactor the "batch from query" a bit also, to allow for multiple batch type options which represent the same underlying batch type. (thought i needed that, then realized i didn't, but seems safe to include.) --- tailbone/templates/products/batch.mako | 2 +- tailbone/views/batch/pricing.py | 11 ++++ tailbone/views/products.py | 78 +++++++++++++++----------- 3 files changed, 56 insertions(+), 35 deletions(-) diff --git a/tailbone/templates/products/batch.mako b/tailbone/templates/products/batch.mako index 97b23035..a66d0486 100644 --- a/tailbone/templates/products/batch.mako +++ b/tailbone/templates/products/batch.mako @@ -1,7 +1,7 @@ ## -*- coding: utf-8; -*- <%inherit file="/base.mako" /> -<%def name="title()">Products: Create Batch +<%def name="title()">Create Batch <%def name="context_menu_items()">
          • ${h.link_to("Back to Products", url('products'))}
          • diff --git a/tailbone/views/batch/pricing.py b/tailbone/views/batch/pricing.py index a0fc4011..c3913a8c 100644 --- a/tailbone/views/batch/pricing.py +++ b/tailbone/views/batch/pricing.py @@ -49,6 +49,11 @@ class PricingBatchView(BatchMasterView): rows_editable = True rows_bulk_deletable = True + labels = { + 'min_diff_threshold': "Min $ Diff", + 'min_diff_percent': "Min % Diff", + } + grid_columns = [ 'id', 'description', @@ -65,6 +70,7 @@ class PricingBatchView(BatchMasterView): 'id', 'description', 'min_diff_threshold', + 'min_diff_percent', 'calculate_for_manual', 'notes', 'created', @@ -124,6 +130,11 @@ class PricingBatchView(BatchMasterView): 'status_text', ] + def configure_form(self, f): + super(PricingBatchView, self).configure_form(f) + + f.set_type('min_diff_threshold', 'currency') + def configure_row_grid(self, g): super(PricingBatchView, self).configure_row_grid(g) diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 0d49401b..c5f85b9a 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -954,11 +954,13 @@ class ProductsView(MasterView): """ supported = self.get_supported_batches() batch_options = [] - for key, spec in list(supported.items()): - handler = load_object(spec)(self.rattail_config) - handler.spec = spec + for key, info in list(supported.items()): + handler = load_object(info['spec'])(self.rattail_config) + handler.spec = info['spec'] + handler.option_key = key + handler.option_title = info.get('title', handler.get_model_title()) supported[key] = handler - batch_options.append((key, handler.get_model_title())) + batch_options.append((key, handler.option_title)) schema = colander.SchemaNode( colander.Mapping(), @@ -983,41 +985,44 @@ class ProductsView(MasterView): params_forms[key] = forms.Form(schema=schema, request=self.request) if self.request.method == 'POST': - controls = self.request.POST.items() - data = form.validate(controls) - batch_key = data['batch_type'] - params = { - 'description': data['description'], - 'notes': data['notes']} - pform = params_forms.get(batch_key) - if pform: - pdata = pform.validate(controls) - for field in pform.schema: - param_name = pform.schema[field.name].param_name - params[param_name] = pdata[field.name] + if form.validate(newstyle=True): + data = form.validated - # TODO: should this be done elsewhere? - for name in params: - if params[name] is colander.null: - params[name] = None + # collect general params + batch_key = data['batch_type'] + params = { + 'description': data['description'], + 'notes': data['notes']} - handler = get_batch_handler(self.rattail_config, batch_key, - default=supported[batch_key].spec) - products = self.get_effective_data() - progress = SessionProgress(self.request, 'products.batch') - thread = Thread(target=self.make_batch_thread, - args=(handler, self.request.user.uuid, products, params, progress)) - thread.start() - return self.render_progress(progress, { - 'cancel_url': self.get_index_url(), - 'cancel_msg': "Batch creation was canceled.", - }) + # collect batch-type-specific params + pform = params_forms.get(batch_key) + if pform and pform.validate(newstyle=True): + pdata = pform.validated + for field in pform.schema: + param_name = pform.schema[field.name].param_name + params[param_name] = pdata[field.name] - return { + # TODO: should this be done elsewhere? + for name in params: + if params[name] is colander.null: + params[name] = None + + handler = supported[batch_key] + products = self.get_effective_data() + progress = SessionProgress(self.request, 'products.batch') + thread = Thread(target=self.make_batch_thread, + args=(handler, self.request.user.uuid, products, params, progress)) + thread.start() + return self.render_progress(progress, { + 'cancel_url': self.get_index_url(), + 'cancel_msg': "Batch creation was canceled.", + }) + + return self.render_to_response('batch', { 'form': form, 'dform': form.make_deform_form(), # TODO: hacky? at least is explicit.. 'params_forms': params_forms, - } + }) def make_batch_params_schema_pricing(self): """ @@ -1025,7 +1030,12 @@ class ProductsView(MasterView): """ return colander.SchemaNode( colander.Mapping(), - colander.SchemaNode(colander.Decimal(), name='min_diff_threshold', quant='1.00', missing=colander.null), + colander.SchemaNode(colander.Decimal(), name='min_diff_threshold', + quant='1.00', missing=colander.null, + title="Min $ Diff"), + colander.SchemaNode(colander.Decimal(), name='min_diff_percent', + quant='1.00', missing=colander.null, + title="Min % Diff"), colander.SchemaNode(colander.Boolean(), name='calculate_for_manual'), ) From d7730434299581d3e9c6ebc37382f530c91f0264 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 26 Nov 2018 18:58:55 -0600 Subject: [PATCH 1041/3196] Allow override of products query when making batch from it also, invoke handler properly when populating the batch (i.e. to include setup/teardown) --- tailbone/views/products.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/tailbone/views/products.py b/tailbone/views/products.py index c5f85b9a..10561582 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -1008,7 +1008,7 @@ class ProductsView(MasterView): params[name] = None handler = supported[batch_key] - products = self.get_effective_data() + products = self.get_products_for_batch(batch_key) progress = SessionProgress(self.request, 'products.batch') thread = Thread(target=self.make_batch_thread, args=(handler, self.request.user.uuid, products, params, progress)) @@ -1024,6 +1024,14 @@ class ProductsView(MasterView): 'params_forms': params_forms, }) + def get_products_for_batch(self, batch_key): + """ + Returns the products query to be used when making a batch (of type + ``batch_key``) with the user's current filters in effect. You can + override this to add eager joins for certain batch types, etc. + """ + return self.get_effective_data() + def make_batch_params_schema_pricing(self): """ Return params schema for making a pricing batch. @@ -1049,7 +1057,7 @@ class ProductsView(MasterView): params['created_by'] = user batch = handler.make_batch(session, **params) batch.products = products.with_session(session).all() - handler.populate(batch, progress=progress) + handler.do_populate(batch, user, progress=progress) session.commit() session.refresh(batch) From 25e61cc8d519d65678f82fa9d697f80d00eb2be6 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 26 Nov 2018 19:25:32 -0600 Subject: [PATCH 1042/3196] Use empty string instead of null as fallback value, for pricing rows CSV --- tailbone/views/batch/pricing.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tailbone/views/batch/pricing.py b/tailbone/views/batch/pricing.py index c3913a8c..1ac5b95b 100644 --- a/tailbone/views/batch/pricing.py +++ b/tailbone/views/batch/pricing.py @@ -201,11 +201,11 @@ class PricingBatchView(BatchMasterView): vendor = row.vendor if 'vendor_id' in fields: - csvrow['vendor_id'] = vendor.id if vendor else None + csvrow['vendor_id'] = (vendor.id or '') if vendor else '' if 'vendor_abbreviation' in fields: - csvrow['vendor_abbreviation'] = vendor.abbreviation if vendor else None + csvrow['vendor_abbreviation'] = (vendor.abbreviation or '') if vendor else '' if 'vendor_name' in fields: - csvrow['vendor_name'] = vendor.name if vendor else None + csvrow['vendor_name'] = (vendor.name or '') if vendor else '' return csvrow From 993d8c3b4ea80e85cd58baa82363fae1e85e835d Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 26 Nov 2018 22:07:30 -0600 Subject: [PATCH 1043/3196] Add very basic Vue.js grid/index experiment for Users table --- tailbone/config.py | 5 ++ tailbone/templates/users/index.mako | 11 +++ tailbone/templates/users/vue_index.mako | 95 +++++++++++++++++++++++++ tailbone/views/users.py | 26 +++++++ 4 files changed, 137 insertions(+) create mode 100644 tailbone/templates/users/index.mako create mode 100644 tailbone/templates/users/vue_index.mako diff --git a/tailbone/config.py b/tailbone/config.py index 51293a26..00a8a6f7 100644 --- a/tailbone/config.py +++ b/tailbone/config.py @@ -47,3 +47,8 @@ class ConfigExtension(BaseExtension): def configure(self, config): Session.configure(rattail_config=config) configure_session(config, Session) + + +def expose_vuejs_experiments(config): + return config.getbool('tailbone', 'expose_vuejs_experiments', + default=False) diff --git a/tailbone/templates/users/index.mako b/tailbone/templates/users/index.mako new file mode 100644 index 00000000..4c5351b7 --- /dev/null +++ b/tailbone/templates/users/index.mako @@ -0,0 +1,11 @@ +## -*- coding: utf-8; -*- +<%inherit file="/principal/index.mako" /> + +<%def name="context_menu_items()"> + ${parent.context_menu_items()} + % if expose_vuejs_experiments: +
          • ${h.link_to("Vue.js Index", url('{}.vue_index'.format(route_prefix)))}
          • + % endif + + +${parent.body()} diff --git a/tailbone/templates/users/vue_index.mako b/tailbone/templates/users/vue_index.mako new file mode 100644 index 00000000..8558a7ce --- /dev/null +++ b/tailbone/templates/users/vue_index.mako @@ -0,0 +1,95 @@ +## -*- coding: utf-8; -*- +<%inherit file="/users/index.mako" /> + +<%def name="extra_javascript()"> + ${parent.extra_javascript()} + + + ${h.javascript_link('https://cdn.jsdelivr.net/npm/vue')} + + + ${h.javascript_link('https://unpkg.com/vuex')} + + + ${h.javascript_link('https://cdn.jsdelivr.net/npm/vue-tables-2@1.4.70/dist/vue-tables-2.min.js')} + + + ${h.stylesheet_link('https://cdnjs.cloudflare.com/ajax/libs/bulma/0.7.2/css/bulma.min.css')} +## + + + +## TODO: just, ugh. +


            + +
            + ## TODO: need to make endpoint a bit more configurable somehow + +
            + + diff --git a/tailbone/views/users.py b/tailbone/views/users.py index bbd21b36..8eebb32e 100644 --- a/tailbone/views/users.py +++ b/tailbone/views/users.py @@ -42,6 +42,7 @@ from tailbone import forms from tailbone.db import Session from tailbone.views import MasterView from tailbone.views.principal import PrincipalMasterView, PermissionsRenderer +from tailbone.config import expose_vuejs_experiments class UsersView(PrincipalMasterView): @@ -124,6 +125,16 @@ class UsersView(PrincipalMasterView): g.set_link('last_name') g.set_link('display_name') + def template_kwargs_index(self, **kwargs): + kwargs['expose_vuejs_experiments'] = expose_vuejs_experiments(self.rattail_config) + return kwargs + + def vue_index(self): + if not expose_vuejs_experiments(self.rattail_config): + raise self.notfound() + + return self.render_to_response('vue_index', {}) + def unique_username(self, node, value): query = self.Session.query(model.User)\ .filter(model.User.username == value) @@ -308,6 +319,21 @@ class UsersView(PrincipalMasterView): assert not removing._roles self.Session.delete(removing) + @classmethod + def defaults(cls, config): + rattail_config = config.registry.settings.get('rattail_config') + route_prefix = cls.get_route_prefix() + url_prefix = cls.get_url_prefix() + permission_prefix = cls.get_permission_prefix() + + # vue-index + config.add_route('{}.vue_index'.format(route_prefix), '{}/vue-index/'.format(url_prefix)) + config.add_view(cls, attr='vue_index', route_name='{}.vue_index'.format(route_prefix), + permission='{}.list'.format(permission_prefix)) + + cls._principal_defaults(config) + cls._defaults(config) + class UserEventsView(MasterView): """ From 02528aecc766deab8772a8e99a9fb2e1518cf7e9 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 26 Nov 2018 22:58:21 -0600 Subject: [PATCH 1044/3196] Tweak styles for "global title" in header --- tailbone/static/css/layout.css | 9 --------- tailbone/templates/base.mako | 2 +- 2 files changed, 1 insertion(+), 10 deletions(-) diff --git a/tailbone/static/css/layout.css b/tailbone/static/css/layout.css index 6305e800..a49f31a5 100644 --- a/tailbone/static/css/layout.css +++ b/tailbone/static/css/layout.css @@ -44,19 +44,10 @@ body > #body-wrapper { margin: 0px; } -#header h1.title { - font-size: 20px; - margin-left: 10px; -} - #header div.login { float: right; } -.global .title { - margin-left: 0.5em; -} - /* new stuff from 'better' theme begins here */ header .global { diff --git a/tailbone/templates/base.mako b/tailbone/templates/base.mako index 992ee681..1c5ed8fe 100644 --- a/tailbone/templates/base.mako +++ b/tailbone/templates/base.mako @@ -32,7 +32,7 @@
            ${self.header_logo()} - ${self.global_title()} + ${self.global_title()} % if master: » From 875f52071042a7c0e2aa78d6a37eb8765a9f04a6 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 27 Nov 2018 00:57:38 -0600 Subject: [PATCH 1045/3196] Add basic FontAwesome support to new Vue.js table grid i.e. for sortable column icons --- tailbone/api/users.py | 1 + tailbone/templates/users/vue_index.mako | 43 +++++++++++++++++++------ 2 files changed, 35 insertions(+), 9 deletions(-) diff --git a/tailbone/api/users.py b/tailbone/api/users.py index f237c885..0f5a22d0 100644 --- a/tailbone/api/users.py +++ b/tailbone/api/users.py @@ -44,6 +44,7 @@ class UserView(APIMasterView): return { 'username': user.username, 'person': six.text_type(user.person or ''), + 'active': user.active, } @view(permission='users.list') diff --git a/tailbone/templates/users/vue_index.mako b/tailbone/templates/users/vue_index.mako index 8558a7ce..6f4d7c7c 100644 --- a/tailbone/templates/users/vue_index.mako +++ b/tailbone/templates/users/vue_index.mako @@ -1,6 +1,13 @@ ## -*- coding: utf-8; -*- <%inherit file="/users/index.mako" /> +## <%def name="head_tags()"> +## ${parent.head_tags()} +## ## TODO: this is needed according to Bulma docs? +## ## https://bulma.io/documentation/overview/start/#code-requirements +## +## + <%def name="extra_javascript()"> ${parent.extra_javascript()} @@ -12,22 +19,31 @@ ${h.javascript_link('https://cdn.jsdelivr.net/npm/vue-tables-2@1.4.70/dist/vue-tables-2.min.js')} + ## ${h.javascript_link(request.static_url('tailbone:static/js/lib/vue-tables.js'))} ${h.stylesheet_link('https://cdnjs.cloudflare.com/ajax/libs/bulma/0.7.2/css/bulma.min.css')} -## + + + + + ## TODO: just, ugh. -


            +
            ## TODO: need to make endpoint a bit more configurable somehow - + + ## // TODO: why on earth doesn't it render bool as string by default? + {{ props.row.active }} +
            ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.js') + '?ver={}'.format(tailbone.__version__))} ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.feedback.js') + '?ver={}'.format(tailbone.__version__))} @@ -189,14 +201,6 @@ <%def name="extra_styles()"> -<%def name="head_tags()"> - -<%def name="header_logo()"> - -<%def name="footer()"> - powered by ${h.link_to("Rattail", url('about'))} - - <%def name="wtfield(form, name, **kwargs)">
            diff --git a/tailbone/templates/base_meta.mako b/tailbone/templates/base_meta.mako new file mode 100644 index 00000000..5ff48fad --- /dev/null +++ b/tailbone/templates/base_meta.mako @@ -0,0 +1,17 @@ +## -*- coding: utf-8; -*- + +<%def name="app_title()">Rattail + +<%def name="global_title()">${"[STAGE] " if not request.rattail_config.production() else ''}${self.app_title()} + +<%def name="favicon()"> + + + +<%def name="head_tags()"> + +<%def name="header_logo()"> + +<%def name="footer()"> + powered by ${h.link_to("Rattail", url('about'))} + diff --git a/tailbone/templates/themes/bobcat/base.mako b/tailbone/templates/themes/bobcat/base.mako new file mode 100644 index 00000000..53be3c40 --- /dev/null +++ b/tailbone/templates/themes/bobcat/base.mako @@ -0,0 +1,214 @@ +## -*- coding: utf-8; -*- +<%namespace file="/menu.mako" import="main_menu_items" /> +<%namespace file="/grids/nav.mako" import="grid_index_nav" /> +<%namespace file="/feedback_dialog.mako" import="feedback_dialog" /> +<%namespace name="base_meta" file="/base_meta.mako" /> + + + + + ${base_meta.global_title()} » ${capture(self.title)|n} + ${base_meta.favicon()} + ${self.header_core()} + + % if not request.rattail_config.production(): + + % endif + + ${base_meta.head_tags()} + + + +
            + +
            + + +
            + + ${base_meta.header_logo()} + ${base_meta.global_title()} + + % if master: + » + % if master.listing: + ${index_title} + % else: + ${h.link_to(index_title, index_url, class_='global')} + % if parent_url is not Undefined: + » + ${h.link_to(parent_title, parent_url, class_='global')} + % elif instance_url is not Undefined: + » + ${h.link_to(instance_title, instance_url, class_='global')} + % endif + % if master.viewing and grid_index: + ${grid_index_nav()} + % endif + % endif + % elif index_title: + » + ${index_title} + % endif + + + + % if expose_theme_picker and request.has_perm('common.change_app_theme'): +
            + ${h.form(url('change_theme'), name="theme_changer", method="post")} + ${h.csrf_token(request)} + Theme: + ${h.select('theme', theme, options=theme_picker_options, id='theme-picker')} + ${h.end_form()} +
            + % endif + +
            + +
            + ${self.content_title()} +
            +
            + +
            + +
            +
            +
            + + % if request.session.peek_flash('error'): +
            + % for error in request.session.pop_flash('error'): +
            + + ${error} +
            + % endfor +
            + % endif + + % if request.session.peek_flash(): +
            + % for msg in request.session.pop_flash(): +
            + + ${msg|n} +
            + % endfor +
            + % endif + + ${self.body()} + +
            +
            +
            + +
            + + + +
            + + ${feedback_dialog()} + + + +<%def name="title()"> + +<%def name="content_title()"> +

            ${self.title()}

            + + +<%def name="header_core()"> + + ## ${h.stylesheet_link('https://cdnjs.cloudflare.com/ajax/libs/bulma/0.7.2/css/bulma.min.css')} + + ${self.core_javascript()} + ${self.extra_javascript()} + ${self.core_styles()} + ${self.extra_styles()} + + ## TODO: should this be elsewhere / more customizable? + % if dform is not Undefined: + <% resources = dform.get_widget_resources() %> + % for path in resources['js']: + ${h.javascript_link(request.static_url(path))} + % endfor + % for path in resources['css']: + ${h.stylesheet_link(request.static_url(path))} + % endfor + % endif + + +<%def name="core_javascript()"> + ${self.jquery()} + ${h.javascript_link(request.static_url('tailbone:static/js/lib/jquery.ui.menubar.js'))} + ${h.javascript_link(request.static_url('tailbone:static/js/lib/jquery.loadmask.min.js'))} + ${h.javascript_link(request.static_url('tailbone:static/js/lib/jquery.ui.timepicker.js'))} + + ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.js') + '?ver={}'.format(tailbone.__version__))} + ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.feedback.js') + '?ver={}'.format(tailbone.__version__))} + ${h.javascript_link(request.static_url('tailbone:static/js/jquery.ui.tailbone.js') + '?ver={}'.format(tailbone.__version__))} + + +<%def name="jquery()"> + ${h.javascript_link('https://code.jquery.com/jquery-1.12.4.min.js')} + ${h.javascript_link('https://code.jquery.com/ui/{}/jquery-ui.min.js'.format(request.rattail_config.get('tailbone', 'jquery_ui.version', default='1.11.4')))} + + +<%def name="extra_javascript()"> + +<%def name="core_styles()"> + ${h.stylesheet_link(request.static_url('tailbone:static/css/normalize.css'))} + ${self.jquery_theme()} + ${h.stylesheet_link(request.static_url('tailbone:static/css/jquery.ui.menubar.css'))} + ${h.stylesheet_link(request.static_url('tailbone:static/css/jquery.loadmask.css'))} + ${h.stylesheet_link(request.static_url('tailbone:static/css/jquery.ui.timepicker.css'))} + ${h.stylesheet_link(request.static_url('tailbone:static/css/jquery.ui.tailbone.css') + '?ver={}'.format(tailbone.__version__))} + ${h.stylesheet_link(request.static_url('tailbone:static/css/base.css') + '?ver={}'.format(tailbone.__version__))} + ${h.stylesheet_link(request.static_url('tailbone:static/css/layout.css') + '?ver={}'.format(tailbone.__version__))} + ${h.stylesheet_link(request.static_url('tailbone:static/css/grids.css') + '?ver={}'.format(tailbone.__version__))} + ${h.stylesheet_link(request.static_url('tailbone:static/css/filters.css') + '?ver={}'.format(tailbone.__version__))} + ${h.stylesheet_link(request.static_url('tailbone:static/css/forms.css') + '?ver={}'.format(tailbone.__version__))} + ${h.stylesheet_link(request.static_url('tailbone:static/css/diffs.css') + '?ver={}'.format(tailbone.__version__))} + + +<%def name="jquery_theme()"> + ${h.stylesheet_link('https://code.jquery.com/ui/1.11.4/themes/excite-bike/jquery-ui.css')} + + +<%def name="extra_styles()"> + +<%def name="wtfield(form, name, **kwargs)"> +
            + +
            + ${form[name](**kwargs)} +
            +
            + diff --git a/tailbone/util.py b/tailbone/util.py index 890cd778..bc891292 100644 --- a/tailbone/util.py +++ b/tailbone/util.py @@ -33,7 +33,9 @@ import pytz import humanize from rattail.time import timezone, make_utc +from rattail.files import resource_path +from pyramid.renderers import get_renderer from webhelpers2.html import HTML, tags @@ -115,3 +117,61 @@ def raw_datetime(config, value): kwargs['title'] = humanize.naturaltime(time_ago) return HTML.tag('span', **kwargs) + + +def set_app_theme(request, theme, session=None): + """ + Set the app theme. This modifies the *global* Mako template lookup + directory path, i.e. theme for all users will change immediately. + + This also saves the setting for the new theme, and updates the running app + registry settings with the new theme. + """ + from rattail.db import api + + theme = get_effective_theme(request.rattail_config, theme=theme, session=session) + theme_path = get_theme_template_path(request.rattail_config, theme=theme, session=session) + + # there's only one global template lookup; can get to it via any renderer + # but should *not* use /base.mako since that one is about to get volatile + renderer = get_renderer('/menu.mako') + lookup = renderer.lookup + + # overwrite first entry in lookup's directory list + lookup.directories[0] = theme_path + + # remove base template from lookup cache, so it will reload from new theme path + lookup._collection.pop('/base.mako', None) + + api.save_setting(session, 'tailbone.theme', theme) + request.registry.settings['tailbone.theme'] = theme + + +def get_theme_template_path(rattail_config, theme=None, session=None): + """ + Retrieves the template path for the given theme. + """ + theme = get_effective_theme(rattail_config, theme=theme, session=session) + theme_path = rattail_config.get('tailbone', 'theme.{}'.format(theme), + default='tailbone:templates/themes/{}'.format(theme)) + return resource_path(theme_path) + + +def get_effective_theme(rattail_config, theme=None, session=None): + """ + Validates and returns the "effective" theme. If you provide a theme, that + will be used; otherwise it is read from database setting. + """ + from rattail.db import api + + if not theme: + theme = api.get_setting(session, 'tailbone.theme') or 'default' + + # confirm requested theme is available + available = rattail_config.getlist('tailbone', 'themes', + default=['bobcat']) + available.append('default') + if theme not in available: + raise ValueError("theme not available: {}".format(theme)) + + return theme diff --git a/tailbone/views/common.py b/tailbone/views/common.py index 22401a04..14370ad4 100644 --- a/tailbone/views/common.py +++ b/tailbone/views/common.py @@ -42,6 +42,7 @@ import tailbone from tailbone import forms from tailbone.db import Session from tailbone.views import View +from tailbone.util import set_app_theme class Feedback(colander.Schema): @@ -125,6 +126,22 @@ class CommonView(View): ('Tailbone', tailbone.__version__), ]) + def change_theme(self): + """ + Simple view which can change user's visible UI theme, then redirect + user back to referring page. + """ + theme = self.request.params.get('theme') + if theme: + try: + set_app_theme(self.request, theme, session=Session()) + except Exception as error: + msg = "Failed to set theme: {}: {}".format(error.__class__.__name__, error) + self.request.session.flash(msg, 'error') + else: + self.request.session.flash("App theme has been changed to: {}".format(theme)) + return self.redirect(self.request.get_referrer()) + def feedback(self): """ Generic view to present/handle the user feedback form. @@ -188,6 +205,12 @@ class CommonView(View): config.add_route('mobile.about', '/mobile/about') config.add_view(cls, attr='about', route_name='mobile.about', renderer='/mobile/about.mako') + # change theme + config.add_tailbone_permission('common', 'common.change_app_theme', + "Change global App Template Theme") + config.add_route('change_theme', '/change-theme', request_method='POST') + config.add_view(cls, attr='change_theme', route_name='change_theme') + # feedback config.add_route('feedback', '/feedback', request_method='POST') config.add_view(cls, attr='feedback', route_name='feedback', renderer='json') From acaa83c31ae989121515bf98551a844afb497627 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 27 Nov 2018 17:53:24 -0600 Subject: [PATCH 1050/3196] Add some code comments --- tailbone/views/auth.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tailbone/views/auth.py b/tailbone/views/auth.py index 1ea2ea0d..86406aed 100644 --- a/tailbone/views/auth.py +++ b/tailbone/views/auth.py @@ -141,10 +141,15 @@ class AuthenticationView(View): This deletes/invalidates the current session and then redirects to the login page. """ + # truly logout the user headers = logout_user(self.request) + + # redirect to home page after login, if so configured if self.rattail_config.getbool('tailbone', 'home_after_logout', default=False): home = 'mobile.home' if mobile else 'home' return self.redirect(self.request.route_url(home), headers=headers) + + # otherwise redirect to referrer, with 'login' page as fallback login = 'mobile.login' if mobile else 'login' referrer = self.request.get_referrer(default=self.request.route_url(login)) return self.redirect(referrer, headers=headers) From 508359a939bceff8d465a44b091d00bd722afe15 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 27 Nov 2018 18:20:15 -0600 Subject: [PATCH 1051/3196] Fix template references for app_title make sure we only look to /base_meta.mako for that --- tailbone/templates/about.mako | 3 ++- tailbone/templates/home.mako | 5 +++-- tailbone/templates/login.mako | 3 ++- tailbone/templates/mobile/about.mako | 3 ++- tailbone/templates/mobile/base.mako | 11 ++++------- 5 files changed, 13 insertions(+), 12 deletions(-) diff --git a/tailbone/templates/about.mako b/tailbone/templates/about.mako index 83f8b3a6..8c0bd67a 100644 --- a/tailbone/templates/about.mako +++ b/tailbone/templates/about.mako @@ -1,7 +1,8 @@ ## -*- coding: utf-8 -*- <%inherit file="/base.mako" /> +<%namespace name="base_meta" file="/base_meta.mako" /> -<%def name="title()">About ${self.app_title()} +<%def name="title()">About ${base_meta.app_title()}

            ${project_title} ${project_version}

            diff --git a/tailbone/templates/home.mako b/tailbone/templates/home.mako index 4081e298..552ed1a2 100644 --- a/tailbone/templates/home.mako +++ b/tailbone/templates/home.mako @@ -1,5 +1,6 @@ ## -*- coding: utf-8; -*- <%inherit file="/base.mako" /> +<%namespace name="base_meta" file="/base_meta.mako" /> <%def name="title()">Home @@ -16,6 +17,6 @@ diff --git a/tailbone/templates/login.mako b/tailbone/templates/login.mako index 7ec87274..7f64eda9 100644 --- a/tailbone/templates/login.mako +++ b/tailbone/templates/login.mako @@ -1,5 +1,6 @@ ## -*- coding: utf-8; -*- <%inherit file="/base.mako" /> +<%namespace name="base_meta" file="/base_meta.mako" /> <%def name="title()">Login @@ -14,7 +15,7 @@ <%def name="logo()"> - ${h.image(image_url, "{} logo".format(capture(self.app_title)), id='logo', width=500)} + ${h.image(image_url, "{} logo".format(capture(base_meta.app_title)), id='logo', width=500)} <%def name="login_form()"> diff --git a/tailbone/templates/mobile/about.mako b/tailbone/templates/mobile/about.mako index 595a1d2b..bfa55379 100644 --- a/tailbone/templates/mobile/about.mako +++ b/tailbone/templates/mobile/about.mako @@ -1,7 +1,8 @@ ## -*- coding: utf-8 -*- <%inherit file="/mobile/base.mako" /> +<%namespace name="base_meta" file="/base_meta.mako" /> -<%def name="title()">About ${self.app_title()} +<%def name="title()">About ${base_meta.app_title()}

            ${project_title} ${project_version}

            diff --git a/tailbone/templates/mobile/base.mako b/tailbone/templates/mobile/base.mako index 43a4a4c0..11ee47ef 100644 --- a/tailbone/templates/mobile/base.mako +++ b/tailbone/templates/mobile/base.mako @@ -1,9 +1,10 @@ ## -*- coding: utf-8 -*- +<%namespace name="base_meta" file="/base_meta.mako" /> - ${self.global_title()} » ${self.title()} + ${base_meta.global_title()} » ${self.title()} ${self.jquery()} @@ -64,10 +65,6 @@ -<%def name="app_title()">Rattail Demo - -<%def name="global_title()">${"[STAGE] " if not request.rattail_config.production() else ''}${self.app_title()} - <%def name="page_url()">${request.current_route_url()} <%def name="page_title()">${self.title()} @@ -88,7 +85,7 @@ <%def name="mobile_header()">
            ${self.mobile_header_link()} -

            ${self.global_title()}

            +

            ${base_meta.global_title()}

            @@ -116,7 +113,7 @@
          • ${h.link_to("Become root", url('become_root'), **{'data-ajax': 'false'})}
          • % endif
          • ${h.link_to("Logout", url('mobile.logout'), **{'data-ajax': 'false'})}
          • -
          • ${h.link_to("About {}".format(capture(self.app_title)), url('mobile.about'))}
          • +
          • ${h.link_to("About {}".format(capture(base_meta.app_title)), url('mobile.about'))}
          • From c56eadc49bf878ddbedd0b1d8b797c483784a3e5 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 28 Nov 2018 15:37:35 -0600 Subject: [PATCH 1052/3196] Fix "delete object" form submit not real sure why that broke, but this is a better pattern anyway --- tailbone/templates/master/delete.mako | 20 +++----------------- 1 file changed, 3 insertions(+), 17 deletions(-) diff --git a/tailbone/templates/master/delete.mako b/tailbone/templates/master/delete.mako index da247e5c..6bd48fc4 100644 --- a/tailbone/templates/master/delete.mako +++ b/tailbone/templates/master/delete.mako @@ -1,22 +1,8 @@ -## -*- coding: utf-8 -*- +## -*- coding: utf-8; -*- <%inherit file="/base.mako" /> <%def name="title()">Delete ${model_title}: ${instance_title} -<%def name="head_tags()"> - ${parent.head_tags()} - - - <%def name="context_menu_items()">
          • ${h.link_to("Back to {}".format(model_title_plural), url(route_prefix))}
          • % if master.viewable and request.has_perm('{}.view'.format(permission_prefix)): @@ -35,11 +21,11 @@

            Are you sure about this?


            - ${h.form(request.current_route_url())} + ${h.form(request.current_route_url(), class_='autodisable')} ${h.csrf_token(request)}
            Whoops, nevermind... - + ${h.submit('submit', "Yes, please DELETE this data forever!")}
            ${h.end_form()} From 36f786f0eb8503840bf2151275aceaa987cf1718 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 28 Nov 2018 15:37:57 -0600 Subject: [PATCH 1053/3196] Clean up how we configure DB sessions on app startup not real sure if the old logic was a "problem" per se, but this cleanup seems warranted and (fingers crossed) shouldn't break anything --- tailbone/app.py | 27 ++++----------------------- 1 file changed, 4 insertions(+), 23 deletions(-) diff --git a/tailbone/app.py b/tailbone/app.py index 651612b0..8fe76c6e 100644 --- a/tailbone/app.py +++ b/tailbone/app.py @@ -31,10 +31,8 @@ import warnings import sqlalchemy as sa -import rattail.db from rattail.config import make_config from rattail.exceptions import ConfigurationError -from rattail.db.config import get_engines from rattail.db.types import GPCType from pyramid.config import Configurator @@ -63,27 +61,10 @@ def make_rattail_config(settings): settings['rattail_config'] = rattail_config rattail_config.configure_logging() - 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 - # 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']) - if hasattr(rattail_config, 'tempmon_engine'): - tailbone.db.TempmonSession.configure(bind=rattail_config.tempmon_engine) - if hasattr(rattail_config, 'trainwreck_engine'): - tailbone.db.TrainwreckSession.configure(bind=rattail_config.trainwreck_engine) + # configure database sessions + tailbone.db.Session.configure(bind=rattail_config.rattail_engine) + tailbone.db.TempmonSession.configure(bind=rattail_config.tempmon_engine) + tailbone.db.TrainwreckSession.configure(bind=rattail_config.trainwreck_engine) # Make sure rattail config object uses our scoped session, to avoid # unnecessary connections (and pooling limits). From 23ce2fb33c834dc70aea41eea4eca1f74b957239 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 28 Nov 2018 18:15:48 -0600 Subject: [PATCH 1054/3196] Add description, notes to default form_fields for batch views --- tailbone/views/batch/core.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index 5a786c82..d1f36c7b 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -98,13 +98,14 @@ class BatchMasterView(MasterView): form_fields = [ 'id', + 'description', + 'notes', 'created', 'created_by', 'rowcount', 'status_code', 'executed', 'executed_by', - 'purge', ] row_labels = { From 3a982f6e384554221780d3593a2ba9333f811a6c Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 28 Nov 2018 19:44:09 -0600 Subject: [PATCH 1055/3196] Fix `head_tags()` template inheritance bug this broke lots of things! --- tailbone/templates/base.mako | 4 +++- tailbone/templates/base_meta.mako | 2 -- tailbone/templates/themes/bobcat/base.mako | 4 +++- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/tailbone/templates/base.mako b/tailbone/templates/base.mako index f1becabd..476acd25 100644 --- a/tailbone/templates/base.mako +++ b/tailbone/templates/base.mako @@ -17,7 +17,7 @@ % endif - ${base_meta.head_tags()} + ${self.head_tags()} @@ -201,6 +201,8 @@ <%def name="extra_styles()"> +<%def name="head_tags()"> + <%def name="wtfield(form, name, **kwargs)">
            diff --git a/tailbone/templates/base_meta.mako b/tailbone/templates/base_meta.mako index 5ff48fad..17b4faba 100644 --- a/tailbone/templates/base_meta.mako +++ b/tailbone/templates/base_meta.mako @@ -8,8 +8,6 @@ -<%def name="head_tags()"> - <%def name="header_logo()"> <%def name="footer()"> diff --git a/tailbone/templates/themes/bobcat/base.mako b/tailbone/templates/themes/bobcat/base.mako index 53be3c40..4f908a15 100644 --- a/tailbone/templates/themes/bobcat/base.mako +++ b/tailbone/templates/themes/bobcat/base.mako @@ -17,7 +17,7 @@ % endif - ${base_meta.head_tags()} + ${self.head_tags()} @@ -204,6 +204,8 @@ <%def name="extra_styles()"> +<%def name="head_tags()"> + <%def name="wtfield(form, name, **kwargs)">
            From 991358615529e5586b7cb81848d38ded475060db Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 28 Nov 2018 19:57:10 -0600 Subject: [PATCH 1056/3196] Add basic 'excite-bike' theme no one will want this, surely... but useful for contrast --- tailbone/templates/themes/excite-bike/base.mako | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 tailbone/templates/themes/excite-bike/base.mako diff --git a/tailbone/templates/themes/excite-bike/base.mako b/tailbone/templates/themes/excite-bike/base.mako new file mode 100644 index 00000000..d4328621 --- /dev/null +++ b/tailbone/templates/themes/excite-bike/base.mako @@ -0,0 +1,8 @@ +## -*- coding: utf-8; -*- +<%inherit file="tailbone:templates/base.mako" /> + +<%def name="jquery_theme()"> + ${h.stylesheet_link('https://code.jquery.com/ui/1.11.4/themes/excite-bike/jquery-ui.css')} + + +${parent.body()} From 103f006cc0742e17ef3ec3a949a8f91f5928c2b5 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 28 Nov 2018 20:00:22 -0600 Subject: [PATCH 1057/3196] Turn on Bulma CSS framework for 'bobcat' theme still trying to match the default theme here, but only in spirit, and giving priority to doing things "the bulma way" if possible --- tailbone/static/themes/bobcat/css/base.css | 115 ++++++++++ tailbone/static/themes/bobcat/css/layout.css | 218 +++++++++++++++++++ tailbone/templates/themes/bobcat/base.mako | 125 ++++++----- 3 files changed, 409 insertions(+), 49 deletions(-) create mode 100644 tailbone/static/themes/bobcat/css/base.css create mode 100644 tailbone/static/themes/bobcat/css/layout.css diff --git a/tailbone/static/themes/bobcat/css/base.css b/tailbone/static/themes/bobcat/css/base.css new file mode 100644 index 00000000..1f6642c7 --- /dev/null +++ b/tailbone/static/themes/bobcat/css/base.css @@ -0,0 +1,115 @@ + +/* /\****************************** */ +/* * General */ +/* ******************************\/ */ + +/* * { */ +/* margin: 0px; */ +/* } */ + +/* body { */ +/* font-family: Verdana, Arial, sans-serif; */ +/* font-size: 11pt; */ +/* } */ + +/* a { */ +/* color: #0972a5; */ +/* text-decoration: none; */ +/* } */ + +/* a:hover { */ +/* text-decoration: underline; */ +/* } */ + +/* h1 { */ +/* margin-bottom: 15px; */ +/* } */ + +/* h2 { */ +/* font-size: 12pt; */ +/* margin: 20px auto 10px auto; */ +/* } */ + +/* li { */ +/* line-height: 2em; */ +/* } */ + +/* p { */ +/* margin-bottom: 5px; */ +/* } */ + +/* .left { */ +/* float: left; */ +/* text-align: left; */ +/* } */ + +/* .right { */ +/* text-align: right; */ +/* } */ + +/* .wrapper { */ +/* overflow: auto; */ +/* } */ + +/* div.buttons { */ +/* clear: both; */ +/* margin-top: 10px; */ +/* } */ + +/* div.dialog { */ +/* display: none; */ +/* } */ + +/* div.flash-message { */ +/* background-color: #dddddd; */ +/* margin-bottom: 8px; */ +/* padding: 3px; */ +/* } */ + +/* div.flash-messages div.ui-state-highlight { */ +/* padding: .3em; */ +/* margin-bottom: 8px; */ +/* } */ + +/* div.error-messages div.ui-state-error { */ +/* padding: .3em; */ +/* margin-bottom: 8px; */ +/* } */ + +/* .flash-messages, */ +/* .error-messages { */ +/* margin: 0.5em 0 0 0; */ +/* } */ + +/* ul.error { */ +/* color: #dd6666; */ +/* font-weight: bold; */ +/* padding: 0px; */ +/* } */ + +/* ul.error li { */ +/* list-style-type: none; */ +/* } */ + +/* /\****************************** */ +/* * jQuery UI tweaks */ +/* ******************************\/ */ + +/* ul.ui-menu { */ +/* max-height: 30em; */ +/* } */ + +/****************************** + * tweaks for root user + ******************************/ + +.menubar .root-user .ui-button-text, +.menubar .root-user.ui-menu-item a { + background-color: red; + color: black; + font-weight: bold; +} + +.menubar .root-user.ui-menu-item a { + padding-left: 1em; +} diff --git a/tailbone/static/themes/bobcat/css/layout.css b/tailbone/static/themes/bobcat/css/layout.css new file mode 100644 index 00000000..589cea9a --- /dev/null +++ b/tailbone/static/themes/bobcat/css/layout.css @@ -0,0 +1,218 @@ + +/* /\****************************** */ +/* * Main Layout */ +/* ******************************\/ */ + +/* html, body, #body-wrapper { */ +/* height: 100%; */ +/* } */ + +/* body > #body-wrapper { */ +/* height: auto; */ +/* min-height: 100%; */ +/* } */ + +/* #body-wrapper { */ +/* margin: 0 1em; */ +/* width: auto; */ +/* } */ + +/* #header { */ +/* height: 50px; */ +/* line-height: 50px; */ +/* } */ + +/* #body { */ +/* padding-top: 10px; */ +/* padding-bottom: 5em; */ +/* } */ + + +/****************************** + * header + ******************************/ + +header nav.level { + background-color: #eaeaea; + /* height: 60px; */ + line-height: 60px; + padding-left: 0.5em; + padding-right: 0.5em; +} + +header nav.level #header-logo { + display: inline-block; +} + +header nav.level .global-title { + font-size: 2em; + font-weight: bold; +} + +header nav.level #current-context { + font-size: 2em; + font-weight: bold; +} + +header nav.level #current-context span { + margin-right: 10px; +} + +/* header .global .grid-nav { */ +/* display: inline-block; */ +/* font-size: 16px; */ +/* font-weight: bold; */ +/* line-height: 60px; */ +/* margin-left: 5em; */ +/* } */ + +/* header .global .grid-nav .ui-button, */ +/* header .global .grid-nav span.viewing { */ +/* margin-left: 1em; */ +/* } */ + +#content-title h1 { + font-size: 2em; +} + +/* /\****************************** */ +/* * Logo */ +/* ******************************\/ */ + +/* #logo { */ +/* display: block; */ +/* margin: 40px auto; */ +/* } */ + + +/* /\**************************************** */ +/* * content */ +/* ****************************************\/ */ + +/* body > #body-wrapper { */ +/* margin: 0px; */ +/* position: relative; */ +/* } */ + +/* .content-wrapper { */ +/* height: 100%; */ +/* padding-bottom: 30px; */ +/* } */ + +/* #scrollpane { */ +/* height: 100%; */ +/* } */ + +/* #scrollpane .inner-content { */ +/* padding: 0 0.5em 0.5em 0.5em; */ +/* } */ + + +/****************************** + * context menu + ******************************/ + +/* TODO: should deprecate / remove in favor of some bulma approach (section?) */ +#context-menu { + float: right; + /* list-style-type: none; */ + margin: 0.5em; + text-align: right; + white-space: nowrap; +} + + +/* /\****************************** */ +/* * Panels */ +/* ******************************\/ */ + +/* .panel-wrapper { */ +/* float: left; */ +/* margin-right: 15px; */ +/* width: 40%; */ +/* } */ + +/* .panel, */ +/* .panel-grid { */ +/* border-left: 1px solid Black; */ +/* margin-bottom: 15px; */ +/* } */ + +/* .panel { */ +/* border-bottom: 1px solid Black; */ +/* border-right: 1px solid Black; */ +/* padding: 0px; */ +/* } */ + +/* .panel h2, */ +/* .panel-grid h2 { */ +/* border-bottom: 1px solid Black; */ +/* border-top: 1px solid Black; */ +/* padding: 5px; */ +/* margin: 0px; */ +/* } */ + +/* .panel-grid h2 { */ +/* border-right: 1px solid Black; */ +/* } */ + +/* .panel-body { */ +/* overflow: auto; */ +/* padding: 5px; */ +/* } */ + +/****************************** + * footer + ******************************/ + +/* TODO: should deprecate / remove in favor of some bulma footer */ +#footer { + border-top: 1px solid lightgray; + bottom: 0; + clear: both; + font-size: 9pt; + height: 20px; + left: 0; + line-height: 20px; + margin: -4em 0 0 0; + position: absolute; + text-align: center; + width: 100%; +} + +/****************************** + * feedback + ******************************/ + +#feedback-dialog { + display: none; +} + +#feedback-dialog p { + margin-top: 1em; +} + +#feedback-dialog .red { + color: red; + font-weight: bold; +} + +#feedback-dialog .field-wrapper { + margin-top: 1em; + padding: 0; +} + +#feedback-dialog .field { + margin-bottom: 0; + margin-top: 0.5em; +} + +#feedback-dialog .referrer .field { + clear: both; + float: none; + margin-top: 1em; +} + +#feedback-dialog textarea { + width: auto; +} diff --git a/tailbone/templates/themes/bobcat/base.mako b/tailbone/templates/themes/bobcat/base.mako index 4f908a15..1fce9db2 100644 --- a/tailbone/templates/themes/bobcat/base.mako +++ b/tailbone/templates/themes/bobcat/base.mako @@ -30,56 +30,82 @@ -
            - - ${base_meta.header_logo()} - ${base_meta.global_title()} - - % if master: - » - % if master.listing: - ${index_title} - % else: - ${h.link_to(index_title, index_url, class_='global')} - % if parent_url is not Undefined: - » - ${h.link_to(parent_title, parent_url, class_='global')} - % elif instance_url is not Undefined: - » - ${h.link_to(instance_title, instance_url, class_='global')} - % endif - % if master.viewing and grid_index: - ${grid_index_nav()} - % endif - % endif - % elif index_title: - » - ${index_title} - % endif + -
            + -
            + ## Page Title +
            +
            ${self.content_title()}
            - +
            @@ -130,13 +156,11 @@ <%def name="title()"> <%def name="content_title()"> -

            ${self.title()}

            +

            ${self.title()}

            <%def name="header_core()"> - ## ${h.stylesheet_link('https://cdnjs.cloudflare.com/ajax/libs/bulma/0.7.2/css/bulma.min.css')} - ${self.core_javascript()} ${self.extra_javascript()} ${self.core_styles()} @@ -184,14 +208,17 @@ <%def name="extra_javascript()"> <%def name="core_styles()"> - ${h.stylesheet_link(request.static_url('tailbone:static/css/normalize.css'))} + + ${h.stylesheet_link('https://cdnjs.cloudflare.com/ajax/libs/bulma/0.7.2/css/bulma.min.css')} + ${self.jquery_theme()} ${h.stylesheet_link(request.static_url('tailbone:static/css/jquery.ui.menubar.css'))} ${h.stylesheet_link(request.static_url('tailbone:static/css/jquery.loadmask.css'))} ${h.stylesheet_link(request.static_url('tailbone:static/css/jquery.ui.timepicker.css'))} ${h.stylesheet_link(request.static_url('tailbone:static/css/jquery.ui.tailbone.css') + '?ver={}'.format(tailbone.__version__))} - ${h.stylesheet_link(request.static_url('tailbone:static/css/base.css') + '?ver={}'.format(tailbone.__version__))} - ${h.stylesheet_link(request.static_url('tailbone:static/css/layout.css') + '?ver={}'.format(tailbone.__version__))} + + ${h.stylesheet_link(request.static_url('tailbone:static/themes/bobcat/css/base.css') + '?ver={}'.format(tailbone.__version__))} + ${h.stylesheet_link(request.static_url('tailbone:static/themes/bobcat/css/layout.css') + '?ver={}'.format(tailbone.__version__))} ${h.stylesheet_link(request.static_url('tailbone:static/css/grids.css') + '?ver={}'.format(tailbone.__version__))} ${h.stylesheet_link(request.static_url('tailbone:static/css/filters.css') + '?ver={}'.format(tailbone.__version__))} ${h.stylesheet_link(request.static_url('tailbone:static/css/forms.css') + '?ver={}'.format(tailbone.__version__))} @@ -199,7 +226,7 @@ <%def name="jquery_theme()"> - ${h.stylesheet_link('https://code.jquery.com/ui/1.11.4/themes/excite-bike/jquery-ui.css')} + ${h.stylesheet_link('https://code.jquery.com/ui/1.11.4/themes/dark-hive/jquery-ui.css')} <%def name="extra_styles()"> From 1fa56aa68363cbb7ddd36f0ae72fa0aa5076cd39 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 28 Nov 2018 22:09:35 -0600 Subject: [PATCH 1058/3196] Add Bulma-style footer to bobcat theme also refactor HTML element tree in general, for sake of bulma --- tailbone/static/js/tailbone.js | 9 - tailbone/static/themes/bobcat/css/layout.css | 19 -- tailbone/templates/base.mako | 7 + tailbone/templates/base_meta.mako | 4 +- tailbone/templates/themes/bobcat/base.mako | 229 +++++++++---------- 5 files changed, 124 insertions(+), 144 deletions(-) diff --git a/tailbone/static/js/tailbone.js b/tailbone/static/js/tailbone.js index bedd4ee4..868e0b7b 100644 --- a/tailbone/static/js/tailbone.js +++ b/tailbone/static/js/tailbone.js @@ -207,15 +207,6 @@ if (session_timeout) { $(function() { - /* - * Initialize the menu bar. - */ - $('ul.menubar').menubar({ - buttons: true, - menuIcon: true, - autoExpand: true - }); - /* * enhance buttons */ diff --git a/tailbone/static/themes/bobcat/css/layout.css b/tailbone/static/themes/bobcat/css/layout.css index 589cea9a..dfbd8312 100644 --- a/tailbone/static/themes/bobcat/css/layout.css +++ b/tailbone/static/themes/bobcat/css/layout.css @@ -161,25 +161,6 @@ header nav.level #current-context span { /* padding: 5px; */ /* } */ -/****************************** - * footer - ******************************/ - -/* TODO: should deprecate / remove in favor of some bulma footer */ -#footer { - border-top: 1px solid lightgray; - bottom: 0; - clear: both; - font-size: 9pt; - height: 20px; - left: 0; - line-height: 20px; - margin: -4em 0 0 0; - position: absolute; - text-align: center; - width: 100%; -} - /****************************** * feedback ******************************/ diff --git a/tailbone/templates/base.mako b/tailbone/templates/base.mako index 476acd25..cefcde91 100644 --- a/tailbone/templates/base.mako +++ b/tailbone/templates/base.mako @@ -160,6 +160,13 @@ var session_timeout = ${request.get_session_timeout() or 'null'}; var logout_url = '${request.route_url('logout')}'; var noop_url = '${request.route_url('noop')}'; + $(function() { + $('ul.menubar').menubar({ + buttons: true, + menuIcon: true, + autoExpand: true + }); + }); % if expose_theme_picker and request.has_perm('common.change_app_theme'): $(function() { $('#theme-picker').change(function() { diff --git a/tailbone/templates/base_meta.mako b/tailbone/templates/base_meta.mako index 17b4faba..acc6aa5b 100644 --- a/tailbone/templates/base_meta.mako +++ b/tailbone/templates/base_meta.mako @@ -11,5 +11,7 @@ <%def name="header_logo()"> <%def name="footer()"> - powered by ${h.link_to("Rattail", url('about'))} +

            + powered by ${h.link_to("Rattail", url('about'))} +

            diff --git a/tailbone/templates/themes/bobcat/base.mako b/tailbone/templates/themes/bobcat/base.mako index 1fce9db2..fb2cd145 100644 --- a/tailbone/templates/themes/bobcat/base.mako +++ b/tailbone/templates/themes/bobcat/base.mako @@ -21,135 +21,124 @@ -
            +
            + -
            - - -
            +
            +
            - ## Page Title -
            -
            - ${self.content_title()} + ## Theme Picker + % if expose_theme_picker and request.has_perm('common.change_app_theme'): +
            + ${h.form(url('change_theme'), name="theme_changer", method="post")} + ${h.csrf_token(request)} + Theme: + ${h.select('theme', theme, options=theme_picker_options, id='theme-picker')} + ${h.end_form()} +
            + % endif + + ## Help + % if help_url is not Undefined and help_url: +
            + ${h.link_to("Help", help_url, target='_blank', class_='button')} +
            + % endif + + ## Feedback +
            + +
            + +
            + + + + ## Page Title +
            +
            + ${self.content_title()} +
            +
            + + ## Page Body +
            + + % if request.session.peek_flash('error'): +
            + % for error in request.session.pop_flash('error'): +
            + + ${error}
            -
            + % endfor +
            + % endif -
            - -
            -
            -
            + % if request.session.peek_flash(): +
            + % for msg in request.session.pop_flash(): +
            + + ${msg|n} +
            + % endfor +
            + % endif - % if request.session.peek_flash('error'): -
            - % for error in request.session.pop_flash('error'): -
            - - ${error} -
            - % endfor -
            - % endif + ${self.body()} + - % if request.session.peek_flash(): -
            - % for msg in request.session.pop_flash(): -
            - - ${msg|n} -
            - % endfor -
            - % endif + ## Feedback Dialog + ${feedback_dialog()} - ${self.body()} - -
            -
            -
            - -
            - - - - ${feedback_dialog()} @@ -194,6 +183,16 @@ }); }); % endif + + ## TODO: replace this with bulma menu! + $(function() { + $('ul.menubar').menubar({ + buttons: true, + menuIcon: true, + autoExpand: true + }); + }); + ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.js') + '?ver={}'.format(tailbone.__version__))} ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.feedback.js') + '?ver={}'.format(tailbone.__version__))} From 0ba1d65b118cde727015c0ae25c62474d19ef264 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 28 Nov 2018 22:35:22 -0600 Subject: [PATCH 1059/3196] Use bulma-style notifications for bobcat theme instead of previous one, which was sort of pseudo-jquery i guess? --- tailbone/templates/themes/bobcat/base.mako | 32 ++++++++++------------ 1 file changed, 14 insertions(+), 18 deletions(-) diff --git a/tailbone/templates/themes/bobcat/base.mako b/tailbone/templates/themes/bobcat/base.mako index fb2cd145..46f11038 100644 --- a/tailbone/templates/themes/bobcat/base.mako +++ b/tailbone/templates/themes/bobcat/base.mako @@ -78,14 +78,14 @@
            % endif - ## Help + ## Help Button % if help_url is not Undefined and help_url:
            ${h.link_to("Help", help_url, target='_blank', class_='button')}
            % endif - ## Feedback + ## Feedback Button
            @@ -105,25 +105,21 @@
            % if request.session.peek_flash('error'): -
            - % for error in request.session.pop_flash('error'): -
            - - ${error} -
            - % endfor -
            + % for error in request.session.pop_flash('error'): +
            + + ${error|n} +
            + % endfor % endif % if request.session.peek_flash(): -
            - % for msg in request.session.pop_flash(): -
            - - ${msg|n} -
            - % endfor -
            + % for msg in request.session.pop_flash(): +
            + + ${msg|n} +
            + % endfor % endif ${self.body()} From a76a7dd54c79810e61ac034d56d108baedf5d07e Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 28 Nov 2018 23:50:50 -0600 Subject: [PATCH 1060/3196] Add support for new Bulma 'navbar' menu for bobcat theme unfortunately the /menu.mako can't be shared (yet?) so apps must maintain a separate one if they wish to support this new theme. also, now when changing app theme we totally clear the lookup object's template cache. this was necessary for sake of /menu.mako but seems to be 'safe' so far --- tailbone/static/themes/bobcat/css/base.css | 13 ++++++------- tailbone/templates/themes/bobcat/base.mako | 7 ++++--- tailbone/util.py | 4 ++-- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/tailbone/static/themes/bobcat/css/base.css b/tailbone/static/themes/bobcat/css/base.css index 1f6642c7..758ea304 100644 --- a/tailbone/static/themes/bobcat/css/base.css +++ b/tailbone/static/themes/bobcat/css/base.css @@ -103,13 +103,12 @@ * tweaks for root user ******************************/ -.menubar .root-user .ui-button-text, -.menubar .root-user.ui-menu-item a { +.navbar .navbar-end .navbar-link.root-user, +.navbar .navbar-end .navbar-link.root-user:hover, +.navbar .navbar-end .navbar-link.root-user.is_active, +.navbar .navbar-end .navbar-item.root-user, +.navbar .navbar-end .navbar-item.root-user:hover, +.navbar .navbar-end .navbar-item.root-user.is_active { background-color: red; - color: black; font-weight: bold; } - -.menubar .root-user.ui-menu-item a { - padding-left: 1em; -} diff --git a/tailbone/templates/themes/bobcat/base.mako b/tailbone/templates/themes/bobcat/base.mako index 46f11038..de32daae 100644 --- a/tailbone/templates/themes/bobcat/base.mako +++ b/tailbone/templates/themes/bobcat/base.mako @@ -22,10 +22,11 @@
            -
            From c1eaf288128b17da92d31779a08310273555a4a2 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 29 Nov 2018 14:51:57 -0600 Subject: [PATCH 1065/3196] Add support for top-level links for simple menus also add 'messaging_enabled' to global template context, so can include (or not) that stuff in the user menu --- tailbone/menus.py | 72 ++++++++++++---------- tailbone/subscribers.py | 4 ++ tailbone/templates/menu.mako | 34 ++++++---- tailbone/templates/themes/bobcat/base.mako | 38 +++++++----- 4 files changed, 91 insertions(+), 57 deletions(-) diff --git a/tailbone/menus.py b/tailbone/menus.py index ea7a86b3..f28574bf 100644 --- a/tailbone/menus.py +++ b/tailbone/menus.py @@ -33,12 +33,14 @@ from rattail.util import import_module_path class MenuGroup(Object): title = None items = None + is_link = False class MenuItem(Object): title = None url = None target = None + is_link = True is_sep = False @@ -62,44 +64,52 @@ def make_simple_menus(request): final_menus = [] for topitem in raw_menus: - # figure out which ones the user has permission to access - allowed = [] - for item in topitem['items']: + if topitem.get('type') == 'link': + final_menus.append( + MenuItem(title=topitem['title'], + url=topitem['url'], + target=topitem.get('target'))) - if item.get('type') == 'sep': - allowed.append(item) + else: # assuming 'menu' type - if item.get('perm'): - if request.has_perm(item['perm']): - allowed.append(item) - else: - allowed.append(item) + # figure out which ones the user has permission to access + allowed = [] + for item in topitem['items']: - if allowed: - - # user must have access to something; construct items for the menu - menu_items = [] - for item in allowed: - - # separator if item.get('type') == 'sep': - if menu_items and not menu_items[-1].is_sep: - menu_items.append(MenuSeparator()) + allowed.append(item) - # menu item + if item.get('perm'): + if request.has_perm(item['perm']): + allowed.append(item) else: - menu_items.append( - MenuItem(title=item['title'], - url=item['url'], - target=item.get('target'))) + allowed.append(item) - # remove final separator if present - if menu_items and menu_items[-1].is_sep: - menu_items.pop() + if allowed: - # only add if we wound up with something - if menu_items: - final_menus.append( - MenuGroup(title=topitem['title'], items=menu_items)) + # user must have access to something; construct items for the menu + menu_items = [] + for item in allowed: + + # separator + if item.get('type') == 'sep': + if menu_items and not menu_items[-1].is_sep: + menu_items.append(MenuSeparator()) + + # menu item + else: + menu_items.append( + MenuItem(title=item['title'], + url=item['url'], + target=item.get('target'))) + + # remove final separator if present + if menu_items and menu_items[-1].is_sep: + menu_items.pop() + + # only add if we wound up with something + if menu_items: + final_menus.append( + MenuGroup(title=topitem['title'], items=menu_items)) return final_menus diff --git a/tailbone/subscribers.py b/tailbone/subscribers.py index 6af90cbc..1a610ef2 100644 --- a/tailbone/subscribers.py +++ b/tailbone/subscribers.py @@ -120,6 +120,10 @@ def before_render(event): if request.rattail_config.getbool('tailbone', 'menus.simple', default=False): renderer_globals['menus'] = make_simple_menus(request) + # TODO: ugh, same deal here + renderer_globals['messaging_enabled'] = request.rattail_config.getbool( + 'tailbone', 'messaging.enabled', default=True) + def add_inbox_count(event): """ diff --git a/tailbone/templates/menu.mako b/tailbone/templates/menu.mako index 29fdd229..7549e763 100644 --- a/tailbone/templates/menu.mako +++ b/tailbone/templates/menu.mako @@ -4,30 +4,40 @@ % for topitem in menus:
          • - ${topitem.title} -
              - % for subitem in topitem.items: - % if subitem.is_sep: -
            • -
            • - % else: -
            • ${h.link_to(subitem.title, subitem.url, target=subitem.target)}
            • - % endif - % endfor -
            + % if topitem.is_link: + ${h.link_to(topitem.title, topitem.url, target=topitem.target)} + % else: + ${topitem.title} +
              + % for subitem in topitem.items: + % if subitem.is_sep: +
            • -
            • + % else: +
            • ${h.link_to(subitem.title, subitem.url, target=subitem.target)}
            • + % endif + % endfor +
            + % endif
          • % endfor ## User Menu % if request.user:
          • - ${request.user}${" ({})".format(inbox_count) if inbox_count else ''} + % if messaging_enabled: + ${request.user}${" ({})".format(inbox_count) if inbox_count else ''} + % else: + ${request.user} + % endif
              % if request.is_root:
            • ${h.link_to("Stop being root", url('stop_root'))}
            • % elif request.is_admin:
            • ${h.link_to("Become root", url('become_root'))}
            • % endif -
            • ${h.link_to("Messages{}".format(" ({})".format(inbox_count) if inbox_count else ''), url('messages.inbox'))}
            • + % if messaging_enabled: +
            • ${h.link_to("Messages{}".format(" ({})".format(inbox_count) if inbox_count else ''), url('messages.inbox'))}
            • + % endif
            • ${h.link_to("Change Password", url('change_password'))}
            • ${h.link_to("Logout", url('logout'))}
            diff --git a/tailbone/templates/themes/bobcat/base.mako b/tailbone/templates/themes/bobcat/base.mako index 1e11aebb..dd1217b8 100644 --- a/tailbone/templates/themes/bobcat/base.mako +++ b/tailbone/templates/themes/bobcat/base.mako @@ -27,18 +27,22 @@ @@ -47,14 +51,20 @@ ## User Menu % if request.user:
          • - % for i, (status, count) in enumerate(status_breakdown): - - - - - % endfor -
            ${status}${count}
            -
            - % else: -

            Nothing to report yet.

            - % endif -
            -
            -% endif +
            + ${form.render(form_id='batch-form', buttons=capture(buttons))|n} +
            -
            - ${form.render(form_id='batch-form', buttons=capture(buttons))|n} -
            +
            + + % if status_breakdown is not Undefined and status_breakdown is not None: +
            +

            Row Status Breakdown

            +
            + % if status_breakdown: +
            + + % for i, (status, count) in enumerate(status_breakdown): + + + + + % endfor +
            ${status}${count}
            +
            + % else: +

            Nothing to report yet.

            + % endif +
            +
            + % endif + +
              + ${self.context_menu_items()} +
            + +
            + +
            ${rows_grid|n} From fe35986432b5fc6bcb33b13b410fbb1a326b70d4 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 30 Nov 2018 19:24:23 -0600 Subject: [PATCH 1071/3196] Expose `old_price_margin` field for pricing batch rows --- tailbone/views/batch/pricing.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tailbone/views/batch/pricing.py b/tailbone/views/batch/pricing.py index 1ac5b95b..39ef84e0 100644 --- a/tailbone/views/batch/pricing.py +++ b/tailbone/views/batch/pricing.py @@ -123,8 +123,9 @@ class PricingBatchView(BatchMasterView): 'new_price', 'price_diff', 'price_diff_percent', - 'price_margin', 'price_markup', + 'price_margin', + 'old_price_margin', 'margin_diff', 'status_code', 'status_text', From 8192b19858276de44dce2705671efcad99bc3d76 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 2 Dec 2018 15:35:42 -0600 Subject: [PATCH 1072/3196] Update changelog --- CHANGES.rst | 49 ++++++++++++++++++++++++++++++++++++++++++++ tailbone/_version.py | 2 +- 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index aa571939..17749007 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,55 @@ CHANGELOG ========= +0.8.0 (2018-12-02) +------------------ + +This version begins the "serious" efforts in pursuit of REST API, Vue.js, Bulma +and related technologies. + +* Use sqlalchemy-filters package for REST API collection_get. + +* Refactor API collection_get to work with vue-tables-2. + +* Remove some relationship fields when creating new Person. + +* Fix bug in receiving template when truck dump not enabled. + +* Tweak default "model title" logic for master view. + +* Add better support for "make import batch from file" pattern. + +* Fix download filename when it contains spaces. + +* Add "min % diff" option for pricing batch from products query. + +* Allow override of products query when making batch from it. + +* Use empty string instead of null as fallback value, for pricing rows CSV. + +* Add very basic Vue.js grid/index experiment for Users table. + +* Add patterns for joining tables in API list methods. + +* Add template "theme" feature, albeit global. + +* Clean up how we configure DB sessions on app startup. + +* Add description, notes to default form_fields for batch views. + +* Add basic 'excite-bike' theme. + +* Use Bulma CSS and some components for 'bobcat' theme. + +* Add basic support for "simple menus". + +* Refactor default theme re: "context menu" and "object helper" styles. + +* Use 4 decimal places when calculating hours for worked shift excel download. + +* Expose ``old_price_margin`` field for pricing batch rows. + + 0.7.50 (2018-11-19) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 2d6c96e6..f29c5cd3 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.7.50' +__version__ = '0.8.0' From bef7a2af36bcec54d2d8318bd22a541dcb245dd2 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 4 Dec 2018 18:56:20 -0600 Subject: [PATCH 1073/3196] Expose new "sync me" flag for LabelProfile settings --- tailbone/views/labels/profiles.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tailbone/views/labels/profiles.py b/tailbone/views/labels/profiles.py index 25ecfa0e..a3f051f5 100644 --- a/tailbone/views/labels/profiles.py +++ b/tailbone/views/labels/profiles.py @@ -48,6 +48,7 @@ class ProfilesView(MasterView): 'code', 'description', 'visible', + 'sync_me', ] form_fields = [ @@ -58,6 +59,7 @@ class ProfilesView(MasterView): 'formatter_spec', 'format', 'visible', + 'sync_me', ] def configure_grid(self, g): From 6907fbe844c37e372020a99184764bc0e49ae7ac Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 10 Dec 2018 18:34:40 -0600 Subject: [PATCH 1074/3196] 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 17749007..4ba3ac2b 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.8.1 (2018-12-10) +------------------ + +* Expose new "sync me" flag for LabelProfile settings. + + 0.8.0 (2018-12-02) ------------------ diff --git a/tailbone/_version.py b/tailbone/_version.py index f29c5cd3..38532fe2 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.0' +__version__ = '0.8.1' From 841dda903f4dd9af18346980cdd907bd03875e11 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 12 Dec 2018 15:07:18 -0600 Subject: [PATCH 1075/3196] Refactor product view template to use flexbox styles finally, the layout is reasonably clean and should stay that way... --- tailbone/static/css/layout.css | 8 +-- tailbone/templates/products/view.mako | 81 +++++++++++++-------------- 2 files changed, 40 insertions(+), 49 deletions(-) diff --git a/tailbone/static/css/layout.css b/tailbone/static/css/layout.css index cedd4d55..48c607e9 100644 --- a/tailbone/static/css/layout.css +++ b/tailbone/static/css/layout.css @@ -170,16 +170,10 @@ body > #body-wrapper { * Panels ******************************/ -.panel-wrapper { - float: left; - margin-right: 15px; - width: 40%; -} - .panel, .panel-grid { border-left: 1px solid Black; - margin-bottom: 15px; + margin-bottom: 1em; } .panel { diff --git a/tailbone/templates/products/view.mako b/tailbone/templates/products/view.mako index d734bb41..db73208f 100644 --- a/tailbone/templates/products/view.mako +++ b/tailbone/templates/products/view.mako @@ -1,55 +1,52 @@ ## -*- coding: utf-8; -*- <%inherit file="/master/view.mako" /> -<%def name="extra_styles()"> - ${parent.extra_styles()} - - - ############################## ## page body ############################## -
            -