From 32d256932e1353b9751463f603755948e8df17c5 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 11 Jul 2017 20:57:31 -0500 Subject: [PATCH] Various tweaks to support mobile inventory batches still not fully there I think, but pretty close.. --- tailbone/static/js/tailbone.mobile.js | 71 ++++++---- .../mobile/batch/inventory/create.mako | 6 + .../mobile/batch/inventory/index.mako | 10 ++ .../mobile/batch/inventory/view.mako | 14 +- .../mobile/batch/inventory/view_row.mako | 61 +++++++- tailbone/templates/mobile/keypad.mako | 39 +++++ tailbone/templates/mobile/master/create.mako | 8 ++ tailbone/views/batch/core.py | 12 +- tailbone/views/inventory.py | 133 +++++++++++++++++- tailbone/views/master.py | 26 +++- 10 files changed, 341 insertions(+), 39 deletions(-) create mode 100644 tailbone/templates/mobile/batch/inventory/create.mako create mode 100644 tailbone/templates/mobile/batch/inventory/index.mako create mode 100644 tailbone/templates/mobile/keypad.mako create mode 100644 tailbone/templates/mobile/master/create.mako diff --git a/tailbone/static/js/tailbone.mobile.js b/tailbone/static/js/tailbone.mobile.js index 4bcb54db..9a377e18 100644 --- a/tailbone/static/js/tailbone.mobile.js +++ b/tailbone/static/js/tailbone.mobile.js @@ -102,42 +102,49 @@ $(document).on('click', '#datasync-restart', function() { }); -// handle global keypress on receiving "row" page, for sake of scanner wedge +// handle global keypress on product batch "row" page, for sake of scanner wedge +var product_batch_routes = [ + 'mobile.batch.inventory.view', + 'mobile.receiving.view', +]; $(document).on('keypress', function(event) { - if ($('.ui-page-active [role="main"]').data('route') == 'mobile.receiving.view') { - var upc = $('#upc-search'); - if (upc.length) { - if (upc.is(':focus')) { - if (event.which == 13) { - if (upc.val()) { - $.mobile.navigate(upc.data('url') + '?upc=' + upc.val()); + var current_route = $('.ui-page-active [role="main"]').data('route'); + for (var route of product_batch_routes) { + if (current_route == route) { + var upc = $('.ui-page-active #upc-search'); + if (upc.length) { + if (upc.is(':focus')) { + if (event.which == 13) { + if (upc.val()) { + $.mobile.navigate(upc.data('url') + '?upc=' + upc.val()); + } } - } - } else { - if (event.which >= 48 && event.which <= 57) { // numeric (qwerty) - upc.val(upc.val() + event.key); - // TODO: these codes are correct for 'keydown' but apparently not 'keypress' ? - // } else if (event.which >= 96 && event.which <= 105) { // numeric (10-key) - // upc.val(upc.val() + event.key); - } else if (event.which == 13) { - if (upc.val()) { - $.mobile.navigate(upc.data('url') + '?upc=' + upc.val()); + } else { + if (event.which >= 48 && event.which <= 57) { // numeric (qwerty) + upc.val(upc.val() + event.key); + // TODO: these codes are correct for 'keydown' but apparently not 'keypress' ? + // } else if (event.which >= 96 && event.which <= 105) { // numeric (10-key) + // upc.val(upc.val() + event.key); + } else if (event.which == 13) { + if (upc.val()) { + $.mobile.navigate(upc.data('url') + '?upc=' + upc.val()); + } } + return false; } - return false; } } } }); -// handle numeric buttons for receiving -// $(document).on('click', '#receiving-quantity-keypad-thingy .ui-btn', function() { -$(document).on('click', '#receiving-quantity-keypad-thingy .keypad-button', function() { - var quantity = $('.receiving-quantity'); +// 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'); + var quantity = keypad.find('.keypad-quantity'); var value = quantity.text(); var key = $(this).text(); - var changed = $('#receiving-quantity-keypad-thingy').data('changed'); + var changed = keypad.data('changed'); if (key == 'Del') { if (value.length == 1) { quantity.text('0'); @@ -166,7 +173,7 @@ $(document).on('click', '#receiving-quantity-keypad-thingy .keypad-button', func } } if (changed) { - $('#receiving-quantity-keypad-thingy').data('changed', true); + keypad.data('changed', true); } }); @@ -214,3 +221,17 @@ $(document).on('click', '.receiving-actions button', function() { } } }); + + +// handle inventory save button +$(document).on('click', '.inventory-actions button.save', function() { + var form = $(this).parents('form:first'); + var uom = form.find('[name="keypad-uom"]:checked').val(); + var qty = form.find('.keypad-quantity').text(); + if (uom == 'CS') { + form.find('input[name="cases"]').val(qty); + } else { // units + form.find('input[name="units"]').val(qty); + } + form.submit(); +}); diff --git a/tailbone/templates/mobile/batch/inventory/create.mako b/tailbone/templates/mobile/batch/inventory/create.mako new file mode 100644 index 00000000..99c8106d --- /dev/null +++ b/tailbone/templates/mobile/batch/inventory/create.mako @@ -0,0 +1,6 @@ +## -*- coding: utf-8; -*- +<%inherit file="/mobile/master/create.mako" /> + +<%def name="title()">${h.link_to("Inventory", url('mobile.batch.inventory'))} » New Batch + +${parent.body()} diff --git a/tailbone/templates/mobile/batch/inventory/index.mako b/tailbone/templates/mobile/batch/inventory/index.mako new file mode 100644 index 00000000..a1fc7b80 --- /dev/null +++ b/tailbone/templates/mobile/batch/inventory/index.mako @@ -0,0 +1,10 @@ +## -*- coding: utf-8; -*- +<%inherit file="/mobile/master/index.mako" /> + +<%def name="title()">Inventory + +% if request.has_perm('batch.inventory.create'): + ${h.link_to("New Inventory Batch", url('mobile.batch.inventory.create'), class_='ui-btn ui-corner-all')} +% endif + +${parent.body()} diff --git a/tailbone/templates/mobile/batch/inventory/view.mako b/tailbone/templates/mobile/batch/inventory/view.mako index 0b99e1a5..6847eebe 100644 --- a/tailbone/templates/mobile/batch/inventory/view.mako +++ b/tailbone/templates/mobile/batch/inventory/view.mako @@ -1,6 +1,16 @@ ## -*- coding: utf-8; -*- <%inherit file="/mobile/newbatch/view.mako" /> -<%def name="title()">${h.link_to("Inventory", url('mobile.batch.inventory'))} » ${instance.id_str} +<%def name="title()">${h.link_to("Inventory", url('mobile.batch.inventory'))} » ${batch.id_str} -${parent.body()} +${form.render()|n} + +% if not batch.executed and not batch.complete: +
+ ${h.text('upc-search', class_='inventory-upc-search', placeholder="Enter UPC", autocomplete='off', **{'data-type': 'search', 'data-url': url('mobile.batch.inventory.row_from_upc', uuid=batch.uuid)})} +% endif + +% if master.has_rows: +
+ ${grid.render_complete()|n} +% endif diff --git a/tailbone/templates/mobile/batch/inventory/view_row.mako b/tailbone/templates/mobile/batch/inventory/view_row.mako index 58d0373f..50870075 100644 --- a/tailbone/templates/mobile/batch/inventory/view_row.mako +++ b/tailbone/templates/mobile/batch/inventory/view_row.mako @@ -1,7 +1,64 @@ ## -*- coding: utf-8; -*- <%inherit file="/mobile/newbatch/view_row.mako" /> +<%namespace file="/mobile/keypad.mako" import="keypad" /> ## TODO: this is broken for actual page (header) title -<%def name="title()">${h.link_to("Inventory", url('mobile.batch.inventory'))} » ${h.link_to(instance.batch.id_str, url('mobile.batch.inventory.view', uuid=instance.batch_uuid))} » row ${row.sequence} +<%def name="title()">${h.link_to("Inventory", url('mobile.batch.inventory'))} » ${h.link_to(instance.batch.id_str, url('mobile.batch.inventory.view', uuid=instance.batch_uuid))} » ${row.upc.pretty()} -${parent.body()} +<% + unit_uom = 'LB' if row.product and row.product.weighed else 'EA' + + if row.cases: + uom = 'CS' + elif row.units: + uom = 'EA' + elif row.case_quantity: + uom = 'CS' + else: + uom = 'EA' +%> + +
+
+ % if instance.product: +

${row.brand_name or ""}

+

${row.description} ${row.size}

+

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

+ % else: +

${row.description}

+ % endif +
+
+ ${h.image(product_image_url, "product image")} +
+
+ +

+ currently:  + % if uom == 'CS': + ${h.pretty_quantity(row.cases or 0)} + % else: + ${h.pretty_quantity(row.units or 0)} + % endif + ${uom} +

+ +% if not row.batch.executed and not row.batch.complete: + + ${h.form(request.current_route_url())} + ${h.csrf_token(request)} + ${h.hidden('row', value=row.uuid)} + ${h.hidden('cases')} + ${h.hidden('units')} + + ${keypad(unit_uom, uom, quantity=row.cases or row.units or 1)} + +
+ + + ${h.link_to("Cancel", url('mobile.batch.inventory.view', uuid=row.batch.uuid), class_='ui-btn ui-btn-inline ui-corner-all')} +
+ + ${h.end_form()} + +% endif diff --git a/tailbone/templates/mobile/keypad.mako b/tailbone/templates/mobile/keypad.mako new file mode 100644 index 00000000..21b70b58 --- /dev/null +++ b/tailbone/templates/mobile/keypad.mako @@ -0,0 +1,39 @@ +## -*- coding: utf-8; -*- + +<%def name="keypad(unit_uom, selected_uom, quantity=1)"> +
+ + + + + + + + + + + + + + + + + + + + + + + + +
${h.link_to("7", '#', class_='keypad-button ui-btn ui-btn-inline ui-corner-all')}${h.link_to("8", '#', class_='keypad-button ui-btn ui-btn-inline ui-corner-all')}${h.link_to("9", '#', class_='keypad-button ui-btn ui-btn-inline ui-corner-all')}
${h.link_to("4", '#', class_='keypad-button ui-btn ui-btn-inline ui-corner-all')}${h.link_to("5", '#', class_='keypad-button ui-btn ui-btn-inline ui-corner-all')}${h.link_to("6", '#', class_='keypad-button ui-btn ui-btn-inline ui-corner-all')}
${h.link_to("1", '#', class_='keypad-button ui-btn ui-btn-inline ui-corner-all')}${h.link_to("2", '#', class_='keypad-button ui-btn ui-btn-inline ui-corner-all')}${h.link_to("3", '#', class_='keypad-button ui-btn ui-btn-inline ui-corner-all')}
${h.link_to("0", '#', class_='keypad-button ui-btn ui-btn-inline ui-corner-all')}${h.link_to(".", '#', class_='keypad-button ui-btn ui-btn-inline ui-corner-all')}${h.link_to("Del", '#', class_='keypad-button ui-btn ui-btn-inline ui-corner-all')}
+ +
+ + + ${h.radio('keypad-uom', value='CS', checked=selected_uom == 'CS', label="CS")} + ${h.radio('keypad-uom', value=unit_uom, checked=selected_uom == unit_uom, label=unit_uom)} +
+ +
+ diff --git a/tailbone/templates/mobile/master/create.mako b/tailbone/templates/mobile/master/create.mako new file mode 100644 index 00000000..9bcca732 --- /dev/null +++ b/tailbone/templates/mobile/master/create.mako @@ -0,0 +1,8 @@ +## -*- coding: utf-8; -*- +<%inherit file="/mobile/base.mako" /> + +<%def name="title()">New ${model_title} + +
+ ${form.render()|n} +
diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index a27d65ed..821e0693 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -204,7 +204,7 @@ class BatchMasterView(MasterView): 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") + 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.notes.set(renderer=fa.TextAreaFieldRenderer, size=(80, 10)) @@ -322,6 +322,7 @@ class BatchMasterView(MasterView): kwargs['notes'] = batch.notes if hasattr(batch, 'filename'): kwargs['filename'] = batch.filename + kwargs['complete'] = batch.complete return kwargs # TODO: deprecate / remove this (is it used at all now?) @@ -338,13 +339,13 @@ class BatchMasterView(MasterView): """ return True - def redirect_after_create(self, batch): + def redirect_after_create(self, batch, mobile=False): if self.handler.should_populate(batch): - return self.redirect(self.get_action_url('prefill', batch)) + return self.redirect(self.get_action_url('prefill', batch, mobile=mobile)) elif self.refresh_after_create: - return self.redirect(self.get_action_url('refresh', batch)) + return self.redirect(self.get_action_url('refresh', batch, mobile=mobile)) else: - return self.redirect(self.get_action_url('view', batch)) + return self.redirect(self.get_action_url('view', batch, mobile=mobile)) # TODO: some of this at least can go to master now right? def edit(self): @@ -429,6 +430,7 @@ class BatchMasterView(MasterView): def get_mobile_row_data(self, batch): return super(BatchMasterView, self).get_mobile_row_data(batch)\ .order_by(self.model_row_class.sequence) + def redirect_after_edit(self, batch): """ If refresh flag is set, do that; otherwise go (back) to view/edit page. diff --git a/tailbone/views/inventory.py b/tailbone/views/inventory.py index 4d471304..5c56c673 100644 --- a/tailbone/views/inventory.py +++ b/tailbone/views/inventory.py @@ -26,10 +26,16 @@ Views for inventory batches from __future__ import unicode_literals, absolute_import -from rattail.db import model +import re + +from rattail import pod +from rattail.db import model, api from rattail.time import localtime +from rattail.gpc import GPC +from rattail.util import pretty_quantity import formalchemy as fa +import formencode as fe from webhelpers2.html import tags from tailbone import forms @@ -46,7 +52,7 @@ class InventoryBatchView(BatchMasterView): route_prefix = 'batch.inventory' url_prefix = '/batch/inventory' creatable = False - editable = False + mobile_creatable = True model_row_class = model.InventoryBatchRow rows_editable = True @@ -87,6 +93,7 @@ class InventoryBatchView(BatchMasterView): fs.handheld_batches, fs.mode, fs.rowcount, + fs.complete, fs.executed, fs.executed_by, ]) @@ -100,6 +107,8 @@ class InventoryBatchView(BatchMasterView): 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: @@ -107,6 +116,89 @@ class InventoryBatchView(BatchMasterView): else: del fs.complete + # 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.. + def mobile_row_from_upc(self): + """ + Locate and/or create a row within the batch, according to the given + product UPC, then redirect to the row view page. + """ + batch = self.get_instance() + row = None + upc = self.request.GET.get('upc', '').strip() + 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 + else: + row.upc = provided # TODO: why not 'checked' instead? how to choose? + row.description = "(unknown product)" + self.handler.add_row(batch, row) + + self.Session.flush() + return self.redirect(self.mobile_row_route_url('view', uuid=row.uuid)) + + def template_kwargs_view_row(self, **kwargs): + row = kwargs['instance'] + kwargs['product_image_url'] = pod.get_image_url(self.rattail_config, row.upc) + return kwargs + + def get_batch_kwargs(self, batch, mobile=False): + kwargs = super(InventoryBatchView, self).get_batch_kwargs(batch, mobile=False) + kwargs['mode'] = batch.mode + kwargs['complete'] = False + return kwargs + + def get_mobile_row_data(self, batch): + # we want newest on top, for inventory batch rows + return self.get_row_data(batch)\ + .order_by(self.model_row_class.sequence.desc()) + + # TODO: ugh, the hackiness. needs a refactor fo sho + def mobile_view_row(self): + """ + Mobile view for inventory batch rows. Note that this also handles + updating a row...ugh. + """ + self.viewing = True + row = self.get_row_instance() + form = self.make_mobile_row_form(row) + context = { + 'row': row, + '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), + 'form': form, + } + + if self.request.has_perm('{}.edit'.format(self.get_row_permission_prefix())): + update_form = forms.SimpleForm(self.request, schema=InventoryForm) + if update_form.validate(): + row = update_form.data['row'] + cases = update_form.data['cases'] + units = update_form.data['units'] + if cases: + row.cases = cases + row.units = None + elif units: + row.cases = None + row.units = units + self.handler.refresh_row(row) + return self.redirect(self.request.route_url('mobile.{}.view'.format(self.get_route_prefix()), uuid=row.batch_uuid)) + + return self.render_to_response('view_row', context, mobile=True) + def _preconfigure_row_grid(self, g): super(InventoryBatchView, self)._preconfigure_row_grid(g) g.upc.set(label="UPC") @@ -139,7 +231,9 @@ class InventoryBatchView(BatchMasterView): if row is None: return '' description = row.product.full_description if row.product else row.description - title = "({}) {}".format(row.upc.pretty(), description) + unit_uom = 'LB' if row.product and row.product.weighed else 'EA' + qty = "{} {}".format(pretty_quantity(row.cases or row.units), 'CS' if row.cases else unit_uom) + title = "({}) {} - {}".format(row.upc.pretty(), description, qty) url = self.request.route_url('mobile.batch.inventory.rows.view', uuid=row.uuid) return tags.link_to(title, url) @@ -164,6 +258,21 @@ class InventoryBatchView(BatchMasterView): fs.units, ]) + @classmethod + def 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), + permission='{}.create'.format(row_permission_prefix)) + class InventoryBatchRenderer(fa.FieldRenderer): @@ -178,5 +287,23 @@ class InventoryBatchRenderer(fa.FieldRenderer): return tags.link_to(title, url) +class ValidBatchRow(forms.validators.ModelValidator): + model_class = model.InventoryBatchRow + + def _to_python(self, value, state): + row = super(ValidBatchRow, self)._to_python(value, state) + if row.batch.executed: + raise fe.Invalid("Batch has already been executed", value, state) + return row + + +class InventoryForm(forms.Schema): + allow_extra_fields = True + filter_extra_fields = True + row = ValidBatchRow() + cases = fe.validators.Number() + units = fe.validators.Number() + + def includeme(config): InventoryBatchView.defaults(config) diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 39e8045e..65e5c307 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -312,6 +312,21 @@ class MasterView(View): return self.redirect_after_create(obj) return self.render_to_response('create', {'form': form}) + def mobile_create(self): + """ + Mobile view for creating a new primary object + """ + self.creating = True + form = self.make_mobile_form(self.get_model_class()) + if self.request.method == 'POST': + if form.validate(): + # let save_create_form() return alternate object if necessary + obj = self.save_create_form(form) or form.fieldset.model + self.after_create(obj) + self.flash_after_create(obj) + return self.redirect_after_create(obj, mobile=True) + return self.render_to_response('create', {'form': form}, mobile=True) + def flash_after_create(self, obj): self.request.session.flash("{} has been created: {}".format( self.get_model_title(), self.get_instance_title(obj))) @@ -320,8 +335,8 @@ class MasterView(View): self.before_create(form) form.save() - def redirect_after_create(self, instance): - return self.redirect(self.get_action_url('view', instance)) + def redirect_after_create(self, instance, mobile=False): + return self.redirect(self.get_action_url('view', instance, mobile=mobile)) def view(self, instance=None): """ @@ -573,6 +588,13 @@ class MasterView(View): 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)