Compare commits

...

20 commits

Author SHA1 Message Date
Lance Edgar 3d956ef875 fix: bump version requirement for wuttaweb 2025-02-21 18:54:21 -06:00
Lance Edgar 1ee398e8fb feat: add basic support to "resolve" a pending product 2025-02-21 18:13:57 -06:00
Lance Edgar 6b4bc3da10 bump: version 0.5.0 → 0.6.0 2025-02-20 09:33:44 -06:00
Lance Edgar edd1f17184 fix: fix customer rendering in OrderItem grids; add sort/filter 2025-02-20 09:04:12 -06:00
Lance Edgar d8b37969c5 fix: track vendor name/SKU per OrderItem
and include vendor name filter by default for Placement, Receiving views
2025-02-20 09:04:10 -06:00
Lance Edgar 7ea83b2715 fix: require store for new orders, if so configured 2025-02-19 19:18:30 -06:00
Lance Edgar f3cca2e370 docs: avoid latest sphinx, per recent error
not sure exactly what the problem here is, but docs cannot build with
sphinx 8.2 - and traceback showed sphinx-toolbox was responsible.  the
latter apparently is required by the `enum-tools[sphinx]` pkg

hopefully we can remove this cap before long..?

cf. https://www.sphinx-doc.org/en/master/changes/index.html#release-8-2-0-released-feb-18-2025
2025-02-19 19:15:50 -06:00
Lance Edgar 4bb4272341 docs: update intersphinx doc links per server migration 2025-02-18 12:15:00 -06:00
Lance Edgar 3ca89a8479 feat: allow re-order past product for new orders
assuming batch has a customer set, with order history

nb. this only uses past *products* and not order qty/uom
2025-02-01 19:39:02 -06:00
Lance Edgar aa31d23fc8 feat: add per-department default item discount 2025-01-30 21:45:10 -06:00
Lance Edgar 7e1d68e2cf fix: move Pricing config to separate section, for orders/configure 2025-01-30 15:46:02 -06:00
Lance Edgar 89e3445ace feat: add config option to show/hide Store ID; default value 2025-01-27 20:33:14 -06:00
Lance Edgar 3ef84ff706 feat: add basic model, views for Stores 2025-01-27 18:15:07 -06:00
Lance Edgar 76075f146c bump: version 0.4.0 → 0.5.0 2025-01-26 17:52:16 -06:00
Lance Edgar d8c834095b feat: add pkg extras for postgres, mysql; update install doc
no longer requires psycopg2 by default; use postgres extra
2025-01-26 17:50:00 -06:00
Lance Edgar a14b97243c docs: add install doc 2025-01-26 13:18:32 -06:00
Lance Edgar df6db3cc56 fix: add setup hook to auto-create Order Admin role 2025-01-26 13:17:35 -06:00
Lance Edgar aeafd9e669 docs: add overview doc 2025-01-26 11:34:26 -06:00
Lance Edgar bdf9e46be5 feat: allow basic support for item discounts 2025-01-25 23:33:49 -06:00
Lance Edgar f8f745c243 fix: bugfix for view order item page template 2025-01-23 20:09:45 -06:00
45 changed files with 3265 additions and 227 deletions

View file

@ -1,3 +1,31 @@
## v0.6.0 (2025-02-20)
### Feat
- allow re-order past product for new orders
- add per-department default item discount
- add config option to show/hide Store ID; default value
- add basic model, views for Stores
### Fix
- fix customer rendering in OrderItem grids; add sort/filter
- track vendor name/SKU per OrderItem
- require store for new orders, if so configured
- move Pricing config to separate section, for orders/configure
## v0.5.0 (2025-01-26)
### Feat
- add pkg extras for postgres, mysql; update install doc
- allow basic support for item discounts
### Fix
- add setup hook to auto-create Order Admin role
- bugfix for view order item page template
## v0.4.0 (2025-01-23)
### Feat

View file

@ -0,0 +1,6 @@
``sideshow.app``
================
.. automodule:: sideshow.app
:members:

View file

@ -0,0 +1,6 @@
``sideshow.db.model.stores``
============================
.. automodule:: sideshow.db.model.stores
:members:

View file

@ -0,0 +1,6 @@
``sideshow.web.views.common``
=============================
.. automodule:: sideshow.web.views.common
:members:

View file

@ -0,0 +1,6 @@
``sideshow.web.views.stores``
=============================
.. automodule:: sideshow.web.views.stores
:members:

View file

@ -30,8 +30,9 @@ exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
intersphinx_mapping = {
'pyramid': ('https://docs.pylonsproject.org/projects/pyramid/en/latest/', None),
'wuttjamaican': ('https://rattailproject.org/docs/wuttjamaican/', None),
'wuttaweb': ('https://rattailproject.org/docs/wuttaweb/', None),
'python': ('https://docs.python.org/3/', None),
'wuttjamaican': ('https://docs.wuttaproject.org/wuttjamaican/', None),
'wuttaweb': ('https://docs.wuttaproject.org/wuttaweb/', None),
}

View file

@ -13,11 +13,16 @@ project.
However as you can see..the API should be fairly well documented but
the narrative docs are pretty scant. That will eventually change.
For an online demo see https://demo.wuttaproject.org/
.. toctree::
:maxdepth: 2
:caption: Documentation:
narr/overview
glossary
narr/install
narr/cli/index
.. toctree::
@ -25,6 +30,7 @@ the narrative docs are pretty scant. That will eventually change.
:caption: Package API:
api/sideshow
api/sideshow.app
api/sideshow.batch
api/sideshow.batch.neworder
api/sideshow.cli
@ -38,6 +44,7 @@ the narrative docs are pretty scant. That will eventually change.
api/sideshow.db.model.customers
api/sideshow.db.model.orders
api/sideshow.db.model.products
api/sideshow.db.model.stores
api/sideshow.enum
api/sideshow.orders
api/sideshow.web
@ -49,6 +56,8 @@ the narrative docs are pretty scant. That will eventually change.
api/sideshow.web.views
api/sideshow.web.views.batch
api/sideshow.web.views.batch.neworder
api/sideshow.web.views.common
api/sideshow.web.views.customers
api/sideshow.web.views.orders
api/sideshow.web.views.products
api/sideshow.web.views.stores

86
docs/narr/install.rst Normal file
View file

@ -0,0 +1,86 @@
==============
Installation
==============
Prerequisites
-------------
You'll need Python >= 3.8, and a database. See also
the Wutta docs:
* :doc:`wuttjamaican:narr/install/prereqs`
* :ref:`wuttjamaican:create-appdb`
But for convenience here is a cheat sheet:
*PostgreSQL*
.. code-block:: sh
sudo apt install build-essential python3-dev python3-venv postgresql libpq-dev
sudo -u postgres createuser sideshow
sudo -u postgres psql -c "ALTER USER sideshow PASSWORD 'mypassword'"
sudo -u postgres createdb -O sideshow sideshow
*MySQL*
.. code-block:: sh
sudo apt install build-essential python3-dev python3-venv default-mysql-server
sudo mysql -e "CREATE USER sideshow@localhost"
sudo mysql -e "ALTER USER sideshow@localhost IDENTIFIED BY 'mypassword'"
sudo mysqladmin create sideshow
sudo mysql -e "GRANT ALL ON sideshow.* TO sideshow@localhost"
Virtual Environment
-------------------
You should use a separate Python virtual environment for Sideshow.
See also :doc:`wuttjamaican:narr/install/venv` but these docs will
assume this exists at ``/srv/envs/sideshow``.
Note that root privileges are required to create the folder, but then
the folder ownership should be changed to whatever you need:
.. code-block:: sh
cd /srv/envs
sudo mkdir -p sideshow
sudo chown myname:myname sideshow
python3 -m venv /srv/envs/sideshow
cd /srv/envs/sideshow
source bin/activate
Install Sideshow
----------------
First install the Sideshow package to your virtual environment. Note
that you must specify which DB backend to use:
.. code-block:: sh
# postgres
bin/pip install Sideshow[postgres]
# mysql
bin/pip install Sideshow[mysql]
Then you can run the Sideshow installer:
.. code-block:: sh
bin/sideshow install
That will prompt you for DB connection info etc. When finished you
can run Sideshow:
.. code-block:: sh
bin/wutta -c app/web.conf webapp

75
docs/narr/overview.rst Normal file
View file

@ -0,0 +1,75 @@
==========
Overview
==========
Here we'll give the high-level view of what Sideshow is/does.
Intended Use Case
-----------------
Sideshow is designed with "brick and mortar" retailers in mind. They
normally sell product "in person" using POS software.
Some retailers allow customers to "place an order" for product which
is not currently in stock. The retailer will then place an order with
their vendor, and ultimately the customer will pay for and receive the
product. Depending on the retailer's business rules, customer may
need to pay for the product up-front, or else at time of pickup.
They may also receive a discount when ordering by the case etc.
Sideshow provides a common system to track such "case / special orders"
- which are just called "orders" in Sideshow. It runs as a web app,
(normally) on the internal network, where staff can access it from any
machine.
Workflow
--------
Staff must first create the :term:`order` in Sideshow. They identify
the customer / contact info, and add item(s) with desired quantity.
Depending on config, staff may be able to create orders for customer
and/or products which do not yet exist in the system.
From there, dedicated workflow pages may be used for various steps:
* Placement - staff indicates item(s) are on order from vendor
* Receiving - staff indicates item(s) arrived from vendor
* Contact - staff indicates customer has been notified
* Delivery - staff indicates customer has picked up item(s)
Each :term:`order item` has a status indicating where it is in the
workflow. Staff can manually override the status if needed, when
unexpected situations arise.
Customer + Product Data
-----------------------
By default, Sideshow stores "local" :term:`customers <local customer>`
and :term:`products <local product>` in its :term:`app database`.
Whenever an order is created for new/unknown customer and/or product,
records are added to the local customer/product tables as needed.
From then on those records are available for lookup when creating new
orders.
However in many cases it's better to query the POS DB for
customer/product data. That way the lookup "just works" from the
staff perspective, and there is no need to store those in Sideshow.
The latter case is referred to as "external" :term:`customers
<external customer>` and :term:`products <external product>`. Some
POS systems are already supported for this:
* CORE-POS via `Sideshow-COREPOS
<https://forgejo.wuttaproject.org/wutta/sideshow-corepos>`_
* ECRS Catapult via `Sideshow-Catapult
<https://forgejo.wuttaproject.org/wutta/sideshow-catapult>`_
(nb. access restricted)
* LOC SMS via `Sideshow-LOCSMS
<https://forgejo.wuttaproject.org/wutta/sideshow-locsms>`_
(nb. access restricted)

View file

@ -5,7 +5,7 @@ build-backend = "hatchling.build"
[project]
name = "Sideshow"
version = "0.4.0"
version = "0.6.0"
description = "Case/Special Order Tracker"
readme = "README.md"
authors = [
@ -32,12 +32,14 @@ classifiers = [
license = {text = "GNU General Public License v3+"}
requires-python = ">= 3.8"
dependencies = [
"psycopg2",
"WuttaWeb>=0.20.5",
"WuttaWeb>=0.21.5",
]
[project.optional-dependencies]
docs = ["Sphinx", "furo", "sphinxcontrib-programoutput", "enum-tools[sphinx]"]
postgres = ["psycopg2"]
mysql = ["mysql-connector-python"]
# TODO: remove sphinx version cap after new sphinx-toolbox release?
docs = ["Sphinx<8.2", "furo", "sphinxcontrib-programoutput", "enum-tools[sphinx]"]
tests = ["pytest-cov", "tox"]
@ -50,6 +52,9 @@ sideshow_libcache = "sideshow.web.static:libcache"
[project.entry-points."paste.app_factory"]
"main" = "sideshow.web.app:main"
[project.entry-points."wutta.app.providers"]
sideshow = "sideshow.app:SideshowAppProvider"
[project.entry-points."wutta.batch.neworder"]
"sideshow" = "sideshow.batch.neworder:NewOrderBatchHandler"

56
src/sideshow/app.py Normal file
View file

@ -0,0 +1,56 @@
# -*- 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 app provider
"""
from wuttjamaican import app as base
class SideshowAppProvider(base.AppProvider):
"""
The :term:`app provider` for Sideshow.
This adds the :meth:`get_order_handler()` method to the :term:`app
handler`.
"""
def get_order_handler(self, **kwargs):
"""
Get the configured :term:`order handler` for the app.
You can specify a custom handler in your :term:`config file`
like:
.. code-block:: ini
[sideshow]
orders.handler_spec = poser.orders:PoserOrderHandler
:returns: Instance of :class:`~sideshow.orders.OrderHandler`.
"""
if 'order_handler' not in self.__dict__:
spec = self.config.get('sideshow.orders.handler_spec',
default='sideshow.orders:OrderHandler')
self.order_handler = self.app.load_object(spec)(self.config)
return self.order_handler

View file

@ -2,7 +2,7 @@
################################################################################
#
# Sideshow -- Case/Special Order Tracker
# Copyright © 2024 Lance Edgar
# Copyright © 2024-2025 Lance Edgar
#
# This file is part of Sideshow.
#
@ -26,6 +26,7 @@ New Order Batch Handler
import datetime
import decimal
from collections import OrderedDict
import sqlalchemy as sa
@ -50,6 +51,14 @@ class NewOrderBatchHandler(BatchHandler):
"""
model_class = NewOrderBatch
def get_default_store_id(self):
"""
Returns the configured default value for
:attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.store_id`,
or ``None``.
"""
return self.config.get('sideshow.orders.default_store_id')
def use_local_customers(self):
"""
Returns boolean indicating whether :term:`local customer`
@ -80,6 +89,32 @@ class NewOrderBatchHandler(BatchHandler):
return self.config.get_bool('sideshow.orders.allow_unknown_products',
default=True)
def allow_item_discounts(self):
"""
Returns boolean indicating whether per-item discounts are
allowed when creating an order.
"""
return self.config.get_bool('sideshow.orders.allow_item_discounts',
default=False)
def allow_item_discounts_if_on_sale(self):
"""
Returns boolean indicating whether per-item discounts are
allowed even when the item is already on sale.
"""
return self.config.get_bool('sideshow.orders.allow_item_discounts_if_on_sale',
default=False)
def get_default_item_discount(self):
"""
Returns the default item discount percentage, e.g. 15.
:rtype: :class:`~python:decimal.Decimal` or ``None``
"""
discount = self.config.get('sideshow.orders.default_item_discount')
if discount:
return decimal.Decimal(discount)
def autocomplete_customers_external(self, session, term, user=None):
"""
Return autocomplete search results for :term:`external
@ -139,6 +174,18 @@ class NewOrderBatchHandler(BatchHandler):
'label': customer.full_name}
return [result(c) for c in customers]
def init_batch(self, batch, session=None, progress=None, **kwargs):
"""
Initialize a new batch.
This sets the
:attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.store_id`,
if the batch does not yet have one and a default is
configured.
"""
if not batch.store_id:
batch.store_id = self.get_default_store_id()
def set_customer(self, batch, customer_info, user=None):
"""
Set/update customer info for the batch.
@ -333,6 +380,21 @@ class NewOrderBatchHandler(BatchHandler):
'label': product.full_description}
return [result(c) for c in products]
def get_default_uom_choices(self):
"""
Returns a list of ordering UOM choices which should be
presented to the user by default.
The built-in logic here will return everything from
:data:`~sideshow.enum.ORDER_UOM`.
:returns: List of dicts, each with ``key`` and ``value``
corresponding to the UOM code and label, respectively.
"""
enum = self.app.enum
return [{'key': key, 'value': val}
for key, val in enum.ORDER_UOM.items()]
def get_product_info_external(self, session, product_id, user=None):
"""
Returns basic info for an :term:`external product` as pertains
@ -342,7 +404,8 @@ class NewOrderBatchHandler(BatchHandler):
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.
There is no default logic here; subclass must implement. See
also :meth:`get_product_info_local()`.
:param session: Current app :term:`db session`.
@ -398,21 +461,58 @@ class NewOrderBatchHandler(BatchHandler):
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.
Returns basic info for a :term:`local 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.
See :meth:`get_product_info_external()` for more explanation.
This method will locate the
:class:`~sideshow.db.model.products.LocalProduct` record, then
(if found) it calls :meth:`normalize_local_product()` and
returns the result.
:param session: Current :term:`db session`.
:param uuid: UUID for the desired
:class:`~sideshow.db.model.products.LocalProduct`.
:param user:
:class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who
is performing the action, if known.
:returns: Dict of product info.
"""
model = self.app.model
product = session.get(model.LocalProduct, uuid)
if not product:
raise ValueError(f"Local Product not found: {uuid}")
return self.normalize_local_product(product)
def normalize_local_product(self, product):
"""
Returns a normalized dict of info for the given :term:`local
product`.
This is called by:
* :meth:`get_product_info_local()`
* :meth:`get_past_products()`
:param product:
:class:`~sideshow.db.model.products.LocalProduct` instance.
:returns: Dict of product info.
The keys for this dict should essentially one-to-one for the
product fields, with one exception:
* ``product_id`` will be set to the product UUID as string
"""
return {
'product_id': product.uuid.hex,
'scancode': product.scancode,
@ -430,7 +530,111 @@ class NewOrderBatchHandler(BatchHandler):
'vendor_item_code': product.vendor_item_code,
}
def add_item(self, batch, product_info, order_qty, order_uom, user=None):
def get_past_orders(self, batch):
"""
Retrieve a (possibly empty) list of past :term:`orders
<order>` for the batch customer.
This is called by :meth:`get_past_products()`.
:param batch:
:class:`~sideshow.db.model.batch.neworder.NewOrderBatch`
instance.
:returns: List of :class:`~sideshow.db.model.orders.Order`
records.
"""
model = self.app.model
session = self.app.get_session(batch)
orders = session.query(model.Order)
if batch.customer_id:
orders = orders.filter(model.Order.customer_id == batch.customer_id)
elif batch.local_customer:
orders = orders.filter(model.Order.local_customer == batch.local_customer)
else:
raise ValueError(f"batch has no customer: {batch}")
orders = orders.order_by(model.Order.created.desc())
return orders.all()
def get_past_products(self, batch, user=None):
"""
Retrieve a (possibly empty) list of products which have been
previously ordered by the batch customer.
Note that this does not return :term:`order items <order
item>`, nor does it return true product records, but rather it
returns a list of dicts. Each will have product info but will
*not* have order quantity etc.
This method calls :meth:`get_past_orders()` and then iterates
through each order item therein. Any duplicated products
encountered will be skipped, so the final list contains unique
products.
Each dict in the result is obtained by calling one of:
* :meth:`normalize_local_product()`
* :meth:`get_product_info_external()`
:param batch:
:class:`~sideshow.db.model.batch.neworder.NewOrderBatch`
instance.
:param user:
:class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who
is performing the action, if known.
:returns: List of product info dicts.
"""
model = self.app.model
session = self.app.get_session(batch)
use_local = self.use_local_products()
user = user or batch.created_by
products = OrderedDict()
# track down all order items for batch contact
for order in self.get_past_orders(batch):
for item in order.items:
# nb. we only need the first match for each product
if use_local:
product = item.local_product
if product and product.uuid not in products:
products[product.uuid] = self.normalize_local_product(product)
elif item.product_id and item.product_id not in products:
products[item.product_id] = self.get_product_info_external(
session, item.product_id, user=user)
products = list(products.values())
for product in products:
price = product['unit_price_reg']
if 'unit_price_reg_display' not in product:
product['unit_price_reg_display'] = self.app.render_currency(price)
if 'unit_price_quoted' not in product:
product['unit_price_quoted'] = price
if 'unit_price_quoted_display' not in product:
product['unit_price_quoted_display'] = product['unit_price_reg_display']
if ('case_price_quoted' not in product
and product.get('unit_price_quoted') is not None
and product.get('case_size') is not None):
product['case_price_quoted'] = product['unit_price_quoted'] * product['case_size']
if ('case_price_quoted_display' not in product
and 'case_price_quoted' in product):
product['case_price_quoted_display'] = self.app.render_currency(
product['case_price_quoted'])
return products
def add_item(self, batch, product_info, order_qty, order_uom,
discount_percent=None, user=None):
"""
Add a new item/row to the batch, for given product and quantity.
@ -451,6 +655,10 @@ class NewOrderBatchHandler(BatchHandler):
:attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.order_uom`
value for the new row.
:param discount_percent: Sets the
:attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.discount_percent`
for the row, if allowed.
:param user:
:class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who
is performing the action. This is used to set
@ -518,12 +726,17 @@ class NewOrderBatchHandler(BatchHandler):
row.order_qty = order_qty
row.order_uom = order_uom
# discount
if self.allow_item_discounts():
row.discount_percent = discount_percent or 0
# add row to batch
self.add_row(batch, row)
session.flush()
return row
def update_item(self, row, product_info, order_qty, order_uom, user=None):
def update_item(self, row, product_info, order_qty, order_uom,
discount_percent=None, user=None):
"""
Update an item/row, per given product and quantity.
@ -544,6 +757,10 @@ class NewOrderBatchHandler(BatchHandler):
:attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.order_uom`
value for the row.
:param discount_percent: Sets the
:attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.discount_percent`
for the row, if allowed.
:param user:
:class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who
is performing the action. This is used to set
@ -608,6 +825,10 @@ class NewOrderBatchHandler(BatchHandler):
row.order_qty = order_qty
row.order_uom = order_uom
# discount
if self.allow_item_discounts():
row.discount_percent = discount_percent or 0
# nb. this may convert float to decimal etc.
session.flush()
session.refresh(row)
@ -675,12 +896,19 @@ class NewOrderBatchHandler(BatchHandler):
# update row total price
row.total_price = None
if row.order_uom == enum.ORDER_UOM_CASE:
# TODO: why are we not using case price again?
# if row.case_price_quoted:
# row.total_price = row.case_price_quoted * row.order_qty
if row.unit_price_quoted is not None and row.case_size is not None:
row.total_price = row.unit_price_quoted * row.case_size * row.order_qty
else: # ORDER_UOM_UNIT (or similar)
if row.unit_price_quoted is not None:
row.total_price = row.unit_price_quoted * row.order_qty
if row.total_price is not None:
if row.discount_percent and self.allow_item_discounts():
row.total_price = (float(row.total_price)
* (100 - float(row.discount_percent))
/ 100.0)
row.total_price = decimal.Decimal(f'{row.total_price:0.2f}')
# update batch if total price changed
@ -710,6 +938,8 @@ class NewOrderBatchHandler(BatchHandler):
row.department_id = product.department_id
row.department_name = product.department_name
row.special_order = product.special_order
row.vendor_name = product.vendor_name
row.vendor_item_code = product.vendor_item_code
row.case_size = product.case_size
row.unit_cost = product.unit_cost
row.unit_price_reg = product.unit_price_reg
@ -731,6 +961,8 @@ class NewOrderBatchHandler(BatchHandler):
row.department_id = product.department_id
row.department_name = product.department_name
row.special_order = product.special_order
row.vendor_name = product.vendor_name
row.vendor_item_code = product.vendor_item_code
row.case_size = product.case_size
row.unit_cost = product.unit_cost
row.unit_price_reg = product.unit_price_reg
@ -789,8 +1021,14 @@ class NewOrderBatchHandler(BatchHandler):
def why_not_execute(self, batch, **kwargs):
"""
By default this checks to ensure the batch has a customer with
phone number, and at least one item.
phone number, and at least one item. It also may check to
ensure the store is assigned, if applicable.
"""
if not batch.store_id:
order_handler = self.app.get_order_handler()
if order_handler.expose_store_id():
return "Must assign the store"
if not batch.customer_name:
return "Must assign the customer"
@ -818,7 +1056,7 @@ class NewOrderBatchHandler(BatchHandler):
By default, this will call:
* :meth:`make_local_customer()`
* :meth:`make_local_products()`
* :meth:`process_pending_products()`
* :meth:`make_new_order()`
And will return the new
@ -830,7 +1068,7 @@ class NewOrderBatchHandler(BatchHandler):
"""
rows = self.get_effective_rows(batch)
self.make_local_customer(batch)
self.make_local_products(batch, rows)
self.process_pending_products(batch, rows)
order = self.make_new_order(batch, rows, user=user, progress=progress, **kwargs)
return order
@ -877,46 +1115,48 @@ class NewOrderBatchHandler(BatchHandler):
session.delete(pending)
session.flush()
def make_local_products(self, batch, rows):
def process_pending_products(self, batch, rows):
"""
If applicable, this converts all :term:`pending products
<pending product>` into :term:`local products <local
product>`.
Process any :term:`pending products <pending product>` which
are present in the batch.
This is called automatically from :meth:`execute()`.
This logic will happen only if :meth:`use_local_products()`
returns true, and the batch has pending instead of local items
(so far).
If :term:`local products <local product>` are used, this will
convert the pending products to local products.
For each affected row, it will create a new
:class:`~sideshow.db.model.products.LocalProduct` record and
populate it from the row
:attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.pending_product`.
The latter is then deleted.
If :term:`external products <external product>` are used, this
will update the pending product records' status to indicate
they are ready to be resolved.
"""
if not self.use_local_products():
return
enum = self.app.enum
model = self.app.model
session = self.app.get_session(batch)
inspector = sa.inspect(model.LocalProduct)
for row in rows:
if row.local_product or not row.pending_product:
continue
if self.use_local_products():
inspector = sa.inspect(model.LocalProduct)
for row in rows:
pending = row.pending_product
local = model.LocalProduct()
if row.local_product or not row.pending_product:
continue
for prop in inspector.column_attrs:
if hasattr(pending, prop.key):
setattr(local, prop.key, getattr(pending, prop.key))
session.add(local)
pending = row.pending_product
local = model.LocalProduct()
row.local_product = local
row.pending_product = None
session.delete(pending)
for prop in inspector.column_attrs:
if hasattr(pending, prop.key):
setattr(local, prop.key, getattr(pending, prop.key))
session.add(local)
row.local_product = local
row.pending_product = None
session.delete(pending)
else: # external products; pending should be marked 'ready'
for row in rows:
pending = row.pending_product
if pending:
pending.status = enum.PendingProductStatus.READY
session.flush()
@ -962,6 +1202,8 @@ class NewOrderBatchHandler(BatchHandler):
'product_weighed',
'department_id',
'department_name',
'vendor_name',
'vendor_item_code',
'case_size',
'order_qty',
'order_uom',
@ -971,7 +1213,7 @@ class NewOrderBatchHandler(BatchHandler):
'unit_price_reg',
'unit_price_sale',
'sale_ends',
# 'discount_percent',
'discount_percent',
'total_price',
'special_order',
]

View file

@ -0,0 +1,41 @@
"""add order_item.vendor*
Revision ID: 13af2ffbc0e0
Revises: a4273360d379
Create Date: 2025-02-19 19:36:30.308840
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import wuttjamaican.db.util
# revision identifiers, used by Alembic.
revision: str = '13af2ffbc0e0'
down_revision: Union[str, None] = 'a4273360d379'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# sideshow_batch_neworder_row
op.add_column('sideshow_batch_neworder_row', sa.Column('vendor_name', sa.String(length=50), nullable=True))
op.add_column('sideshow_batch_neworder_row', sa.Column('vendor_item_code', sa.String(length=20), nullable=True))
# sideshow_order_item
op.add_column('sideshow_order_item', sa.Column('vendor_name', sa.String(length=50), nullable=True))
op.add_column('sideshow_order_item', sa.Column('vendor_item_code', sa.String(length=20), nullable=True))
def downgrade() -> None:
# sideshow_order_item
op.drop_column('sideshow_order_item', 'vendor_item_code')
op.drop_column('sideshow_order_item', 'vendor_name')
# sideshow_batch_neworder_row
op.drop_column('sideshow_batch_neworder_row', 'vendor_item_code')
op.drop_column('sideshow_batch_neworder_row', 'vendor_name')

View file

@ -0,0 +1,39 @@
"""add stores
Revision ID: a4273360d379
Revises: 7a6df83afbd4
Create Date: 2025-01-27 17:48:20.638664
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import wuttjamaican.db.util
# revision identifiers, used by Alembic.
revision: str = 'a4273360d379'
down_revision: Union[str, None] = '7a6df83afbd4'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# sideshow_store
op.create_table('sideshow_store',
sa.Column('uuid', wuttjamaican.db.util.UUID(), nullable=False),
sa.Column('store_id', sa.String(length=10), nullable=False),
sa.Column('name', sa.String(length=100), nullable=False),
sa.Column('archived', sa.Boolean(), nullable=False),
sa.PrimaryKeyConstraint('uuid', name=op.f('pk_sideshow_store')),
sa.UniqueConstraint('store_id', name=op.f('uq_sideshow_store_store_id')),
sa.UniqueConstraint('name', name=op.f('uq_sideshow_store_name'))
)
def downgrade() -> None:
# sideshow_store
op.drop_table('sideshow_store')

View file

@ -0,0 +1,61 @@
"""add ignored status
Revision ID: fd8a2527bd30
Revises: 13af2ffbc0e0
Create Date: 2025-02-20 12:08:27.374172
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import wuttjamaican.db.util
from alembic_postgresql_enum import TableReference
# revision identifiers, used by Alembic.
revision: str = 'fd8a2527bd30'
down_revision: Union[str, None] = '13af2ffbc0e0'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# pendingcustomerstatus
op.sync_enum_values(
enum_schema='public',
enum_name='pendingcustomerstatus',
new_values=['PENDING', 'READY', 'RESOLVED', 'IGNORED'],
affected_columns=[TableReference(table_schema='public', table_name='sideshow_customer_pending', column_name='status')],
enum_values_to_rename=[],
)
# pendingproductstatus
op.sync_enum_values(
enum_schema='public',
enum_name='pendingproductstatus',
new_values=['PENDING', 'READY', 'RESOLVED', 'IGNORED'],
affected_columns=[TableReference(table_schema='public', table_name='sideshow_product_pending', column_name='status')],
enum_values_to_rename=[],
)
def downgrade() -> None:
# pendingproductstatus
op.sync_enum_values(
enum_schema='public',
enum_name='pendingproductstatus',
new_values=['PENDING', 'READY', 'RESOLVED'],
affected_columns=[TableReference(table_schema='public', table_name='sideshow_product_pending', column_name='status')],
enum_values_to_rename=[],
)
# pendingcustomerstatus
op.sync_enum_values(
enum_schema='public',
enum_name='pendingcustomerstatus',
new_values=['PENDING', 'READY', 'RESOLVED'],
affected_columns=[TableReference(table_schema='public', table_name='sideshow_customer_pending', column_name='status')],
enum_values_to_rename=[],
)

View file

@ -30,6 +30,7 @@ This namespace exposes everything from
Primary :term:`data models <data model>`:
* :class:`~sideshow.db.model.stores.Store`
* :class:`~sideshow.db.model.orders.Order`
* :class:`~sideshow.db.model.orders.OrderItem`
* :class:`~sideshow.db.model.orders.OrderItemEvent`
@ -48,6 +49,7 @@ And the :term:`batch` models:
from wuttjamaican.db.model import *
# sideshow models
from .stores import Store
from .customers import LocalCustomer, PendingCustomer
from .products import LocalProduct, PendingProduct
from .orders import Order, OrderItem, OrderItemEvent

View file

@ -252,6 +252,16 @@ class NewOrderBatchRow(model.BatchRowMixin, model.Base):
normally carried by the store. Default is null.
""")
vendor_name = sa.Column(sa.String(length=50), nullable=True, doc="""
Name of vendor from which product may be purchased, if known. See
also :attr:`vendor_item_code`.
""")
vendor_item_code = sa.Column(sa.String(length=20), nullable=True, doc="""
Item code (SKU) to use when ordering this product from the vendor
identified by :attr:`vendor_name`, if known.
""")
case_size = sa.Column(sa.Numeric(precision=10, scale=4), nullable=True, doc="""
Case pack count for the product, if known.

View file

@ -62,6 +62,15 @@ class Order(model.Base):
ID of the store to which the order pertains, if applicable.
""")
store = orm.relationship(
'Store',
primaryjoin='Store.store_id == Order.store_id',
foreign_keys='Order.store_id',
doc="""
Reference to the :class:`~sideshow.db.model.stores.Store`
record, if applicable.
""")
customer_id = sa.Column(sa.String(length=20), nullable=True, doc="""
Proper account ID for the :term:`external customer` to which the
order pertains, if applicable.
@ -244,6 +253,16 @@ class OrderItem(model.Base):
normally carried by the store. Default is null.
""")
vendor_name = sa.Column(sa.String(length=50), nullable=True, doc="""
Name of vendor from which product may be purchased, if known. See
also :attr:`vendor_item_code`.
""")
vendor_item_code = sa.Column(sa.String(length=20), nullable=True, doc="""
Item code (SKU) to use when ordering this product from the vendor
identified by :attr:`vendor_name`, if known.
""")
case_size = sa.Column(sa.Numeric(precision=10, scale=4), nullable=True, doc="""
Case pack count for the product, if known.
""")

View file

@ -185,7 +185,8 @@ class PendingProduct(ProductMixin, model.Base):
uuid = model.uuid_column()
product_id = sa.Column(sa.String(length=20), nullable=True, doc="""
ID of the true product associated with this record, if applicable.
ID of the :term:`external product` associated with this record, if
applicable/known.
""")
status = sa.Column(sa.Enum(PendingProductStatus), nullable=False, doc="""

View file

@ -0,0 +1,62 @@
# -*- coding: utf-8; -*-
################################################################################
#
# Sideshow -- Case/Special Order Tracker
# Copyright © 2024-2025 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/>.
#
################################################################################
"""
Data models for Stores
"""
import sqlalchemy as sa
from wuttjamaican.db import model
class Store(model.Base):
"""
Represents a physical location for the business.
"""
__tablename__ = 'sideshow_store'
uuid = model.uuid_column()
store_id = sa.Column(sa.String(length=10), nullable=False, unique=True, doc="""
Unique ID for the store.
""")
name = sa.Column(sa.String(length=100), nullable=False, unique=True, doc="""
Display name for the store (must be unique!).
""")
archived = sa.Column(sa.Boolean(), nullable=False, default=False, doc="""
Indicates the store has been "retired" essentially, and mostly
hidden from view.
""")
def __str__(self):
return self.get_display()
def get_display(self):
"""
Returns the display string for the store, e.g. "001 Acme Goods".
"""
return ' '.join([(self.store_id or '').strip(),
(self.name or '').strip()])\
.strip()

View file

@ -91,6 +91,7 @@ class PendingCustomerStatus(Enum):
PENDING = 'pending'
READY = 'ready'
RESOLVED = 'resolved'
IGNORED = 'ignored'
class PendingProductStatus(Enum):
@ -101,6 +102,7 @@ class PendingProductStatus(Enum):
PENDING = 'pending'
READY = 'ready'
RESOLVED = 'resolved'
IGNORED = 'ignored'
########################################

View file

@ -37,6 +37,14 @@ class OrderHandler(GenericHandler):
handler is responsible for creation logic.)
"""
def expose_store_id(self):
"""
Returns boolean indicating whether the ``store_id`` field
should be exposed at all. This is false by default.
"""
return self.config.get_bool('sideshow.orders.expose_store_id',
default=False)
def get_order_qty_uom_text(self, order_qty, order_uom, case_size=None, html=False):
"""
Return the display text for a given order quantity.
@ -97,6 +105,81 @@ class OrderHandler(GenericHandler):
enum.ORDER_ITEM_STATUS_INACTIVE):
return 'warning'
def resolve_pending_product(self, pending_product, product_info, user, note=None):
"""
Resolve a :term:`pending product`, to reflect the given
product info.
At a high level this does 2 things:
* update the ``pending_product``
* find and update any related :term:`order item(s) <order item>`
The first step just sets
:attr:`~sideshow.db.model.products.PendingProduct.product_id`
from the provided info, and gives it the "resolved" status.
Note that it does *not* update the pending product record
further, so it will not fully "match" the product info.
The second step will fetch all
:class:`~sideshow.db.model.orders.OrderItem` records which
reference the ``pending_product`` **and** which do not yet
have a ``product_id`` value. For each, it then updates the
order item to contain all data from ``product_info``. And
finally, it adds an event to the item history, indicating who
resolved and when. (If ``note`` is specified, a *second*
event is added for that.)
:param pending_product:
:class:`~sideshow.db.model.products.PendingProduct` to be
resolved.
:param product_info: Dict of product info, as obtained from
:meth:`~sideshow.batch.neworder.NewOrderBatchHandler.get_product_info_external()`.
:param user:
:class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who
is performing the action.
:param note: Optional note to be added to event history for
related order item(s).
"""
enum = self.app.enum
model = self.app.model
session = self.app.get_session(pending_product)
if pending_product.status != enum.PendingProductStatus.READY:
raise ValueError("pending product does not have 'ready' status")
info = product_info
pending_product.product_id = info['product_id']
pending_product.status = enum.PendingProductStatus.RESOLVED
items = session.query(model.OrderItem)\
.filter(model.OrderItem.pending_product == pending_product)\
.filter(model.OrderItem.product_id == None)\
.all()
for item in items:
item.product_id = info['product_id']
item.product_scancode = info['scancode']
item.product_brand = info['brand_name']
item.product_description = info['description']
item.product_size = info['size']
item.product_weighed = info['weighed']
item.department_id = info['department_id']
item.department_name = info['department_name']
item.special_order = info['special_order']
item.vendor_name = info['vendor_name']
item.vendor_item_code = info['vendor_item_code']
item.case_size = info['case_size']
item.unit_cost = info['unit_cost']
item.unit_price_reg = info['unit_price_reg']
item.add_event(enum.ORDER_ITEM_EVENT_PRODUCT_RESOLVED, user)
if note:
item.add_event(enum.ORDER_ITEM_EVENT_NOTE_ADDED, user, note=note)
def process_placement(self, items, user, vendor_name=None, po_number=None, note=None):
"""
Process the "placement" step for the given order items.

View file

@ -2,7 +2,7 @@
################################################################################
#
# Sideshow -- Case/Special Order Tracker
# Copyright © 2024 Lance Edgar
# Copyright © 2024-2025 Lance Edgar
#
# This file is part of Sideshow.
#
@ -33,4 +33,6 @@ class WebTestCase(base.WebTestCase):
config = super().make_config(**kwargs)
config.setdefault('wutta.model_spec', 'sideshow.db.model')
config.setdefault('wutta.enum_spec', 'sideshow.enum')
config.setdefault(f'{config.appname}.batch.neworder.handler.default_spec',
'sideshow.batch.neworder:NewOrderBatchHandler')
return config

View file

@ -162,4 +162,12 @@ class SideshowMenuHandler(base.MenuHandler):
def make_admin_menu(self, request, **kwargs):
""" """
kwargs['include_people'] = True
return super().make_admin_menu(request, **kwargs)
menu = super().make_admin_menu(request, **kwargs)
menu['items'].insert(0, {
'title': "Stores",
'route': 'stores',
'perm': 'stores.list',
})
return menu

View file

@ -0,0 +1,8 @@
## -*- coding: utf-8; -*-
<%inherit file="wuttaweb:templates/base.mako" />
<%namespace file="/sideshow-components.mako" import="make_sideshow_components" />
<%def name="render_vue_templates()">
${make_sideshow_components()}
${parent.render_vue_templates()}
</%def>

View file

@ -29,6 +29,17 @@
<b-field horizontal label="ID">
<span>${h.link_to(f"Order ID {order.order_id}", url('orders.view', uuid=order.uuid))} &mdash; Item #${item.sequence}</span>
</b-field>
% if expose_store_id:
<b-field horizontal label="Store">
<span>
% if order.store:
${h.link_to(order.store.get_display(), url('stores.view', uuid=order.store.uuid))}
% elif order.store_id:
${order.store_id}
% endif
</span>
</b-field>
% endif
<b-field horizontal label="Order Qty">
<span>${order_qty_uom_text|n}</span>
</b-field>
@ -196,39 +207,48 @@
<div class="panel-block">
<div style="width: 100%;">
<b-field horizontal label="Product ID">
<span>${item.product_id}</span>
<span>${item.product_id or ''}</span>
</b-field>
% if not item.product_id and item.local_product:
<b-field horizontal label="Local Product">
<span>${h.link_to(item.local_product, url('local_products.view', uuid=order.local_product.uuid))}</span>
<span>${h.link_to(item.local_product, url('local_products.view', uuid=item.local_product.uuid))}</span>
</b-field>
% endif
% if not item.product_id and item.pending_product:
<b-field horizontal label="Pending Product">
<span>${h.link_to(item.pending_product, url('pending_products.view', uuid=order.pending_product.uuid))}</span>
<span>${h.link_to(item.pending_product, url('pending_products.view', uuid=item.pending_product.uuid))}</span>
</b-field>
% endif
<b-field horizontal label="Scancode">
<span>${item.product_scancode}</span>
<span>${item.product_scancode or ''}</span>
</b-field>
<b-field horizontal label="Brand">
<span>${item.product_brand}</span>
<span>${item.product_brand or ''}</span>
</b-field>
<b-field horizontal label="Description">
<span>${item.product_description}</span>
<span>${item.product_description or ''}</span>
</b-field>
<b-field horizontal label="Size">
<span>${item.product_size}</span>
<span>${item.product_size or ''}</span>
</b-field>
<b-field horizontal label="Sold by Weight">
<span>${app.render_boolean(item.product_weighed)}</span>
</b-field>
<b-field horizontal label="Department">
<span>${item.department_name}</span>
<b-field horizontal label="Department ID">
<span>${item.department_id or ''}</span>
</b-field>
<b-field horizontal label="Department Name">
<span>${item.department_name or ''}</span>
</b-field>
<b-field horizontal label="Special Order">
<span>${app.render_boolean(item.special_order)}</span>
</b-field>
<b-field horizontal label="Vendor Name">
<span>${item.vendor_name or ''}</span>
</b-field>
<b-field horizontal label="Vendor Item Code">
<span>${item.vendor_item_code or ''}</span>
</b-field>
</div>
</div>
</nav>

View file

@ -3,6 +3,28 @@
<%def name="form_content()">
<h3 class="block is-size-3">Stores</h3>
<div class="block" style="padding-left: 2rem;">
<b-field>
<b-checkbox name="sideshow.orders.expose_store_id"
v-model="simpleSettings['sideshow.orders.expose_store_id']"
native-value="true"
@input="settingsNeedSaved = true">
Show/choose the Store ID for each order
</b-checkbox>
</b-field>
<b-field v-show="simpleSettings['sideshow.orders.expose_store_id']"
label="Default Store ID">
<b-input name="sideshow.orders.default_store_id"
v-model="simpleSettings['sideshow.orders.default_store_id']"
@input="settingsNeedSaved = true"
style="width: 25rem;" />
</b-field>
</div>
<h3 class="block is-size-3">Customers</h3>
<div class="block" style="padding-left: 2rem;">
@ -14,6 +36,7 @@
<option value="false">External Customers (e.g. in POS)</option>
</b-select>
</b-field>
</div>
<h3 class="block is-size-3">Products</h3>
@ -62,6 +85,136 @@
</div>
</div>
<h3 class="block is-size-3">Pricing</h3>
<div class="block" style="padding-left: 2rem;">
<b-field>
<b-checkbox name="sideshow.orders.allow_item_discounts"
v-model="simpleSettings['sideshow.orders.allow_item_discounts']"
native-value="true"
@input="settingsNeedSaved = true">
Allow per-item discounts
</b-checkbox>
</b-field>
<b-field v-show="simpleSettings['sideshow.orders.allow_item_discounts']">
<b-checkbox name="sideshow.orders.allow_item_discounts_if_on_sale"
v-model="simpleSettings['sideshow.orders.allow_item_discounts_if_on_sale']"
native-value="true"
@input="settingsNeedSaved = true">
Allow discount even if item is on sale
</b-checkbox>
</b-field>
<div v-show="simpleSettings['sideshow.orders.allow_item_discounts']"
class="block"
style="display: flex; gap: 0.5rem; align-items: center;">
<span>Global default item discount</span>
<b-input name="sideshow.orders.default_item_discount"
v-model="simpleSettings['sideshow.orders.default_item_discount']"
@input="settingsNeedSaved = true"
style="width: 5rem;" />
<span>%</span>
</div>
<div v-show="simpleSettings['sideshow.orders.allow_item_discounts']"
style="width: 50%;">
<div style="display: flex; gap: 1rem; align-items: center;">
<p>Per-Department default item discounts</p>
<div>
<b-button type="is-primary"
@click="deptItemDiscountInit()"
icon-pack="fas"
icon-left="plus">
Add
</b-button>
<input type="hidden" name="dept_item_discounts" :value="JSON.stringify(deptItemDiscounts)" />
<${b}-modal has-modal-card
% if request.use_oruga:
v-model:active="deptItemDiscountShowDialog"
% else:
:active.sync="deptItemDiscountShowDialog"
% endif
>
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">Default Discount for Department</p>
</header>
<section class="modal-card-body">
<div style="display: flex; gap: 1rem;">
<b-field label="Dept. ID"
:type="deptItemDiscountDeptID ? null : 'is-danger'">
<b-input v-model="deptItemDiscountDeptID"
ref="deptItemDiscountDeptID"
style="width: 6rem;;" />
</b-field>
<b-field label="Department Name"
:type="deptItemDiscountDeptName ? null : 'is-danger'"
style="flex-grow: 1;">
<b-input v-model="deptItemDiscountDeptName" />
</b-field>
<b-field label="Discount"
:type="deptItemDiscountPercent ? null : 'is-danger'">
<div style="display: flex; gap: 0.5rem; align-items: center;">
<b-input v-model="deptItemDiscountPercent"
ref="deptItemDiscountPercent"
style="width: 6rem;" />
<span>%</span>
</div>
</b-field>
</div>
</section>
<footer class="modal-card-foot">
<b-button type="is-primary"
icon-pack="fas"
icon-left="save"
:disabled="deptItemDiscountSaveDisabled"
@click="deptItemDiscountSave()">
Save
</b-button>
<b-button @click="deptItemDiscountShowDialog = false">
Cancel
</b-button>
</footer>
</div>
</${b}-modal>
</div>
</div>
<${b}-table :data="deptItemDiscounts">
<${b}-table-column field="department_id"
label="Dept. ID"
v-slot="props">
{{ props.row.department_id }}
</${b}-table-column>
<${b}-table-column field="department_name"
label="Department Name"
v-slot="props">
{{ props.row.department_name }}
</${b}-table-column>
<${b}-table-column field="default_item_discount"
label="Discount"
v-slot="props">
{{ props.row.default_item_discount }} %
</${b}-table-column>
<${b}-table-column label="Actions"
v-slot="props">
<a href="#" @click.prevent="deptItemDiscountInit(props.row)">
<i class="fas fa-edit" />
Edit
</a>
<a href="#" @click.prevent="deptItemDiscountDelete(props.row)"
class="has-text-danger">
<i class="fas fa-trash" />
Delete
</a>
</${b}-table-column>
</${b}-table>
</div>
</div>
<h3 class="block is-size-3">Batches</h3>
<div class="block" style="padding-left: 2rem;">
@ -88,5 +241,62 @@
ThisPageData.batchHandlers = ${json.dumps(batch_handlers)|n}
ThisPageData.deptItemDiscounts = ${json.dumps(dept_item_discounts)|n}
ThisPageData.deptItemDiscountShowDialog = false
ThisPageData.deptItemDiscountRow = null
ThisPageData.deptItemDiscountDeptID = null
ThisPageData.deptItemDiscountDeptName = null
ThisPageData.deptItemDiscountPercent = null
ThisPage.computed.deptItemDiscountSaveDisabled = function() {
if (!this.deptItemDiscountDeptID) {
return true
}
if (!this.deptItemDiscountDeptName) {
return true
}
if (!this.deptItemDiscountPercent) {
return true
}
return false
}
ThisPage.methods.deptItemDiscountDelete = function(row) {
const i = this.deptItemDiscounts.indexOf(row)
this.deptItemDiscounts.splice(i, 1)
this.settingsNeedSaved = true
}
ThisPage.methods.deptItemDiscountInit = function(row) {
this.deptItemDiscountRow = row
this.deptItemDiscountDeptID = row?.department_id
this.deptItemDiscountDeptName = row?.department_name
this.deptItemDiscountPercent = row?.default_item_discount
this.deptItemDiscountShowDialog = true
this.$nextTick(() => {
if (row) {
this.$refs.deptItemDiscountPercent.focus()
} else {
this.$refs.deptItemDiscountDeptID.focus()
}
})
}
ThisPage.methods.deptItemDiscountSave = function() {
if (this.deptItemDiscountRow) {
this.deptItemDiscountRow.department_id = this.deptItemDiscountDeptID
this.deptItemDiscountRow.department_name = this.deptItemDiscountDeptName
this.deptItemDiscountRow.default_item_discount = this.deptItemDiscountPercent
} else {
this.deptItemDiscounts.push({
department_id: this.deptItemDiscountDeptID,
department_name: this.deptItemDiscountDeptName,
default_item_discount: this.deptItemDiscountPercent,
})
}
this.deptItemDiscountShowDialog = false
this.settingsNeedSaved = true
}
</script>
</%def>

View file

@ -42,7 +42,25 @@
<script type="text/x-template" id="order-creator-template">
<div>
${self.order_form_buttons()}
<div style="display: flex; justify-content: space-between; margin-bottom: 1.5rem;">
<div>
% if expose_store_id:
<b-loading v-model="storeLoading" is-full-page />
<b-field label="Store" horizontal
:type="storeID ? null : 'is-danger'">
<b-select v-model="storeID"
@input="storeChanged">
<option v-for="store in stores"
:key="store.store_id"
:value="store.store_id">
{{ store.display }}
</option>
</b-select>
</b-field>
% endif
</div>
${self.order_form_buttons()}
</div>
<${b}-collapse class="panel"
:class="customerPanelType"
@ -337,6 +355,135 @@
@click="showAddItemDialog()">
Add Item
</b-button>
% if allow_past_item_reorder:
<b-button v-if="customerIsKnown && customerID"
icon-pack="fas"
icon-left="plus"
@click="showAddPastItem()">
Add Past Item
</b-button>
<${b}-modal
% if request.use_oruga:
v-model:active="pastItemsShowDialog"
% else:
:active.sync="pastItemsShowDialog"
% endif
>
<div class="card">
<div class="card-content">
<${b}-table :data="pastItems"
icon-pack="fas"
:loading="pastItemsLoading"
% if request.use_oruga:
v-model:selected="pastItemsSelected"
% else:
:selected.sync="pastItemsSelected"
% endif
sortable
paginated
per-page="5"
## :debounce-search="1000"
>
<${b}-table-column label="Scancode"
field="key"
v-slot="props"
sortable>
{{ props.row.scancode }}
</${b}-table-column>
<${b}-table-column label="Brand"
field="brand_name"
v-slot="props"
sortable
searchable>
{{ props.row.brand_name }}
</${b}-table-column>
<${b}-table-column label="Description"
field="description"
v-slot="props"
sortable
searchable>
{{ props.row.description }}
{{ props.row.size }}
</${b}-table-column>
<${b}-table-column label="Unit Price"
field="unit_price_reg_display"
v-slot="props"
sortable>
{{ props.row.unit_price_reg_display }}
</${b}-table-column>
<${b}-table-column label="Sale Price"
field="sale_price"
v-slot="props"
sortable>
<span class="has-background-warning">
{{ props.row.sale_price_display }}
</span>
</${b}-table-column>
<${b}-table-column label="Sale Ends"
field="sale_ends"
v-slot="props"
sortable>
<span class="has-background-warning">
{{ props.row.sale_ends_display }}
</span>
</${b}-table-column>
<${b}-table-column label="Department"
field="department_name"
v-slot="props"
sortable
searchable>
{{ props.row.department_name }}
</${b}-table-column>
<${b}-table-column label="Vendor"
field="vendor_name"
v-slot="props"
sortable
searchable>
{{ props.row.vendor_name }}
</${b}-table-column>
<template #empty>
<div class="content has-text-grey has-text-centered">
<p>
<b-icon
pack="fas"
icon="sad-tear"
size="is-large">
</b-icon>
</p>
<p>Nothing here.</p>
</div>
</template>
</${b}-table>
<div class="buttons">
<b-button @click="pastItemsShowDialog = false">
Cancel
</b-button>
<b-button type="is-primary"
icon-pack="fas"
icon-left="plus"
@click="pastItemsAddSelected()"
:disabled="!pastItemsSelected">
Add Selected Item
</b-button>
</div>
</div>
</div>
</${b}-modal>
% endif
</div>
<${b}-modal
@ -376,12 +523,10 @@
<div style="flex-grow: 1;">
<b-field label="Product">
<wutta-autocomplete ref="productAutocomplete"
v-model="productID"
:display="productDisplay"
service-url="${url(f'{route_prefix}.product_autocomplete')}"
placeholder="Enter brand, description etc."
@input="productChanged" />
<sideshow-product-lookup v-model="productID"
ref="productLookup"
:display="productDisplay"
@input="productChanged" />
</b-field>
<div v-if="productID">
@ -486,7 +631,16 @@
<b-input v-model="pendingProduct.scancode" />
</b-field>
<b-field label="Department"
<b-field label="Dept. ID"
% if 'department_id' in pending_product_required_fields:
:type="pendingProduct.department_id ? null : 'is-danger'"
% endif
style="width: 15rem;">
<b-input v-model="pendingProduct.department_id"
@input="updateDiscount" />
</b-field>
<b-field label="Department Name"
% if 'department_name' in pending_product_required_fields:
:type="pendingProduct.department_name ? null : 'is-danger'"
% endif
@ -494,8 +648,7 @@
<b-input v-model="pendingProduct.department_name" />
</b-field>
<b-field label="Special Order"
style="width: 100%;">
<b-field label="Special Order">
<b-checkbox v-model="pendingProduct.special_order" />
</b-field>
@ -663,11 +816,11 @@
<b-field label="Discount" horizontal>
<div class="level">
<div class="level-item">
<numeric-input v-model="productDiscountPercent"
@input="refreshTotalPrice += 1"
style="width: 5rem;"
:disabled="!allowItemDiscount">
</numeric-input>
## TODO: needs numeric-input component
<b-input v-model="productDiscountPercent"
@input="refreshTotalPrice += 1"
style="width: 5rem;"
:disabled="!allowItemDiscount" />
</div>
<div class="level-item">
<span>&nbsp;%</span>
@ -732,7 +885,7 @@
<${b}-table-column label="Department"
v-slot="props">
{{ props.row.department_display }}
{{ props.row.department_name }}
</${b}-table-column>
<${b}-table-column label="Quantity"
@ -749,6 +902,13 @@
</span>
</${b}-table-column>
% if allow_item_discounts:
<${b}-table-column label="Discount"
v-slot="props">
{{ props.row.discount_percent }}{{ props.row.discount_percent ? " %" : "" }}
</${b}-table-column>
% endif
<${b}-table-column label="Total"
v-slot="props">
<span :class="props.row.pricing_reflects_sale ? 'has-background-warning' : null">
@ -830,6 +990,12 @@
batchTotalPriceDisplay: ${json.dumps(normalized_batch['total_price_display'])|n},
% if expose_store_id:
stores: ${json.dumps(stores)|n},
storeID: ${json.dumps(batch.store_id)|n},
storeLoading: false,
% endif
customerPanelOpen: false,
customerLoading: false,
customerIsKnown: ${json.dumps(customer_is_known)|n},
@ -890,12 +1056,26 @@
productUOM: defaultUOM,
productCaseSize: null,
% if allow_item_discounts:
defaultItemDiscount: ${json.dumps(default_item_discount)|n},
deptItemDiscounts: ${json.dumps(dept_item_discounts)|n},
allowDiscountsIfOnSale: ${json.dumps(allow_item_discounts_if_on_sale)|n},
productDiscountPercent: null,
% endif
pendingProduct: {},
pendingProductRequiredFields: ${json.dumps(pending_product_required_fields)|n},
## TODO
## departmentOptions: ${json.dumps(department_options)|n},
departmentOptions: [],
% if allow_past_item_reorder:
pastItemsShowDialog: false,
pastItemsLoading: false,
pastItems: [],
pastItemsSelected: null,
% endif
// nb. hack to force refresh for vue3
refreshProductDescription: 1,
refreshTotalPrice: 1,
@ -1011,6 +1191,19 @@
return text
},
% if allow_item_discounts:
allowItemDiscount() {
if (!this.allowDiscountsIfOnSale) {
if (this.productSalePriceDisplay) {
return false
}
}
return true
},
% endif
pendingProductGrossMargin() {
let cost = this.pendingProduct.unit_cost
let price = this.pendingProduct.unit_price_reg
@ -1135,8 +1328,31 @@
})
},
% if expose_store_id:
storeChanged(storeID) {
this.storeLoading = true
const params = {
action: 'set_store',
store_id: storeID,
}
this.submitBatchData(params, ({data}) => {
this.storeLoading = false
}, response => {
this.$buefy.toast.open({
message: "Update failed: " + (response.data.error || "(unknown error)"),
type: 'is-danger',
duration: 2000, // 2 seconds
})
this.storeLoading = false
})
},
% endif
customerChanged(customerID, callback) {
this.customerLoading = true
this.pastItems = []
const params = {}
if (customerID) {
@ -1188,6 +1404,23 @@
})
},
% if allow_item_discounts:
updateDiscount(deptID) {
if (deptID) {
// nb. our map requires ID as string
deptID = deptID.toString()
}
const i = Object.keys(this.deptItemDiscounts).indexOf(deptID)
if (i == -1) {
this.productDiscountPercent = this.defaultItemDiscount
} else {
this.productDiscountPercent = this.deptItemDiscounts[deptID]
}
},
% endif
editNewCustomerSave() {
this.editNewCustomerSaving = true
@ -1324,6 +1557,10 @@
this.productSalePriceDisplay = null
this.productSaleEndsDisplay = null
this.productUnitChoices = this.defaultUnitChoices
% if allow_item_discounts:
this.productDiscountPercent = this.defaultItemDiscount
% endif
},
productChanged(productID) {
@ -1360,6 +1597,18 @@
this.productSalePriceDisplay = data.unit_price_sale_display
this.productSaleEndsDisplay = data.sale_ends_display
% if allow_item_discounts:
if (this.allowItemDiscount) {
if (data?.default_item_discount != null) {
this.productDiscountPercent = data.default_item_discount
} else {
this.updateDiscount(data?.department_id)
}
} else {
this.productDiscountPercent = null
}
% endif
// this.setProductUnitChoices(data.uom_choices)
% if request.use_oruga:
@ -1434,6 +1683,10 @@
this.productUnitChoices = this.defaultUnitChoices
this.productUOM = this.defaultUOM
% if allow_item_discounts:
this.productDiscountPercent = this.defaultItemDiscount
% endif
% if request.use_oruga:
this.itemDialogTab = 'product'
% else:
@ -1441,8 +1694,7 @@
% endif
this.editItemShowDialog = true
this.$nextTick(() => {
// this.$refs.productLookup.focus()
this.$refs.productAutocomplete.focus()
this.$refs.productLookup.focus()
})
},
@ -1485,9 +1737,13 @@
## this.productSpecialOrder = row.special_order
this.productQuantity = row.order_qty
this.productUnitChoices = row.order_uom_choices
this.productUnitChoices = row?.order_uom_choices || this.defaultUnitChoices
this.productUOM = row.order_uom
% if allow_item_discounts:
this.productDiscountPercent = row.discount_percent
% endif
// nb. hack to force refresh for vue3
this.refreshProductDescription += 1
this.refreshTotalPrice += 1
@ -1523,12 +1779,77 @@
})
},
% if allow_past_item_reorder:
showAddPastItem() {
this.pastItemsSelected = null
if (!this.pastItems.length) {
this.pastItemsLoading = true
const params = {action: 'get_past_products'}
this.submitBatchData(params, ({data}) => {
this.pastItems = data
this.pastItemsLoading = false
})
}
this.pastItemsShowDialog = true
},
pastItemsAddSelected() {
this.pastItemsShowDialog = false
const selected = this.pastItemsSelected
this.editItemRow = null
this.productIsKnown = true
this.productID = selected.product_id
this.selectedProduct = {
product_id: selected.product_id,
full_description: selected.full_description,
// url: selected.product_url,
}
this.productDisplay = selected.full_description
this.productScancode = selected.scancode
this.productSize = selected.size
this.productCaseQuantity = selected.case_size
this.productUnitPrice = selected.unit_price_quoted
this.productUnitPriceDisplay = selected.unit_price_quoted_display
this.productUnitRegularPriceDisplay = selected.unit_price_reg_display
this.productCasePrice = selected.case_price_quoted
this.productCasePriceDisplay = selected.case_price_quoted_display
this.productSalePrice = selected.unit_price_sale
this.productSalePriceDisplay = selected.unit_price_sale_display
this.productSaleEndsDisplay = selected.sale_ends_display
this.productSpecialOrder = selected.special_order
this.productQuantity = 1
this.productUnitChoices = selected?.order_uom_choices || this.defaultUnitChoices
this.productUOM = selected?.order_uom || this.defaultUOM
% if allow_item_discounts:
this.updateDiscount(selected.department_id)
% endif
// nb. hack to force refresh for vue3
this.refreshProductDescription += 1
this.refreshTotalPrice += 1
% if request.use_oruga:
this.itemDialogTab = 'quantity'
% else:
this.itemDialogTabIndex = 1
% endif
this.editItemShowDialog = true
},
% endif
itemDialogAttemptSave() {
this.itemDialogSaving = true
this.editItemLoading = true
const params = {
order_qty: this.productQuantity,
order_qty: parseFloat(this.productQuantity),
order_uom: this.productUOM,
}
@ -1538,6 +1859,12 @@
params.product_info = this.pendingProduct
}
% if allow_item_discounts:
if (this.productDiscountPercent) {
params.discount_percent = parseFloat(this.productDiscountPercent)
}
% endif
if (this.editItemRow) {
params.action = 'update_item'
params.uuid = this.editItemRow.uuid

View file

@ -0,0 +1,166 @@
## -*- coding: utf-8; -*-
<%inherit file="/master/view.mako" />
<%def name="tool_panels()">
${parent.tool_panels()}
<wutta-tool-panel heading="Status" style="white-space: nowrap;">
<b-field horizontal label="Current Status">
<span>${instance.status.value}</span>
</b-field>
% if instance.status.name == 'READY' and master.has_perm('resolve') and not use_local_products:
<b-button type="is-primary"
icon-pack="fas"
icon-left="object-ungroup"
@click="resolveInit()">
Resolve Product
</b-button>
<b-modal :active.sync="resolveShowDialog">
<div class="card">
<div class="card-content">
${h.form(master.get_action_url('resolve', instance), **{'@submit': 'resolveSubmitting = true'})}
${h.csrf_token(request)}
<div style="display: flex; gap: 1rem;">
<div style="flex-grow: 1;">
<p class="block has-text-weight-bold">
Please identify the corresponding External Product.
</p>
<p class="block">
All related orders etc. will be updated accordingly.
</p>
<b-field grouped>
<b-field label="Scancode">
<span>${instance.scancode or ''}</span>
</b-field>
<b-field label="Brand">
<span>${instance.brand_name or ''}</span>
</b-field>
<b-field label="Description">
<span>${instance.description or ''}</span>
</b-field>
<b-field label="Size">
<span>${instance.size or ''}</span>
</b-field>
</b-field>
<b-field grouped>
<b-field label="Vendor Name">
<span>${instance.vendor_name or ''}</span>
</b-field>
<b-field label="Vendor Item Code">
<span>${instance.vendor_item_code or ''}</span>
</b-field>
</b-field>
</div>
<div style="flex-grow: 1;">
<b-field label="External Product">
<div>
<sideshow-product-lookup v-model="resolveProductID"
ref="productLookup" />
${h.hidden('product_id', **{':value': 'resolveProductID'})}
</div>
</b-field>
</div>
</div>
<footer>
<div class="buttons">
<b-button @click="resolveShowDialog = false">
Cancel
</b-button>
<b-button type="is-primary"
native-type="submit"
icon-pack="fas"
icon-left="object-ungroup"
:disabled="resolveSubmitting">
{{ resolveSubmitting ? "Working, please wait..." : "Resolve" }}
</b-button>
</div>
</footer>
${h.end_form()}
</div>
</div>
</b-modal>
% endif
% if instance.status.name == 'READY' and master.has_perm('ignore') and not use_local_products:
<b-button type="is-warning"
icon-pack="fas"
icon-left="ban"
@click="ignoreShowDialog = true">
Ignore Product
</b-button>
<b-modal has-modal-card
:active.sync="ignoreShowDialog">
<div class="modal-card">
${h.form(master.get_action_url('ignore', instance), **{'@submit': 'ignoreSubmitting = true'})}
${h.csrf_token(request)}
<header class="modal-card-head">
<p class="modal-card-title">Ignore Product</p>
</header>
<section class="modal-card-body">
<p class="block has-text-weight-bold">
Really ignore this product?
</p>
<p class="block">
This will change the product status to "ignored"<br />
and you will no longer be prompted to resolve it.
</p>
</section>
<footer class="modal-card-foot">
<b-button @click="ignoreShowDialog = false">
Cancel
</b-button>
<b-button type="is-warning"
native-type="submit"
icon-pack="fas"
icon-left="ban"
:disabled="ignoreSubmitting">
{{ ignoreSubmitting ? "Working, please wait..." : "Ignore" }}
</b-button>
</footer>
${h.end_form()}
</div>
</b-modal>
% endif
</wutta-tool-panel>
</%def>
<%def name="modify_vue_vars()">
${parent.modify_vue_vars()}
<script>
% if instance.status.name == 'READY' and master.has_perm('resolve') and not use_local_products:
ThisPageData.resolveShowDialog = false
ThisPageData.resolveProductID = null
ThisPageData.resolveSubmitting = false
ThisPage.methods.resolveInit = function() {
this.resolveShowDialog = true
this.$nextTick(() => {
this.$refs.productLookup.focus()
})
}
% endif
% if instance.status.name == 'READY' and master.has_perm('ignore') and not use_local_products:
ThisPageData.ignoreShowDialog = false
ThisPageData.ignoreSubmitting = false
% endif
</script>
</%def>

View file

@ -0,0 +1,53 @@
<%def name="make_sideshow_components()">
${self.make_sideshow_product_lookup_component()}
</%def>
<%def name="make_sideshow_product_lookup_component()">
<script type="text/x-template" id="sideshow-product-lookup-template">
<wutta-autocomplete ref="autocomplete"
v-model="productID"
:display="display"
placeholder="Enter brand, description etc."
:service-url="serviceUrl"
@input="val => $emit('input', val)" />
</script>
<script>
const SideshowProductLookup = {
template: '#sideshow-product-lookup-template',
props: {
// this should contain the productID, or null
// caller specifies this as `v-model`
// component emits @input event when value changes
value: String,
// caller must specify initial display string, if the
// (v-model) value is not empty when component loads
display: String,
// the url from which search results are obtained
serviceUrl: {
type: String,
default: '${url('orders.product_autocomplete')}',
},
},
data() {
return {
productID: this.value,
}
},
methods: {
focus() {
this.$refs.autocomplete.focus()
},
},
}
Vue.component('sideshow-product-lookup', SideshowProductLookup)
</script>
</%def>

View file

@ -2,7 +2,7 @@
################################################################################
#
# Sideshow -- Case/Special Order Tracker
# Copyright © 2024 Lance Edgar
# Copyright © 2024-2025 Lance Edgar
#
# This file is part of Sideshow.
#
@ -24,13 +24,18 @@
Sideshow Views
"""
from wuttaweb.views import essential
def includeme(config):
# core views for wuttaweb
config.include('wuttaweb.views.essential')
essential.defaults(config, **{
'wuttaweb.views.common': 'sideshow.web.views.common',
})
# sideshow views
config.include('sideshow.web.views.stores')
config.include('sideshow.web.views.customers')
config.include('sideshow.web.views.products')
config.include('sideshow.web.views.orders')

View file

@ -2,7 +2,7 @@
################################################################################
#
# Sideshow -- Case/Special Order Tracker
# Copyright © 2024 Lance Edgar
# Copyright © 2024-2025 Lance Edgar
#
# This file is part of Sideshow.
#
@ -121,10 +121,15 @@ class NewOrderBatchView(BatchMasterView):
'case_price_quoted',
'order_qty',
'order_uom',
'discount_percent',
'total_price',
'status_code',
]
def __init__(self, request, context=None):
super().__init__(request, context=context)
self.order_handler = self.app.get_order_handler()
def get_batch_handler(self):
""" """
# TODO: call self.app.get_batch_handler()
@ -134,6 +139,10 @@ class NewOrderBatchView(BatchMasterView):
""" """
super().configure_grid(g)
# store_id
if not self.order_handler.expose_store_id():
g.remove('store_id')
# total_price
g.set_renderer('total_price', 'currency')
@ -141,6 +150,10 @@ class NewOrderBatchView(BatchMasterView):
""" """
super().configure_form(f)
# store_id
if not self.order_handler.expose_store_id():
f.remove('store_id')
# local_customer
f.set_node('local_customer', LocalCustomerRef(self.request))
@ -167,6 +180,10 @@ class NewOrderBatchView(BatchMasterView):
g.set_label('case_price_quoted', "Case Price", column_only=True)
g.set_renderer('case_price_quoted', 'currency')
# discount_percent
g.set_renderer('discount_percent', 'percent')
g.set_label('discount_percent', "Disc. %", column_only=True)
# total_price
g.set_renderer('total_price', 'currency')

View file

@ -0,0 +1,104 @@
# -*- coding: utf-8; -*-
################################################################################
#
# Sideshow -- Case/Special Order Tracker
# Copyright © 2024-2025 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/>.
#
################################################################################
"""
Common Views
"""
from wuttaweb.views import common as base
class CommonView(base.CommonView):
"""
Sideshow overrides for common view logic.
"""
def setup_enhance_admin_user(self, user):
"""
Adds the "Order Admin" role with all relevant permissions.
The default logic for creating a new user will create the
"Site Admin" role with permissions for app and user account
maintenance etc. Sideshow needs another role for the order
maintenance.
"""
model = self.app.model
session = self.app.get_session(user)
auth = self.app.get_auth_handler()
admin = model.Role(name="Order Admin")
admin.notes = ("this role was auto-created; "
"you can change or remove it as needed.")
session.add(admin)
user.roles.append(admin)
order_admin_perms = [
'local_customers.list',
'local_customers.view',
'local_products.list',
'local_products.view',
'neworder_batches.list',
'neworder_batches.view',
'order_items.add_note',
'order_items.change_status',
'order_items.list',
'order_items.view',
'order_items_contact.add_note',
'order_items_contact.change_status',
'order_items_contact.list',
'order_items_contact.process_contact',
'order_items_contact.view',
'order_items_delivery.add_note',
'order_items_delivery.change_status',
'order_items_delivery.list',
'order_items_delivery.process_delivery',
'order_items_delivery.process_restock',
'order_items_delivery.view',
'order_items_placement.add_note',
'order_items_placement.change_status',
'order_items_placement.list',
'order_items_placement.process_placement',
'order_items_placement.view',
'order_items_receiving.add_note',
'order_items_receiving.change_status',
'order_items_receiving.list',
'order_items_receiving.process_receiving',
'order_items_receiving.process_reorder',
'order_items_receiving.view',
'orders.configure',
'orders.create',
'orders.create_unknown_product',
'orders.list',
'orders.view',
'pending_customers.list',
'pending_customers.view',
'pending_products.list',
'pending_products.view',
]
for perm in order_admin_perms:
auth.grant_permission(admin, perm)
def includeme(config):
base.defaults(config, **{'CommonView': CommonView})

View file

@ -2,7 +2,7 @@
################################################################################
#
# Sideshow -- Case/Special Order Tracker
# Copyright © 2024 Lance Edgar
# Copyright © 2024-2025 Lance Edgar
#
# This file is part of Sideshow.
#
@ -25,7 +25,9 @@ Views for Orders
"""
import decimal
import json
import logging
import re
import colander
import sqlalchemy as sa
@ -35,9 +37,9 @@ from webhelpers2.html import tags, HTML
from wuttaweb.views import MasterView
from wuttaweb.forms.schema import UserRef, WuttaMoney, WuttaQuantity, WuttaEnum, WuttaDictEnum
from wuttaweb.util import make_json_safe
from sideshow.db.model import Order, OrderItem
from sideshow.orders import OrderHandler
from sideshow.batch.neworder import NewOrderBatchHandler
from sideshow.web.forms.schema import (OrderRef,
LocalCustomerRef, LocalProductRef,
@ -65,13 +67,13 @@ class OrderView(MasterView):
.. attribute:: order_handler
Reference to the :term:`order handler` as returned by
:meth:`get_order_handler()`. This gets set in the constructor.
:meth:`~sideshow.app.SideshowAppProvider.get_order_handler()`.
This gets set in the constructor.
.. attribute:: batch_handler
Reference to the :term:`new order batch` handler, as returned
by :meth:`get_batch_handler()`. This gets set in the
constructor.
Reference to the :term:`new order batch` handler. This gets
set in the constructor.
"""
model_class = Order
editable = False
@ -135,6 +137,7 @@ class OrderView(MasterView):
'special_order',
'order_qty',
'order_uom',
'discount_percent',
'total_price',
'status_code',
]
@ -144,6 +147,7 @@ class OrderView(MasterView):
'brand_name',
'description',
'size',
'department_id',
'department_name',
'vendor_name',
'vendor_item_code',
@ -154,41 +158,17 @@ class OrderView(MasterView):
def __init__(self, request, context=None):
super().__init__(request, context=context)
self.order_handler = self.get_order_handler()
def get_order_handler(self):
"""
Returns the configured :term:`order handler`.
You normally would not need to call this, and can use
:attr:`order_handler` instead.
:rtype: :class:`~sideshow.orders.OrderHandler`
"""
if hasattr(self, 'order_handler'):
return self.order_handler
return OrderHandler(self.config)
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, and can use
:attr:`batch_handler` instead.
:returns:
:class:`~sideshow.batch.neworder.NewOrderBatchHandler`
instance.
"""
if hasattr(self, 'batch_handler'):
return self.batch_handler
return self.app.get_batch_handler('neworder')
self.order_handler = self.app.get_order_handler()
self.batch_handler = self.app.get_batch_handler('neworder')
def configure_grid(self, g):
""" """
super().configure_grid(g)
# store_id
if not self.order_handler.expose_store_id():
g.remove('store_id')
# order_id
g.set_link('order_id')
@ -222,6 +202,7 @@ class OrderView(MasterView):
* :meth:`start_over()`
* :meth:`cancel_order()`
* :meth:`set_store()`
* :meth:`assign_customer()`
* :meth:`unassign_customer()`
* :meth:`set_pending_customer()`
@ -231,10 +212,11 @@ class OrderView(MasterView):
* :meth:`delete_item()`
* :meth:`submit_order()`
"""
model = self.app.model
enum = self.app.enum
self.creating = True
self.batch_handler = self.get_batch_handler()
session = self.Session()
batch = self.get_current_batch()
self.creating = True
context = self.get_context_customer(batch)
@ -253,6 +235,7 @@ class OrderView(MasterView):
data = dict(self.request.json_body)
action = data.pop('action')
json_actions = [
'set_store',
'assign_customer',
'unassign_customer',
# 'update_phone_number',
@ -261,7 +244,7 @@ class OrderView(MasterView):
# 'get_customer_info',
# # 'set_customer_data',
'get_product_info',
# 'get_past_items',
'get_past_products',
'add_item',
'update_item',
'delete_item',
@ -282,12 +265,37 @@ class OrderView(MasterView):
'normalized_batch': self.normalize_batch(batch),
'order_items': [self.normalize_row(row)
for row in batch.rows],
'default_uom_choices': self.get_default_uom_choices(),
'default_uom_choices': self.batch_handler.get_default_uom_choices(),
'default_uom': None, # TODO?
'expose_store_id': self.order_handler.expose_store_id(),
'allow_item_discounts': self.batch_handler.allow_item_discounts(),
'allow_unknown_products': (self.batch_handler.allow_unknown_products()
and self.has_perm('create_unknown_product')),
'pending_product_required_fields': self.get_pending_product_required_fields(),
'allow_past_item_reorder': True, # TODO: make configurable?
})
if context['expose_store_id']:
stores = session.query(model.Store)\
.filter(model.Store.archived == False)\
.order_by(model.Store.store_id)\
.all()
context['stores'] = [{'store_id': store.store_id, 'display': store.get_display()}
for store in stores]
# set default so things just work
if not batch.store_id:
batch.store_id = self.batch_handler.get_default_store_id()
if context['allow_item_discounts']:
context['allow_item_discounts_if_on_sale'] = self.batch_handler\
.allow_item_discounts_if_on_sale()
# nb. render quantity so that '10.0' => '10'
context['default_item_discount'] = self.app.render_quantity(
self.batch_handler.get_default_item_discount())
context['dept_item_discounts'] = dict([(d['department_id'], d['default_item_discount'])
for d in self.get_dept_item_discounts()])
return self.render_to_response('create', context)
def get_current_batch(self):
@ -342,7 +350,7 @@ class OrderView(MasterView):
if not term:
return []
handler = self.get_batch_handler()
handler = self.batch_handler
if handler.use_local_customers():
return handler.autocomplete_customers_local(session, term, user=self.request.user)
else:
@ -366,7 +374,7 @@ class OrderView(MasterView):
if not term:
return []
handler = self.get_batch_handler()
handler = self.batch_handler
if handler.use_local_products():
return handler.autocomplete_products_local(session, term, user=self.request.user)
else:
@ -384,6 +392,43 @@ class OrderView(MasterView):
required.append(field)
return required
def get_dept_item_discounts(self):
"""
Returns the list of per-department default item discount settings.
Each entry in the list will look like::
{
'department_id': '42',
'department_name': 'Grocery',
'default_item_discount': 10,
}
:returns: List of department settings as shown above.
"""
model = self.app.model
session = self.Session()
pattern = re.compile(r'^sideshow\.orders\.departments\.([^.]+)\.default_item_discount$')
dept_item_discounts = []
settings = session.query(model.Setting)\
.filter(model.Setting.name.like('sideshow.orders.departments.%.default_item_discount'))\
.all()
for setting in settings:
match = pattern.match(setting.name)
if not match:
log.warning("invalid setting name: %s", setting.name)
continue
deptid = match.group(1)
name = self.app.get_setting(session, f'sideshow.orders.departments.{deptid}.name')
dept_item_discounts.append({
'department_id': deptid,
'department_name': name,
'default_item_discount': setting.value,
})
dept_item_discounts.sort(key=lambda d: d['department_name'])
return dept_item_discounts
def start_over(self, batch):
"""
This will delete the user's current batch, then redirect user
@ -426,9 +471,26 @@ class OrderView(MasterView):
url = self.get_index_url()
return self.redirect(url)
def set_store(self, batch, data):
"""
Assign the
:attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.store_id`
for a batch.
This is a "batch action" method which may be called from
:meth:`create()`.
"""
store_id = data.get('store_id')
if not store_id:
return {'error': "Must provide store_id"}
batch.store_id = store_id
return self.get_context_customer(batch)
def get_context_customer(self, batch):
""" """
context = {
'store_id': batch.store_id,
'customer_is_known': True,
'customer_id': None,
'customer_name': batch.customer_name,
@ -521,18 +583,20 @@ class OrderView(MasterView):
def get_product_info(self, batch, data):
"""
Fetch data for a specific product. (Nothing is modified.)
Fetch data for a specific product.
Depending on config, this will fetch a :term:`local product`
or :term:`external product` to get the data.
Depending on config, this calls one of the following to get
its primary data:
This should invoke a configured handler for the query
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.
* :meth:`~sideshow.batch.neworder.NewOrderBatchHandler.get_product_info_local()`
* :meth:`~sideshow.batch.neworder.NewOrderBatchHandler.get_product_info_external()`
It then may supplement the data with additional fields.
This is a "batch action" method which may be called from
:meth:`create()`.
:returns: Dict of product info.
"""
product_id = data.get('product_id')
if not product_id:
@ -569,6 +633,7 @@ class OrderView(MasterView):
'unit_price_reg',
'unit_price_quoted',
'case_price_quoted',
'default_item_discount',
]
for field in decimal_fields:
@ -579,6 +644,22 @@ class OrderView(MasterView):
return data
def get_past_products(self, batch, data):
"""
Fetch past products for convenient re-ordering.
This essentially calls
:meth:`~sideshow.batch.neworder.NewOrderBatchHandler.get_past_products()`
on the :attr:`batch_handler` and returns the result.
This is a "batch action" method which may be called from
:meth:`create()`.
:returns: List of product info dicts.
"""
past_products = self.batch_handler.get_past_products(batch)
return make_json_safe(past_products)
def add_item(self, batch, data):
"""
This adds a row to the user's current new order batch.
@ -589,8 +670,11 @@ class OrderView(MasterView):
* :meth:`update_item()`
* :meth:`delete_item()`
"""
kw = {'user': self.request.user}
if 'discount_percent' in data and self.batch_handler.allow_item_discounts():
kw['discount_percent'] = data['discount_percent']
row = self.batch_handler.add_item(batch, data['product_info'],
data['order_qty'], data['order_uom'])
data['order_qty'], data['order_uom'], **kw)
return {'batch': self.normalize_batch(batch),
'row': self.normalize_row(row)}
@ -619,8 +703,11 @@ class OrderView(MasterView):
if row.batch is not batch:
return {'error': "Row is for wrong batch"}
kw = {'user': self.request.user}
if 'discount_percent' in data and self.batch_handler.allow_item_discounts():
kw['discount_percent'] = data['discount_percent']
self.batch_handler.update_item(row, data['product_info'],
data['order_qty'], data['order_uom'])
data['order_qty'], data['order_uom'], **kw)
return {'batch': self.normalize_batch(batch),
'row': self.normalize_row(row)}
@ -689,12 +776,6 @@ class OrderView(MasterView):
'status_text': batch.status_text,
}
def get_default_uom_choices(self):
""" """
enum = self.app.enum
return [{'key': key, 'value': val}
for key, val in enum.ORDER_UOM.items()]
def normalize_row(self, row):
""" """
data = {
@ -709,12 +790,15 @@ class OrderView(MasterView):
row.product_description,
row.product_size),
'product_weighed': row.product_weighed,
'department_display': row.department_name,
'department_id': row.department_id,
'department_name': row.department_name,
'special_order': row.special_order,
'vendor_name': row.vendor_name,
'vendor_item_code': row.vendor_item_code,
'case_size': float(row.case_size) if row.case_size is not None else None,
'order_qty': float(row.order_qty),
'order_uom': row.order_uom,
'order_uom_choices': self.get_default_uom_choices(),
'discount_percent': self.app.render_quantity(row.discount_percent),
'unit_price_quoted': float(row.unit_price_quoted) if row.unit_price_quoted is not None else None,
'unit_price_quoted_display': self.app.render_currency(row.unit_price_quoted),
'case_price_quoted': float(row.case_price_quoted) if row.case_price_quoted is not None else None,
@ -789,6 +873,10 @@ class OrderView(MasterView):
super().configure_form(f)
order = f.model_instance
# store_id
if not self.order_handler.expose_store_id():
f.remove('store_id')
# local_customer
if order.customer_id and not order.local_customer:
f.remove('local_customer')
@ -857,6 +945,10 @@ class OrderView(MasterView):
# order_uom
#g.set_renderer('order_uom', self.grid_render_enum, enum=enum.OrderUOM)
# discount_percent
g.set_renderer('discount_percent', 'percent')
g.set_label('discount_percent', "Disc. %", column_only=True)
# total_price
g.set_renderer('total_price', g.render_currency)
@ -885,8 +977,10 @@ class OrderView(MasterView):
""" """
settings = [
# batches
{'name': 'wutta.batch.neworder.handler.spec'},
# stores
{'name': 'sideshow.orders.expose_store_id',
'type': bool},
{'name': 'sideshow.orders.default_store_id'},
# customers
{'name': 'sideshow.orders.use_local_customers',
@ -902,6 +996,17 @@ class OrderView(MasterView):
{'name': 'sideshow.orders.allow_unknown_products',
'type': bool,
'default': True},
# pricing
{'name': 'sideshow.orders.allow_item_discounts',
'type': bool},
{'name': 'sideshow.orders.allow_item_discounts_if_on_sale',
'type': bool},
{'name': 'sideshow.orders.default_item_discount',
'type': float},
# batches
{'name': 'wutta.batch.neworder.handler.spec'},
]
# required fields for new product entry
@ -924,8 +1029,39 @@ class OrderView(MasterView):
handlers = [{'spec': spec} for spec in handlers]
context['batch_handlers'] = handlers
context['dept_item_discounts'] = self.get_dept_item_discounts()
return context
def configure_gather_settings(self, data, simple_settings=None):
""" """
settings = super().configure_gather_settings(data, simple_settings=simple_settings)
for dept in json.loads(data['dept_item_discounts']):
deptid = dept['department_id']
settings.append({'name': f'sideshow.orders.departments.{deptid}.name',
'value': dept['department_name']})
settings.append({'name': f'sideshow.orders.departments.{deptid}.default_item_discount',
'value': dept['default_item_discount']})
return settings
def configure_remove_settings(self, **kwargs):
""" """
model = self.app.model
session = self.Session()
super().configure_remove_settings(**kwargs)
to_delete = session.query(model.Setting)\
.filter(sa.or_(
model.Setting.name.like('sideshow.orders.departments.%.name'),
model.Setting.name.like('sideshow.orders.departments.%.default_item_discount')))\
.all()
for setting in to_delete:
self.app.delete_setting(session, setting.name)
@classmethod
def defaults(cls, config):
cls._order_defaults(config)
@ -1006,6 +1142,7 @@ class OrderItemView(MasterView):
labels = {
'order_id': "Order ID",
'store_id': "Store ID",
'product_id': "Product ID",
'product_scancode': "Scancode",
'product_brand': "Brand",
@ -1019,6 +1156,7 @@ class OrderItemView(MasterView):
grid_columns = [
'order_id',
'store_id',
'customer_name',
# 'sequence',
'product_scancode',
@ -1068,20 +1206,7 @@ class OrderItemView(MasterView):
def __init__(self, request, context=None):
super().__init__(request, context=context)
self.order_handler = self.get_order_handler()
def get_order_handler(self):
"""
Returns the configured :term:`order handler`.
You normally would not need to call this, and can use
:attr:`order_handler` instead.
:rtype: :class:`~sideshow.orders.OrderHandler`
"""
if hasattr(self, 'order_handler'):
return self.order_handler
return OrderHandler(self.config)
self.order_handler = self.app.get_order_handler()
def get_fallback_templates(self, template):
""" """
@ -1101,13 +1226,24 @@ class OrderItemView(MasterView):
model = self.app.model
# enum = self.app.enum
# store_id
if not self.order_handler.expose_store_id():
g.remove('store_id')
# order_id
g.set_sorter('order_id', model.Order.order_id)
g.set_renderer('order_id', self.render_order_id)
g.set_renderer('order_id', self.render_order_attr)
g.set_link('order_id')
# store_id
g.set_sorter('store_id', model.Order.store_id)
g.set_renderer('store_id', self.render_order_attr)
# customer_name
g.set_label('customer_name', "Customer", column_only=True)
g.set_renderer('customer_name', self.render_order_attr)
g.set_sorter('customer_name', model.Order.customer_name)
g.set_filter('customer_name', model.Order.customer_name)
# # sequence
# g.set_label('sequence', "Seq.", column_only=True)
@ -1134,9 +1270,10 @@ class OrderItemView(MasterView):
# status_code
g.set_renderer('status_code', self.render_status_code)
def render_order_id(self, item, key, value):
def render_order_attr(self, item, key, value):
""" """
return item.order.order_id
order = item.order
return getattr(order, key)
def render_status_code(self, item, key, value):
""" """
@ -1206,6 +1343,8 @@ class OrderItemView(MasterView):
item = context['instance']
form = context['form']
context['expose_store_id'] = self.order_handler.expose_store_id()
context['item'] = item
context['order'] = item.order
context['order_qty_uom_text'] = self.order_handler.get_order_qty_uom_text(
@ -1428,6 +1567,26 @@ class PlacementView(OrderItemView):
route_prefix = 'order_items_placement'
url_prefix = '/placement'
grid_columns = [
'order_id',
'store_id',
'customer_name',
'product_brand',
'product_description',
'product_size',
'department_name',
'special_order',
'vendor_name',
'vendor_item_code',
'order_qty',
'order_uom',
'total_price',
]
filter_defaults = {
'vendor_name': {'active': True},
}
def get_query(self, session=None):
""" """
query = super().get_query(session=session)
@ -1530,6 +1689,26 @@ class ReceivingView(OrderItemView):
route_prefix = 'order_items_receiving'
url_prefix = '/receiving'
grid_columns = [
'order_id',
'store_id',
'customer_name',
'product_brand',
'product_description',
'product_size',
'department_name',
'special_order',
'vendor_name',
'vendor_item_code',
'order_qty',
'order_uom',
'total_price',
]
filter_defaults = {
'vendor_name': {'active': True},
}
def get_query(self, session=None):
""" """
query = super().get_query(session=session)

View file

@ -2,7 +2,7 @@
################################################################################
#
# Sideshow -- Case/Special Order Tracker
# Copyright © 2024 Lance Edgar
# Copyright © 2024-2025 Lance Edgar
#
# This file is part of Sideshow.
#
@ -27,6 +27,7 @@ Views for Products
from wuttaweb.views import MasterView
from wuttaweb.forms.schema import UserRef, WuttaEnum, WuttaMoney, WuttaQuantity
from sideshow.enum import PendingProductStatus
from sideshow.db.model import LocalProduct, PendingProduct
@ -235,6 +236,7 @@ class PendingProductView(MasterView):
url_prefix = '/pending/products'
labels = {
'department_id': "Department ID",
'product_id': "Product ID",
}
@ -253,7 +255,12 @@ class PendingProductView(MasterView):
'created_by',
]
sort_defaults = 'scancode'
sort_defaults = ('created', 'desc')
filter_defaults = {
'status': {'active': True,
'value': PendingProductStatus.READY.name},
}
form_fields = [
'product_id',
@ -270,7 +277,6 @@ class PendingProductView(MasterView):
'unit_price_reg',
'special_order',
'notes',
'status',
'created',
'created_by',
'orders',
@ -290,7 +296,7 @@ class PendingProductView(MasterView):
g.set_renderer('unit_price_reg', 'currency')
# status
g.set_renderer('status', self.grid_render_enum, enum=enum.PendingProductStatus)
g.set_enum('status', enum.PendingProductStatus)
# links
g.set_link('scancode')
@ -298,6 +304,12 @@ class PendingProductView(MasterView):
g.set_link('description')
g.set_link('size')
def grid_row_class(self, product, data, i):
""" """
enum = self.app.enum
if product.status == enum.PendingProductStatus.IGNORED:
return 'has-background-warning'
def configure_form(self, f):
""" """
super().configure_form(f)
@ -316,13 +328,6 @@ class PendingProductView(MasterView):
# notes
f.set_widget('notes', 'notes')
# status
if self.creating:
f.remove('status')
else:
f.set_node('status', WuttaEnum(self.request, enum.PendingProductStatus))
f.set_readonly('status')
# created
if self.creating:
f.remove('created')
@ -416,6 +421,19 @@ class PendingProductView(MasterView):
return grid
def get_template_context(self, context):
""" """
enum = self.app.enum
if self.viewing:
product = context['instance']
if (product.status == enum.PendingProductStatus.READY
and self.has_any_perm('resolve', 'ignore')):
handler = self.app.get_batch_handler('neworder')
context['use_local_products'] = handler.use_local_products()
return context
def delete_instance(self, product):
""" """
@ -430,6 +448,98 @@ class PendingProductView(MasterView):
# go ahead and delete per usual
super().delete_instance(product)
def resolve(self):
"""
View to "resolve" a :term:`pending product` with the real
:term:`external product`.
This view requires POST, with ``product_id`` referencing the
desired external product.
It will call
:meth:`~sideshow.batch.neworder.NewOrderBatchHandler.get_product_info_external()`
to fetch product info, then with that it calls
:meth:`~sideshow.orders.OrderHandler.resolve_pending_product()`
to update related :term:`order items <order item>` etc.
See also :meth:`ignore()`.
"""
enum = self.app.enum
session = self.Session()
product = self.get_instance()
if product.status != enum.PendingProductStatus.READY:
self.request.session.flash("pending product does not have 'ready' status!", 'error')
return self.redirect(self.get_action_url('view', product))
product_id = self.request.POST.get('product_id')
if not product_id:
self.request.session.flash("must specify valid product_id", 'error')
return self.redirect(self.get_action_url('view', product))
batch_handler = self.app.get_batch_handler('neworder')
order_handler = self.app.get_order_handler()
info = batch_handler.get_product_info_external(session, product_id)
order_handler.resolve_pending_product(product, info, self.request.user)
return self.redirect(self.get_action_url('view', product))
def ignore(self):
"""
View to "ignore" a :term:`pending product` so the user is no
longer prompted to resolve it.
This view requires POST; it merely sets the product status to
"ignored".
See also :meth:`resolve()`.
"""
enum = self.app.enum
product = self.get_instance()
if product.status != enum.PendingProductStatus.READY:
self.request.session.flash("pending product does not have 'ready' status!", 'error')
return self.redirect(self.get_action_url('view', product))
product.status = enum.PendingProductStatus.IGNORED
return self.redirect(self.get_action_url('view', product))
@classmethod
def defaults(cls, config):
""" """
cls._defaults(config)
cls._pending_product_defaults(config)
@classmethod
def _pending_product_defaults(cls, config):
route_prefix = cls.get_route_prefix()
permission_prefix = cls.get_permission_prefix()
instance_url_prefix = cls.get_instance_url_prefix()
model_title = cls.get_model_title()
# resolve
config.add_wutta_permission(permission_prefix,
f'{permission_prefix}.resolve',
f"Resolve {model_title}")
config.add_route(f'{route_prefix}.resolve',
f'{instance_url_prefix}/resolve',
request_method='POST')
config.add_view(cls, attr='resolve',
route_name=f'{route_prefix}.resolve',
permission=f'{permission_prefix}.resolve')
# ignore
config.add_wutta_permission(permission_prefix,
f'{permission_prefix}.ignore',
f"Ignore {model_title}")
config.add_route(f'{route_prefix}.ignore',
f'{instance_url_prefix}/ignore',
request_method='POST')
config.add_view(cls, attr='ignore',
route_name=f'{route_prefix}.ignore',
permission=f'{permission_prefix}.ignore')
def defaults(config, **kwargs):
base = globals()

View file

@ -0,0 +1,120 @@
# -*- coding: utf-8; -*-
################################################################################
#
# Sideshow -- Case/Special Order Tracker
# Copyright © 2024-2025 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/>.
#
################################################################################
"""
Views for Stores
"""
from wuttaweb.views import MasterView
from sideshow.db.model import Store
class StoreView(MasterView):
"""
Master view for
:class:`~sideshow.db.model.stores.Store`; route prefix
is ``stores``.
Notable URLs provided by this class:
* ``/stores/``
* ``/stores/new``
* ``/stores/XXX``
* ``/stores/XXX/edit``
* ``/stores/XXX/delete``
"""
model_class = Store
labels = {
'store_id': "Store ID",
}
filter_defaults = {
'archived': {'active': True, 'verb': 'is_false'},
}
sort_defaults = 'store_id'
def configure_grid(self, g):
""" """
super().configure_grid(g)
# links
g.set_link('store_id')
g.set_link('name')
def grid_row_class(self, store, data, i):
""" """
if store.archived:
return 'has-background-warning'
def configure_form(self, f):
""" """
super().configure_form(f)
# store_id
f.set_validator('store_id', self.unique_store_id)
# name
f.set_validator('name', self.unique_name)
def unique_store_id(self, node, value):
""" """
model = self.app.model
session = self.Session()
query = session.query(model.Store)\
.filter(model.Store.store_id == value)
if self.editing:
uuid = self.request.matchdict['uuid']
query = query.filter(model.Store.uuid != uuid)
if query.count():
node.raise_invalid("Store ID must be unique")
def unique_name(self, node, value):
""" """
model = self.app.model
session = self.Session()
query = session.query(model.Store)\
.filter(model.Store.name == value)
if self.editing:
uuid = self.request.matchdict['uuid']
query = query.filter(model.Store.uuid != uuid)
if query.count():
node.raise_invalid("Name must be unique")
def defaults(config, **kwargs):
base = globals()
StoreView = kwargs.get('StoreView', base['StoreView'])
StoreView.defaults(config)
def includeme(config):
defaults(config)

View file

@ -4,6 +4,8 @@ import datetime
import decimal
from unittest.mock import patch
import sqlalchemy as sa
from wuttjamaican.testing import DataTestCase
from sideshow.batch import neworder as mod
@ -20,36 +22,76 @@ class TestNewOrderBatchHandler(DataTestCase):
def make_handler(self):
return mod.NewOrderBatchHandler(self.config)
def tets_use_local_customers(self):
def test_get_default_store_id(self):
handler = self.make_handler()
# null by default
self.assertIsNone(handler.get_default_store_id())
# whatever is configured
self.config.setdefault('sideshow.orders.default_store_id', '042')
self.assertEqual(handler.get_default_store_id(), '042')
def test_use_local_customers(self):
handler = self.make_handler()
# true by default
self.assertTrue(handler.use_local_customers())
# config can disable
config.setdefault('sideshow.orders.use_local_customers', 'false')
self.config.setdefault('sideshow.orders.use_local_customers', 'false')
self.assertFalse(handler.use_local_customers())
def tets_use_local_products(self):
def test_use_local_products(self):
handler = self.make_handler()
# true by default
self.assertTrue(handler.use_local_products())
# config can disable
config.setdefault('sideshow.orders.use_local_products', 'false')
self.config.setdefault('sideshow.orders.use_local_products', 'false')
self.assertFalse(handler.use_local_products())
def tets_allow_unknown_products(self):
def test_allow_unknown_products(self):
handler = self.make_handler()
# true by default
self.assertTrue(handler.allow_unknown_products())
# config can disable
config.setdefault('sideshow.orders.allow_unknown_products', 'false')
self.config.setdefault('sideshow.orders.allow_unknown_products', 'false')
self.assertFalse(handler.allow_unknown_products())
def test_allow_item_discounts(self):
handler = self.make_handler()
# false by default
self.assertFalse(handler.allow_item_discounts())
# config can enable
self.config.setdefault('sideshow.orders.allow_item_discounts', 'true')
self.assertTrue(handler.allow_item_discounts())
def test_allow_item_discounts_if_on_sale(self):
handler = self.make_handler()
# false by default
self.assertFalse(handler.allow_item_discounts_if_on_sale())
# config can enable
self.config.setdefault('sideshow.orders.allow_item_discounts_if_on_sale', 'true')
self.assertTrue(handler.allow_item_discounts_if_on_sale())
def test_get_default_item_discount(self):
handler = self.make_handler()
# null by default
self.assertIsNone(handler.get_default_item_discount())
# config can define
self.config.setdefault('sideshow.orders.default_item_discount', '15')
self.assertEqual(handler.get_default_item_discount(), decimal.Decimal('15.00'))
def test_autocomplete_customers_external(self):
handler = self.make_handler()
self.assertRaises(NotImplementedError, handler.autocomplete_customers_external,
@ -78,6 +120,23 @@ class TestNewOrderBatchHandler(DataTestCase):
# search for sally finds nothing
self.assertEqual(handler.autocomplete_customers_local(self.session, 'sally'), [])
def test_init_batch(self):
model = self.app.model
handler = self.make_handler()
# store_id is null by default
batch = handler.model_class()
self.assertIsNone(batch.store_id)
handler.init_batch(batch)
self.assertIsNone(batch.store_id)
# but default can be configured
self.config.setdefault('sideshow.orders.default_store_id', '042')
batch = handler.model_class()
self.assertIsNone(batch.store_id)
handler.init_batch(batch)
self.assertEqual(batch.store_id, '042')
def test_set_customer(self):
model = self.app.model
handler = self.make_handler()
@ -210,6 +269,14 @@ class TestNewOrderBatchHandler(DataTestCase):
# search for juice finds nothing
self.assertEqual(handler.autocomplete_products_local(self.session, 'juice'), [])
def test_get_default_uom_choices(self):
enum = self.app.enum
handler = self.make_handler()
uoms = handler.get_default_uom_choices()
self.assertEqual(uoms, [{'key': key, 'value': val}
for key, val in enum.ORDER_UOM.items()])
def test_get_product_info_external(self):
handler = self.make_handler()
self.assertRaises(NotImplementedError, handler.get_product_info_external,
@ -251,6 +318,174 @@ class TestNewOrderBatchHandler(DataTestCase):
mock_uuid = self.app.make_true_uuid()
self.assertRaises(ValueError, handler.get_product_info_local, self.session, mock_uuid.hex)
def test_normalize_local_product(self):
model = self.app.model
handler = self.make_handler()
product = model.LocalProduct(scancode='07430500132',
brand_name="Bragg's",
description="Apple Cider Vinegar",
size="32oz",
department_name="Grocery",
case_size=12,
unit_price_reg=5.99,
vendor_name="UNFI",
vendor_item_code='1234')
self.session.add(product)
self.session.flush()
info = handler.normalize_local_product(product)
self.assertIsInstance(info, dict)
self.assertEqual(info['product_id'], product.uuid.hex)
for prop in sa.inspect(model.LocalProduct).column_attrs:
if prop.key == 'uuid':
continue
if prop.key not in info:
continue
self.assertEqual(info[prop.key], getattr(product, prop.key))
def test_get_past_orders(self):
model = self.app.model
handler = self.make_handler()
user = model.User(username='barney')
self.session.add(user)
batch = handler.make_batch(self.session, created_by=user)
self.session.add(batch)
self.session.flush()
# ..will test local customers first
# error if no customer
self.assertRaises(ValueError, handler.get_past_orders, batch)
# empty history for customer
customer = model.LocalCustomer(full_name='Fred Flintstone')
batch.local_customer = customer
self.session.flush()
orders = handler.get_past_orders(batch)
self.assertEqual(len(orders), 0)
# mock historical order
order = model.Order(order_id=42, local_customer=customer, created_by=user)
self.session.add(order)
self.session.flush()
# that should now be returned
orders = handler.get_past_orders(batch)
self.assertEqual(len(orders), 1)
self.assertIs(orders[0], order)
# ..now we test external customers, w/ new batch
with patch.object(handler, 'use_local_customers', return_value=False):
batch2 = handler.make_batch(self.session, created_by=user)
self.session.add(batch2)
self.session.flush()
# error if no customer
self.assertRaises(ValueError, handler.get_past_orders, batch2)
# empty history for customer
batch2.customer_id = '123'
self.session.flush()
orders = handler.get_past_orders(batch2)
self.assertEqual(len(orders), 0)
# mock historical order
order2 = model.Order(order_id=42, customer_id='123', created_by=user)
self.session.add(order2)
self.session.flush()
# that should now be returned
orders = handler.get_past_orders(batch2)
self.assertEqual(len(orders), 1)
self.assertIs(orders[0], order2)
def test_get_past_products(self):
model = self.app.model
enum = self.app.enum
handler = self.make_handler()
user = model.User(username='barney')
self.session.add(user)
batch = handler.make_batch(self.session, created_by=user)
self.session.add(batch)
self.session.flush()
# (nb. this all assumes local customers)
# ..will test local products first
# error if no customer
self.assertRaises(ValueError, handler.get_past_products, batch)
# empty history for customer
customer = model.LocalCustomer(full_name='Fred Flintstone')
batch.local_customer = customer
self.session.flush()
products = handler.get_past_products(batch)
self.assertEqual(len(products), 0)
# mock historical order
order = model.Order(order_id=42, local_customer=customer, created_by=user)
product = model.LocalProduct(scancode='07430500132', description='Vinegar',
unit_price_reg=5.99, case_size=12)
item = model.OrderItem(local_product=product, order_qty=1, order_uom=enum.ORDER_UOM_UNIT,
status_code=enum.ORDER_ITEM_STATUS_READY)
order.items.append(item)
self.session.add(order)
self.session.flush()
self.session.refresh(product)
# that should now be returned
products = handler.get_past_products(batch)
self.assertEqual(len(products), 1)
self.assertEqual(products[0]['product_id'], product.uuid.hex)
self.assertEqual(products[0]['scancode'], '07430500132')
self.assertEqual(products[0]['description'], 'Vinegar')
self.assertEqual(products[0]['case_price_quoted'], decimal.Decimal('71.88'))
self.assertEqual(products[0]['case_price_quoted_display'], '$71.88')
# ..now we test external products, w/ new batch
with patch.object(handler, 'use_local_products', return_value=False):
batch2 = handler.make_batch(self.session, created_by=user)
self.session.add(batch2)
self.session.flush()
# error if no customer
self.assertRaises(ValueError, handler.get_past_products, batch2)
# empty history for customer
batch2.local_customer = customer
self.session.flush()
products = handler.get_past_products(batch2)
self.assertEqual(len(products), 0)
# mock historical order
order2 = model.Order(order_id=44, local_customer=customer, created_by=user)
self.session.add(order2)
item2 = model.OrderItem(product_id='07430500116',
order_qty=1, order_uom=enum.ORDER_UOM_UNIT,
status_code=enum.ORDER_ITEM_STATUS_READY)
order2.items.append(item2)
self.session.flush()
# its product should now be returned
with patch.object(handler, 'get_product_info_external', return_value={
'product_id': '07430500116',
'scancode': '07430500116',
'description': 'VINEGAR',
'unit_price_reg': decimal.Decimal('3.99'),
'case_size': 12,
}):
products = handler.get_past_products(batch2)
self.assertEqual(len(products), 1)
self.assertEqual(products[0]['product_id'], '07430500116')
self.assertEqual(products[0]['scancode'], '07430500116')
self.assertEqual(products[0]['description'], 'VINEGAR')
self.assertEqual(products[0]['case_price_quoted'], decimal.Decimal('47.88'))
self.assertEqual(products[0]['case_price_quoted_display'], '$47.88')
def test_add_item(self):
model = self.app.model
enum = self.app.enum
@ -327,7 +562,7 @@ class TestNewOrderBatchHandler(DataTestCase):
self.config.setdefault('sideshow.orders.allow_unknown_products', 'false')
self.assertRaises(TypeError, handler.add_item, batch, kw, 1, enum.ORDER_UOM_UNIT)
# local product
# local product w/ discount
local = model.LocalProduct(scancode='07430500002',
description='Vinegar',
size='2oz',
@ -335,7 +570,9 @@ class TestNewOrderBatchHandler(DataTestCase):
case_size=12)
self.session.add(local)
self.session.flush()
row = handler.add_item(batch, local.uuid.hex, 1, enum.ORDER_UOM_CASE)
with patch.object(handler, 'allow_item_discounts', return_value=True):
row = handler.add_item(batch, local.uuid.hex, 1, enum.ORDER_UOM_CASE,
discount_percent=15)
self.session.flush()
self.session.refresh(row)
self.session.refresh(local)
@ -359,7 +596,8 @@ class TestNewOrderBatchHandler(DataTestCase):
self.assertEqual(row.unit_price_reg, decimal.Decimal('2.99'))
self.assertEqual(row.unit_price_quoted, decimal.Decimal('2.99'))
self.assertEqual(row.case_price_quoted, decimal.Decimal('35.88'))
self.assertEqual(row.total_price, decimal.Decimal('35.88'))
self.assertEqual(row.discount_percent, decimal.Decimal('15.00'))
self.assertEqual(row.total_price, decimal.Decimal('30.50'))
# local product, not found
mock_uuid = self.app.make_true_uuid()
@ -511,8 +749,10 @@ class TestNewOrderBatchHandler(DataTestCase):
self.config.setdefault('sideshow.orders.allow_unknown_products', 'false')
self.assertRaises(TypeError, handler.update_item, row, kw, 1, enum.ORDER_UOM_UNIT)
# update w/ local product
handler.update_item(row, local.uuid.hex, 1, enum.ORDER_UOM_CASE)
# update w/ local product and discount percent
with patch.object(handler, 'allow_item_discounts', return_value=True):
handler.update_item(row, local.uuid.hex, 1, enum.ORDER_UOM_CASE,
discount_percent=15)
self.assertIsNone(row.product_id)
# nb. pending remains intact here
self.assertIsNotNone(row.pending_product)
@ -536,7 +776,8 @@ class TestNewOrderBatchHandler(DataTestCase):
self.assertEqual(row.case_price_quoted, decimal.Decimal('47.88'))
self.assertEqual(row.order_qty, 1)
self.assertEqual(row.order_uom, enum.ORDER_UOM_CASE)
self.assertEqual(row.total_price, decimal.Decimal('47.88'))
self.assertEqual(row.discount_percent, decimal.Decimal('15.00'))
self.assertEqual(row.total_price, decimal.Decimal('40.70'))
# update w/ local, not found
mock_uuid = self.app.make_true_uuid()
@ -577,6 +818,8 @@ class TestNewOrderBatchHandler(DataTestCase):
brand_name='Bragg',
description='Vinegar',
size='32oz',
vendor_name='Acme Distributors',
vendor_item_code='1234',
created_by=user,
status=enum.PendingProductStatus.PENDING)
row = handler.make_row(pending_product=product, order_qty=1, order_uom=enum.ORDER_UOM_UNIT)
@ -589,6 +832,8 @@ class TestNewOrderBatchHandler(DataTestCase):
self.assertEqual(row.product_brand, 'Bragg')
self.assertEqual(row.product_description, 'Vinegar')
self.assertEqual(row.product_size, '32oz')
self.assertEqual(row.vendor_name, 'Acme Distributors')
self.assertEqual(row.vendor_item_code, '1234')
self.assertIsNone(row.case_size)
self.assertIsNone(row.unit_cost)
self.assertIsNone(row.unit_price_reg)
@ -873,9 +1118,15 @@ class TestNewOrderBatchHandler(DataTestCase):
self.session.add(row)
self.session.flush()
# batch is okay to execute..
reason = handler.why_not_execute(batch)
self.assertIsNone(reason)
# unless we also require store
self.config.setdefault('sideshow.orders.expose_store_id', 'true')
reason = handler.why_not_execute(batch)
self.assertEqual(reason, "Must assign the store")
def test_make_local_customer(self):
model = self.app.model
enum = self.app.enum
@ -968,7 +1219,7 @@ class TestNewOrderBatchHandler(DataTestCase):
self.assertEqual(local.full_name, "Chuck Norris")
self.assertEqual(local.phone_number, '555-1234')
def test_make_local_products(self):
def test_process_pending_products(self):
model = self.app.model
enum = self.app.enum
handler = self.make_handler()
@ -1002,7 +1253,7 @@ class TestNewOrderBatchHandler(DataTestCase):
self.assertEqual(self.session.query(model.LocalProduct).count(), 1)
self.assertIsNotNone(row2.pending_product)
self.assertIsNone(row2.local_product)
handler.make_local_products(batch, batch.rows)
handler.process_pending_products(batch, batch.rows)
self.assertEqual(self.session.query(model.PendingProduct).count(), 0)
self.assertEqual(self.session.query(model.LocalProduct).count(), 2)
self.assertIsNone(row2.pending_product)
@ -1015,7 +1266,7 @@ class TestNewOrderBatchHandler(DataTestCase):
self.assertEqual(local.unit_price_reg, decimal.Decimal('5.99'))
# trying again does nothing
handler.make_local_products(batch, batch.rows)
handler.process_pending_products(batch, batch.rows)
self.assertEqual(self.session.query(model.PendingProduct).count(), 0)
self.assertEqual(self.session.query(model.LocalProduct).count(), 2)
self.assertIsNone(row2.pending_product)
@ -1040,24 +1291,26 @@ class TestNewOrderBatchHandler(DataTestCase):
), 1, enum.ORDER_UOM_UNIT)
self.session.flush()
# should do nothing if local products disabled
# should update status if using external products
with patch.object(handler, 'use_local_products', return_value=False):
self.assertEqual(self.session.query(model.PendingProduct).count(), 1)
self.assertEqual(self.session.query(model.LocalProduct).count(), 2)
self.assertIsNotNone(row.pending_product)
self.assertEqual(row.pending_product.status, enum.PendingProductStatus.PENDING)
self.assertIsNone(row.local_product)
handler.make_local_products(batch, batch.rows)
handler.process_pending_products(batch, batch.rows)
self.assertEqual(self.session.query(model.PendingProduct).count(), 1)
self.assertEqual(self.session.query(model.LocalProduct).count(), 2)
self.assertIsNotNone(row.pending_product)
self.assertEqual(row.pending_product.status, enum.PendingProductStatus.READY)
self.assertIsNone(row.local_product)
# but things happen by default, since local products enabled
# but if using local products (the default), pending is converted to local
self.assertEqual(self.session.query(model.PendingProduct).count(), 1)
self.assertEqual(self.session.query(model.LocalProduct).count(), 2)
self.assertIsNotNone(row.pending_product)
self.assertIsNone(row.local_product)
handler.make_local_products(batch, batch.rows)
handler.process_pending_products(batch, batch.rows)
self.assertEqual(self.session.query(model.PendingProduct).count(), 0)
self.assertEqual(self.session.query(model.LocalProduct).count(), 3)
self.assertIsNone(row.pending_product)

View file

@ -0,0 +1,28 @@
# -*- coding: utf-8; -*-
from wuttjamaican.testing import DataTestCase
from sideshow.db.model import stores as mod
class TestPendingCustomer(DataTestCase):
def test_str(self):
store = mod.Store()
self.assertEqual(str(store), "")
store.name = "Acme Goods"
self.assertEqual(str(store), "Acme Goods")
store.store_id = "001"
self.assertEqual(str(store), "001 Acme Goods")
def test_get_display(self):
store = mod.Store()
self.assertEqual(store.get_display(), "")
store.name = "Acme Goods"
self.assertEqual(store.get_display(), "Acme Goods")
store.store_id = "001"
self.assertEqual(store.get_display(), "001 Acme Goods")

17
tests/test_app.py Normal file
View file

@ -0,0 +1,17 @@
# -*- coding: utf-8; -*-
from wuttjamaican.testing import ConfigTestCase
from sideshow import app as mod
from sideshow.orders import OrderHandler
class TestSideshowAppProvider(ConfigTestCase):
def make_provider(self):
return mod.SideshowAppProvider(self.config)
def test_get_order_handler(self):
provider = self.make_provider()
handler = provider.get_order_handler()
self.assertIsInstance(handler, OrderHandler)

View file

@ -16,6 +16,16 @@ class TestOrderHandler(DataTestCase):
def make_handler(self):
return mod.OrderHandler(self.config)
def test_expose_store_id(self):
handler = self.make_handler()
# false by default
self.assertFalse(handler.expose_store_id())
# config can enable
self.config.setdefault('sideshow.orders.expose_store_id', 'true')
self.assertTrue(handler.expose_store_id())
def test_get_order_qty_uom_text(self):
enum = self.app.enum
handler = self.make_handler()
@ -60,6 +70,75 @@ class TestOrderHandler(DataTestCase):
self.assertEqual(handler.item_status_to_variant(enum.ORDER_ITEM_STATUS_EXPIRED), 'warning')
self.assertEqual(handler.item_status_to_variant(enum.ORDER_ITEM_STATUS_INACTIVE), 'warning')
def test_resolve_pending_product(self):
model = self.app.model
enum = self.app.enum
handler = self.make_handler()
# sample data
user = model.User(username='barney')
self.session.add(user)
pending = model.PendingProduct(description='vinegar', unit_price_reg=5.99,
status=enum.PendingProductStatus.PENDING,
created_by=user)
self.session.add(pending)
order = model.Order(order_id=100, customer_name="Fred Flintstone", created_by=user)
item = model.OrderItem(pending_product=pending,
order_qty=1, order_uom=enum.ORDER_UOM_UNIT,
status_code=enum.ORDER_ITEM_STATUS_READY)
order.items.append(item)
self.session.add(order)
self.session.flush()
info = {
'product_id': '07430500132',
'scancode': '07430500132',
'brand_name': "Bragg's",
'description': "Apple Cider Vinegar",
'size': "32oz",
'weighed': False,
'department_id': None,
'department_name': None,
'special_order': False,
'vendor_name': None,
'vendor_item_code': None,
'case_size': 12,
'unit_cost': 2.99,
'unit_price_reg': 5.99,
}
# first try fails b/c pending status
self.assertEqual(len(item.events), 0)
self.assertRaises(ValueError, handler.resolve_pending_product, pending, info, user)
# resolves okay if ready status
pending.status = enum.PendingProductStatus.READY
handler.resolve_pending_product(pending, info, user)
self.assertEqual(len(item.events), 1)
self.assertEqual(item.events[0].type_code, enum.ORDER_ITEM_EVENT_PRODUCT_RESOLVED)
self.assertIsNone(item.events[0].note)
# more sample data
pending2 = model.PendingProduct(description='vinegar', unit_price_reg=5.99,
status=enum.PendingProductStatus.READY,
created_by=user)
self.session.add(pending2)
order2 = model.Order(order_id=101, customer_name="Wilma Flintstone", created_by=user)
item2 = model.OrderItem(pending_product=pending2,
order_qty=1, order_uom=enum.ORDER_UOM_UNIT,
status_code=enum.ORDER_ITEM_STATUS_READY)
order2.items.append(item2)
self.session.add(order2)
self.session.flush()
# resolve with extra note
handler.resolve_pending_product(pending2, info, user, note='hello world')
self.assertEqual(len(item2.events), 2)
self.assertEqual(item2.events[0].type_code, enum.ORDER_ITEM_EVENT_PRODUCT_RESOLVED)
self.assertIsNone(item2.events[0].note)
self.assertEqual(item2.events[1].type_code, enum.ORDER_ITEM_EVENT_NOTE_ADDED)
self.assertEqual(item2.events[1].note, "hello world")
def test_process_placement(self):
model = self.app.model
enum = self.app.enum

View file

@ -30,10 +30,19 @@ class TestNewOrderBatchView(WebTestCase):
def test_configure_grid(self):
model = self.app.model
view = self.make_view()
# store_id not exposed by default
grid = view.make_grid(model_class=model.NewOrderBatch)
self.assertNotIn('total_price', grid.renderers)
self.assertIn('store_id', grid.columns)
view.configure_grid(grid)
self.assertIn('total_price', grid.renderers)
self.assertNotIn('store_id', grid.columns)
# store_id is exposed if configured
self.config.setdefault('sideshow.orders.expose_store_id', 'true')
grid = view.make_grid(model_class=model.NewOrderBatch)
self.assertIn('store_id', grid.columns)
view.configure_grid(grid)
self.assertIn('store_id', grid.columns)
def test_configure_form(self):
model = self.app.model
@ -58,6 +67,19 @@ class TestNewOrderBatchView(WebTestCase):
self.assertIsInstance(schema['pending_customer'].typ, PendingCustomerRef)
self.assertIsInstance(schema['total_price'].typ, WuttaMoney)
# store_id not exposed by default
form = view.make_form(model_instance=batch)
self.assertIn('store_id', form)
view.configure_form(form)
self.assertNotIn('store_id', form)
# store_id is exposed if configured
self.config.setdefault('sideshow.orders.expose_store_id', 'true')
form = view.make_form(model_instance=batch)
self.assertIn('store_id', form)
view.configure_form(form)
self.assertIn('store_id', form)
def test_configure_row_grid(self):
model = self.app.model
view = self.make_view()

View file

@ -0,0 +1,29 @@
# -*- coding: utf-8; -*-
from sideshow.testing import WebTestCase
from sideshow.web.views import common as mod
class TestIncludeme(WebTestCase):
def test_coverage(self):
mod.includeme(self.pyramid_config)
class TestCommonView(WebTestCase):
def make_view(self):
return mod.CommonView(self.request)
def test_setup_enhance_admin_user(self):
model = self.app.model
view = self.make_view()
user = model.User(username='barney')
self.session.add(user)
self.session.flush()
self.assertEqual(len(user.roles), 0)
view.setup_enhance_admin_user(user)
self.assertEqual(len(user.roles), 1)
self.assertEqual(user.roles[0].name, 'Order Admin')

View file

@ -2,6 +2,7 @@
import datetime
import decimal
import json
from unittest.mock import patch
from sqlalchemy import orm
@ -15,6 +16,7 @@ from sideshow.orders import OrderHandler
from sideshow.testing import WebTestCase
from sideshow.web.views import orders as mod
from sideshow.web.forms.schema import OrderRef, PendingProductRef
from sideshow.config import SideshowConfig
class TestIncludeme(WebTestCase):
@ -25,39 +27,50 @@ class TestIncludeme(WebTestCase):
class TestOrderView(WebTestCase):
def make_config(self, **kw):
config = super().make_config(**kw)
SideshowConfig().configure(config)
return config
def make_view(self):
return mod.OrderView(self.request)
def make_handler(self):
return NewOrderBatchHandler(self.config)
def test_order_handler(self):
view = self.make_view()
handler = view.order_handler
self.assertIsInstance(handler, OrderHandler)
handler2 = view.get_order_handler()
self.assertIs(handler2, handler)
def test_configure_grid(self):
model = self.app.model
view = self.make_view()
grid = view.make_grid(model_class=model.PendingProduct)
self.assertNotIn('order_id', grid.linked_columns)
self.assertNotIn('total_price', grid.renderers)
# store_id hidden by default
grid = view.make_grid(model_class=model.Order, columns=['store_id', 'order_id'])
self.assertIn('store_id', grid.columns)
view.configure_grid(grid)
self.assertIn('order_id', grid.linked_columns)
self.assertIn('total_price', grid.renderers)
self.assertNotIn('store_id', grid.columns)
# store_id is shown if configured
self.config.setdefault('sideshow.orders.expose_store_id', 'true')
grid = view.make_grid(model_class=model.Order, columns=['store_id', 'order_id'])
self.assertIn('store_id', grid.columns)
view.configure_grid(grid)
self.assertIn('store_id', grid.columns)
def test_create(self):
self.pyramid_config.include('sideshow.web.views')
self.config.setdefault('wutta.batch.neworder.handler.spec',
'sideshow.batch.neworder:NewOrderBatchHandler')
self.config.setdefault('sideshow.orders.expose_store_id', 'true')
self.config.setdefault('sideshow.orders.allow_item_discounts', 'true')
model = self.app.model
enum = self.app.enum
view = self.make_view()
user = model.User(username='barney')
self.session.add(user)
store = model.Store(store_id='001', name='Acme Goods')
self.session.add(store)
store = model.Store(store_id='002', name='Acme Services')
self.session.add(store)
self.session.flush()
with patch.object(view, 'Session', return_value=self.session):
@ -100,6 +113,7 @@ class TestOrderView(WebTestCase):
self.assertIsInstance(response, Response)
self.assertEqual(response.content_type, 'application/json')
self.assertEqual(response.json_body, {
'store_id': None,
'customer_is_known': False,
'customer_id': None,
'customer_name': 'Fred Flintstone',
@ -284,6 +298,50 @@ class TestOrderView(WebTestCase):
fields = view.get_pending_product_required_fields()
self.assertEqual(fields, ['brand_name', 'size', 'unit_price_reg'])
def test_get_dept_item_discounts(self):
model = self.app.model
view = self.make_view()
with patch.object(view, 'Session', return_value=self.session):
# empty list by default
discounts = view.get_dept_item_discounts()
self.assertEqual(discounts, [])
# mock settings
self.app.save_setting(self.session, 'sideshow.orders.departments.5.name', 'Bulk')
self.app.save_setting(self.session, 'sideshow.orders.departments.5.default_item_discount', '15')
self.app.save_setting(self.session, 'sideshow.orders.departments.6.name', 'Produce')
self.app.save_setting(self.session, 'sideshow.orders.departments.6.default_item_discount', '5')
discounts = view.get_dept_item_discounts()
self.assertEqual(len(discounts), 2)
self.assertEqual(discounts[0], {
'department_id': '5',
'department_name': 'Bulk',
'default_item_discount': '15',
})
self.assertEqual(discounts[1], {
'department_id': '6',
'department_name': 'Produce',
'default_item_discount': '5',
})
# invalid setting
self.app.save_setting(self.session, 'sideshow.orders.departments.I.N.V.A.L.I.D.name', 'Bad News')
self.app.save_setting(self.session, 'sideshow.orders.departments.I.N.V.A.L.I.D.default_item_discount', '42')
discounts = view.get_dept_item_discounts()
self.assertEqual(len(discounts), 2)
self.assertEqual(discounts[0], {
'department_id': '5',
'department_name': 'Bulk',
'default_item_discount': '15',
})
self.assertEqual(discounts[1], {
'department_id': '6',
'department_name': 'Produce',
'default_item_discount': '5',
})
def test_get_context_customer(self):
self.pyramid_config.add_route('orders', '/orders/')
model = self.app.model
@ -303,6 +361,7 @@ class TestOrderView(WebTestCase):
self.session.flush()
context = view.get_context_customer(batch)
self.assertEqual(context, {
'store_id': None,
'customer_is_known': True,
'customer_id': 42,
'customer_name': 'Fred Flintstone',
@ -320,6 +379,7 @@ class TestOrderView(WebTestCase):
self.session.flush()
context = view.get_context_customer(batch)
self.assertEqual(context, {
'store_id': None,
'customer_is_known': True,
'customer_id': local.uuid.hex,
'customer_name': 'Betty Boop',
@ -338,6 +398,7 @@ class TestOrderView(WebTestCase):
self.session.flush()
context = view.get_context_customer(batch)
self.assertEqual(context, {
'store_id': None,
'customer_is_known': False,
'customer_id': None,
'customer_name': 'Fred Flintstone',
@ -356,6 +417,7 @@ class TestOrderView(WebTestCase):
self.session.flush()
context = view.get_context_customer(batch)
self.assertEqual(context, {
'store_id': None,
'customer_is_known': True, # nb. this is for UI default
'customer_id': None,
'customer_name': None,
@ -407,6 +469,34 @@ class TestOrderView(WebTestCase):
self.session.flush()
self.assertEqual(self.session.query(model.NewOrderBatch).count(), 0)
def test_set_store(self):
model = self.app.model
view = self.make_view()
handler = NewOrderBatchHandler(self.config)
user = model.User(username='barney')
self.session.add(user)
self.session.flush()
with patch.object(view, 'batch_handler', create=True, new=handler):
with patch.object(view, 'Session', return_value=self.session):
with patch.object(self.request, 'user', new=user):
batch = view.get_current_batch()
self.assertIsNone(batch.store_id)
# store_id is required
result = view.set_store(batch, {})
self.assertEqual(result, {'error': "Must provide store_id"})
result = view.set_store(batch, {'store_id': ''})
self.assertEqual(result, {'error': "Must provide store_id"})
# store_id is set on batch
result = view.set_store(batch, {'store_id': '042'})
self.assertEqual(batch.store_id, '042')
self.assertIn('store_id', result)
self.assertEqual(result['store_id'], '042')
def test_assign_customer(self):
self.pyramid_config.add_route('orders.create', '/orders/new')
model = self.app.model
@ -431,6 +521,7 @@ class TestOrderView(WebTestCase):
self.assertIsNone(batch.pending_customer)
self.assertIs(batch.local_customer, weirdal)
self.assertEqual(context, {
'store_id': None,
'customer_is_known': True,
'customer_id': weirdal.uuid.hex,
'customer_name': 'Weird Al',
@ -469,6 +560,7 @@ class TestOrderView(WebTestCase):
self.assertIsNone(batch.customer_name)
self.assertIsNone(batch.local_customer)
self.assertEqual(context, {
'store_id': None,
'customer_is_known': True,
'customer_id': None,
'customer_name': None,
@ -509,6 +601,7 @@ class TestOrderView(WebTestCase):
context = view.set_pending_customer(batch, data)
self.assertIsInstance(batch.pending_customer, model.PendingCustomer)
self.assertEqual(context, {
'store_id': None,
'customer_is_known': False,
'customer_id': None,
'customer_name': 'Fred Flintstone',
@ -574,9 +667,55 @@ class TestOrderView(WebTestCase):
context = view.get_product_info(batch, {'product_id': '42'})
self.assertEqual(context, {'error': "something smells fishy"})
def test_get_past_products(self):
model = self.app.model
enum = self.app.enum
view = self.make_view()
handler = view.batch_handler
user = model.User(username='barney')
self.session.add(user)
batch = handler.make_batch(self.session, created_by=user)
self.session.add(batch)
self.session.flush()
# (nb. this all assumes local customers and products)
# error if no customer
self.assertRaises(ValueError, view.get_past_products, batch, {})
# empty history for customer
customer = model.LocalCustomer(full_name='Fred Flintstone')
batch.local_customer = customer
self.session.flush()
products = view.get_past_products(batch, {})
self.assertEqual(len(products), 0)
# mock historical order
order = model.Order(order_id=42, local_customer=customer, created_by=user)
product = model.LocalProduct(scancode='07430500132', description='Vinegar',
unit_price_reg=5.99, case_size=12)
item = model.OrderItem(local_product=product, order_qty=1, order_uom=enum.ORDER_UOM_UNIT,
status_code=enum.ORDER_ITEM_STATUS_READY)
order.items.append(item)
self.session.add(order)
self.session.flush()
self.session.refresh(product)
# that should now be returned
products = view.get_past_products(batch, {})
self.assertEqual(len(products), 1)
self.assertEqual(products[0]['product_id'], product.uuid.hex)
self.assertEqual(products[0]['scancode'], '07430500132')
self.assertEqual(products[0]['description'], 'Vinegar')
# nb. this is a float, since result is JSON-safe
self.assertEqual(products[0]['case_price_quoted'], 71.88)
self.assertEqual(products[0]['case_price_quoted_display'], '$71.88')
def test_add_item(self):
model = self.app.model
enum = self.app.enum
self.config.setdefault('sideshow.orders.allow_item_discounts', 'true')
handler = NewOrderBatchHandler(self.config)
view = self.make_view()
@ -594,6 +733,7 @@ class TestOrderView(WebTestCase):
},
'order_qty': 1,
'order_uom': enum.ORDER_UOM_UNIT,
'discount_percent': 10,
}
with patch.object(view, 'batch_handler', create=True, new=handler):
@ -620,6 +760,7 @@ class TestOrderView(WebTestCase):
def test_update_item(self):
model = self.app.model
enum = self.app.enum
self.config.setdefault('sideshow.orders.allow_item_discounts', 'true')
handler = NewOrderBatchHandler(self.config)
view = self.make_view()
@ -638,6 +779,7 @@ class TestOrderView(WebTestCase):
},
'order_qty': 1,
'order_uom': enum.ORDER_UOM_CASE,
'discount_percent': 15,
}
with patch.object(view, 'batch_handler', create=True, new=handler):
@ -820,14 +962,6 @@ class TestOrderView(WebTestCase):
'error': f"ValueError: batch has already been executed: {batch}",
})
def test_get_default_uom_choices(self):
enum = self.app.enum
view = self.make_view()
uoms = view.get_default_uom_choices()
self.assertEqual(uoms, [{'key': key, 'value': val}
for key, val in enum.ORDER_UOM.items()])
def test_normalize_batch(self):
model = self.app.model
enum = self.app.enum
@ -1010,6 +1144,8 @@ class TestOrderView(WebTestCase):
row.department_id = 1
row.department_name = "Bricks & Mortar"
row.special_order = False
row.vendor_name = 'Acme Distributors'
row.vendor_item_code = '1234'
row.case_size = None
row.unit_cost = decimal.Decimal('599.99')
row.unit_price_reg = decimal.Decimal('999.99')
@ -1025,7 +1161,8 @@ class TestOrderView(WebTestCase):
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['vendor_name'], 'Acme Distributors')
self.assertEqual(data['vendor_item_code'], '1234')
self.assertEqual(data['order_qty'], 1)
self.assertEqual(data['order_uom'], 'EA')
self.assertEqual(data['order_qty_display'], '1 Units')
@ -1073,7 +1210,11 @@ class TestOrderView(WebTestCase):
form = view.make_form(model_instance=order)
# nb. this is to avoid include/exclude ambiguity
form.remove('items')
# nb. store_id gets hidden by default
form.append('store_id')
self.assertIn('store_id', form)
view.configure_form(form)
self.assertNotIn('store_id', form)
schema = form.get_schema()
self.assertIn('pending_customer', form)
self.assertIsInstance(schema['total_price'].typ, WuttaMoney)
@ -1084,13 +1225,20 @@ class TestOrderView(WebTestCase):
self.session.add(local)
self.session.flush()
# nb. from now on we include store_id
self.config.setdefault('sideshow.orders.expose_store_id', 'true')
# viewing (local customer)
with patch.object(view, 'viewing', new=True):
with patch.object(order, 'local_customer', new=local):
form = view.make_form(model_instance=order)
# nb. this is to avoid include/exclude ambiguity
form.remove('items')
# nb. store_id will now remain
form.append('store_id')
self.assertIn('store_id', form)
view.configure_form(form)
self.assertIn('store_id', form)
self.assertNotIn('pending_customer', form)
schema = form.get_schema()
self.assertIsInstance(schema['total_price'].typ, WuttaMoney)
@ -1231,6 +1379,12 @@ class TestOrderView(WebTestCase):
model = self.app.model
view = self.make_view()
self.app.save_setting(self.session, 'sideshow.orders.departments.5.name', 'Bulk')
self.app.save_setting(self.session, 'sideshow.orders.departments.5.default_item_discount', '15')
self.app.save_setting(self.session, 'sideshow.orders.departments.6.name', 'Produce')
self.app.save_setting(self.session, 'sideshow.orders.departments.6.default_item_discount', '5')
self.session.commit()
with patch.object(view, 'Session', return_value=self.session):
with patch.multiple(self.config, usedb=True, preferdb=True):
@ -1238,7 +1392,19 @@ class TestOrderView(WebTestCase):
allowed = self.config.get_bool('sideshow.orders.allow_unknown_products',
session=self.session)
self.assertIsNone(allowed)
self.assertEqual(self.session.query(model.Setting).count(), 0)
self.assertEqual(self.session.query(model.Setting).count(), 4)
discounts = view.get_dept_item_discounts()
self.assertEqual(len(discounts), 2)
self.assertEqual(discounts[0], {
'department_id': '5',
'department_name': 'Bulk',
'default_item_discount': '15',
})
self.assertEqual(discounts[1], {
'department_id': '6',
'department_name': 'Produce',
'default_item_discount': '5',
})
# fetch initial page
response = view.configure()
@ -1248,13 +1414,18 @@ class TestOrderView(WebTestCase):
allowed = self.config.get_bool('sideshow.orders.allow_unknown_products',
session=self.session)
self.assertIsNone(allowed)
self.assertEqual(self.session.query(model.Setting).count(), 0)
self.assertEqual(self.session.query(model.Setting).count(), 4)
# post new settings
with patch.multiple(self.request, create=True,
method='POST',
POST={
'sideshow.orders.allow_unknown_products': 'true',
'dept_item_discounts': json.dumps([{
'department_id': '5',
'department_name': 'Grocery',
'default_item_discount': 10,
}])
}):
response = view.configure()
self.assertIsInstance(response, HTTPFound)
@ -1263,17 +1434,17 @@ class TestOrderView(WebTestCase):
session=self.session)
self.assertTrue(allowed)
self.assertTrue(self.session.query(model.Setting).count() > 1)
discounts = view.get_dept_item_discounts()
self.assertEqual(len(discounts), 1)
self.assertEqual(discounts[0], {
'department_id': '5',
'department_name': 'Grocery',
'default_item_discount': '10',
})
class OrderItemViewTestMixin:
def test_common_order_handler(self):
view = self.make_view()
handler = view.order_handler
self.assertIsInstance(handler, OrderHandler)
handler2 = view.get_order_handler()
self.assertIs(handler2, handler)
def test_common_get_fallback_templates(self):
view = self.make_view()
@ -1289,18 +1460,29 @@ class OrderItemViewTestMixin:
def test_common_configure_grid(self):
model = self.app.model
view = self.make_view()
grid = view.make_grid(model_class=model.OrderItem)
self.assertNotIn('order_id', grid.linked_columns)
view.configure_grid(grid)
self.assertIn('order_id', grid.linked_columns)
def test_common_render_order_id(self):
# store_id is removed by default
grid = view.make_grid(model_class=model.OrderItem)
grid.append('store_id')
self.assertIn('store_id', grid.columns)
view.configure_grid(grid)
self.assertNotIn('store_id', grid.columns)
# store_id is shown if configured
self.config.setdefault('sideshow.orders.expose_store_id', 'true')
grid = view.make_grid(model_class=model.OrderItem)
grid.append('store_id')
self.assertIn('store_id', grid.columns)
view.configure_grid(grid)
self.assertIn('store_id', grid.columns)
def test_common_render_order_attr(self):
model = self.app.model
view = self.make_view()
order = model.Order(order_id=42)
item = model.OrderItem()
order.items.append(item)
self.assertEqual(view.render_order_id(item, None, None), 42)
self.assertEqual(view.render_order_attr(item, 'order_id', None), 42)
def test_common_render_status_code(self):
enum = self.app.enum

View file

@ -132,6 +132,19 @@ class TestPendingProductView(WebTestCase):
self.assertIn('brand_name', grid.linked_columns)
self.assertIn('description', grid.linked_columns)
def test_grid_row_class(self):
enum = self.app.enum
model = self.app.model
view = self.make_view()
product = model.PendingProduct()
# null by default
self.assertIsNone(view.grid_row_class(product, {}, 1))
# warning for ignored
product.status = enum.PendingProductStatus.IGNORED
self.assertEqual(view.grid_row_class(product, {}, 1), 'has-background-warning')
def test_configure_form(self):
model = self.app.model
enum = self.app.enum
@ -141,7 +154,6 @@ class TestPendingProductView(WebTestCase):
with patch.object(view, 'creating', new=True):
form = view.make_form(model_class=model.PendingProduct)
view.configure_form(form)
self.assertNotIn('status', form)
self.assertNotIn('created', form)
self.assertNotIn('created_by', form)
@ -216,6 +228,39 @@ class TestPendingProductView(WebTestCase):
self.assertEqual(len(grid.actions), 1)
self.assertEqual(grid.actions[0].key, 'view')
def test_get_template_context(self):
enum = self.app.enum
model = self.app.model
view = self.make_view()
product = model.PendingProduct(status=enum.PendingProductStatus.PENDING)
orig_context = {'instance': product}
# local setting omitted by default
context = view.get_template_context(orig_context)
self.assertNotIn('use_local_products', context)
# still omitted even though 'viewing'
with patch.object(view, 'viewing', new=True):
context = view.get_template_context(orig_context)
self.assertNotIn('use_local_products', context)
# still omitted even though correct status
product.status = enum.PendingProductStatus.READY
context = view.get_template_context(orig_context)
self.assertNotIn('use_local_products', context)
# no longer omitted if user has perm
with patch.object(self.request, 'is_root', new=True):
context = view.get_template_context(orig_context)
self.assertIn('use_local_products', context)
# nb. true by default
self.assertTrue(context['use_local_products'])
# accurately reflects config
self.config.setdefault('sideshow.orders.use_local_products', 'false')
context = view.get_template_context(orig_context)
self.assertFalse(context['use_local_products'])
def test_delete_instance(self):
self.pyramid_config.add_route('pending_products.view', '/pending/products/{uuid}')
model = self.app.model
@ -259,3 +304,117 @@ class TestPendingProductView(WebTestCase):
view.delete_instance(product)
self.session.flush()
self.assertEqual(self.session.query(model.PendingProduct).count(), 0)
def test_resolve(self):
self.pyramid_config.add_route('pending_products.view', '/pending/products/{uuid}')
model = self.app.model
enum = self.app.enum
view = self.make_view()
# sample data
user = model.User(username='barney')
self.session.add(user)
product = model.PendingProduct(status=enum.PendingProductStatus.PENDING,
created_by=user)
self.session.add(product)
self.session.flush()
info = {
'product_id': '07430500132',
'scancode': '07430500132',
'brand_name': "Bragg's",
'description': "Apple Cider Vinegar",
'size': "32oz",
'weighed': False,
'department_id': None,
'department_name': None,
'special_order': False,
'vendor_name': None,
'vendor_item_code': None,
'case_size': 12,
'unit_cost': 2.99,
'unit_price_reg': 5.99,
}
with patch.object(view, 'Session', return_value=self.session):
with patch.object(self.request, 'user', new=user):
with patch.object(self.request, 'matchdict', new={'uuid': product.uuid}):
# flash error if wrong status
result = view.resolve()
self.assertIsInstance(result, HTTPFound)
self.assertTrue(self.request.session.peek_flash('error'))
self.assertEqual(self.request.session.pop_flash('error'),
["pending product does not have 'ready' status!"])
# flash error if product_id not specified
product.status = enum.PendingProductStatus.READY
result = view.resolve()
self.assertIsInstance(result, HTTPFound)
self.assertTrue(self.request.session.peek_flash('error'))
self.assertEqual(self.request.session.pop_flash('error'),
["must specify valid product_id"])
# more sample data
order = model.Order(order_id=100, created_by=user,
customer_name="Fred Flintstone")
item = model.OrderItem(pending_product=product,
order_qty=1, order_uom=enum.ORDER_UOM_UNIT,
status_code=enum.ORDER_ITEM_STATUS_READY)
order.items.append(item)
self.session.add(order)
# product + order items updated
self.assertIsNone(product.product_id)
self.assertEqual(product.status, enum.PendingProductStatus.READY)
self.assertIsNone(item.product_id)
batch_handler = NewOrderBatchHandler(self.config)
with patch.object(batch_handler, 'get_product_info_external',
return_value=info):
with patch.object(self.app, 'get_batch_handler',
return_value=batch_handler):
with patch.object(self.request, 'POST',
new={'product_id': '07430500132'}):
with patch.object(batch_handler, 'get_product_info_external',
return_value=info):
result = view.resolve()
self.assertIsInstance(result, HTTPFound)
self.assertFalse(self.request.session.peek_flash('error'))
self.assertEqual(product.product_id, '07430500132')
self.assertEqual(product.status, enum.PendingProductStatus.RESOLVED)
self.assertEqual(item.product_id, '07430500132')
def test_ignore(self):
self.pyramid_config.add_route('pending_products.view', '/pending/products/{uuid}')
model = self.app.model
enum = self.app.enum
view = self.make_view()
# sample data
user = model.User(username='barney')
self.session.add(user)
product = model.PendingProduct(status=enum.PendingProductStatus.PENDING,
created_by=user)
self.session.add(product)
self.session.flush()
with patch.object(view, 'Session', return_value=self.session):
with patch.object(self.request, 'user', new=user):
with patch.object(self.request, 'matchdict', new={'uuid': product.uuid}):
# flash error if wrong status
result = view.ignore()
self.assertIsInstance(result, HTTPFound)
self.assertTrue(self.request.session.peek_flash('error'))
self.assertEqual(self.request.session.pop_flash('error'),
["pending product does not have 'ready' status!"])
# product updated
product.status = enum.PendingProductStatus.READY
self.assertIsNone(product.product_id)
self.assertEqual(product.status, enum.PendingProductStatus.READY)
result = view.ignore()
self.assertIsInstance(result, HTTPFound)
self.assertFalse(self.request.session.peek_flash('error'))
self.assertIsNone(product.product_id)
self.assertEqual(product.status, enum.PendingProductStatus.IGNORED)

View file

@ -0,0 +1,94 @@
# -*- coding: utf-8; -*-
from unittest.mock import patch
import colander
from sideshow.testing import WebTestCase
from sideshow.web.views import stores as mod
class TestIncludeme(WebTestCase):
def test_coverage(self):
mod.includeme(self.pyramid_config)
class TestStoreView(WebTestCase):
def make_view(self):
return mod.StoreView(self.request)
def test_configure_grid(self):
model = self.app.model
view = self.make_view()
grid = view.make_grid(model_class=model.Store)
self.assertNotIn('store_id', grid.linked_columns)
self.assertNotIn('name', grid.linked_columns)
view.configure_grid(grid)
self.assertIn('store_id', grid.linked_columns)
self.assertIn('name', grid.linked_columns)
def test_grid_row_class(self):
model = self.app.model
view = self.make_view()
store = model.Store()
self.assertFalse(store.archived)
self.assertIsNone(view.grid_row_class(store, {}, 0))
store = model.Store(archived=True)
self.assertTrue(store.archived)
self.assertEqual(view.grid_row_class(store, {}, 0), 'has-background-warning')
def test_configure_form(self):
model = self.app.model
view = self.make_view()
# unique validators are set
form = view.make_form(model_class=model.Store)
self.assertNotIn('store_id', form.validators)
self.assertNotIn('name', form.validators)
view.configure_form(form)
self.assertIn('store_id', form.validators)
self.assertIn('name', form.validators)
def test_unique_store_id(self):
model = self.app.model
view = self.make_view()
store = model.Store(store_id='001', name='whatever')
self.session.add(store)
self.session.commit()
with patch.object(view, 'Session', return_value=self.session):
# invalid if same store_id in data
node = colander.SchemaNode(colander.String(), name='store_id')
self.assertRaises(colander.Invalid, view.unique_store_id, node, '001')
# but not if store_id belongs to current store
with patch.object(self.request, 'matchdict', new={'uuid': store.uuid}):
with patch.object(view, 'editing', new=True):
node = colander.SchemaNode(colander.String(), name='store_id')
self.assertIsNone(view.unique_store_id(node, '001'))
def test_unique_name(self):
model = self.app.model
view = self.make_view()
store = model.Store(store_id='001', name='Acme Goods')
self.session.add(store)
self.session.commit()
with patch.object(view, 'Session', return_value=self.session):
# invalid if same name in data
node = colander.SchemaNode(colander.String(), name='name')
self.assertRaises(colander.Invalid, view.unique_name, node, 'Acme Goods')
# but not if name belongs to current store
with patch.object(self.request, 'matchdict', new={'uuid': store.uuid}):
with patch.object(view, 'editing', new=True):
node = colander.SchemaNode(colander.String(), name='name')
self.assertIsNone(view.unique_name(node, 'Acme Goods'))