diff --git a/.gitignore b/.gitignore index f3d74a9..ef8d4d1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,5 @@ *.pyc *~ +.coverage +docs/_build/ +.tox/ diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..d4bb2cb --- /dev/null +++ b/docs/Makefile @@ -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) diff --git a/docs/api/sideshow.batch.neworder.rst b/docs/api/sideshow.batch.neworder.rst new file mode 100644 index 0000000..2904346 --- /dev/null +++ b/docs/api/sideshow.batch.neworder.rst @@ -0,0 +1,6 @@ + +``sideshow.batch.neworder`` +=========================== + +.. automodule:: sideshow.batch.neworder + :members: diff --git a/docs/api/sideshow.batch.rst b/docs/api/sideshow.batch.rst new file mode 100644 index 0000000..8492569 --- /dev/null +++ b/docs/api/sideshow.batch.rst @@ -0,0 +1,6 @@ + +``sideshow.batch`` +================== + +.. automodule:: sideshow.batch + :members: diff --git a/docs/api/sideshow.cli.base.rst b/docs/api/sideshow.cli.base.rst new file mode 100644 index 0000000..b75f1b3 --- /dev/null +++ b/docs/api/sideshow.cli.base.rst @@ -0,0 +1,6 @@ + +``sideshow.cli.base`` +===================== + +.. automodule:: sideshow.cli.base + :members: diff --git a/docs/api/sideshow.cli.install.rst b/docs/api/sideshow.cli.install.rst new file mode 100644 index 0000000..d48c5f8 --- /dev/null +++ b/docs/api/sideshow.cli.install.rst @@ -0,0 +1,6 @@ + +``sideshow.cli.install`` +======================== + +.. automodule:: sideshow.cli.install + :members: diff --git a/docs/api/sideshow.cli.rst b/docs/api/sideshow.cli.rst new file mode 100644 index 0000000..0ff2a39 --- /dev/null +++ b/docs/api/sideshow.cli.rst @@ -0,0 +1,6 @@ + +``sideshow.cli`` +================ + +.. automodule:: sideshow.cli + :members: diff --git a/docs/api/sideshow.config.rst b/docs/api/sideshow.config.rst new file mode 100644 index 0000000..d8d0e0d --- /dev/null +++ b/docs/api/sideshow.config.rst @@ -0,0 +1,6 @@ + +``sideshow.config`` +=================== + +.. automodule:: sideshow.config + :members: diff --git a/docs/api/sideshow.db.model.batch.neworder.rst b/docs/api/sideshow.db.model.batch.neworder.rst new file mode 100644 index 0000000..384f955 --- /dev/null +++ b/docs/api/sideshow.db.model.batch.neworder.rst @@ -0,0 +1,6 @@ + +``sideshow.db.model.batch.neworder`` +==================================== + +.. automodule:: sideshow.db.model.batch.neworder + :members: diff --git a/docs/api/sideshow.db.model.batch.rst b/docs/api/sideshow.db.model.batch.rst new file mode 100644 index 0000000..e4a1478 --- /dev/null +++ b/docs/api/sideshow.db.model.batch.rst @@ -0,0 +1,6 @@ + +``sideshow.db.model.batch`` +=========================== + +.. automodule:: sideshow.db.model.batch + :members: diff --git a/docs/api/sideshow.db.model.customers.rst b/docs/api/sideshow.db.model.customers.rst new file mode 100644 index 0000000..1dbe5c8 --- /dev/null +++ b/docs/api/sideshow.db.model.customers.rst @@ -0,0 +1,6 @@ + +``sideshow.db.model.customers`` +=============================== + +.. automodule:: sideshow.db.model.customers + :members: diff --git a/docs/api/sideshow.db.model.orders.rst b/docs/api/sideshow.db.model.orders.rst new file mode 100644 index 0000000..4d43849 --- /dev/null +++ b/docs/api/sideshow.db.model.orders.rst @@ -0,0 +1,6 @@ + +``sideshow.db.model.orders`` +============================ + +.. automodule:: sideshow.db.model.orders + :members: diff --git a/docs/api/sideshow.db.model.products.rst b/docs/api/sideshow.db.model.products.rst new file mode 100644 index 0000000..2bbef30 --- /dev/null +++ b/docs/api/sideshow.db.model.products.rst @@ -0,0 +1,6 @@ + +``sideshow.db.model.products`` +============================== + +.. automodule:: sideshow.db.model.products + :members: diff --git a/docs/api/sideshow.db.model.rst b/docs/api/sideshow.db.model.rst new file mode 100644 index 0000000..315e53c --- /dev/null +++ b/docs/api/sideshow.db.model.rst @@ -0,0 +1,6 @@ + +``sideshow.db.model`` +===================== + +.. automodule:: sideshow.db.model + :members: diff --git a/docs/api/sideshow.db.rst b/docs/api/sideshow.db.rst new file mode 100644 index 0000000..dd96400 --- /dev/null +++ b/docs/api/sideshow.db.rst @@ -0,0 +1,6 @@ + +``sideshow.db`` +=============== + +.. automodule:: sideshow.db + :members: diff --git a/docs/api/sideshow.enum.rst b/docs/api/sideshow.enum.rst new file mode 100644 index 0000000..912d951 --- /dev/null +++ b/docs/api/sideshow.enum.rst @@ -0,0 +1,6 @@ + +``sideshow.enum`` +================= + +.. automodule:: sideshow.enum + :members: diff --git a/docs/api/sideshow.rst b/docs/api/sideshow.rst new file mode 100644 index 0000000..d1bc626 --- /dev/null +++ b/docs/api/sideshow.rst @@ -0,0 +1,6 @@ + +``sideshow`` +============ + +.. automodule:: sideshow + :members: diff --git a/docs/api/sideshow.web.app.rst b/docs/api/sideshow.web.app.rst new file mode 100644 index 0000000..2d204b9 --- /dev/null +++ b/docs/api/sideshow.web.app.rst @@ -0,0 +1,6 @@ + +``sideshow.web.app`` +==================== + +.. automodule:: sideshow.web.app + :members: diff --git a/docs/api/sideshow.web.forms.rst b/docs/api/sideshow.web.forms.rst new file mode 100644 index 0000000..2671ebd --- /dev/null +++ b/docs/api/sideshow.web.forms.rst @@ -0,0 +1,6 @@ + +``sideshow.web.forms`` +====================== + +.. automodule:: sideshow.web.forms + :members: diff --git a/docs/api/sideshow.web.forms.schema.rst b/docs/api/sideshow.web.forms.schema.rst new file mode 100644 index 0000000..b7315a9 --- /dev/null +++ b/docs/api/sideshow.web.forms.schema.rst @@ -0,0 +1,6 @@ + +``sideshow.web.forms.schema`` +============================= + +.. automodule:: sideshow.web.forms.schema + :members: diff --git a/docs/api/sideshow.web.menus.rst b/docs/api/sideshow.web.menus.rst new file mode 100644 index 0000000..2ad8a0c --- /dev/null +++ b/docs/api/sideshow.web.menus.rst @@ -0,0 +1,6 @@ + +``sideshow.web.menus`` +====================== + +.. automodule:: sideshow.web.menus + :members: diff --git a/docs/api/sideshow.web.rst b/docs/api/sideshow.web.rst new file mode 100644 index 0000000..6dba72c --- /dev/null +++ b/docs/api/sideshow.web.rst @@ -0,0 +1,6 @@ + +``sideshow.web`` +================ + +.. automodule:: sideshow.web + :members: diff --git a/docs/api/sideshow.web.static.rst b/docs/api/sideshow.web.static.rst new file mode 100644 index 0000000..0151e24 --- /dev/null +++ b/docs/api/sideshow.web.static.rst @@ -0,0 +1,6 @@ + +``sideshow.web.static`` +======================= + +.. automodule:: sideshow.web.static + :members: diff --git a/docs/api/sideshow.web.views.batch.neworder.rst b/docs/api/sideshow.web.views.batch.neworder.rst new file mode 100644 index 0000000..b31230d --- /dev/null +++ b/docs/api/sideshow.web.views.batch.neworder.rst @@ -0,0 +1,6 @@ + +``sideshow.web.views.batch.neworder`` +===================================== + +.. automodule:: sideshow.web.views.batch.neworder + :members: diff --git a/docs/api/sideshow.web.views.batch.rst b/docs/api/sideshow.web.views.batch.rst new file mode 100644 index 0000000..d44cb12 --- /dev/null +++ b/docs/api/sideshow.web.views.batch.rst @@ -0,0 +1,6 @@ + +``sideshow.web.views.batch`` +============================ + +.. automodule:: sideshow.web.views.batch + :members: diff --git a/docs/api/sideshow.web.views.customers.rst b/docs/api/sideshow.web.views.customers.rst new file mode 100644 index 0000000..1b3a473 --- /dev/null +++ b/docs/api/sideshow.web.views.customers.rst @@ -0,0 +1,6 @@ + +``sideshow.web.views.customers`` +================================ + +.. automodule:: sideshow.web.views.customers + :members: diff --git a/docs/api/sideshow.web.views.orders.rst b/docs/api/sideshow.web.views.orders.rst new file mode 100644 index 0000000..3ac59ff --- /dev/null +++ b/docs/api/sideshow.web.views.orders.rst @@ -0,0 +1,6 @@ + +``sideshow.web.views.orders`` +============================= + +.. automodule:: sideshow.web.views.orders + :members: diff --git a/docs/api/sideshow.web.views.products.rst b/docs/api/sideshow.web.views.products.rst new file mode 100644 index 0000000..c1b43a1 --- /dev/null +++ b/docs/api/sideshow.web.views.products.rst @@ -0,0 +1,6 @@ + +``sideshow.web.views.products`` +=============================== + +.. automodule:: sideshow.web.views.products + :members: diff --git a/docs/api/sideshow.web.views.rst b/docs/api/sideshow.web.views.rst new file mode 100644 index 0000000..23e21b5 --- /dev/null +++ b/docs/api/sideshow.web.views.rst @@ -0,0 +1,6 @@ + +``sideshow.web.views`` +====================== + +.. automodule:: sideshow.web.views + :members: diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..bb7910f --- /dev/null +++ b/docs/conf.py @@ -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'] diff --git a/docs/glossary.rst b/docs/glossary.rst new file mode 100644 index 0000000..d639262 --- /dev/null +++ b/docs/glossary.rst @@ -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 + 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. diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000..e1673e3 --- /dev/null +++ b/docs/index.rst @@ -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 diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..32bb245 --- /dev/null +++ b/docs/make.bat @@ -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 diff --git a/docs/narr/cli/builtin.rst b/docs/narr/cli/builtin.rst new file mode 100644 index 0000000..7320b5d --- /dev/null +++ b/docs/narr/cli/builtin.rst @@ -0,0 +1,43 @@ + +=================== + Built-in Commands +=================== + +Sideshow comes with one top-level :term:`command`, and some +:term:`subcommands`. + +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 diff --git a/docs/narr/cli/index.rst b/docs/narr/cli/index.rst new file mode 100644 index 0000000..8fbeb94 --- /dev/null +++ b/docs/narr/cli/index.rst @@ -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 diff --git a/pyproject.toml b/pyproject.toml index d716f55..9a610d2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/src/sideshow/__init__.py b/src/sideshow/__init__.py new file mode 100644 index 0000000..c37d043 --- /dev/null +++ b/src/sideshow/__init__.py @@ -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 . +# +################################################################################ +""" +Sideshow - Case/Special Order Tracker +""" diff --git a/src/sideshow/batch/__init__.py b/src/sideshow/batch/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/sideshow/batch/neworder.py b/src/sideshow/batch/neworder.py new file mode 100644 index 0000000..5ec9b8d --- /dev/null +++ b/src/sideshow/batch/neworder.py @@ -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 . +# +################################################################################ +""" +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 `. 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 `. + + :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 diff --git a/src/sideshow/web/subscribers.py b/src/sideshow/cli/__init__.py similarity index 76% rename from src/sideshow/web/subscribers.py rename to src/sideshow/cli/__init__.py index b2c81ca..3c66cf6 100644 --- a/src/sideshow/web/subscribers.py +++ b/src/sideshow/cli/__init__.py @@ -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 diff --git a/src/sideshow/cli/base.py b/src/sideshow/cli/base.py new file mode 100644 index 0000000..63b430e --- /dev/null +++ b/src/sideshow/cli/base.py @@ -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 . +# +################################################################################ +""" +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" +) diff --git a/src/sideshow/cli.py b/src/sideshow/cli/install.py similarity index 89% rename from src/sideshow/cli.py rename to src/sideshow/cli/install.py index 69d5e47..065aa37 100644 --- a/src/sideshow/cli.py +++ b/src/sideshow/cli/install.py @@ -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() diff --git a/src/sideshow/config.py b/src/sideshow/config.py index 94946e9..2a414aa 100644 --- a/src/sideshow/config.py +++ b/src/sideshow/config.py @@ -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', diff --git a/src/sideshow/db/alembic/versions/7a6df83afbd4_initial_order_tables.py b/src/sideshow/db/alembic/versions/7a6df83afbd4_initial_order_tables.py new file mode 100644 index 0000000..da1d591 --- /dev/null +++ b/src/sideshow/db/alembic/versions/7a6df83afbd4_initial_order_tables.py @@ -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()) diff --git a/src/sideshow/db/model/__init__.py b/src/sideshow/db/model/__init__.py index 6fe442d..1395d59 100644 --- a/src/sideshow/db/model/__init__.py +++ b/src/sideshow/db/model/__init__.py @@ -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 `: + +* :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 diff --git a/src/sideshow/db/model/batch/__init__.py b/src/sideshow/db/model/batch/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/sideshow/db/model/batch/neworder.py b/src/sideshow/db/model/batch/neworder.py new file mode 100644 index 0000000..f121b5c --- /dev/null +++ b/src/sideshow/db/model/batch/neworder.py @@ -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 . +# +################################################################################ +""" +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 ` used for entering new :term:`orders ` + 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 "") diff --git a/src/sideshow/db/model/customers.py b/src/sideshow/db/model/customers.py new file mode 100644 index 0000000..f845b30 --- /dev/null +++ b/src/sideshow/db/model/customers.py @@ -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 . +# +################################################################################ +""" +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 "" diff --git a/src/sideshow/db/model/orders.py b/src/sideshow/db/model/orders.py new file mode 100644 index 0000000..c3392f7 --- /dev/null +++ b/src/sideshow/db/model/orders.py @@ -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 . +# +################################################################################ +""" +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 "") diff --git a/src/sideshow/db/model/products.py b/src/sideshow/db/model/products.py new file mode 100644 index 0000000..6113621 --- /dev/null +++ b/src/sideshow/db/model/products.py @@ -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 . +# +################################################################################ +""" +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 diff --git a/src/sideshow/enum.py b/src/sideshow/enum.py new file mode 100644 index 0000000..2bd1e1a --- /dev/null +++ b/src/sideshow/enum.py @@ -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 . +# +################################################################################ +""" +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"), +]) diff --git a/src/sideshow/testing.py b/src/sideshow/testing.py new file mode 100644 index 0000000..ee3cd64 --- /dev/null +++ b/src/sideshow/testing.py @@ -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 . +# +################################################################################ +""" +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 diff --git a/src/sideshow/web/app.py b/src/sideshow/web/app.py index a4a20b1..66ff8c3 100644 --- a/src/sideshow/web/app.py +++ b/src/sideshow/web/app.py @@ -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() diff --git a/src/sideshow/web/forms/__init__.py b/src/sideshow/web/forms/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/sideshow/web/forms/schema.py b/src/sideshow/web/forms/schema.py new file mode 100644 index 0000000..4b78a4b --- /dev/null +++ b/src/sideshow/web/forms/schema.py @@ -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 . +# +################################################################################ +""" +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) diff --git a/src/sideshow/web/menus.py b/src/sideshow/web/menus.py index 9ec651d..5feb017 100644 --- a/src/sideshow/web/menus.py +++ b/src/sideshow/web/menus.py @@ -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) diff --git a/src/sideshow/web/templates/orders/create.mako b/src/sideshow/web/templates/orders/create.mako new file mode 100644 index 0000000..7763775 --- /dev/null +++ b/src/sideshow/web/templates/orders/create.mako @@ -0,0 +1,1522 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/create.mako" /> + +<%def name="page_content()"> +
+ + + +<%def name="order_form_buttons()"> +
+
+
+
+
+
+ + {{ submittingOrder ? "Working, please wait..." : "Submit this Order" }} + + + Start Over Entirely + + + Cancel this Order + +
+
+
+
+ + +<%def name="render_vue_templates()"> + ${parent.render_vue_templates()} + + + + +<%def name="make_vue_components()"> + ${parent.make_vue_components()} + + diff --git a/src/sideshow/web/views/__init__.py b/src/sideshow/web/views/__init__.py index be0bdb4..efcf397 100644 --- a/src/sideshow/web/views/__init__.py +++ b/src/sideshow/web/views/__init__.py @@ -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') diff --git a/src/sideshow/web/views/batch/__init__.py b/src/sideshow/web/views/batch/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/sideshow/web/views/batch/neworder.py b/src/sideshow/web/views/batch/neworder.py new file mode 100644 index 0000000..0c0aad5 --- /dev/null +++ b/src/sideshow/web/views/batch/neworder.py @@ -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 . +# +################################################################################ +""" +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) diff --git a/src/sideshow/web/views/customers.py b/src/sideshow/web/views/customers.py new file mode 100644 index 0000000..9d1720e --- /dev/null +++ b/src/sideshow/web/views/customers.py @@ -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 . +# +################################################################################ +""" +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) diff --git a/src/sideshow/web/views/orders.py b/src/sideshow/web/views/orders.py new file mode 100644 index 0000000..76b49f3 --- /dev/null +++ b/src/sideshow/web/views/orders.py @@ -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 . +# +################################################################################ +""" +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"(× {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) diff --git a/src/sideshow/web/views/products.py b/src/sideshow/web/views/products.py new file mode 100644 index 0000000..90b94ab --- /dev/null +++ b/src/sideshow/web/views/products.py @@ -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 . +# +################################################################################ +""" +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) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/batch/__init__.py b/tests/batch/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/batch/test_neworder.py b/tests/batch/test_neworder.py new file mode 100644 index 0000000..66e625e --- /dev/null +++ b/tests/batch/test_neworder.py @@ -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')) diff --git a/tests/cli/__init__.py b/tests/cli/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/cli/test_install.py b/tests/cli/test_install.py new file mode 100644 index 0000000..25effc1 --- /dev/null +++ b/tests/cli/test_install.py @@ -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() diff --git a/tests/db/__init__.py b/tests/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/db/model/__init__.py b/tests/db/model/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/db/model/batch/__init__.py b/tests/db/model/batch/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/db/model/batch/test_neworder.py b/tests/db/model/batch/test_neworder.py new file mode 100644 index 0000000..04992dc --- /dev/null +++ b/tests/db/model/batch/test_neworder.py @@ -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") diff --git a/tests/db/model/test_customers.py b/tests/db/model/test_customers.py new file mode 100644 index 0000000..5e65923 --- /dev/null +++ b/tests/db/model/test_customers.py @@ -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") diff --git a/tests/db/model/test_orders.py b/tests/db/model/test_orders.py new file mode 100644 index 0000000..b0ad9f4 --- /dev/null +++ b/tests/db/model/test_orders.py @@ -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") diff --git a/tests/db/model/test_products.py b/tests/db/model/test_products.py new file mode 100644 index 0000000..17ffbc3 --- /dev/null +++ b/tests/db/model/test_products.py @@ -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") diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..403793f --- /dev/null +++ b/tests/test_config.py @@ -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") diff --git a/tests/web/example.conf b/tests/web/example.conf new file mode 100644 index 0000000..e69de29 diff --git a/tests/web/forms/test_schema.py b/tests/web/forms/test_schema.py new file mode 100644 index 0000000..38ff106 --- /dev/null +++ b/tests/web/forms/test_schema.py @@ -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) diff --git a/tests/web/test_app.py b/tests/web/test_app.py new file mode 100644 index 0000000..49f4cd4 --- /dev/null +++ b/tests/web/test_app.py @@ -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) diff --git a/tests/web/test_menus.py b/tests/web/test_menus.py new file mode 100644 index 0000000..bff33cd --- /dev/null +++ b/tests/web/test_menus.py @@ -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) diff --git a/tests/web/test_static.py b/tests/web/test_static.py new file mode 100644 index 0000000..27f52bf --- /dev/null +++ b/tests/web/test_static.py @@ -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) diff --git a/tests/web/views/batch/test_neworder.py b/tests/web/views/batch/test_neworder.py new file mode 100644 index 0000000..fbf2335 --- /dev/null +++ b/tests/web/views/batch/test_neworder.py @@ -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) diff --git a/tests/web/views/test_customers.py b/tests/web/views/test_customers.py new file mode 100644 index 0000000..b8f1db1 --- /dev/null +++ b/tests/web/views/test_customers.py @@ -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) diff --git a/tests/web/views/test_orders.py b/tests/web/views/test_orders.py new file mode 100644 index 0000000..3925832 --- /dev/null +++ b/tests/web/views/test_orders.py @@ -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 (× 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 (× ?? = ?? 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]) diff --git a/tests/web/views/test_products.py b/tests/web/views/test_products.py new file mode 100644 index 0000000..e7e61fe --- /dev/null +++ b/tests/web/views/test_products.py @@ -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) diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..ac5e756 --- /dev/null +++ b/tox.ini @@ -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