feat: add basic "create order" feature, docs, tests

just the package API docs so far, narrative will come later
This commit is contained in:
Lance Edgar 2025-01-06 17:03:41 -06:00
parent 89265f0240
commit ef07d30a85
86 changed files with 7749 additions and 35 deletions

3
.gitignore vendored
View file

@ -1,2 +1,5 @@
*.pyc
*~
.coverage
docs/_build/
.tox/

20
docs/Makefile Normal file
View file

@ -0,0 +1,20 @@
# Minimal makefile for Sphinx documentation
#
# You can set these variables from the command line, and also
# from the environment for the first two.
SPHINXOPTS ?=
SPHINXBUILD ?= sphinx-build
SOURCEDIR = .
BUILDDIR = _build
# Put it first so that "make" without argument is like "make help".
help:
@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
.PHONY: help Makefile
# Catch-all target: route all unknown targets to Sphinx using the new
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
%: Makefile
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

6
docs/api/sideshow.db.rst Normal file
View file

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

View file

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

6
docs/api/sideshow.rst Normal file
View file

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

View file

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

View file

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

View file

@ -0,0 +1,6 @@
``sideshow.web.forms.schema``
=============================
.. automodule:: sideshow.web.forms.schema
:members:

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

42
docs/conf.py Normal file
View file

@ -0,0 +1,42 @@
# Configuration file for the Sphinx documentation builder.
#
# For the full list of built-in configuration values, see the documentation:
# https://www.sphinx-doc.org/en/master/usage/configuration.html
# -- Project information -----------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
from importlib.metadata import version as get_version
project = 'Sideshow'
copyright = '2025, Lance Edgar'
author = 'Lance Edgar'
release = get_version('Sideshow')
# -- General configuration ---------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
extensions = [
'sphinx.ext.autodoc',
'sphinx.ext.intersphinx',
'sphinx.ext.viewcode',
'sphinx.ext.todo',
'sphinxcontrib.programoutput',
'enum_tools.autoenum',
]
templates_path = ['_templates']
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),
}
# -- Options for HTML output -------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output
html_theme = 'furo'
html_static_path = ['_static']

36
docs/glossary.rst Normal file
View file

@ -0,0 +1,36 @@
Glossary
========
.. glossary::
:sorted:
order
This is the central focus of the app; it refers to a customer
case/special order which is tracked over time, from placement to
fulfillment. Each order may have one or more :term:`order items
<order item>`.
order item
This is effectively a "line item" within an :term:`order`. It
represents a particular product, with quantity and pricing
specific to the order.
Each order item is tracked independently of its parent order and
sibling items.
pending customer
Generally refers to a "new / unknown" customer, e.g. for whom a
new order is being created. This allows the order lifecycle to
get going before the customer has a proper account in the system.
See :class:`~sideshow.db.model.customers.PendingCustomer` for the
data model.
pending product
Generally refers to a "new / unknown" product, e.g. for which a
new order is being created. This allows the order lifecycle to
get going before the product has a true record in the system.
See :class:`~sideshow.db.model.products.PendingProduct` for the
data model.

53
docs/index.rst Normal file
View file

@ -0,0 +1,53 @@
Sideshow
========
This is a web app which provides retailers a way to track case/special
orders.
Good documentation and 100% `test coverage`_ are priorities for this
project.
.. _test coverage: https://buildbot.rattailproject.org/coverage/sideshow/
However as you can see..the API should be fairly well documented but
the narrative docs are pretty scant. That will eventually change.
.. toctree::
:maxdepth: 2
:caption: Documentation:
glossary
narr/cli/index
.. toctree::
:maxdepth: 1
:caption: Package API:
api/sideshow
api/sideshow.batch
api/sideshow.batch.neworder
api/sideshow.cli
api/sideshow.cli.base
api/sideshow.cli.install
api/sideshow.config
api/sideshow.db
api/sideshow.db.model
api/sideshow.db.model.batch
api/sideshow.db.model.batch.neworder
api/sideshow.db.model.customers
api/sideshow.db.model.orders
api/sideshow.db.model.products
api/sideshow.enum
api/sideshow.web
api/sideshow.web.app
api/sideshow.web.forms
api/sideshow.web.forms.schema
api/sideshow.web.menus
api/sideshow.web.static
api/sideshow.web.views
api/sideshow.web.views.batch
api/sideshow.web.views.batch.neworder
api/sideshow.web.views.customers
api/sideshow.web.views.orders
api/sideshow.web.views.products

35
docs/make.bat Normal file
View file

@ -0,0 +1,35 @@
@ECHO OFF
pushd %~dp0
REM Command file for Sphinx documentation
if "%SPHINXBUILD%" == "" (
set SPHINXBUILD=sphinx-build
)
set SOURCEDIR=.
set BUILDDIR=_build
%SPHINXBUILD% >NUL 2>NUL
if errorlevel 9009 (
echo.
echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
echo.installed, then set the SPHINXBUILD environment variable to point
echo.to the full path of the 'sphinx-build' executable. Alternatively you
echo.may add the Sphinx directory to PATH.
echo.
echo.If you don't have Sphinx installed, grab it from
echo.https://www.sphinx-doc.org/
exit /b 1
)
if "%1" == "" goto help
%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
goto end
:help
%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
:end
popd

43
docs/narr/cli/builtin.rst Normal file
View file

@ -0,0 +1,43 @@
===================
Built-in Commands
===================
Sideshow comes with one top-level :term:`command`, and some
:term:`subcommands<subcommand>`.
It uses `Typer`_ for the underlying CLI framework.
.. _Typer: https://typer.tiangolo.com/
``sideshow``
------------
This is the top-level command. Its purpose is to expose subcommands
pertaining to Sideshow.
It is installed to the virtual environment in the ``bin`` folder (or
``Scripts`` on Windows):
.. code-block:: sh
cd /path/to/venv
bin/sideshow --help
Defined in: :mod:`sideshow.cli`
.. program-output:: sideshow --help
.. _sideshow-install:
``sideshow install``
--------------------
Install the web app, generating config files based on interactive
prompting.
Defined in: :mod:`sideshow.cli.install`
.. program-output:: sideshow install --help

14
docs/narr/cli/index.rst Normal file
View file

@ -0,0 +1,14 @@
==============
Command Line
==============
There isn't much to the command line for Sideshow, but here it is.
For more general info about CLI see
:doc:`wuttjamaican:narr/cli/index`.
.. toctree::
:maxdepth: 2
builtin

View file

@ -32,9 +32,14 @@ license = {text = "GNU General Public License v3+"}
requires-python = ">= 3.8"
dependencies = [
"psycopg2",
"WuttaWeb",
"WuttaWeb>=0.19.1",
]
[project.optional-dependencies]
docs = ["Sphinx", "furo", "sphinxcontrib-programoutput", "enum-tools[sphinx]"]
tests = ["pytest-cov", "tox"]
[project.scripts]
"sideshow" = "sideshow.cli:sideshow_typer"

25
src/sideshow/__init__.py Normal file
View file

@ -0,0 +1,25 @@
# -*- 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 - Case/Special Order Tracker
"""

View file

View file

@ -0,0 +1,471 @@
# -*- 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/>.
#
################################################################################
"""
New Order Batch Handler
"""
import datetime
import decimal
from wuttjamaican.batch import BatchHandler
from sideshow.db.model import NewOrderBatch
class NewOrderBatchHandler(BatchHandler):
"""
The :term:`batch handler` for New Order Batches.
This is responsible for business logic around the creation of new
:term:`orders <order>`. A
:class:`~sideshow.db.model.batch.neworder.NewOrderBatch` tracks
all user input until they "submit" (execute) at which point an
:class:`~sideshow.db.model.orders.Order` is created.
"""
model_class = NewOrderBatch
def set_pending_customer(self, batch, data):
"""
Set (add or update) pending customer info for the batch.
This will clear the
:attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.customer_id`
and set the
:attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.pending_customer`,
creating a new record if needed. It then updates the pending
customer record per the given ``data``.
:param batch:
:class:`~sideshow.db.model.batch.neworder.NewOrderBatch`
to be updated.
:param data: Dict of field data for the
:class:`~sideshow.db.model.customers.PendingCustomer`
record.
"""
model = self.app.model
enum = self.app.enum
# remove customer account if set
batch.customer_id = None
# create pending customer if needed
pending = batch.pending_customer
if not pending:
kw = dict(data)
kw.setdefault('status', enum.PendingCustomerStatus.PENDING)
pending = model.PendingCustomer(**kw)
batch.pending_customer = pending
# update pending customer
if 'first_name' in data:
pending.first_name = data['first_name']
if 'last_name' in data:
pending.last_name = data['last_name']
if 'full_name' in data:
pending.full_name = data['full_name']
elif 'first_name' in data or 'last_name' in data:
pending.full_name = self.app.make_full_name(data.get('first_name'),
data.get('last_name'))
if 'phone_number' in data:
pending.phone_number = data['phone_number']
if 'email_address' in data:
pending.email_address = data['email_address']
# update batch per pending customer
batch.customer_name = pending.full_name
batch.phone_number = pending.phone_number
batch.email_address = pending.email_address
def add_pending_product(self, batch, pending_info,
order_qty, order_uom):
"""
Add a new row to the batch, for the given "pending" product
and order quantity.
See also :meth:`set_pending_product()` to update an existing row.
:param batch:
:class:`~sideshow.db.model.batch.neworder.NewOrderBatch` to
which the row should be added.
:param pending_info: Dict of kwargs to use when constructing a
new :class:`~sideshow.db.model.products.PendingProduct`.
:param order_qty: Quantity of the product to be added to the
order.
:param order_uom: UOM for the order quantity; must be a code
from :data:`~sideshow.enum.ORDER_UOM`.
:returns:
:class:`~sideshow.db.model.batch.neworder.NewOrderBatchRow`
which was added to the batch.
"""
model = self.app.model
enum = self.app.enum
session = self.app.get_session(batch)
# make new pending product
kw = dict(pending_info)
kw.setdefault('status', enum.PendingProductStatus.PENDING)
product = model.PendingProduct(**kw)
session.add(product)
session.flush()
# nb. this may convert float to decimal etc.
session.refresh(product)
# make/add new row, w/ pending product
row = self.make_row(pending_product=product,
order_qty=order_qty, order_uom=order_uom)
self.add_row(batch, row)
session.add(row)
session.flush()
return row
def set_pending_product(self, row, data):
"""
Set (add or update) pending product info for the given batch row.
This will clear the
:attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.product_id`
and set the
:attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.pending_product`,
creating a new record if needed. It then updates the pending
product record per the given ``data``, and finally calls
:meth:`refresh_row()`.
Note that this does not update order quantity for the item.
See also :meth:`add_pending_product()` to add a new row
instead of updating.
:param row:
:class:`~sideshow.db.model.batch.neworder.NewOrderBatchRow`
to be updated.
:param data: Dict of field data for the
:class:`~sideshow.db.model.products.PendingProduct` record.
"""
model = self.app.model
enum = self.app.enum
session = self.app.get_session(row)
# values for these fields can be used as-is
simple_fields = [
'scancode',
'brand_name',
'description',
'size',
'weighed',
'department_id',
'department_name',
'special_order',
'vendor_name',
'vendor_item_code',
'notes',
'unit_cost',
'case_size',
'case_cost',
'unit_price_reg',
]
# clear true product id
row.product_id = None
# make pending product if needed
product = row.pending_product
if not product:
kw = dict(data)
kw.setdefault('status', enum.PendingProductStatus.PENDING)
product = model.PendingProduct(**kw)
session.add(product)
row.pending_product = product
session.flush()
# update pending product
for field in simple_fields:
if field in data:
setattr(product, field, data[field])
# nb. this may convert float to decimal etc.
session.flush()
session.refresh(product)
# refresh per new info
self.refresh_row(row)
def refresh_row(self, row, now=None):
"""
Refresh all data for the row. This is called when adding a
new row to the batch, or anytime the row is updated (e.g. when
changing order quantity).
This calls one of the following to update product-related
attributes for the row:
* :meth:`refresh_row_from_pending_product()`
* :meth:`refresh_row_from_true_product()`
It then re-calculates the row's
:attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.total_price`
and updates the batch accordingly.
It also sets the row
:attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.status_code`.
"""
enum = self.app.enum
row.status_code = None
row.status_text = None
# ensure product
if not row.product_id and not row.pending_product:
row.status_code = row.STATUS_MISSING_PRODUCT
return
# ensure order qty/uom
if not row.order_qty or not row.order_uom:
row.status_code = row.STATUS_MISSING_ORDER_QTY
return
# update product attrs on row
if row.product_id:
self.refresh_row_from_true_product(row)
else:
self.refresh_row_from_pending_product(row)
# we need to know if total price changes
old_total = row.total_price
# update quoted price
row.unit_price_quoted = None
row.case_price_quoted = None
if row.unit_price_sale is not None and (
not row.sale_ends
or row.sale_ends > (now or datetime.datetime.now())):
row.unit_price_quoted = row.unit_price_sale
else:
row.unit_price_quoted = row.unit_price_reg
if row.unit_price_quoted is not None and row.case_size:
row.case_price_quoted = row.unit_price_quoted * row.case_size
# update row total price
row.total_price = None
if row.order_uom == enum.ORDER_UOM_CASE:
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:
row.total_price = decimal.Decimal(f'{row.total_price:0.2f}')
# update batch if total price changed
if row.total_price != old_total:
batch = row.batch
batch.total_price = ((batch.total_price or 0)
+ (row.total_price or 0)
- (old_total or 0))
# all ok
row.status_code = row.STATUS_OK
def refresh_row_from_pending_product(self, row):
"""
Update product-related attributes on the row, from its
:attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.pending_product`
record.
This is called automatically from :meth:`refresh_row()`.
"""
product = row.pending_product
row.product_scancode = product.scancode
row.product_brand = product.brand_name
row.product_description = product.description
row.product_size = product.size
row.product_weighed = product.weighed
row.department_id = product.department_id
row.department_name = product.department_name
row.special_order = product.special_order
row.case_size = product.case_size
row.unit_cost = product.unit_cost
row.unit_price_reg = product.unit_price_reg
def refresh_row_from_true_product(self, row):
"""
Update product-related attributes on the row, from its "true"
product record indicated by
:attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.product_id`.
This is called automatically from :meth:`refresh_row()`.
There is no default logic here; subclass must implement as
needed.
"""
def remove_row(self, row):
"""
Remove a row from its batch.
This also will update the batch
:attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.total_price`
accordingly.
"""
if row.total_price:
batch = row.batch
batch.total_price = (batch.total_price or 0) - row.total_price
super().remove_row(row)
def do_delete(self, batch, user, **kwargs):
"""
Delete the given batch entirely.
If the batch has a
:attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.pending_customer`
record, that is deleted also.
"""
# maybe delete pending customer record, if it only exists for
# sake of this batch
if batch.pending_customer:
if len(batch.pending_customer.new_order_batches) == 1:
# TODO: check for past orders too
session = self.app.get_session(batch)
session.delete(batch.pending_customer)
# continue with normal deletion
super().do_delete(batch, user, **kwargs)
def why_not_execute(self, batch, **kwargs):
"""
By default this checks to ensure the batch has a customer and
at least one item.
"""
if not batch.customer_id and not batch.pending_customer:
return "Must assign the customer"
rows = self.get_effective_rows(batch)
if not rows:
return "Must add at least one valid item"
def get_effective_rows(self, batch):
"""
Only rows with
:attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.STATUS_OK`
are "effective" - i.e. rows with other status codes will not
be created as proper order items.
"""
return [row for row in batch.rows
if row.status_code == row.STATUS_OK]
def execute(self, batch, user=None, progress=None, **kwargs):
"""
By default, this will call :meth:`make_new_order()` and return
the new :class:`~sideshow.db.model.orders.Order` instance.
Note that callers should use
:meth:`~wuttjamaican:wuttjamaican.batch.BatchHandler.do_execute()`
instead, which calls this method automatically.
"""
rows = self.get_effective_rows(batch)
order = self.make_new_order(batch, rows, user=user, progress=progress, **kwargs)
return order
def make_new_order(self, batch, rows, user=None, progress=None, **kwargs):
"""
Create a new :term:`order` from the batch data.
This is called automatically from :meth:`execute()`.
:param batch:
:class:`~sideshow.db.model.batch.neworder.NewOrderBatch`
instance.
:param rows: List of effective rows for the batch, i.e. which
rows should be converted to :term:`order items <order
item>`.
:returns: :class:`~sideshow.db.model.orders.Order` instance.
"""
model = self.app.model
enum = self.app.enum
session = self.app.get_session(batch)
batch_fields = [
'store_id',
'customer_id',
'pending_customer',
'customer_name',
'phone_number',
'email_address',
'total_price',
]
row_fields = [
'pending_product_uuid',
'product_scancode',
'product_brand',
'product_description',
'product_size',
'product_weighed',
'department_id',
'department_name',
'case_size',
'order_qty',
'order_uom',
'unit_cost',
'unit_price_quoted',
'case_price_quoted',
'unit_price_reg',
'unit_price_sale',
'sale_ends',
# 'discount_percent',
'total_price',
'special_order',
]
# make order
kw = dict([(field, getattr(batch, field))
for field in batch_fields])
kw['order_id'] = batch.id
kw['created_by'] = user
order = model.Order(**kw)
session.add(order)
session.flush()
def convert(row, i):
# make order item
kw = dict([(field, getattr(row, field))
for field in row_fields])
item = model.OrderItem(**kw)
order.items.append(item)
# set item status
item.status_code = enum.ORDER_ITEM_STATUS_INITIATED
self.app.progress_loop(convert, rows, progress,
message="Converting batch rows to order items")
session.flush()
return order

View file

@ -21,17 +21,16 @@
#
################################################################################
"""
Pyramid event subscribers
Sideshow - command line interface
See also :doc:`/narr/cli/index`.
This (``sideshow.cli``) namespace exposes the following:
* :data:`~sideshow.cli.base.sideshow_typer` (top-level command)
"""
import sideshow
from .base import sideshow_typer
def add_sideshow_to_context(event):
renderer_globals = event
renderer_globals['sideshow'] = sideshow
def includeme(config):
config.include('wuttaweb.subscribers')
config.add_subscriber(add_sideshow_to_context, 'pyramid.events.BeforeRender')
# nb. must bring in all modules for discovery to work
from . import install

40
src/sideshow/cli/base.py Normal file
View file

@ -0,0 +1,40 @@
# -*- 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 - core command logic
See also :doc:`/narr/cli/index`.
.. data:: sideshow_typer
This is the top-level ``sideshow`` :term:`command`, using the Typer
framework.
"""
from wuttjamaican.cli import make_typer
sideshow_typer = make_typer(
name='sideshow',
help="Sideshow -- Case/Special Order Tracker"
)

View file

@ -21,18 +21,12 @@
#
################################################################################
"""
Sideshow CLI
See also: :ref:`sideshow-install`
"""
import typer
from wuttjamaican.cli import make_typer
sideshow_typer = make_typer(
name='sideshow',
help="Sideshow -- Case/Special Order Tracker"
)
from .base import sideshow_typer
@sideshow_typer.command()

View file

@ -21,7 +21,7 @@
#
################################################################################
"""
Sideshow config extensions
Sideshow config extension
"""
from wuttjamaican.conf import WuttaConfigExtension
@ -29,18 +29,22 @@ from wuttjamaican.conf import WuttaConfigExtension
class SideshowConfig(WuttaConfigExtension):
"""
Config extension for Sideshow
Config extension for Sideshow.
This establishes some config defaults specific to Sideshow.
"""
key = 'sideshow'
def configure(self, config):
""" """
# app info
config.setdefault(f'{config.appname}.app_title', "Sideshow")
config.setdefault(f'{config.appname}.app_dist', "Sideshow")
# app model
# app model, enum
config.setdefault(f'{config.appname}.model_spec', 'sideshow.db.model')
config.setdefault(f'{config.appname}.enum_spec', 'sideshow.enum')
# web app menu
config.setdefault(f'{config.appname}.web.menus.handler_spec',

View file

@ -0,0 +1,203 @@
"""initial order tables
Revision ID: 7a6df83afbd4
Revises:
Create Date: 2024-12-30 18:53:51.358163
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
import wuttjamaican.db.util
# revision identifiers, used by Alembic.
revision: str = '7a6df83afbd4'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = ('sideshow',)
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# enums
sa.Enum('PENDING', 'READY', 'RESOLVED', name='pendingcustomerstatus').create(op.get_bind())
sa.Enum('PENDING', 'READY', 'RESOLVED', name='pendingproductstatus').create(op.get_bind())
# sideshow_pending_customer
op.create_table('sideshow_pending_customer',
sa.Column('uuid', wuttjamaican.db.util.UUID(), nullable=False),
sa.Column('customer_id', sa.String(length=20), nullable=True),
sa.Column('full_name', sa.String(length=100), nullable=True),
sa.Column('first_name', sa.String(length=50), nullable=True),
sa.Column('last_name', sa.String(length=50), nullable=True),
sa.Column('phone_number', sa.String(length=20), nullable=True),
sa.Column('email_address', sa.String(length=255), nullable=True),
sa.Column('status', postgresql.ENUM('PENDING', 'READY', 'RESOLVED', name='pendingcustomerstatus', create_type=False), nullable=False),
sa.Column('created', sa.DateTime(timezone=True), nullable=False),
sa.Column('created_by_uuid', wuttjamaican.db.util.UUID(), nullable=False),
sa.ForeignKeyConstraint(['created_by_uuid'], ['user.uuid'], name=op.f('fk_sideshow_pending_customer_created_by_uuid_user')),
sa.PrimaryKeyConstraint('uuid', name=op.f('pk_sideshow_pending_customer'))
)
# sideshow_pending_product
op.create_table('sideshow_pending_product',
sa.Column('uuid', wuttjamaican.db.util.UUID(), nullable=False),
sa.Column('product_id', sa.String(length=20), nullable=True),
sa.Column('scancode', sa.String(length=14), nullable=True),
sa.Column('department_id', sa.String(length=10), nullable=True),
sa.Column('department_name', sa.String(length=30), nullable=True),
sa.Column('brand_name', sa.String(length=100), nullable=True),
sa.Column('description', sa.String(length=255), nullable=True),
sa.Column('size', sa.String(length=30), nullable=True),
sa.Column('weighed', sa.Boolean(), nullable=True),
sa.Column('vendor_name', sa.String(length=50), nullable=True),
sa.Column('vendor_item_code', sa.String(length=20), nullable=True),
sa.Column('unit_cost', sa.Numeric(precision=9, scale=5), nullable=True),
sa.Column('case_size', sa.Numeric(precision=9, scale=4), nullable=True),
sa.Column('unit_price_reg', sa.Numeric(precision=8, scale=3), nullable=True),
sa.Column('special_order', sa.Boolean(), nullable=True),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('status', postgresql.ENUM('PENDING', 'READY', 'RESOLVED', name='pendingproductstatus', create_type=False), nullable=False),
sa.Column('created', sa.DateTime(timezone=True), nullable=False),
sa.Column('created_by_uuid', wuttjamaican.db.util.UUID(), nullable=False),
sa.ForeignKeyConstraint(['created_by_uuid'], ['user.uuid'], name=op.f('fk_sideshow_pending_product_created_by_uuid_user')),
sa.PrimaryKeyConstraint('uuid', name=op.f('pk_sideshow_pending_product'))
)
# sideshow_order
op.create_table('sideshow_order',
sa.Column('uuid', wuttjamaican.db.util.UUID(), nullable=False),
sa.Column('order_id', sa.Integer(), nullable=False),
sa.Column('store_id', sa.String(length=10), nullable=True),
sa.Column('customer_id', sa.String(length=20), nullable=True),
sa.Column('pending_customer_uuid', wuttjamaican.db.util.UUID(), nullable=True),
sa.Column('customer_name', sa.String(length=100), nullable=True),
sa.Column('phone_number', sa.String(length=20), nullable=True),
sa.Column('email_address', sa.String(length=255), nullable=True),
sa.Column('total_price', sa.Numeric(precision=10, scale=3), nullable=True),
sa.Column('created', sa.DateTime(timezone=True), nullable=False),
sa.Column('created_by_uuid', wuttjamaican.db.util.UUID(), nullable=False),
sa.ForeignKeyConstraint(['pending_customer_uuid'], ['sideshow_pending_customer.uuid'], name=op.f('fk_order_pending_customer_uuid_pending_customer')),
sa.ForeignKeyConstraint(['created_by_uuid'], ['user.uuid'], name=op.f('fk_order_created_by_uuid_user')),
sa.PrimaryKeyConstraint('uuid', name=op.f('pk_order'))
)
# sideshow_order_item
op.create_table('sideshow_order_item',
sa.Column('uuid', wuttjamaican.db.util.UUID(), nullable=False),
sa.Column('order_uuid', wuttjamaican.db.util.UUID(), nullable=False),
sa.Column('sequence', sa.Integer(), nullable=False),
sa.Column('product_id', sa.String(length=20), nullable=True),
sa.Column('pending_product_uuid', wuttjamaican.db.util.UUID(), nullable=True),
sa.Column('product_scancode', sa.String(length=14), nullable=True),
sa.Column('product_brand', sa.String(length=100), nullable=True),
sa.Column('product_description', sa.String(length=255), nullable=True),
sa.Column('product_size', sa.String(length=30), nullable=True),
sa.Column('product_weighed', sa.Boolean(), nullable=True),
sa.Column('department_id', sa.String(length=10), nullable=True),
sa.Column('department_name', sa.String(length=30), nullable=True),
sa.Column('special_order', sa.Boolean(), nullable=True),
sa.Column('case_size', sa.Numeric(precision=10, scale=4), nullable=True),
sa.Column('order_qty', sa.Numeric(precision=10, scale=4), nullable=False),
sa.Column('order_uom', sa.String(length=10), nullable=False),
sa.Column('unit_cost', sa.Numeric(precision=9, scale=5), nullable=True),
sa.Column('unit_price_reg', sa.Numeric(precision=8, scale=3), nullable=True),
sa.Column('unit_price_sale', sa.Numeric(precision=8, scale=3), nullable=True),
sa.Column('sale_ends', sa.DateTime(timezone=True), nullable=True),
sa.Column('unit_price_quoted', sa.Numeric(precision=8, scale=3), nullable=True),
sa.Column('case_price_quoted', sa.Numeric(precision=8, scale=3), nullable=True),
sa.Column('discount_percent', sa.Numeric(precision=5, scale=3), nullable=True),
sa.Column('total_price', sa.Numeric(precision=8, scale=3), nullable=True),
sa.Column('status_code', sa.Integer(), nullable=False),
sa.Column('paid_amount', sa.Numeric(precision=8, scale=3), nullable=False),
sa.Column('payment_transaction_number', sa.String(length=20), nullable=True),
sa.ForeignKeyConstraint(['order_uuid'], ['sideshow_order.uuid'], name=op.f('fk_sideshow_order_item_order_uuid_order')),
sa.ForeignKeyConstraint(['pending_product_uuid'], ['sideshow_pending_product.uuid'], name=op.f('fk_sideshow_order_item_pending_product_uuid_pending_product')),
sa.PrimaryKeyConstraint('uuid', name=op.f('pk_order_item'))
)
# sideshow_batch_neworder
op.create_table('sideshow_batch_neworder',
sa.Column('uuid', wuttjamaican.db.util.UUID(), nullable=False),
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('description', sa.String(length=255), nullable=True),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('row_count', sa.Integer(), nullable=True),
sa.Column('status_code', sa.Integer(), nullable=True),
sa.Column('status_text', sa.String(length=255), nullable=True),
sa.Column('created', sa.DateTime(timezone=True), nullable=False),
sa.Column('created_by_uuid', wuttjamaican.db.util.UUID(), nullable=False),
sa.Column('executed', sa.DateTime(timezone=True), nullable=True),
sa.Column('executed_by_uuid', wuttjamaican.db.util.UUID(), nullable=True),
sa.Column('store_id', sa.String(length=10), nullable=True),
sa.Column('customer_id', sa.String(length=20), nullable=True),
sa.Column('pending_customer_uuid', wuttjamaican.db.util.UUID(), nullable=True),
sa.Column('customer_name', sa.String(length=100), nullable=True),
sa.Column('phone_number', sa.String(length=20), nullable=True),
sa.Column('email_address', sa.String(length=255), nullable=True),
sa.Column('total_price', sa.Numeric(precision=10, scale=3), nullable=True),
sa.ForeignKeyConstraint(['created_by_uuid'], ['user.uuid'], name=op.f('fk_sideshow_batch_neworder_created_by_uuid_user')),
sa.ForeignKeyConstraint(['executed_by_uuid'], ['user.uuid'], name=op.f('fk_sideshow_batch_neworder_executed_by_uuid_user')),
sa.ForeignKeyConstraint(['pending_customer_uuid'], ['sideshow_pending_customer.uuid'], name=op.f('fk_sideshow_batch_neworder_pending_customer_uuid_pending_customer')),
sa.PrimaryKeyConstraint('uuid', name=op.f('pk_sideshow_batch_neworder'))
)
# sideshow_batch_neworder_row
op.create_table('sideshow_batch_neworder_row',
sa.Column('uuid', wuttjamaican.db.util.UUID(), nullable=False),
sa.Column('batch_uuid', wuttjamaican.db.util.UUID(), nullable=False),
sa.Column('sequence', sa.Integer(), nullable=False),
sa.Column('status_text', sa.String(length=255), nullable=True),
sa.Column('modified', sa.DateTime(timezone=True), nullable=True),
sa.Column('pending_product_uuid', wuttjamaican.db.util.UUID(), nullable=True),
sa.Column('product_id', sa.String(length=20), nullable=True),
sa.Column('product_scancode', sa.String(length=14), nullable=True),
sa.Column('product_brand', sa.String(length=100), nullable=True),
sa.Column('product_description', sa.String(length=255), nullable=True),
sa.Column('product_size', sa.String(length=30), nullable=True),
sa.Column('product_weighed', sa.Boolean(), nullable=True),
sa.Column('department_id', sa.String(length=10), nullable=True),
sa.Column('department_name', sa.String(length=30), nullable=True),
sa.Column('special_order', sa.Boolean(), nullable=True),
sa.Column('case_size', sa.Numeric(precision=10, scale=4), nullable=True),
sa.Column('order_qty', sa.Numeric(precision=10, scale=4), nullable=False),
sa.Column('order_uom', sa.String(length=10), nullable=False),
sa.Column('unit_cost', sa.Numeric(precision=9, scale=5), nullable=True),
sa.Column('unit_price_reg', sa.Numeric(precision=8, scale=3), nullable=True),
sa.Column('unit_price_sale', sa.Numeric(precision=8, scale=3), nullable=True),
sa.Column('sale_ends', sa.DateTime(timezone=True), nullable=True),
sa.Column('unit_price_quoted', sa.Numeric(precision=8, scale=3), nullable=True),
sa.Column('case_price_quoted', sa.Numeric(precision=8, scale=3), nullable=True),
sa.Column('discount_percent', sa.Numeric(precision=5, scale=3), nullable=True),
sa.Column('total_price', sa.Numeric(precision=8, scale=3), nullable=True),
sa.Column('status_code', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['batch_uuid'], ['sideshow_batch_neworder.uuid'], name=op.f('fk_sideshow_batch_neworder_row_batch_uuid_batch_neworder')),
sa.ForeignKeyConstraint(['pending_product_uuid'], ['sideshow_pending_product.uuid'], name=op.f('fk_sideshow_batch_neworder_row_pending_product_uuid_pending_product')),
sa.PrimaryKeyConstraint('uuid', name=op.f('pk_sideshow_batch_neworder_row'))
)
def downgrade() -> None:
# sideshow_batch_neworder*
op.drop_table('sideshow_batch_neworder_row')
op.drop_table('sideshow_batch_neworder')
# sideshow_order_item
op.drop_table('sideshow_order_item')
# sideshow_order
op.drop_table('sideshow_order')
# sideshow_pending_product
op.drop_table('sideshow_pending_product')
# sideshow_pending_customer
op.drop_table('sideshow_pending_customer')
# enums
sa.Enum('PENDING', 'READY', 'RESOLVED', name='pendingproductstatus').drop(op.get_bind())
sa.Enum('PENDING', 'READY', 'RESOLVED', name='pendingcustomerstatus').drop(op.get_bind())

View file

@ -22,9 +22,32 @@
################################################################################
"""
Sideshow data models
This is the default :term:`app model` module for Sideshow.
This namespace exposes everything from
:mod:`wuttjamaican:wuttjamaican.db.model`, plus the following.
Primary :term:`data models <data model>`:
* :class:`~sideshow.db.model.orders.Order`
* :class:`~sideshow.db.model.orders.OrderItem`
* :class:`~sideshow.db.model.customers.PendingCustomer`
* :class:`~sideshow.db.model.products.PendingProduct`
And the :term:`batch` models:
* :class:`~sideshow.db.model.batch.neworder.NewOrderBatch`
* :class:`~sideshow.db.model.batch.neworder.NewOrderBatchRow`
"""
# bring in all of wutta
from wuttjamaican.db.model import *
# TODO: import other/custom models here...
# sideshow models
from .customers import PendingCustomer
from .products import PendingProduct
from .orders import Order, OrderItem
# batch models
from .batch.neworder import NewOrderBatch, NewOrderBatchRow

View file

View file

@ -0,0 +1,310 @@
# -*- 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/>.
#
################################################################################
"""
Data models for New Order Batch
* :class:`NewOrderBatch`
* :class:`NewOrderBatchRow`
"""
import sqlalchemy as sa
from sqlalchemy import orm
from sqlalchemy.ext.declarative import declared_attr
from wuttjamaican.db import model
class NewOrderBatch(model.BatchMixin, model.Base):
"""
:term:`Batch <batch>` used for entering new :term:`orders <order>`
into the system. Each batch ultimately becomes an
:class:`~sideshow.db.model.orders.Order`.
See also :class:`~sideshow.batch.neworder.NewOrderBatchHandler`
which is the default :term:`batch handler` for this :term:`batch
type`.
Generic batch attributes (undocumented below) are inherited from
:class:`~wuttjamaican:wuttjamaican.db.model.batch.BatchMixin`.
"""
__tablename__ = 'sideshow_batch_neworder'
__batchrow_class__ = 'NewOrderBatchRow'
batch_type = 'neworder'
"""
Official :term:`batch type` key.
"""
@declared_attr
def __table_args__(cls):
return cls.__default_table_args__() + (
sa.ForeignKeyConstraint(['pending_customer_uuid'], ['sideshow_pending_customer.uuid']),
)
STATUS_OK = 1
STATUS = {
STATUS_OK : "ok",
}
store_id = sa.Column(sa.String(length=10), nullable=True, doc="""
ID of the store to which the order pertains, if applicable.
""")
customer_id = sa.Column(sa.String(length=20), nullable=True, doc="""
ID of the proper customer account to which the order pertains, if
applicable.
This will be set only when an "existing" customer account can be
selected for the order. See also :attr:`pending_customer`.
""")
pending_customer_uuid = sa.Column(model.UUID(), nullable=True)
@declared_attr
def pending_customer(cls):
return orm.relationship(
'PendingCustomer',
back_populates='new_order_batches',
doc="""
Reference to the
:class:`~sideshow.db.model.customers.PendingCustomer`
record for the order, if applicable.
This is set only when making an order for a "new /
unknown" customer. See also :attr:`customer_id`.
""")
customer_name = sa.Column(sa.String(length=100), nullable=True, doc="""
Name for the customer account.
""")
phone_number = sa.Column(sa.String(length=20), nullable=True, doc="""
Phone number for the customer.
""")
email_address = sa.Column(sa.String(length=255), nullable=True, doc="""
Email address for the customer.
""")
total_price = sa.Column(sa.Numeric(precision=10, scale=3), nullable=True, doc="""
Full price (not including tax etc.) for all items on the order.
""")
class NewOrderBatchRow(model.BatchRowMixin, model.Base):
"""
Row of data within a :class:`NewOrderBatch`. Each row ultimately
becomes an :class:`~sideshow.db.model.orders.OrderItem`.
Generic row attributes (undocumented below) are inherited from
:class:`~wuttjamaican:wuttjamaican.db.model.batch.BatchRowMixin`.
"""
__tablename__ = 'sideshow_batch_neworder_row'
__batch_class__ = NewOrderBatch
@declared_attr
def __table_args__(cls):
return cls.__default_table_args__() + (
sa.ForeignKeyConstraint(['pending_product_uuid'], ['sideshow_pending_product.uuid']),
)
STATUS_OK = 1
"""
This is the default value for :attr:`status_code`. All rows are
considered "OK" if they have either a :attr:`product_id` or
:attr:`pending_product`.
"""
STATUS_MISSING_PRODUCT = 2
"""
Status code indicating the row has no :attr:`product_id` or
:attr:`pending_product` set.
"""
STATUS_MISSING_ORDER_QTY = 3
"""
Status code indicating the row has no :attr:`order_qty` and/or
:attr:`order_uom` set.
"""
STATUS = {
STATUS_OK : "ok",
STATUS_MISSING_PRODUCT : "missing product",
STATUS_MISSING_ORDER_QTY : "missing order qty/uom",
}
"""
Dict of possible status code -> label options.
"""
product_id = sa.Column(sa.String(length=20), nullable=True, doc="""
ID of the true product which the order item represents, if
applicable.
This will be set only when an "existing" product can be selected
for the order. See also :attr:`pending_product`.
""")
pending_product_uuid = sa.Column(model.UUID(), nullable=True)
@declared_attr
def pending_product(cls):
return orm.relationship(
'PendingProduct',
back_populates='new_order_batch_rows',
doc="""
Reference to the
:class:`~sideshow.db.model.products.PendingProduct` record
for the order item, if applicable.
This is set only when making an order for a "new /
unknown" product. See also :attr:`product_id`.
""")
product_scancode = sa.Column(sa.String(length=14), nullable=True, doc="""
Scancode for the product, as string.
.. note::
This column allows 14 chars, so can store a full GPC with check
digit. However as of writing the actual format used here does
not matter to Sideshow logic; "anything" should work.
That may change eventually, depending on POS integration
scenarios that come up. Maybe a config option to declare
whether check digit should be included or not, etc.
""")
product_brand = sa.Column(sa.String(length=100), nullable=True, doc="""
Brand name for the product - up to 100 chars.
""")
product_description = sa.Column(sa.String(length=255), nullable=True, doc="""
Description for the product - up to 255 chars.
""")
product_size = sa.Column(sa.String(length=30), nullable=True, doc="""
Size of the product, as string - up to 30 chars.
""")
product_weighed = sa.Column(sa.Boolean(), nullable=True, doc="""
Flag indicating the product is sold by weight; default is null.
""")
department_id = sa.Column(sa.String(length=10), nullable=True, doc="""
ID of the department to which the product belongs, if known.
""")
department_name = sa.Column(sa.String(length=30), nullable=True, doc="""
Name of the department to which the product belongs, if known.
""")
special_order = sa.Column(sa.Boolean(), nullable=True, doc="""
Flag indicating the item is a "special order" - e.g. something not
normally carried by the store. Default is null.
""")
case_size = sa.Column(sa.Numeric(precision=10, scale=4), nullable=True, doc="""
Case pack count for the product, if known.
If this is not set, then customer cannot order a "case" of the item.
""")
order_qty = sa.Column(sa.Numeric(precision=10, scale=4), nullable=False, doc="""
Quantity (as decimal) of product being ordered.
This must be interpreted along with :attr:`order_uom` to determine
the *complete* order quantity, e.g. "2 cases".
""")
order_uom = sa.Column(sa.String(length=10), nullable=False, doc="""
Code indicating the unit of measure for product being ordered.
This should be one of the codes from
:data:`~sideshow.enum.ORDER_UOM`.
Sideshow will treat :data:`~sideshow.enum.ORDER_UOM_CASE`
differently but :data:`~sideshow.enum.ORDER_UOM_UNIT` and others
are all treated the same (i.e. "unit" is assumed).
""")
unit_cost = sa.Column(sa.Numeric(precision=9, scale=5), nullable=True, doc="""
Cost of goods amount for one "unit" (not "case") of the product,
as decimal to 4 places.
""")
unit_price_reg = sa.Column(sa.Numeric(precision=8, scale=3), nullable=True, doc="""
Regular price for the item unit. Unless a sale is in effect,
:attr:`unit_price_quoted` will typically match this value.
""")
unit_price_sale = sa.Column(sa.Numeric(precision=8, scale=3), nullable=True, doc="""
Sale price for the item unit, if applicable. If set, then
:attr:`unit_price_quoted` will typically match this value. See
also :attr:`sale_ends`.
""")
sale_ends = sa.Column(sa.DateTime(timezone=True), nullable=True, doc="""
End date/time for the sale in effect, if any.
This is only relevant if :attr:`unit_price_sale` is set.
""")
unit_price_quoted = sa.Column(sa.Numeric(precision=8, scale=3), nullable=True, doc="""
Quoted price for the item unit. This is the "effective" unit
price, which is used to calculate :attr:`total_price`.
This price does *not* reflect the :attr:`discount_percent`. It
normally should match either :attr:`unit_price_reg` or
:attr:`unit_price_sale`.
See also :attr:`case_price_quoted`, if applicable.
""")
case_price_quoted = sa.Column(sa.Numeric(precision=8, scale=3), nullable=True, doc="""
Quoted price for a "case" of the item, if applicable.
This is mostly for display purposes; :attr:`unit_price_quoted` is
used for calculations.
""")
discount_percent = sa.Column(sa.Numeric(precision=5, scale=3), nullable=True, doc="""
Discount percent to apply when calculating :attr:`total_price`, if
applicable.
""")
total_price = sa.Column(sa.Numeric(precision=8, scale=3), nullable=True, doc="""
Full price (not including tax etc.) which the customer is quoted
for the order item.
This is calculated using values from:
* :attr:`unit_price_quoted`
* :attr:`order_qty`
* :attr:`order_uom`
* :attr:`case_size`
* :attr:`discount_percent`
""")
def __str__(self):
return str(self.pending_product or self.product_description or "")

View file

@ -0,0 +1,112 @@
# -*- 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/>.
#
################################################################################
"""
Data models for Customers
"""
import datetime
import sqlalchemy as sa
from sqlalchemy import orm
from wuttjamaican.db import model
from sideshow.enum import PendingCustomerStatus
class PendingCustomer(model.Base):
"""
A "pending" customer record, used when entering an :term:`order`
for new/unknown customer.
"""
__tablename__ = 'sideshow_pending_customer'
uuid = model.uuid_column()
customer_id = sa.Column(sa.String(length=20), nullable=True, doc="""
ID of the proper customer account associated with this record, if
applicable.
""")
full_name = sa.Column(sa.String(length=100), nullable=True, doc="""
Full display name for the customer account.
""")
first_name = sa.Column(sa.String(length=50), nullable=True, doc="""
First name of the customer.
""")
last_name = sa.Column(sa.String(length=50), nullable=True, doc="""
Last name of the customer.
""")
phone_number = sa.Column(sa.String(length=20), nullable=True, doc="""
Phone number for the customer.
""")
email_address = sa.Column(sa.String(length=255), nullable=True, doc="""
Email address for the customer.
""")
status = sa.Column(sa.Enum(PendingCustomerStatus), nullable=False, doc="""
Status code for the customer record.
""")
created = sa.Column(sa.DateTime(timezone=True), nullable=False,
default=datetime.datetime.now, doc="""
Timestamp when the customer record was created.
""")
created_by_uuid = model.uuid_fk_column('user.uuid', nullable=False)
created_by = orm.relationship(
model.User,
cascade_backrefs=False,
doc="""
Reference to the
:class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who
created the customer record.
""")
orders = orm.relationship(
'Order',
order_by='Order.order_id.desc()',
cascade_backrefs=False,
back_populates='pending_customer',
doc="""
List of :class:`~sideshow.db.model.orders.Order` records
associated with this customer.
""")
new_order_batches = orm.relationship(
'NewOrderBatch',
order_by='NewOrderBatch.id.desc()',
cascade_backrefs=False,
back_populates='pending_customer',
doc="""
List of
:class:`~sideshow.db.model.batch.neworder.NewOrderBatch`
records associated with this customer.
""")
def __str__(self):
return self.full_name or ""

View file

@ -0,0 +1,314 @@
# -*- 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/>.
#
################################################################################
"""
Data models for Orders
"""
import datetime
import sqlalchemy as sa
from sqlalchemy import orm
from sqlalchemy.ext.orderinglist import ordering_list
from wuttjamaican.db import model
class Order(model.Base):
"""
Represents an :term:`order` for a customer. Each order has one or
more :attr:`items`.
Usually, orders are created by way of a
:class:`~sideshow.db.model.batch.neworder.NewOrderBatch`.
"""
__tablename__ = 'sideshow_order'
# TODO: this feels a bit hacky yet but it does avoid problems
# showing the Orders grid for a PendingCustomer
__colanderalchemy_config__ = {
'excludes': ['items'],
}
uuid = model.uuid_column()
order_id = sa.Column(sa.Integer(), nullable=False, doc="""
Unique ID for the order.
When the order is created from New Order Batch, this order ID will
match the batch ID.
""")
store_id = sa.Column(sa.String(length=10), nullable=True, doc="""
ID of the store to which the order pertains, if applicable.
""")
customer_id = sa.Column(sa.String(length=20), nullable=True, doc="""
ID of the proper customer account to which the order pertains, if
applicable.
This will be set only when an "existing" customer account can be
assigned for the order. See also :attr:`pending_customer`.
""")
pending_customer_uuid = model.uuid_fk_column('sideshow_pending_customer.uuid', nullable=True)
pending_customer = orm.relationship(
'PendingCustomer',
cascade_backrefs=False,
back_populates='orders',
doc="""
Reference to the
:class:`~sideshow.db.model.customers.PendingCustomer` record
for the order, if applicable.
This is set only when the order is for a "new / unknown"
customer. See also :attr:`customer_id`.
""")
customer_name = sa.Column(sa.String(length=100), nullable=True, doc="""
Name for the customer account.
""")
phone_number = sa.Column(sa.String(length=20), nullable=True, doc="""
Phone number for the customer.
""")
email_address = sa.Column(sa.String(length=255), nullable=True, doc="""
Email address for the customer.
""")
total_price = sa.Column(sa.Numeric(precision=10, scale=3), nullable=True, doc="""
Full price (not including tax etc.) for all items on the order.
""")
created = sa.Column(sa.DateTime(timezone=True), nullable=False, default=datetime.datetime.now, doc="""
Timestamp when the order was created.
If the order is created via New Order Batch, this will match the
batch execution timestamp.
""")
created_by_uuid = model.uuid_fk_column('user.uuid', nullable=False)
created_by = orm.relationship(
model.User,
cascade_backrefs=False,
doc="""
Reference to the
:class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who
created the order.
""")
items = orm.relationship(
'OrderItem',
collection_class=ordering_list('sequence', count_from=1),
cascade='all, delete-orphan',
cascade_backrefs=False,
back_populates='order',
doc="""
List of :class:`OrderItem` records belonging to the order.
""")
def __str__(self):
return str(self.order_id)
class OrderItem(model.Base):
"""
Represents an :term:`order item` within an :class:`Order`.
Usually these are created from
:class:`~sideshow.db.model.batch.neworder.NewOrderBatchRow`
records.
"""
__tablename__ = 'sideshow_order_item'
uuid = model.uuid_column()
order_uuid = model.uuid_fk_column('sideshow_order.uuid', nullable=False)
order = orm.relationship(
Order,
cascade_backrefs=False,
back_populates='items',
doc="""
Reference to the :class:`Order` to which the item belongs.
""")
sequence = sa.Column(sa.Integer(), nullable=False, doc="""
1-based numeric sequence for the item, i.e. its line number within
the order.
""")
product_id = sa.Column(sa.String(length=20), nullable=True, doc="""
ID of the true product which the order item represents, if
applicable.
This will be set only when an "existing" product can be selected
for the order. See also :attr:`pending_product`.
""")
pending_product_uuid = model.uuid_fk_column('sideshow_pending_product.uuid', nullable=True)
pending_product = orm.relationship(
'PendingProduct',
cascade_backrefs=False,
back_populates='order_items',
doc="""
Reference to the
:class:`~sideshow.db.model.products.PendingProduct` record for
the order item, if applicable.
This is set only when the order item is for a "new / unknown"
product. See also :attr:`product_id`.
""")
product_scancode = sa.Column(sa.String(length=14), nullable=True, doc="""
Scancode for the product, as string.
.. note::
This column allows 14 chars, so can store a full GPC with check
digit. However as of writing the actual format used here does
not matter to Sideshow logic; "anything" should work.
That may change eventually, depending on POS integration
scenarios that come up. Maybe a config option to declare
whether check digit should be included or not, etc.
""")
product_brand = sa.Column(sa.String(length=100), nullable=True, doc="""
Brand name for the product - up to 100 chars.
""")
product_description = sa.Column(sa.String(length=255), nullable=True, doc="""
Description for the product - up to 255 chars.
""")
product_size = sa.Column(sa.String(length=30), nullable=True, doc="""
Size of the product, as string - up to 30 chars.
""")
product_weighed = sa.Column(sa.Boolean(), nullable=True, doc="""
Flag indicating the product is sold by weight; default is null.
""")
department_id = sa.Column(sa.String(length=10), nullable=True, doc="""
ID of the department to which the product belongs, if known.
""")
department_name = sa.Column(sa.String(length=30), nullable=True, doc="""
Name of the department to which the product belongs, if known.
""")
special_order = sa.Column(sa.Boolean(), nullable=True, doc="""
Flag indicating the item is a "special order" - e.g. something not
normally carried by the store. Default is null.
""")
case_size = sa.Column(sa.Numeric(precision=10, scale=4), nullable=True, doc="""
Case pack count for the product, if known.
""")
order_qty = sa.Column(sa.Numeric(precision=10, scale=4), nullable=False, doc="""
Quantity (as decimal) of product being ordered.
This must be interpreted along with :attr:`order_uom` to determine
the *complete* order quantity, e.g. "2 cases".
""")
order_uom = sa.Column(sa.String(length=10), nullable=False, doc="""
Code indicating the unit of measure for product being ordered.
This should be one of the codes from
:data:`~sideshow.enum.ORDER_UOM`.
""")
unit_cost = sa.Column(sa.Numeric(precision=9, scale=5), nullable=True, doc="""
Cost of goods amount for one "unit" (not "case") of the product,
as decimal to 4 places.
""")
unit_price_reg = sa.Column(sa.Numeric(precision=8, scale=3), nullable=True, doc="""
Regular price for the item unit. Unless a sale is in effect,
:attr:`unit_price_quoted` will typically match this value.
""")
unit_price_sale = sa.Column(sa.Numeric(precision=8, scale=3), nullable=True, doc="""
Sale price for the item unit, if applicable. If set, then
:attr:`unit_price_quoted` will typically match this value. See
also :attr:`sale_ends`.
""")
sale_ends = sa.Column(sa.DateTime(timezone=True), nullable=True, doc="""
End date/time for the sale in effect, if any.
This is only relevant if :attr:`unit_price_sale` is set.
""")
unit_price_quoted = sa.Column(sa.Numeric(precision=8, scale=3), nullable=True, doc="""
Quoted price for the item unit. This is the "effective" unit
price, which is used to calculate :attr:`total_price`.
This price does *not* reflect the :attr:`discount_percent`. It
normally should match either :attr:`unit_price_reg` or
:attr:`unit_price_sale`.
""")
case_price_quoted = sa.Column(sa.Numeric(precision=8, scale=3), nullable=True, doc="""
Quoted price for a "case" of the item, if applicable.
This is mostly for display purposes; :attr:`unit_price_quoted` is
used for calculations.
""")
discount_percent = sa.Column(sa.Numeric(precision=5, scale=3), nullable=True, doc="""
Discount percent to apply when calculating :attr:`total_price`, if
applicable.
""")
total_price = sa.Column(sa.Numeric(precision=8, scale=3), nullable=True, doc="""
Full price (not including tax etc.) which the customer is quoted
for the order item.
This is calculated using values from:
* :attr:`unit_price_quoted`
* :attr:`order_qty`
* :attr:`order_uom`
* :attr:`case_size`
* :attr:`discount_percent`
""")
status_code = sa.Column(sa.Integer(), nullable=False, doc="""
Code indicating current status for the order item.
""")
paid_amount = sa.Column(sa.Numeric(precision=8, scale=3), nullable=False, default=0, doc="""
Amount which the customer has paid toward the :attr:`total_price`
of the item.
""")
payment_transaction_number = sa.Column(sa.String(length=20), nullable=True, doc="""
Transaction number in which payment for the order was taken, if
applicable/known.
""")
def __str__(self):
return str(self.pending_product or self.product_description or "")

View file

@ -0,0 +1,173 @@
# -*- 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/>.
#
################################################################################
"""
Data models for Products
"""
import datetime
import sqlalchemy as sa
from sqlalchemy import orm
from wuttjamaican.db import model
from sideshow.enum import PendingProductStatus
class PendingProduct(model.Base):
"""
A "pending" product record, used when entering an :term:`order
item` for new/unknown product.
"""
__tablename__ = 'sideshow_pending_product'
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.
""")
scancode = sa.Column(sa.String(length=14), nullable=True, doc="""
Scancode for the product, as string.
.. note::
This column allows 14 chars, so can store a full GPC with check
digit. However as of writing the actual format used here does
not matter to Sideshow logic; "anything" should work.
That may change eventually, depending on POS integration
scenarios that come up. Maybe a config option to declare
whether check digit should be included or not, etc.
""")
brand_name = sa.Column(sa.String(length=100), nullable=True, doc="""
Brand name for the product - up to 100 chars.
""")
description = sa.Column(sa.String(length=255), nullable=True, doc="""
Description for the product - up to 255 chars.
""")
size = sa.Column(sa.String(length=30), nullable=True, doc="""
Size of the product, as string - up to 30 chars.
""")
weighed = sa.Column(sa.Boolean(), nullable=True, doc="""
Flag indicating the product is sold by weight; default is null.
""")
department_id = sa.Column(sa.String(length=10), nullable=True, doc="""
ID of the department to which the product belongs, if known.
""")
department_name = sa.Column(sa.String(length=30), nullable=True, doc="""
Name of the department to which the product belongs, if known.
""")
special_order = sa.Column(sa.Boolean(), nullable=True, doc="""
Flag indicating the item is a "special order" - e.g. something not
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=9, scale=4), nullable=True, doc="""
Case pack count for the product, if known.
""")
unit_cost = sa.Column(sa.Numeric(precision=9, scale=5), nullable=True, doc="""
Cost of goods amount for one "unit" (not "case") of the product,
as decimal to 4 places.
""")
unit_price_reg = sa.Column(sa.Numeric(precision=8, scale=3), nullable=True, doc="""
Regular price for a "unit" of the product.
""")
notes = sa.Column(sa.Text(), nullable=True, doc="""
Arbitrary notes regarding the product, if applicable.
""")
status = sa.Column(sa.Enum(PendingProductStatus), nullable=False, doc="""
Status code for the product record.
""")
created = sa.Column(sa.DateTime(timezone=True), nullable=False,
default=datetime.datetime.now, doc="""
Timestamp when the product record was created.
""")
created_by_uuid = model.uuid_fk_column('user.uuid', nullable=False)
created_by = orm.relationship(
model.User,
cascade_backrefs=False,
doc="""
Reference to the
:class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who
created the product record.
""")
order_items = orm.relationship(
'OrderItem',
# TODO
# order_by='NewOrderBatchRow.id.desc()',
cascade_backrefs=False,
back_populates='pending_product',
doc="""
List of :class:`~sideshow.db.model.orders.OrderItem` records
associated with this product.
""")
new_order_batch_rows = orm.relationship(
'NewOrderBatchRow',
# TODO
# order_by='NewOrderBatchRow.id.desc()',
cascade_backrefs=False,
back_populates='pending_product',
doc="""
List of
:class:`~sideshow.db.model.batch.neworder.NewOrderBatchRow`
records associated with this product.
""")
@property
def full_description(self):
""" """
fields = [
self.brand_name or '',
self.description or '',
self.size or '']
fields = [f.strip() for f in fields if f.strip()]
return ' '.join(fields)
def __str__(self):
return self.full_description

146
src/sideshow/enum.py Normal file
View file

@ -0,0 +1,146 @@
# -*- 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/>.
#
################################################################################
"""
Enum Values
"""
from enum import Enum
from collections import OrderedDict
from wuttjamaican.enum import *
ORDER_UOM_CASE = 'CS'
"""
UOM code for ordering a "case" of product.
Sideshow will treat "case" orders somewhat differently as compared to
"unit" orders.
"""
ORDER_UOM_UNIT = 'EA'
"""
UOM code for ordering a "unit" of product.
This is the default "unit" UOM but in practice all others are treated
the same by Sideshow, whereas "case" orders are treated somewhat
differently.
"""
ORDER_UOM_KILOGRAM = 'KG'
"""
UOM code for ordering a "kilogram" of product.
This is treated same as "unit" by Sideshow. However it should
(probably?) only be used for items where
e.g. :attr:`~sideshow.db.model.orders.OrderItem.product_weighed` is
true.
"""
ORDER_UOM_POUND = 'LB'
"""
UOM code for ordering a "pound" of product.
This is treated same as "unit" by Sideshow. However it should
(probably?) only be used for items where
e.g. :attr:`~sideshow.db.model.orders.OrderItem.product_weighed` is
true.
"""
ORDER_UOM = OrderedDict([
(ORDER_UOM_CASE, "Cases"),
(ORDER_UOM_UNIT, "Units"),
(ORDER_UOM_KILOGRAM, "Kilograms"),
(ORDER_UOM_POUND, "Pounds"),
])
"""
Dict of possible code -> label options for ordering unit of measure.
These codes are referenced by:
* :attr:`sideshow.db.model.batch.neworder.NewOrderBatchRow.order_uom`
* :attr:`sideshow.db.model.orders.OrderItem.order_uom`
"""
class PendingCustomerStatus(Enum):
"""
Enum values for
:attr:`sideshow.db.model.customers.PendingCustomer.status`.
"""
PENDING = 'pending'
READY = 'ready'
RESOLVED = 'resolved'
class PendingProductStatus(Enum):
"""
Enum values for
:attr:`sideshow.db.model.products.PendingProduct.status`.
"""
PENDING = 'pending'
READY = 'ready'
RESOLVED = 'resolved'
########################################
# Order Item Status
########################################
ORDER_ITEM_STATUS_UNINITIATED = 1
ORDER_ITEM_STATUS_INITIATED = 10
ORDER_ITEM_STATUS_PAID_BEFORE = 50
# TODO: deprecate / remove this one
ORDER_ITEM_STATUS_PAID = ORDER_ITEM_STATUS_PAID_BEFORE
ORDER_ITEM_STATUS_READY = 100
ORDER_ITEM_STATUS_PLACED = 200
ORDER_ITEM_STATUS_RECEIVED = 300
ORDER_ITEM_STATUS_CONTACTED = 350
ORDER_ITEM_STATUS_CONTACT_FAILED = 375
ORDER_ITEM_STATUS_DELIVERED = 500
ORDER_ITEM_STATUS_PAID_AFTER = 550
ORDER_ITEM_STATUS_CANCELED = 900
ORDER_ITEM_STATUS_REFUND_PENDING = 910
ORDER_ITEM_STATUS_REFUNDED = 920
ORDER_ITEM_STATUS_RESTOCKED = 930
ORDER_ITEM_STATUS_EXPIRED = 940
ORDER_ITEM_STATUS_INACTIVE = 950
ORDER_ITEM_STATUS = OrderedDict([
(ORDER_ITEM_STATUS_UNINITIATED, "uninitiated"),
(ORDER_ITEM_STATUS_INITIATED, "initiated"),
(ORDER_ITEM_STATUS_PAID_BEFORE, "paid"),
(ORDER_ITEM_STATUS_READY, "ready"),
(ORDER_ITEM_STATUS_PLACED, "placed"),
(ORDER_ITEM_STATUS_RECEIVED, "received"),
(ORDER_ITEM_STATUS_CONTACTED, "contacted"),
(ORDER_ITEM_STATUS_CONTACT_FAILED, "contact failed"),
(ORDER_ITEM_STATUS_DELIVERED, "delivered"),
(ORDER_ITEM_STATUS_PAID_AFTER, "paid"),
(ORDER_ITEM_STATUS_CANCELED, "canceled"),
(ORDER_ITEM_STATUS_REFUND_PENDING, "refund pending"),
(ORDER_ITEM_STATUS_REFUNDED, "refunded"),
(ORDER_ITEM_STATUS_RESTOCKED, "restocked"),
(ORDER_ITEM_STATUS_EXPIRED, "expired"),
(ORDER_ITEM_STATUS_INACTIVE, "inactive"),
])

36
src/sideshow/testing.py Normal file
View file

@ -0,0 +1,36 @@
# -*- 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 - test utilities
"""
from wuttaweb import testing as base
class WebTestCase(base.WebTestCase):
def make_config(self, **kwargs):
config = super().make_config(**kwargs)
config.setdefault('wutta.model_spec', 'sideshow.db.model')
config.setdefault('wutta.enum_spec', 'sideshow.enum')
return config

View file

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

View file

View file

@ -0,0 +1,101 @@
# -*- 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/>.
#
################################################################################
"""
Form schema types
"""
from wuttaweb.forms.schema import ObjectRef
class OrderRef(ObjectRef):
"""
Custom schema type for an :class:`~sideshow.db.model.orders.Order`
reference field.
This is a subclass of
:class:`~wuttaweb:wuttaweb.forms.schema.ObjectRef`.
"""
@property
def model_class(self):
""" """
model = self.app.model
return model.Order
def sort_query(self, query):
""" """
return query.order_by(self.model_class.order_id)
def get_object_url(self, order):
""" """
return self.request.route_url('orders.view', uuid=order.uuid)
class PendingCustomerRef(ObjectRef):
"""
Custom schema type for a
:class:`~sideshow.db.model.customers.PendingCustomer` reference
field.
This is a subclass of
:class:`~wuttaweb:wuttaweb.forms.schema.ObjectRef`.
"""
@property
def model_class(self):
""" """
model = self.app.model
return model.PendingCustomer
def sort_query(self, query):
""" """
return query.order_by(self.model_class.full_name)
def get_object_url(self, customer):
""" """
return self.request.route_url('pending_customers.view', uuid=customer.uuid)
class PendingProductRef(ObjectRef):
"""
Custom schema type for a
:class:`~sideshow.db.model.products.PendingProduct` reference
field.
This is a subclass of
:class:`~wuttaweb:wuttaweb.forms.schema.ObjectRef`.
"""
@property
def model_class(self):
""" """
model = self.app.model
return model.PendingProduct
def sort_query(self, query):
""" """
return query.order_by(self.model_class.scancode)
def get_object_url(self, product):
""" """
return self.request.route_url('pending_products.view', uuid=product.uuid)

View file

@ -33,15 +33,79 @@ class SideshowMenuHandler(base.MenuHandler):
"""
def make_menus(self, request, **kwargs):
""" """
return [
self.make_orders_menu(request),
self.make_pending_menu(request),
self.make_batch_menu(request),
self.make_admin_menu(request),
]
# TODO: override this if you need custom menus...
def make_orders_menu(self, request, **kwargs):
"""
Generate a typical Orders menu.
"""
return {
'title': "Orders",
'type': 'menu',
'items': [
{
'title': "Create New Order",
'route': 'orders.create',
'perm': 'orders.create',
},
{'type': 'sep'},
{
'title': "All Orders",
'route': 'orders',
'perm': 'orders.list',
},
{
'title': "All Order Items",
'route': 'order_items',
'perm': 'order_items.list',
},
],
}
# menus = [
# self.make_products_menu(request),
# self.make_admin_menu(request),
# ]
def make_pending_menu(self, request, **kwargs):
"""
Generate a typical Pending menu.
"""
return {
'title': "Pending",
'type': 'menu',
'items': [
{
'title': "Customers",
'route': 'pending_customers',
'perm': 'pending_customers.list',
},
{
'title': "Products",
'route': 'pending_products',
'perm': 'pending_products.list',
},
],
}
# ...but for now this uses default menus
menus = super().make_menus(request, **kwargs)
def make_batch_menu(self, request, **kwargs):
"""
Generate a typical Batch menu.
"""
return {
'title': "Batches",
'type': 'menu',
'items': [
{
'title': "New Orders",
'route': 'neworder_batches',
'perm': 'neworder_batches.list',
},
],
}
return menus
def make_admin_menu(self, request, **kwargs):
""" """
kwargs['include_people'] = True
return super().make_admin_menu(request, **kwargs)

File diff suppressed because it is too large Load diff

View file

@ -30,5 +30,10 @@ def includeme(config):
# core views for wuttaweb
config.include('wuttaweb.views.essential')
# TODO: include your own views here
#config.include('sideshow.web.views.widgets')
# sideshow views
config.include('sideshow.web.views.customers')
config.include('sideshow.web.views.products')
config.include('sideshow.web.views.orders')
# batch views
config.include('sideshow.web.views.batch.neworder')

View file

View file

@ -0,0 +1,189 @@
# -*- 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/>.
#
################################################################################
"""
Views for New Order Batch
"""
from wuttaweb.views.batch import BatchMasterView
from wuttaweb.forms.schema import WuttaMoney
from sideshow.db.model import NewOrderBatch
from sideshow.batch.neworder import NewOrderBatchHandler
from sideshow.web.forms.schema import PendingCustomerRef
class NewOrderBatchView(BatchMasterView):
"""
Master view for :class:`~sideshow.db.model.batch.neworder.NewOrderBatch`.
Route prefix is ``neworder_batches``.
Notable URLs provided by this class:
* ``/batch/neworder/``
* ``/batch/neworder/XXX``
* ``/batch/neworder/XXX/delete``
The purpose of this class is to expose "raw" batch data, e.g. for
troubleshooting purposes by the admin. Ideally it is not very
useful.
Note that the "create" and "edit" views are not exposed here,
since those should be handled by
:class:`~sideshow.web.views.orders.OrderView` instead.
"""
model_class = NewOrderBatch
model_title = "New Order Batch"
model_title_plural = "New Order Batches"
route_prefix = 'neworder_batches'
url_prefix = '/batch/neworder'
creatable = False
editable = False
labels = {
'store_id': "Store ID",
'customer_id': "Customer ID",
}
grid_columns = [
'id',
'store_id',
'customer_id',
'customer_name',
'phone_number',
'email_address',
'total_price',
'row_count',
'created',
'created_by',
'executed',
]
filter_defaults = {
'executed': {'active': True, 'verb': 'is_null'},
}
form_fields = [
'id',
'store_id',
'customer_id',
'pending_customer',
'customer_name',
'phone_number',
'email_address',
'total_price',
'row_count',
'status_code',
'created',
'created_by',
'executed',
'executed_by',
]
row_labels = {
'product_scancode': "Scancode",
'product_brand': "Brand",
'product_description': "Description",
'product_size': "Size",
'order_uom': "Order UOM",
}
row_grid_columns = [
'sequence',
'product_scancode',
'product_brand',
'product_description',
'product_size',
'special_order',
'order_qty',
'order_uom',
'case_size',
'total_price',
'status_code',
]
def get_batch_handler(self):
""" """
# TODO: call self.app.get_batch_handler()
return NewOrderBatchHandler(self.config)
def configure_grid(self, g):
""" """
super().configure_grid(g)
# total_price
g.set_renderer('total_price', 'currency')
def configure_form(self, f):
""" """
super().configure_form(f)
# pending_customer
f.set_node('pending_customer', PendingCustomerRef(self.request))
# total_price
f.set_node('total_price', WuttaMoney(self.request))
def configure_row_grid(self, g):
""" """
super().configure_row_grid(g)
enum = self.app.enum
# TODO
# order_uom
#g.set_renderer('order_uom', self.grid_render_enum, enum=enum.ORDER_UOM)
# total_price
g.set_renderer('total_price', 'currency')
def get_xref_buttons(self, batch):
"""
Adds "View this Order" button, if batch has been executed and
a corresponding :class:`~sideshow.db.model.orders.Order` can
be located.
"""
buttons = super().get_xref_buttons(batch)
model = self.app.model
session = self.Session()
if batch.executed and self.request.has_perm('orders.view'):
order = session.query(model.Order)\
.filter(model.Order.order_id == batch.id)\
.first()
if order:
url = self.request.route_url('orders.view', uuid=order.uuid)
buttons.append(
self.make_button("View the Order", primary=True, icon_left='eye', url=url))
return buttons
def defaults(config, **kwargs):
base = globals()
NewOrderBatchView = kwargs.get('NewOrderBatchView', base['NewOrderBatchView'])
NewOrderBatchView.defaults(config)
def includeme(config):
defaults(config)

View file

@ -0,0 +1,246 @@
# -*- 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/>.
#
################################################################################
"""
Views for Customers
"""
from wuttaweb.views import MasterView
from wuttaweb.forms.schema import UserRef, WuttaEnum
from sideshow.db.model import PendingCustomer
class PendingCustomerView(MasterView):
"""
Master view for
:class:`~sideshow.db.model.customers.PendingCustomer`; route
prefix is ``pending_customers``.
Notable URLs provided by this class:
* ``/pending/customers/``
* ``/pending/customers/new``
* ``/pending/customers/XXX``
* ``/pending/customers/XXX/edit``
* ``/pending/customers/XXX/delete``
"""
model_class = PendingCustomer
model_title = "Pending Customer"
route_prefix = 'pending_customers'
url_prefix = '/pending/customers'
labels = {
'customer_id': "Customer ID",
}
grid_columns = [
'full_name',
'first_name',
'last_name',
'phone_number',
'email_address',
'customer_id',
'status',
'created',
'created_by',
]
sort_defaults = 'full_name'
form_fields = [
'customer_id',
'full_name',
'first_name',
'middle_name',
'last_name',
'phone_number',
'phone_type',
'email_address',
'email_type',
'status',
'created',
'created_by',
'orders',
'new_order_batches',
]
def configure_grid(self, g):
""" """
super().configure_grid(g)
enum = self.app.enum
# status
g.set_renderer('status', self.grid_render_enum, enum=enum.PendingCustomerStatus)
# links
g.set_link('full_name')
g.set_link('first_name')
g.set_link('last_name')
g.set_link('phone_number')
g.set_link('email_address')
def configure_form(self, f):
""" """
super().configure_form(f)
enum = self.app.enum
customer = f.model_instance
# customer_id
if self.creating:
f.remove('customer_id')
else:
f.set_readonly('customer_id')
# status
if self.creating:
f.remove('status')
else:
f.set_node('status', WuttaEnum(self.request, enum.PendingCustomerStatus))
f.set_readonly('status')
# created
if self.creating:
f.remove('created')
else:
f.set_readonly('created')
# created_by
if self.creating:
f.remove('created_by')
else:
f.set_node('created_by', UserRef(self.request))
f.set_readonly('created_by')
# orders
if self.creating or self.editing:
f.remove('orders')
else:
f.set_grid('orders', self.make_orders_grid(customer))
# new_order_batches
if self.creating or self.editing:
f.remove('new_order_batches')
else:
f.set_grid('new_order_batches', self.make_new_order_batches_grid(customer))
def make_orders_grid(self, customer):
"""
Make and return the grid for the Orders field.
"""
model = self.app.model
route_prefix = self.get_route_prefix()
grid = self.make_grid(key=f'{route_prefix}.view.orders',
model_class=model.Order,
data=customer.orders,
columns=[
'order_id',
'total_price',
'created',
'created_by',
],
labels={
'order_id': "Order ID",
})
grid.set_renderer('total_price', grid.render_currency)
if self.request.has_perm('orders.view'):
url = lambda order, i: self.request.route_url('orders.view', uuid=order.uuid)
grid.add_action('view', icon='eye', url=url)
grid.set_link('order_id')
return grid
def make_new_order_batches_grid(self, customer):
"""
Make and return the grid for the New Order Batches field.
"""
model = self.app.model
route_prefix = self.get_route_prefix()
grid = self.make_grid(key=f'{route_prefix}.view.new_order_batches',
model_class=model.NewOrderBatch,
data=customer.new_order_batches,
columns=[
'id',
'total_price',
'created',
'created_by',
'executed',
],
labels={
'id': "Batch ID",
},
renderers={
'id': 'batch_id',
'total_price': 'currency',
})
if self.request.has_perm('neworder_batches.view'):
url = lambda batch, i: self.request.route_url('neworder_batches.view', uuid=batch.uuid)
grid.add_action('view', icon='eye', url=url)
grid.set_link('id')
return grid
def objectify(self, form):
""" """
enum = self.app.enum
customer = super().objectify(form)
if self.creating:
customer.status = enum.PendingCustomerStatus.PENDING
customer.created_by = self.request.user
return customer
def delete_instance(self, customer):
""" """
model_title = self.get_model_title()
# avoid deleting if still referenced by order(s)
for order in customer.orders:
self.request.session.flash(f"Cannot delete {model_title} still attached "
"to Order(s)", 'warning')
raise self.redirect(self.get_action_url('view', customer))
# avoid deleting if still referenced by new order batch(es)
for batch in customer.new_order_batches:
if not batch.executed:
self.request.session.flash(f"Cannot delete {model_title} still attached "
"to New Order Batch(es)", 'warning')
raise self.redirect(self.get_action_url('view', customer))
# go ahead and delete per usual
super().delete_instance(customer)
def defaults(config, **kwargs):
base = globals()
PendingCustomerView = kwargs.get('PendingCustomerView', base['PendingCustomerView'])
PendingCustomerView.defaults(config)
def includeme(config):
defaults(config)

View file

@ -0,0 +1,866 @@
# -*- 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/>.
#
################################################################################
"""
Views for Orders
"""
import decimal
import logging
import colander
from sqlalchemy import orm
from wuttaweb.views import MasterView
from wuttaweb.forms.schema import UserRef, WuttaMoney, WuttaQuantity, WuttaEnum
from sideshow.db.model import Order, OrderItem
from sideshow.batch.neworder import NewOrderBatchHandler
from sideshow.web.forms.schema import OrderRef, PendingCustomerRef, PendingProductRef
log = logging.getLogger(__name__)
class OrderView(MasterView):
"""
Master view for :class:`~sideshow.db.model.orders.Order`; route
prefix is ``orders``.
Notable URLs provided by this class:
* ``/orders/``
* ``/orders/new``
* ``/orders/XXX``
* ``/orders/XXX/delete``
Note that the "edit" view is not exposed here; user must perform
various other workflow actions to modify the order.
"""
model_class = Order
editable = False
labels = {
'order_id': "Order ID",
'store_id': "Store ID",
'customer_id': "Customer ID",
}
grid_columns = [
'order_id',
'store_id',
'customer_id',
'customer_name',
'total_price',
'created',
'created_by',
]
sort_defaults = ('order_id', 'desc')
form_fields = [
'order_id',
'store_id',
'customer_id',
'pending_customer',
'customer_name',
'phone_number',
'email_address',
'total_price',
'created',
'created_by',
]
has_rows = True
row_model_class = OrderItem
rows_title = "Order Items"
rows_sort_defaults = 'sequence'
rows_viewable = True
row_labels = {
'product_scancode': "Scancode",
'product_brand': "Brand",
'product_description': "Description",
'product_size': "Size",
'department_name': "Department",
'order_uom': "Order UOM",
'status_code': "Status",
}
row_grid_columns = [
'sequence',
'product_scancode',
'product_brand',
'product_description',
'product_size',
'department_name',
'special_order',
'order_qty',
'order_uom',
'total_price',
'status_code',
]
PENDING_PRODUCT_ENTRY_FIELDS = [
'scancode',
'department_id',
'department_name',
'brand_name',
'description',
'size',
'vendor_name',
'vendor_item_code',
'unit_cost',
'case_size',
'unit_price_reg',
]
def configure_grid(self, g):
""" """
super().configure_grid(g)
# order_id
g.set_link('order_id')
# customer_id
g.set_link('customer_id')
# customer_name
g.set_link('customer_name')
# total_price
g.set_renderer('total_price', g.render_currency)
def create(self):
"""
Instead of the typical "create" view, this displays a "wizard"
of sorts.
Under the hood a
:class:`~sideshow.db.model.batch.neworder.NewOrderBatch` is
automatically created for the user when they first visit this
page. They can select a customer, add items etc.
When user is finished assembling the order (i.e. populating
the batch), they submit it. This of course executes the
batch, which in turn creates a true
:class:`~sideshow.db.model.orders.Order`, and user is
redirected to the "view order" page.
"""
enum = self.app.enum
self.creating = True
self.batch_handler = NewOrderBatchHandler(self.config)
batch = self.get_current_batch()
context = self.get_context_customer(batch)
if self.request.method == 'POST':
# first we check for traditional form post
action = self.request.POST.get('action')
post_actions = [
'start_over',
'cancel_order',
]
if action in post_actions:
return getattr(self, action)(batch)
# okay then, we'll assume newer JSON-style post params
data = dict(self.request.json_body)
action = data.pop('action')
json_actions = [
# 'assign_contact',
# 'unassign_contact',
# 'update_phone_number',
# 'update_email_address',
'set_pending_customer',
# 'get_customer_info',
# # 'set_customer_data',
# 'get_product_info',
# 'get_past_items',
'add_item',
'update_item',
'delete_item',
'submit_new_order',
]
if action in json_actions:
result = getattr(self, action)(batch, data)
return self.json_response(result)
return self.json_response({'error': "unknown form action"})
context.update({
'batch': batch,
'normalized_batch': self.normalize_batch(batch),
'order_items': [self.normalize_row(row)
for row in batch.rows],
'allow_unknown_product': True, # TODO
'default_uom_choices': self.get_default_uom_choices(),
'default_uom': None, # TODO?
'pending_product_required_fields': self.get_pending_product_required_fields(),
})
return self.render_to_response('create', context)
def get_current_batch(self):
"""
Returns the current batch for the current user.
This looks for a new order batch which was created by the
user, but not yet executed. If none is found, a new batch is
created.
:returns:
:class:`~sideshow.db.model.batch.neworder.NewOrderBatch`
instance
"""
model = self.app.model
session = self.Session()
user = self.request.user
if not user:
raise self.forbidden()
try:
# there should be at most *one* new batch per user
batch = session.query(model.NewOrderBatch)\
.filter(model.NewOrderBatch.created_by == user)\
.filter(model.NewOrderBatch.executed == None)\
.one()
except orm.exc.NoResultFound:
# no batch yet for this user, so make one
batch = self.batch_handler.make_batch(session, created_by=user)
session.add(batch)
session.flush()
return batch
def get_pending_product_required_fields(self):
""" """
required = []
for field in self.PENDING_PRODUCT_ENTRY_FIELDS:
require = self.config.get_bool(
f'sideshow.orders.unknown_product.fields.{field}.required')
if require is None and field == 'description':
require = True
if require:
required.append(field)
return required
def start_over(self, batch):
"""
This will delete the user's current batch, then redirect user
back to "Create Order" page, which in turn will auto-create a
new batch for them.
This is a "batch action" method which may be called from
:meth:`create()`.
"""
# drop current batch
self.batch_handler.do_delete(batch, self.request.user)
self.Session.flush()
# send back to "create order" which makes new batch
route_prefix = self.get_route_prefix()
url = self.request.route_url(f'{route_prefix}.create')
return self.redirect(url)
def cancel_order(self, batch):
"""
This will delete the user's current batch, then redirect user
back to "List Orders" page.
This is a "batch action" method which may be called from
:meth:`create()`.
"""
self.batch_handler.do_delete(batch, self.request.user)
self.Session.flush()
# set flash msg just to be more obvious
self.request.session.flash("New order has been deleted.")
# send user back to orders list, w/ no new batch generated
url = self.get_index_url()
return self.redirect(url)
def get_context_customer(self, batch):
""" """
context = {
'customer_id': batch.customer_id,
'customer_name': batch.customer_name,
'phone_number': batch.phone_number,
'email_address': batch.email_address,
'new_customer_name': None,
'new_customer_first_name': None,
'new_customer_last_name': None,
'new_customer_phone': None,
'new_customer_email': None,
}
pending = batch.pending_customer
if pending:
context.update({
'new_customer_first_name': pending.first_name,
'new_customer_last_name': pending.last_name,
'new_customer_name': pending.full_name,
'new_customer_phone': pending.phone_number,
'new_customer_email': pending.email_address,
})
# figure out if customer is "known" from user's perspective.
# if we have an ID then it's definitely known, otherwise if we
# have a pending customer then it's definitely *not* known,
# but if no pending customer yet then we can still "assume" it
# is known, by default, until user specifies otherwise.
if batch.customer_id:
context['customer_is_known'] = True
else:
context['customer_is_known'] = not pending
return context
def set_pending_customer(self, batch, data):
"""
This will set/update the batch pending customer info.
This calls
:meth:`~sideshow.batch.neworder.NewOrderBatchHandler.set_pending_customer()`
for the heavy lifting.
This is a "batch action" method which may be called from
:meth:`create()`.
"""
data['created_by'] = self.request.user
try:
self.batch_handler.set_pending_customer(batch, data)
except Exception as error:
return {'error': self.app.render_error(error)}
self.Session.flush()
context = self.get_context_customer(batch)
return context
def add_item(self, batch, data):
"""
This adds a row to the user's current new order batch.
This is a "batch action" method which may be called from
:meth:`create()`.
"""
order_qty = decimal.Decimal(data.get('order_qty') or '0')
order_uom = data['order_uom']
if data.get('product_is_known'):
raise NotImplementedError
else: # unknown product; add pending
pending = data['pending_product']
for field in ('unit_cost', 'unit_price_reg', 'case_size'):
if field in pending:
try:
pending[field] = decimal.Decimal(pending[field])
except decimal.InvalidOperation:
return {'error': f"Invalid entry for field: {field}"}
pending['created_by'] = self.request.user
row = self.batch_handler.add_pending_product(batch, pending,
order_qty, order_uom)
return {'batch': self.normalize_batch(batch),
'row': self.normalize_row(row)}
def update_item(self, batch, data):
"""
This updates a row in the user's current new order batch.
This is a "batch action" method which may be called from
:meth:`create()`.
"""
model = self.app.model
enum = self.app.enum
session = self.Session()
uuid = data.get('uuid')
if not uuid:
return {'error': "Must specify a row UUID"}
row = session.get(model.NewOrderBatchRow, uuid)
if not row:
return {'error': "Row not found"}
if row.batch is not batch:
return {'error': "Row is for wrong batch"}
order_qty = decimal.Decimal(data.get('order_qty') or '0')
order_uom = data['order_uom']
if data.get('product_is_known'):
raise NotImplementedError
else: # pending product
# set these first, since row will be refreshed below
row.order_qty = order_qty
row.order_uom = order_uom
# nb. this will refresh the row
self.batch_handler.set_pending_product(row, data['pending_product'])
return {'batch': self.normalize_batch(batch),
'row': self.normalize_row(row)}
def delete_item(self, batch, data):
"""
This deletes a row from the user's current new order batch.
This is a "batch action" method which may be called from
:meth:`create()`.
"""
model = self.app.model
session = self.app.get_session(batch)
uuid = data.get('uuid')
if not uuid:
return {'error': "Must specify a row UUID"}
row = session.get(model.NewOrderBatchRow, uuid)
if not row:
return {'error': "Row not found"}
if row.batch is not batch:
return {'error': "Row is for wrong batch"}
self.batch_handler.do_remove_row(row)
session.flush()
return {'batch': self.normalize_batch(batch)}
def submit_new_order(self, batch, data):
"""
This submits the user's current new order batch, hence
executing the batch and creating the true order.
This is a "batch action" method which may be called from
:meth:`create()`.
"""
user = self.request.user
reason = self.batch_handler.why_not_execute(batch, user=user)
if reason:
return {'error': reason}
try:
order = self.batch_handler.do_execute(batch, user)
except Exception as error:
log.warning("failed to execute new order batch: %s", batch,
exc_info=True)
return {'error': self.app.render_error(error)}
return {
'next_url': self.get_action_url('view', order),
}
def normalize_batch(self, batch):
""" """
return {
'uuid': batch.uuid.hex,
'total_price': str(batch.total_price or 0),
'total_price_display': self.app.render_currency(batch.total_price),
'status_code': batch.status_code,
'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):
""" """
enum = self.app.enum
data = {
'uuid': row.uuid.hex,
'sequence': row.sequence,
'product_scancode': row.product_scancode,
'product_brand': row.product_brand,
'product_description': row.product_description,
'product_size': row.product_size,
'product_weighed': row.product_weighed,
'department_display': row.department_name,
'special_order': row.special_order,
'case_size': self.app.render_quantity(row.case_size),
'order_qty': self.app.render_quantity(row.order_qty),
'order_uom': row.order_uom,
'order_uom_choices': self.get_default_uom_choices(),
'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,
'case_price_quoted_display': self.app.render_currency(row.case_price_quoted),
'total_price': float(row.total_price) if row.total_price is not None else None,
'total_price_display': self.app.render_currency(row.total_price),
'status_code': row.status_code,
'status_text': row.status_text,
}
if row.unit_price_reg:
data['unit_price_reg'] = float(row.unit_price_reg)
data['unit_price_reg_display'] = self.app.render_currency(row.unit_price_reg)
if row.unit_price_sale:
data['unit_price_sale'] = float(row.unit_price_sale)
data['unit_price_sale_display'] = self.app.render_currency(row.unit_price_sale)
if row.sale_ends:
sale_ends = row.sale_ends
data['sale_ends'] = str(row.sale_ends)
data['sale_ends_display'] = self.app.render_date(row.sale_ends)
# if row.unit_price_sale and row.unit_price_quoted == row.unit_price_sale:
# data['pricing_reflects_sale'] = True
# TODO
if row.pending_product:
data['product_full_description'] = row.pending_product.full_description
# else:
# data['product_full_description'] = row.product_description
# if row.pending_product:
# data['vendor_display'] = row.pending_product.vendor_name
if row.pending_product:
pending = row.pending_product
# data['vendor_display'] = pending.vendor_name
data['pending_product'] = {
'uuid': pending.uuid.hex,
'scancode': pending.scancode,
'brand_name': pending.brand_name,
'description': pending.description,
'size': pending.size,
'department_id': pending.department_id,
'department_name': pending.department_name,
'unit_price_reg': float(pending.unit_price_reg) if pending.unit_price_reg is not None else None,
'vendor_name': pending.vendor_name,
'vendor_item_code': pending.vendor_item_code,
'unit_cost': float(pending.unit_cost) if pending.unit_cost is not None else None,
'case_size': float(pending.case_size) if pending.case_size is not None else None,
'notes': pending.notes,
'special_order': pending.special_order,
}
# TODO: remove this
data['product_key'] = row.product_scancode
# display text for order qty/uom
if row.order_uom == enum.ORDER_UOM_CASE:
if row.case_size is None:
case_qty = unit_qty = '??'
else:
case_qty = data['case_size']
unit_qty = self.app.render_quantity(row.order_qty * row.case_size)
CS = enum.ORDER_UOM[enum.ORDER_UOM_CASE]
EA = enum.ORDER_UOM[enum.ORDER_UOM_UNIT]
data['order_qty_display'] = (f"{data['order_qty']} {CS} "
f"(&times; {case_qty} = {unit_qty} {EA})")
else:
unit_qty = self.app.render_quantity(row.order_qty)
EA = enum.ORDER_UOM[enum.ORDER_UOM_UNIT]
data['order_qty_display'] = f"{unit_qty} {EA}"
return data
def get_instance_title(self, order):
""" """
return f"#{order.order_id} for {order.customer_name}"
def configure_form(self, f):
""" """
super().configure_form(f)
# pending_customer
f.set_node('pending_customer', PendingCustomerRef(self.request))
# total_price
f.set_node('total_price', WuttaMoney(self.request))
# created_by
f.set_node('created_by', UserRef(self.request))
f.set_readonly('created_by')
def get_xref_buttons(self, order):
""" """
buttons = super().get_xref_buttons(order)
model = self.app.model
session = self.Session()
if self.request.has_perm('neworder_batches.view'):
batch = session.query(model.NewOrderBatch)\
.filter(model.NewOrderBatch.id == order.order_id)\
.first()
if batch:
url = self.request.route_url('neworder_batches.view', uuid=batch.uuid)
buttons.append(
self.make_button("View the Batch", primary=True, icon_left='eye', url=url))
return buttons
def get_row_grid_data(self, order):
""" """
model = self.app.model
session = self.Session()
return session.query(model.OrderItem)\
.filter(model.OrderItem.order == order)
def configure_row_grid(self, g):
""" """
super().configure_row_grid(g)
enum = self.app.enum
# sequence
g.set_label('sequence', "Seq.", column_only=True)
g.set_link('sequence')
# product_scancode
g.set_link('product_scancode')
# product_brand
g.set_link('product_brand')
# product_description
g.set_link('product_description')
# product_size
g.set_link('product_size')
# TODO
# order_uom
#g.set_renderer('order_uom', self.grid_render_enum, enum=enum.OrderUOM)
# total_price
g.set_renderer('total_price', g.render_currency)
# status_code
g.set_renderer('status_code', self.render_status_code)
def render_status_code(self, item, key, value):
""" """
enum = self.app.enum
return enum.ORDER_ITEM_STATUS[value]
def get_row_action_url_view(self, item, i):
""" """
return self.request.route_url('order_items.view', uuid=item.uuid)
class OrderItemView(MasterView):
"""
Master view for :class:`~sideshow.db.model.orders.OrderItem`;
route prefix is ``order_items``.
Notable URLs provided by this class:
* ``/order-items/``
* ``/order-items/XXX``
Note that this does not expose create, edit or delete. The user
must perform various other workflow actions to modify the item.
"""
model_class = OrderItem
model_title = "Order Item"
route_prefix = 'order_items'
url_prefix = '/order-items'
creatable = False
editable = False
deletable = False
labels = {
'order_id': "Order ID",
'product_id': "Product ID",
'product_scancode': "Scancode",
'product_brand': "Brand",
'product_description': "Description",
'product_size': "Size",
'department_name': "Department",
'order_uom': "Order UOM",
'status_code': "Status",
}
grid_columns = [
'order_id',
'customer_name',
# 'sequence',
'product_scancode',
'product_brand',
'product_description',
'product_size',
'department_name',
'special_order',
'order_qty',
'order_uom',
'total_price',
'status_code',
]
sort_defaults = ('order_id', 'desc')
form_fields = [
'order',
# 'customer_name',
'sequence',
'product_id',
'pending_product',
'product_scancode',
'product_brand',
'product_description',
'product_size',
'product_weighed',
'department_id',
'department_name',
'special_order',
'order_qty',
'order_uom',
'case_size',
'unit_cost',
'unit_price_reg',
'unit_price_sale',
'sale_ends',
'unit_price_quoted',
'case_price_quoted',
'discount_percent',
'total_price',
'status_code',
'paid_amount',
'payment_transaction_number',
]
def get_query(self, session=None):
""" """
query = super().get_query(session=session)
model = self.app.model
return query.join(model.Order)
def configure_grid(self, g):
""" """
super().configure_grid(g)
model = self.app.model
# enum = self.app.enum
# order_id
g.set_sorter('order_id', model.Order.order_id)
g.set_renderer('order_id', self.render_order_id)
g.set_link('order_id')
# customer_name
g.set_label('customer_name', "Customer", column_only=True)
# # sequence
# g.set_label('sequence', "Seq.", column_only=True)
# product_scancode
g.set_link('product_scancode')
# product_brand
g.set_link('product_brand')
# product_description
g.set_link('product_description')
# product_size
g.set_link('product_size')
# order_uom
# TODO
#g.set_renderer('order_uom', self.grid_render_enum, enum=enum.OrderUOM)
# total_price
g.set_renderer('total_price', g.render_currency)
# status_code
g.set_renderer('status_code', self.render_status_code)
def render_order_id(self, item, key, value):
""" """
return item.order.order_id
def render_status_code(self, item, key, value):
""" """
enum = self.app.enum
return enum.ORDER_ITEM_STATUS[value]
def configure_form(self, f):
""" """
super().configure_form(f)
enum = self.app.enum
# order
f.set_node('order', OrderRef(self.request))
# pending_product
f.set_node('pending_product', PendingProductRef(self.request))
# order_qty
f.set_node('order_qty', WuttaQuantity(self.request))
# order_uom
# TODO
#f.set_node('order_uom', WuttaEnum(self.request, enum.OrderUOM))
# case_size
f.set_node('case_size', WuttaQuantity(self.request))
# unit_price_quoted
f.set_node('unit_price_quoted', WuttaMoney(self.request))
# case_price_quoted
f.set_node('case_price_quoted', WuttaMoney(self.request))
# total_price
f.set_node('total_price', WuttaMoney(self.request))
# paid_amount
f.set_node('paid_amount', WuttaMoney(self.request))
def get_xref_buttons(self, item):
""" """
buttons = super().get_xref_buttons(item)
model = self.app.model
if self.request.has_perm('orders.view'):
url = self.request.route_url('orders.view', uuid=item.order_uuid)
buttons.append(
self.make_button("View the Order", primary=True, icon_left='eye', url=url))
return buttons
def defaults(config, **kwargs):
base = globals()
OrderView = kwargs.get('OrderView', base['OrderView'])
OrderView.defaults(config)
OrderItemView = kwargs.get('OrderItemView', base['OrderItemView'])
OrderItemView.defaults(config)
def includeme(config):
defaults(config)

View file

@ -0,0 +1,257 @@
# -*- 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/>.
#
################################################################################
"""
Views for Products
"""
from wuttaweb.views import MasterView
from wuttaweb.forms.schema import UserRef, WuttaEnum, WuttaMoney
from sideshow.db.model import PendingProduct
class PendingProductView(MasterView):
"""
Master view for
:class:`~sideshow.db.model.products.PendingProduct`; route
prefix is ``pending_products``.
Notable URLs provided by this class:
* ``/pending/products/``
* ``/pending/products/new``
* ``/pending/products/XXX``
* ``/pending/products/XXX/edit``
* ``/pending/products/XXX/delete``
"""
model_class = PendingProduct
model_title = "Pending Product"
route_prefix = 'pending_products'
url_prefix = '/pending/products'
labels = {
'product_id': "Product ID",
}
grid_columns = [
'scancode',
'department_name',
'brand_name',
'description',
'size',
'unit_cost',
'case_size',
'unit_price_reg',
'special_order',
'status',
'created',
'created_by',
]
sort_defaults = 'scancode'
form_fields = [
'product_id',
'scancode',
'department_id',
'department_name',
'brand_name',
'description',
'size',
'vendor_name',
'vendor_item_code',
'unit_cost',
'case_size',
'unit_price_reg',
'special_order',
'notes',
'status',
'created',
'created_by',
'orders',
'new_order_batches',
]
def configure_grid(self, g):
""" """
super().configure_grid(g)
enum = self.app.enum
# unit_cost
g.set_renderer('unit_cost', 'currency', scale=4)
# unit_price_reg
g.set_label('unit_price_reg', "Reg. Price", column_only=True)
g.set_renderer('unit_price_reg', 'currency')
# status
g.set_renderer('status', self.grid_render_enum, enum=enum.PendingProductStatus)
# links
g.set_link('scancode')
g.set_link('brand_name')
g.set_link('description')
g.set_link('size')
def configure_form(self, f):
""" """
super().configure_form(f)
enum = self.app.enum
product = f.model_instance
# product_id
if self.creating:
f.remove('product_id')
else:
f.set_readonly('product_id')
# unit_price_reg
f.set_node('unit_price_reg', WuttaMoney(self.request))
# 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')
else:
f.set_readonly('created')
# created_by
if self.creating:
f.remove('created_by')
else:
f.set_node('created_by', UserRef(self.request))
f.set_readonly('created_by')
# orders
if self.creating or self.editing:
f.remove('orders')
else:
f.set_grid('orders', self.make_orders_grid(product))
# new_order_batches
if self.creating or self.editing:
f.remove('new_order_batches')
else:
f.set_grid('new_order_batches', self.make_new_order_batches_grid(product))
def make_orders_grid(self, product):
"""
Make and return the grid for the Orders field.
"""
model = self.app.model
route_prefix = self.get_route_prefix()
orders = set([item.order for item in product.order_items])
orders = sorted(orders, key=lambda order: order.order_id)
grid = self.make_grid(key=f'{route_prefix}.view.orders',
model_class=model.Order,
data=orders,
columns=[
'order_id',
'total_price',
'created',
'created_by',
],
labels={
'order_id': "Order ID",
},
renderers={
'total_price': 'currency',
})
if self.request.has_perm('orders.view'):
url = lambda order, i: self.request.route_url('orders.view', uuid=order.uuid)
grid.add_action('view', icon='eye', url=url)
grid.set_link('order_id')
return grid
def make_new_order_batches_grid(self, product):
"""
Make and return the grid for the New Order Batches field.
"""
model = self.app.model
route_prefix = self.get_route_prefix()
batches = set([row.batch for row in product.new_order_batch_rows])
batches = sorted(batches, key=lambda batch: batch.id)
grid = self.make_grid(key=f'{route_prefix}.view.new_order_batches',
model_class=model.NewOrderBatch,
data=batches,
columns=[
'id',
'total_price',
'created',
'created_by',
'executed',
],
labels={
'id': "Batch ID",
'status_code': "Status",
},
renderers={
'id': 'batch_id',
})
if self.request.has_perm('neworder_batches.view'):
url = lambda batch, i: self.request.route_url('neworder_batches.view', uuid=batch.uuid)
grid.add_action('view', icon='eye', url=url)
grid.set_link('id')
return grid
def delete_instance(self, product):
""" """
# avoid deleting if still referenced by new order batch(es)
for row in product.new_order_batch_rows:
if not row.batch.executed:
model_title = self.get_model_title()
self.request.session.flash(f"Cannot delete {model_title} still attached "
"to New Order Batch(es)", 'warning')
raise self.redirect(self.get_action_url('view', product))
# go ahead and delete per usual
super().delete_instance(product)
def defaults(config, **kwargs):
base = globals()
PendingProductView = kwargs.get('PendingProductView', base['PendingProductView'])
PendingProductView.defaults(config)
def includeme(config):
defaults(config)

0
tests/__init__.py Normal file
View file

0
tests/batch/__init__.py Normal file
View file

View file

@ -0,0 +1,539 @@
# -*- coding: utf-8; -*-
import decimal
from wuttjamaican.testing import DataTestCase
from sideshow.batch import neworder as mod
class TestNewOrderBatchHandler(DataTestCase):
def make_config(self, **kwargs):
config = super().make_config(**kwargs)
config.setdefault('wutta.model_spec', 'sideshow.db.model')
config.setdefault('wutta.enum_spec', 'sideshow.enum')
return config
def make_handler(self):
return mod.NewOrderBatchHandler(self.config)
def test_set_pending_customer(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, customer_id=42)
self.assertEqual(batch.customer_id, 42)
self.assertIsNone(batch.pending_customer)
self.assertIsNone(batch.customer_name)
self.assertIsNone(batch.phone_number)
self.assertIsNone(batch.email_address)
# auto full_name
handler.set_pending_customer(batch, {
'first_name': "Fred",
'last_name': "Flintstone",
'phone_number': '555-1234',
'email_address': 'fred@mailinator.com',
})
self.assertIsNone(batch.customer_id)
self.assertIsInstance(batch.pending_customer, model.PendingCustomer)
customer = batch.pending_customer
self.assertEqual(customer.full_name, "Fred Flintstone")
self.assertEqual(customer.first_name, "Fred")
self.assertEqual(customer.last_name, "Flintstone")
self.assertEqual(customer.phone_number, '555-1234')
self.assertEqual(customer.email_address, 'fred@mailinator.com')
self.assertEqual(batch.customer_name, "Fred Flintstone")
self.assertEqual(batch.phone_number, '555-1234')
self.assertEqual(batch.email_address, 'fred@mailinator.com')
# explicit full_name
batch = handler.make_batch(self.session, created_by=user, customer_id=42)
handler.set_pending_customer(batch, {
'full_name': "Freddy Flintstone",
'first_name': "Fred",
'last_name': "Flintstone",
'phone_number': '555-1234',
'email_address': 'fred@mailinator.com',
})
self.assertIsNone(batch.customer_id)
self.assertIsInstance(batch.pending_customer, model.PendingCustomer)
customer = batch.pending_customer
self.assertEqual(customer.full_name, "Freddy Flintstone")
self.assertEqual(customer.first_name, "Fred")
self.assertEqual(customer.last_name, "Flintstone")
self.assertEqual(customer.phone_number, '555-1234')
self.assertEqual(customer.email_address, 'fred@mailinator.com')
self.assertEqual(batch.customer_name, "Freddy Flintstone")
self.assertEqual(batch.phone_number, '555-1234')
self.assertEqual(batch.email_address, 'fred@mailinator.com')
def test_add_pending_product(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.assertEqual(len(batch.rows), 0)
kw = dict(
scancode='07430500132',
brand_name='Bragg',
description='Vinegar',
size='32oz',
case_size=12,
unit_cost=decimal.Decimal('3.99'),
unit_price_reg=decimal.Decimal('5.99'),
created_by=user,
)
row = handler.add_pending_product(batch, kw, 1, enum.ORDER_UOM_UNIT)
self.assertEqual(len(batch.rows), 1)
self.assertIs(batch.rows[0], row)
self.assertEqual(row.product_scancode, '07430500132')
self.assertEqual(row.product_brand, 'Bragg')
self.assertEqual(row.product_description, 'Vinegar')
self.assertEqual(row.product_size, '32oz')
self.assertEqual(row.case_size, 12)
self.assertEqual(row.unit_cost, decimal.Decimal('3.99'))
self.assertEqual(row.unit_price_reg, decimal.Decimal('5.99'))
self.assertEqual(row.unit_price_quoted, decimal.Decimal('5.99'))
self.assertEqual(row.case_price_quoted, decimal.Decimal('71.88'))
product = row.pending_product
self.assertIsInstance(product, model.PendingProduct)
self.assertEqual(product.scancode, '07430500132')
self.assertEqual(product.brand_name, 'Bragg')
self.assertEqual(product.description, 'Vinegar')
self.assertEqual(product.size, '32oz')
self.assertEqual(product.case_size, 12)
self.assertEqual(product.unit_cost, decimal.Decimal('3.99'))
self.assertEqual(product.unit_price_reg, decimal.Decimal('5.99'))
self.assertIs(product.created_by, user)
def test_set_pending_product(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.assertEqual(len(batch.rows), 0)
# start with mock product_id
row = handler.make_row(product_id=42, order_qty=1, order_uom=enum.ORDER_UOM_UNIT)
handler.add_row(batch, row)
self.session.flush()
self.assertEqual(row.product_id, 42)
self.assertIsNone(row.pending_product)
self.assertIsNone(row.product_scancode)
self.assertIsNone(row.product_brand)
self.assertIsNone(row.product_description)
self.assertIsNone(row.product_size)
self.assertIsNone(row.case_size)
self.assertIsNone(row.unit_cost)
self.assertIsNone(row.unit_price_reg)
self.assertIsNone(row.unit_price_quoted)
# set pending, which clears product_id
handler.set_pending_product(row, dict(
scancode='07430500132',
brand_name='Bragg',
description='Vinegar',
size='32oz',
case_size=12,
unit_cost=decimal.Decimal('3.99'),
unit_price_reg=decimal.Decimal('5.99'),
created_by=user,
))
self.session.flush()
self.assertIsNone(row.product_id)
self.assertIsInstance(row.pending_product, model.PendingProduct)
self.assertEqual(row.product_scancode, '07430500132')
self.assertEqual(row.product_brand, 'Bragg')
self.assertEqual(row.product_description, 'Vinegar')
self.assertEqual(row.product_size, '32oz')
self.assertEqual(row.case_size, 12)
self.assertEqual(row.unit_cost, decimal.Decimal('3.99'))
self.assertEqual(row.unit_price_reg, decimal.Decimal('5.99'))
self.assertEqual(row.unit_price_quoted, decimal.Decimal('5.99'))
self.assertEqual(row.case_price_quoted, decimal.Decimal('71.88'))
product = row.pending_product
self.assertIsInstance(product, model.PendingProduct)
self.assertEqual(product.scancode, '07430500132')
self.assertEqual(product.brand_name, 'Bragg')
self.assertEqual(product.description, 'Vinegar')
self.assertEqual(product.size, '32oz')
self.assertEqual(product.case_size, 12)
self.assertEqual(product.unit_cost, decimal.Decimal('3.99'))
self.assertEqual(product.unit_price_reg, decimal.Decimal('5.99'))
self.assertIs(product.created_by, user)
# set again to update pending
handler.set_pending_product(row, dict(
scancode='07430500116',
size='16oz',
unit_cost=decimal.Decimal('2.19'),
unit_price_reg=decimal.Decimal('3.59'),
))
self.session.flush()
self.assertIsNone(row.product_id)
self.assertIsInstance(row.pending_product, model.PendingProduct)
self.assertEqual(row.product_scancode, '07430500116')
self.assertEqual(row.product_brand, 'Bragg')
self.assertEqual(row.product_description, 'Vinegar')
self.assertEqual(row.product_size, '16oz')
self.assertEqual(row.case_size, 12)
self.assertEqual(row.unit_cost, decimal.Decimal('2.19'))
self.assertEqual(row.unit_price_reg, decimal.Decimal('3.59'))
self.assertEqual(row.unit_price_quoted, decimal.Decimal('3.59'))
self.assertEqual(row.case_price_quoted, decimal.Decimal('43.08'))
product = row.pending_product
self.assertIsInstance(product, model.PendingProduct)
self.assertEqual(product.scancode, '07430500116')
self.assertEqual(product.brand_name, 'Bragg')
self.assertEqual(product.description, 'Vinegar')
self.assertEqual(product.size, '16oz')
self.assertEqual(product.case_size, 12)
self.assertEqual(product.unit_cost, decimal.Decimal('2.19'))
self.assertEqual(product.unit_price_reg, decimal.Decimal('3.59'))
self.assertIs(product.created_by, user)
def test_refresh_row(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.assertEqual(len(batch.rows), 0)
# missing product
row = handler.make_row(order_qty=1, order_uom=enum.ORDER_UOM_UNIT)
self.assertIsNone(row.status_code)
handler.add_row(batch, row)
self.assertEqual(row.status_code, row.STATUS_MISSING_PRODUCT)
# missing order_qty
row = handler.make_row(product_id=42, order_uom=enum.ORDER_UOM_UNIT)
self.assertIsNone(row.status_code)
handler.add_row(batch, row)
self.assertEqual(row.status_code, row.STATUS_MISSING_ORDER_QTY)
# refreshed from pending product (null price)
product = model.PendingProduct(scancode='07430500132',
brand_name='Bragg',
description='Vinegar',
size='32oz',
created_by=user,
status=enum.PendingProductStatus.PENDING)
row = handler.make_row(pending_product=product, order_qty=1, order_uom=enum.ORDER_UOM_UNIT)
self.assertIsNone(row.status_code)
handler.add_row(batch, row)
self.assertEqual(row.status_code, row.STATUS_OK)
self.assertIsNone(row.product_id)
self.assertIs(row.pending_product, product)
self.assertEqual(row.product_scancode, '07430500132')
self.assertEqual(row.product_brand, 'Bragg')
self.assertEqual(row.product_description, 'Vinegar')
self.assertEqual(row.product_size, '32oz')
self.assertIsNone(row.case_size)
self.assertIsNone(row.unit_cost)
self.assertIsNone(row.unit_price_reg)
self.assertIsNone(row.unit_price_quoted)
self.assertIsNone(row.case_price_quoted)
self.assertIsNone(row.total_price)
# refreshed from pending product (zero price)
product = model.PendingProduct(scancode='07430500132',
brand_name='Bragg',
description='Vinegar',
size='32oz',
unit_price_reg=0,
created_by=user,
status=enum.PendingProductStatus.PENDING)
row = handler.make_row(pending_product=product, order_qty=1, order_uom=enum.ORDER_UOM_UNIT)
self.assertIsNone(row.status_code)
handler.add_row(batch, row)
self.assertEqual(row.status_code, row.STATUS_OK)
self.assertIsNone(row.product_id)
self.assertIs(row.pending_product, product)
self.assertEqual(row.product_scancode, '07430500132')
self.assertEqual(row.product_brand, 'Bragg')
self.assertEqual(row.product_description, 'Vinegar')
self.assertEqual(row.product_size, '32oz')
self.assertIsNone(row.case_size)
self.assertIsNone(row.unit_cost)
self.assertEqual(row.unit_price_reg, 0)
self.assertEqual(row.unit_price_quoted, 0)
self.assertIsNone(row.case_price_quoted)
self.assertEqual(row.total_price, 0)
# refreshed from pending product (normal, case)
product = model.PendingProduct(scancode='07430500132',
brand_name='Bragg',
description='Vinegar',
size='32oz',
case_size=12,
unit_cost=decimal.Decimal('3.99'),
unit_price_reg=decimal.Decimal('5.99'),
created_by=user,
status=enum.PendingProductStatus.PENDING)
row = handler.make_row(pending_product=product, order_qty=2, order_uom=enum.ORDER_UOM_CASE)
self.assertIsNone(row.status_code)
handler.add_row(batch, row)
self.assertEqual(row.status_code, row.STATUS_OK)
self.assertIsNone(row.product_id)
self.assertIs(row.pending_product, product)
self.assertEqual(row.product_scancode, '07430500132')
self.assertEqual(row.product_brand, 'Bragg')
self.assertEqual(row.product_description, 'Vinegar')
self.assertEqual(row.product_size, '32oz')
self.assertEqual(row.case_size, 12)
self.assertEqual(row.unit_cost, decimal.Decimal('3.99'))
self.assertEqual(row.unit_price_reg, decimal.Decimal('5.99'))
self.assertEqual(row.unit_price_quoted, decimal.Decimal('5.99'))
self.assertEqual(row.case_price_quoted, decimal.Decimal('71.88'))
self.assertEqual(row.total_price, decimal.Decimal('143.76'))
def test_remove_row(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.assertEqual(len(batch.rows), 0)
kw = dict(
scancode='07430500132',
brand_name='Bragg',
description='Vinegar',
size='32oz',
case_size=12,
unit_cost=decimal.Decimal('3.99'),
unit_price_reg=decimal.Decimal('5.99'),
created_by=user,
)
row = handler.add_pending_product(batch, kw, 1, enum.ORDER_UOM_CASE)
self.session.add(row)
self.session.flush()
self.assertEqual(batch.row_count, 1)
self.assertEqual(row.total_price, decimal.Decimal('71.88'))
self.assertEqual(batch.total_price, decimal.Decimal('71.88'))
handler.do_remove_row(row)
self.assertEqual(batch.row_count, 0)
self.assertEqual(batch.total_price, 0)
def test_do_delete(self):
model = self.app.model
enum = self.app.enum
handler = self.make_handler()
user = model.User(username='barney')
self.session.add(user)
# make batch w/ pending customer
customer = model.PendingCustomer(full_name="Fred Flintstone",
status=enum.PendingCustomerStatus.PENDING,
created_by=user)
self.session.add(customer)
batch = handler.make_batch(self.session, created_by=user, pending_customer=customer)
self.session.add(batch)
self.session.commit()
# deleting batch will also delete pending customer
self.assertIn(batch, self.session)
self.assertEqual(self.session.query(model.PendingCustomer).count(), 1)
handler.do_delete(batch, user)
self.session.commit()
self.assertNotIn(batch, self.session)
self.assertEqual(self.session.query(model.PendingCustomer).count(), 0)
# make new pending customer
customer = model.PendingCustomer(full_name="Fred Flintstone",
status=enum.PendingCustomerStatus.PENDING,
created_by=user)
self.session.add(customer)
# make 2 batches with same pending customer
batch1 = handler.make_batch(self.session, created_by=user, pending_customer=customer)
batch2 = handler.make_batch(self.session, created_by=user, pending_customer=customer)
self.session.add(batch1)
self.session.add(batch2)
self.session.commit()
# deleting 1 will not delete pending customer
self.assertEqual(self.session.query(model.PendingCustomer).count(), 1)
handler.do_delete(batch1, user)
self.session.commit()
self.assertEqual(self.session.query(model.PendingCustomer).count(), 1)
self.assertIs(batch2.pending_customer, customer)
def test_get_effective_rows(self):
model = self.app.model
enum = self.app.enum
handler = self.make_handler()
user = model.User(username='barney')
self.session.add(user)
# make batch w/ different status rows
batch = handler.make_batch(self.session, created_by=user)
self.session.add(batch)
# STATUS_MISSING_PRODUCT
row = handler.make_row(order_qty=1, order_uom=enum.ORDER_UOM_UNIT)
handler.add_row(batch, row)
self.session.add(row)
self.session.flush()
# STATUS_MISSING_ORDER_QTY
row = handler.make_row(product_id=42, order_qty=0, order_uom=enum.ORDER_UOM_UNIT)
handler.add_row(batch, row)
self.session.add(row)
self.session.flush()
# STATUS_OK
row = handler.make_row(product_id=42, order_qty=1, order_uom=enum.ORDER_UOM_UNIT)
handler.add_row(batch, row)
self.session.add(row)
self.session.commit()
# only 1 effective row
rows = handler.get_effective_rows(batch)
self.assertEqual(len(rows), 1)
row = rows[0]
self.assertEqual(row.status_code, row.STATUS_OK)
def test_why_not_execute(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()
reason = handler.why_not_execute(batch)
self.assertEqual(reason, "Must assign the customer")
batch.customer_id = 42
reason = handler.why_not_execute(batch)
self.assertEqual(reason, "Must add at least one valid item")
kw = dict(
scancode='07430500132',
brand_name='Bragg',
description='Vinegar',
size='32oz',
case_size=12,
unit_cost=decimal.Decimal('3.99'),
unit_price_reg=decimal.Decimal('5.99'),
created_by=user,
)
row = handler.add_pending_product(batch, kw, 1, enum.ORDER_UOM_CASE)
self.session.add(row)
self.session.flush()
reason = handler.why_not_execute(batch)
self.assertIsNone(reason)
def test_make_new_order(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,
customer_id=42, customer_name="John Doe")
self.session.add(batch)
kw = dict(
scancode='07430500132',
brand_name='Bragg',
description='Vinegar',
size='32oz',
case_size=12,
unit_cost=decimal.Decimal('3.99'),
unit_price_reg=decimal.Decimal('5.99'),
created_by=user,
)
row = handler.add_pending_product(batch, kw, 1, enum.ORDER_UOM_CASE)
self.session.add(row)
self.session.flush()
order = handler.make_new_order(batch, [row], user=user)
self.assertIsInstance(order, model.Order)
self.assertIs(order.created_by, user)
self.assertEqual(order.customer_id, 42)
self.assertEqual(order.customer_name, "John Doe")
self.assertEqual(len(order.items), 1)
item = order.items[0]
self.assertEqual(item.product_scancode, '07430500132')
self.assertEqual(item.product_brand, 'Bragg')
self.assertEqual(item.product_description, 'Vinegar')
self.assertEqual(item.product_size, '32oz')
self.assertEqual(item.case_size, 12)
self.assertEqual(item.unit_cost, decimal.Decimal('3.99'))
self.assertEqual(item.unit_price_reg, decimal.Decimal('5.99'))
def test_execute(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,
customer_id=42, customer_name="John Doe")
self.session.add(batch)
kw = dict(
scancode='07430500132',
brand_name='Bragg',
description='Vinegar',
size='32oz',
case_size=12,
unit_cost=decimal.Decimal('3.99'),
unit_price_reg=decimal.Decimal('5.99'),
created_by=user,
)
row = handler.add_pending_product(batch, kw, 1, enum.ORDER_UOM_CASE)
self.session.add(row)
self.session.flush()
order = handler.execute(batch, user=user)
self.assertIsInstance(order, model.Order)
self.assertIs(order.created_by, user)
self.assertEqual(order.customer_id, 42)
self.assertEqual(order.customer_name, "John Doe")
self.assertEqual(len(order.items), 1)
item = order.items[0]
self.assertEqual(item.product_scancode, '07430500132')
self.assertEqual(item.product_brand, 'Bragg')
self.assertEqual(item.product_description, 'Vinegar')
self.assertEqual(item.product_size, '32oz')
self.assertEqual(item.case_size, 12)
self.assertEqual(item.unit_cost, decimal.Decimal('3.99'))
self.assertEqual(item.unit_price_reg, decimal.Decimal('5.99'))

0
tests/cli/__init__.py Normal file
View file

18
tests/cli/test_install.py Normal file
View file

@ -0,0 +1,18 @@
# -*- coding: utf-8; -*-
from unittest.mock import MagicMock, patch
from wuttjamaican.testing import ConfigTestCase
from wuttjamaican.install import InstallHandler
from sideshow.cli import install as mod
class TestInstall(ConfigTestCase):
def test_run(self):
ctx = MagicMock(params={})
ctx.parent.wutta_config = self.config
with patch.object(InstallHandler, 'run') as run:
mod.install(ctx)
run.assert_called_once_with()

0
tests/db/__init__.py Normal file
View file

View file

View file

View file

@ -0,0 +1,23 @@
# -*- coding: utf-8; -*-
from wuttjamaican.testing import DataTestCase
from sideshow.db.model.batch import neworder as mod
from sideshow.db.model.products import PendingProduct
class TestNewOrderBatchRow(DataTestCase):
def test_str(self):
row = mod.NewOrderBatchRow()
self.assertEqual(str(row), "")
row = mod.NewOrderBatchRow(product_description="Vinegar")
self.assertEqual(str(row), "Vinegar")
product = PendingProduct(brand_name="Bragg",
description="Vinegar",
size="32oz")
row = mod.NewOrderBatchRow(pending_product=product)
self.assertEqual(str(row), "Bragg Vinegar 32oz")

View file

@ -0,0 +1,15 @@
# -*- coding: utf-8; -*-
from wuttjamaican.testing import DataTestCase
from sideshow.db.model import customers as mod
class TestPendingCustomer(DataTestCase):
def test_str(self):
customer = mod.PendingCustomer()
self.assertEqual(str(customer), "")
customer.full_name = "Fred Flintstone"
self.assertEqual(str(customer), "Fred Flintstone")

View file

@ -0,0 +1,34 @@
# -*- coding: utf-8; -*-
from wuttjamaican.testing import DataTestCase
from sideshow.db.model import orders as mod
from sideshow.db.model.products import PendingProduct
class TestOrder(DataTestCase):
def test_str(self):
order = mod.Order()
self.assertEqual(str(order), "None")
order = mod.Order(order_id=42)
self.assertEqual(str(order), "42")
class TestOrderItem(DataTestCase):
def test_str(self):
item = mod.OrderItem()
self.assertEqual(str(item), "")
item = mod.OrderItem(product_description="Vinegar")
self.assertEqual(str(item), "Vinegar")
product = PendingProduct(brand_name="Bragg",
description="Vinegar",
size="32oz")
item = mod.OrderItem(pending_product=product)
self.assertEqual(str(item), "Bragg Vinegar 32oz")

View file

@ -0,0 +1,44 @@
# -*- coding: utf-8; -*-
from wuttjamaican.testing import DataTestCase
from sideshow.db.model import products as mod
class TestPendingProduct(DataTestCase):
def test_str(self):
product = mod.PendingProduct()
self.assertEqual(str(product), "")
product = mod.PendingProduct(brand_name="Bragg")
self.assertEqual(str(product), "Bragg")
product = mod.PendingProduct(description="Vinegar")
self.assertEqual(str(product), "Vinegar")
product = mod.PendingProduct(size="32oz")
self.assertEqual(str(product), "32oz")
product = mod.PendingProduct(brand_name="Bragg",
description="Vinegar",
size="32oz")
self.assertEqual(str(product), "Bragg Vinegar 32oz")
def test_full_description(self):
product = mod.PendingProduct()
self.assertEqual(product.full_description, "")
product = mod.PendingProduct(brand_name="Bragg")
self.assertEqual(product.full_description, "Bragg")
product = mod.PendingProduct(description="Vinegar")
self.assertEqual(product.full_description, "Vinegar")
product = mod.PendingProduct(size="32oz")
self.assertEqual(product.full_description, "32oz")
product = mod.PendingProduct(brand_name="Bragg",
description="Vinegar",
size="32oz")
self.assertEqual(product.full_description, "Bragg Vinegar 32oz")

17
tests/test_config.py Normal file
View file

@ -0,0 +1,17 @@
# -*- coding: utf-8; -*-
from unittest import TestCase
from wuttjamaican.conf import WuttaConfig
from sideshow import config as mod
class TestSideshowConfig(TestCase):
def test_configure(self):
config = WuttaConfig(files=[])
ext = mod.SideshowConfig()
ext.configure(config)
self.assertEqual(config.get('wutta.app_title'), "Sideshow")
self.assertEqual(config.get('wutta.app_dist'), "Sideshow")

0
tests/web/example.conf Normal file
View file

View file

@ -0,0 +1,88 @@
# -*- coding: utf-8; -*-
from sqlalchemy import orm
from sideshow.testing import WebTestCase
from sideshow.web.forms import schema as mod
class TestOrderRef(WebTestCase):
def test_sort_query(self):
typ = mod.OrderRef(self.request, session=self.session)
query = typ.get_query()
self.assertIsInstance(query, orm.Query)
sorted_query = typ.sort_query(query)
self.assertIsInstance(sorted_query, orm.Query)
self.assertIsNot(sorted_query, query)
def test_get_object_url(self):
self.pyramid_config.add_route('orders.view', '/orders/{uuid}')
model = self.app.model
user = model.User(username='barney')
self.session.add(user)
order = model.Order(order_id=42, created_by=user)
self.session.add(order)
self.session.commit()
typ = mod.OrderRef(self.request, session=self.session)
url = typ.get_object_url(order)
self.assertIsNotNone(url)
self.assertIn(f'/orders/{order.uuid}', url)
class TestPendingCustomerRef(WebTestCase):
def test_sort_query(self):
typ = mod.PendingCustomerRef(self.request, session=self.session)
query = typ.get_query()
self.assertIsInstance(query, orm.Query)
sorted_query = typ.sort_query(query)
self.assertIsInstance(sorted_query, orm.Query)
self.assertIsNot(sorted_query, query)
def test_get_object_url(self):
self.pyramid_config.add_route('pending_customers.view', '/pending/customers/{uuid}')
model = self.app.model
enum = self.app.enum
user = model.User(username='barney')
self.session.add(user)
customer = model.PendingCustomer(status=enum.PendingCustomerStatus.PENDING,
created_by=user)
self.session.add(customer)
self.session.commit()
typ = mod.PendingCustomerRef(self.request, session=self.session)
url = typ.get_object_url(customer)
self.assertIsNotNone(url)
self.assertIn(f'/pending/customers/{customer.uuid}', url)
class TestPendingProductRef(WebTestCase):
def test_sort_query(self):
typ = mod.PendingProductRef(self.request, session=self.session)
query = typ.get_query()
self.assertIsInstance(query, orm.Query)
sorted_query = typ.sort_query(query)
self.assertIsInstance(sorted_query, orm.Query)
self.assertIsNot(sorted_query, query)
def test_get_object_url(self):
self.pyramid_config.add_route('pending_products.view', '/pending/products/{uuid}')
model = self.app.model
enum = self.app.enum
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.commit()
typ = mod.PendingProductRef(self.request, session=self.session)
url = typ.get_object_url(product)
self.assertIsNotNone(url)
self.assertIn(f'/pending/products/{product.uuid}', url)

34
tests/web/test_app.py Normal file
View file

@ -0,0 +1,34 @@
# -*- coding: utf-8; -*-
import os
from unittest import TestCase
from asgiref.wsgi import WsgiToAsgi
from pyramid.router import Router
from sideshow.web import app as mod
here = os.path.dirname(__file__)
example_conf = os.path.join(here, 'example.conf')
class TestMain(TestCase):
def test_coverage(self):
app = mod.main({}, **{'wutta.config': example_conf})
self.assertIsInstance(app, Router)
class TestMakeWsgiApp(TestCase):
def test_coverage(self):
app = mod.make_wsgi_app()
self.assertIsInstance(app, Router)
class TestMakeAsgiApp(TestCase):
def test_coverage(self):
app = mod.make_asgi_app()
self.assertIsInstance(app, WsgiToAsgi)

12
tests/web/test_menus.py Normal file
View file

@ -0,0 +1,12 @@
# -*- coding: utf-8; -*-
from sideshow.testing import WebTestCase
from sideshow.web import menus as mod
class TestSideshowMenuHandler(WebTestCase):
def test_make_menus(self):
handler = mod.SideshowMenuHandler(self.config)
menus = handler.make_menus(self.request)
self.assertEqual(len(menus), 4)

10
tests/web/test_static.py Normal file
View file

@ -0,0 +1,10 @@
# -*- coding: utf-8; -*-
from sideshow.testing import WebTestCase
from sideshow.web import static as mod
class TestIncludeme(WebTestCase):
def test_coverage(self):
mod.includeme(self.pyramid_config)

View file

@ -0,0 +1,101 @@
# -*- coding: utf-8; -*-
import datetime
from unittest.mock import patch
from wuttaweb.forms.schema import WuttaMoney
from sideshow.testing import WebTestCase
from sideshow.web.views.batch import neworder as mod
from sideshow.web.forms.schema import PendingCustomerRef
from sideshow.batch.neworder import NewOrderBatchHandler
class TestIncludeme(WebTestCase):
def test_coverage(self):
mod.includeme(self.pyramid_config)
class TestNewOrderBatchView(WebTestCase):
def make_view(self):
return mod.NewOrderBatchView(self.request)
def test_get_batch_handler(self):
view = self.make_view()
handler = view.get_batch_handler()
self.assertIsInstance(handler, NewOrderBatchHandler)
def test_configure_grid(self):
model = self.app.model
view = self.make_view()
grid = view.make_grid(model_class=model.NewOrderBatch)
self.assertNotIn('total_price', grid.renderers)
view.configure_grid(grid)
self.assertIn('total_price', grid.renderers)
def test_configure_form(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)
customer = model.PendingCustomer(status=enum.PendingCustomerStatus.PENDING,
created_by=user)
self.session.add(customer)
batch = handler.make_batch(self.session, pending_customer=customer, created_by=user)
self.session.add(batch)
self.session.commit()
# viewing
with patch.object(view, 'viewing', new=True):
form = view.make_form(model_instance=batch)
view.configure_form(form)
schema = form.get_schema()
self.assertIsInstance(schema['pending_customer'].typ, PendingCustomerRef)
self.assertIsInstance(schema['total_price'].typ, WuttaMoney)
def test_configure_row_grid(self):
model = self.app.model
view = self.make_view()
grid = view.make_grid(model_class=model.NewOrderBatchRow)
self.assertNotIn('total_price', grid.renderers)
view.configure_row_grid(grid)
self.assertIn('total_price', grid.renderers)
def test_get_xref_buttons(self):
self.pyramid_config.add_route('orders.view', '/orders/{uuid}')
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)
customer = model.PendingCustomer(status=enum.PendingCustomerStatus.PENDING,
created_by=user)
self.session.add(customer)
# 1st batch has no order
batch = handler.make_batch(self.session, pending_customer=customer, created_by=user)
self.session.add(batch)
self.session.flush()
buttons = view.get_xref_buttons(batch)
self.assertEqual(len(buttons), 0)
# 2nd batch is executed; has order
batch = handler.make_batch(self.session, pending_customer=customer, created_by=user,
executed=datetime.datetime.now(), executed_by=user)
self.session.add(batch)
self.session.flush()
order = model.Order(order_id=batch.id, created_by=user)
self.session.add(order)
self.session.flush()
with patch.object(view, 'Session', return_value=self.session):
# nb. this also requires perm
with patch.object(self.request, 'is_root', new=True):
buttons = view.get_xref_buttons(batch)
self.assertEqual(len(buttons), 1)

View file

@ -0,0 +1,184 @@
# -*- coding: utf-8; -*-
import datetime
from unittest.mock import patch
from pyramid.httpexceptions import HTTPFound
from sideshow.testing import WebTestCase
from sideshow.web.views import customers as mod
from sideshow.batch.neworder import NewOrderBatchHandler
class TestIncludeme(WebTestCase):
def test_coverage(self):
mod.includeme(self.pyramid_config)
class TestPendingCustomerView(WebTestCase):
def make_view(self):
return mod.PendingCustomerView(self.request)
def test_configure_grid(self):
model = self.app.model
view = self.make_view()
# nb. mostly just getting coverage here
grid = view.make_grid(model_class=model.PendingCustomer)
view.configure_grid(grid)
self.assertIn('full_name', grid.linked_columns)
def test_configure_form(self):
model = self.app.model
enum = self.app.enum
view = self.make_view()
# creating
with patch.object(view, 'creating', new=True):
form = view.make_form(model_class=model.PendingCustomer)
view.configure_form(form)
self.assertNotIn('status', form)
self.assertNotIn('created', form)
self.assertNotIn('created_by', form)
self.assertNotIn('orders', form)
self.assertNotIn('new_order_batches', form)
user = model.User(username='barney')
self.session.add(user)
customer = model.PendingCustomer(status=enum.PendingCustomerStatus.PENDING,
created_by=user)
self.session.add(customer)
self.session.commit()
# viewing
with patch.object(view, 'viewing', new=True):
form = view.make_form(model_instance=customer)
view.configure_form(form)
self.assertIn('status', form)
self.assertIn('created', form)
self.assertIn('created_by', form)
self.assertIn('orders', form)
self.assertIn('new_order_batches', form)
def test_make_orders_grid(self):
model = self.app.model
enum = self.app.enum
view = self.make_view()
user = model.User(username='barney')
self.session.add(user)
customer = model.PendingCustomer(status=enum.PendingCustomerStatus.PENDING,
created_by=user)
self.session.add(customer)
order = model.Order(order_id=42, pending_customer=customer, created_by=user)
self.session.add(order)
self.session.commit()
# no view perm
grid = view.make_orders_grid(customer)
self.assertEqual(len(grid.actions), 0)
# with view perm
with patch.object(self.request, 'is_root', new=True):
grid = view.make_orders_grid(customer)
self.assertEqual(len(grid.actions), 1)
self.assertEqual(grid.actions[0].key, 'view')
def test_make_new_order_batches_grid(self):
model = self.app.model
enum = self.app.enum
handler = NewOrderBatchHandler(self.config)
view = self.make_view()
user = model.User(username='barney')
self.session.add(user)
customer = model.PendingCustomer(status=enum.PendingCustomerStatus.PENDING,
created_by=user)
self.session.add(customer)
batch = handler.make_batch(self.session, pending_customer=customer, created_by=user)
self.session.add(batch)
self.session.commit()
# no view perm
grid = view.make_new_order_batches_grid(customer)
self.assertEqual(len(grid.actions), 0)
# with view perm
with patch.object(self.request, 'is_root', new=True):
grid = view.make_new_order_batches_grid(customer)
self.assertEqual(len(grid.actions), 1)
self.assertEqual(grid.actions[0].key, 'view')
def test_objectify(self):
model = self.app.model
enum = self.app.enum
view = self.make_view()
user = model.User(username='barney')
self.session.add(user)
self.session.commit()
with patch.object(view, 'creating', new=True):
with patch.object(self.request, 'user', new=user):
form = view.make_model_form()
with patch.object(form, 'validated', create=True, new={
'full_name': "Fred Flinstone",
}):
customer = view.objectify(form)
self.assertIsInstance(customer, model.PendingCustomer)
self.assertIs(customer.created_by, user)
self.assertEqual(customer.status, enum.PendingCustomerStatus.PENDING)
def test_delete_instance(self):
self.pyramid_config.add_route('pending_customers.view', '/pending/customers/{uuid}')
model = self.app.model
enum = self.app.enum
handler = NewOrderBatchHandler(self.config)
view = self.make_view()
user = model.User(username='barney')
self.session.add(user)
# 1st customer is standalone, will be deleted
customer = model.PendingCustomer(status=enum.PendingCustomerStatus.PENDING,
created_by=user)
self.session.add(customer)
self.session.flush()
self.assertEqual(self.session.query(model.PendingCustomer).count(), 1)
view.delete_instance(customer)
self.session.flush()
self.assertEqual(self.session.query(model.PendingCustomer).count(), 0)
# 2nd customer is attached to new order batch, will not be deleted
customer = model.PendingCustomer(status=enum.PendingCustomerStatus.PENDING,
created_by=user)
self.session.add(customer)
batch = handler.make_batch(self.session, created_by=user, pending_customer=customer)
self.session.add(batch)
self.session.flush()
self.assertEqual(self.session.query(model.PendingCustomer).count(), 1)
self.assertRaises(HTTPFound, view.delete_instance, customer)
self.session.flush()
self.assertEqual(self.session.query(model.PendingCustomer).count(), 1)
# but after batch is executed, 2nd customer can be deleted
batch.executed = datetime.datetime.now()
batch.executed_by = user
self.session.flush()
self.assertEqual(self.session.query(model.PendingCustomer).count(), 1)
view.delete_instance(customer)
self.session.flush()
self.assertEqual(self.session.query(model.PendingCustomer).count(), 0)
# 3rd customer is attached to order, will not be deleted
customer = model.PendingCustomer(status=enum.PendingCustomerStatus.PENDING,
created_by=user)
self.session.add(customer)
order = model.Order(order_id=42, created_by=user, pending_customer=customer)
self.session.add(order)
self.session.flush()
self.assertEqual(self.session.query(model.PendingCustomer).count(), 1)
self.assertRaises(HTTPFound, view.delete_instance, customer)
self.session.flush()
self.assertEqual(self.session.query(model.PendingCustomer).count(), 1)

View file

@ -0,0 +1,902 @@
# -*- coding: utf-8; -*-
import datetime
import decimal
from unittest.mock import patch
from sqlalchemy import orm
from pyramid.httpexceptions import HTTPForbidden, HTTPFound
from pyramid.response import Response
from wuttaweb.forms.schema import WuttaMoney
from sideshow.batch.neworder import NewOrderBatchHandler
from sideshow.testing import WebTestCase
from sideshow.web.views import orders as mod
from sideshow.web.forms.schema import OrderRef
class TestIncludeme(WebTestCase):
def test_coverage(self):
mod.includeme(self.pyramid_config)
class TestOrderView(WebTestCase):
def make_view(self):
return mod.OrderView(self.request)
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)
view.configure_grid(grid)
self.assertIn('order_id', grid.linked_columns)
self.assertIn('total_price', grid.renderers)
def test_create(self):
self.pyramid_config.include('sideshow.web.views')
model = self.app.model
view = self.make_view()
user = model.User(username='barney')
self.session.add(user)
self.session.flush()
with patch.object(view, 'Session', return_value=self.session):
with patch.object(self.request, 'current_route_url', return_value='/orders/new'):
# this will require some perms
with patch.multiple(self.request, create=True,
user=user, is_root=True):
# fetch page to start things off
self.assertEqual(self.session.query(model.NewOrderBatch).count(), 0)
response = view.create()
self.assertEqual(self.session.query(model.NewOrderBatch).count(), 1)
batch1 = self.session.query(model.NewOrderBatch).one()
# start over; deletes current batch
with patch.multiple(self.request, create=True,
method='POST',
POST={'action': 'start_over'}):
response = view.create()
self.assertIsInstance(response, HTTPFound)
self.assertIn('/orders/new', response.location)
self.assertEqual(self.session.query(model.NewOrderBatch).count(), 0)
# fetch again to get new batch
response = view.create()
self.assertEqual(self.session.query(model.NewOrderBatch).count(), 1)
batch2 = self.session.query(model.NewOrderBatch).one()
self.assertIsNot(batch2, batch1)
# set pending customer
with patch.multiple(self.request, create=True,
method='POST',
json_body={'action': 'set_pending_customer',
'first_name': 'Fred',
'last_name': 'Flintstone',
'phone_number': '555-1234',
'email_address': 'fred@mailinator.com'}):
response = view.create()
self.assertIsInstance(response, Response)
self.assertEqual(response.content_type, 'application/json')
self.assertEqual(response.json_body, {
'customer_is_known': False,
'customer_id': None,
'customer_name': 'Fred Flintstone',
'phone_number': '555-1234',
'email_address': 'fred@mailinator.com',
'new_customer_name': 'Fred Flintstone',
'new_customer_first_name': 'Fred',
'new_customer_last_name': 'Flintstone',
'new_customer_phone': '555-1234',
'new_customer_email': 'fred@mailinator.com',
})
# invalid action
with patch.multiple(self.request, create=True,
method='POST',
POST={'action': 'bogus'},
json_body={'action': 'bogus'}):
response = view.create()
self.assertIsInstance(response, Response)
self.assertEqual(response.content_type, 'application/json')
self.assertEqual(response.json_body, {'error': 'unknown form action'})
def test_get_current_batch(self):
model = self.app.model
handler = NewOrderBatchHandler(self.config)
view = self.make_view()
# user is required
self.assertRaises(HTTPForbidden, view.get_current_batch)
user = model.User(username='barney')
self.session.add(user)
self.session.commit()
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 is auto-created
self.assertEqual(self.session.query(model.NewOrderBatch).count(), 0)
batch = view.get_current_batch()
self.session.flush()
self.assertEqual(self.session.query(model.NewOrderBatch).count(), 1)
self.assertIs(batch.created_by, user)
# same batch is returned subsequently
batch2 = view.get_current_batch()
self.session.flush()
self.assertEqual(self.session.query(model.NewOrderBatch).count(), 1)
self.assertIs(batch2, batch)
def test_get_pending_product_required_fields(self):
model = self.app.model
view = self.make_view()
# only description is required by default
fields = view.get_pending_product_required_fields()
self.assertEqual(fields, ['description'])
# but config can specify otherwise
self.config.setdefault('sideshow.orders.unknown_product.fields.brand_name.required', 'true')
self.config.setdefault('sideshow.orders.unknown_product.fields.description.required', 'false')
self.config.setdefault('sideshow.orders.unknown_product.fields.size.required', 'true')
self.config.setdefault('sideshow.orders.unknown_product.fields.unit_price_reg.required', 'true')
fields = view.get_pending_product_required_fields()
self.assertEqual(fields, ['brand_name', 'size', 'unit_price_reg'])
def test_get_context_customer(self):
self.pyramid_config.add_route('orders', '/orders/')
model = self.app.model
handler = NewOrderBatchHandler(self.config)
view = self.make_view()
user = model.User(username='barney')
self.session.add(user)
# with true customer
batch = handler.make_batch(self.session, created_by=user,
customer_id=42, customer_name='Fred Flintstone',
phone_number='555-1234', email_address='fred@mailinator.com')
self.session.add(batch)
self.session.flush()
context = view.get_context_customer(batch)
self.assertEqual(context, {
'customer_is_known': True,
'customer_id': 42,
'customer_name': 'Fred Flintstone',
'phone_number': '555-1234',
'email_address': 'fred@mailinator.com',
'new_customer_name': None,
'new_customer_first_name': None,
'new_customer_last_name': None,
'new_customer_phone': None,
'new_customer_email': None,
})
# with pending customer
batch = handler.make_batch(self.session, created_by=user)
self.session.add(batch)
handler.set_pending_customer(batch, dict(
full_name="Fred Flintstone",
first_name="Fred", last_name="Flintstone",
phone_number='555-1234', email_address='fred@mailinator.com',
created_by=user,
))
self.session.flush()
context = view.get_context_customer(batch)
self.assertEqual(context, {
'customer_is_known': False,
'customer_id': None,
'customer_name': 'Fred Flintstone',
'phone_number': '555-1234',
'email_address': 'fred@mailinator.com',
'new_customer_name': 'Fred Flintstone',
'new_customer_first_name': 'Fred',
'new_customer_last_name': 'Flintstone',
'new_customer_phone': '555-1234',
'new_customer_email': 'fred@mailinator.com',
})
# with no customer
batch = handler.make_batch(self.session, created_by=user)
self.session.add(batch)
self.session.flush()
context = view.get_context_customer(batch)
self.assertEqual(context, {
'customer_is_known': True, # nb. this is for UI default
'customer_id': None,
'customer_name': None,
'phone_number': None,
'email_address': None,
'new_customer_name': None,
'new_customer_first_name': None,
'new_customer_last_name': None,
'new_customer_phone': None,
'new_customer_email': None,
})
def test_start_over(self):
self.pyramid_config.add_route('orders.create', '/orders/new')
model = self.app.model
handler = NewOrderBatchHandler(self.config)
view = self.make_view()
user = model.User(username='barney')
self.session.add(user)
self.session.commit()
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.session.flush()
self.assertEqual(self.session.query(model.NewOrderBatch).count(), 1)
result = view.start_over(batch)
self.assertIsInstance(result, HTTPFound)
self.session.flush()
self.assertEqual(self.session.query(model.NewOrderBatch).count(), 0)
def test_cancel_order(self):
self.pyramid_config.add_route('orders', '/orders/')
model = self.app.model
handler = NewOrderBatchHandler(self.config)
view = self.make_view()
user = model.User(username='barney')
self.session.add(user)
self.session.commit()
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.session.flush()
self.assertEqual(self.session.query(model.NewOrderBatch).count(), 1)
result = view.cancel_order(batch)
self.assertIsInstance(result, HTTPFound)
self.session.flush()
self.assertEqual(self.session.query(model.NewOrderBatch).count(), 0)
def test_set_pending_customer(self):
self.pyramid_config.add_route('orders.create', '/orders/new')
model = self.app.model
handler = NewOrderBatchHandler(self.config)
view = self.make_view()
user = model.User(username='barney')
self.session.add(user)
self.session.commit()
data = {
'first_name': 'Fred',
'last_name': 'Flintstone',
'phone_number': '555-1234',
'email_address': 'fred@mailinator.com',
}
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.session.flush()
# normal
self.assertIsNone(batch.pending_customer)
context = view.set_pending_customer(batch, data)
self.assertIsInstance(batch.pending_customer, model.PendingCustomer)
self.assertEqual(context, {
'customer_is_known': False,
'customer_id': None,
'customer_name': 'Fred Flintstone',
'phone_number': '555-1234',
'email_address': 'fred@mailinator.com',
'new_customer_name': 'Fred Flintstone',
'new_customer_first_name': 'Fred',
'new_customer_last_name': 'Flintstone',
'new_customer_phone': '555-1234',
'new_customer_email': 'fred@mailinator.com',
})
# error
with patch.object(handler, 'set_pending_customer', side_effect=RuntimeError):
context = view.set_pending_customer(batch, data)
self.assertEqual(context, {
'error': 'RuntimeError',
})
def test_add_item(self):
model = self.app.model
enum = self.app.enum
handler = NewOrderBatchHandler(self.config)
view = self.make_view()
user = model.User(username='barney')
self.session.add(user)
self.session.commit()
data = {
'pending_product': {
'scancode': '07430500132',
'brand_name': 'Bragg',
'description': 'Vinegar',
'size': '32oz',
'unit_price_reg': 5.99,
},
'order_qty': 1,
'order_uom': enum.ORDER_UOM_UNIT,
}
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.session.flush()
self.assertEqual(len(batch.rows), 0)
# normal pending product
result = view.add_item(batch, data)
self.assertIn('batch', result)
self.assertIn('row', result)
self.session.flush()
self.assertEqual(len(batch.rows), 1)
row = batch.rows[0]
self.assertIsInstance(row.pending_product, model.PendingProduct)
# pending w/ invalid price
with patch.dict(data['pending_product'], unit_price_reg='invalid'):
result = view.add_item(batch, data)
self.assertEqual(result, {'error': "Invalid entry for field: unit_price_reg"})
self.session.flush()
self.assertEqual(len(batch.rows), 1) # still just the 1st row
# true product not yet supported
with patch.dict(data, product_is_known=True):
self.assertRaises(NotImplementedError, view.add_item, batch, data)
def test_update_item(self):
model = self.app.model
enum = self.app.enum
handler = NewOrderBatchHandler(self.config)
view = self.make_view()
user = model.User(username='barney')
self.session.add(user)
self.session.commit()
data = {
'pending_product': {
'scancode': '07430500132',
'brand_name': 'Bragg',
'description': 'Vinegar',
'size': '32oz',
'unit_price_reg': 5.99,
'case_size': 12,
},
'order_qty': 1,
'order_uom': enum.ORDER_UOM_CASE,
}
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.session.flush()
self.assertEqual(len(batch.rows), 0)
# add row w/ pending product
view.add_item(batch, data)
self.session.flush()
row = batch.rows[0]
self.assertIsInstance(row.pending_product, model.PendingProduct)
self.assertEqual(row.unit_price_quoted, decimal.Decimal('5.99'))
# missing row uuid
result = view.update_item(batch, data)
self.assertEqual(result, {'error': "Must specify a row UUID"})
# row not found
with patch.dict(data, uuid=self.app.make_true_uuid()):
result = view.update_item(batch, data)
self.assertEqual(result, {'error': "Row not found"})
# row for wrong batch
batch2 = handler.make_batch(self.session, created_by=user)
self.session.add(batch2)
row2 = handler.make_row(order_qty=1, order_uom=enum.ORDER_UOM_UNIT)
handler.add_row(batch2, row2)
self.session.flush()
with patch.dict(data, uuid=row2.uuid):
result = view.update_item(batch, data)
self.assertEqual(result, {'error': "Row is for wrong batch"})
# set row for remaining tests
data['uuid'] = row.uuid
# true product not yet supported
with patch.dict(data, product_is_known=True):
self.assertRaises(NotImplementedError, view.update_item, batch, data)
# update row, pending product
with patch.dict(data, order_qty=2):
with patch.dict(data['pending_product'], scancode='07430500116'):
self.assertEqual(row.product_scancode, '07430500132')
self.assertEqual(row.order_qty, 1)
result = view.update_item(batch, data)
self.assertEqual(sorted(result), ['batch', 'row'])
self.assertEqual(row.product_scancode, '07430500116')
self.assertEqual(row.order_qty, 2)
self.assertEqual(row.pending_product.scancode, '07430500116')
self.assertEqual(result['row']['product_scancode'], '07430500116')
self.assertEqual(result['row']['order_qty'], '2')
def test_delete_item(self):
model = self.app.model
enum = self.app.enum
handler = NewOrderBatchHandler(self.config)
view = self.make_view()
user = model.User(username='barney')
self.session.add(user)
self.session.commit()
data = {
'pending_product': {
'scancode': '07430500132',
'brand_name': 'Bragg',
'description': 'Vinegar',
'size': '32oz',
'unit_price_reg': 5.99,
'case_size': 12,
},
'order_qty': 1,
'order_uom': enum.ORDER_UOM_CASE,
}
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.session.flush()
self.assertEqual(len(batch.rows), 0)
# add row w/ pending product
view.add_item(batch, data)
self.session.flush()
row = batch.rows[0]
self.assertIsInstance(row.pending_product, model.PendingProduct)
self.assertEqual(row.unit_price_quoted, decimal.Decimal('5.99'))
# missing row uuid
result = view.delete_item(batch, data)
self.assertEqual(result, {'error': "Must specify a row UUID"})
# row not found
with patch.dict(data, uuid=self.app.make_true_uuid()):
result = view.delete_item(batch, data)
self.assertEqual(result, {'error': "Row not found"})
# row for wrong batch
batch2 = handler.make_batch(self.session, created_by=user)
self.session.add(batch2)
row2 = handler.make_row(order_qty=1, order_uom=enum.ORDER_UOM_UNIT)
handler.add_row(batch2, row2)
self.session.flush()
with patch.dict(data, uuid=row2.uuid):
result = view.delete_item(batch, data)
self.assertEqual(result, {'error': "Row is for wrong batch"})
# row is deleted
data['uuid'] = row.uuid
self.assertEqual(len(batch.rows), 1)
self.assertEqual(batch.row_count, 1)
result = view.delete_item(batch, data)
self.assertEqual(sorted(result), ['batch'])
self.session.refresh(batch)
self.assertEqual(len(batch.rows), 0)
self.assertEqual(batch.row_count, 0)
def test_submit_new_order(self):
self.pyramid_config.add_route('orders.view', '/orders/{uuid}')
model = self.app.model
enum = self.app.enum
handler = NewOrderBatchHandler(self.config)
view = self.make_view()
user = model.User(username='barney')
self.session.add(user)
self.session.commit()
data = {
'pending_product': {
'scancode': '07430500132',
'brand_name': 'Bragg',
'description': 'Vinegar',
'size': '32oz',
'unit_price_reg': 5.99,
'case_size': 12,
},
'order_qty': 1,
'order_uom': enum.ORDER_UOM_CASE,
}
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.session.flush()
self.assertEqual(len(batch.rows), 0)
# add row w/ pending product
view.add_item(batch, data)
self.session.flush()
row = batch.rows[0]
self.assertIsInstance(row.pending_product, model.PendingProduct)
self.assertEqual(row.unit_price_quoted, decimal.Decimal('5.99'))
# execute not allowed yet (no customer)
result = view.submit_new_order(batch, {})
self.assertEqual(result, {'error': "Must assign the customer"})
# submit/execute ok
batch.customer_id = 42
result = view.submit_new_order(batch, {})
self.assertEqual(sorted(result), ['next_url'])
self.assertIn('/orders/', result['next_url'])
# error (already executed)
result = view.submit_new_order(batch, {})
self.assertEqual(result, {
'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
handler = NewOrderBatchHandler(self.config)
view = self.make_view()
user = model.User(username='barney')
self.session.add(user)
batch = handler.make_batch(self.session, created_by=user)
self.session.add(batch)
pending = {
'scancode': '07430500132',
'brand_name': 'Bragg',
'description': 'Vinegar',
'size': '32oz',
'unit_price_reg': 5.99,
'case_size': 12,
'created_by': user,
}
row = handler.add_pending_product(batch, pending, 1, enum.ORDER_UOM_CASE)
self.session.commit()
data = view.normalize_batch(batch)
self.assertEqual(data, {
'uuid': batch.uuid.hex,
'total_price': '71.880',
'total_price_display': '$71.88',
'status_code': None,
'status_text': None,
})
def test_normalize_row(self):
model = self.app.model
enum = self.app.enum
handler = NewOrderBatchHandler(self.config)
view = self.make_view()
user = model.User(username='barney')
self.session.add(user)
batch = handler.make_batch(self.session, created_by=user)
self.session.add(batch)
pending = {
'scancode': '07430500132',
'brand_name': 'Bragg',
'description': 'Vinegar',
'size': '32oz',
'unit_price_reg': 5.99,
'case_size': 12,
'created_by': user,
}
row = handler.add_pending_product(batch, pending, 2, enum.ORDER_UOM_CASE)
self.session.commit()
# normal
data = view.normalize_row(row)
self.assertIsInstance(data, dict)
self.assertEqual(data['uuid'], row.uuid.hex)
self.assertEqual(data['sequence'], 1)
self.assertEqual(data['product_scancode'], '07430500132')
self.assertEqual(data['case_size'], '12')
self.assertEqual(data['order_qty'], '2')
self.assertEqual(data['order_uom'], 'CS')
self.assertEqual(data['order_qty_display'], '2 Cases (&times; 12 = 24 Units)')
self.assertEqual(data['unit_price_reg'], 5.99)
self.assertEqual(data['unit_price_reg_display'], '$5.99')
self.assertNotIn('unit_price_sale', data)
self.assertNotIn('unit_price_sale_display', data)
self.assertNotIn('sale_ends', data)
self.assertNotIn('sale_ends_display', data)
self.assertEqual(data['unit_price_quoted'], 5.99)
self.assertEqual(data['unit_price_quoted_display'], '$5.99')
self.assertEqual(data['case_price_quoted'], 71.88)
self.assertEqual(data['case_price_quoted_display'], '$71.88')
self.assertEqual(data['total_price'], 143.76)
self.assertEqual(data['total_price_display'], '$143.76')
self.assertIsNone(data['special_order'])
self.assertEqual(data['status_code'], row.STATUS_OK)
self.assertEqual(data['pending_product'], {
'uuid': row.pending_product_uuid.hex,
'scancode': '07430500132',
'brand_name': 'Bragg',
'description': 'Vinegar',
'size': '32oz',
'department_id': None,
'department_name': None,
'unit_price_reg': 5.99,
'vendor_name': None,
'vendor_item_code': None,
'unit_cost': None,
'case_size': 12.0,
'notes': None,
'special_order': None,
})
# unknown case size
row.pending_product.case_size = None
handler.refresh_row(row)
self.session.flush()
data = view.normalize_row(row)
self.assertEqual(data['order_qty_display'], '2 Cases (&times; ?? = ?? Units)')
# order by unit
row.order_uom = enum.ORDER_UOM_UNIT
handler.refresh_row(row)
self.session.flush()
data = view.normalize_row(row)
self.assertEqual(data['order_qty_display'], '2 Units')
# item on sale
row.pending_product.case_size = 12
row.unit_price_sale = decimal.Decimal('5.19')
row.sale_ends = datetime.datetime(2025, 1, 5, 20, 32)
handler.refresh_row(row, now=datetime.datetime(2025, 1, 5, 19))
self.session.flush()
data = view.normalize_row(row)
self.assertEqual(data['unit_price_sale'], 5.19)
self.assertEqual(data['unit_price_sale_display'], '$5.19')
self.assertEqual(data['sale_ends'], '2025-01-05 20:32:00')
self.assertEqual(data['sale_ends_display'], '2025-01-05')
self.assertEqual(data['unit_price_quoted'], 5.19)
self.assertEqual(data['unit_price_quoted_display'], '$5.19')
self.assertEqual(data['case_price_quoted'], 62.28)
self.assertEqual(data['case_price_quoted_display'], '$62.28')
def test_get_instance_title(self):
model = self.app.model
view = self.make_view()
user = model.User(username='barney')
self.session.add(user)
order = model.Order(order_id=42, customer_name="Fred Flintstone", created_by=user)
self.session.add(order)
self.session.flush()
title = view.get_instance_title(order)
self.assertEqual(title, "#42 for Fred Flintstone")
def test_configure_form(self):
model = self.app.model
view = self.make_view()
user = model.User(username='barney')
self.session.add(user)
order = model.Order(order_id=42, created_by=user)
self.session.add(order)
self.session.commit()
# viewing
with patch.object(view, 'viewing', new=True):
form = view.make_form(model_instance=order)
# nb. this is to avoid include/exclude ambiguity
form.remove('items')
view.configure_form(form)
schema = form.get_schema()
self.assertIsInstance(schema['total_price'].typ, WuttaMoney)
def test_get_xref_buttons(self):
self.pyramid_config.add_route('neworder_batches.view', '/batch/neworder/{uuid}')
model = self.app.model
handler = NewOrderBatchHandler(self.config)
view = self.make_view()
user = model.User(username='barney')
self.session.add(user)
order = model.Order(order_id=42, created_by=user)
self.session.add(order)
self.session.flush()
with patch.object(view, 'Session', return_value=self.session):
# nb. this requires perm to view batch
with patch.object(self.request, 'is_root', new=True):
# order has no batch, so no buttons
buttons = view.get_xref_buttons(order)
self.assertEqual(buttons, [])
# mock up a batch to get a button
batch = handler.make_batch(self.session,
id=order.order_id,
created_by=user,
executed=datetime.datetime.now(),
executed_by=user)
self.session.add(batch)
self.session.flush()
buttons = view.get_xref_buttons(order)
self.assertEqual(len(buttons), 1)
button = buttons[0]
self.assertIn("View the Batch", button)
def test_get_row_grid_data(self):
model = self.app.model
enum = self.app.enum
view = self.make_view()
user = model.User(username='barney')
self.session.add(user)
order = model.Order(order_id=42, created_by=user)
self.session.add(order)
self.session.flush()
order.items.append(model.OrderItem(product_id='07430500132',
product_scancode='07430500132',
order_qty=1, order_uom=enum.ORDER_UOM_UNIT,
status_code=enum.ORDER_ITEM_STATUS_INITIATED))
self.session.flush()
with patch.object(view, 'Session', return_value=self.session):
query = view.get_row_grid_data(order)
self.assertIsInstance(query, orm.Query)
items = query.all()
self.assertEqual(len(items), 1)
self.assertEqual(items[0].product_scancode, '07430500132')
def test_configure_row_grid(self):
model = self.app.model
enum = self.app.enum
view = self.make_view()
user = model.User(username='barney')
self.session.add(user)
order = model.Order(order_id=42, created_by=user)
self.session.add(order)
self.session.flush()
order.items.append(model.OrderItem(product_id='07430500132',
product_scancode='07430500132',
order_qty=1, order_uom=enum.ORDER_UOM_UNIT,
status_code=enum.ORDER_ITEM_STATUS_INITIATED))
self.session.flush()
with patch.object(view, 'Session', return_value=self.session):
grid = view.make_grid(model_class=model.OrderItem, data=order.items)
self.assertNotIn('product_scancode', grid.linked_columns)
view.configure_row_grid(grid)
self.assertIn('product_scancode', grid.linked_columns)
def test_render_status_code(self):
enum = self.app.enum
view = self.make_view()
result = view.render_status_code(None, None, enum.ORDER_ITEM_STATUS_INITIATED)
self.assertEqual(result, "initiated")
self.assertEqual(result, enum.ORDER_ITEM_STATUS[enum.ORDER_ITEM_STATUS_INITIATED])
def test_get_row_action_url_view(self):
self.pyramid_config.add_route('order_items.view', '/order-items/{uuid}')
model = self.app.model
enum = self.app.enum
view = self.make_view()
user = model.User(username='barney')
self.session.add(user)
order = model.Order(order_id=42, created_by=user)
self.session.add(order)
self.session.flush()
item = model.OrderItem(product_id='07430500132',
product_scancode='07430500132',
order_qty=1, order_uom=enum.ORDER_UOM_UNIT,
status_code=enum.ORDER_ITEM_STATUS_INITIATED)
order.items.append(item)
self.session.flush()
url = view.get_row_action_url_view(item, 0)
self.assertIn(f'/order-items/{item.uuid}', url)
class TestOrderItemView(WebTestCase):
def make_view(self):
return mod.OrderItemView(self.request)
def test_get_query(self):
view = self.make_view()
query = view.get_query(session=self.session)
self.assertIsInstance(query, orm.Query)
def test_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_render_order_id(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)
def test_render_status_code(self):
enum = self.app.enum
view = self.make_view()
self.assertEqual(view.render_status_code(None, None, enum.ORDER_ITEM_STATUS_INITIATED),
'initiated')
def test_configure_form(self):
model = self.app.model
enum = self.app.enum
view = self.make_view()
item = model.OrderItem(status_code=enum.ORDER_ITEM_STATUS_INITIATED)
# viewing
with patch.object(view, 'viewing', new=True):
form = view.make_form(model_instance=item)
view.configure_form(form)
schema = form.get_schema()
self.assertIsInstance(schema['order'].typ, OrderRef)
def test_get_xref_buttons(self):
self.pyramid_config.add_route('orders.view', '/orders/{uuid}')
model = self.app.model
enum = self.app.enum
view = self.make_view()
user = model.User(username='barney')
self.session.add(user)
order = model.Order(order_id=42, created_by=user)
self.session.add(order)
item = model.OrderItem(order_qty=1, order_uom=enum.ORDER_UOM_UNIT,
status_code=enum.ORDER_ITEM_STATUS_INITIATED)
order.items.append(item)
self.session.flush()
# nb. this requires perms
with patch.object(self.request, 'is_root', new=True):
# one button by default
buttons = view.get_xref_buttons(item)
self.assertEqual(len(buttons), 1)
self.assertIn("View the Order", buttons[0])

View file

@ -0,0 +1,163 @@
# -*- coding: utf-8; -*-
import datetime
from unittest.mock import patch
from pyramid.httpexceptions import HTTPFound
from sideshow.testing import WebTestCase
from sideshow.web.views import products as mod
from sideshow.batch.neworder import NewOrderBatchHandler
class TestIncludeme(WebTestCase):
def test_coverage(self):
mod.includeme(self.pyramid_config)
class TestPendingProductView(WebTestCase):
def make_view(self):
return mod.PendingProductView(self.request)
def test_configure_grid(self):
model = self.app.model
view = self.make_view()
# nb. mostly just getting coverage here
grid = view.make_grid(model_class=model.PendingProduct)
self.assertNotIn('scancode', grid.linked_columns)
self.assertNotIn('brand_name', grid.linked_columns)
self.assertNotIn('description', grid.linked_columns)
view.configure_grid(grid)
self.assertIn('scancode', grid.linked_columns)
self.assertIn('brand_name', grid.linked_columns)
self.assertIn('description', grid.linked_columns)
def test_configure_form(self):
model = self.app.model
enum = self.app.enum
view = self.make_view()
# creating
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)
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.commit()
# viewing
with patch.object(view, 'viewing', new=True):
form = view.make_form(model_instance=product)
view.configure_form(form)
self.assertIn('status', form)
self.assertIn('created', form)
self.assertIn('created_by', form)
def test_make_orders_grid(self):
model = self.app.model
enum = self.app.enum
view = self.make_view()
user = model.User(username='barney')
self.session.add(user)
order = model.Order(order_id=42, customer_id=42, created_by=user)
product = model.PendingProduct(status=enum.PendingProductStatus.PENDING,
created_by=user)
self.session.add(product)
item = model.OrderItem(pending_product=product,
order_qty=1, order_uom=enum.ORDER_UOM_UNIT,
status_code=enum.ORDER_ITEM_STATUS_INITIATED)
order.items.append(item)
self.session.add(order)
self.session.commit()
# no view perm
grid = view.make_orders_grid(product)
self.assertEqual(len(grid.actions), 0)
# with view perm
with patch.object(self.request, 'is_root', new=True):
grid = view.make_orders_grid(product)
self.assertEqual(len(grid.actions), 1)
self.assertEqual(grid.actions[0].key, 'view')
def test_make_new_order_batches_grid(self):
model = self.app.model
enum = self.app.enum
handler = NewOrderBatchHandler(self.config)
view = self.make_view()
user = model.User(username='barney')
self.session.add(user)
batch = handler.make_batch(self.session, created_by=user)
self.session.add(batch)
product = model.PendingProduct(status=enum.PendingProductStatus.PENDING,
created_by=user)
self.session.add(product)
row = handler.make_row(pending_product=product,
order_qty=1, order_uom=enum.ORDER_UOM_UNIT)
handler.add_row(batch, row)
self.session.commit()
# no view perm
grid = view.make_new_order_batches_grid(product)
self.assertEqual(len(grid.actions), 0)
# with view perm
with patch.object(self.request, 'is_root', new=True):
grid = view.make_new_order_batches_grid(product)
self.assertEqual(len(grid.actions), 1)
self.assertEqual(grid.actions[0].key, 'view')
def test_delete_instance(self):
self.pyramid_config.add_route('pending_products.view', '/pending/products/{uuid}')
model = self.app.model
enum = self.app.enum
handler = NewOrderBatchHandler(self.config)
view = self.make_view()
user = model.User(username='barney')
self.session.add(user)
# 1st product is standalone, will be deleted
product = model.PendingProduct(status=enum.PendingProductStatus.PENDING,
created_by=user)
self.session.add(product)
self.session.flush()
self.assertEqual(self.session.query(model.PendingProduct).count(), 1)
view.delete_instance(product)
self.session.flush()
self.assertEqual(self.session.query(model.PendingProduct).count(), 0)
# 2nd product is attached to new order batch, will not be deleted
batch = handler.make_batch(self.session, created_by=user)
self.session.add(batch)
product = model.PendingProduct(status=enum.PendingProductStatus.PENDING,
created_by=user)
self.session.add(product)
row = handler.make_row(pending_product=product,
order_qty=1, order_uom=enum.ORDER_UOM_UNIT)
handler.add_row(batch, row)
self.session.flush()
self.assertEqual(self.session.query(model.PendingProduct).count(), 1)
self.assertRaises(HTTPFound, view.delete_instance, product)
self.session.flush()
self.assertEqual(self.session.query(model.PendingProduct).count(), 1)
# but after batch is executed, 2nd product can be deleted
batch.executed = datetime.datetime.now()
batch.executed_by = user
self.session.flush()
self.assertEqual(self.session.query(model.PendingProduct).count(), 1)
view.delete_instance(product)
self.session.flush()
self.assertEqual(self.session.query(model.PendingProduct).count(), 0)

17
tox.ini Normal file
View file

@ -0,0 +1,17 @@
[tox]
envlist = py38, py39, py310, py311
[testenv]
extras = tests
commands = pytest {posargs}
[testenv:coverage]
basepython = python3.11
commands = pytest --cov=sideshow --cov-report=html --cov-fail-under=100
[testenv:docs]
basepython = python3.11
extras = docs
changedir = docs
commands = sphinx-build -b html -d {envtmpdir}/doctrees -W -T . {envtmpdir}/docs