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', return self.config.get_bool('sideshow.orders.allow_unknown_products',
default=True) 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): def set_customer(self, batch, customer_info, user=None):
""" """
Set/update customer info for the batch. Set/update customer info for the batch.
@ -91,14 +150,14 @@ class NewOrderBatchHandler(BatchHandler):
:class:`~sideshow.db.model.customers.PendingCustomer` record :class:`~sideshow.db.model.customers.PendingCustomer` record
is created if necessary. 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` * :meth:`refresh_batch_from_external_customer()`
* :attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.phone_number` * :meth:`refresh_batch_from_local_customer()`
* :attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.email_address` * :meth:`refresh_batch_from_pending_customer()`
Note that ``customer_info`` may be ``None``, which will cause 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: :param batch:
:class:`~sideshow.db.model.batch.neworder.NewOrderBatch` to :class:`~sideshow.db.model.batch.neworder.NewOrderBatch` to
@ -129,13 +188,11 @@ class NewOrderBatchHandler(BatchHandler):
if not customer: if not customer:
raise ValueError("local customer not found") raise ValueError("local customer not found")
batch.local_customer = customer batch.local_customer = customer
batch.customer_name = customer.full_name self.refresh_batch_from_local_customer(batch)
batch.phone_number = customer.phone_number
batch.email_address = customer.email_address
else: # external customer_id else: # external customer_id
#batch.customer_id = customer_info batch.customer_id = customer_info
raise NotImplementedError self.refresh_batch_from_external_customer(batch)
elif customer_info: elif customer_info:
@ -160,9 +217,7 @@ class NewOrderBatchHandler(BatchHandler):
if 'full_name' not in customer_info: if 'full_name' not in customer_info:
customer.full_name = self.app.make_full_name(customer.first_name, customer.full_name = self.app.make_full_name(customer.first_name,
customer.last_name) customer.last_name)
batch.customer_name = customer.full_name self.refresh_batch_from_pending_customer(batch)
batch.phone_number = customer.phone_number
batch.email_address = customer.email_address
else: else:
@ -175,6 +230,203 @@ class NewOrderBatchHandler(BatchHandler):
session.flush() 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): 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. Add a new item/row to the batch, for given product and quantity.
@ -224,8 +476,7 @@ class NewOrderBatchHandler(BatchHandler):
row.local_product = local row.local_product = local
else: # external product_id else: # external product_id
#row.product_id = product_info row.product_id = product_info
raise NotImplementedError
else: else:
# pending_product # pending_product
@ -313,8 +564,7 @@ class NewOrderBatchHandler(BatchHandler):
row.local_product = local row.local_product = local
else: # external product_id else: # external product_id
#row.product_id = product_info row.product_id = product_info
raise NotImplementedError
else: else:
# pending_product # pending_product
@ -493,6 +743,7 @@ class NewOrderBatchHandler(BatchHandler):
There is no default logic here; subclass must implement as There is no default logic here; subclass must implement as
needed. needed.
""" """
raise NotImplementedError
def remove_row(self, row): 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) pyramid_config = base.make_pyramid_config(settings)
# bring in the rest of Sideshow # bring in the rest of Sideshow
pyramid_config.include('sideshow.web.static') pyramid_config.include('sideshow.web')
pyramid_config.include('wuttaweb.subscribers')
pyramid_config.include('sideshow.web.views')
return pyramid_config.make_wsgi_app() return pyramid_config.make_wsgi_app()

View file

@ -3,15 +3,38 @@
<%def name="form_content()"> <%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> <h3 class="block is-size-3">Products</h3>
<div class="block" style="padding-left: 2rem;"> <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" <b-checkbox name="sideshow.orders.allow_unknown_products"
v-model="simpleSettings['sideshow.orders.allow_unknown_products']" v-model="simpleSettings['sideshow.orders.allow_unknown_products']"
native-value="true" native-value="true"
@input="settingsNeedSaved = true"> @input="settingsNeedSaved = true">
Allow creating orders for "unknown" products Allow creating orders for new/unknown products
</b-checkbox> </b-checkbox>
</b-field> </b-field>

View file

@ -57,6 +57,11 @@ class OrderView(MasterView):
Note that the "edit" view is not exposed here; user must perform Note that the "edit" view is not exposed here; user must perform
various other workflow actions to modify the order. 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 model_class = Order
editable = False editable = False
@ -153,6 +158,24 @@ class OrderView(MasterView):
# total_price # total_price
g.set_renderer('total_price', g.render_currency) 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): def create(self):
""" """
Instead of the typical "create" view, this displays a "wizard" Instead of the typical "create" view, this displays a "wizard"
@ -185,7 +208,7 @@ class OrderView(MasterView):
""" """
enum = self.app.enum enum = self.app.enum
self.creating = True self.creating = True
self.batch_handler = NewOrderBatchHandler(self.config) self.batch_handler = self.get_batch_handler()
batch = self.get_current_batch() batch = self.get_current_batch()
context = self.get_context_customer(batch) context = self.get_context_customer(batch)
@ -223,6 +246,7 @@ class OrderView(MasterView):
try: try:
result = getattr(self, action)(batch, data) result = getattr(self, action)(batch, data)
except Exception as error: except Exception as error:
log.warning("error calling json action for order", exc_info=True)
result = {'error': self.app.render_error(error)} result = {'error': self.app.render_error(error)}
return self.json_response(result) return self.json_response(result)
@ -279,91 +303,49 @@ class OrderView(MasterView):
""" """
AJAX view for customer autocomplete, when entering new order. AJAX view for customer autocomplete, when entering new order.
This should invoke a configured handler for the autocomplete This invokes one of the following on the
behavior, but that is not yet implemented. For now it uses :attr:`batch_handler`:
built-in logic only, which queries the
:class:`~sideshow.db.model.customers.LocalCustomer` table. * :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() session = self.Session()
term = self.request.GET.get('term', '').strip() term = self.request.GET.get('term', '').strip()
if not term: if not term:
return [] return []
return self.mock_autocomplete_customers(session, term, user=self.request.user)
# TODO: move this to some handler handler = self.get_batch_handler()
def mock_autocomplete_customers(self, session, term, user=None): if handler.use_local_customers():
""" """ return handler.autocomplete_customers_local(session, term, user=self.request.user)
import sqlalchemy as sa else:
return handler.autocomplete_customers_external(session, term, user=self.request.user)
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 product_autocomplete(self): def product_autocomplete(self):
""" """
AJAX view for product autocomplete, when entering new order. AJAX view for product autocomplete, when entering new order.
This should invoke a configured handler for the autocomplete This invokes one of the following on the
behavior, but that is not yet implemented. For now it uses :attr:`batch_handler`:
built-in logic only, which queries the
:class:`~sideshow.db.model.products.LocalProduct` table. * :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() session = self.Session()
term = self.request.GET.get('term', '').strip() term = self.request.GET.get('term', '').strip()
if not term: if not term:
return [] return []
return self.mock_autocomplete_products(session, term, user=self.request.user)
# TODO: move this to some handler handler = self.get_batch_handler()
def mock_autocomplete_products(self, session, term, user=None): if handler.use_local_products():
""" """ return handler.autocomplete_products_local(session, term, user=self.request.user)
import sqlalchemy as sa else:
return handler.autocomplete_products_external(session, term, user=self.request.user)
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_pending_product_required_fields(self): def get_pending_product_required_fields(self):
""" """ """ """
@ -531,11 +513,12 @@ class OrderView(MasterView):
if not product_id: if not product_id:
return {'error': "Must specify a product ID"} return {'error': "Must specify a product ID"}
session = self.Session()
use_local = self.batch_handler.use_local_products() use_local = self.batch_handler.use_local_products()
if use_local: if use_local:
data = self.get_local_product_info(product_id) data = self.batch_handler.get_product_info_local(session, product_id)
else: else:
raise NotImplementedError("TODO: add integration handler") data = self.batch_handler.get_product_info_external(session, product_id)
if 'error' in data: if 'error' in data:
return data return data
@ -571,32 +554,6 @@ class OrderView(MasterView):
return data 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): def add_item(self, batch, data):
""" """
This adds a row to the user's current new order batch. 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_brand': row.product_brand,
'product_description': row.product_description, 'product_description': row.product_description,
'product_size': row.product_size, '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, 'product_weighed': row.product_weighed,
'department_display': row.department_name, 'department_display': row.department_name,
'special_order': row.special_order, 'special_order': row.special_order,
@ -751,15 +711,6 @@ class OrderView(MasterView):
else: else:
data['product_id'] = row.product_id 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 # vendor_name
if use_local: if use_local:
if row.local_product: if row.local_product:
@ -912,7 +863,17 @@ class OrderView(MasterView):
""" """ """ """
settings = [ 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 # 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', {'name': 'sideshow.orders.allow_unknown_products',
'type': bool, 'type': bool,
'default': True}, 'default': True},
@ -1045,8 +1006,6 @@ class OrderItemView(MasterView):
'department_id', 'department_id',
'department_name', 'department_name',
'special_order', 'special_order',
'order_qty',
'order_uom',
'case_size', 'case_size',
'unit_cost', 'unit_cost',
'unit_price_reg', 'unit_price_reg',
@ -1054,6 +1013,8 @@ class OrderItemView(MasterView):
'sale_ends', 'sale_ends',
'unit_price_quoted', 'unit_price_quoted',
'case_price_quoted', 'case_price_quoted',
'order_qty',
'order_uom',
'discount_percent', 'discount_percent',
'total_price', 'total_price',
'status_code', 'status_code',

View file

@ -50,6 +50,34 @@ class TestNewOrderBatchHandler(DataTestCase):
config.setdefault('sideshow.orders.allow_unknown_products', 'false') config.setdefault('sideshow.orders.allow_unknown_products', 'false')
self.assertFalse(handler.allow_unknown_products()) 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): def test_set_customer(self):
model = self.app.model model = self.app.model
handler = self.make_handler() handler = self.make_handler()
@ -146,6 +174,83 @@ class TestNewOrderBatchHandler(DataTestCase):
self.assertIsNone(batch.phone_number) self.assertIsNone(batch.phone_number)
self.assertIsNone(batch.email_address) 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): def test_add_item(self):
model = self.app.model model = self.app.model
enum = self.app.enum enum = self.app.enum
@ -719,10 +824,8 @@ class TestNewOrderBatchHandler(DataTestCase):
self.session.add(row) self.session.add(row)
self.session.flush() self.session.flush()
# STATUS_OK # STATUS_OK
row = handler.make_row(product_id=42, order_qty=1, order_uom=enum.ORDER_UOM_UNIT) row = handler.add_item(batch, {'scancode': '07430500132'}, 1, enum.ORDER_UOM_UNIT)
handler.add_row(batch, row) self.session.flush()
self.session.add(row)
self.session.commit()
# only 1 effective row # only 1 effective row
rows = handler.get_effective_rows(batch) rows = handler.get_effective_rows(batch)

View file

@ -177,7 +177,9 @@ class TestOrderView(WebTestCase):
def test_customer_autocomplete(self): def test_customer_autocomplete(self):
model = self.app.model model = self.app.model
handler = self.make_handler()
view = self.make_view() view = self.make_view()
view.batch_handler = handler
with patch.object(view, 'Session', return_value=self.session): with patch.object(view, 'Session', return_value=self.session):
@ -205,9 +207,16 @@ class TestOrderView(WebTestCase):
result = view.customer_autocomplete() result = view.customer_autocomplete()
self.assertEqual(result, []) 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): def test_product_autocomplete(self):
model = self.app.model model = self.app.model
handler = self.make_handler()
view = self.make_view() view = self.make_view()
view.batch_handler = handler
with patch.object(view, 'Session', return_value=self.session): with patch.object(view, 'Session', return_value=self.session):
@ -244,6 +253,11 @@ class TestOrderView(WebTestCase):
result = view.product_autocomplete() result = view.product_autocomplete()
self.assertEqual(result, []) 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): def test_get_pending_product_required_fields(self):
model = self.app.model model = self.app.model
view = self.make_view() view = self.make_view()
@ -529,20 +543,27 @@ class TestOrderView(WebTestCase):
self.assertEqual(context['case_size'], 12) self.assertEqual(context['case_size'], 12)
self.assertEqual(context['unit_price_reg'], 5.99) 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 # error if no product_id
context = view.get_product_info(batch, {}) context = view.get_product_info(batch, {})
self.assertEqual(context, {'error': "Must specify a product ID"}) 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): with patch.object(handler, 'use_local_products', return_value=False):
# external lookup not implemented by default
self.assertRaises(NotImplementedError, view.get_product_info, self.assertRaises(NotImplementedError, view.get_product_info,
batch, {'product_id': '42'}) 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): def test_add_item(self):
model = self.app.model model = self.app.model
enum = self.app.enum enum = self.app.enum
@ -970,33 +991,47 @@ class TestOrderView(WebTestCase):
# the next few tests will morph 2nd row.. # 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 # typical, external product
row2.product_id = '42'
with patch.object(handler, 'use_local_products', return_value=False): 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) data = view.normalize_row(row2)
self.assertEqual(data['uuid'], row2.uuid.hex) self.assertEqual(data['uuid'], row2.uuid.hex)
self.assertEqual(data['sequence'], 2) self.assertEqual(data['sequence'], 2)
self.assertEqual(data['product_id'], '42') self.assertEqual(data['product_id'], '42')
self.assertIsNone(data['product_scancode']) self.assertEqual(data['product_scancode'], '012345')
self.assertNotIn('product_full_description', data) # TODO self.assertEqual(data['product_full_description'], 'Acme Bricks 1 ton')
self.assertIsNone(data['case_size']) self.assertIsNone(data['case_size'])
self.assertNotIn('vendor_name', data) # TODO self.assertNotIn('vendor_name', data) # TODO
self.assertEqual(data['order_qty'], 1) self.assertEqual(data['order_qty'], 1)
self.assertEqual(data['order_uom'], 'EA') self.assertEqual(data['order_uom'], 'EA')
self.assertEqual(data['order_qty_display'], '1 Units') self.assertEqual(data['order_qty_display'], '1 Units')
self.assertEqual(data['unit_price_reg'], 3.29) self.assertEqual(data['unit_price_reg'], 999.99)
self.assertEqual(data['unit_price_reg_display'], '$3.29') self.assertEqual(data['unit_price_reg_display'], '$999.99')
self.assertNotIn('unit_price_sale', data) self.assertNotIn('unit_price_sale', data)
self.assertNotIn('unit_price_sale_display', data) self.assertNotIn('unit_price_sale_display', data)
self.assertNotIn('sale_ends', data) self.assertNotIn('sale_ends', data)
self.assertNotIn('sale_ends_display', data) self.assertNotIn('sale_ends_display', data)
self.assertEqual(data['unit_price_quoted'], 3.29) self.assertEqual(data['unit_price_quoted'], 999.99)
self.assertEqual(data['unit_price_quoted_display'], '$3.29') self.assertEqual(data['unit_price_quoted_display'], '$999.99')
self.assertIsNone(data['case_price_quoted']) self.assertIsNone(data['case_price_quoted'])
self.assertEqual(data['case_price_quoted_display'], '') self.assertEqual(data['case_price_quoted_display'], '')
self.assertEqual(data['total_price'], 3.29) self.assertEqual(data['total_price'], 999.99)
self.assertEqual(data['total_price_display'], '$3.29') self.assertEqual(data['total_price_display'], '$999.99')
self.assertIsNone(data['special_order']) self.assertFalse(data['special_order'])
self.assertEqual(data['status_code'], row2.STATUS_OK) self.assertEqual(data['status_code'], row2.STATUS_OK)
self.assertNotIn('pending_product', data) self.assertNotIn('pending_product', data)