feat: move lookup logic to handler; improve support for external lookup

This commit is contained in:
Lance Edgar 2025-01-12 22:03:31 -06:00
parent f8f4933ca1
commit 811a37995d
7 changed files with 553 additions and 151 deletions

View file

@ -77,6 +77,65 @@ class NewOrderBatchHandler(BatchHandler):
return self.config.get_bool('sideshow.orders.allow_unknown_products',
default=True)
def autocomplete_customers_external(self, session, term, user=None):
"""
Return autocomplete search results for :term:`external
customer` records.
There is no default logic here; subclass must implement.
:param session: Current app :term:`db session`.
:param term: Search term string from user input.
:param user:
:class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who
is doing the search, if known.
:returns: List of search results; each should be a dict with
``value`` and ``label`` keys.
"""
raise NotImplementedError
def autocomplete_customers_local(self, session, term, user=None):
"""
Return autocomplete search results for
:class:`~sideshow.db.model.customers.LocalCustomer` records.
:param session: Current app :term:`db session`.
:param term: Search term string from user input.
:param user:
:class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who
is doing the search, if known.
:returns: List of search results; each should be a dict with
``value`` and ``label`` keys.
"""
model = self.app.model
# base query
query = session.query(model.LocalCustomer)
# filter query
criteria = [model.LocalCustomer.full_name.ilike(f'%{word}%')
for word in term.split()]
query = query.filter(sa.and_(*criteria))
# sort query
query = query.order_by(model.LocalCustomer.full_name)
# get data
# TODO: need max_results option
customers = query.all()
# get results
def result(customer):
return {'value': customer.uuid.hex,
'label': customer.full_name}
return [result(c) for c in customers]
def set_customer(self, batch, customer_info, user=None):
"""
Set/update customer info for the batch.
@ -91,14 +150,14 @@ class NewOrderBatchHandler(BatchHandler):
:class:`~sideshow.db.model.customers.PendingCustomer` record
is created if necessary.
And then it will update these accordingly:
And then it will update customer-related attributes via one of:
* :attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.customer_name`
* :attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.phone_number`
* :attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.email_address`
* :meth:`refresh_batch_from_external_customer()`
* :meth:`refresh_batch_from_local_customer()`
* :meth:`refresh_batch_from_pending_customer()`
Note that ``customer_info`` may be ``None``, which will cause
all the above to be set to ``None`` also.
customer attributes to be set to ``None`` also.
:param batch:
:class:`~sideshow.db.model.batch.neworder.NewOrderBatch` to
@ -129,13 +188,11 @@ class NewOrderBatchHandler(BatchHandler):
if not customer:
raise ValueError("local customer not found")
batch.local_customer = customer
batch.customer_name = customer.full_name
batch.phone_number = customer.phone_number
batch.email_address = customer.email_address
self.refresh_batch_from_local_customer(batch)
else: # external customer_id
#batch.customer_id = customer_info
raise NotImplementedError
batch.customer_id = customer_info
self.refresh_batch_from_external_customer(batch)
elif customer_info:
@ -160,9 +217,7 @@ class NewOrderBatchHandler(BatchHandler):
if 'full_name' not in customer_info:
customer.full_name = self.app.make_full_name(customer.first_name,
customer.last_name)
batch.customer_name = customer.full_name
batch.phone_number = customer.phone_number
batch.email_address = customer.email_address
self.refresh_batch_from_pending_customer(batch)
else:
@ -175,6 +230,203 @@ class NewOrderBatchHandler(BatchHandler):
session.flush()
def refresh_batch_from_external_customer(self, batch):
"""
Update customer-related attributes on the batch, from its
:term:`external customer` record.
This is called automatically from :meth:`set_customer()`.
There is no default logic here; subclass must implement.
"""
raise NotImplementedError
def refresh_batch_from_local_customer(self, batch):
"""
Update customer-related attributes on the batch, from its
:attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.local_customer`
record.
This is called automatically from :meth:`set_customer()`.
"""
customer = batch.local_customer
batch.customer_name = customer.full_name
batch.phone_number = customer.phone_number
batch.email_address = customer.email_address
def refresh_batch_from_pending_customer(self, batch):
"""
Update customer-related attributes on the batch, from its
:attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.pending_customer`
record.
This is called automatically from :meth:`set_customer()`.
"""
customer = batch.pending_customer
batch.customer_name = customer.full_name
batch.phone_number = customer.phone_number
batch.email_address = customer.email_address
def autocomplete_products_external(self, session, term, user=None):
"""
Return autocomplete search results for :term:`external
product` records.
There is no default logic here; subclass must implement.
:param session: Current app :term:`db session`.
:param term: Search term string from user input.
:param user:
:class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who
is doing the search, if known.
:returns: List of search results; each should be a dict with
``value`` and ``label`` keys.
"""
raise NotImplementedError
def autocomplete_products_local(self, session, term, user=None):
"""
Return autocomplete search results for
:class:`~sideshow.db.model.products.LocalProduct` records.
:param session: Current app :term:`db session`.
:param term: Search term string from user input.
:param user:
:class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who
is doing the search, if known.
:returns: List of search results; each should be a dict with
``value`` and ``label`` keys.
"""
model = self.app.model
# base query
query = session.query(model.LocalProduct)
# filter query
criteria = []
for word in term.split():
criteria.append(sa.or_(
model.LocalProduct.brand_name.ilike(f'%{word}%'),
model.LocalProduct.description.ilike(f'%{word}%')))
query = query.filter(sa.and_(*criteria))
# sort query
query = query.order_by(model.LocalProduct.brand_name,
model.LocalProduct.description)
# get data
# TODO: need max_results option
products = query.all()
# get results
def result(product):
return {'value': product.uuid.hex,
'label': product.full_description}
return [result(c) for c in products]
def get_product_info_external(self, session, product_id, user=None):
"""
Returns basic info for an :term:`external product` as pertains
to ordering.
When user has located a product via search, and must then
choose order quantity and UOM based on case size, pricing
etc., this method is called to retrieve the product info.
There is no default logic here; subclass must implement.
:param session: Current app :term:`db session`.
:param product_id: Product ID string for which to retrieve
info.
:param user:
:class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who
is performing the action, if known.
:returns: Dict of product info. Should raise error instead of
returning ``None`` if product not found.
This method should only be called after a product has been
identified via autocomplete/search lookup; therefore the
``product_id`` should be valid, and the caller can expect this
method to *always* return a dict. If for some reason the
product cannot be found here, an error should be raised.
The dict should contain as much product info as is available
and needed; if some are missing it should not cause too much
trouble in the app. Here is a basic example::
def get_product_info_external(self, session, product_id, user=None):
ext_model = get_external_model()
ext_session = make_external_session()
ext_product = ext_session.get(ext_model.Product, product_id)
if not ext_product:
ext_session.close()
raise ValueError(f"external product not found: {product_id}")
info = {
'product_id': product_id,
'scancode': product.scancode,
'brand_name': product.brand_name,
'description': product.description,
'size': product.size,
'weighed': product.sold_by_weight,
'special_order': False,
'department_id': str(product.department_number),
'department_name': product.department_name,
'case_size': product.case_size,
'unit_price_reg': product.unit_price_reg,
'vendor_name': product.vendor_name,
'vendor_item_code': product.vendor_item_code,
}
ext_session.close()
return info
"""
raise NotImplementedError
def get_product_info_local(self, session, uuid, user=None):
"""
Returns basic info for a
:class:`~sideshow.db.model.products.LocalProduct` as pertains
to ordering.
When user has located a product via search, and must then
choose order quantity and UOM based on case size, pricing
etc., this method is called to retrieve the product info.
See :meth:`get_product_info_external()` for more explanation.
"""
model = self.app.model
product = session.get(model.LocalProduct, uuid)
if not product:
raise ValueError(f"Local Product not found: {uuid}")
return {
'product_id': product.uuid.hex,
'scancode': product.scancode,
'brand_name': product.brand_name,
'description': product.description,
'size': product.size,
'full_description': product.full_description,
'weighed': product.weighed,
'special_order': product.special_order,
'department_id': product.department_id,
'department_name': product.department_name,
'case_size': product.case_size,
'unit_price_reg': product.unit_price_reg,
'vendor_name': product.vendor_name,
'vendor_item_code': product.vendor_item_code,
}
def add_item(self, batch, product_info, order_qty, order_uom, user=None):
"""
Add a new item/row to the batch, for given product and quantity.
@ -224,8 +476,7 @@ class NewOrderBatchHandler(BatchHandler):
row.local_product = local
else: # external product_id
#row.product_id = product_info
raise NotImplementedError
row.product_id = product_info
else:
# pending_product
@ -313,8 +564,7 @@ class NewOrderBatchHandler(BatchHandler):
row.local_product = local
else: # external product_id
#row.product_id = product_info
raise NotImplementedError
row.product_id = product_info
else:
# pending_product
@ -493,6 +743,7 @@ class NewOrderBatchHandler(BatchHandler):
There is no default logic here; subclass must implement as
needed.
"""
raise NotImplementedError
def remove_row(self, row):
"""

View file

@ -0,0 +1,31 @@
# -*- coding: utf-8; -*-
################################################################################
#
# Sideshow -- Case/Special Order Tracker
# Copyright © 2024 Lance Edgar
#
# This file is part of Sideshow.
#
# Sideshow is free software: you can redistribute it and/or modify it
# under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Sideshow is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Sideshow. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Sideshow web app
"""
def includeme(config):
config.include('sideshow.web.static')
config.include('wuttaweb.subscribers')
config.include('sideshow.web.views')

View file

@ -42,9 +42,7 @@ def main(global_config, **settings):
pyramid_config = base.make_pyramid_config(settings)
# bring in the rest of Sideshow
pyramid_config.include('sideshow.web.static')
pyramid_config.include('wuttaweb.subscribers')
pyramid_config.include('sideshow.web.views')
pyramid_config.include('sideshow.web')
return pyramid_config.make_wsgi_app()

View file

@ -3,15 +3,38 @@
<%def name="form_content()">
<h3 class="block is-size-3">Customers</h3>
<div class="block" style="padding-left: 2rem;">
<b-field label="Customer Source">
<b-select name="sideshow.orders.use_local_customers"
v-model="simpleSettings['sideshow.orders.use_local_customers']"
@input="settingsNeedSaved = true">
<option value="true">Local Customers (in Sideshow)</option>
<option value="false">External Customers (e.g. in POS)</option>
</b-select>
</b-field>
</div>
<h3 class="block is-size-3">Products</h3>
<div class="block" style="padding-left: 2rem;">
<b-field message="If set, user can enter details of an arbitrary new &quot;pending&quot; product.">
<b-field label="Product Source">
<b-select name="sideshow.orders.use_local_products"
v-model="simpleSettings['sideshow.orders.use_local_products']"
@input="settingsNeedSaved = true">
<option value="true">Local Products (in Sideshow)</option>
<option value="false">External Products (e.g. in POS)</option>
</b-select>
</b-field>
<b-field label="New/Unknown Products"
message="If set, user can enter details of an arbitrary new &quot;pending&quot; product.">
<b-checkbox name="sideshow.orders.allow_unknown_products"
v-model="simpleSettings['sideshow.orders.allow_unknown_products']"
native-value="true"
@input="settingsNeedSaved = true">
Allow creating orders for "unknown" products
Allow creating orders for new/unknown products
</b-checkbox>
</b-field>

View file

@ -57,6 +57,11 @@ class OrderView(MasterView):
Note that the "edit" view is not exposed here; user must perform
various other workflow actions to modify the order.
.. attribute:: batch_handler
Reference to the new order batch handler, as returned by
:meth:`get_batch_handler()`. This gets set in the constructor.
"""
model_class = Order
editable = False
@ -153,6 +158,24 @@ class OrderView(MasterView):
# total_price
g.set_renderer('total_price', g.render_currency)
def get_batch_handler(self):
"""
Returns the configured :term:`handler` for :term:`new order
batches <new order batch>`.
You normally would not need to call this; just use
:attr:`batch_handler` instead.
:returns:
:class:`~sideshow.batch.neworder.NewOrderBatchHandler`
instance.
"""
if hasattr(self, 'batch_handler'):
return self.batch_handler
# TODO
return NewOrderBatchHandler(self.config)
def create(self):
"""
Instead of the typical "create" view, this displays a "wizard"
@ -185,7 +208,7 @@ class OrderView(MasterView):
"""
enum = self.app.enum
self.creating = True
self.batch_handler = NewOrderBatchHandler(self.config)
self.batch_handler = self.get_batch_handler()
batch = self.get_current_batch()
context = self.get_context_customer(batch)
@ -223,6 +246,7 @@ class OrderView(MasterView):
try:
result = getattr(self, action)(batch, data)
except Exception as error:
log.warning("error calling json action for order", exc_info=True)
result = {'error': self.app.render_error(error)}
return self.json_response(result)
@ -279,91 +303,49 @@ class OrderView(MasterView):
"""
AJAX view for customer autocomplete, when entering new order.
This should invoke a configured handler for the autocomplete
behavior, but that is not yet implemented. For now it uses
built-in logic only, which queries the
:class:`~sideshow.db.model.customers.LocalCustomer` table.
This invokes one of the following on the
:attr:`batch_handler`:
* :meth:`~sideshow.batch.neworder.NewOrderBatchHandler.autocomplete_customers_external()`
* :meth:`~sideshow.batch.neworder.NewOrderBatchHandler.autocomplete_customers_local()`
:returns: List of search results; each should be a dict with
``value`` and ``label`` keys.
"""
session = self.Session()
term = self.request.GET.get('term', '').strip()
if not term:
return []
return self.mock_autocomplete_customers(session, term, user=self.request.user)
# TODO: move this to some handler
def mock_autocomplete_customers(self, session, term, user=None):
""" """
import sqlalchemy as sa
model = self.app.model
# base query
query = session.query(model.LocalCustomer)
# filter query
criteria = [model.LocalCustomer.full_name.ilike(f'%{word}%')
for word in term.split()]
query = query.filter(sa.and_(*criteria))
# sort query
query = query.order_by(model.LocalCustomer.full_name)
# get data
# TODO: need max_results option
customers = query.all()
# get results
def result(customer):
return {'value': customer.uuid.hex,
'label': customer.full_name}
return [result(c) for c in customers]
handler = self.get_batch_handler()
if handler.use_local_customers():
return handler.autocomplete_customers_local(session, term, user=self.request.user)
else:
return handler.autocomplete_customers_external(session, term, user=self.request.user)
def product_autocomplete(self):
"""
AJAX view for product autocomplete, when entering new order.
This should invoke a configured handler for the autocomplete
behavior, but that is not yet implemented. For now it uses
built-in logic only, which queries the
:class:`~sideshow.db.model.products.LocalProduct` table.
This invokes one of the following on the
:attr:`batch_handler`:
* :meth:`~sideshow.batch.neworder.NewOrderBatchHandler.autocomplete_products_external()`
* :meth:`~sideshow.batch.neworder.NewOrderBatchHandler.autocomplete_products_local()`
:returns: List of search results; each should be a dict with
``value`` and ``label`` keys.
"""
session = self.Session()
term = self.request.GET.get('term', '').strip()
if not term:
return []
return self.mock_autocomplete_products(session, term, user=self.request.user)
# TODO: move this to some handler
def mock_autocomplete_products(self, session, term, user=None):
""" """
import sqlalchemy as sa
model = self.app.model
# base query
query = session.query(model.LocalProduct)
# filter query
criteria = []
for word in term.split():
criteria.append(sa.or_(
model.LocalProduct.brand_name.ilike(f'%{word}%'),
model.LocalProduct.description.ilike(f'%{word}%')))
query = query.filter(sa.and_(*criteria))
# sort query
query = query.order_by(model.LocalProduct.brand_name,
model.LocalProduct.description)
# get data
# TODO: need max_results option
products = query.all()
# get results
def result(product):
return {'value': product.uuid.hex,
'label': product.full_description}
return [result(c) for c in products]
handler = self.get_batch_handler()
if handler.use_local_products():
return handler.autocomplete_products_local(session, term, user=self.request.user)
else:
return handler.autocomplete_products_external(session, term, user=self.request.user)
def get_pending_product_required_fields(self):
""" """
@ -531,11 +513,12 @@ class OrderView(MasterView):
if not product_id:
return {'error': "Must specify a product ID"}
session = self.Session()
use_local = self.batch_handler.use_local_products()
if use_local:
data = self.get_local_product_info(product_id)
data = self.batch_handler.get_product_info_local(session, product_id)
else:
raise NotImplementedError("TODO: add integration handler")
data = self.batch_handler.get_product_info_external(session, product_id)
if 'error' in data:
return data
@ -571,32 +554,6 @@ class OrderView(MasterView):
return data
# TODO: move this to some handler
def get_local_product_info(self, product_id):
""" """
model = self.app.model
session = self.Session()
product = session.get(model.LocalProduct, product_id)
if not product:
return {'error': "Product not found"}
return {
'product_id': product.uuid.hex,
'scancode': product.scancode,
'brand_name': product.brand_name,
'description': product.description,
'size': product.size,
'full_description': product.full_description,
'weighed': product.weighed,
'special_order': product.special_order,
'department_id': product.department_id,
'department_name': product.department_name,
'case_size': product.case_size,
'unit_price_reg': product.unit_price_reg,
'vendor_name': product.vendor_name,
'vendor_item_code': product.vendor_item_code,
}
def add_item(self, batch, data):
"""
This adds a row to the user's current new order batch.
@ -725,6 +682,9 @@ class OrderView(MasterView):
'product_brand': row.product_brand,
'product_description': row.product_description,
'product_size': row.product_size,
'product_full_description': self.app.make_full_name(row.product_brand,
row.product_description,
row.product_size),
'product_weighed': row.product_weighed,
'department_display': row.department_name,
'special_order': row.special_order,
@ -751,15 +711,6 @@ class OrderView(MasterView):
else:
data['product_id'] = row.product_id
# product_full_description
if use_local:
if row.local_product:
data['product_full_description'] = row.local_product.full_description
else: # use external
pass # TODO
if not data.get('product_id') and row.pending_product:
data['product_full_description'] = row.pending_product.full_description
# vendor_name
if use_local:
if row.local_product:
@ -912,7 +863,17 @@ class OrderView(MasterView):
""" """
settings = [
# customers
{'name': 'sideshow.orders.use_local_customers',
# nb. this is really a bool but we present as string in config UI
#'type': bool,
'default': 'true'},
# products
{'name': 'sideshow.orders.use_local_products',
# nb. this is really a bool but we present as string in config UI
#'type': bool,
'default': 'true'},
{'name': 'sideshow.orders.allow_unknown_products',
'type': bool,
'default': True},
@ -1045,8 +1006,6 @@ class OrderItemView(MasterView):
'department_id',
'department_name',
'special_order',
'order_qty',
'order_uom',
'case_size',
'unit_cost',
'unit_price_reg',
@ -1054,6 +1013,8 @@ class OrderItemView(MasterView):
'sale_ends',
'unit_price_quoted',
'case_price_quoted',
'order_qty',
'order_uom',
'discount_percent',
'total_price',
'status_code',

View file

@ -50,6 +50,34 @@ class TestNewOrderBatchHandler(DataTestCase):
config.setdefault('sideshow.orders.allow_unknown_products', 'false')
self.assertFalse(handler.allow_unknown_products())
def test_autocomplete_customers_external(self):
handler = self.make_handler()
self.assertRaises(NotImplementedError, handler.autocomplete_customers_external,
self.session, 'jack')
def test_autocomplete_cutomers_local(self):
model = self.app.model
handler = self.make_handler()
# empty results by default
self.assertEqual(handler.autocomplete_customers_local(self.session, 'foo'), [])
# add a customer
customer = model.LocalCustomer(full_name="Chuck Norris")
self.session.add(customer)
self.session.flush()
# search for chuck finds chuck
results = handler.autocomplete_customers_local(self.session, 'chuck')
self.assertEqual(len(results), 1)
self.assertEqual(results[0], {
'value': customer.uuid.hex,
'label': "Chuck Norris",
})
# search for sally finds nothing
self.assertEqual(handler.autocomplete_customers_local(self.session, 'sally'), [])
def test_set_customer(self):
model = self.app.model
handler = self.make_handler()
@ -146,6 +174,83 @@ class TestNewOrderBatchHandler(DataTestCase):
self.assertIsNone(batch.phone_number)
self.assertIsNone(batch.email_address)
def test_autocomplete_products_external(self):
handler = self.make_handler()
self.assertRaises(NotImplementedError, handler.autocomplete_products_external,
self.session, 'cheese')
def test_autocomplete_products_local(self):
model = self.app.model
handler = self.make_handler()
# empty results by default
self.assertEqual(handler.autocomplete_products_local(self.session, 'foo'), [])
# add a product
product = model.LocalProduct(brand_name="Bragg's", description="Vinegar")
self.session.add(product)
self.session.flush()
# search for vinegar finds product
results = handler.autocomplete_products_local(self.session, 'vinegar')
self.assertEqual(len(results), 1)
self.assertEqual(results[0], {
'value': product.uuid.hex,
'label': "Bragg's Vinegar",
})
# search for brag finds product
results = handler.autocomplete_products_local(self.session, 'brag')
self.assertEqual(len(results), 1)
self.assertEqual(results[0], {
'value': product.uuid.hex,
'label': "Bragg's Vinegar",
})
# search for juice finds nothing
self.assertEqual(handler.autocomplete_products_local(self.session, 'juice'), [])
def test_get_product_info_external(self):
handler = self.make_handler()
self.assertRaises(NotImplementedError, handler.get_product_info_external,
self.session, '07430500132')
def test_get_product_info_local(self):
model = self.app.model
handler = self.make_handler()
user = model.User(username='barney')
self.session.add(user)
local = model.LocalProduct(scancode='07430500132',
brand_name='Bragg',
description='Vinegar',
size='32oz',
case_size=12,
unit_price_reg=decimal.Decimal('5.99'))
self.session.add(local)
self.session.flush()
batch = handler.make_batch(self.session, created_by=user)
self.session.add(batch)
# typical, for local product
info = handler.get_product_info_local(self.session, local.uuid.hex)
self.assertEqual(info['product_id'], local.uuid.hex)
self.assertEqual(info['scancode'], '07430500132')
self.assertEqual(info['brand_name'], 'Bragg')
self.assertEqual(info['description'], 'Vinegar')
self.assertEqual(info['size'], '32oz')
self.assertEqual(info['full_description'], 'Bragg Vinegar 32oz')
self.assertEqual(info['case_size'], 12)
self.assertEqual(info['unit_price_reg'], decimal.Decimal('5.99'))
# error if no product_id
self.assertRaises(ValueError, handler.get_product_info_local, self.session, None)
# error if product not found
mock_uuid = self.app.make_true_uuid()
self.assertRaises(ValueError, handler.get_product_info_local, self.session, mock_uuid.hex)
def test_add_item(self):
model = self.app.model
enum = self.app.enum
@ -719,10 +824,8 @@ class TestNewOrderBatchHandler(DataTestCase):
self.session.add(row)
self.session.flush()
# STATUS_OK
row = handler.make_row(product_id=42, order_qty=1, order_uom=enum.ORDER_UOM_UNIT)
handler.add_row(batch, row)
self.session.add(row)
self.session.commit()
row = handler.add_item(batch, {'scancode': '07430500132'}, 1, enum.ORDER_UOM_UNIT)
self.session.flush()
# only 1 effective row
rows = handler.get_effective_rows(batch)

View file

@ -177,7 +177,9 @@ class TestOrderView(WebTestCase):
def test_customer_autocomplete(self):
model = self.app.model
handler = self.make_handler()
view = self.make_view()
view.batch_handler = handler
with patch.object(view, 'Session', return_value=self.session):
@ -205,9 +207,16 @@ class TestOrderView(WebTestCase):
result = view.customer_autocomplete()
self.assertEqual(result, [])
# external lookup not implemented by default
with patch.object(handler, 'use_local_customers', return_value=False):
with patch.object(self.request, 'GET', new={'term': 'sally'}, create=True):
self.assertRaises(NotImplementedError, view.customer_autocomplete)
def test_product_autocomplete(self):
model = self.app.model
handler = self.make_handler()
view = self.make_view()
view.batch_handler = handler
with patch.object(view, 'Session', return_value=self.session):
@ -244,6 +253,11 @@ class TestOrderView(WebTestCase):
result = view.product_autocomplete()
self.assertEqual(result, [])
# external lookup not implemented by default
with patch.object(handler, 'use_local_products', return_value=False):
with patch.object(self.request, 'GET', new={'term': 'juice'}, create=True):
self.assertRaises(NotImplementedError, view.product_autocomplete)
def test_get_pending_product_required_fields(self):
model = self.app.model
view = self.make_view()
@ -529,20 +543,27 @@ class TestOrderView(WebTestCase):
self.assertEqual(context['case_size'], 12)
self.assertEqual(context['unit_price_reg'], 5.99)
# error if local product missing
mock_uuid = self.app.make_true_uuid()
context = view.get_product_info(batch, {'product_id': mock_uuid.hex})
self.assertEqual(context, {'error': "Product not found"})
# error if no product_id
context = view.get_product_info(batch, {})
self.assertEqual(context, {'error': "Must specify a product ID"})
# external lookup not implemented (yet)
# error if product not found
mock_uuid = self.app.make_true_uuid()
self.assertRaises(ValueError, view.get_product_info,
batch, {'product_id': mock_uuid.hex})
with patch.object(handler, 'use_local_products', return_value=False):
# external lookup not implemented by default
self.assertRaises(NotImplementedError, view.get_product_info,
batch, {'product_id': '42'})
# external lookup may return its own error
with patch.object(handler, 'get_product_info_external',
return_value={'error': "something smells fishy"}):
context = view.get_product_info(batch, {'product_id': '42'})
self.assertEqual(context, {'error': "something smells fishy"})
def test_add_item(self):
model = self.app.model
enum = self.app.enum
@ -970,33 +991,47 @@ class TestOrderView(WebTestCase):
# the next few tests will morph 2nd row..
def refresh_external(row):
row.product_scancode = '012345'
row.product_brand = 'Acme'
row.product_description = 'Bricks'
row.product_size = '1 ton'
row.product_weighed = True
row.department_id = 1
row.department_name = "Bricks & Mortar"
row.special_order = False
row.case_size = None
row.unit_cost = decimal.Decimal('599.99')
row.unit_price_reg = decimal.Decimal('999.99')
# typical, external product
row2.product_id = '42'
with patch.object(handler, 'use_local_products', return_value=False):
with patch.object(handler, 'refresh_row_from_external_product', new=refresh_external):
handler.update_item(row2, '42', 1, enum.ORDER_UOM_UNIT)
data = view.normalize_row(row2)
self.assertEqual(data['uuid'], row2.uuid.hex)
self.assertEqual(data['sequence'], 2)
self.assertEqual(data['product_id'], '42')
self.assertIsNone(data['product_scancode'])
self.assertNotIn('product_full_description', data) # TODO
self.assertEqual(data['product_scancode'], '012345')
self.assertEqual(data['product_full_description'], 'Acme Bricks 1 ton')
self.assertIsNone(data['case_size'])
self.assertNotIn('vendor_name', data) # TODO
self.assertEqual(data['order_qty'], 1)
self.assertEqual(data['order_uom'], 'EA')
self.assertEqual(data['order_qty_display'], '1 Units')
self.assertEqual(data['unit_price_reg'], 3.29)
self.assertEqual(data['unit_price_reg_display'], '$3.29')
self.assertEqual(data['unit_price_reg'], 999.99)
self.assertEqual(data['unit_price_reg_display'], '$999.99')
self.assertNotIn('unit_price_sale', data)
self.assertNotIn('unit_price_sale_display', data)
self.assertNotIn('sale_ends', data)
self.assertNotIn('sale_ends_display', data)
self.assertEqual(data['unit_price_quoted'], 3.29)
self.assertEqual(data['unit_price_quoted_display'], '$3.29')
self.assertEqual(data['unit_price_quoted'], 999.99)
self.assertEqual(data['unit_price_quoted_display'], '$999.99')
self.assertIsNone(data['case_price_quoted'])
self.assertEqual(data['case_price_quoted_display'], '')
self.assertEqual(data['total_price'], 3.29)
self.assertEqual(data['total_price_display'], '$3.29')
self.assertIsNone(data['special_order'])
self.assertEqual(data['total_price'], 999.99)
self.assertEqual(data['total_price_display'], '$999.99')
self.assertFalse(data['special_order'])
self.assertEqual(data['status_code'], row2.STATUS_OK)
self.assertNotIn('pending_product', data)