Refactor mobile receiving to use "quick row" feature

plus some other random things thrown in there, for good measure..
This commit is contained in:
Lance Edgar 2018-07-16 20:40:29 -05:00
parent 3cc8adba86
commit a34a42d2b2
9 changed files with 177 additions and 156 deletions

View file

@ -113,7 +113,6 @@ $(document).on('click', '#datasync-restart', function() {
// 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) {
var current_route = $('.ui-page-active [role="main"]').data('route');
@ -146,10 +145,12 @@ $(document).on('keypress', function(event) {
});
// handle ENTER press for quick_row forms
// handle various keypress events for quick row forms
$(document).on('keypress', function(event) {
var quick_row = $('.ui-page-active #quick_row_entry');
if (quick_row.length) {
// if user hits enter with quick row input focused, submit form
if (quick_row.is(':focus')) {
if (event.which == 13) { // ENTER
if (quick_row.val()) {
@ -158,6 +159,31 @@ $(document).on('keypress', function(event) {
return false;
}
}
} else { // quick row input not focused
// mimic keyboard wedge if we're so instructed
if (quick_row.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);
return false;
}
// 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) { // ENTER
// submit form when ENTER is received via keyboard "wedge"
if (quick_row.val()) {
var form = quick_row.parents('form:first');
form.submit();
return false;
}
}
}
}
}
});

View file

@ -18,21 +18,23 @@ ${form.render()|n}
% if master.mobile_rows_creatable and master.rows_creatable_for(instance):
## TODO: this seems like a poor choice of names? what are we really testing for here?
% if master.mobile_rows_creatable_via_browse:
${h.link_to(add_item_title, url('mobile.{}.create_row'.format(route_prefix), uuid=instance.uuid), class_='ui-btn ui-corner-all')}
<% add_title = "Add Record" if add_item_title is Undefined else add_item_title %>
${h.link_to(add_title, url('mobile.{}.create_row'.format(route_prefix), uuid=instance.uuid), class_='ui-btn ui-corner-all')}
% 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 %>
${h.form(url('mobile.{}.quick_row'.format(route_prefix), uuid=instance.uuid))}
${h.csrf_token(request)}
% if quick_row_autocomplete:
<div class="field autocomplete quick-row" data-url="${quick_row_autocomplete_url}">
${h.hidden('quick_row_entry')}
${h.text('quick_row_autocomplete_text', placeholder=quick_row_entry_placeholder, autocomplete='off', data_type='search')}
${h.text('quick_row_autocomplete_text', placeholder=placeholder, autocomplete='off', data_type='search')}
<ul data-role="listview" data-inset="true" data-filter="true" data-input="#quick_row_autocomplete_text"></ul>
<button type="button" style="display: none;">Change</button>
</div>
% else:
${h.text('quick_row_entry', placeholder=quick_row_entry_placeholder, autocomplete='off', **{'data-type': 'search', 'data-url': url('mobile.{}.quick_row'.format(route_prefix), uuid=instance.uuid)})}
${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': quick_row_keyboard_wedge})}
% endif
${h.end_form()}
% endif

View file

@ -1,24 +0,0 @@
## -*- coding: utf-8; -*-
<%inherit file="/mobile/master/view.mako" />
<%def name="title()">Receiving &raquo; ${instance.id_str}</%def>
<%def name="page_title()">${h.link_to("Receiving", url('mobile.receiving'))} &raquo; ${instance.id_str}</%def>
${form.render()|n}
<br />
% if not instance.executed and not instance.complete:
${h.text('upc-search', class_='receiving-upc-search', placeholder="Enter UPC", autocomplete='off', **{'data-type': 'search', 'data-url': url('mobile.receiving.lookup', uuid=batch.uuid)})}
<br />
% endif
${grid.render_complete()|n}
% if not instance.executed and not instance.complete:
<br /><br />
${h.form(request.route_url('mobile.receiving.mark_complete', uuid=instance.uuid))}
${h.csrf_token(request)}
${h.hidden('mark-complete', value='true')}
<button type="submit">Mark Batch as Complete</button>
% endif

View file

@ -2,9 +2,9 @@
<%inherit file="/mobile/master/view_row.mako" />
<%namespace file="/mobile/keypad.mako" import="keypad" />
<%def name="title()">Receiving &raquo; ${batch.id_str} &raquo; ${row.upc.pretty()}</%def>
<%def name="title()">Receiving &raquo; ${batch.id_str} &raquo; ${master.render_product_key_value(row)}</%def>
<%def name="page_title()">${h.link_to("Receiving", url('mobile.receiving'))} &raquo; ${h.link_to(batch.id_str, url('mobile.receiving.view', uuid=batch.uuid))} &raquo; ${row.upc.pretty()}</%def>
<%def name="page_title()">${h.link_to("Receiving", url('mobile.receiving'))} &raquo; ${h.link_to(batch.id_str, url('mobile.receiving.view', uuid=batch.uuid))} &raquo; ${master.render_product_key_value(row)}</%def>
<div class="ui-grid-a">

View file

@ -172,10 +172,8 @@ class CustomersView(MasterView):
raise HTTPNotFound
def configure_form(self, f):
if not self.mobile:
super(CustomersView, self).configure_form(f)
def configure_common_form(self, f):
super(CustomersView, self).configure_common_form(f)
customer = f.model_instance
permission_prefix = self.get_permission_prefix()
@ -222,10 +220,6 @@ class CustomersView(MasterView):
f.set_renderer('groups', self.render_groups)
f.set_readonly('groups')
def configure_mobile_form(self, f):
super(CustomersView, self).configure_mobile_form(f)
self.configure_form(f)
def objectify(self, form, data):
customer = super(CustomersView, self).objectify(form, data)
customer = self.objectify_contact(customer, data)

View file

@ -717,6 +717,15 @@ class MasterView(View):
def process_uploads(self, obj, form, uploads):
pass
def render_product_key_value(self, obj):
"""
Render the "canonical" product key value for the given object.
"""
product_key = self.rattail_config.product_key()
if product_key == 'upc':
return obj.upc.pretty() if obj.upc else ''
return getattr(obj, product_key)
def before_create_flush(self, obj, form):
pass
@ -1060,24 +1069,36 @@ class MasterView(View):
defaults.update(kwargs)
return defaults
def configure_mobile_form(self, form):
def configure_common_form(self, form):
"""
Configure the primary mobile form.
Configure the form in whatever way is deemed "common" - i.e. where
configuration should be done the same for desktop and mobile.
By default this removes the 'uuid' field (if present), sets any primary
key fields to be readonly (if we have a :attr:`model_class` and are in
edit mode), and sets labels as defined by the master class hierarchy.
"""
# 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')
form.remove_field('uuid')
if self.editing:
model_class = self.get_model_class(error=False)
if model_class:
# set readonly for all primary key fields
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
self.set_labels(form)
def configure_mobile_form(self, form):
"""
Configure the main "mobile" form for the view's data model.
"""
self.configure_common_form(form)
def validate_mobile_form(self, form):
controls = self.request.POST.items()
try:
@ -1832,17 +1853,14 @@ class MasterView(View):
context['row_model_title_plural'] = self.get_row_model_title_plural()
context['row_action_url'] = self.get_row_action_url
if mobile:
if mobile and self.viewing and self.mobile_rows_quickable:
if self.mobile_rows_creatable:
context['add_item_title'] = "Add Record"
# quick row does *not* mimic keyboard wedge by default, but can
context['quick_row_keyboard_wedge'] = False
if self.mobile_rows_quickable:
context['quick_row_entry_placeholder'] = "Enter search text"
# quick row does *not* use autocomplete by default
context['quick_row_autocomplete'] = False
context['quick_row_autocomplete_url'] = '#'
# quick row does *not* use autocomplete by default, but can
context['quick_row_autocomplete'] = False
context['quick_row_autocomplete_url'] = '#'
context.update(data)
context.update(self.template_kwargs(**context))
@ -2366,22 +2384,9 @@ class MasterView(View):
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`).
Configure the main "desktop" form for the view's data model.
"""
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)
self.configure_common_form(form)
def validate_form(self, form):
if form.validate(newstyle=True):

View file

@ -139,9 +139,8 @@ class PeopleView(MasterView):
return not bool(person.user and person.user.username == 'chuck')
return True
def configure_form(self, f):
if not self.mobile:
super(PeopleView, self).configure_form(f)
def configure_common_form(self, f):
super(PeopleView, self).configure_common_form(f)
f.set_label('display_name', "Full Name")
@ -163,10 +162,6 @@ class PeopleView(MasterView):
f.set_readonly('users')
f.set_renderer('users', self.render_users)
def configure_mobile_form(self, f):
super(PeopleView, self).configure_mobile_form(f)
self.configure_form(f)
def render_employee(self, person, field):
employee = person.employee
if not employee:

View file

@ -50,6 +50,10 @@ class PurchasingBatchView(BatchMasterView):
supports_new_product = False
cloneable = True
labels = {
'po_total': "PO Total",
}
grid_columns = [
'id',
'vendor',
@ -214,6 +218,16 @@ class PurchasingBatchView(BatchMasterView):
# form = super(PurchasingBatchView, self).make_form(batch, **kwargs)
# return form
def configure_common_form(self, f):
super(PurchasingBatchView, self).configure_common_form(f)
# po_total
if self.creating:
f.remove_field('po_total')
else:
f.set_readonly('po_total')
f.set_type('po_total', 'currency')
def configure_form(self, f):
super(PurchasingBatchView, self).configure_form(f)
batch = f.model_instance
@ -329,11 +343,6 @@ class PurchasingBatchView(BatchMasterView):
# po_number
f.set_label('po_number', "PO Number")
# po_total
f.set_readonly('po_total')
f.set_type('po_total', 'currency')
f.set_label('po_total', "PO Total")
# invoice_total
f.set_readonly('invoice_total')
f.set_type('invoice_total', 'currency')
@ -364,12 +373,6 @@ 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:

View file

@ -104,6 +104,7 @@ class ReceivingBatchView(PurchasingBatchView):
mobile_creatable = True
mobile_rows_filterable = True
mobile_rows_creatable = True
mobile_rows_quickable = True
allow_from_po = False
allow_from_scratch = True
@ -351,7 +352,17 @@ class ReceivingBatchView(PurchasingBatchView):
def get_batch_kwargs(self, batch, mobile=False):
kwargs = super(ReceivingBatchView, self).get_batch_kwargs(batch, mobile=mobile)
if not mobile:
if mobile:
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
batch_type = self.request.POST['batch_type']
if batch_type == 'from_scratch':
kwargs.pop('truck_dump_batch', None)
@ -365,6 +376,9 @@ class ReceivingBatchView(PurchasingBatchView):
raise NotImplementedError
return kwargs
def department_for_purchase(self, purchase):
pass
def delete_instance(self, batch):
"""
Delete all data (files etc.) for the batch.
@ -498,6 +512,9 @@ class ReceivingBatchView(PurchasingBatchView):
default_value=default_status)
return filters
def get_purchase(self, uuid):
return self.Session.query(model.Purchase).get(uuid)
def mobile_create(self):
"""
Mobile view for creating a new receiving batch
@ -580,8 +597,9 @@ class ReceivingBatchView(PurchasingBatchView):
f.set_readonly('invoice_total')
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
return "({}) {}".format(row.upc.pretty(), description)
return "({}) {}".format(key, description)
def should_aggregate_products(self, batch):
"""
@ -590,72 +608,79 @@ class ReceivingBatchView(PurchasingBatchView):
"""
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..
def mobile_lookup(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 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()
def quick_locate_rows(self, batch, entry):
rows = []
# we prefer "exact" matches, i.e. those which assumed the entry already
# contained the check digit.
provided = GPC(entry, calc_check_digit=False)
for row in batch.active_rows():
if row.upc == provided:
rows.append(row)
if rows:
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))
row = rows[0]
return rows
else:
# if no "exact" matches, we'll settle for those which assume the entry
# lacked a check digit.
checked = GPC(entry, calc_check_digit='upc')
for row in batch.active_rows():
if row.upc == checked:
rows.append(row)
return rows
def save_quick_row_form(self, form):
batch = self.get_instance()
entry = form.validated['quick_row_entry']
# maybe try to locate existing row first
rows = self.quick_locate_rows(batch, entry)
if rows:
# if aggregating, just re-use matching row
prefer_existing = self.should_aggregate_products(batch)
if prefer_existing:
if len(rows) > 1:
log.warning("found multiple row matches for '%s' in batch %s: %s",
entry, batch.id_str, batch)
return rows[0]
else: # borrow product from matching row, but make new row
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)
self.Session.flush()
return row
else:
# 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()
return row
# 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
self.handler.add_row(batch, 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:
row = model.PurchaseBatchRow()
row.upc = provided # TODO: why not checked? how to know?
row.description = "(unknown product)"
self.handler.add_row(batch, row)
self.handler.refresh_batch_status(batch)
# check for "bad" upc
if len(entry) > 14:
return
# 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?
row.description = "(unknown product)"
self.handler.add_row(batch, row)
self.Session.flush()
return self.redirect(self.mobile_row_route_url('view', uuid=row.batch_uuid, row_uuid=row.uuid))
return row
def redirect_after_quick_row(self, row, mobile=False):
if mobile:
return self.redirect(self.get_row_action_url('view', row, mobile=mobile))
return super(ReceivingBatchView, self).redirect_after_quick_row(row, mobile=mobile)
def mobile_view_row(self):
"""
@ -806,11 +831,6 @@ class ReceivingBatchView(PurchasingBatchView):
model_key = cls.get_model_key()
permission_prefix = cls.get_permission_prefix()
# mobile lookup (note perm; this view can create new rows)
config.add_route('mobile.{}.lookup'.format(route_prefix), '/mobile{}/{{{}}}/lookup'.format(url_prefix, model_key))
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),