From a34a42d2b28a18a3136f4103732eccea876c3a11 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 16 Jul 2018 20:40:29 -0500 Subject: [PATCH] Refactor mobile receiving to use "quick row" feature plus some other random things thrown in there, for good measure.. --- tailbone/static/js/tailbone.mobile.js | 30 +++- tailbone/templates/mobile/master/view.mako | 8 +- tailbone/templates/mobile/receiving/view.mako | 24 --- .../templates/mobile/receiving/view_row.mako | 4 +- tailbone/views/customers.py | 10 +- tailbone/views/master.py | 79 +++++----- tailbone/views/people.py | 9 +- tailbone/views/purchasing/batch.py | 25 +-- tailbone/views/purchasing/receiving.py | 144 ++++++++++-------- 9 files changed, 177 insertions(+), 156 deletions(-) delete mode 100644 tailbone/templates/mobile/receiving/view.mako diff --git a/tailbone/static/js/tailbone.mobile.js b/tailbone/static/js/tailbone.mobile.js index 0bd1f824..0c8ebee0 100644 --- a/tailbone/static/js/tailbone.mobile.js +++ b/tailbone/static/js/tailbone.mobile.js @@ -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; + } + } + } } } }); diff --git a/tailbone/templates/mobile/master/view.mako b/tailbone/templates/mobile/master/view.mako index f9aac0f8..32c6466d 100644 --- a/tailbone/templates/mobile/master/view.mako +++ b/tailbone/templates/mobile/master/view.mako @@ -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:
${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')}
% 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 diff --git a/tailbone/templates/mobile/receiving/view.mako b/tailbone/templates/mobile/receiving/view.mako deleted file mode 100644 index 0df7b50c..00000000 --- a/tailbone/templates/mobile/receiving/view.mako +++ /dev/null @@ -1,24 +0,0 @@ -## -*- coding: utf-8; -*- -<%inherit file="/mobile/master/view.mako" /> - -<%def name="title()">Receiving » ${instance.id_str} - -<%def name="page_title()">${h.link_to("Receiving", url('mobile.receiving'))} » ${instance.id_str} - -${form.render()|n} -
- -% 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)})} -
-% endif - -${grid.render_complete()|n} - -% if not instance.executed and not instance.complete: -

- ${h.form(request.route_url('mobile.receiving.mark_complete', uuid=instance.uuid))} - ${h.csrf_token(request)} - ${h.hidden('mark-complete', value='true')} - -% endif diff --git a/tailbone/templates/mobile/receiving/view_row.mako b/tailbone/templates/mobile/receiving/view_row.mako index 966b939b..4fe16a27 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 » ${batch.id_str} » ${row.upc.pretty()} +<%def name="title()">Receiving » ${batch.id_str} » ${master.render_product_key_value(row)} -<%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()} +<%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)}
diff --git a/tailbone/views/customers.py b/tailbone/views/customers.py index 04db6de2..79bc7cd0 100644 --- a/tailbone/views/customers.py +++ b/tailbone/views/customers.py @@ -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) diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 7c7937f9..613934ab 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -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): diff --git a/tailbone/views/people.py b/tailbone/views/people.py index 5060cc18..bab450d4 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -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: diff --git a/tailbone/views/purchasing/batch.py b/tailbone/views/purchasing/batch.py index e018817a..1c711854 100644 --- a/tailbone/views/purchasing/batch.py +++ b/tailbone/views/purchasing/batch.py @@ -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: diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index 2de377c7..a6bdd9f9 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -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),