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 // handle global keypress on product batch "row" page, for sake of scanner wedge
var product_batch_routes = [ var product_batch_routes = [
'mobile.batch.inventory.view', 'mobile.batch.inventory.view',
'mobile.receiving.view',
]; ];
$(document).on('keypress', function(event) { $(document).on('keypress', function(event) {
var current_route = $('.ui-page-active [role="main"]').data('route'); 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) { $(document).on('keypress', function(event) {
var quick_row = $('.ui-page-active #quick_row_entry'); var quick_row = $('.ui-page-active #quick_row_entry');
if (quick_row.length) { if (quick_row.length) {
// if user hits enter with quick row input focused, submit form
if (quick_row.is(':focus')) { if (quick_row.is(':focus')) {
if (event.which == 13) { // ENTER if (event.which == 13) { // ENTER
if (quick_row.val()) { if (quick_row.val()) {
@ -158,6 +159,31 @@ $(document).on('keypress', function(event) {
return false; 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): % 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? ## TODO: this seems like a poor choice of names? what are we really testing for here?
% if master.mobile_rows_creatable_via_browse: % 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
% endif % endif
% if master.mobile_rows_quickable and master.rows_quickable_for(instance): % 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.form(url('mobile.{}.quick_row'.format(route_prefix), uuid=instance.uuid))}
${h.csrf_token(request)} ${h.csrf_token(request)}
% if quick_row_autocomplete: % if quick_row_autocomplete:
<div class="field autocomplete quick-row" data-url="${quick_row_autocomplete_url}"> <div class="field autocomplete quick-row" data-url="${quick_row_autocomplete_url}">
${h.hidden('quick_row_entry')} ${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> <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> <button type="button" style="display: none;">Change</button>
</div> </div>
% else: % 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 % endif
${h.end_form()} ${h.end_form()}
% endif % 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" /> <%inherit file="/mobile/master/view_row.mako" />
<%namespace file="/mobile/keypad.mako" import="keypad" /> <%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"> <div class="ui-grid-a">

View file

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

View file

@ -717,6 +717,15 @@ class MasterView(View):
def process_uploads(self, obj, form, uploads): def process_uploads(self, obj, form, uploads):
pass 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): def before_create_flush(self, obj, form):
pass pass
@ -1060,24 +1069,36 @@ class MasterView(View):
defaults.update(kwargs) defaults.update(kwargs)
return defaults 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? form.remove_field('uuid')
# if self.editing:
# model_class = self.get_model_class(error=False) if self.editing:
# if model_class: model_class = self.get_model_class(error=False)
# mapper = orm.class_mapper(model_class) if model_class:
# for key in mapper.primary_key: # set readonly for all primary key fields
# for field in form.fields: mapper = orm.class_mapper(model_class)
# if field == key.name: for key in mapper.primary_key:
# form.set_readonly(field) for field in form.fields:
# break if field == key.name:
# form.remove_field('uuid') form.set_readonly(field)
break
self.set_labels(form) 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): def validate_mobile_form(self, form):
controls = self.request.POST.items() controls = self.request.POST.items()
try: try:
@ -1832,15 +1853,12 @@ class MasterView(View):
context['row_model_title_plural'] = self.get_row_model_title_plural() context['row_model_title_plural'] = self.get_row_model_title_plural()
context['row_action_url'] = self.get_row_action_url 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: # quick row does *not* mimic keyboard wedge by default, but can
context['add_item_title'] = "Add Record" context['quick_row_keyboard_wedge'] = False
if self.mobile_rows_quickable: # quick row does *not* use autocomplete by default, but can
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'] = False
context['quick_row_autocomplete_url'] = '#' context['quick_row_autocomplete_url'] = '#'
@ -2366,22 +2384,9 @@ class MasterView(View):
def configure_form(self, form): def configure_form(self, form):
""" """
Configure the primary form. By default this just sets any primary key Configure the main "desktop" form for the view's data model.
fields to be readonly (if we have a :attr:`model_class`).
""" """
if self.editing: self.configure_common_form(form)
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): def validate_form(self, form):
if form.validate(newstyle=True): 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 not bool(person.user and person.user.username == 'chuck')
return True return True
def configure_form(self, f): def configure_common_form(self, f):
if not self.mobile: super(PeopleView, self).configure_common_form(f)
super(PeopleView, self).configure_form(f)
f.set_label('display_name', "Full Name") f.set_label('display_name', "Full Name")
@ -163,10 +162,6 @@ class PeopleView(MasterView):
f.set_readonly('users') f.set_readonly('users')
f.set_renderer('users', self.render_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): def render_employee(self, person, field):
employee = person.employee employee = person.employee
if not employee: if not employee:

View file

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

View file

@ -104,6 +104,7 @@ class ReceivingBatchView(PurchasingBatchView):
mobile_creatable = True mobile_creatable = True
mobile_rows_filterable = True mobile_rows_filterable = True
mobile_rows_creatable = True mobile_rows_creatable = True
mobile_rows_quickable = True
allow_from_po = False allow_from_po = False
allow_from_scratch = True allow_from_scratch = True
@ -351,7 +352,17 @@ class ReceivingBatchView(PurchasingBatchView):
def get_batch_kwargs(self, batch, mobile=False): def get_batch_kwargs(self, batch, mobile=False):
kwargs = super(ReceivingBatchView, self).get_batch_kwargs(batch, mobile=mobile) 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'] batch_type = self.request.POST['batch_type']
if batch_type == 'from_scratch': if batch_type == 'from_scratch':
kwargs.pop('truck_dump_batch', None) kwargs.pop('truck_dump_batch', None)
@ -365,6 +376,9 @@ class ReceivingBatchView(PurchasingBatchView):
raise NotImplementedError raise NotImplementedError
return kwargs return kwargs
def department_for_purchase(self, purchase):
pass
def delete_instance(self, batch): def delete_instance(self, batch):
""" """
Delete all data (files etc.) for the batch. Delete all data (files etc.) for the batch.
@ -498,6 +512,9 @@ class ReceivingBatchView(PurchasingBatchView):
default_value=default_status) default_value=default_status)
return filters return filters
def get_purchase(self, uuid):
return self.Session.query(model.Purchase).get(uuid)
def mobile_create(self): def mobile_create(self):
""" """
Mobile view for creating a new receiving batch Mobile view for creating a new receiving batch
@ -580,8 +597,9 @@ class ReceivingBatchView(PurchasingBatchView):
f.set_readonly('invoice_total') f.set_readonly('invoice_total')
def render_mobile_row_listitem(self, row, i): 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 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): def should_aggregate_products(self, batch):
""" """
@ -590,49 +608,53 @@ class ReceivingBatchView(PurchasingBatchView):
""" """
return True return True
# TODO: this view can create new rows, with only a GET query. that should def quick_locate_rows(self, batch, entry):
# probably be changed to require POST; for now we just require the "create rows = []
# 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()
# 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 rows:
if self.should_aggregate_products(batch): return rows
if len(rows) > 1:
log.warning("found multiple UPC matches for {} in batch {}: {}".format(
upc, batch.id_str, batch))
row = rows[0]
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] other_row = rows[0]
row = model.PurchaseBatchRow() row = model.PurchaseBatchRow()
row.product = other_row.product row.product = other_row.product
self.handler.add_row(batch, row) self.handler.add_row(batch, row)
# TODO: is this necessary here? is so, then what about further below? self.Session.flush()
# self.handler.refresh_batch_status(batch) return row
else: # try to locate product by upc
provided = GPC(entry, calc_check_digit=False)
# try to locate general product by UPC; add to batch if found checked = GPC(entry, calc_check_digit='upc')
product = api.get_product_by_upc(self.Session(), provided) product = api.get_product_by_upc(self.Session(), provided)
if not product: if not product:
product = api.get_product_by_upc(self.Session(), checked) product = api.get_product_by_upc(self.Session(), checked)
@ -640,22 +662,25 @@ class ReceivingBatchView(PurchasingBatchView):
row = model.PurchaseBatchRow() row = model.PurchaseBatchRow()
row.product = product row.product = product
self.handler.add_row(batch, row) self.handler.add_row(batch, row)
self.Session.flush()
return row
# check for "bad" upc # check for "bad" upc
elif len(upc) > 14: if len(entry) > 14:
self.request.session.flash("Invalid UPC: {}".format(upc), 'error') return
return self.redirect(self.get_action_url('view', batch, mobile=True))
# product in system, but sane upc, so add to batch anyway # product not in system, but presumably sane upc, so add to batch anyway
else:
row = model.PurchaseBatchRow() row = model.PurchaseBatchRow()
row.upc = provided # TODO: why not checked? how to know? row.upc = provided # TODO: why not checked? how to know?
row.description = "(unknown product)" row.description = "(unknown product)"
self.handler.add_row(batch, row) self.handler.add_row(batch, row)
self.handler.refresh_batch_status(batch)
self.Session.flush() 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): def mobile_view_row(self):
""" """
@ -806,11 +831,6 @@ class ReceivingBatchView(PurchasingBatchView):
model_key = cls.get_model_key() model_key = cls.get_model_key()
permission_prefix = cls.get_permission_prefix() 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: 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_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), config.add_view(cls, attr='add_child_from_invoice', route_name='{}.add_child_from_invoice'.format(route_prefix),