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%def>
+
+${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%def>
+
+% 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>
+<%def name="title()">${h.link_to("Inventory", url('mobile.batch.inventory'))} » ${batch.id_str}%def>
-${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>
+<%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()}%def>
-${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.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')} |
+
+
+
+
+
+
+
+%def>
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}%def>
+
+
+ ${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)