diff --git a/.gitignore b/.gitignore index ef8d4d1..f3d74a9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,2 @@ *.pyc *~ -.coverage -docs/_build/ -.tox/ diff --git a/docs/Makefile b/docs/Makefile deleted file mode 100644 index d4bb2cb..0000000 --- a/docs/Makefile +++ /dev/null @@ -1,20 +0,0 @@ -# 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 deleted file mode 100644 index 2904346..0000000 --- a/docs/api/sideshow.batch.neworder.rst +++ /dev/null @@ -1,6 +0,0 @@ - -``sideshow.batch.neworder`` -=========================== - -.. automodule:: sideshow.batch.neworder - :members: diff --git a/docs/api/sideshow.batch.rst b/docs/api/sideshow.batch.rst deleted file mode 100644 index 8492569..0000000 --- a/docs/api/sideshow.batch.rst +++ /dev/null @@ -1,6 +0,0 @@ - -``sideshow.batch`` -================== - -.. automodule:: sideshow.batch - :members: diff --git a/docs/api/sideshow.cli.base.rst b/docs/api/sideshow.cli.base.rst deleted file mode 100644 index b75f1b3..0000000 --- a/docs/api/sideshow.cli.base.rst +++ /dev/null @@ -1,6 +0,0 @@ - -``sideshow.cli.base`` -===================== - -.. automodule:: sideshow.cli.base - :members: diff --git a/docs/api/sideshow.cli.install.rst b/docs/api/sideshow.cli.install.rst deleted file mode 100644 index d48c5f8..0000000 --- a/docs/api/sideshow.cli.install.rst +++ /dev/null @@ -1,6 +0,0 @@ - -``sideshow.cli.install`` -======================== - -.. automodule:: sideshow.cli.install - :members: diff --git a/docs/api/sideshow.cli.rst b/docs/api/sideshow.cli.rst deleted file mode 100644 index 0ff2a39..0000000 --- a/docs/api/sideshow.cli.rst +++ /dev/null @@ -1,6 +0,0 @@ - -``sideshow.cli`` -================ - -.. automodule:: sideshow.cli - :members: diff --git a/docs/api/sideshow.config.rst b/docs/api/sideshow.config.rst deleted file mode 100644 index d8d0e0d..0000000 --- a/docs/api/sideshow.config.rst +++ /dev/null @@ -1,6 +0,0 @@ - -``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 deleted file mode 100644 index 384f955..0000000 --- a/docs/api/sideshow.db.model.batch.neworder.rst +++ /dev/null @@ -1,6 +0,0 @@ - -``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 deleted file mode 100644 index e4a1478..0000000 --- a/docs/api/sideshow.db.model.batch.rst +++ /dev/null @@ -1,6 +0,0 @@ - -``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 deleted file mode 100644 index 1dbe5c8..0000000 --- a/docs/api/sideshow.db.model.customers.rst +++ /dev/null @@ -1,6 +0,0 @@ - -``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 deleted file mode 100644 index 4d43849..0000000 --- a/docs/api/sideshow.db.model.orders.rst +++ /dev/null @@ -1,6 +0,0 @@ - -``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 deleted file mode 100644 index 2bbef30..0000000 --- a/docs/api/sideshow.db.model.products.rst +++ /dev/null @@ -1,6 +0,0 @@ - -``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 deleted file mode 100644 index 315e53c..0000000 --- a/docs/api/sideshow.db.model.rst +++ /dev/null @@ -1,6 +0,0 @@ - -``sideshow.db.model`` -===================== - -.. automodule:: sideshow.db.model - :members: diff --git a/docs/api/sideshow.db.rst b/docs/api/sideshow.db.rst deleted file mode 100644 index dd96400..0000000 --- a/docs/api/sideshow.db.rst +++ /dev/null @@ -1,6 +0,0 @@ - -``sideshow.db`` -=============== - -.. automodule:: sideshow.db - :members: diff --git a/docs/api/sideshow.enum.rst b/docs/api/sideshow.enum.rst deleted file mode 100644 index 912d951..0000000 --- a/docs/api/sideshow.enum.rst +++ /dev/null @@ -1,6 +0,0 @@ - -``sideshow.enum`` -================= - -.. automodule:: sideshow.enum - :members: diff --git a/docs/api/sideshow.rst b/docs/api/sideshow.rst deleted file mode 100644 index d1bc626..0000000 --- a/docs/api/sideshow.rst +++ /dev/null @@ -1,6 +0,0 @@ - -``sideshow`` -============ - -.. automodule:: sideshow - :members: diff --git a/docs/api/sideshow.web.app.rst b/docs/api/sideshow.web.app.rst deleted file mode 100644 index 2d204b9..0000000 --- a/docs/api/sideshow.web.app.rst +++ /dev/null @@ -1,6 +0,0 @@ - -``sideshow.web.app`` -==================== - -.. automodule:: sideshow.web.app - :members: diff --git a/docs/api/sideshow.web.forms.rst b/docs/api/sideshow.web.forms.rst deleted file mode 100644 index 2671ebd..0000000 --- a/docs/api/sideshow.web.forms.rst +++ /dev/null @@ -1,6 +0,0 @@ - -``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 deleted file mode 100644 index b7315a9..0000000 --- a/docs/api/sideshow.web.forms.schema.rst +++ /dev/null @@ -1,6 +0,0 @@ - -``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 deleted file mode 100644 index 2ad8a0c..0000000 --- a/docs/api/sideshow.web.menus.rst +++ /dev/null @@ -1,6 +0,0 @@ - -``sideshow.web.menus`` -====================== - -.. automodule:: sideshow.web.menus - :members: diff --git a/docs/api/sideshow.web.rst b/docs/api/sideshow.web.rst deleted file mode 100644 index 6dba72c..0000000 --- a/docs/api/sideshow.web.rst +++ /dev/null @@ -1,6 +0,0 @@ - -``sideshow.web`` -================ - -.. automodule:: sideshow.web - :members: diff --git a/docs/api/sideshow.web.static.rst b/docs/api/sideshow.web.static.rst deleted file mode 100644 index 0151e24..0000000 --- a/docs/api/sideshow.web.static.rst +++ /dev/null @@ -1,6 +0,0 @@ - -``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 deleted file mode 100644 index b31230d..0000000 --- a/docs/api/sideshow.web.views.batch.neworder.rst +++ /dev/null @@ -1,6 +0,0 @@ - -``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 deleted file mode 100644 index d44cb12..0000000 --- a/docs/api/sideshow.web.views.batch.rst +++ /dev/null @@ -1,6 +0,0 @@ - -``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 deleted file mode 100644 index 1b3a473..0000000 --- a/docs/api/sideshow.web.views.customers.rst +++ /dev/null @@ -1,6 +0,0 @@ - -``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 deleted file mode 100644 index 3ac59ff..0000000 --- a/docs/api/sideshow.web.views.orders.rst +++ /dev/null @@ -1,6 +0,0 @@ - -``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 deleted file mode 100644 index c1b43a1..0000000 --- a/docs/api/sideshow.web.views.products.rst +++ /dev/null @@ -1,6 +0,0 @@ - -``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 deleted file mode 100644 index 23e21b5..0000000 --- a/docs/api/sideshow.web.views.rst +++ /dev/null @@ -1,6 +0,0 @@ - -``sideshow.web.views`` -====================== - -.. automodule:: sideshow.web.views - :members: diff --git a/docs/conf.py b/docs/conf.py deleted file mode 100644 index bb7910f..0000000 --- a/docs/conf.py +++ /dev/null @@ -1,42 +0,0 @@ -# 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 deleted file mode 100644 index d639262..0000000 --- a/docs/glossary.rst +++ /dev/null @@ -1,36 +0,0 @@ - -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 deleted file mode 100644 index e1673e3..0000000 --- a/docs/index.rst +++ /dev/null @@ -1,53 +0,0 @@ - -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 deleted file mode 100644 index 32bb245..0000000 --- a/docs/make.bat +++ /dev/null @@ -1,35 +0,0 @@ -@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 deleted file mode 100644 index 7320b5d..0000000 --- a/docs/narr/cli/builtin.rst +++ /dev/null @@ -1,43 +0,0 @@ - -=================== - 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 deleted file mode 100644 index 8fbeb94..0000000 --- a/docs/narr/cli/index.rst +++ /dev/null @@ -1,14 +0,0 @@ - -============== - 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 9a610d2..d716f55 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,14 +32,9 @@ license = {text = "GNU General Public License v3+"} requires-python = ">= 3.8" dependencies = [ "psycopg2", - "WuttaWeb>=0.19.1", + "WuttaWeb", ] -[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 deleted file mode 100644 index c37d043..0000000 --- a/src/sideshow/__init__.py +++ /dev/null @@ -1,25 +0,0 @@ -# -*- 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 deleted file mode 100644 index e69de29..0000000 diff --git a/src/sideshow/batch/neworder.py b/src/sideshow/batch/neworder.py deleted file mode 100644 index 5ec9b8d..0000000 --- a/src/sideshow/batch/neworder.py +++ /dev/null @@ -1,471 +0,0 @@ -# -*- 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/cli.py b/src/sideshow/cli.py new file mode 100644 index 0000000..05905ee --- /dev/null +++ b/src/sideshow/cli.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8; -*- +""" +Sideshow CLI +""" + +import typer + +from wuttjamaican.cli import make_typer + + +sideshow_typer = make_typer( + name='sideshow', + help="Sideshow -- Case/Special Order Tracker" +) + + +@sideshow_typer.command() +def install( + ctx: typer.Context, +): + """ + Install the Sideshow app + """ + config = ctx.parent.wutta_config + app = config.get_app() + install = app.get_install_handler(pkg_name='sideshow', + app_title="Sideshow", + pypi_name='Sideshow', + egg_name='Sideshow') + install.run() diff --git a/src/sideshow/cli/__init__.py b/src/sideshow/cli/__init__.py deleted file mode 100644 index 3c66cf6..0000000 --- a/src/sideshow/cli/__init__.py +++ /dev/null @@ -1,36 +0,0 @@ -# -*- 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 - 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) -""" - -from .base import sideshow_typer - -# 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 deleted file mode 100644 index 63b430e..0000000 --- a/src/sideshow/cli/base.py +++ /dev/null @@ -1,40 +0,0 @@ -# -*- 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/install.py b/src/sideshow/cli/install.py deleted file mode 100644 index 065aa37..0000000 --- a/src/sideshow/cli/install.py +++ /dev/null @@ -1,45 +0,0 @@ -# -*- 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 . -# -################################################################################ -""" -See also: :ref:`sideshow-install` -""" - -import typer - -from .base import sideshow_typer - - -@sideshow_typer.command() -def install( - ctx: typer.Context, -): - """ - Install the Sideshow app - """ - config = ctx.parent.wutta_config - app = config.get_app() - install = app.get_install_handler(pkg_name='sideshow', - app_title="Sideshow", - pypi_name='Sideshow', - egg_name='Sideshow') - install.run() diff --git a/src/sideshow/config.py b/src/sideshow/config.py index 2a414aa..fb508d7 100644 --- a/src/sideshow/config.py +++ b/src/sideshow/config.py @@ -1,27 +1,6 @@ # -*- 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 config extension +Sideshow config extensions """ from wuttjamaican.conf import WuttaConfigExtension @@ -29,22 +8,18 @@ from wuttjamaican.conf import WuttaConfigExtension class SideshowConfig(WuttaConfigExtension): """ - Config extension for Sideshow. - - This establishes some config defaults specific to Sideshow. + Config extension for 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, enum + # app model 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 deleted file mode 100644 index da1d591..0000000 --- a/src/sideshow/db/alembic/versions/7a6df83afbd4_initial_order_tables.py +++ /dev/null @@ -1,203 +0,0 @@ -"""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 1395d59..1d66fe8 100644 --- a/src/sideshow/db/model/__init__.py +++ b/src/sideshow/db/model/__init__.py @@ -1,53 +1,9 @@ # -*- 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 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 * -# sideshow models -from .customers import PendingCustomer -from .products import PendingProduct -from .orders import Order, OrderItem - -# batch models -from .batch.neworder import NewOrderBatch, NewOrderBatchRow +# TODO: import other/custom models here... diff --git a/src/sideshow/db/model/batch/__init__.py b/src/sideshow/db/model/batch/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/sideshow/db/model/batch/neworder.py b/src/sideshow/db/model/batch/neworder.py deleted file mode 100644 index f121b5c..0000000 --- a/src/sideshow/db/model/batch/neworder.py +++ /dev/null @@ -1,310 +0,0 @@ -# -*- 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 deleted file mode 100644 index f845b30..0000000 --- a/src/sideshow/db/model/customers.py +++ /dev/null @@ -1,112 +0,0 @@ -# -*- 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 deleted file mode 100644 index c3392f7..0000000 --- a/src/sideshow/db/model/orders.py +++ /dev/null @@ -1,314 +0,0 @@ -# -*- 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 deleted file mode 100644 index 6113621..0000000 --- a/src/sideshow/db/model/products.py +++ /dev/null @@ -1,173 +0,0 @@ -# -*- 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 deleted file mode 100644 index 2bd1e1a..0000000 --- a/src/sideshow/enum.py +++ /dev/null @@ -1,146 +0,0 @@ -# -*- 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 deleted file mode 100644 index ee3cd64..0000000 --- a/src/sideshow/testing.py +++ /dev/null @@ -1,36 +0,0 @@ -# -*- 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 66ff8c3..4e189ee 100644 --- a/src/sideshow/web/app.py +++ b/src/sideshow/web/app.py @@ -1,25 +1,4 @@ # -*- 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 web app """ @@ -43,7 +22,7 @@ def main(global_config, **settings): # bring in the rest of Sideshow pyramid_config.include('sideshow.web.static') - pyramid_config.include('wuttaweb.subscribers') + pyramid_config.include('sideshow.web.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 deleted file mode 100644 index e69de29..0000000 diff --git a/src/sideshow/web/forms/schema.py b/src/sideshow/web/forms/schema.py deleted file mode 100644 index 4b78a4b..0000000 --- a/src/sideshow/web/forms/schema.py +++ /dev/null @@ -1,101 +0,0 @@ -# -*- 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 5feb017..32478e3 100644 --- a/src/sideshow/web/menus.py +++ b/src/sideshow/web/menus.py @@ -1,25 +1,4 @@ # -*- 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 Menu """ @@ -33,79 +12,15 @@ 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), - ] - 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', - }, - ], - } + # TODO: override this if you need custom menus... - 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', - }, - ], - } + # menus = [ + # self.make_products_menu(request), + # self.make_admin_menu(request), + # ] - 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', - }, - ], - } + # ...but for now this uses default menus + menus = super().make_menus(request, **kwargs) - def make_admin_menu(self, request, **kwargs): - """ """ - kwargs['include_people'] = True - return super().make_admin_menu(request, **kwargs) + return menus diff --git a/src/sideshow/web/static/__init__.py b/src/sideshow/web/static/__init__.py index 36dcc2f..0eb95ea 100644 --- a/src/sideshow/web/static/__init__.py +++ b/src/sideshow/web/static/__init__.py @@ -1,25 +1,4 @@ # -*- 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 . -# -################################################################################ """ Static assets """ diff --git a/src/sideshow/web/static/libcache/README b/src/sideshow/web/static/libcache/README new file mode 100644 index 0000000..f9b34c7 --- /dev/null +++ b/src/sideshow/web/static/libcache/README @@ -0,0 +1,2 @@ +Place files in this folder, which correspond to the Resource() +definitions found in `sideshow/web/static/__init__.py` diff --git a/src/sideshow/web/subscribers.py b/src/sideshow/web/subscribers.py new file mode 100644 index 0000000..72c15db --- /dev/null +++ b/src/sideshow/web/subscribers.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8; -*- +""" +Pyramid event subscribers +""" + +import sideshow + + +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') diff --git a/src/sideshow/web/templates/orders/create.mako b/src/sideshow/web/templates/orders/create.mako deleted file mode 100644 index 7763775..0000000 --- a/src/sideshow/web/templates/orders/create.mako +++ /dev/null @@ -1,1522 +0,0 @@ -## -*- 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 efcf397..d2f9f21 100644 --- a/src/sideshow/web/views/__init__.py +++ b/src/sideshow/web/views/__init__.py @@ -1,25 +1,4 @@ # -*- 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 Views """ @@ -30,10 +9,5 @@ def includeme(config): # core views for wuttaweb config.include('wuttaweb.views.essential') - # 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') + # TODO: include your own views here + #config.include('sideshow.web.views.widgets') diff --git a/src/sideshow/web/views/batch/__init__.py b/src/sideshow/web/views/batch/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/sideshow/web/views/batch/neworder.py b/src/sideshow/web/views/batch/neworder.py deleted file mode 100644 index 0c0aad5..0000000 --- a/src/sideshow/web/views/batch/neworder.py +++ /dev/null @@ -1,189 +0,0 @@ -# -*- 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 deleted file mode 100644 index 9d1720e..0000000 --- a/src/sideshow/web/views/customers.py +++ /dev/null @@ -1,246 +0,0 @@ -# -*- 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 deleted file mode 100644 index 76b49f3..0000000 --- a/src/sideshow/web/views/orders.py +++ /dev/null @@ -1,866 +0,0 @@ -# -*- 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 deleted file mode 100644 index 90b94ab..0000000 --- a/src/sideshow/web/views/products.py +++ /dev/null @@ -1,257 +0,0 @@ -# -*- 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 deleted file mode 100644 index e69de29..0000000 diff --git a/tests/batch/__init__.py b/tests/batch/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/batch/test_neworder.py b/tests/batch/test_neworder.py deleted file mode 100644 index 66e625e..0000000 --- a/tests/batch/test_neworder.py +++ /dev/null @@ -1,539 +0,0 @@ -# -*- 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 deleted file mode 100644 index e69de29..0000000 diff --git a/tests/cli/test_install.py b/tests/cli/test_install.py deleted file mode 100644 index 25effc1..0000000 --- a/tests/cli/test_install.py +++ /dev/null @@ -1,18 +0,0 @@ -# -*- 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 deleted file mode 100644 index e69de29..0000000 diff --git a/tests/db/model/__init__.py b/tests/db/model/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/db/model/batch/__init__.py b/tests/db/model/batch/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/db/model/batch/test_neworder.py b/tests/db/model/batch/test_neworder.py deleted file mode 100644 index 04992dc..0000000 --- a/tests/db/model/batch/test_neworder.py +++ /dev/null @@ -1,23 +0,0 @@ -# -*- 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 deleted file mode 100644 index 5e65923..0000000 --- a/tests/db/model/test_customers.py +++ /dev/null @@ -1,15 +0,0 @@ -# -*- 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 deleted file mode 100644 index b0ad9f4..0000000 --- a/tests/db/model/test_orders.py +++ /dev/null @@ -1,34 +0,0 @@ -# -*- 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 deleted file mode 100644 index 17ffbc3..0000000 --- a/tests/db/model/test_products.py +++ /dev/null @@ -1,44 +0,0 @@ -# -*- 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 deleted file mode 100644 index 403793f..0000000 --- a/tests/test_config.py +++ /dev/null @@ -1,17 +0,0 @@ -# -*- 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 deleted file mode 100644 index e69de29..0000000 diff --git a/tests/web/forms/test_schema.py b/tests/web/forms/test_schema.py deleted file mode 100644 index 38ff106..0000000 --- a/tests/web/forms/test_schema.py +++ /dev/null @@ -1,88 +0,0 @@ -# -*- 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 deleted file mode 100644 index 49f4cd4..0000000 --- a/tests/web/test_app.py +++ /dev/null @@ -1,34 +0,0 @@ -# -*- 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 deleted file mode 100644 index bff33cd..0000000 --- a/tests/web/test_menus.py +++ /dev/null @@ -1,12 +0,0 @@ -# -*- 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 deleted file mode 100644 index 27f52bf..0000000 --- a/tests/web/test_static.py +++ /dev/null @@ -1,10 +0,0 @@ -# -*- 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 deleted file mode 100644 index fbf2335..0000000 --- a/tests/web/views/batch/test_neworder.py +++ /dev/null @@ -1,101 +0,0 @@ -# -*- 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 deleted file mode 100644 index b8f1db1..0000000 --- a/tests/web/views/test_customers.py +++ /dev/null @@ -1,184 +0,0 @@ -# -*- 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 deleted file mode 100644 index 3925832..0000000 --- a/tests/web/views/test_orders.py +++ /dev/null @@ -1,902 +0,0 @@ -# -*- 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 deleted file mode 100644 index e7e61fe..0000000 --- a/tests/web/views/test_products.py +++ /dev/null @@ -1,163 +0,0 @@ -# -*- 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 deleted file mode 100644 index ac5e756..0000000 --- a/tox.ini +++ /dev/null @@ -1,17 +0,0 @@ - -[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