feat: add basic "create order" feature, docs, tests
just the package API docs so far, narrative will come later
This commit is contained in:
parent
89265f0240
commit
ef07d30a85
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -1,2 +1,5 @@
|
||||||
*.pyc
|
*.pyc
|
||||||
*~
|
*~
|
||||||
|
.coverage
|
||||||
|
docs/_build/
|
||||||
|
.tox/
|
||||||
|
|
20
docs/Makefile
Normal file
20
docs/Makefile
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
# Minimal makefile for Sphinx documentation
|
||||||
|
#
|
||||||
|
|
||||||
|
# You can set these variables from the command line, and also
|
||||||
|
# from the environment for the first two.
|
||||||
|
SPHINXOPTS ?=
|
||||||
|
SPHINXBUILD ?= sphinx-build
|
||||||
|
SOURCEDIR = .
|
||||||
|
BUILDDIR = _build
|
||||||
|
|
||||||
|
# Put it first so that "make" without argument is like "make help".
|
||||||
|
help:
|
||||||
|
@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
|
||||||
|
|
||||||
|
.PHONY: help Makefile
|
||||||
|
|
||||||
|
# Catch-all target: route all unknown targets to Sphinx using the new
|
||||||
|
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
|
||||||
|
%: Makefile
|
||||||
|
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
|
6
docs/api/sideshow.batch.neworder.rst
Normal file
6
docs/api/sideshow.batch.neworder.rst
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
|
||||||
|
``sideshow.batch.neworder``
|
||||||
|
===========================
|
||||||
|
|
||||||
|
.. automodule:: sideshow.batch.neworder
|
||||||
|
:members:
|
6
docs/api/sideshow.batch.rst
Normal file
6
docs/api/sideshow.batch.rst
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
|
||||||
|
``sideshow.batch``
|
||||||
|
==================
|
||||||
|
|
||||||
|
.. automodule:: sideshow.batch
|
||||||
|
:members:
|
6
docs/api/sideshow.cli.base.rst
Normal file
6
docs/api/sideshow.cli.base.rst
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
|
||||||
|
``sideshow.cli.base``
|
||||||
|
=====================
|
||||||
|
|
||||||
|
.. automodule:: sideshow.cli.base
|
||||||
|
:members:
|
6
docs/api/sideshow.cli.install.rst
Normal file
6
docs/api/sideshow.cli.install.rst
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
|
||||||
|
``sideshow.cli.install``
|
||||||
|
========================
|
||||||
|
|
||||||
|
.. automodule:: sideshow.cli.install
|
||||||
|
:members:
|
6
docs/api/sideshow.cli.rst
Normal file
6
docs/api/sideshow.cli.rst
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
|
||||||
|
``sideshow.cli``
|
||||||
|
================
|
||||||
|
|
||||||
|
.. automodule:: sideshow.cli
|
||||||
|
:members:
|
6
docs/api/sideshow.config.rst
Normal file
6
docs/api/sideshow.config.rst
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
|
||||||
|
``sideshow.config``
|
||||||
|
===================
|
||||||
|
|
||||||
|
.. automodule:: sideshow.config
|
||||||
|
:members:
|
6
docs/api/sideshow.db.model.batch.neworder.rst
Normal file
6
docs/api/sideshow.db.model.batch.neworder.rst
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
|
||||||
|
``sideshow.db.model.batch.neworder``
|
||||||
|
====================================
|
||||||
|
|
||||||
|
.. automodule:: sideshow.db.model.batch.neworder
|
||||||
|
:members:
|
6
docs/api/sideshow.db.model.batch.rst
Normal file
6
docs/api/sideshow.db.model.batch.rst
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
|
||||||
|
``sideshow.db.model.batch``
|
||||||
|
===========================
|
||||||
|
|
||||||
|
.. automodule:: sideshow.db.model.batch
|
||||||
|
:members:
|
6
docs/api/sideshow.db.model.customers.rst
Normal file
6
docs/api/sideshow.db.model.customers.rst
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
|
||||||
|
``sideshow.db.model.customers``
|
||||||
|
===============================
|
||||||
|
|
||||||
|
.. automodule:: sideshow.db.model.customers
|
||||||
|
:members:
|
6
docs/api/sideshow.db.model.orders.rst
Normal file
6
docs/api/sideshow.db.model.orders.rst
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
|
||||||
|
``sideshow.db.model.orders``
|
||||||
|
============================
|
||||||
|
|
||||||
|
.. automodule:: sideshow.db.model.orders
|
||||||
|
:members:
|
6
docs/api/sideshow.db.model.products.rst
Normal file
6
docs/api/sideshow.db.model.products.rst
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
|
||||||
|
``sideshow.db.model.products``
|
||||||
|
==============================
|
||||||
|
|
||||||
|
.. automodule:: sideshow.db.model.products
|
||||||
|
:members:
|
6
docs/api/sideshow.db.model.rst
Normal file
6
docs/api/sideshow.db.model.rst
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
|
||||||
|
``sideshow.db.model``
|
||||||
|
=====================
|
||||||
|
|
||||||
|
.. automodule:: sideshow.db.model
|
||||||
|
:members:
|
6
docs/api/sideshow.db.rst
Normal file
6
docs/api/sideshow.db.rst
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
|
||||||
|
``sideshow.db``
|
||||||
|
===============
|
||||||
|
|
||||||
|
.. automodule:: sideshow.db
|
||||||
|
:members:
|
6
docs/api/sideshow.enum.rst
Normal file
6
docs/api/sideshow.enum.rst
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
|
||||||
|
``sideshow.enum``
|
||||||
|
=================
|
||||||
|
|
||||||
|
.. automodule:: sideshow.enum
|
||||||
|
:members:
|
6
docs/api/sideshow.rst
Normal file
6
docs/api/sideshow.rst
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
|
||||||
|
``sideshow``
|
||||||
|
============
|
||||||
|
|
||||||
|
.. automodule:: sideshow
|
||||||
|
:members:
|
6
docs/api/sideshow.web.app.rst
Normal file
6
docs/api/sideshow.web.app.rst
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
|
||||||
|
``sideshow.web.app``
|
||||||
|
====================
|
||||||
|
|
||||||
|
.. automodule:: sideshow.web.app
|
||||||
|
:members:
|
6
docs/api/sideshow.web.forms.rst
Normal file
6
docs/api/sideshow.web.forms.rst
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
|
||||||
|
``sideshow.web.forms``
|
||||||
|
======================
|
||||||
|
|
||||||
|
.. automodule:: sideshow.web.forms
|
||||||
|
:members:
|
6
docs/api/sideshow.web.forms.schema.rst
Normal file
6
docs/api/sideshow.web.forms.schema.rst
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
|
||||||
|
``sideshow.web.forms.schema``
|
||||||
|
=============================
|
||||||
|
|
||||||
|
.. automodule:: sideshow.web.forms.schema
|
||||||
|
:members:
|
6
docs/api/sideshow.web.menus.rst
Normal file
6
docs/api/sideshow.web.menus.rst
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
|
||||||
|
``sideshow.web.menus``
|
||||||
|
======================
|
||||||
|
|
||||||
|
.. automodule:: sideshow.web.menus
|
||||||
|
:members:
|
6
docs/api/sideshow.web.rst
Normal file
6
docs/api/sideshow.web.rst
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
|
||||||
|
``sideshow.web``
|
||||||
|
================
|
||||||
|
|
||||||
|
.. automodule:: sideshow.web
|
||||||
|
:members:
|
6
docs/api/sideshow.web.static.rst
Normal file
6
docs/api/sideshow.web.static.rst
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
|
||||||
|
``sideshow.web.static``
|
||||||
|
=======================
|
||||||
|
|
||||||
|
.. automodule:: sideshow.web.static
|
||||||
|
:members:
|
6
docs/api/sideshow.web.views.batch.neworder.rst
Normal file
6
docs/api/sideshow.web.views.batch.neworder.rst
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
|
||||||
|
``sideshow.web.views.batch.neworder``
|
||||||
|
=====================================
|
||||||
|
|
||||||
|
.. automodule:: sideshow.web.views.batch.neworder
|
||||||
|
:members:
|
6
docs/api/sideshow.web.views.batch.rst
Normal file
6
docs/api/sideshow.web.views.batch.rst
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
|
||||||
|
``sideshow.web.views.batch``
|
||||||
|
============================
|
||||||
|
|
||||||
|
.. automodule:: sideshow.web.views.batch
|
||||||
|
:members:
|
6
docs/api/sideshow.web.views.customers.rst
Normal file
6
docs/api/sideshow.web.views.customers.rst
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
|
||||||
|
``sideshow.web.views.customers``
|
||||||
|
================================
|
||||||
|
|
||||||
|
.. automodule:: sideshow.web.views.customers
|
||||||
|
:members:
|
6
docs/api/sideshow.web.views.orders.rst
Normal file
6
docs/api/sideshow.web.views.orders.rst
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
|
||||||
|
``sideshow.web.views.orders``
|
||||||
|
=============================
|
||||||
|
|
||||||
|
.. automodule:: sideshow.web.views.orders
|
||||||
|
:members:
|
6
docs/api/sideshow.web.views.products.rst
Normal file
6
docs/api/sideshow.web.views.products.rst
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
|
||||||
|
``sideshow.web.views.products``
|
||||||
|
===============================
|
||||||
|
|
||||||
|
.. automodule:: sideshow.web.views.products
|
||||||
|
:members:
|
6
docs/api/sideshow.web.views.rst
Normal file
6
docs/api/sideshow.web.views.rst
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
|
||||||
|
``sideshow.web.views``
|
||||||
|
======================
|
||||||
|
|
||||||
|
.. automodule:: sideshow.web.views
|
||||||
|
:members:
|
42
docs/conf.py
Normal file
42
docs/conf.py
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
# Configuration file for the Sphinx documentation builder.
|
||||||
|
#
|
||||||
|
# For the full list of built-in configuration values, see the documentation:
|
||||||
|
# https://www.sphinx-doc.org/en/master/usage/configuration.html
|
||||||
|
|
||||||
|
# -- Project information -----------------------------------------------------
|
||||||
|
# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
|
||||||
|
|
||||||
|
from importlib.metadata import version as get_version
|
||||||
|
|
||||||
|
project = 'Sideshow'
|
||||||
|
copyright = '2025, Lance Edgar'
|
||||||
|
author = 'Lance Edgar'
|
||||||
|
release = get_version('Sideshow')
|
||||||
|
|
||||||
|
# -- General configuration ---------------------------------------------------
|
||||||
|
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
|
||||||
|
|
||||||
|
extensions = [
|
||||||
|
'sphinx.ext.autodoc',
|
||||||
|
'sphinx.ext.intersphinx',
|
||||||
|
'sphinx.ext.viewcode',
|
||||||
|
'sphinx.ext.todo',
|
||||||
|
'sphinxcontrib.programoutput',
|
||||||
|
'enum_tools.autoenum',
|
||||||
|
]
|
||||||
|
|
||||||
|
templates_path = ['_templates']
|
||||||
|
exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
|
||||||
|
|
||||||
|
intersphinx_mapping = {
|
||||||
|
'pyramid': ('https://docs.pylonsproject.org/projects/pyramid/en/latest/', None),
|
||||||
|
'wuttjamaican': ('https://rattailproject.org/docs/wuttjamaican/', None),
|
||||||
|
'wuttaweb': ('https://rattailproject.org/docs/wuttaweb/', None),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# -- Options for HTML output -------------------------------------------------
|
||||||
|
# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output
|
||||||
|
|
||||||
|
html_theme = 'furo'
|
||||||
|
html_static_path = ['_static']
|
36
docs/glossary.rst
Normal file
36
docs/glossary.rst
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
|
||||||
|
Glossary
|
||||||
|
========
|
||||||
|
|
||||||
|
.. glossary::
|
||||||
|
:sorted:
|
||||||
|
|
||||||
|
order
|
||||||
|
This is the central focus of the app; it refers to a customer
|
||||||
|
case/special order which is tracked over time, from placement to
|
||||||
|
fulfillment. Each order may have one or more :term:`order items
|
||||||
|
<order item>`.
|
||||||
|
|
||||||
|
order item
|
||||||
|
This is effectively a "line item" within an :term:`order`. It
|
||||||
|
represents a particular product, with quantity and pricing
|
||||||
|
specific to the order.
|
||||||
|
|
||||||
|
Each order item is tracked independently of its parent order and
|
||||||
|
sibling items.
|
||||||
|
|
||||||
|
pending customer
|
||||||
|
Generally refers to a "new / unknown" customer, e.g. for whom a
|
||||||
|
new order is being created. This allows the order lifecycle to
|
||||||
|
get going before the customer has a proper account in the system.
|
||||||
|
|
||||||
|
See :class:`~sideshow.db.model.customers.PendingCustomer` for the
|
||||||
|
data model.
|
||||||
|
|
||||||
|
pending product
|
||||||
|
Generally refers to a "new / unknown" product, e.g. for which a
|
||||||
|
new order is being created. This allows the order lifecycle to
|
||||||
|
get going before the product has a true record in the system.
|
||||||
|
|
||||||
|
See :class:`~sideshow.db.model.products.PendingProduct` for the
|
||||||
|
data model.
|
53
docs/index.rst
Normal file
53
docs/index.rst
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
|
||||||
|
Sideshow
|
||||||
|
========
|
||||||
|
|
||||||
|
This is a web app which provides retailers a way to track case/special
|
||||||
|
orders.
|
||||||
|
|
||||||
|
Good documentation and 100% `test coverage`_ are priorities for this
|
||||||
|
project.
|
||||||
|
|
||||||
|
.. _test coverage: https://buildbot.rattailproject.org/coverage/sideshow/
|
||||||
|
|
||||||
|
However as you can see..the API should be fairly well documented but
|
||||||
|
the narrative docs are pretty scant. That will eventually change.
|
||||||
|
|
||||||
|
.. toctree::
|
||||||
|
:maxdepth: 2
|
||||||
|
:caption: Documentation:
|
||||||
|
|
||||||
|
glossary
|
||||||
|
narr/cli/index
|
||||||
|
|
||||||
|
.. toctree::
|
||||||
|
:maxdepth: 1
|
||||||
|
:caption: Package API:
|
||||||
|
|
||||||
|
api/sideshow
|
||||||
|
api/sideshow.batch
|
||||||
|
api/sideshow.batch.neworder
|
||||||
|
api/sideshow.cli
|
||||||
|
api/sideshow.cli.base
|
||||||
|
api/sideshow.cli.install
|
||||||
|
api/sideshow.config
|
||||||
|
api/sideshow.db
|
||||||
|
api/sideshow.db.model
|
||||||
|
api/sideshow.db.model.batch
|
||||||
|
api/sideshow.db.model.batch.neworder
|
||||||
|
api/sideshow.db.model.customers
|
||||||
|
api/sideshow.db.model.orders
|
||||||
|
api/sideshow.db.model.products
|
||||||
|
api/sideshow.enum
|
||||||
|
api/sideshow.web
|
||||||
|
api/sideshow.web.app
|
||||||
|
api/sideshow.web.forms
|
||||||
|
api/sideshow.web.forms.schema
|
||||||
|
api/sideshow.web.menus
|
||||||
|
api/sideshow.web.static
|
||||||
|
api/sideshow.web.views
|
||||||
|
api/sideshow.web.views.batch
|
||||||
|
api/sideshow.web.views.batch.neworder
|
||||||
|
api/sideshow.web.views.customers
|
||||||
|
api/sideshow.web.views.orders
|
||||||
|
api/sideshow.web.views.products
|
35
docs/make.bat
Normal file
35
docs/make.bat
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
@ECHO OFF
|
||||||
|
|
||||||
|
pushd %~dp0
|
||||||
|
|
||||||
|
REM Command file for Sphinx documentation
|
||||||
|
|
||||||
|
if "%SPHINXBUILD%" == "" (
|
||||||
|
set SPHINXBUILD=sphinx-build
|
||||||
|
)
|
||||||
|
set SOURCEDIR=.
|
||||||
|
set BUILDDIR=_build
|
||||||
|
|
||||||
|
%SPHINXBUILD% >NUL 2>NUL
|
||||||
|
if errorlevel 9009 (
|
||||||
|
echo.
|
||||||
|
echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
|
||||||
|
echo.installed, then set the SPHINXBUILD environment variable to point
|
||||||
|
echo.to the full path of the 'sphinx-build' executable. Alternatively you
|
||||||
|
echo.may add the Sphinx directory to PATH.
|
||||||
|
echo.
|
||||||
|
echo.If you don't have Sphinx installed, grab it from
|
||||||
|
echo.https://www.sphinx-doc.org/
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
if "%1" == "" goto help
|
||||||
|
|
||||||
|
%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
|
||||||
|
goto end
|
||||||
|
|
||||||
|
:help
|
||||||
|
%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
|
||||||
|
|
||||||
|
:end
|
||||||
|
popd
|
43
docs/narr/cli/builtin.rst
Normal file
43
docs/narr/cli/builtin.rst
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
|
||||||
|
===================
|
||||||
|
Built-in Commands
|
||||||
|
===================
|
||||||
|
|
||||||
|
Sideshow comes with one top-level :term:`command`, and some
|
||||||
|
:term:`subcommands<subcommand>`.
|
||||||
|
|
||||||
|
It uses `Typer`_ for the underlying CLI framework.
|
||||||
|
|
||||||
|
.. _Typer: https://typer.tiangolo.com/
|
||||||
|
|
||||||
|
|
||||||
|
``sideshow``
|
||||||
|
------------
|
||||||
|
|
||||||
|
This is the top-level command. Its purpose is to expose subcommands
|
||||||
|
pertaining to Sideshow.
|
||||||
|
|
||||||
|
It is installed to the virtual environment in the ``bin`` folder (or
|
||||||
|
``Scripts`` on Windows):
|
||||||
|
|
||||||
|
.. code-block:: sh
|
||||||
|
|
||||||
|
cd /path/to/venv
|
||||||
|
bin/sideshow --help
|
||||||
|
|
||||||
|
Defined in: :mod:`sideshow.cli`
|
||||||
|
|
||||||
|
.. program-output:: sideshow --help
|
||||||
|
|
||||||
|
|
||||||
|
.. _sideshow-install:
|
||||||
|
|
||||||
|
``sideshow install``
|
||||||
|
--------------------
|
||||||
|
|
||||||
|
Install the web app, generating config files based on interactive
|
||||||
|
prompting.
|
||||||
|
|
||||||
|
Defined in: :mod:`sideshow.cli.install`
|
||||||
|
|
||||||
|
.. program-output:: sideshow install --help
|
14
docs/narr/cli/index.rst
Normal file
14
docs/narr/cli/index.rst
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
|
||||||
|
==============
|
||||||
|
Command Line
|
||||||
|
==============
|
||||||
|
|
||||||
|
There isn't much to the command line for Sideshow, but here it is.
|
||||||
|
|
||||||
|
For more general info about CLI see
|
||||||
|
:doc:`wuttjamaican:narr/cli/index`.
|
||||||
|
|
||||||
|
.. toctree::
|
||||||
|
:maxdepth: 2
|
||||||
|
|
||||||
|
builtin
|
|
@ -32,9 +32,14 @@ license = {text = "GNU General Public License v3+"}
|
||||||
requires-python = ">= 3.8"
|
requires-python = ">= 3.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"psycopg2",
|
"psycopg2",
|
||||||
"WuttaWeb",
|
"WuttaWeb>=0.19.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
docs = ["Sphinx", "furo", "sphinxcontrib-programoutput", "enum-tools[sphinx]"]
|
||||||
|
tests = ["pytest-cov", "tox"]
|
||||||
|
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
"sideshow" = "sideshow.cli:sideshow_typer"
|
"sideshow" = "sideshow.cli:sideshow_typer"
|
||||||
|
|
||||||
|
|
25
src/sideshow/__init__.py
Normal file
25
src/sideshow/__init__.py
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
# -*- coding: utf-8; -*-
|
||||||
|
################################################################################
|
||||||
|
#
|
||||||
|
# Sideshow -- Case/Special Order Tracker
|
||||||
|
# Copyright © 2024 Lance Edgar
|
||||||
|
#
|
||||||
|
# This file is part of Sideshow.
|
||||||
|
#
|
||||||
|
# Sideshow is free software: you can redistribute it and/or modify it
|
||||||
|
# under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# Sideshow is distributed in the hope that it will be useful, but
|
||||||
|
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||||
|
# General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with Sideshow. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
################################################################################
|
||||||
|
"""
|
||||||
|
Sideshow - Case/Special Order Tracker
|
||||||
|
"""
|
0
src/sideshow/batch/__init__.py
Normal file
0
src/sideshow/batch/__init__.py
Normal file
471
src/sideshow/batch/neworder.py
Normal file
471
src/sideshow/batch/neworder.py
Normal file
|
@ -0,0 +1,471 @@
|
||||||
|
# -*- coding: utf-8; -*-
|
||||||
|
################################################################################
|
||||||
|
#
|
||||||
|
# Sideshow -- Case/Special Order Tracker
|
||||||
|
# Copyright © 2024 Lance Edgar
|
||||||
|
#
|
||||||
|
# This file is part of Sideshow.
|
||||||
|
#
|
||||||
|
# Sideshow is free software: you can redistribute it and/or modify it
|
||||||
|
# under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# Sideshow is distributed in the hope that it will be useful, but
|
||||||
|
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||||
|
# General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with Sideshow. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
################################################################################
|
||||||
|
"""
|
||||||
|
New Order Batch Handler
|
||||||
|
"""
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
import decimal
|
||||||
|
|
||||||
|
from wuttjamaican.batch import BatchHandler
|
||||||
|
|
||||||
|
from sideshow.db.model import NewOrderBatch
|
||||||
|
|
||||||
|
|
||||||
|
class NewOrderBatchHandler(BatchHandler):
|
||||||
|
"""
|
||||||
|
The :term:`batch handler` for New Order Batches.
|
||||||
|
|
||||||
|
This is responsible for business logic around the creation of new
|
||||||
|
:term:`orders <order>`. A
|
||||||
|
:class:`~sideshow.db.model.batch.neworder.NewOrderBatch` tracks
|
||||||
|
all user input until they "submit" (execute) at which point an
|
||||||
|
:class:`~sideshow.db.model.orders.Order` is created.
|
||||||
|
"""
|
||||||
|
model_class = NewOrderBatch
|
||||||
|
|
||||||
|
def set_pending_customer(self, batch, data):
|
||||||
|
"""
|
||||||
|
Set (add or update) pending customer info for the batch.
|
||||||
|
|
||||||
|
This will clear the
|
||||||
|
:attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.customer_id`
|
||||||
|
and set the
|
||||||
|
:attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.pending_customer`,
|
||||||
|
creating a new record if needed. It then updates the pending
|
||||||
|
customer record per the given ``data``.
|
||||||
|
|
||||||
|
:param batch:
|
||||||
|
:class:`~sideshow.db.model.batch.neworder.NewOrderBatch`
|
||||||
|
to be updated.
|
||||||
|
|
||||||
|
:param data: Dict of field data for the
|
||||||
|
:class:`~sideshow.db.model.customers.PendingCustomer`
|
||||||
|
record.
|
||||||
|
"""
|
||||||
|
model = self.app.model
|
||||||
|
enum = self.app.enum
|
||||||
|
|
||||||
|
# remove customer account if set
|
||||||
|
batch.customer_id = None
|
||||||
|
|
||||||
|
# create pending customer if needed
|
||||||
|
pending = batch.pending_customer
|
||||||
|
if not pending:
|
||||||
|
kw = dict(data)
|
||||||
|
kw.setdefault('status', enum.PendingCustomerStatus.PENDING)
|
||||||
|
pending = model.PendingCustomer(**kw)
|
||||||
|
batch.pending_customer = pending
|
||||||
|
|
||||||
|
# update pending customer
|
||||||
|
if 'first_name' in data:
|
||||||
|
pending.first_name = data['first_name']
|
||||||
|
if 'last_name' in data:
|
||||||
|
pending.last_name = data['last_name']
|
||||||
|
if 'full_name' in data:
|
||||||
|
pending.full_name = data['full_name']
|
||||||
|
elif 'first_name' in data or 'last_name' in data:
|
||||||
|
pending.full_name = self.app.make_full_name(data.get('first_name'),
|
||||||
|
data.get('last_name'))
|
||||||
|
if 'phone_number' in data:
|
||||||
|
pending.phone_number = data['phone_number']
|
||||||
|
if 'email_address' in data:
|
||||||
|
pending.email_address = data['email_address']
|
||||||
|
|
||||||
|
# update batch per pending customer
|
||||||
|
batch.customer_name = pending.full_name
|
||||||
|
batch.phone_number = pending.phone_number
|
||||||
|
batch.email_address = pending.email_address
|
||||||
|
|
||||||
|
def add_pending_product(self, batch, pending_info,
|
||||||
|
order_qty, order_uom):
|
||||||
|
"""
|
||||||
|
Add a new row to the batch, for the given "pending" product
|
||||||
|
and order quantity.
|
||||||
|
|
||||||
|
See also :meth:`set_pending_product()` to update an existing row.
|
||||||
|
|
||||||
|
:param batch:
|
||||||
|
:class:`~sideshow.db.model.batch.neworder.NewOrderBatch` to
|
||||||
|
which the row should be added.
|
||||||
|
|
||||||
|
:param pending_info: Dict of kwargs to use when constructing a
|
||||||
|
new :class:`~sideshow.db.model.products.PendingProduct`.
|
||||||
|
|
||||||
|
:param order_qty: Quantity of the product to be added to the
|
||||||
|
order.
|
||||||
|
|
||||||
|
:param order_uom: UOM for the order quantity; must be a code
|
||||||
|
from :data:`~sideshow.enum.ORDER_UOM`.
|
||||||
|
|
||||||
|
:returns:
|
||||||
|
:class:`~sideshow.db.model.batch.neworder.NewOrderBatchRow`
|
||||||
|
which was added to the batch.
|
||||||
|
"""
|
||||||
|
model = self.app.model
|
||||||
|
enum = self.app.enum
|
||||||
|
session = self.app.get_session(batch)
|
||||||
|
|
||||||
|
# make new pending product
|
||||||
|
kw = dict(pending_info)
|
||||||
|
kw.setdefault('status', enum.PendingProductStatus.PENDING)
|
||||||
|
product = model.PendingProduct(**kw)
|
||||||
|
session.add(product)
|
||||||
|
session.flush()
|
||||||
|
# nb. this may convert float to decimal etc.
|
||||||
|
session.refresh(product)
|
||||||
|
|
||||||
|
# make/add new row, w/ pending product
|
||||||
|
row = self.make_row(pending_product=product,
|
||||||
|
order_qty=order_qty, order_uom=order_uom)
|
||||||
|
self.add_row(batch, row)
|
||||||
|
session.add(row)
|
||||||
|
session.flush()
|
||||||
|
return row
|
||||||
|
|
||||||
|
def set_pending_product(self, row, data):
|
||||||
|
"""
|
||||||
|
Set (add or update) pending product info for the given batch row.
|
||||||
|
|
||||||
|
This will clear the
|
||||||
|
:attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.product_id`
|
||||||
|
and set the
|
||||||
|
:attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.pending_product`,
|
||||||
|
creating a new record if needed. It then updates the pending
|
||||||
|
product record per the given ``data``, and finally calls
|
||||||
|
:meth:`refresh_row()`.
|
||||||
|
|
||||||
|
Note that this does not update order quantity for the item.
|
||||||
|
|
||||||
|
See also :meth:`add_pending_product()` to add a new row
|
||||||
|
instead of updating.
|
||||||
|
|
||||||
|
:param row:
|
||||||
|
:class:`~sideshow.db.model.batch.neworder.NewOrderBatchRow`
|
||||||
|
to be updated.
|
||||||
|
|
||||||
|
:param data: Dict of field data for the
|
||||||
|
:class:`~sideshow.db.model.products.PendingProduct` record.
|
||||||
|
"""
|
||||||
|
model = self.app.model
|
||||||
|
enum = self.app.enum
|
||||||
|
session = self.app.get_session(row)
|
||||||
|
|
||||||
|
# values for these fields can be used as-is
|
||||||
|
simple_fields = [
|
||||||
|
'scancode',
|
||||||
|
'brand_name',
|
||||||
|
'description',
|
||||||
|
'size',
|
||||||
|
'weighed',
|
||||||
|
'department_id',
|
||||||
|
'department_name',
|
||||||
|
'special_order',
|
||||||
|
'vendor_name',
|
||||||
|
'vendor_item_code',
|
||||||
|
'notes',
|
||||||
|
'unit_cost',
|
||||||
|
'case_size',
|
||||||
|
'case_cost',
|
||||||
|
'unit_price_reg',
|
||||||
|
]
|
||||||
|
|
||||||
|
# clear true product id
|
||||||
|
row.product_id = None
|
||||||
|
|
||||||
|
# make pending product if needed
|
||||||
|
product = row.pending_product
|
||||||
|
if not product:
|
||||||
|
kw = dict(data)
|
||||||
|
kw.setdefault('status', enum.PendingProductStatus.PENDING)
|
||||||
|
product = model.PendingProduct(**kw)
|
||||||
|
session.add(product)
|
||||||
|
row.pending_product = product
|
||||||
|
session.flush()
|
||||||
|
|
||||||
|
# update pending product
|
||||||
|
for field in simple_fields:
|
||||||
|
if field in data:
|
||||||
|
setattr(product, field, data[field])
|
||||||
|
|
||||||
|
# nb. this may convert float to decimal etc.
|
||||||
|
session.flush()
|
||||||
|
session.refresh(product)
|
||||||
|
|
||||||
|
# refresh per new info
|
||||||
|
self.refresh_row(row)
|
||||||
|
|
||||||
|
def refresh_row(self, row, now=None):
|
||||||
|
"""
|
||||||
|
Refresh all data for the row. This is called when adding a
|
||||||
|
new row to the batch, or anytime the row is updated (e.g. when
|
||||||
|
changing order quantity).
|
||||||
|
|
||||||
|
This calls one of the following to update product-related
|
||||||
|
attributes for the row:
|
||||||
|
|
||||||
|
* :meth:`refresh_row_from_pending_product()`
|
||||||
|
* :meth:`refresh_row_from_true_product()`
|
||||||
|
|
||||||
|
It then re-calculates the row's
|
||||||
|
:attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.total_price`
|
||||||
|
and updates the batch accordingly.
|
||||||
|
|
||||||
|
It also sets the row
|
||||||
|
:attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.status_code`.
|
||||||
|
"""
|
||||||
|
enum = self.app.enum
|
||||||
|
row.status_code = None
|
||||||
|
row.status_text = None
|
||||||
|
|
||||||
|
# ensure product
|
||||||
|
if not row.product_id and not row.pending_product:
|
||||||
|
row.status_code = row.STATUS_MISSING_PRODUCT
|
||||||
|
return
|
||||||
|
|
||||||
|
# ensure order qty/uom
|
||||||
|
if not row.order_qty or not row.order_uom:
|
||||||
|
row.status_code = row.STATUS_MISSING_ORDER_QTY
|
||||||
|
return
|
||||||
|
|
||||||
|
# update product attrs on row
|
||||||
|
if row.product_id:
|
||||||
|
self.refresh_row_from_true_product(row)
|
||||||
|
else:
|
||||||
|
self.refresh_row_from_pending_product(row)
|
||||||
|
|
||||||
|
# we need to know if total price changes
|
||||||
|
old_total = row.total_price
|
||||||
|
|
||||||
|
# update quoted price
|
||||||
|
row.unit_price_quoted = None
|
||||||
|
row.case_price_quoted = None
|
||||||
|
if row.unit_price_sale is not None and (
|
||||||
|
not row.sale_ends
|
||||||
|
or row.sale_ends > (now or datetime.datetime.now())):
|
||||||
|
row.unit_price_quoted = row.unit_price_sale
|
||||||
|
else:
|
||||||
|
row.unit_price_quoted = row.unit_price_reg
|
||||||
|
if row.unit_price_quoted is not None and row.case_size:
|
||||||
|
row.case_price_quoted = row.unit_price_quoted * row.case_size
|
||||||
|
|
||||||
|
# update row total price
|
||||||
|
row.total_price = None
|
||||||
|
if row.order_uom == enum.ORDER_UOM_CASE:
|
||||||
|
if row.unit_price_quoted is not None and row.case_size is not None:
|
||||||
|
row.total_price = row.unit_price_quoted * row.case_size * row.order_qty
|
||||||
|
else: # ORDER_UOM_UNIT (or similar)
|
||||||
|
if row.unit_price_quoted is not None:
|
||||||
|
row.total_price = row.unit_price_quoted * row.order_qty
|
||||||
|
if row.total_price is not None:
|
||||||
|
row.total_price = decimal.Decimal(f'{row.total_price:0.2f}')
|
||||||
|
|
||||||
|
# update batch if total price changed
|
||||||
|
if row.total_price != old_total:
|
||||||
|
batch = row.batch
|
||||||
|
batch.total_price = ((batch.total_price or 0)
|
||||||
|
+ (row.total_price or 0)
|
||||||
|
- (old_total or 0))
|
||||||
|
|
||||||
|
# all ok
|
||||||
|
row.status_code = row.STATUS_OK
|
||||||
|
|
||||||
|
def refresh_row_from_pending_product(self, row):
|
||||||
|
"""
|
||||||
|
Update product-related attributes on the row, from its
|
||||||
|
:attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.pending_product`
|
||||||
|
record.
|
||||||
|
|
||||||
|
This is called automatically from :meth:`refresh_row()`.
|
||||||
|
"""
|
||||||
|
product = row.pending_product
|
||||||
|
|
||||||
|
row.product_scancode = product.scancode
|
||||||
|
row.product_brand = product.brand_name
|
||||||
|
row.product_description = product.description
|
||||||
|
row.product_size = product.size
|
||||||
|
row.product_weighed = product.weighed
|
||||||
|
row.department_id = product.department_id
|
||||||
|
row.department_name = product.department_name
|
||||||
|
row.special_order = product.special_order
|
||||||
|
row.case_size = product.case_size
|
||||||
|
row.unit_cost = product.unit_cost
|
||||||
|
row.unit_price_reg = product.unit_price_reg
|
||||||
|
|
||||||
|
def refresh_row_from_true_product(self, row):
|
||||||
|
"""
|
||||||
|
Update product-related attributes on the row, from its "true"
|
||||||
|
product record indicated by
|
||||||
|
:attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.product_id`.
|
||||||
|
|
||||||
|
This is called automatically from :meth:`refresh_row()`.
|
||||||
|
|
||||||
|
There is no default logic here; subclass must implement as
|
||||||
|
needed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def remove_row(self, row):
|
||||||
|
"""
|
||||||
|
Remove a row from its batch.
|
||||||
|
|
||||||
|
This also will update the batch
|
||||||
|
:attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.total_price`
|
||||||
|
accordingly.
|
||||||
|
"""
|
||||||
|
if row.total_price:
|
||||||
|
batch = row.batch
|
||||||
|
batch.total_price = (batch.total_price or 0) - row.total_price
|
||||||
|
|
||||||
|
super().remove_row(row)
|
||||||
|
|
||||||
|
def do_delete(self, batch, user, **kwargs):
|
||||||
|
"""
|
||||||
|
Delete the given batch entirely.
|
||||||
|
|
||||||
|
If the batch has a
|
||||||
|
:attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.pending_customer`
|
||||||
|
record, that is deleted also.
|
||||||
|
"""
|
||||||
|
# maybe delete pending customer record, if it only exists for
|
||||||
|
# sake of this batch
|
||||||
|
if batch.pending_customer:
|
||||||
|
if len(batch.pending_customer.new_order_batches) == 1:
|
||||||
|
# TODO: check for past orders too
|
||||||
|
session = self.app.get_session(batch)
|
||||||
|
session.delete(batch.pending_customer)
|
||||||
|
|
||||||
|
# continue with normal deletion
|
||||||
|
super().do_delete(batch, user, **kwargs)
|
||||||
|
|
||||||
|
def why_not_execute(self, batch, **kwargs):
|
||||||
|
"""
|
||||||
|
By default this checks to ensure the batch has a customer and
|
||||||
|
at least one item.
|
||||||
|
"""
|
||||||
|
if not batch.customer_id and not batch.pending_customer:
|
||||||
|
return "Must assign the customer"
|
||||||
|
|
||||||
|
rows = self.get_effective_rows(batch)
|
||||||
|
if not rows:
|
||||||
|
return "Must add at least one valid item"
|
||||||
|
|
||||||
|
def get_effective_rows(self, batch):
|
||||||
|
"""
|
||||||
|
Only rows with
|
||||||
|
:attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.STATUS_OK`
|
||||||
|
are "effective" - i.e. rows with other status codes will not
|
||||||
|
be created as proper order items.
|
||||||
|
"""
|
||||||
|
return [row for row in batch.rows
|
||||||
|
if row.status_code == row.STATUS_OK]
|
||||||
|
|
||||||
|
def execute(self, batch, user=None, progress=None, **kwargs):
|
||||||
|
"""
|
||||||
|
By default, this will call :meth:`make_new_order()` and return
|
||||||
|
the new :class:`~sideshow.db.model.orders.Order` instance.
|
||||||
|
|
||||||
|
Note that callers should use
|
||||||
|
:meth:`~wuttjamaican:wuttjamaican.batch.BatchHandler.do_execute()`
|
||||||
|
instead, which calls this method automatically.
|
||||||
|
"""
|
||||||
|
rows = self.get_effective_rows(batch)
|
||||||
|
order = self.make_new_order(batch, rows, user=user, progress=progress, **kwargs)
|
||||||
|
return order
|
||||||
|
|
||||||
|
def make_new_order(self, batch, rows, user=None, progress=None, **kwargs):
|
||||||
|
"""
|
||||||
|
Create a new :term:`order` from the batch data.
|
||||||
|
|
||||||
|
This is called automatically from :meth:`execute()`.
|
||||||
|
|
||||||
|
:param batch:
|
||||||
|
:class:`~sideshow.db.model.batch.neworder.NewOrderBatch`
|
||||||
|
instance.
|
||||||
|
|
||||||
|
:param rows: List of effective rows for the batch, i.e. which
|
||||||
|
rows should be converted to :term:`order items <order
|
||||||
|
item>`.
|
||||||
|
|
||||||
|
:returns: :class:`~sideshow.db.model.orders.Order` instance.
|
||||||
|
"""
|
||||||
|
model = self.app.model
|
||||||
|
enum = self.app.enum
|
||||||
|
session = self.app.get_session(batch)
|
||||||
|
|
||||||
|
batch_fields = [
|
||||||
|
'store_id',
|
||||||
|
'customer_id',
|
||||||
|
'pending_customer',
|
||||||
|
'customer_name',
|
||||||
|
'phone_number',
|
||||||
|
'email_address',
|
||||||
|
'total_price',
|
||||||
|
]
|
||||||
|
|
||||||
|
row_fields = [
|
||||||
|
'pending_product_uuid',
|
||||||
|
'product_scancode',
|
||||||
|
'product_brand',
|
||||||
|
'product_description',
|
||||||
|
'product_size',
|
||||||
|
'product_weighed',
|
||||||
|
'department_id',
|
||||||
|
'department_name',
|
||||||
|
'case_size',
|
||||||
|
'order_qty',
|
||||||
|
'order_uom',
|
||||||
|
'unit_cost',
|
||||||
|
'unit_price_quoted',
|
||||||
|
'case_price_quoted',
|
||||||
|
'unit_price_reg',
|
||||||
|
'unit_price_sale',
|
||||||
|
'sale_ends',
|
||||||
|
# 'discount_percent',
|
||||||
|
'total_price',
|
||||||
|
'special_order',
|
||||||
|
]
|
||||||
|
|
||||||
|
# make order
|
||||||
|
kw = dict([(field, getattr(batch, field))
|
||||||
|
for field in batch_fields])
|
||||||
|
kw['order_id'] = batch.id
|
||||||
|
kw['created_by'] = user
|
||||||
|
order = model.Order(**kw)
|
||||||
|
session.add(order)
|
||||||
|
session.flush()
|
||||||
|
|
||||||
|
def convert(row, i):
|
||||||
|
|
||||||
|
# make order item
|
||||||
|
kw = dict([(field, getattr(row, field))
|
||||||
|
for field in row_fields])
|
||||||
|
item = model.OrderItem(**kw)
|
||||||
|
order.items.append(item)
|
||||||
|
|
||||||
|
# set item status
|
||||||
|
item.status_code = enum.ORDER_ITEM_STATUS_INITIATED
|
||||||
|
|
||||||
|
self.app.progress_loop(convert, rows, progress,
|
||||||
|
message="Converting batch rows to order items")
|
||||||
|
session.flush()
|
||||||
|
return order
|
|
@ -21,17 +21,16 @@
|
||||||
#
|
#
|
||||||
################################################################################
|
################################################################################
|
||||||
"""
|
"""
|
||||||
Pyramid event subscribers
|
Sideshow - command line interface
|
||||||
|
|
||||||
|
See also :doc:`/narr/cli/index`.
|
||||||
|
|
||||||
|
This (``sideshow.cli``) namespace exposes the following:
|
||||||
|
|
||||||
|
* :data:`~sideshow.cli.base.sideshow_typer` (top-level command)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sideshow
|
from .base import sideshow_typer
|
||||||
|
|
||||||
|
# nb. must bring in all modules for discovery to work
|
||||||
def add_sideshow_to_context(event):
|
from . import install
|
||||||
renderer_globals = event
|
|
||||||
renderer_globals['sideshow'] = sideshow
|
|
||||||
|
|
||||||
|
|
||||||
def includeme(config):
|
|
||||||
config.include('wuttaweb.subscribers')
|
|
||||||
config.add_subscriber(add_sideshow_to_context, 'pyramid.events.BeforeRender')
|
|
40
src/sideshow/cli/base.py
Normal file
40
src/sideshow/cli/base.py
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
# -*- coding: utf-8; -*-
|
||||||
|
################################################################################
|
||||||
|
#
|
||||||
|
# Sideshow -- Case/Special Order Tracker
|
||||||
|
# Copyright © 2024 Lance Edgar
|
||||||
|
#
|
||||||
|
# This file is part of Sideshow.
|
||||||
|
#
|
||||||
|
# Sideshow is free software: you can redistribute it and/or modify it
|
||||||
|
# under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# Sideshow is distributed in the hope that it will be useful, but
|
||||||
|
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||||
|
# General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with Sideshow. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
################################################################################
|
||||||
|
"""
|
||||||
|
sideshow - core command logic
|
||||||
|
|
||||||
|
See also :doc:`/narr/cli/index`.
|
||||||
|
|
||||||
|
.. data:: sideshow_typer
|
||||||
|
|
||||||
|
This is the top-level ``sideshow`` :term:`command`, using the Typer
|
||||||
|
framework.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from wuttjamaican.cli import make_typer
|
||||||
|
|
||||||
|
|
||||||
|
sideshow_typer = make_typer(
|
||||||
|
name='sideshow',
|
||||||
|
help="Sideshow -- Case/Special Order Tracker"
|
||||||
|
)
|
|
@ -21,18 +21,12 @@
|
||||||
#
|
#
|
||||||
################################################################################
|
################################################################################
|
||||||
"""
|
"""
|
||||||
Sideshow CLI
|
See also: :ref:`sideshow-install`
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import typer
|
import typer
|
||||||
|
|
||||||
from wuttjamaican.cli import make_typer
|
from .base import sideshow_typer
|
||||||
|
|
||||||
|
|
||||||
sideshow_typer = make_typer(
|
|
||||||
name='sideshow',
|
|
||||||
help="Sideshow -- Case/Special Order Tracker"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@sideshow_typer.command()
|
@sideshow_typer.command()
|
|
@ -21,7 +21,7 @@
|
||||||
#
|
#
|
||||||
################################################################################
|
################################################################################
|
||||||
"""
|
"""
|
||||||
Sideshow config extensions
|
Sideshow config extension
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from wuttjamaican.conf import WuttaConfigExtension
|
from wuttjamaican.conf import WuttaConfigExtension
|
||||||
|
@ -29,18 +29,22 @@ from wuttjamaican.conf import WuttaConfigExtension
|
||||||
|
|
||||||
class SideshowConfig(WuttaConfigExtension):
|
class SideshowConfig(WuttaConfigExtension):
|
||||||
"""
|
"""
|
||||||
Config extension for Sideshow
|
Config extension for Sideshow.
|
||||||
|
|
||||||
|
This establishes some config defaults specific to Sideshow.
|
||||||
"""
|
"""
|
||||||
key = 'sideshow'
|
key = 'sideshow'
|
||||||
|
|
||||||
def configure(self, config):
|
def configure(self, config):
|
||||||
|
""" """
|
||||||
|
|
||||||
# app info
|
# app info
|
||||||
config.setdefault(f'{config.appname}.app_title', "Sideshow")
|
config.setdefault(f'{config.appname}.app_title', "Sideshow")
|
||||||
config.setdefault(f'{config.appname}.app_dist', "Sideshow")
|
config.setdefault(f'{config.appname}.app_dist', "Sideshow")
|
||||||
|
|
||||||
# app model
|
# app model, enum
|
||||||
config.setdefault(f'{config.appname}.model_spec', 'sideshow.db.model')
|
config.setdefault(f'{config.appname}.model_spec', 'sideshow.db.model')
|
||||||
|
config.setdefault(f'{config.appname}.enum_spec', 'sideshow.enum')
|
||||||
|
|
||||||
# web app menu
|
# web app menu
|
||||||
config.setdefault(f'{config.appname}.web.menus.handler_spec',
|
config.setdefault(f'{config.appname}.web.menus.handler_spec',
|
||||||
|
|
|
@ -0,0 +1,203 @@
|
||||||
|
"""initial order tables
|
||||||
|
|
||||||
|
Revision ID: 7a6df83afbd4
|
||||||
|
Revises:
|
||||||
|
Create Date: 2024-12-30 18:53:51.358163
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
import wuttjamaican.db.util
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '7a6df83afbd4'
|
||||||
|
down_revision: Union[str, None] = None
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = ('sideshow',)
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
|
||||||
|
# enums
|
||||||
|
sa.Enum('PENDING', 'READY', 'RESOLVED', name='pendingcustomerstatus').create(op.get_bind())
|
||||||
|
sa.Enum('PENDING', 'READY', 'RESOLVED', name='pendingproductstatus').create(op.get_bind())
|
||||||
|
|
||||||
|
# sideshow_pending_customer
|
||||||
|
op.create_table('sideshow_pending_customer',
|
||||||
|
sa.Column('uuid', wuttjamaican.db.util.UUID(), nullable=False),
|
||||||
|
sa.Column('customer_id', sa.String(length=20), nullable=True),
|
||||||
|
sa.Column('full_name', sa.String(length=100), nullable=True),
|
||||||
|
sa.Column('first_name', sa.String(length=50), nullable=True),
|
||||||
|
sa.Column('last_name', sa.String(length=50), nullable=True),
|
||||||
|
sa.Column('phone_number', sa.String(length=20), nullable=True),
|
||||||
|
sa.Column('email_address', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('status', postgresql.ENUM('PENDING', 'READY', 'RESOLVED', name='pendingcustomerstatus', create_type=False), nullable=False),
|
||||||
|
sa.Column('created', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column('created_by_uuid', wuttjamaican.db.util.UUID(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['created_by_uuid'], ['user.uuid'], name=op.f('fk_sideshow_pending_customer_created_by_uuid_user')),
|
||||||
|
sa.PrimaryKeyConstraint('uuid', name=op.f('pk_sideshow_pending_customer'))
|
||||||
|
)
|
||||||
|
|
||||||
|
# sideshow_pending_product
|
||||||
|
op.create_table('sideshow_pending_product',
|
||||||
|
sa.Column('uuid', wuttjamaican.db.util.UUID(), nullable=False),
|
||||||
|
sa.Column('product_id', sa.String(length=20), nullable=True),
|
||||||
|
sa.Column('scancode', sa.String(length=14), nullable=True),
|
||||||
|
sa.Column('department_id', sa.String(length=10), nullable=True),
|
||||||
|
sa.Column('department_name', sa.String(length=30), nullable=True),
|
||||||
|
sa.Column('brand_name', sa.String(length=100), nullable=True),
|
||||||
|
sa.Column('description', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('size', sa.String(length=30), nullable=True),
|
||||||
|
sa.Column('weighed', sa.Boolean(), nullable=True),
|
||||||
|
sa.Column('vendor_name', sa.String(length=50), nullable=True),
|
||||||
|
sa.Column('vendor_item_code', sa.String(length=20), nullable=True),
|
||||||
|
sa.Column('unit_cost', sa.Numeric(precision=9, scale=5), nullable=True),
|
||||||
|
sa.Column('case_size', sa.Numeric(precision=9, scale=4), nullable=True),
|
||||||
|
sa.Column('unit_price_reg', sa.Numeric(precision=8, scale=3), nullable=True),
|
||||||
|
sa.Column('special_order', sa.Boolean(), nullable=True),
|
||||||
|
sa.Column('notes', sa.Text(), nullable=True),
|
||||||
|
sa.Column('status', postgresql.ENUM('PENDING', 'READY', 'RESOLVED', name='pendingproductstatus', create_type=False), nullable=False),
|
||||||
|
sa.Column('created', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column('created_by_uuid', wuttjamaican.db.util.UUID(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['created_by_uuid'], ['user.uuid'], name=op.f('fk_sideshow_pending_product_created_by_uuid_user')),
|
||||||
|
sa.PrimaryKeyConstraint('uuid', name=op.f('pk_sideshow_pending_product'))
|
||||||
|
)
|
||||||
|
|
||||||
|
# sideshow_order
|
||||||
|
op.create_table('sideshow_order',
|
||||||
|
sa.Column('uuid', wuttjamaican.db.util.UUID(), nullable=False),
|
||||||
|
sa.Column('order_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('store_id', sa.String(length=10), nullable=True),
|
||||||
|
sa.Column('customer_id', sa.String(length=20), nullable=True),
|
||||||
|
sa.Column('pending_customer_uuid', wuttjamaican.db.util.UUID(), nullable=True),
|
||||||
|
sa.Column('customer_name', sa.String(length=100), nullable=True),
|
||||||
|
sa.Column('phone_number', sa.String(length=20), nullable=True),
|
||||||
|
sa.Column('email_address', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('total_price', sa.Numeric(precision=10, scale=3), nullable=True),
|
||||||
|
sa.Column('created', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column('created_by_uuid', wuttjamaican.db.util.UUID(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['pending_customer_uuid'], ['sideshow_pending_customer.uuid'], name=op.f('fk_order_pending_customer_uuid_pending_customer')),
|
||||||
|
sa.ForeignKeyConstraint(['created_by_uuid'], ['user.uuid'], name=op.f('fk_order_created_by_uuid_user')),
|
||||||
|
sa.PrimaryKeyConstraint('uuid', name=op.f('pk_order'))
|
||||||
|
)
|
||||||
|
|
||||||
|
# sideshow_order_item
|
||||||
|
op.create_table('sideshow_order_item',
|
||||||
|
sa.Column('uuid', wuttjamaican.db.util.UUID(), nullable=False),
|
||||||
|
sa.Column('order_uuid', wuttjamaican.db.util.UUID(), nullable=False),
|
||||||
|
sa.Column('sequence', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('product_id', sa.String(length=20), nullable=True),
|
||||||
|
sa.Column('pending_product_uuid', wuttjamaican.db.util.UUID(), nullable=True),
|
||||||
|
sa.Column('product_scancode', sa.String(length=14), nullable=True),
|
||||||
|
sa.Column('product_brand', sa.String(length=100), nullable=True),
|
||||||
|
sa.Column('product_description', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('product_size', sa.String(length=30), nullable=True),
|
||||||
|
sa.Column('product_weighed', sa.Boolean(), nullable=True),
|
||||||
|
sa.Column('department_id', sa.String(length=10), nullable=True),
|
||||||
|
sa.Column('department_name', sa.String(length=30), nullable=True),
|
||||||
|
sa.Column('special_order', sa.Boolean(), nullable=True),
|
||||||
|
sa.Column('case_size', sa.Numeric(precision=10, scale=4), nullable=True),
|
||||||
|
sa.Column('order_qty', sa.Numeric(precision=10, scale=4), nullable=False),
|
||||||
|
sa.Column('order_uom', sa.String(length=10), nullable=False),
|
||||||
|
sa.Column('unit_cost', sa.Numeric(precision=9, scale=5), nullable=True),
|
||||||
|
sa.Column('unit_price_reg', sa.Numeric(precision=8, scale=3), nullable=True),
|
||||||
|
sa.Column('unit_price_sale', sa.Numeric(precision=8, scale=3), nullable=True),
|
||||||
|
sa.Column('sale_ends', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column('unit_price_quoted', sa.Numeric(precision=8, scale=3), nullable=True),
|
||||||
|
sa.Column('case_price_quoted', sa.Numeric(precision=8, scale=3), nullable=True),
|
||||||
|
sa.Column('discount_percent', sa.Numeric(precision=5, scale=3), nullable=True),
|
||||||
|
sa.Column('total_price', sa.Numeric(precision=8, scale=3), nullable=True),
|
||||||
|
sa.Column('status_code', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('paid_amount', sa.Numeric(precision=8, scale=3), nullable=False),
|
||||||
|
sa.Column('payment_transaction_number', sa.String(length=20), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['order_uuid'], ['sideshow_order.uuid'], name=op.f('fk_sideshow_order_item_order_uuid_order')),
|
||||||
|
sa.ForeignKeyConstraint(['pending_product_uuid'], ['sideshow_pending_product.uuid'], name=op.f('fk_sideshow_order_item_pending_product_uuid_pending_product')),
|
||||||
|
sa.PrimaryKeyConstraint('uuid', name=op.f('pk_order_item'))
|
||||||
|
)
|
||||||
|
|
||||||
|
# sideshow_batch_neworder
|
||||||
|
op.create_table('sideshow_batch_neworder',
|
||||||
|
sa.Column('uuid', wuttjamaican.db.util.UUID(), nullable=False),
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('description', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('notes', sa.Text(), nullable=True),
|
||||||
|
sa.Column('row_count', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('status_code', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('status_text', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('created', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column('created_by_uuid', wuttjamaican.db.util.UUID(), nullable=False),
|
||||||
|
sa.Column('executed', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column('executed_by_uuid', wuttjamaican.db.util.UUID(), nullable=True),
|
||||||
|
sa.Column('store_id', sa.String(length=10), nullable=True),
|
||||||
|
sa.Column('customer_id', sa.String(length=20), nullable=True),
|
||||||
|
sa.Column('pending_customer_uuid', wuttjamaican.db.util.UUID(), nullable=True),
|
||||||
|
sa.Column('customer_name', sa.String(length=100), nullable=True),
|
||||||
|
sa.Column('phone_number', sa.String(length=20), nullable=True),
|
||||||
|
sa.Column('email_address', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('total_price', sa.Numeric(precision=10, scale=3), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['created_by_uuid'], ['user.uuid'], name=op.f('fk_sideshow_batch_neworder_created_by_uuid_user')),
|
||||||
|
sa.ForeignKeyConstraint(['executed_by_uuid'], ['user.uuid'], name=op.f('fk_sideshow_batch_neworder_executed_by_uuid_user')),
|
||||||
|
sa.ForeignKeyConstraint(['pending_customer_uuid'], ['sideshow_pending_customer.uuid'], name=op.f('fk_sideshow_batch_neworder_pending_customer_uuid_pending_customer')),
|
||||||
|
sa.PrimaryKeyConstraint('uuid', name=op.f('pk_sideshow_batch_neworder'))
|
||||||
|
)
|
||||||
|
|
||||||
|
# sideshow_batch_neworder_row
|
||||||
|
op.create_table('sideshow_batch_neworder_row',
|
||||||
|
sa.Column('uuid', wuttjamaican.db.util.UUID(), nullable=False),
|
||||||
|
sa.Column('batch_uuid', wuttjamaican.db.util.UUID(), nullable=False),
|
||||||
|
sa.Column('sequence', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('status_text', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('modified', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column('pending_product_uuid', wuttjamaican.db.util.UUID(), nullable=True),
|
||||||
|
sa.Column('product_id', sa.String(length=20), nullable=True),
|
||||||
|
sa.Column('product_scancode', sa.String(length=14), nullable=True),
|
||||||
|
sa.Column('product_brand', sa.String(length=100), nullable=True),
|
||||||
|
sa.Column('product_description', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('product_size', sa.String(length=30), nullable=True),
|
||||||
|
sa.Column('product_weighed', sa.Boolean(), nullable=True),
|
||||||
|
sa.Column('department_id', sa.String(length=10), nullable=True),
|
||||||
|
sa.Column('department_name', sa.String(length=30), nullable=True),
|
||||||
|
sa.Column('special_order', sa.Boolean(), nullable=True),
|
||||||
|
sa.Column('case_size', sa.Numeric(precision=10, scale=4), nullable=True),
|
||||||
|
sa.Column('order_qty', sa.Numeric(precision=10, scale=4), nullable=False),
|
||||||
|
sa.Column('order_uom', sa.String(length=10), nullable=False),
|
||||||
|
sa.Column('unit_cost', sa.Numeric(precision=9, scale=5), nullable=True),
|
||||||
|
sa.Column('unit_price_reg', sa.Numeric(precision=8, scale=3), nullable=True),
|
||||||
|
sa.Column('unit_price_sale', sa.Numeric(precision=8, scale=3), nullable=True),
|
||||||
|
sa.Column('sale_ends', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column('unit_price_quoted', sa.Numeric(precision=8, scale=3), nullable=True),
|
||||||
|
sa.Column('case_price_quoted', sa.Numeric(precision=8, scale=3), nullable=True),
|
||||||
|
sa.Column('discount_percent', sa.Numeric(precision=5, scale=3), nullable=True),
|
||||||
|
sa.Column('total_price', sa.Numeric(precision=8, scale=3), nullable=True),
|
||||||
|
sa.Column('status_code', sa.Integer(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['batch_uuid'], ['sideshow_batch_neworder.uuid'], name=op.f('fk_sideshow_batch_neworder_row_batch_uuid_batch_neworder')),
|
||||||
|
sa.ForeignKeyConstraint(['pending_product_uuid'], ['sideshow_pending_product.uuid'], name=op.f('fk_sideshow_batch_neworder_row_pending_product_uuid_pending_product')),
|
||||||
|
sa.PrimaryKeyConstraint('uuid', name=op.f('pk_sideshow_batch_neworder_row'))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
|
||||||
|
# sideshow_batch_neworder*
|
||||||
|
op.drop_table('sideshow_batch_neworder_row')
|
||||||
|
op.drop_table('sideshow_batch_neworder')
|
||||||
|
|
||||||
|
# sideshow_order_item
|
||||||
|
op.drop_table('sideshow_order_item')
|
||||||
|
|
||||||
|
# sideshow_order
|
||||||
|
op.drop_table('sideshow_order')
|
||||||
|
|
||||||
|
# sideshow_pending_product
|
||||||
|
op.drop_table('sideshow_pending_product')
|
||||||
|
|
||||||
|
# sideshow_pending_customer
|
||||||
|
op.drop_table('sideshow_pending_customer')
|
||||||
|
|
||||||
|
# enums
|
||||||
|
sa.Enum('PENDING', 'READY', 'RESOLVED', name='pendingproductstatus').drop(op.get_bind())
|
||||||
|
sa.Enum('PENDING', 'READY', 'RESOLVED', name='pendingcustomerstatus').drop(op.get_bind())
|
|
@ -22,9 +22,32 @@
|
||||||
################################################################################
|
################################################################################
|
||||||
"""
|
"""
|
||||||
Sideshow data models
|
Sideshow data models
|
||||||
|
|
||||||
|
This is the default :term:`app model` module for Sideshow.
|
||||||
|
|
||||||
|
This namespace exposes everything from
|
||||||
|
:mod:`wuttjamaican:wuttjamaican.db.model`, plus the following.
|
||||||
|
|
||||||
|
Primary :term:`data models <data model>`:
|
||||||
|
|
||||||
|
* :class:`~sideshow.db.model.orders.Order`
|
||||||
|
* :class:`~sideshow.db.model.orders.OrderItem`
|
||||||
|
* :class:`~sideshow.db.model.customers.PendingCustomer`
|
||||||
|
* :class:`~sideshow.db.model.products.PendingProduct`
|
||||||
|
|
||||||
|
And the :term:`batch` models:
|
||||||
|
|
||||||
|
* :class:`~sideshow.db.model.batch.neworder.NewOrderBatch`
|
||||||
|
* :class:`~sideshow.db.model.batch.neworder.NewOrderBatchRow`
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# bring in all of wutta
|
# bring in all of wutta
|
||||||
from wuttjamaican.db.model import *
|
from wuttjamaican.db.model import *
|
||||||
|
|
||||||
# TODO: import other/custom models here...
|
# sideshow models
|
||||||
|
from .customers import PendingCustomer
|
||||||
|
from .products import PendingProduct
|
||||||
|
from .orders import Order, OrderItem
|
||||||
|
|
||||||
|
# batch models
|
||||||
|
from .batch.neworder import NewOrderBatch, NewOrderBatchRow
|
||||||
|
|
0
src/sideshow/db/model/batch/__init__.py
Normal file
0
src/sideshow/db/model/batch/__init__.py
Normal file
310
src/sideshow/db/model/batch/neworder.py
Normal file
310
src/sideshow/db/model/batch/neworder.py
Normal file
|
@ -0,0 +1,310 @@
|
||||||
|
# -*- coding: utf-8; -*-
|
||||||
|
################################################################################
|
||||||
|
#
|
||||||
|
# Sideshow -- Case/Special Order Tracker
|
||||||
|
# Copyright © 2024 Lance Edgar
|
||||||
|
#
|
||||||
|
# This file is part of Sideshow.
|
||||||
|
#
|
||||||
|
# Sideshow is free software: you can redistribute it and/or modify it
|
||||||
|
# under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# Sideshow is distributed in the hope that it will be useful, but
|
||||||
|
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||||
|
# General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with Sideshow. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
################################################################################
|
||||||
|
"""
|
||||||
|
Data models for New Order Batch
|
||||||
|
|
||||||
|
* :class:`NewOrderBatch`
|
||||||
|
* :class:`NewOrderBatchRow`
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import orm
|
||||||
|
from sqlalchemy.ext.declarative import declared_attr
|
||||||
|
|
||||||
|
from wuttjamaican.db import model
|
||||||
|
|
||||||
|
|
||||||
|
class NewOrderBatch(model.BatchMixin, model.Base):
|
||||||
|
"""
|
||||||
|
:term:`Batch <batch>` used for entering new :term:`orders <order>`
|
||||||
|
into the system. Each batch ultimately becomes an
|
||||||
|
:class:`~sideshow.db.model.orders.Order`.
|
||||||
|
|
||||||
|
See also :class:`~sideshow.batch.neworder.NewOrderBatchHandler`
|
||||||
|
which is the default :term:`batch handler` for this :term:`batch
|
||||||
|
type`.
|
||||||
|
|
||||||
|
Generic batch attributes (undocumented below) are inherited from
|
||||||
|
:class:`~wuttjamaican:wuttjamaican.db.model.batch.BatchMixin`.
|
||||||
|
"""
|
||||||
|
__tablename__ = 'sideshow_batch_neworder'
|
||||||
|
__batchrow_class__ = 'NewOrderBatchRow'
|
||||||
|
|
||||||
|
batch_type = 'neworder'
|
||||||
|
"""
|
||||||
|
Official :term:`batch type` key.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@declared_attr
|
||||||
|
def __table_args__(cls):
|
||||||
|
return cls.__default_table_args__() + (
|
||||||
|
sa.ForeignKeyConstraint(['pending_customer_uuid'], ['sideshow_pending_customer.uuid']),
|
||||||
|
)
|
||||||
|
|
||||||
|
STATUS_OK = 1
|
||||||
|
|
||||||
|
STATUS = {
|
||||||
|
STATUS_OK : "ok",
|
||||||
|
}
|
||||||
|
|
||||||
|
store_id = sa.Column(sa.String(length=10), nullable=True, doc="""
|
||||||
|
ID of the store to which the order pertains, if applicable.
|
||||||
|
""")
|
||||||
|
|
||||||
|
customer_id = sa.Column(sa.String(length=20), nullable=True, doc="""
|
||||||
|
ID of the proper customer account to which the order pertains, if
|
||||||
|
applicable.
|
||||||
|
|
||||||
|
This will be set only when an "existing" customer account can be
|
||||||
|
selected for the order. See also :attr:`pending_customer`.
|
||||||
|
""")
|
||||||
|
|
||||||
|
pending_customer_uuid = sa.Column(model.UUID(), nullable=True)
|
||||||
|
|
||||||
|
@declared_attr
|
||||||
|
def pending_customer(cls):
|
||||||
|
return orm.relationship(
|
||||||
|
'PendingCustomer',
|
||||||
|
back_populates='new_order_batches',
|
||||||
|
doc="""
|
||||||
|
Reference to the
|
||||||
|
:class:`~sideshow.db.model.customers.PendingCustomer`
|
||||||
|
record for the order, if applicable.
|
||||||
|
|
||||||
|
This is set only when making an order for a "new /
|
||||||
|
unknown" customer. See also :attr:`customer_id`.
|
||||||
|
""")
|
||||||
|
|
||||||
|
customer_name = sa.Column(sa.String(length=100), nullable=True, doc="""
|
||||||
|
Name for the customer account.
|
||||||
|
""")
|
||||||
|
|
||||||
|
phone_number = sa.Column(sa.String(length=20), nullable=True, doc="""
|
||||||
|
Phone number for the customer.
|
||||||
|
""")
|
||||||
|
|
||||||
|
email_address = sa.Column(sa.String(length=255), nullable=True, doc="""
|
||||||
|
Email address for the customer.
|
||||||
|
""")
|
||||||
|
|
||||||
|
total_price = sa.Column(sa.Numeric(precision=10, scale=3), nullable=True, doc="""
|
||||||
|
Full price (not including tax etc.) for all items on the order.
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
class NewOrderBatchRow(model.BatchRowMixin, model.Base):
|
||||||
|
"""
|
||||||
|
Row of data within a :class:`NewOrderBatch`. Each row ultimately
|
||||||
|
becomes an :class:`~sideshow.db.model.orders.OrderItem`.
|
||||||
|
|
||||||
|
Generic row attributes (undocumented below) are inherited from
|
||||||
|
:class:`~wuttjamaican:wuttjamaican.db.model.batch.BatchRowMixin`.
|
||||||
|
"""
|
||||||
|
__tablename__ = 'sideshow_batch_neworder_row'
|
||||||
|
__batch_class__ = NewOrderBatch
|
||||||
|
|
||||||
|
@declared_attr
|
||||||
|
def __table_args__(cls):
|
||||||
|
return cls.__default_table_args__() + (
|
||||||
|
sa.ForeignKeyConstraint(['pending_product_uuid'], ['sideshow_pending_product.uuid']),
|
||||||
|
)
|
||||||
|
|
||||||
|
STATUS_OK = 1
|
||||||
|
"""
|
||||||
|
This is the default value for :attr:`status_code`. All rows are
|
||||||
|
considered "OK" if they have either a :attr:`product_id` or
|
||||||
|
:attr:`pending_product`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
STATUS_MISSING_PRODUCT = 2
|
||||||
|
"""
|
||||||
|
Status code indicating the row has no :attr:`product_id` or
|
||||||
|
:attr:`pending_product` set.
|
||||||
|
"""
|
||||||
|
|
||||||
|
STATUS_MISSING_ORDER_QTY = 3
|
||||||
|
"""
|
||||||
|
Status code indicating the row has no :attr:`order_qty` and/or
|
||||||
|
:attr:`order_uom` set.
|
||||||
|
"""
|
||||||
|
|
||||||
|
STATUS = {
|
||||||
|
STATUS_OK : "ok",
|
||||||
|
STATUS_MISSING_PRODUCT : "missing product",
|
||||||
|
STATUS_MISSING_ORDER_QTY : "missing order qty/uom",
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
Dict of possible status code -> label options.
|
||||||
|
"""
|
||||||
|
|
||||||
|
product_id = sa.Column(sa.String(length=20), nullable=True, doc="""
|
||||||
|
ID of the true product which the order item represents, if
|
||||||
|
applicable.
|
||||||
|
|
||||||
|
This will be set only when an "existing" product can be selected
|
||||||
|
for the order. See also :attr:`pending_product`.
|
||||||
|
""")
|
||||||
|
|
||||||
|
pending_product_uuid = sa.Column(model.UUID(), nullable=True)
|
||||||
|
|
||||||
|
@declared_attr
|
||||||
|
def pending_product(cls):
|
||||||
|
return orm.relationship(
|
||||||
|
'PendingProduct',
|
||||||
|
back_populates='new_order_batch_rows',
|
||||||
|
doc="""
|
||||||
|
Reference to the
|
||||||
|
:class:`~sideshow.db.model.products.PendingProduct` record
|
||||||
|
for the order item, if applicable.
|
||||||
|
|
||||||
|
This is set only when making an order for a "new /
|
||||||
|
unknown" product. See also :attr:`product_id`.
|
||||||
|
""")
|
||||||
|
|
||||||
|
product_scancode = sa.Column(sa.String(length=14), nullable=True, doc="""
|
||||||
|
Scancode for the product, as string.
|
||||||
|
|
||||||
|
.. note::
|
||||||
|
|
||||||
|
This column allows 14 chars, so can store a full GPC with check
|
||||||
|
digit. However as of writing the actual format used here does
|
||||||
|
not matter to Sideshow logic; "anything" should work.
|
||||||
|
|
||||||
|
That may change eventually, depending on POS integration
|
||||||
|
scenarios that come up. Maybe a config option to declare
|
||||||
|
whether check digit should be included or not, etc.
|
||||||
|
""")
|
||||||
|
|
||||||
|
product_brand = sa.Column(sa.String(length=100), nullable=True, doc="""
|
||||||
|
Brand name for the product - up to 100 chars.
|
||||||
|
""")
|
||||||
|
|
||||||
|
product_description = sa.Column(sa.String(length=255), nullable=True, doc="""
|
||||||
|
Description for the product - up to 255 chars.
|
||||||
|
""")
|
||||||
|
|
||||||
|
product_size = sa.Column(sa.String(length=30), nullable=True, doc="""
|
||||||
|
Size of the product, as string - up to 30 chars.
|
||||||
|
""")
|
||||||
|
|
||||||
|
product_weighed = sa.Column(sa.Boolean(), nullable=True, doc="""
|
||||||
|
Flag indicating the product is sold by weight; default is null.
|
||||||
|
""")
|
||||||
|
|
||||||
|
department_id = sa.Column(sa.String(length=10), nullable=True, doc="""
|
||||||
|
ID of the department to which the product belongs, if known.
|
||||||
|
""")
|
||||||
|
|
||||||
|
department_name = sa.Column(sa.String(length=30), nullable=True, doc="""
|
||||||
|
Name of the department to which the product belongs, if known.
|
||||||
|
""")
|
||||||
|
|
||||||
|
special_order = sa.Column(sa.Boolean(), nullable=True, doc="""
|
||||||
|
Flag indicating the item is a "special order" - e.g. something not
|
||||||
|
normally carried by the store. Default is null.
|
||||||
|
""")
|
||||||
|
|
||||||
|
case_size = sa.Column(sa.Numeric(precision=10, scale=4), nullable=True, doc="""
|
||||||
|
Case pack count for the product, if known.
|
||||||
|
|
||||||
|
If this is not set, then customer cannot order a "case" of the item.
|
||||||
|
""")
|
||||||
|
|
||||||
|
order_qty = sa.Column(sa.Numeric(precision=10, scale=4), nullable=False, doc="""
|
||||||
|
Quantity (as decimal) of product being ordered.
|
||||||
|
|
||||||
|
This must be interpreted along with :attr:`order_uom` to determine
|
||||||
|
the *complete* order quantity, e.g. "2 cases".
|
||||||
|
""")
|
||||||
|
|
||||||
|
order_uom = sa.Column(sa.String(length=10), nullable=False, doc="""
|
||||||
|
Code indicating the unit of measure for product being ordered.
|
||||||
|
|
||||||
|
This should be one of the codes from
|
||||||
|
:data:`~sideshow.enum.ORDER_UOM`.
|
||||||
|
|
||||||
|
Sideshow will treat :data:`~sideshow.enum.ORDER_UOM_CASE`
|
||||||
|
differently but :data:`~sideshow.enum.ORDER_UOM_UNIT` and others
|
||||||
|
are all treated the same (i.e. "unit" is assumed).
|
||||||
|
""")
|
||||||
|
|
||||||
|
unit_cost = sa.Column(sa.Numeric(precision=9, scale=5), nullable=True, doc="""
|
||||||
|
Cost of goods amount for one "unit" (not "case") of the product,
|
||||||
|
as decimal to 4 places.
|
||||||
|
""")
|
||||||
|
|
||||||
|
unit_price_reg = sa.Column(sa.Numeric(precision=8, scale=3), nullable=True, doc="""
|
||||||
|
Regular price for the item unit. Unless a sale is in effect,
|
||||||
|
:attr:`unit_price_quoted` will typically match this value.
|
||||||
|
""")
|
||||||
|
|
||||||
|
unit_price_sale = sa.Column(sa.Numeric(precision=8, scale=3), nullable=True, doc="""
|
||||||
|
Sale price for the item unit, if applicable. If set, then
|
||||||
|
:attr:`unit_price_quoted` will typically match this value. See
|
||||||
|
also :attr:`sale_ends`.
|
||||||
|
""")
|
||||||
|
|
||||||
|
sale_ends = sa.Column(sa.DateTime(timezone=True), nullable=True, doc="""
|
||||||
|
End date/time for the sale in effect, if any.
|
||||||
|
|
||||||
|
This is only relevant if :attr:`unit_price_sale` is set.
|
||||||
|
""")
|
||||||
|
|
||||||
|
unit_price_quoted = sa.Column(sa.Numeric(precision=8, scale=3), nullable=True, doc="""
|
||||||
|
Quoted price for the item unit. This is the "effective" unit
|
||||||
|
price, which is used to calculate :attr:`total_price`.
|
||||||
|
|
||||||
|
This price does *not* reflect the :attr:`discount_percent`. It
|
||||||
|
normally should match either :attr:`unit_price_reg` or
|
||||||
|
:attr:`unit_price_sale`.
|
||||||
|
|
||||||
|
See also :attr:`case_price_quoted`, if applicable.
|
||||||
|
""")
|
||||||
|
|
||||||
|
case_price_quoted = sa.Column(sa.Numeric(precision=8, scale=3), nullable=True, doc="""
|
||||||
|
Quoted price for a "case" of the item, if applicable.
|
||||||
|
|
||||||
|
This is mostly for display purposes; :attr:`unit_price_quoted` is
|
||||||
|
used for calculations.
|
||||||
|
""")
|
||||||
|
|
||||||
|
discount_percent = sa.Column(sa.Numeric(precision=5, scale=3), nullable=True, doc="""
|
||||||
|
Discount percent to apply when calculating :attr:`total_price`, if
|
||||||
|
applicable.
|
||||||
|
""")
|
||||||
|
|
||||||
|
total_price = sa.Column(sa.Numeric(precision=8, scale=3), nullable=True, doc="""
|
||||||
|
Full price (not including tax etc.) which the customer is quoted
|
||||||
|
for the order item.
|
||||||
|
|
||||||
|
This is calculated using values from:
|
||||||
|
|
||||||
|
* :attr:`unit_price_quoted`
|
||||||
|
* :attr:`order_qty`
|
||||||
|
* :attr:`order_uom`
|
||||||
|
* :attr:`case_size`
|
||||||
|
* :attr:`discount_percent`
|
||||||
|
""")
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return str(self.pending_product or self.product_description or "")
|
112
src/sideshow/db/model/customers.py
Normal file
112
src/sideshow/db/model/customers.py
Normal file
|
@ -0,0 +1,112 @@
|
||||||
|
# -*- coding: utf-8; -*-
|
||||||
|
################################################################################
|
||||||
|
#
|
||||||
|
# Sideshow -- Case/Special Order Tracker
|
||||||
|
# Copyright © 2024 Lance Edgar
|
||||||
|
#
|
||||||
|
# This file is part of Sideshow.
|
||||||
|
#
|
||||||
|
# Sideshow is free software: you can redistribute it and/or modify it
|
||||||
|
# under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# Sideshow is distributed in the hope that it will be useful, but
|
||||||
|
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||||
|
# General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with Sideshow. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
################################################################################
|
||||||
|
"""
|
||||||
|
Data models for Customers
|
||||||
|
"""
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import orm
|
||||||
|
|
||||||
|
from wuttjamaican.db import model
|
||||||
|
|
||||||
|
from sideshow.enum import PendingCustomerStatus
|
||||||
|
|
||||||
|
|
||||||
|
class PendingCustomer(model.Base):
|
||||||
|
"""
|
||||||
|
A "pending" customer record, used when entering an :term:`order`
|
||||||
|
for new/unknown customer.
|
||||||
|
"""
|
||||||
|
__tablename__ = 'sideshow_pending_customer'
|
||||||
|
|
||||||
|
uuid = model.uuid_column()
|
||||||
|
|
||||||
|
customer_id = sa.Column(sa.String(length=20), nullable=True, doc="""
|
||||||
|
ID of the proper customer account associated with this record, if
|
||||||
|
applicable.
|
||||||
|
""")
|
||||||
|
|
||||||
|
full_name = sa.Column(sa.String(length=100), nullable=True, doc="""
|
||||||
|
Full display name for the customer account.
|
||||||
|
""")
|
||||||
|
|
||||||
|
first_name = sa.Column(sa.String(length=50), nullable=True, doc="""
|
||||||
|
First name of the customer.
|
||||||
|
""")
|
||||||
|
|
||||||
|
last_name = sa.Column(sa.String(length=50), nullable=True, doc="""
|
||||||
|
Last name of the customer.
|
||||||
|
""")
|
||||||
|
|
||||||
|
phone_number = sa.Column(sa.String(length=20), nullable=True, doc="""
|
||||||
|
Phone number for the customer.
|
||||||
|
""")
|
||||||
|
|
||||||
|
email_address = sa.Column(sa.String(length=255), nullable=True, doc="""
|
||||||
|
Email address for the customer.
|
||||||
|
""")
|
||||||
|
|
||||||
|
status = sa.Column(sa.Enum(PendingCustomerStatus), nullable=False, doc="""
|
||||||
|
Status code for the customer record.
|
||||||
|
""")
|
||||||
|
|
||||||
|
created = sa.Column(sa.DateTime(timezone=True), nullable=False,
|
||||||
|
default=datetime.datetime.now, doc="""
|
||||||
|
Timestamp when the customer record was created.
|
||||||
|
""")
|
||||||
|
|
||||||
|
created_by_uuid = model.uuid_fk_column('user.uuid', nullable=False)
|
||||||
|
created_by = orm.relationship(
|
||||||
|
model.User,
|
||||||
|
cascade_backrefs=False,
|
||||||
|
doc="""
|
||||||
|
Reference to the
|
||||||
|
:class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who
|
||||||
|
created the customer record.
|
||||||
|
""")
|
||||||
|
|
||||||
|
orders = orm.relationship(
|
||||||
|
'Order',
|
||||||
|
order_by='Order.order_id.desc()',
|
||||||
|
cascade_backrefs=False,
|
||||||
|
back_populates='pending_customer',
|
||||||
|
doc="""
|
||||||
|
List of :class:`~sideshow.db.model.orders.Order` records
|
||||||
|
associated with this customer.
|
||||||
|
""")
|
||||||
|
|
||||||
|
new_order_batches = orm.relationship(
|
||||||
|
'NewOrderBatch',
|
||||||
|
order_by='NewOrderBatch.id.desc()',
|
||||||
|
cascade_backrefs=False,
|
||||||
|
back_populates='pending_customer',
|
||||||
|
doc="""
|
||||||
|
List of
|
||||||
|
:class:`~sideshow.db.model.batch.neworder.NewOrderBatch`
|
||||||
|
records associated with this customer.
|
||||||
|
""")
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.full_name or ""
|
314
src/sideshow/db/model/orders.py
Normal file
314
src/sideshow/db/model/orders.py
Normal file
|
@ -0,0 +1,314 @@
|
||||||
|
# -*- coding: utf-8; -*-
|
||||||
|
################################################################################
|
||||||
|
#
|
||||||
|
# Sideshow -- Case/Special Order Tracker
|
||||||
|
# Copyright © 2024 Lance Edgar
|
||||||
|
#
|
||||||
|
# This file is part of Sideshow.
|
||||||
|
#
|
||||||
|
# Sideshow is free software: you can redistribute it and/or modify it
|
||||||
|
# under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# Sideshow is distributed in the hope that it will be useful, but
|
||||||
|
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||||
|
# General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with Sideshow. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
################################################################################
|
||||||
|
"""
|
||||||
|
Data models for Orders
|
||||||
|
"""
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import orm
|
||||||
|
from sqlalchemy.ext.orderinglist import ordering_list
|
||||||
|
|
||||||
|
from wuttjamaican.db import model
|
||||||
|
|
||||||
|
|
||||||
|
class Order(model.Base):
|
||||||
|
"""
|
||||||
|
Represents an :term:`order` for a customer. Each order has one or
|
||||||
|
more :attr:`items`.
|
||||||
|
|
||||||
|
Usually, orders are created by way of a
|
||||||
|
:class:`~sideshow.db.model.batch.neworder.NewOrderBatch`.
|
||||||
|
"""
|
||||||
|
__tablename__ = 'sideshow_order'
|
||||||
|
|
||||||
|
# TODO: this feels a bit hacky yet but it does avoid problems
|
||||||
|
# showing the Orders grid for a PendingCustomer
|
||||||
|
__colanderalchemy_config__ = {
|
||||||
|
'excludes': ['items'],
|
||||||
|
}
|
||||||
|
|
||||||
|
uuid = model.uuid_column()
|
||||||
|
|
||||||
|
order_id = sa.Column(sa.Integer(), nullable=False, doc="""
|
||||||
|
Unique ID for the order.
|
||||||
|
|
||||||
|
When the order is created from New Order Batch, this order ID will
|
||||||
|
match the batch ID.
|
||||||
|
""")
|
||||||
|
|
||||||
|
store_id = sa.Column(sa.String(length=10), nullable=True, doc="""
|
||||||
|
ID of the store to which the order pertains, if applicable.
|
||||||
|
""")
|
||||||
|
|
||||||
|
customer_id = sa.Column(sa.String(length=20), nullable=True, doc="""
|
||||||
|
ID of the proper customer account to which the order pertains, if
|
||||||
|
applicable.
|
||||||
|
|
||||||
|
This will be set only when an "existing" customer account can be
|
||||||
|
assigned for the order. See also :attr:`pending_customer`.
|
||||||
|
""")
|
||||||
|
|
||||||
|
pending_customer_uuid = model.uuid_fk_column('sideshow_pending_customer.uuid', nullable=True)
|
||||||
|
pending_customer = orm.relationship(
|
||||||
|
'PendingCustomer',
|
||||||
|
cascade_backrefs=False,
|
||||||
|
back_populates='orders',
|
||||||
|
doc="""
|
||||||
|
Reference to the
|
||||||
|
:class:`~sideshow.db.model.customers.PendingCustomer` record
|
||||||
|
for the order, if applicable.
|
||||||
|
|
||||||
|
This is set only when the order is for a "new / unknown"
|
||||||
|
customer. See also :attr:`customer_id`.
|
||||||
|
""")
|
||||||
|
|
||||||
|
customer_name = sa.Column(sa.String(length=100), nullable=True, doc="""
|
||||||
|
Name for the customer account.
|
||||||
|
""")
|
||||||
|
|
||||||
|
phone_number = sa.Column(sa.String(length=20), nullable=True, doc="""
|
||||||
|
Phone number for the customer.
|
||||||
|
""")
|
||||||
|
|
||||||
|
email_address = sa.Column(sa.String(length=255), nullable=True, doc="""
|
||||||
|
Email address for the customer.
|
||||||
|
""")
|
||||||
|
|
||||||
|
total_price = sa.Column(sa.Numeric(precision=10, scale=3), nullable=True, doc="""
|
||||||
|
Full price (not including tax etc.) for all items on the order.
|
||||||
|
""")
|
||||||
|
|
||||||
|
created = sa.Column(sa.DateTime(timezone=True), nullable=False, default=datetime.datetime.now, doc="""
|
||||||
|
Timestamp when the order was created.
|
||||||
|
|
||||||
|
If the order is created via New Order Batch, this will match the
|
||||||
|
batch execution timestamp.
|
||||||
|
""")
|
||||||
|
|
||||||
|
created_by_uuid = model.uuid_fk_column('user.uuid', nullable=False)
|
||||||
|
created_by = orm.relationship(
|
||||||
|
model.User,
|
||||||
|
cascade_backrefs=False,
|
||||||
|
doc="""
|
||||||
|
Reference to the
|
||||||
|
:class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who
|
||||||
|
created the order.
|
||||||
|
""")
|
||||||
|
|
||||||
|
items = orm.relationship(
|
||||||
|
'OrderItem',
|
||||||
|
collection_class=ordering_list('sequence', count_from=1),
|
||||||
|
cascade='all, delete-orphan',
|
||||||
|
cascade_backrefs=False,
|
||||||
|
back_populates='order',
|
||||||
|
doc="""
|
||||||
|
List of :class:`OrderItem` records belonging to the order.
|
||||||
|
""")
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return str(self.order_id)
|
||||||
|
|
||||||
|
|
||||||
|
class OrderItem(model.Base):
|
||||||
|
"""
|
||||||
|
Represents an :term:`order item` within an :class:`Order`.
|
||||||
|
|
||||||
|
Usually these are created from
|
||||||
|
:class:`~sideshow.db.model.batch.neworder.NewOrderBatchRow`
|
||||||
|
records.
|
||||||
|
"""
|
||||||
|
__tablename__ = 'sideshow_order_item'
|
||||||
|
|
||||||
|
uuid = model.uuid_column()
|
||||||
|
|
||||||
|
order_uuid = model.uuid_fk_column('sideshow_order.uuid', nullable=False)
|
||||||
|
order = orm.relationship(
|
||||||
|
Order,
|
||||||
|
cascade_backrefs=False,
|
||||||
|
back_populates='items',
|
||||||
|
doc="""
|
||||||
|
Reference to the :class:`Order` to which the item belongs.
|
||||||
|
""")
|
||||||
|
|
||||||
|
sequence = sa.Column(sa.Integer(), nullable=False, doc="""
|
||||||
|
1-based numeric sequence for the item, i.e. its line number within
|
||||||
|
the order.
|
||||||
|
""")
|
||||||
|
|
||||||
|
product_id = sa.Column(sa.String(length=20), nullable=True, doc="""
|
||||||
|
ID of the true product which the order item represents, if
|
||||||
|
applicable.
|
||||||
|
|
||||||
|
This will be set only when an "existing" product can be selected
|
||||||
|
for the order. See also :attr:`pending_product`.
|
||||||
|
""")
|
||||||
|
|
||||||
|
pending_product_uuid = model.uuid_fk_column('sideshow_pending_product.uuid', nullable=True)
|
||||||
|
pending_product = orm.relationship(
|
||||||
|
'PendingProduct',
|
||||||
|
cascade_backrefs=False,
|
||||||
|
back_populates='order_items',
|
||||||
|
doc="""
|
||||||
|
Reference to the
|
||||||
|
:class:`~sideshow.db.model.products.PendingProduct` record for
|
||||||
|
the order item, if applicable.
|
||||||
|
|
||||||
|
This is set only when the order item is for a "new / unknown"
|
||||||
|
product. See also :attr:`product_id`.
|
||||||
|
""")
|
||||||
|
|
||||||
|
product_scancode = sa.Column(sa.String(length=14), nullable=True, doc="""
|
||||||
|
Scancode for the product, as string.
|
||||||
|
|
||||||
|
.. note::
|
||||||
|
|
||||||
|
This column allows 14 chars, so can store a full GPC with check
|
||||||
|
digit. However as of writing the actual format used here does
|
||||||
|
not matter to Sideshow logic; "anything" should work.
|
||||||
|
|
||||||
|
That may change eventually, depending on POS integration
|
||||||
|
scenarios that come up. Maybe a config option to declare
|
||||||
|
whether check digit should be included or not, etc.
|
||||||
|
""")
|
||||||
|
|
||||||
|
product_brand = sa.Column(sa.String(length=100), nullable=True, doc="""
|
||||||
|
Brand name for the product - up to 100 chars.
|
||||||
|
""")
|
||||||
|
|
||||||
|
product_description = sa.Column(sa.String(length=255), nullable=True, doc="""
|
||||||
|
Description for the product - up to 255 chars.
|
||||||
|
""")
|
||||||
|
|
||||||
|
product_size = sa.Column(sa.String(length=30), nullable=True, doc="""
|
||||||
|
Size of the product, as string - up to 30 chars.
|
||||||
|
""")
|
||||||
|
|
||||||
|
product_weighed = sa.Column(sa.Boolean(), nullable=True, doc="""
|
||||||
|
Flag indicating the product is sold by weight; default is null.
|
||||||
|
""")
|
||||||
|
|
||||||
|
department_id = sa.Column(sa.String(length=10), nullable=True, doc="""
|
||||||
|
ID of the department to which the product belongs, if known.
|
||||||
|
""")
|
||||||
|
|
||||||
|
department_name = sa.Column(sa.String(length=30), nullable=True, doc="""
|
||||||
|
Name of the department to which the product belongs, if known.
|
||||||
|
""")
|
||||||
|
|
||||||
|
special_order = sa.Column(sa.Boolean(), nullable=True, doc="""
|
||||||
|
Flag indicating the item is a "special order" - e.g. something not
|
||||||
|
normally carried by the store. Default is null.
|
||||||
|
""")
|
||||||
|
|
||||||
|
case_size = sa.Column(sa.Numeric(precision=10, scale=4), nullable=True, doc="""
|
||||||
|
Case pack count for the product, if known.
|
||||||
|
""")
|
||||||
|
|
||||||
|
order_qty = sa.Column(sa.Numeric(precision=10, scale=4), nullable=False, doc="""
|
||||||
|
Quantity (as decimal) of product being ordered.
|
||||||
|
|
||||||
|
This must be interpreted along with :attr:`order_uom` to determine
|
||||||
|
the *complete* order quantity, e.g. "2 cases".
|
||||||
|
""")
|
||||||
|
|
||||||
|
order_uom = sa.Column(sa.String(length=10), nullable=False, doc="""
|
||||||
|
Code indicating the unit of measure for product being ordered.
|
||||||
|
|
||||||
|
This should be one of the codes from
|
||||||
|
:data:`~sideshow.enum.ORDER_UOM`.
|
||||||
|
""")
|
||||||
|
|
||||||
|
unit_cost = sa.Column(sa.Numeric(precision=9, scale=5), nullable=True, doc="""
|
||||||
|
Cost of goods amount for one "unit" (not "case") of the product,
|
||||||
|
as decimal to 4 places.
|
||||||
|
""")
|
||||||
|
|
||||||
|
unit_price_reg = sa.Column(sa.Numeric(precision=8, scale=3), nullable=True, doc="""
|
||||||
|
Regular price for the item unit. Unless a sale is in effect,
|
||||||
|
:attr:`unit_price_quoted` will typically match this value.
|
||||||
|
""")
|
||||||
|
|
||||||
|
unit_price_sale = sa.Column(sa.Numeric(precision=8, scale=3), nullable=True, doc="""
|
||||||
|
Sale price for the item unit, if applicable. If set, then
|
||||||
|
:attr:`unit_price_quoted` will typically match this value. See
|
||||||
|
also :attr:`sale_ends`.
|
||||||
|
""")
|
||||||
|
|
||||||
|
sale_ends = sa.Column(sa.DateTime(timezone=True), nullable=True, doc="""
|
||||||
|
End date/time for the sale in effect, if any.
|
||||||
|
|
||||||
|
This is only relevant if :attr:`unit_price_sale` is set.
|
||||||
|
""")
|
||||||
|
|
||||||
|
unit_price_quoted = sa.Column(sa.Numeric(precision=8, scale=3), nullable=True, doc="""
|
||||||
|
Quoted price for the item unit. This is the "effective" unit
|
||||||
|
price, which is used to calculate :attr:`total_price`.
|
||||||
|
|
||||||
|
This price does *not* reflect the :attr:`discount_percent`. It
|
||||||
|
normally should match either :attr:`unit_price_reg` or
|
||||||
|
:attr:`unit_price_sale`.
|
||||||
|
""")
|
||||||
|
|
||||||
|
case_price_quoted = sa.Column(sa.Numeric(precision=8, scale=3), nullable=True, doc="""
|
||||||
|
Quoted price for a "case" of the item, if applicable.
|
||||||
|
|
||||||
|
This is mostly for display purposes; :attr:`unit_price_quoted` is
|
||||||
|
used for calculations.
|
||||||
|
""")
|
||||||
|
|
||||||
|
discount_percent = sa.Column(sa.Numeric(precision=5, scale=3), nullable=True, doc="""
|
||||||
|
Discount percent to apply when calculating :attr:`total_price`, if
|
||||||
|
applicable.
|
||||||
|
""")
|
||||||
|
|
||||||
|
total_price = sa.Column(sa.Numeric(precision=8, scale=3), nullable=True, doc="""
|
||||||
|
Full price (not including tax etc.) which the customer is quoted
|
||||||
|
for the order item.
|
||||||
|
|
||||||
|
This is calculated using values from:
|
||||||
|
|
||||||
|
* :attr:`unit_price_quoted`
|
||||||
|
* :attr:`order_qty`
|
||||||
|
* :attr:`order_uom`
|
||||||
|
* :attr:`case_size`
|
||||||
|
* :attr:`discount_percent`
|
||||||
|
""")
|
||||||
|
|
||||||
|
status_code = sa.Column(sa.Integer(), nullable=False, doc="""
|
||||||
|
Code indicating current status for the order item.
|
||||||
|
""")
|
||||||
|
|
||||||
|
paid_amount = sa.Column(sa.Numeric(precision=8, scale=3), nullable=False, default=0, doc="""
|
||||||
|
Amount which the customer has paid toward the :attr:`total_price`
|
||||||
|
of the item.
|
||||||
|
""")
|
||||||
|
|
||||||
|
payment_transaction_number = sa.Column(sa.String(length=20), nullable=True, doc="""
|
||||||
|
Transaction number in which payment for the order was taken, if
|
||||||
|
applicable/known.
|
||||||
|
""")
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return str(self.pending_product or self.product_description or "")
|
173
src/sideshow/db/model/products.py
Normal file
173
src/sideshow/db/model/products.py
Normal file
|
@ -0,0 +1,173 @@
|
||||||
|
# -*- coding: utf-8; -*-
|
||||||
|
################################################################################
|
||||||
|
#
|
||||||
|
# Sideshow -- Case/Special Order Tracker
|
||||||
|
# Copyright © 2024 Lance Edgar
|
||||||
|
#
|
||||||
|
# This file is part of Sideshow.
|
||||||
|
#
|
||||||
|
# Sideshow is free software: you can redistribute it and/or modify it
|
||||||
|
# under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# Sideshow is distributed in the hope that it will be useful, but
|
||||||
|
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||||
|
# General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with Sideshow. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
################################################################################
|
||||||
|
"""
|
||||||
|
Data models for Products
|
||||||
|
"""
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import orm
|
||||||
|
|
||||||
|
from wuttjamaican.db import model
|
||||||
|
|
||||||
|
from sideshow.enum import PendingProductStatus
|
||||||
|
|
||||||
|
|
||||||
|
class PendingProduct(model.Base):
|
||||||
|
"""
|
||||||
|
A "pending" product record, used when entering an :term:`order
|
||||||
|
item` for new/unknown product.
|
||||||
|
"""
|
||||||
|
__tablename__ = 'sideshow_pending_product'
|
||||||
|
|
||||||
|
uuid = model.uuid_column()
|
||||||
|
|
||||||
|
product_id = sa.Column(sa.String(length=20), nullable=True, doc="""
|
||||||
|
ID of the true product associated with this record, if applicable.
|
||||||
|
""")
|
||||||
|
|
||||||
|
scancode = sa.Column(sa.String(length=14), nullable=True, doc="""
|
||||||
|
Scancode for the product, as string.
|
||||||
|
|
||||||
|
.. note::
|
||||||
|
|
||||||
|
This column allows 14 chars, so can store a full GPC with check
|
||||||
|
digit. However as of writing the actual format used here does
|
||||||
|
not matter to Sideshow logic; "anything" should work.
|
||||||
|
|
||||||
|
That may change eventually, depending on POS integration
|
||||||
|
scenarios that come up. Maybe a config option to declare
|
||||||
|
whether check digit should be included or not, etc.
|
||||||
|
""")
|
||||||
|
|
||||||
|
brand_name = sa.Column(sa.String(length=100), nullable=True, doc="""
|
||||||
|
Brand name for the product - up to 100 chars.
|
||||||
|
""")
|
||||||
|
|
||||||
|
description = sa.Column(sa.String(length=255), nullable=True, doc="""
|
||||||
|
Description for the product - up to 255 chars.
|
||||||
|
""")
|
||||||
|
|
||||||
|
size = sa.Column(sa.String(length=30), nullable=True, doc="""
|
||||||
|
Size of the product, as string - up to 30 chars.
|
||||||
|
""")
|
||||||
|
|
||||||
|
weighed = sa.Column(sa.Boolean(), nullable=True, doc="""
|
||||||
|
Flag indicating the product is sold by weight; default is null.
|
||||||
|
""")
|
||||||
|
|
||||||
|
department_id = sa.Column(sa.String(length=10), nullable=True, doc="""
|
||||||
|
ID of the department to which the product belongs, if known.
|
||||||
|
""")
|
||||||
|
|
||||||
|
department_name = sa.Column(sa.String(length=30), nullable=True, doc="""
|
||||||
|
Name of the department to which the product belongs, if known.
|
||||||
|
""")
|
||||||
|
|
||||||
|
special_order = sa.Column(sa.Boolean(), nullable=True, doc="""
|
||||||
|
Flag indicating the item is a "special order" - e.g. something not
|
||||||
|
normally carried by the store. Default is null.
|
||||||
|
""")
|
||||||
|
|
||||||
|
vendor_name = sa.Column(sa.String(length=50), nullable=True, doc="""
|
||||||
|
Name of vendor from which product may be purchased, if known. See
|
||||||
|
also :attr:`vendor_item_code`.
|
||||||
|
""")
|
||||||
|
|
||||||
|
vendor_item_code = sa.Column(sa.String(length=20), nullable=True, doc="""
|
||||||
|
Item code (SKU) to use when ordering this product from the vendor
|
||||||
|
identified by :attr:`vendor_name`, if known.
|
||||||
|
""")
|
||||||
|
|
||||||
|
case_size = sa.Column(sa.Numeric(precision=9, scale=4), nullable=True, doc="""
|
||||||
|
Case pack count for the product, if known.
|
||||||
|
""")
|
||||||
|
|
||||||
|
unit_cost = sa.Column(sa.Numeric(precision=9, scale=5), nullable=True, doc="""
|
||||||
|
Cost of goods amount for one "unit" (not "case") of the product,
|
||||||
|
as decimal to 4 places.
|
||||||
|
""")
|
||||||
|
|
||||||
|
unit_price_reg = sa.Column(sa.Numeric(precision=8, scale=3), nullable=True, doc="""
|
||||||
|
Regular price for a "unit" of the product.
|
||||||
|
""")
|
||||||
|
|
||||||
|
notes = sa.Column(sa.Text(), nullable=True, doc="""
|
||||||
|
Arbitrary notes regarding the product, if applicable.
|
||||||
|
""")
|
||||||
|
|
||||||
|
status = sa.Column(sa.Enum(PendingProductStatus), nullable=False, doc="""
|
||||||
|
Status code for the product record.
|
||||||
|
""")
|
||||||
|
|
||||||
|
created = sa.Column(sa.DateTime(timezone=True), nullable=False,
|
||||||
|
default=datetime.datetime.now, doc="""
|
||||||
|
Timestamp when the product record was created.
|
||||||
|
""")
|
||||||
|
|
||||||
|
created_by_uuid = model.uuid_fk_column('user.uuid', nullable=False)
|
||||||
|
created_by = orm.relationship(
|
||||||
|
model.User,
|
||||||
|
cascade_backrefs=False,
|
||||||
|
doc="""
|
||||||
|
Reference to the
|
||||||
|
:class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who
|
||||||
|
created the product record.
|
||||||
|
""")
|
||||||
|
|
||||||
|
order_items = orm.relationship(
|
||||||
|
'OrderItem',
|
||||||
|
# TODO
|
||||||
|
# order_by='NewOrderBatchRow.id.desc()',
|
||||||
|
cascade_backrefs=False,
|
||||||
|
back_populates='pending_product',
|
||||||
|
doc="""
|
||||||
|
List of :class:`~sideshow.db.model.orders.OrderItem` records
|
||||||
|
associated with this product.
|
||||||
|
""")
|
||||||
|
|
||||||
|
new_order_batch_rows = orm.relationship(
|
||||||
|
'NewOrderBatchRow',
|
||||||
|
# TODO
|
||||||
|
# order_by='NewOrderBatchRow.id.desc()',
|
||||||
|
cascade_backrefs=False,
|
||||||
|
back_populates='pending_product',
|
||||||
|
doc="""
|
||||||
|
List of
|
||||||
|
:class:`~sideshow.db.model.batch.neworder.NewOrderBatchRow`
|
||||||
|
records associated with this product.
|
||||||
|
""")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def full_description(self):
|
||||||
|
""" """
|
||||||
|
fields = [
|
||||||
|
self.brand_name or '',
|
||||||
|
self.description or '',
|
||||||
|
self.size or '']
|
||||||
|
fields = [f.strip() for f in fields if f.strip()]
|
||||||
|
return ' '.join(fields)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.full_description
|
146
src/sideshow/enum.py
Normal file
146
src/sideshow/enum.py
Normal file
|
@ -0,0 +1,146 @@
|
||||||
|
# -*- coding: utf-8; -*-
|
||||||
|
################################################################################
|
||||||
|
#
|
||||||
|
# Sideshow -- Case/Special Order Tracker
|
||||||
|
# Copyright © 2024 Lance Edgar
|
||||||
|
#
|
||||||
|
# This file is part of Sideshow.
|
||||||
|
#
|
||||||
|
# Sideshow is free software: you can redistribute it and/or modify it
|
||||||
|
# under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# Sideshow is distributed in the hope that it will be useful, but
|
||||||
|
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||||
|
# General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with Sideshow. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
################################################################################
|
||||||
|
"""
|
||||||
|
Enum Values
|
||||||
|
"""
|
||||||
|
|
||||||
|
from enum import Enum
|
||||||
|
from collections import OrderedDict
|
||||||
|
|
||||||
|
from wuttjamaican.enum import *
|
||||||
|
|
||||||
|
|
||||||
|
ORDER_UOM_CASE = 'CS'
|
||||||
|
"""
|
||||||
|
UOM code for ordering a "case" of product.
|
||||||
|
|
||||||
|
Sideshow will treat "case" orders somewhat differently as compared to
|
||||||
|
"unit" orders.
|
||||||
|
"""
|
||||||
|
|
||||||
|
ORDER_UOM_UNIT = 'EA'
|
||||||
|
"""
|
||||||
|
UOM code for ordering a "unit" of product.
|
||||||
|
|
||||||
|
This is the default "unit" UOM but in practice all others are treated
|
||||||
|
the same by Sideshow, whereas "case" orders are treated somewhat
|
||||||
|
differently.
|
||||||
|
"""
|
||||||
|
|
||||||
|
ORDER_UOM_KILOGRAM = 'KG'
|
||||||
|
"""
|
||||||
|
UOM code for ordering a "kilogram" of product.
|
||||||
|
|
||||||
|
This is treated same as "unit" by Sideshow. However it should
|
||||||
|
(probably?) only be used for items where
|
||||||
|
e.g. :attr:`~sideshow.db.model.orders.OrderItem.product_weighed` is
|
||||||
|
true.
|
||||||
|
"""
|
||||||
|
|
||||||
|
ORDER_UOM_POUND = 'LB'
|
||||||
|
"""
|
||||||
|
UOM code for ordering a "pound" of product.
|
||||||
|
|
||||||
|
This is treated same as "unit" by Sideshow. However it should
|
||||||
|
(probably?) only be used for items where
|
||||||
|
e.g. :attr:`~sideshow.db.model.orders.OrderItem.product_weighed` is
|
||||||
|
true.
|
||||||
|
"""
|
||||||
|
|
||||||
|
ORDER_UOM = OrderedDict([
|
||||||
|
(ORDER_UOM_CASE, "Cases"),
|
||||||
|
(ORDER_UOM_UNIT, "Units"),
|
||||||
|
(ORDER_UOM_KILOGRAM, "Kilograms"),
|
||||||
|
(ORDER_UOM_POUND, "Pounds"),
|
||||||
|
])
|
||||||
|
"""
|
||||||
|
Dict of possible code -> label options for ordering unit of measure.
|
||||||
|
|
||||||
|
These codes are referenced by:
|
||||||
|
|
||||||
|
* :attr:`sideshow.db.model.batch.neworder.NewOrderBatchRow.order_uom`
|
||||||
|
* :attr:`sideshow.db.model.orders.OrderItem.order_uom`
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class PendingCustomerStatus(Enum):
|
||||||
|
"""
|
||||||
|
Enum values for
|
||||||
|
:attr:`sideshow.db.model.customers.PendingCustomer.status`.
|
||||||
|
"""
|
||||||
|
PENDING = 'pending'
|
||||||
|
READY = 'ready'
|
||||||
|
RESOLVED = 'resolved'
|
||||||
|
|
||||||
|
|
||||||
|
class PendingProductStatus(Enum):
|
||||||
|
"""
|
||||||
|
Enum values for
|
||||||
|
:attr:`sideshow.db.model.products.PendingProduct.status`.
|
||||||
|
"""
|
||||||
|
PENDING = 'pending'
|
||||||
|
READY = 'ready'
|
||||||
|
RESOLVED = 'resolved'
|
||||||
|
|
||||||
|
|
||||||
|
########################################
|
||||||
|
# Order Item Status
|
||||||
|
########################################
|
||||||
|
|
||||||
|
ORDER_ITEM_STATUS_UNINITIATED = 1
|
||||||
|
ORDER_ITEM_STATUS_INITIATED = 10
|
||||||
|
ORDER_ITEM_STATUS_PAID_BEFORE = 50
|
||||||
|
# TODO: deprecate / remove this one
|
||||||
|
ORDER_ITEM_STATUS_PAID = ORDER_ITEM_STATUS_PAID_BEFORE
|
||||||
|
ORDER_ITEM_STATUS_READY = 100
|
||||||
|
ORDER_ITEM_STATUS_PLACED = 200
|
||||||
|
ORDER_ITEM_STATUS_RECEIVED = 300
|
||||||
|
ORDER_ITEM_STATUS_CONTACTED = 350
|
||||||
|
ORDER_ITEM_STATUS_CONTACT_FAILED = 375
|
||||||
|
ORDER_ITEM_STATUS_DELIVERED = 500
|
||||||
|
ORDER_ITEM_STATUS_PAID_AFTER = 550
|
||||||
|
ORDER_ITEM_STATUS_CANCELED = 900
|
||||||
|
ORDER_ITEM_STATUS_REFUND_PENDING = 910
|
||||||
|
ORDER_ITEM_STATUS_REFUNDED = 920
|
||||||
|
ORDER_ITEM_STATUS_RESTOCKED = 930
|
||||||
|
ORDER_ITEM_STATUS_EXPIRED = 940
|
||||||
|
ORDER_ITEM_STATUS_INACTIVE = 950
|
||||||
|
|
||||||
|
ORDER_ITEM_STATUS = OrderedDict([
|
||||||
|
(ORDER_ITEM_STATUS_UNINITIATED, "uninitiated"),
|
||||||
|
(ORDER_ITEM_STATUS_INITIATED, "initiated"),
|
||||||
|
(ORDER_ITEM_STATUS_PAID_BEFORE, "paid"),
|
||||||
|
(ORDER_ITEM_STATUS_READY, "ready"),
|
||||||
|
(ORDER_ITEM_STATUS_PLACED, "placed"),
|
||||||
|
(ORDER_ITEM_STATUS_RECEIVED, "received"),
|
||||||
|
(ORDER_ITEM_STATUS_CONTACTED, "contacted"),
|
||||||
|
(ORDER_ITEM_STATUS_CONTACT_FAILED, "contact failed"),
|
||||||
|
(ORDER_ITEM_STATUS_DELIVERED, "delivered"),
|
||||||
|
(ORDER_ITEM_STATUS_PAID_AFTER, "paid"),
|
||||||
|
(ORDER_ITEM_STATUS_CANCELED, "canceled"),
|
||||||
|
(ORDER_ITEM_STATUS_REFUND_PENDING, "refund pending"),
|
||||||
|
(ORDER_ITEM_STATUS_REFUNDED, "refunded"),
|
||||||
|
(ORDER_ITEM_STATUS_RESTOCKED, "restocked"),
|
||||||
|
(ORDER_ITEM_STATUS_EXPIRED, "expired"),
|
||||||
|
(ORDER_ITEM_STATUS_INACTIVE, "inactive"),
|
||||||
|
])
|
36
src/sideshow/testing.py
Normal file
36
src/sideshow/testing.py
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
# -*- coding: utf-8; -*-
|
||||||
|
################################################################################
|
||||||
|
#
|
||||||
|
# Sideshow -- Case/Special Order Tracker
|
||||||
|
# Copyright © 2024 Lance Edgar
|
||||||
|
#
|
||||||
|
# This file is part of Sideshow.
|
||||||
|
#
|
||||||
|
# Sideshow is free software: you can redistribute it and/or modify it
|
||||||
|
# under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# Sideshow is distributed in the hope that it will be useful, but
|
||||||
|
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||||
|
# General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with Sideshow. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
################################################################################
|
||||||
|
"""
|
||||||
|
Sideshow - test utilities
|
||||||
|
"""
|
||||||
|
|
||||||
|
from wuttaweb import testing as base
|
||||||
|
|
||||||
|
|
||||||
|
class WebTestCase(base.WebTestCase):
|
||||||
|
|
||||||
|
def make_config(self, **kwargs):
|
||||||
|
config = super().make_config(**kwargs)
|
||||||
|
config.setdefault('wutta.model_spec', 'sideshow.db.model')
|
||||||
|
config.setdefault('wutta.enum_spec', 'sideshow.enum')
|
||||||
|
return config
|
|
@ -43,7 +43,7 @@ def main(global_config, **settings):
|
||||||
|
|
||||||
# bring in the rest of Sideshow
|
# bring in the rest of Sideshow
|
||||||
pyramid_config.include('sideshow.web.static')
|
pyramid_config.include('sideshow.web.static')
|
||||||
pyramid_config.include('sideshow.web.subscribers')
|
pyramid_config.include('wuttaweb.subscribers')
|
||||||
pyramid_config.include('sideshow.web.views')
|
pyramid_config.include('sideshow.web.views')
|
||||||
|
|
||||||
return pyramid_config.make_wsgi_app()
|
return pyramid_config.make_wsgi_app()
|
||||||
|
|
0
src/sideshow/web/forms/__init__.py
Normal file
0
src/sideshow/web/forms/__init__.py
Normal file
101
src/sideshow/web/forms/schema.py
Normal file
101
src/sideshow/web/forms/schema.py
Normal file
|
@ -0,0 +1,101 @@
|
||||||
|
# -*- coding: utf-8; -*-
|
||||||
|
################################################################################
|
||||||
|
#
|
||||||
|
# Sideshow -- Case/Special Order Tracker
|
||||||
|
# Copyright © 2024 Lance Edgar
|
||||||
|
#
|
||||||
|
# This file is part of Sideshow.
|
||||||
|
#
|
||||||
|
# Sideshow is free software: you can redistribute it and/or modify it
|
||||||
|
# under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# Sideshow is distributed in the hope that it will be useful, but
|
||||||
|
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||||
|
# General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with Sideshow. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
################################################################################
|
||||||
|
"""
|
||||||
|
Form schema types
|
||||||
|
"""
|
||||||
|
|
||||||
|
from wuttaweb.forms.schema import ObjectRef
|
||||||
|
|
||||||
|
|
||||||
|
class OrderRef(ObjectRef):
|
||||||
|
"""
|
||||||
|
Custom schema type for an :class:`~sideshow.db.model.orders.Order`
|
||||||
|
reference field.
|
||||||
|
|
||||||
|
This is a subclass of
|
||||||
|
:class:`~wuttaweb:wuttaweb.forms.schema.ObjectRef`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def model_class(self):
|
||||||
|
""" """
|
||||||
|
model = self.app.model
|
||||||
|
return model.Order
|
||||||
|
|
||||||
|
def sort_query(self, query):
|
||||||
|
""" """
|
||||||
|
return query.order_by(self.model_class.order_id)
|
||||||
|
|
||||||
|
def get_object_url(self, order):
|
||||||
|
""" """
|
||||||
|
return self.request.route_url('orders.view', uuid=order.uuid)
|
||||||
|
|
||||||
|
|
||||||
|
class PendingCustomerRef(ObjectRef):
|
||||||
|
"""
|
||||||
|
Custom schema type for a
|
||||||
|
:class:`~sideshow.db.model.customers.PendingCustomer` reference
|
||||||
|
field.
|
||||||
|
|
||||||
|
This is a subclass of
|
||||||
|
:class:`~wuttaweb:wuttaweb.forms.schema.ObjectRef`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def model_class(self):
|
||||||
|
""" """
|
||||||
|
model = self.app.model
|
||||||
|
return model.PendingCustomer
|
||||||
|
|
||||||
|
def sort_query(self, query):
|
||||||
|
""" """
|
||||||
|
return query.order_by(self.model_class.full_name)
|
||||||
|
|
||||||
|
def get_object_url(self, customer):
|
||||||
|
""" """
|
||||||
|
return self.request.route_url('pending_customers.view', uuid=customer.uuid)
|
||||||
|
|
||||||
|
|
||||||
|
class PendingProductRef(ObjectRef):
|
||||||
|
"""
|
||||||
|
Custom schema type for a
|
||||||
|
:class:`~sideshow.db.model.products.PendingProduct` reference
|
||||||
|
field.
|
||||||
|
|
||||||
|
This is a subclass of
|
||||||
|
:class:`~wuttaweb:wuttaweb.forms.schema.ObjectRef`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def model_class(self):
|
||||||
|
""" """
|
||||||
|
model = self.app.model
|
||||||
|
return model.PendingProduct
|
||||||
|
|
||||||
|
def sort_query(self, query):
|
||||||
|
""" """
|
||||||
|
return query.order_by(self.model_class.scancode)
|
||||||
|
|
||||||
|
def get_object_url(self, product):
|
||||||
|
""" """
|
||||||
|
return self.request.route_url('pending_products.view', uuid=product.uuid)
|
|
@ -33,15 +33,79 @@ class SideshowMenuHandler(base.MenuHandler):
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def make_menus(self, request, **kwargs):
|
def make_menus(self, request, **kwargs):
|
||||||
|
""" """
|
||||||
|
return [
|
||||||
|
self.make_orders_menu(request),
|
||||||
|
self.make_pending_menu(request),
|
||||||
|
self.make_batch_menu(request),
|
||||||
|
self.make_admin_menu(request),
|
||||||
|
]
|
||||||
|
|
||||||
# TODO: override this if you need custom menus...
|
def make_orders_menu(self, request, **kwargs):
|
||||||
|
"""
|
||||||
|
Generate a typical Orders menu.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
'title': "Orders",
|
||||||
|
'type': 'menu',
|
||||||
|
'items': [
|
||||||
|
{
|
||||||
|
'title': "Create New Order",
|
||||||
|
'route': 'orders.create',
|
||||||
|
'perm': 'orders.create',
|
||||||
|
},
|
||||||
|
{'type': 'sep'},
|
||||||
|
{
|
||||||
|
'title': "All Orders",
|
||||||
|
'route': 'orders',
|
||||||
|
'perm': 'orders.list',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'title': "All Order Items",
|
||||||
|
'route': 'order_items',
|
||||||
|
'perm': 'order_items.list',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
# menus = [
|
def make_pending_menu(self, request, **kwargs):
|
||||||
# self.make_products_menu(request),
|
"""
|
||||||
# self.make_admin_menu(request),
|
Generate a typical Pending menu.
|
||||||
# ]
|
"""
|
||||||
|
return {
|
||||||
|
'title': "Pending",
|
||||||
|
'type': 'menu',
|
||||||
|
'items': [
|
||||||
|
{
|
||||||
|
'title': "Customers",
|
||||||
|
'route': 'pending_customers',
|
||||||
|
'perm': 'pending_customers.list',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'title': "Products",
|
||||||
|
'route': 'pending_products',
|
||||||
|
'perm': 'pending_products.list',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
# ...but for now this uses default menus
|
def make_batch_menu(self, request, **kwargs):
|
||||||
menus = super().make_menus(request, **kwargs)
|
"""
|
||||||
|
Generate a typical Batch menu.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
'title': "Batches",
|
||||||
|
'type': 'menu',
|
||||||
|
'items': [
|
||||||
|
{
|
||||||
|
'title': "New Orders",
|
||||||
|
'route': 'neworder_batches',
|
||||||
|
'perm': 'neworder_batches.list',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
return menus
|
def make_admin_menu(self, request, **kwargs):
|
||||||
|
""" """
|
||||||
|
kwargs['include_people'] = True
|
||||||
|
return super().make_admin_menu(request, **kwargs)
|
||||||
|
|
1522
src/sideshow/web/templates/orders/create.mako
Normal file
1522
src/sideshow/web/templates/orders/create.mako
Normal file
File diff suppressed because it is too large
Load diff
|
@ -30,5 +30,10 @@ def includeme(config):
|
||||||
# core views for wuttaweb
|
# core views for wuttaweb
|
||||||
config.include('wuttaweb.views.essential')
|
config.include('wuttaweb.views.essential')
|
||||||
|
|
||||||
# TODO: include your own views here
|
# sideshow views
|
||||||
#config.include('sideshow.web.views.widgets')
|
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')
|
||||||
|
|
0
src/sideshow/web/views/batch/__init__.py
Normal file
0
src/sideshow/web/views/batch/__init__.py
Normal file
189
src/sideshow/web/views/batch/neworder.py
Normal file
189
src/sideshow/web/views/batch/neworder.py
Normal file
|
@ -0,0 +1,189 @@
|
||||||
|
# -*- coding: utf-8; -*-
|
||||||
|
################################################################################
|
||||||
|
#
|
||||||
|
# Sideshow -- Case/Special Order Tracker
|
||||||
|
# Copyright © 2024 Lance Edgar
|
||||||
|
#
|
||||||
|
# This file is part of Sideshow.
|
||||||
|
#
|
||||||
|
# Sideshow is free software: you can redistribute it and/or modify it
|
||||||
|
# under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# Sideshow is distributed in the hope that it will be useful, but
|
||||||
|
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||||
|
# General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with Sideshow. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
################################################################################
|
||||||
|
"""
|
||||||
|
Views for New Order Batch
|
||||||
|
"""
|
||||||
|
|
||||||
|
from wuttaweb.views.batch import BatchMasterView
|
||||||
|
from wuttaweb.forms.schema import WuttaMoney
|
||||||
|
|
||||||
|
from sideshow.db.model import NewOrderBatch
|
||||||
|
from sideshow.batch.neworder import NewOrderBatchHandler
|
||||||
|
from sideshow.web.forms.schema import PendingCustomerRef
|
||||||
|
|
||||||
|
|
||||||
|
class NewOrderBatchView(BatchMasterView):
|
||||||
|
"""
|
||||||
|
Master view for :class:`~sideshow.db.model.batch.neworder.NewOrderBatch`.
|
||||||
|
|
||||||
|
Route prefix is ``neworder_batches``.
|
||||||
|
|
||||||
|
Notable URLs provided by this class:
|
||||||
|
|
||||||
|
* ``/batch/neworder/``
|
||||||
|
* ``/batch/neworder/XXX``
|
||||||
|
* ``/batch/neworder/XXX/delete``
|
||||||
|
|
||||||
|
The purpose of this class is to expose "raw" batch data, e.g. for
|
||||||
|
troubleshooting purposes by the admin. Ideally it is not very
|
||||||
|
useful.
|
||||||
|
|
||||||
|
Note that the "create" and "edit" views are not exposed here,
|
||||||
|
since those should be handled by
|
||||||
|
:class:`~sideshow.web.views.orders.OrderView` instead.
|
||||||
|
"""
|
||||||
|
model_class = NewOrderBatch
|
||||||
|
model_title = "New Order Batch"
|
||||||
|
model_title_plural = "New Order Batches"
|
||||||
|
route_prefix = 'neworder_batches'
|
||||||
|
url_prefix = '/batch/neworder'
|
||||||
|
creatable = False
|
||||||
|
editable = False
|
||||||
|
|
||||||
|
labels = {
|
||||||
|
'store_id': "Store ID",
|
||||||
|
'customer_id': "Customer ID",
|
||||||
|
}
|
||||||
|
|
||||||
|
grid_columns = [
|
||||||
|
'id',
|
||||||
|
'store_id',
|
||||||
|
'customer_id',
|
||||||
|
'customer_name',
|
||||||
|
'phone_number',
|
||||||
|
'email_address',
|
||||||
|
'total_price',
|
||||||
|
'row_count',
|
||||||
|
'created',
|
||||||
|
'created_by',
|
||||||
|
'executed',
|
||||||
|
]
|
||||||
|
|
||||||
|
filter_defaults = {
|
||||||
|
'executed': {'active': True, 'verb': 'is_null'},
|
||||||
|
}
|
||||||
|
|
||||||
|
form_fields = [
|
||||||
|
'id',
|
||||||
|
'store_id',
|
||||||
|
'customer_id',
|
||||||
|
'pending_customer',
|
||||||
|
'customer_name',
|
||||||
|
'phone_number',
|
||||||
|
'email_address',
|
||||||
|
'total_price',
|
||||||
|
'row_count',
|
||||||
|
'status_code',
|
||||||
|
'created',
|
||||||
|
'created_by',
|
||||||
|
'executed',
|
||||||
|
'executed_by',
|
||||||
|
]
|
||||||
|
|
||||||
|
row_labels = {
|
||||||
|
'product_scancode': "Scancode",
|
||||||
|
'product_brand': "Brand",
|
||||||
|
'product_description': "Description",
|
||||||
|
'product_size': "Size",
|
||||||
|
'order_uom': "Order UOM",
|
||||||
|
}
|
||||||
|
|
||||||
|
row_grid_columns = [
|
||||||
|
'sequence',
|
||||||
|
'product_scancode',
|
||||||
|
'product_brand',
|
||||||
|
'product_description',
|
||||||
|
'product_size',
|
||||||
|
'special_order',
|
||||||
|
'order_qty',
|
||||||
|
'order_uom',
|
||||||
|
'case_size',
|
||||||
|
'total_price',
|
||||||
|
'status_code',
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_batch_handler(self):
|
||||||
|
""" """
|
||||||
|
# TODO: call self.app.get_batch_handler()
|
||||||
|
return NewOrderBatchHandler(self.config)
|
||||||
|
|
||||||
|
def configure_grid(self, g):
|
||||||
|
""" """
|
||||||
|
super().configure_grid(g)
|
||||||
|
|
||||||
|
# total_price
|
||||||
|
g.set_renderer('total_price', 'currency')
|
||||||
|
|
||||||
|
def configure_form(self, f):
|
||||||
|
""" """
|
||||||
|
super().configure_form(f)
|
||||||
|
|
||||||
|
# pending_customer
|
||||||
|
f.set_node('pending_customer', PendingCustomerRef(self.request))
|
||||||
|
|
||||||
|
# total_price
|
||||||
|
f.set_node('total_price', WuttaMoney(self.request))
|
||||||
|
|
||||||
|
def configure_row_grid(self, g):
|
||||||
|
""" """
|
||||||
|
super().configure_row_grid(g)
|
||||||
|
enum = self.app.enum
|
||||||
|
|
||||||
|
# TODO
|
||||||
|
# order_uom
|
||||||
|
#g.set_renderer('order_uom', self.grid_render_enum, enum=enum.ORDER_UOM)
|
||||||
|
|
||||||
|
# total_price
|
||||||
|
g.set_renderer('total_price', 'currency')
|
||||||
|
|
||||||
|
def get_xref_buttons(self, batch):
|
||||||
|
"""
|
||||||
|
Adds "View this Order" button, if batch has been executed and
|
||||||
|
a corresponding :class:`~sideshow.db.model.orders.Order` can
|
||||||
|
be located.
|
||||||
|
"""
|
||||||
|
buttons = super().get_xref_buttons(batch)
|
||||||
|
model = self.app.model
|
||||||
|
session = self.Session()
|
||||||
|
|
||||||
|
if batch.executed and self.request.has_perm('orders.view'):
|
||||||
|
order = session.query(model.Order)\
|
||||||
|
.filter(model.Order.order_id == batch.id)\
|
||||||
|
.first()
|
||||||
|
if order:
|
||||||
|
url = self.request.route_url('orders.view', uuid=order.uuid)
|
||||||
|
buttons.append(
|
||||||
|
self.make_button("View the Order", primary=True, icon_left='eye', url=url))
|
||||||
|
|
||||||
|
return buttons
|
||||||
|
|
||||||
|
|
||||||
|
def defaults(config, **kwargs):
|
||||||
|
base = globals()
|
||||||
|
|
||||||
|
NewOrderBatchView = kwargs.get('NewOrderBatchView', base['NewOrderBatchView'])
|
||||||
|
NewOrderBatchView.defaults(config)
|
||||||
|
|
||||||
|
|
||||||
|
def includeme(config):
|
||||||
|
defaults(config)
|
246
src/sideshow/web/views/customers.py
Normal file
246
src/sideshow/web/views/customers.py
Normal file
|
@ -0,0 +1,246 @@
|
||||||
|
# -*- coding: utf-8; -*-
|
||||||
|
################################################################################
|
||||||
|
#
|
||||||
|
# Sideshow -- Case/Special Order Tracker
|
||||||
|
# Copyright © 2024 Lance Edgar
|
||||||
|
#
|
||||||
|
# This file is part of Sideshow.
|
||||||
|
#
|
||||||
|
# Sideshow is free software: you can redistribute it and/or modify it
|
||||||
|
# under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# Sideshow is distributed in the hope that it will be useful, but
|
||||||
|
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||||
|
# General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with Sideshow. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
################################################################################
|
||||||
|
"""
|
||||||
|
Views for Customers
|
||||||
|
"""
|
||||||
|
|
||||||
|
from wuttaweb.views import MasterView
|
||||||
|
from wuttaweb.forms.schema import UserRef, WuttaEnum
|
||||||
|
|
||||||
|
from sideshow.db.model import PendingCustomer
|
||||||
|
|
||||||
|
|
||||||
|
class PendingCustomerView(MasterView):
|
||||||
|
"""
|
||||||
|
Master view for
|
||||||
|
:class:`~sideshow.db.model.customers.PendingCustomer`; route
|
||||||
|
prefix is ``pending_customers``.
|
||||||
|
|
||||||
|
Notable URLs provided by this class:
|
||||||
|
|
||||||
|
* ``/pending/customers/``
|
||||||
|
* ``/pending/customers/new``
|
||||||
|
* ``/pending/customers/XXX``
|
||||||
|
* ``/pending/customers/XXX/edit``
|
||||||
|
* ``/pending/customers/XXX/delete``
|
||||||
|
"""
|
||||||
|
model_class = PendingCustomer
|
||||||
|
model_title = "Pending Customer"
|
||||||
|
route_prefix = 'pending_customers'
|
||||||
|
url_prefix = '/pending/customers'
|
||||||
|
|
||||||
|
labels = {
|
||||||
|
'customer_id': "Customer ID",
|
||||||
|
}
|
||||||
|
|
||||||
|
grid_columns = [
|
||||||
|
'full_name',
|
||||||
|
'first_name',
|
||||||
|
'last_name',
|
||||||
|
'phone_number',
|
||||||
|
'email_address',
|
||||||
|
'customer_id',
|
||||||
|
'status',
|
||||||
|
'created',
|
||||||
|
'created_by',
|
||||||
|
]
|
||||||
|
|
||||||
|
sort_defaults = 'full_name'
|
||||||
|
|
||||||
|
form_fields = [
|
||||||
|
'customer_id',
|
||||||
|
'full_name',
|
||||||
|
'first_name',
|
||||||
|
'middle_name',
|
||||||
|
'last_name',
|
||||||
|
'phone_number',
|
||||||
|
'phone_type',
|
||||||
|
'email_address',
|
||||||
|
'email_type',
|
||||||
|
'status',
|
||||||
|
'created',
|
||||||
|
'created_by',
|
||||||
|
'orders',
|
||||||
|
'new_order_batches',
|
||||||
|
]
|
||||||
|
|
||||||
|
def configure_grid(self, g):
|
||||||
|
""" """
|
||||||
|
super().configure_grid(g)
|
||||||
|
enum = self.app.enum
|
||||||
|
|
||||||
|
# status
|
||||||
|
g.set_renderer('status', self.grid_render_enum, enum=enum.PendingCustomerStatus)
|
||||||
|
|
||||||
|
# links
|
||||||
|
g.set_link('full_name')
|
||||||
|
g.set_link('first_name')
|
||||||
|
g.set_link('last_name')
|
||||||
|
g.set_link('phone_number')
|
||||||
|
g.set_link('email_address')
|
||||||
|
|
||||||
|
def configure_form(self, f):
|
||||||
|
""" """
|
||||||
|
super().configure_form(f)
|
||||||
|
enum = self.app.enum
|
||||||
|
customer = f.model_instance
|
||||||
|
|
||||||
|
# customer_id
|
||||||
|
if self.creating:
|
||||||
|
f.remove('customer_id')
|
||||||
|
else:
|
||||||
|
f.set_readonly('customer_id')
|
||||||
|
|
||||||
|
# status
|
||||||
|
if self.creating:
|
||||||
|
f.remove('status')
|
||||||
|
else:
|
||||||
|
f.set_node('status', WuttaEnum(self.request, enum.PendingCustomerStatus))
|
||||||
|
f.set_readonly('status')
|
||||||
|
|
||||||
|
# created
|
||||||
|
if self.creating:
|
||||||
|
f.remove('created')
|
||||||
|
else:
|
||||||
|
f.set_readonly('created')
|
||||||
|
|
||||||
|
# created_by
|
||||||
|
if self.creating:
|
||||||
|
f.remove('created_by')
|
||||||
|
else:
|
||||||
|
f.set_node('created_by', UserRef(self.request))
|
||||||
|
f.set_readonly('created_by')
|
||||||
|
|
||||||
|
# orders
|
||||||
|
if self.creating or self.editing:
|
||||||
|
f.remove('orders')
|
||||||
|
else:
|
||||||
|
f.set_grid('orders', self.make_orders_grid(customer))
|
||||||
|
|
||||||
|
# new_order_batches
|
||||||
|
if self.creating or self.editing:
|
||||||
|
f.remove('new_order_batches')
|
||||||
|
else:
|
||||||
|
f.set_grid('new_order_batches', self.make_new_order_batches_grid(customer))
|
||||||
|
|
||||||
|
def make_orders_grid(self, customer):
|
||||||
|
"""
|
||||||
|
Make and return the grid for the Orders field.
|
||||||
|
"""
|
||||||
|
model = self.app.model
|
||||||
|
route_prefix = self.get_route_prefix()
|
||||||
|
|
||||||
|
grid = self.make_grid(key=f'{route_prefix}.view.orders',
|
||||||
|
model_class=model.Order,
|
||||||
|
data=customer.orders,
|
||||||
|
columns=[
|
||||||
|
'order_id',
|
||||||
|
'total_price',
|
||||||
|
'created',
|
||||||
|
'created_by',
|
||||||
|
],
|
||||||
|
labels={
|
||||||
|
'order_id': "Order ID",
|
||||||
|
})
|
||||||
|
grid.set_renderer('total_price', grid.render_currency)
|
||||||
|
|
||||||
|
if self.request.has_perm('orders.view'):
|
||||||
|
url = lambda order, i: self.request.route_url('orders.view', uuid=order.uuid)
|
||||||
|
grid.add_action('view', icon='eye', url=url)
|
||||||
|
grid.set_link('order_id')
|
||||||
|
|
||||||
|
return grid
|
||||||
|
|
||||||
|
def make_new_order_batches_grid(self, customer):
|
||||||
|
"""
|
||||||
|
Make and return the grid for the New Order Batches field.
|
||||||
|
"""
|
||||||
|
model = self.app.model
|
||||||
|
route_prefix = self.get_route_prefix()
|
||||||
|
|
||||||
|
grid = self.make_grid(key=f'{route_prefix}.view.new_order_batches',
|
||||||
|
model_class=model.NewOrderBatch,
|
||||||
|
data=customer.new_order_batches,
|
||||||
|
columns=[
|
||||||
|
'id',
|
||||||
|
'total_price',
|
||||||
|
'created',
|
||||||
|
'created_by',
|
||||||
|
'executed',
|
||||||
|
],
|
||||||
|
labels={
|
||||||
|
'id': "Batch ID",
|
||||||
|
},
|
||||||
|
renderers={
|
||||||
|
'id': 'batch_id',
|
||||||
|
'total_price': 'currency',
|
||||||
|
})
|
||||||
|
|
||||||
|
if self.request.has_perm('neworder_batches.view'):
|
||||||
|
url = lambda batch, i: self.request.route_url('neworder_batches.view', uuid=batch.uuid)
|
||||||
|
grid.add_action('view', icon='eye', url=url)
|
||||||
|
grid.set_link('id')
|
||||||
|
|
||||||
|
return grid
|
||||||
|
|
||||||
|
def objectify(self, form):
|
||||||
|
""" """
|
||||||
|
enum = self.app.enum
|
||||||
|
customer = super().objectify(form)
|
||||||
|
|
||||||
|
if self.creating:
|
||||||
|
customer.status = enum.PendingCustomerStatus.PENDING
|
||||||
|
customer.created_by = self.request.user
|
||||||
|
|
||||||
|
return customer
|
||||||
|
|
||||||
|
def delete_instance(self, customer):
|
||||||
|
""" """
|
||||||
|
model_title = self.get_model_title()
|
||||||
|
|
||||||
|
# avoid deleting if still referenced by order(s)
|
||||||
|
for order in customer.orders:
|
||||||
|
self.request.session.flash(f"Cannot delete {model_title} still attached "
|
||||||
|
"to Order(s)", 'warning')
|
||||||
|
raise self.redirect(self.get_action_url('view', customer))
|
||||||
|
|
||||||
|
# avoid deleting if still referenced by new order batch(es)
|
||||||
|
for batch in customer.new_order_batches:
|
||||||
|
if not batch.executed:
|
||||||
|
self.request.session.flash(f"Cannot delete {model_title} still attached "
|
||||||
|
"to New Order Batch(es)", 'warning')
|
||||||
|
raise self.redirect(self.get_action_url('view', customer))
|
||||||
|
|
||||||
|
# go ahead and delete per usual
|
||||||
|
super().delete_instance(customer)
|
||||||
|
|
||||||
|
|
||||||
|
def defaults(config, **kwargs):
|
||||||
|
base = globals()
|
||||||
|
|
||||||
|
PendingCustomerView = kwargs.get('PendingCustomerView', base['PendingCustomerView'])
|
||||||
|
PendingCustomerView.defaults(config)
|
||||||
|
|
||||||
|
|
||||||
|
def includeme(config):
|
||||||
|
defaults(config)
|
866
src/sideshow/web/views/orders.py
Normal file
866
src/sideshow/web/views/orders.py
Normal file
|
@ -0,0 +1,866 @@
|
||||||
|
# -*- coding: utf-8; -*-
|
||||||
|
################################################################################
|
||||||
|
#
|
||||||
|
# Sideshow -- Case/Special Order Tracker
|
||||||
|
# Copyright © 2024 Lance Edgar
|
||||||
|
#
|
||||||
|
# This file is part of Sideshow.
|
||||||
|
#
|
||||||
|
# Sideshow is free software: you can redistribute it and/or modify it
|
||||||
|
# under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# Sideshow is distributed in the hope that it will be useful, but
|
||||||
|
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||||
|
# General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with Sideshow. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
################################################################################
|
||||||
|
"""
|
||||||
|
Views for Orders
|
||||||
|
"""
|
||||||
|
|
||||||
|
import decimal
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import colander
|
||||||
|
from sqlalchemy import orm
|
||||||
|
|
||||||
|
from wuttaweb.views import MasterView
|
||||||
|
from wuttaweb.forms.schema import UserRef, WuttaMoney, WuttaQuantity, WuttaEnum
|
||||||
|
|
||||||
|
from sideshow.db.model import Order, OrderItem
|
||||||
|
from sideshow.batch.neworder import NewOrderBatchHandler
|
||||||
|
from sideshow.web.forms.schema import OrderRef, PendingCustomerRef, PendingProductRef
|
||||||
|
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class OrderView(MasterView):
|
||||||
|
"""
|
||||||
|
Master view for :class:`~sideshow.db.model.orders.Order`; route
|
||||||
|
prefix is ``orders``.
|
||||||
|
|
||||||
|
Notable URLs provided by this class:
|
||||||
|
|
||||||
|
* ``/orders/``
|
||||||
|
* ``/orders/new``
|
||||||
|
* ``/orders/XXX``
|
||||||
|
* ``/orders/XXX/delete``
|
||||||
|
|
||||||
|
Note that the "edit" view is not exposed here; user must perform
|
||||||
|
various other workflow actions to modify the order.
|
||||||
|
"""
|
||||||
|
model_class = Order
|
||||||
|
editable = False
|
||||||
|
|
||||||
|
labels = {
|
||||||
|
'order_id': "Order ID",
|
||||||
|
'store_id': "Store ID",
|
||||||
|
'customer_id': "Customer ID",
|
||||||
|
}
|
||||||
|
|
||||||
|
grid_columns = [
|
||||||
|
'order_id',
|
||||||
|
'store_id',
|
||||||
|
'customer_id',
|
||||||
|
'customer_name',
|
||||||
|
'total_price',
|
||||||
|
'created',
|
||||||
|
'created_by',
|
||||||
|
]
|
||||||
|
|
||||||
|
sort_defaults = ('order_id', 'desc')
|
||||||
|
|
||||||
|
form_fields = [
|
||||||
|
'order_id',
|
||||||
|
'store_id',
|
||||||
|
'customer_id',
|
||||||
|
'pending_customer',
|
||||||
|
'customer_name',
|
||||||
|
'phone_number',
|
||||||
|
'email_address',
|
||||||
|
'total_price',
|
||||||
|
'created',
|
||||||
|
'created_by',
|
||||||
|
]
|
||||||
|
|
||||||
|
has_rows = True
|
||||||
|
row_model_class = OrderItem
|
||||||
|
rows_title = "Order Items"
|
||||||
|
rows_sort_defaults = 'sequence'
|
||||||
|
rows_viewable = True
|
||||||
|
|
||||||
|
row_labels = {
|
||||||
|
'product_scancode': "Scancode",
|
||||||
|
'product_brand': "Brand",
|
||||||
|
'product_description': "Description",
|
||||||
|
'product_size': "Size",
|
||||||
|
'department_name': "Department",
|
||||||
|
'order_uom': "Order UOM",
|
||||||
|
'status_code': "Status",
|
||||||
|
}
|
||||||
|
|
||||||
|
row_grid_columns = [
|
||||||
|
'sequence',
|
||||||
|
'product_scancode',
|
||||||
|
'product_brand',
|
||||||
|
'product_description',
|
||||||
|
'product_size',
|
||||||
|
'department_name',
|
||||||
|
'special_order',
|
||||||
|
'order_qty',
|
||||||
|
'order_uom',
|
||||||
|
'total_price',
|
||||||
|
'status_code',
|
||||||
|
]
|
||||||
|
|
||||||
|
PENDING_PRODUCT_ENTRY_FIELDS = [
|
||||||
|
'scancode',
|
||||||
|
'department_id',
|
||||||
|
'department_name',
|
||||||
|
'brand_name',
|
||||||
|
'description',
|
||||||
|
'size',
|
||||||
|
'vendor_name',
|
||||||
|
'vendor_item_code',
|
||||||
|
'unit_cost',
|
||||||
|
'case_size',
|
||||||
|
'unit_price_reg',
|
||||||
|
]
|
||||||
|
|
||||||
|
def configure_grid(self, g):
|
||||||
|
""" """
|
||||||
|
super().configure_grid(g)
|
||||||
|
|
||||||
|
# order_id
|
||||||
|
g.set_link('order_id')
|
||||||
|
|
||||||
|
# customer_id
|
||||||
|
g.set_link('customer_id')
|
||||||
|
|
||||||
|
# customer_name
|
||||||
|
g.set_link('customer_name')
|
||||||
|
|
||||||
|
# total_price
|
||||||
|
g.set_renderer('total_price', g.render_currency)
|
||||||
|
|
||||||
|
def create(self):
|
||||||
|
"""
|
||||||
|
Instead of the typical "create" view, this displays a "wizard"
|
||||||
|
of sorts.
|
||||||
|
|
||||||
|
Under the hood a
|
||||||
|
:class:`~sideshow.db.model.batch.neworder.NewOrderBatch` is
|
||||||
|
automatically created for the user when they first visit this
|
||||||
|
page. They can select a customer, add items etc.
|
||||||
|
|
||||||
|
When user is finished assembling the order (i.e. populating
|
||||||
|
the batch), they submit it. This of course executes the
|
||||||
|
batch, which in turn creates a true
|
||||||
|
:class:`~sideshow.db.model.orders.Order`, and user is
|
||||||
|
redirected to the "view order" page.
|
||||||
|
"""
|
||||||
|
enum = self.app.enum
|
||||||
|
self.creating = True
|
||||||
|
self.batch_handler = NewOrderBatchHandler(self.config)
|
||||||
|
batch = self.get_current_batch()
|
||||||
|
|
||||||
|
context = self.get_context_customer(batch)
|
||||||
|
|
||||||
|
if self.request.method == 'POST':
|
||||||
|
|
||||||
|
# first we check for traditional form post
|
||||||
|
action = self.request.POST.get('action')
|
||||||
|
post_actions = [
|
||||||
|
'start_over',
|
||||||
|
'cancel_order',
|
||||||
|
]
|
||||||
|
if action in post_actions:
|
||||||
|
return getattr(self, action)(batch)
|
||||||
|
|
||||||
|
# okay then, we'll assume newer JSON-style post params
|
||||||
|
data = dict(self.request.json_body)
|
||||||
|
action = data.pop('action')
|
||||||
|
json_actions = [
|
||||||
|
# 'assign_contact',
|
||||||
|
# 'unassign_contact',
|
||||||
|
# 'update_phone_number',
|
||||||
|
# 'update_email_address',
|
||||||
|
'set_pending_customer',
|
||||||
|
# 'get_customer_info',
|
||||||
|
# # 'set_customer_data',
|
||||||
|
# 'get_product_info',
|
||||||
|
# 'get_past_items',
|
||||||
|
'add_item',
|
||||||
|
'update_item',
|
||||||
|
'delete_item',
|
||||||
|
'submit_new_order',
|
||||||
|
]
|
||||||
|
if action in json_actions:
|
||||||
|
result = getattr(self, action)(batch, data)
|
||||||
|
return self.json_response(result)
|
||||||
|
|
||||||
|
return self.json_response({'error': "unknown form action"})
|
||||||
|
|
||||||
|
context.update({
|
||||||
|
'batch': batch,
|
||||||
|
'normalized_batch': self.normalize_batch(batch),
|
||||||
|
'order_items': [self.normalize_row(row)
|
||||||
|
for row in batch.rows],
|
||||||
|
|
||||||
|
'allow_unknown_product': True, # TODO
|
||||||
|
'default_uom_choices': self.get_default_uom_choices(),
|
||||||
|
'default_uom': None, # TODO?
|
||||||
|
'pending_product_required_fields': self.get_pending_product_required_fields(),
|
||||||
|
})
|
||||||
|
return self.render_to_response('create', context)
|
||||||
|
|
||||||
|
def get_current_batch(self):
|
||||||
|
"""
|
||||||
|
Returns the current batch for the current user.
|
||||||
|
|
||||||
|
This looks for a new order batch which was created by the
|
||||||
|
user, but not yet executed. If none is found, a new batch is
|
||||||
|
created.
|
||||||
|
|
||||||
|
:returns:
|
||||||
|
:class:`~sideshow.db.model.batch.neworder.NewOrderBatch`
|
||||||
|
instance
|
||||||
|
"""
|
||||||
|
model = self.app.model
|
||||||
|
session = self.Session()
|
||||||
|
|
||||||
|
user = self.request.user
|
||||||
|
if not user:
|
||||||
|
raise self.forbidden()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# there should be at most *one* new batch per user
|
||||||
|
batch = session.query(model.NewOrderBatch)\
|
||||||
|
.filter(model.NewOrderBatch.created_by == user)\
|
||||||
|
.filter(model.NewOrderBatch.executed == None)\
|
||||||
|
.one()
|
||||||
|
|
||||||
|
except orm.exc.NoResultFound:
|
||||||
|
# no batch yet for this user, so make one
|
||||||
|
batch = self.batch_handler.make_batch(session, created_by=user)
|
||||||
|
session.add(batch)
|
||||||
|
session.flush()
|
||||||
|
|
||||||
|
return batch
|
||||||
|
|
||||||
|
def get_pending_product_required_fields(self):
|
||||||
|
""" """
|
||||||
|
required = []
|
||||||
|
for field in self.PENDING_PRODUCT_ENTRY_FIELDS:
|
||||||
|
require = self.config.get_bool(
|
||||||
|
f'sideshow.orders.unknown_product.fields.{field}.required')
|
||||||
|
if require is None and field == 'description':
|
||||||
|
require = True
|
||||||
|
if require:
|
||||||
|
required.append(field)
|
||||||
|
return required
|
||||||
|
|
||||||
|
def start_over(self, batch):
|
||||||
|
"""
|
||||||
|
This will delete the user's current batch, then redirect user
|
||||||
|
back to "Create Order" page, which in turn will auto-create a
|
||||||
|
new batch for them.
|
||||||
|
|
||||||
|
This is a "batch action" method which may be called from
|
||||||
|
:meth:`create()`.
|
||||||
|
"""
|
||||||
|
# drop current batch
|
||||||
|
self.batch_handler.do_delete(batch, self.request.user)
|
||||||
|
self.Session.flush()
|
||||||
|
|
||||||
|
# send back to "create order" which makes new batch
|
||||||
|
route_prefix = self.get_route_prefix()
|
||||||
|
url = self.request.route_url(f'{route_prefix}.create')
|
||||||
|
return self.redirect(url)
|
||||||
|
|
||||||
|
def cancel_order(self, batch):
|
||||||
|
"""
|
||||||
|
This will delete the user's current batch, then redirect user
|
||||||
|
back to "List Orders" page.
|
||||||
|
|
||||||
|
This is a "batch action" method which may be called from
|
||||||
|
:meth:`create()`.
|
||||||
|
"""
|
||||||
|
self.batch_handler.do_delete(batch, self.request.user)
|
||||||
|
self.Session.flush()
|
||||||
|
|
||||||
|
# set flash msg just to be more obvious
|
||||||
|
self.request.session.flash("New order has been deleted.")
|
||||||
|
|
||||||
|
# send user back to orders list, w/ no new batch generated
|
||||||
|
url = self.get_index_url()
|
||||||
|
return self.redirect(url)
|
||||||
|
|
||||||
|
def get_context_customer(self, batch):
|
||||||
|
""" """
|
||||||
|
context = {
|
||||||
|
'customer_id': batch.customer_id,
|
||||||
|
'customer_name': batch.customer_name,
|
||||||
|
'phone_number': batch.phone_number,
|
||||||
|
'email_address': batch.email_address,
|
||||||
|
'new_customer_name': None,
|
||||||
|
'new_customer_first_name': None,
|
||||||
|
'new_customer_last_name': None,
|
||||||
|
'new_customer_phone': None,
|
||||||
|
'new_customer_email': None,
|
||||||
|
}
|
||||||
|
|
||||||
|
pending = batch.pending_customer
|
||||||
|
if pending:
|
||||||
|
context.update({
|
||||||
|
'new_customer_first_name': pending.first_name,
|
||||||
|
'new_customer_last_name': pending.last_name,
|
||||||
|
'new_customer_name': pending.full_name,
|
||||||
|
'new_customer_phone': pending.phone_number,
|
||||||
|
'new_customer_email': pending.email_address,
|
||||||
|
})
|
||||||
|
|
||||||
|
# figure out if customer is "known" from user's perspective.
|
||||||
|
# if we have an ID then it's definitely known, otherwise if we
|
||||||
|
# have a pending customer then it's definitely *not* known,
|
||||||
|
# but if no pending customer yet then we can still "assume" it
|
||||||
|
# is known, by default, until user specifies otherwise.
|
||||||
|
if batch.customer_id:
|
||||||
|
context['customer_is_known'] = True
|
||||||
|
else:
|
||||||
|
context['customer_is_known'] = not pending
|
||||||
|
|
||||||
|
return context
|
||||||
|
|
||||||
|
def set_pending_customer(self, batch, data):
|
||||||
|
"""
|
||||||
|
This will set/update the batch pending customer info.
|
||||||
|
|
||||||
|
This calls
|
||||||
|
:meth:`~sideshow.batch.neworder.NewOrderBatchHandler.set_pending_customer()`
|
||||||
|
for the heavy lifting.
|
||||||
|
|
||||||
|
This is a "batch action" method which may be called from
|
||||||
|
:meth:`create()`.
|
||||||
|
"""
|
||||||
|
data['created_by'] = self.request.user
|
||||||
|
try:
|
||||||
|
self.batch_handler.set_pending_customer(batch, data)
|
||||||
|
except Exception as error:
|
||||||
|
return {'error': self.app.render_error(error)}
|
||||||
|
|
||||||
|
self.Session.flush()
|
||||||
|
context = self.get_context_customer(batch)
|
||||||
|
return context
|
||||||
|
|
||||||
|
def add_item(self, batch, data):
|
||||||
|
"""
|
||||||
|
This adds a row to the user's current new order batch.
|
||||||
|
|
||||||
|
This is a "batch action" method which may be called from
|
||||||
|
:meth:`create()`.
|
||||||
|
"""
|
||||||
|
order_qty = decimal.Decimal(data.get('order_qty') or '0')
|
||||||
|
order_uom = data['order_uom']
|
||||||
|
|
||||||
|
if data.get('product_is_known'):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
else: # unknown product; add pending
|
||||||
|
pending = data['pending_product']
|
||||||
|
|
||||||
|
for field in ('unit_cost', 'unit_price_reg', 'case_size'):
|
||||||
|
if field in pending:
|
||||||
|
try:
|
||||||
|
pending[field] = decimal.Decimal(pending[field])
|
||||||
|
except decimal.InvalidOperation:
|
||||||
|
return {'error': f"Invalid entry for field: {field}"}
|
||||||
|
|
||||||
|
pending['created_by'] = self.request.user
|
||||||
|
row = self.batch_handler.add_pending_product(batch, pending,
|
||||||
|
order_qty, order_uom)
|
||||||
|
|
||||||
|
return {'batch': self.normalize_batch(batch),
|
||||||
|
'row': self.normalize_row(row)}
|
||||||
|
|
||||||
|
def update_item(self, batch, data):
|
||||||
|
"""
|
||||||
|
This updates a row in the user's current new order batch.
|
||||||
|
|
||||||
|
This is a "batch action" method which may be called from
|
||||||
|
:meth:`create()`.
|
||||||
|
"""
|
||||||
|
model = self.app.model
|
||||||
|
enum = self.app.enum
|
||||||
|
session = self.Session()
|
||||||
|
|
||||||
|
uuid = data.get('uuid')
|
||||||
|
if not uuid:
|
||||||
|
return {'error': "Must specify a row UUID"}
|
||||||
|
|
||||||
|
row = session.get(model.NewOrderBatchRow, uuid)
|
||||||
|
if not row:
|
||||||
|
return {'error': "Row not found"}
|
||||||
|
|
||||||
|
if row.batch is not batch:
|
||||||
|
return {'error': "Row is for wrong batch"}
|
||||||
|
|
||||||
|
order_qty = decimal.Decimal(data.get('order_qty') or '0')
|
||||||
|
order_uom = data['order_uom']
|
||||||
|
|
||||||
|
if data.get('product_is_known'):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
else: # pending product
|
||||||
|
|
||||||
|
# set these first, since row will be refreshed below
|
||||||
|
row.order_qty = order_qty
|
||||||
|
row.order_uom = order_uom
|
||||||
|
|
||||||
|
# nb. this will refresh the row
|
||||||
|
self.batch_handler.set_pending_product(row, data['pending_product'])
|
||||||
|
|
||||||
|
return {'batch': self.normalize_batch(batch),
|
||||||
|
'row': self.normalize_row(row)}
|
||||||
|
|
||||||
|
def delete_item(self, batch, data):
|
||||||
|
"""
|
||||||
|
This deletes a row from the user's current new order batch.
|
||||||
|
|
||||||
|
This is a "batch action" method which may be called from
|
||||||
|
:meth:`create()`.
|
||||||
|
"""
|
||||||
|
model = self.app.model
|
||||||
|
session = self.app.get_session(batch)
|
||||||
|
|
||||||
|
uuid = data.get('uuid')
|
||||||
|
if not uuid:
|
||||||
|
return {'error': "Must specify a row UUID"}
|
||||||
|
|
||||||
|
row = session.get(model.NewOrderBatchRow, uuid)
|
||||||
|
if not row:
|
||||||
|
return {'error': "Row not found"}
|
||||||
|
|
||||||
|
if row.batch is not batch:
|
||||||
|
return {'error': "Row is for wrong batch"}
|
||||||
|
|
||||||
|
self.batch_handler.do_remove_row(row)
|
||||||
|
session.flush()
|
||||||
|
return {'batch': self.normalize_batch(batch)}
|
||||||
|
|
||||||
|
def submit_new_order(self, batch, data):
|
||||||
|
"""
|
||||||
|
This submits the user's current new order batch, hence
|
||||||
|
executing the batch and creating the true order.
|
||||||
|
|
||||||
|
This is a "batch action" method which may be called from
|
||||||
|
:meth:`create()`.
|
||||||
|
"""
|
||||||
|
user = self.request.user
|
||||||
|
reason = self.batch_handler.why_not_execute(batch, user=user)
|
||||||
|
if reason:
|
||||||
|
return {'error': reason}
|
||||||
|
|
||||||
|
try:
|
||||||
|
order = self.batch_handler.do_execute(batch, user)
|
||||||
|
except Exception as error:
|
||||||
|
log.warning("failed to execute new order batch: %s", batch,
|
||||||
|
exc_info=True)
|
||||||
|
return {'error': self.app.render_error(error)}
|
||||||
|
|
||||||
|
return {
|
||||||
|
'next_url': self.get_action_url('view', order),
|
||||||
|
}
|
||||||
|
|
||||||
|
def normalize_batch(self, batch):
|
||||||
|
""" """
|
||||||
|
return {
|
||||||
|
'uuid': batch.uuid.hex,
|
||||||
|
'total_price': str(batch.total_price or 0),
|
||||||
|
'total_price_display': self.app.render_currency(batch.total_price),
|
||||||
|
'status_code': batch.status_code,
|
||||||
|
'status_text': batch.status_text,
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_default_uom_choices(self):
|
||||||
|
""" """
|
||||||
|
enum = self.app.enum
|
||||||
|
return [{'key': key, 'value': val}
|
||||||
|
for key, val in enum.ORDER_UOM.items()]
|
||||||
|
|
||||||
|
def normalize_row(self, row):
|
||||||
|
""" """
|
||||||
|
enum = self.app.enum
|
||||||
|
|
||||||
|
data = {
|
||||||
|
'uuid': row.uuid.hex,
|
||||||
|
'sequence': row.sequence,
|
||||||
|
'product_scancode': row.product_scancode,
|
||||||
|
'product_brand': row.product_brand,
|
||||||
|
'product_description': row.product_description,
|
||||||
|
'product_size': row.product_size,
|
||||||
|
'product_weighed': row.product_weighed,
|
||||||
|
'department_display': row.department_name,
|
||||||
|
'special_order': row.special_order,
|
||||||
|
'case_size': self.app.render_quantity(row.case_size),
|
||||||
|
'order_qty': self.app.render_quantity(row.order_qty),
|
||||||
|
'order_uom': row.order_uom,
|
||||||
|
'order_uom_choices': self.get_default_uom_choices(),
|
||||||
|
'unit_price_quoted': float(row.unit_price_quoted) if row.unit_price_quoted is not None else None,
|
||||||
|
'unit_price_quoted_display': self.app.render_currency(row.unit_price_quoted),
|
||||||
|
'case_price_quoted': float(row.case_price_quoted) if row.case_price_quoted is not None else None,
|
||||||
|
'case_price_quoted_display': self.app.render_currency(row.case_price_quoted),
|
||||||
|
'total_price': float(row.total_price) if row.total_price is not None else None,
|
||||||
|
'total_price_display': self.app.render_currency(row.total_price),
|
||||||
|
'status_code': row.status_code,
|
||||||
|
'status_text': row.status_text,
|
||||||
|
}
|
||||||
|
|
||||||
|
if row.unit_price_reg:
|
||||||
|
data['unit_price_reg'] = float(row.unit_price_reg)
|
||||||
|
data['unit_price_reg_display'] = self.app.render_currency(row.unit_price_reg)
|
||||||
|
|
||||||
|
if row.unit_price_sale:
|
||||||
|
data['unit_price_sale'] = float(row.unit_price_sale)
|
||||||
|
data['unit_price_sale_display'] = self.app.render_currency(row.unit_price_sale)
|
||||||
|
if row.sale_ends:
|
||||||
|
sale_ends = row.sale_ends
|
||||||
|
data['sale_ends'] = str(row.sale_ends)
|
||||||
|
data['sale_ends_display'] = self.app.render_date(row.sale_ends)
|
||||||
|
|
||||||
|
# if row.unit_price_sale and row.unit_price_quoted == row.unit_price_sale:
|
||||||
|
# data['pricing_reflects_sale'] = True
|
||||||
|
|
||||||
|
# TODO
|
||||||
|
if row.pending_product:
|
||||||
|
data['product_full_description'] = row.pending_product.full_description
|
||||||
|
# else:
|
||||||
|
# data['product_full_description'] = row.product_description
|
||||||
|
|
||||||
|
# if row.pending_product:
|
||||||
|
# data['vendor_display'] = row.pending_product.vendor_name
|
||||||
|
|
||||||
|
if row.pending_product:
|
||||||
|
pending = row.pending_product
|
||||||
|
# data['vendor_display'] = pending.vendor_name
|
||||||
|
data['pending_product'] = {
|
||||||
|
'uuid': pending.uuid.hex,
|
||||||
|
'scancode': pending.scancode,
|
||||||
|
'brand_name': pending.brand_name,
|
||||||
|
'description': pending.description,
|
||||||
|
'size': pending.size,
|
||||||
|
'department_id': pending.department_id,
|
||||||
|
'department_name': pending.department_name,
|
||||||
|
'unit_price_reg': float(pending.unit_price_reg) if pending.unit_price_reg is not None else None,
|
||||||
|
'vendor_name': pending.vendor_name,
|
||||||
|
'vendor_item_code': pending.vendor_item_code,
|
||||||
|
'unit_cost': float(pending.unit_cost) if pending.unit_cost is not None else None,
|
||||||
|
'case_size': float(pending.case_size) if pending.case_size is not None else None,
|
||||||
|
'notes': pending.notes,
|
||||||
|
'special_order': pending.special_order,
|
||||||
|
}
|
||||||
|
|
||||||
|
# TODO: remove this
|
||||||
|
data['product_key'] = row.product_scancode
|
||||||
|
|
||||||
|
# display text for order qty/uom
|
||||||
|
if row.order_uom == enum.ORDER_UOM_CASE:
|
||||||
|
if row.case_size is None:
|
||||||
|
case_qty = unit_qty = '??'
|
||||||
|
else:
|
||||||
|
case_qty = data['case_size']
|
||||||
|
unit_qty = self.app.render_quantity(row.order_qty * row.case_size)
|
||||||
|
CS = enum.ORDER_UOM[enum.ORDER_UOM_CASE]
|
||||||
|
EA = enum.ORDER_UOM[enum.ORDER_UOM_UNIT]
|
||||||
|
data['order_qty_display'] = (f"{data['order_qty']} {CS} "
|
||||||
|
f"(× {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)
|
257
src/sideshow/web/views/products.py
Normal file
257
src/sideshow/web/views/products.py
Normal file
|
@ -0,0 +1,257 @@
|
||||||
|
# -*- coding: utf-8; -*-
|
||||||
|
################################################################################
|
||||||
|
#
|
||||||
|
# Sideshow -- Case/Special Order Tracker
|
||||||
|
# Copyright © 2024 Lance Edgar
|
||||||
|
#
|
||||||
|
# This file is part of Sideshow.
|
||||||
|
#
|
||||||
|
# Sideshow is free software: you can redistribute it and/or modify it
|
||||||
|
# under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# Sideshow is distributed in the hope that it will be useful, but
|
||||||
|
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||||
|
# General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with Sideshow. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
################################################################################
|
||||||
|
"""
|
||||||
|
Views for Products
|
||||||
|
"""
|
||||||
|
|
||||||
|
from wuttaweb.views import MasterView
|
||||||
|
from wuttaweb.forms.schema import UserRef, WuttaEnum, WuttaMoney
|
||||||
|
|
||||||
|
from sideshow.db.model import PendingProduct
|
||||||
|
|
||||||
|
|
||||||
|
class PendingProductView(MasterView):
|
||||||
|
"""
|
||||||
|
Master view for
|
||||||
|
:class:`~sideshow.db.model.products.PendingProduct`; route
|
||||||
|
prefix is ``pending_products``.
|
||||||
|
|
||||||
|
Notable URLs provided by this class:
|
||||||
|
|
||||||
|
* ``/pending/products/``
|
||||||
|
* ``/pending/products/new``
|
||||||
|
* ``/pending/products/XXX``
|
||||||
|
* ``/pending/products/XXX/edit``
|
||||||
|
* ``/pending/products/XXX/delete``
|
||||||
|
"""
|
||||||
|
model_class = PendingProduct
|
||||||
|
model_title = "Pending Product"
|
||||||
|
route_prefix = 'pending_products'
|
||||||
|
url_prefix = '/pending/products'
|
||||||
|
|
||||||
|
labels = {
|
||||||
|
'product_id': "Product ID",
|
||||||
|
}
|
||||||
|
|
||||||
|
grid_columns = [
|
||||||
|
'scancode',
|
||||||
|
'department_name',
|
||||||
|
'brand_name',
|
||||||
|
'description',
|
||||||
|
'size',
|
||||||
|
'unit_cost',
|
||||||
|
'case_size',
|
||||||
|
'unit_price_reg',
|
||||||
|
'special_order',
|
||||||
|
'status',
|
||||||
|
'created',
|
||||||
|
'created_by',
|
||||||
|
]
|
||||||
|
|
||||||
|
sort_defaults = 'scancode'
|
||||||
|
|
||||||
|
form_fields = [
|
||||||
|
'product_id',
|
||||||
|
'scancode',
|
||||||
|
'department_id',
|
||||||
|
'department_name',
|
||||||
|
'brand_name',
|
||||||
|
'description',
|
||||||
|
'size',
|
||||||
|
'vendor_name',
|
||||||
|
'vendor_item_code',
|
||||||
|
'unit_cost',
|
||||||
|
'case_size',
|
||||||
|
'unit_price_reg',
|
||||||
|
'special_order',
|
||||||
|
'notes',
|
||||||
|
'status',
|
||||||
|
'created',
|
||||||
|
'created_by',
|
||||||
|
'orders',
|
||||||
|
'new_order_batches',
|
||||||
|
]
|
||||||
|
|
||||||
|
def configure_grid(self, g):
|
||||||
|
""" """
|
||||||
|
super().configure_grid(g)
|
||||||
|
enum = self.app.enum
|
||||||
|
|
||||||
|
# unit_cost
|
||||||
|
g.set_renderer('unit_cost', 'currency', scale=4)
|
||||||
|
|
||||||
|
# unit_price_reg
|
||||||
|
g.set_label('unit_price_reg', "Reg. Price", column_only=True)
|
||||||
|
g.set_renderer('unit_price_reg', 'currency')
|
||||||
|
|
||||||
|
# status
|
||||||
|
g.set_renderer('status', self.grid_render_enum, enum=enum.PendingProductStatus)
|
||||||
|
|
||||||
|
# links
|
||||||
|
g.set_link('scancode')
|
||||||
|
g.set_link('brand_name')
|
||||||
|
g.set_link('description')
|
||||||
|
g.set_link('size')
|
||||||
|
|
||||||
|
def configure_form(self, f):
|
||||||
|
""" """
|
||||||
|
super().configure_form(f)
|
||||||
|
enum = self.app.enum
|
||||||
|
product = f.model_instance
|
||||||
|
|
||||||
|
# product_id
|
||||||
|
if self.creating:
|
||||||
|
f.remove('product_id')
|
||||||
|
else:
|
||||||
|
f.set_readonly('product_id')
|
||||||
|
|
||||||
|
# unit_price_reg
|
||||||
|
f.set_node('unit_price_reg', WuttaMoney(self.request))
|
||||||
|
|
||||||
|
# notes
|
||||||
|
f.set_widget('notes', 'notes')
|
||||||
|
|
||||||
|
# status
|
||||||
|
if self.creating:
|
||||||
|
f.remove('status')
|
||||||
|
else:
|
||||||
|
f.set_node('status', WuttaEnum(self.request, enum.PendingProductStatus))
|
||||||
|
f.set_readonly('status')
|
||||||
|
|
||||||
|
# created
|
||||||
|
if self.creating:
|
||||||
|
f.remove('created')
|
||||||
|
else:
|
||||||
|
f.set_readonly('created')
|
||||||
|
|
||||||
|
# created_by
|
||||||
|
if self.creating:
|
||||||
|
f.remove('created_by')
|
||||||
|
else:
|
||||||
|
f.set_node('created_by', UserRef(self.request))
|
||||||
|
f.set_readonly('created_by')
|
||||||
|
|
||||||
|
# orders
|
||||||
|
if self.creating or self.editing:
|
||||||
|
f.remove('orders')
|
||||||
|
else:
|
||||||
|
f.set_grid('orders', self.make_orders_grid(product))
|
||||||
|
|
||||||
|
# new_order_batches
|
||||||
|
if self.creating or self.editing:
|
||||||
|
f.remove('new_order_batches')
|
||||||
|
else:
|
||||||
|
f.set_grid('new_order_batches', self.make_new_order_batches_grid(product))
|
||||||
|
|
||||||
|
def make_orders_grid(self, product):
|
||||||
|
"""
|
||||||
|
Make and return the grid for the Orders field.
|
||||||
|
"""
|
||||||
|
model = self.app.model
|
||||||
|
route_prefix = self.get_route_prefix()
|
||||||
|
|
||||||
|
orders = set([item.order for item in product.order_items])
|
||||||
|
orders = sorted(orders, key=lambda order: order.order_id)
|
||||||
|
|
||||||
|
grid = self.make_grid(key=f'{route_prefix}.view.orders',
|
||||||
|
model_class=model.Order,
|
||||||
|
data=orders,
|
||||||
|
columns=[
|
||||||
|
'order_id',
|
||||||
|
'total_price',
|
||||||
|
'created',
|
||||||
|
'created_by',
|
||||||
|
],
|
||||||
|
labels={
|
||||||
|
'order_id': "Order ID",
|
||||||
|
},
|
||||||
|
renderers={
|
||||||
|
'total_price': 'currency',
|
||||||
|
})
|
||||||
|
|
||||||
|
if self.request.has_perm('orders.view'):
|
||||||
|
url = lambda order, i: self.request.route_url('orders.view', uuid=order.uuid)
|
||||||
|
grid.add_action('view', icon='eye', url=url)
|
||||||
|
grid.set_link('order_id')
|
||||||
|
|
||||||
|
return grid
|
||||||
|
|
||||||
|
def make_new_order_batches_grid(self, product):
|
||||||
|
"""
|
||||||
|
Make and return the grid for the New Order Batches field.
|
||||||
|
"""
|
||||||
|
model = self.app.model
|
||||||
|
route_prefix = self.get_route_prefix()
|
||||||
|
|
||||||
|
batches = set([row.batch for row in product.new_order_batch_rows])
|
||||||
|
batches = sorted(batches, key=lambda batch: batch.id)
|
||||||
|
|
||||||
|
grid = self.make_grid(key=f'{route_prefix}.view.new_order_batches',
|
||||||
|
model_class=model.NewOrderBatch,
|
||||||
|
data=batches,
|
||||||
|
columns=[
|
||||||
|
'id',
|
||||||
|
'total_price',
|
||||||
|
'created',
|
||||||
|
'created_by',
|
||||||
|
'executed',
|
||||||
|
],
|
||||||
|
labels={
|
||||||
|
'id': "Batch ID",
|
||||||
|
'status_code': "Status",
|
||||||
|
},
|
||||||
|
renderers={
|
||||||
|
'id': 'batch_id',
|
||||||
|
})
|
||||||
|
|
||||||
|
if self.request.has_perm('neworder_batches.view'):
|
||||||
|
url = lambda batch, i: self.request.route_url('neworder_batches.view', uuid=batch.uuid)
|
||||||
|
grid.add_action('view', icon='eye', url=url)
|
||||||
|
grid.set_link('id')
|
||||||
|
|
||||||
|
return grid
|
||||||
|
|
||||||
|
def delete_instance(self, product):
|
||||||
|
""" """
|
||||||
|
|
||||||
|
# avoid deleting if still referenced by new order batch(es)
|
||||||
|
for row in product.new_order_batch_rows:
|
||||||
|
if not row.batch.executed:
|
||||||
|
model_title = self.get_model_title()
|
||||||
|
self.request.session.flash(f"Cannot delete {model_title} still attached "
|
||||||
|
"to New Order Batch(es)", 'warning')
|
||||||
|
raise self.redirect(self.get_action_url('view', product))
|
||||||
|
|
||||||
|
# go ahead and delete per usual
|
||||||
|
super().delete_instance(product)
|
||||||
|
|
||||||
|
|
||||||
|
def defaults(config, **kwargs):
|
||||||
|
base = globals()
|
||||||
|
|
||||||
|
PendingProductView = kwargs.get('PendingProductView', base['PendingProductView'])
|
||||||
|
PendingProductView.defaults(config)
|
||||||
|
|
||||||
|
|
||||||
|
def includeme(config):
|
||||||
|
defaults(config)
|
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
0
tests/batch/__init__.py
Normal file
0
tests/batch/__init__.py
Normal file
539
tests/batch/test_neworder.py
Normal file
539
tests/batch/test_neworder.py
Normal file
|
@ -0,0 +1,539 @@
|
||||||
|
# -*- coding: utf-8; -*-
|
||||||
|
|
||||||
|
import decimal
|
||||||
|
|
||||||
|
from wuttjamaican.testing import DataTestCase
|
||||||
|
|
||||||
|
from sideshow.batch import neworder as mod
|
||||||
|
|
||||||
|
|
||||||
|
class TestNewOrderBatchHandler(DataTestCase):
|
||||||
|
|
||||||
|
def make_config(self, **kwargs):
|
||||||
|
config = super().make_config(**kwargs)
|
||||||
|
config.setdefault('wutta.model_spec', 'sideshow.db.model')
|
||||||
|
config.setdefault('wutta.enum_spec', 'sideshow.enum')
|
||||||
|
return config
|
||||||
|
|
||||||
|
def make_handler(self):
|
||||||
|
return mod.NewOrderBatchHandler(self.config)
|
||||||
|
|
||||||
|
def test_set_pending_customer(self):
|
||||||
|
model = self.app.model
|
||||||
|
handler = self.make_handler()
|
||||||
|
|
||||||
|
user = model.User(username='barney')
|
||||||
|
self.session.add(user)
|
||||||
|
|
||||||
|
batch = handler.make_batch(self.session, created_by=user, customer_id=42)
|
||||||
|
self.assertEqual(batch.customer_id, 42)
|
||||||
|
self.assertIsNone(batch.pending_customer)
|
||||||
|
self.assertIsNone(batch.customer_name)
|
||||||
|
self.assertIsNone(batch.phone_number)
|
||||||
|
self.assertIsNone(batch.email_address)
|
||||||
|
|
||||||
|
# auto full_name
|
||||||
|
handler.set_pending_customer(batch, {
|
||||||
|
'first_name': "Fred",
|
||||||
|
'last_name': "Flintstone",
|
||||||
|
'phone_number': '555-1234',
|
||||||
|
'email_address': 'fred@mailinator.com',
|
||||||
|
})
|
||||||
|
self.assertIsNone(batch.customer_id)
|
||||||
|
self.assertIsInstance(batch.pending_customer, model.PendingCustomer)
|
||||||
|
customer = batch.pending_customer
|
||||||
|
self.assertEqual(customer.full_name, "Fred Flintstone")
|
||||||
|
self.assertEqual(customer.first_name, "Fred")
|
||||||
|
self.assertEqual(customer.last_name, "Flintstone")
|
||||||
|
self.assertEqual(customer.phone_number, '555-1234')
|
||||||
|
self.assertEqual(customer.email_address, 'fred@mailinator.com')
|
||||||
|
self.assertEqual(batch.customer_name, "Fred Flintstone")
|
||||||
|
self.assertEqual(batch.phone_number, '555-1234')
|
||||||
|
self.assertEqual(batch.email_address, 'fred@mailinator.com')
|
||||||
|
|
||||||
|
# explicit full_name
|
||||||
|
batch = handler.make_batch(self.session, created_by=user, customer_id=42)
|
||||||
|
handler.set_pending_customer(batch, {
|
||||||
|
'full_name': "Freddy Flintstone",
|
||||||
|
'first_name': "Fred",
|
||||||
|
'last_name': "Flintstone",
|
||||||
|
'phone_number': '555-1234',
|
||||||
|
'email_address': 'fred@mailinator.com',
|
||||||
|
})
|
||||||
|
self.assertIsNone(batch.customer_id)
|
||||||
|
self.assertIsInstance(batch.pending_customer, model.PendingCustomer)
|
||||||
|
customer = batch.pending_customer
|
||||||
|
self.assertEqual(customer.full_name, "Freddy Flintstone")
|
||||||
|
self.assertEqual(customer.first_name, "Fred")
|
||||||
|
self.assertEqual(customer.last_name, "Flintstone")
|
||||||
|
self.assertEqual(customer.phone_number, '555-1234')
|
||||||
|
self.assertEqual(customer.email_address, 'fred@mailinator.com')
|
||||||
|
self.assertEqual(batch.customer_name, "Freddy Flintstone")
|
||||||
|
self.assertEqual(batch.phone_number, '555-1234')
|
||||||
|
self.assertEqual(batch.email_address, 'fred@mailinator.com')
|
||||||
|
|
||||||
|
def test_add_pending_product(self):
|
||||||
|
model = self.app.model
|
||||||
|
enum = self.app.enum
|
||||||
|
handler = self.make_handler()
|
||||||
|
|
||||||
|
user = model.User(username='barney')
|
||||||
|
self.session.add(user)
|
||||||
|
|
||||||
|
batch = handler.make_batch(self.session, created_by=user)
|
||||||
|
self.session.add(batch)
|
||||||
|
self.assertEqual(len(batch.rows), 0)
|
||||||
|
|
||||||
|
kw = dict(
|
||||||
|
scancode='07430500132',
|
||||||
|
brand_name='Bragg',
|
||||||
|
description='Vinegar',
|
||||||
|
size='32oz',
|
||||||
|
case_size=12,
|
||||||
|
unit_cost=decimal.Decimal('3.99'),
|
||||||
|
unit_price_reg=decimal.Decimal('5.99'),
|
||||||
|
created_by=user,
|
||||||
|
)
|
||||||
|
row = handler.add_pending_product(batch, kw, 1, enum.ORDER_UOM_UNIT)
|
||||||
|
self.assertEqual(len(batch.rows), 1)
|
||||||
|
self.assertIs(batch.rows[0], row)
|
||||||
|
|
||||||
|
self.assertEqual(row.product_scancode, '07430500132')
|
||||||
|
self.assertEqual(row.product_brand, 'Bragg')
|
||||||
|
self.assertEqual(row.product_description, 'Vinegar')
|
||||||
|
self.assertEqual(row.product_size, '32oz')
|
||||||
|
self.assertEqual(row.case_size, 12)
|
||||||
|
self.assertEqual(row.unit_cost, decimal.Decimal('3.99'))
|
||||||
|
self.assertEqual(row.unit_price_reg, decimal.Decimal('5.99'))
|
||||||
|
self.assertEqual(row.unit_price_quoted, decimal.Decimal('5.99'))
|
||||||
|
self.assertEqual(row.case_price_quoted, decimal.Decimal('71.88'))
|
||||||
|
|
||||||
|
product = row.pending_product
|
||||||
|
self.assertIsInstance(product, model.PendingProduct)
|
||||||
|
self.assertEqual(product.scancode, '07430500132')
|
||||||
|
self.assertEqual(product.brand_name, 'Bragg')
|
||||||
|
self.assertEqual(product.description, 'Vinegar')
|
||||||
|
self.assertEqual(product.size, '32oz')
|
||||||
|
self.assertEqual(product.case_size, 12)
|
||||||
|
self.assertEqual(product.unit_cost, decimal.Decimal('3.99'))
|
||||||
|
self.assertEqual(product.unit_price_reg, decimal.Decimal('5.99'))
|
||||||
|
self.assertIs(product.created_by, user)
|
||||||
|
|
||||||
|
def test_set_pending_product(self):
|
||||||
|
model = self.app.model
|
||||||
|
enum = self.app.enum
|
||||||
|
handler = self.make_handler()
|
||||||
|
|
||||||
|
user = model.User(username='barney')
|
||||||
|
self.session.add(user)
|
||||||
|
|
||||||
|
batch = handler.make_batch(self.session, created_by=user)
|
||||||
|
self.session.add(batch)
|
||||||
|
self.assertEqual(len(batch.rows), 0)
|
||||||
|
|
||||||
|
# start with mock product_id
|
||||||
|
row = handler.make_row(product_id=42, order_qty=1, order_uom=enum.ORDER_UOM_UNIT)
|
||||||
|
handler.add_row(batch, row)
|
||||||
|
self.session.flush()
|
||||||
|
self.assertEqual(row.product_id, 42)
|
||||||
|
self.assertIsNone(row.pending_product)
|
||||||
|
self.assertIsNone(row.product_scancode)
|
||||||
|
self.assertIsNone(row.product_brand)
|
||||||
|
self.assertIsNone(row.product_description)
|
||||||
|
self.assertIsNone(row.product_size)
|
||||||
|
self.assertIsNone(row.case_size)
|
||||||
|
self.assertIsNone(row.unit_cost)
|
||||||
|
self.assertIsNone(row.unit_price_reg)
|
||||||
|
self.assertIsNone(row.unit_price_quoted)
|
||||||
|
|
||||||
|
# set pending, which clears product_id
|
||||||
|
handler.set_pending_product(row, dict(
|
||||||
|
scancode='07430500132',
|
||||||
|
brand_name='Bragg',
|
||||||
|
description='Vinegar',
|
||||||
|
size='32oz',
|
||||||
|
case_size=12,
|
||||||
|
unit_cost=decimal.Decimal('3.99'),
|
||||||
|
unit_price_reg=decimal.Decimal('5.99'),
|
||||||
|
created_by=user,
|
||||||
|
))
|
||||||
|
self.session.flush()
|
||||||
|
self.assertIsNone(row.product_id)
|
||||||
|
self.assertIsInstance(row.pending_product, model.PendingProduct)
|
||||||
|
self.assertEqual(row.product_scancode, '07430500132')
|
||||||
|
self.assertEqual(row.product_brand, 'Bragg')
|
||||||
|
self.assertEqual(row.product_description, 'Vinegar')
|
||||||
|
self.assertEqual(row.product_size, '32oz')
|
||||||
|
self.assertEqual(row.case_size, 12)
|
||||||
|
self.assertEqual(row.unit_cost, decimal.Decimal('3.99'))
|
||||||
|
self.assertEqual(row.unit_price_reg, decimal.Decimal('5.99'))
|
||||||
|
self.assertEqual(row.unit_price_quoted, decimal.Decimal('5.99'))
|
||||||
|
self.assertEqual(row.case_price_quoted, decimal.Decimal('71.88'))
|
||||||
|
product = row.pending_product
|
||||||
|
self.assertIsInstance(product, model.PendingProduct)
|
||||||
|
self.assertEqual(product.scancode, '07430500132')
|
||||||
|
self.assertEqual(product.brand_name, 'Bragg')
|
||||||
|
self.assertEqual(product.description, 'Vinegar')
|
||||||
|
self.assertEqual(product.size, '32oz')
|
||||||
|
self.assertEqual(product.case_size, 12)
|
||||||
|
self.assertEqual(product.unit_cost, decimal.Decimal('3.99'))
|
||||||
|
self.assertEqual(product.unit_price_reg, decimal.Decimal('5.99'))
|
||||||
|
self.assertIs(product.created_by, user)
|
||||||
|
|
||||||
|
# set again to update pending
|
||||||
|
handler.set_pending_product(row, dict(
|
||||||
|
scancode='07430500116',
|
||||||
|
size='16oz',
|
||||||
|
unit_cost=decimal.Decimal('2.19'),
|
||||||
|
unit_price_reg=decimal.Decimal('3.59'),
|
||||||
|
))
|
||||||
|
self.session.flush()
|
||||||
|
self.assertIsNone(row.product_id)
|
||||||
|
self.assertIsInstance(row.pending_product, model.PendingProduct)
|
||||||
|
self.assertEqual(row.product_scancode, '07430500116')
|
||||||
|
self.assertEqual(row.product_brand, 'Bragg')
|
||||||
|
self.assertEqual(row.product_description, 'Vinegar')
|
||||||
|
self.assertEqual(row.product_size, '16oz')
|
||||||
|
self.assertEqual(row.case_size, 12)
|
||||||
|
self.assertEqual(row.unit_cost, decimal.Decimal('2.19'))
|
||||||
|
self.assertEqual(row.unit_price_reg, decimal.Decimal('3.59'))
|
||||||
|
self.assertEqual(row.unit_price_quoted, decimal.Decimal('3.59'))
|
||||||
|
self.assertEqual(row.case_price_quoted, decimal.Decimal('43.08'))
|
||||||
|
product = row.pending_product
|
||||||
|
self.assertIsInstance(product, model.PendingProduct)
|
||||||
|
self.assertEqual(product.scancode, '07430500116')
|
||||||
|
self.assertEqual(product.brand_name, 'Bragg')
|
||||||
|
self.assertEqual(product.description, 'Vinegar')
|
||||||
|
self.assertEqual(product.size, '16oz')
|
||||||
|
self.assertEqual(product.case_size, 12)
|
||||||
|
self.assertEqual(product.unit_cost, decimal.Decimal('2.19'))
|
||||||
|
self.assertEqual(product.unit_price_reg, decimal.Decimal('3.59'))
|
||||||
|
self.assertIs(product.created_by, user)
|
||||||
|
|
||||||
|
def test_refresh_row(self):
|
||||||
|
model = self.app.model
|
||||||
|
enum = self.app.enum
|
||||||
|
handler = self.make_handler()
|
||||||
|
|
||||||
|
user = model.User(username='barney')
|
||||||
|
self.session.add(user)
|
||||||
|
|
||||||
|
batch = handler.make_batch(self.session, created_by=user)
|
||||||
|
self.session.add(batch)
|
||||||
|
self.assertEqual(len(batch.rows), 0)
|
||||||
|
|
||||||
|
# missing product
|
||||||
|
row = handler.make_row(order_qty=1, order_uom=enum.ORDER_UOM_UNIT)
|
||||||
|
self.assertIsNone(row.status_code)
|
||||||
|
handler.add_row(batch, row)
|
||||||
|
self.assertEqual(row.status_code, row.STATUS_MISSING_PRODUCT)
|
||||||
|
|
||||||
|
# missing order_qty
|
||||||
|
row = handler.make_row(product_id=42, order_uom=enum.ORDER_UOM_UNIT)
|
||||||
|
self.assertIsNone(row.status_code)
|
||||||
|
handler.add_row(batch, row)
|
||||||
|
self.assertEqual(row.status_code, row.STATUS_MISSING_ORDER_QTY)
|
||||||
|
|
||||||
|
# refreshed from pending product (null price)
|
||||||
|
product = model.PendingProduct(scancode='07430500132',
|
||||||
|
brand_name='Bragg',
|
||||||
|
description='Vinegar',
|
||||||
|
size='32oz',
|
||||||
|
created_by=user,
|
||||||
|
status=enum.PendingProductStatus.PENDING)
|
||||||
|
row = handler.make_row(pending_product=product, order_qty=1, order_uom=enum.ORDER_UOM_UNIT)
|
||||||
|
self.assertIsNone(row.status_code)
|
||||||
|
handler.add_row(batch, row)
|
||||||
|
self.assertEqual(row.status_code, row.STATUS_OK)
|
||||||
|
self.assertIsNone(row.product_id)
|
||||||
|
self.assertIs(row.pending_product, product)
|
||||||
|
self.assertEqual(row.product_scancode, '07430500132')
|
||||||
|
self.assertEqual(row.product_brand, 'Bragg')
|
||||||
|
self.assertEqual(row.product_description, 'Vinegar')
|
||||||
|
self.assertEqual(row.product_size, '32oz')
|
||||||
|
self.assertIsNone(row.case_size)
|
||||||
|
self.assertIsNone(row.unit_cost)
|
||||||
|
self.assertIsNone(row.unit_price_reg)
|
||||||
|
self.assertIsNone(row.unit_price_quoted)
|
||||||
|
self.assertIsNone(row.case_price_quoted)
|
||||||
|
self.assertIsNone(row.total_price)
|
||||||
|
|
||||||
|
# refreshed from pending product (zero price)
|
||||||
|
product = model.PendingProduct(scancode='07430500132',
|
||||||
|
brand_name='Bragg',
|
||||||
|
description='Vinegar',
|
||||||
|
size='32oz',
|
||||||
|
unit_price_reg=0,
|
||||||
|
created_by=user,
|
||||||
|
status=enum.PendingProductStatus.PENDING)
|
||||||
|
row = handler.make_row(pending_product=product, order_qty=1, order_uom=enum.ORDER_UOM_UNIT)
|
||||||
|
self.assertIsNone(row.status_code)
|
||||||
|
handler.add_row(batch, row)
|
||||||
|
self.assertEqual(row.status_code, row.STATUS_OK)
|
||||||
|
self.assertIsNone(row.product_id)
|
||||||
|
self.assertIs(row.pending_product, product)
|
||||||
|
self.assertEqual(row.product_scancode, '07430500132')
|
||||||
|
self.assertEqual(row.product_brand, 'Bragg')
|
||||||
|
self.assertEqual(row.product_description, 'Vinegar')
|
||||||
|
self.assertEqual(row.product_size, '32oz')
|
||||||
|
self.assertIsNone(row.case_size)
|
||||||
|
self.assertIsNone(row.unit_cost)
|
||||||
|
self.assertEqual(row.unit_price_reg, 0)
|
||||||
|
self.assertEqual(row.unit_price_quoted, 0)
|
||||||
|
self.assertIsNone(row.case_price_quoted)
|
||||||
|
self.assertEqual(row.total_price, 0)
|
||||||
|
|
||||||
|
# refreshed from pending product (normal, case)
|
||||||
|
product = model.PendingProduct(scancode='07430500132',
|
||||||
|
brand_name='Bragg',
|
||||||
|
description='Vinegar',
|
||||||
|
size='32oz',
|
||||||
|
case_size=12,
|
||||||
|
unit_cost=decimal.Decimal('3.99'),
|
||||||
|
unit_price_reg=decimal.Decimal('5.99'),
|
||||||
|
created_by=user,
|
||||||
|
status=enum.PendingProductStatus.PENDING)
|
||||||
|
row = handler.make_row(pending_product=product, order_qty=2, order_uom=enum.ORDER_UOM_CASE)
|
||||||
|
self.assertIsNone(row.status_code)
|
||||||
|
handler.add_row(batch, row)
|
||||||
|
self.assertEqual(row.status_code, row.STATUS_OK)
|
||||||
|
self.assertIsNone(row.product_id)
|
||||||
|
self.assertIs(row.pending_product, product)
|
||||||
|
self.assertEqual(row.product_scancode, '07430500132')
|
||||||
|
self.assertEqual(row.product_brand, 'Bragg')
|
||||||
|
self.assertEqual(row.product_description, 'Vinegar')
|
||||||
|
self.assertEqual(row.product_size, '32oz')
|
||||||
|
self.assertEqual(row.case_size, 12)
|
||||||
|
self.assertEqual(row.unit_cost, decimal.Decimal('3.99'))
|
||||||
|
self.assertEqual(row.unit_price_reg, decimal.Decimal('5.99'))
|
||||||
|
self.assertEqual(row.unit_price_quoted, decimal.Decimal('5.99'))
|
||||||
|
self.assertEqual(row.case_price_quoted, decimal.Decimal('71.88'))
|
||||||
|
self.assertEqual(row.total_price, decimal.Decimal('143.76'))
|
||||||
|
|
||||||
|
def test_remove_row(self):
|
||||||
|
model = self.app.model
|
||||||
|
enum = self.app.enum
|
||||||
|
handler = self.make_handler()
|
||||||
|
|
||||||
|
user = model.User(username='barney')
|
||||||
|
self.session.add(user)
|
||||||
|
|
||||||
|
batch = handler.make_batch(self.session, created_by=user)
|
||||||
|
self.session.add(batch)
|
||||||
|
self.assertEqual(len(batch.rows), 0)
|
||||||
|
|
||||||
|
kw = dict(
|
||||||
|
scancode='07430500132',
|
||||||
|
brand_name='Bragg',
|
||||||
|
description='Vinegar',
|
||||||
|
size='32oz',
|
||||||
|
case_size=12,
|
||||||
|
unit_cost=decimal.Decimal('3.99'),
|
||||||
|
unit_price_reg=decimal.Decimal('5.99'),
|
||||||
|
created_by=user,
|
||||||
|
)
|
||||||
|
row = handler.add_pending_product(batch, kw, 1, enum.ORDER_UOM_CASE)
|
||||||
|
self.session.add(row)
|
||||||
|
self.session.flush()
|
||||||
|
self.assertEqual(batch.row_count, 1)
|
||||||
|
self.assertEqual(row.total_price, decimal.Decimal('71.88'))
|
||||||
|
self.assertEqual(batch.total_price, decimal.Decimal('71.88'))
|
||||||
|
|
||||||
|
handler.do_remove_row(row)
|
||||||
|
self.assertEqual(batch.row_count, 0)
|
||||||
|
self.assertEqual(batch.total_price, 0)
|
||||||
|
|
||||||
|
def test_do_delete(self):
|
||||||
|
model = self.app.model
|
||||||
|
enum = self.app.enum
|
||||||
|
handler = self.make_handler()
|
||||||
|
|
||||||
|
user = model.User(username='barney')
|
||||||
|
self.session.add(user)
|
||||||
|
|
||||||
|
# make batch w/ pending customer
|
||||||
|
customer = model.PendingCustomer(full_name="Fred Flintstone",
|
||||||
|
status=enum.PendingCustomerStatus.PENDING,
|
||||||
|
created_by=user)
|
||||||
|
self.session.add(customer)
|
||||||
|
batch = handler.make_batch(self.session, created_by=user, pending_customer=customer)
|
||||||
|
self.session.add(batch)
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
# deleting batch will also delete pending customer
|
||||||
|
self.assertIn(batch, self.session)
|
||||||
|
self.assertEqual(self.session.query(model.PendingCustomer).count(), 1)
|
||||||
|
handler.do_delete(batch, user)
|
||||||
|
self.session.commit()
|
||||||
|
self.assertNotIn(batch, self.session)
|
||||||
|
self.assertEqual(self.session.query(model.PendingCustomer).count(), 0)
|
||||||
|
|
||||||
|
# make new pending customer
|
||||||
|
customer = model.PendingCustomer(full_name="Fred Flintstone",
|
||||||
|
status=enum.PendingCustomerStatus.PENDING,
|
||||||
|
created_by=user)
|
||||||
|
self.session.add(customer)
|
||||||
|
|
||||||
|
# make 2 batches with same pending customer
|
||||||
|
batch1 = handler.make_batch(self.session, created_by=user, pending_customer=customer)
|
||||||
|
batch2 = handler.make_batch(self.session, created_by=user, pending_customer=customer)
|
||||||
|
self.session.add(batch1)
|
||||||
|
self.session.add(batch2)
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
# deleting 1 will not delete pending customer
|
||||||
|
self.assertEqual(self.session.query(model.PendingCustomer).count(), 1)
|
||||||
|
handler.do_delete(batch1, user)
|
||||||
|
self.session.commit()
|
||||||
|
self.assertEqual(self.session.query(model.PendingCustomer).count(), 1)
|
||||||
|
self.assertIs(batch2.pending_customer, customer)
|
||||||
|
|
||||||
|
def test_get_effective_rows(self):
|
||||||
|
model = self.app.model
|
||||||
|
enum = self.app.enum
|
||||||
|
handler = self.make_handler()
|
||||||
|
|
||||||
|
user = model.User(username='barney')
|
||||||
|
self.session.add(user)
|
||||||
|
|
||||||
|
# make batch w/ different status rows
|
||||||
|
batch = handler.make_batch(self.session, created_by=user)
|
||||||
|
self.session.add(batch)
|
||||||
|
# STATUS_MISSING_PRODUCT
|
||||||
|
row = handler.make_row(order_qty=1, order_uom=enum.ORDER_UOM_UNIT)
|
||||||
|
handler.add_row(batch, row)
|
||||||
|
self.session.add(row)
|
||||||
|
self.session.flush()
|
||||||
|
# STATUS_MISSING_ORDER_QTY
|
||||||
|
row = handler.make_row(product_id=42, order_qty=0, order_uom=enum.ORDER_UOM_UNIT)
|
||||||
|
handler.add_row(batch, row)
|
||||||
|
self.session.add(row)
|
||||||
|
self.session.flush()
|
||||||
|
# STATUS_OK
|
||||||
|
row = handler.make_row(product_id=42, order_qty=1, order_uom=enum.ORDER_UOM_UNIT)
|
||||||
|
handler.add_row(batch, row)
|
||||||
|
self.session.add(row)
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
# only 1 effective row
|
||||||
|
rows = handler.get_effective_rows(batch)
|
||||||
|
self.assertEqual(len(rows), 1)
|
||||||
|
row = rows[0]
|
||||||
|
self.assertEqual(row.status_code, row.STATUS_OK)
|
||||||
|
|
||||||
|
def test_why_not_execute(self):
|
||||||
|
model = self.app.model
|
||||||
|
enum = self.app.enum
|
||||||
|
handler = self.make_handler()
|
||||||
|
|
||||||
|
user = model.User(username='barney')
|
||||||
|
self.session.add(user)
|
||||||
|
|
||||||
|
batch = handler.make_batch(self.session, created_by=user)
|
||||||
|
self.session.add(batch)
|
||||||
|
self.session.flush()
|
||||||
|
|
||||||
|
reason = handler.why_not_execute(batch)
|
||||||
|
self.assertEqual(reason, "Must assign the customer")
|
||||||
|
|
||||||
|
batch.customer_id = 42
|
||||||
|
|
||||||
|
reason = handler.why_not_execute(batch)
|
||||||
|
self.assertEqual(reason, "Must add at least one valid item")
|
||||||
|
|
||||||
|
kw = dict(
|
||||||
|
scancode='07430500132',
|
||||||
|
brand_name='Bragg',
|
||||||
|
description='Vinegar',
|
||||||
|
size='32oz',
|
||||||
|
case_size=12,
|
||||||
|
unit_cost=decimal.Decimal('3.99'),
|
||||||
|
unit_price_reg=decimal.Decimal('5.99'),
|
||||||
|
created_by=user,
|
||||||
|
)
|
||||||
|
row = handler.add_pending_product(batch, kw, 1, enum.ORDER_UOM_CASE)
|
||||||
|
self.session.add(row)
|
||||||
|
self.session.flush()
|
||||||
|
|
||||||
|
reason = handler.why_not_execute(batch)
|
||||||
|
self.assertIsNone(reason)
|
||||||
|
|
||||||
|
def test_make_new_order(self):
|
||||||
|
model = self.app.model
|
||||||
|
enum = self.app.enum
|
||||||
|
handler = self.make_handler()
|
||||||
|
|
||||||
|
user = model.User(username='barney')
|
||||||
|
self.session.add(user)
|
||||||
|
|
||||||
|
batch = handler.make_batch(self.session, created_by=user,
|
||||||
|
customer_id=42, customer_name="John Doe")
|
||||||
|
self.session.add(batch)
|
||||||
|
kw = dict(
|
||||||
|
scancode='07430500132',
|
||||||
|
brand_name='Bragg',
|
||||||
|
description='Vinegar',
|
||||||
|
size='32oz',
|
||||||
|
case_size=12,
|
||||||
|
unit_cost=decimal.Decimal('3.99'),
|
||||||
|
unit_price_reg=decimal.Decimal('5.99'),
|
||||||
|
created_by=user,
|
||||||
|
)
|
||||||
|
row = handler.add_pending_product(batch, kw, 1, enum.ORDER_UOM_CASE)
|
||||||
|
self.session.add(row)
|
||||||
|
self.session.flush()
|
||||||
|
|
||||||
|
order = handler.make_new_order(batch, [row], user=user)
|
||||||
|
self.assertIsInstance(order, model.Order)
|
||||||
|
self.assertIs(order.created_by, user)
|
||||||
|
self.assertEqual(order.customer_id, 42)
|
||||||
|
self.assertEqual(order.customer_name, "John Doe")
|
||||||
|
self.assertEqual(len(order.items), 1)
|
||||||
|
item = order.items[0]
|
||||||
|
self.assertEqual(item.product_scancode, '07430500132')
|
||||||
|
self.assertEqual(item.product_brand, 'Bragg')
|
||||||
|
self.assertEqual(item.product_description, 'Vinegar')
|
||||||
|
self.assertEqual(item.product_size, '32oz')
|
||||||
|
self.assertEqual(item.case_size, 12)
|
||||||
|
self.assertEqual(item.unit_cost, decimal.Decimal('3.99'))
|
||||||
|
self.assertEqual(item.unit_price_reg, decimal.Decimal('5.99'))
|
||||||
|
|
||||||
|
def test_execute(self):
|
||||||
|
model = self.app.model
|
||||||
|
enum = self.app.enum
|
||||||
|
handler = self.make_handler()
|
||||||
|
|
||||||
|
user = model.User(username='barney')
|
||||||
|
self.session.add(user)
|
||||||
|
|
||||||
|
batch = handler.make_batch(self.session, created_by=user,
|
||||||
|
customer_id=42, customer_name="John Doe")
|
||||||
|
self.session.add(batch)
|
||||||
|
kw = dict(
|
||||||
|
scancode='07430500132',
|
||||||
|
brand_name='Bragg',
|
||||||
|
description='Vinegar',
|
||||||
|
size='32oz',
|
||||||
|
case_size=12,
|
||||||
|
unit_cost=decimal.Decimal('3.99'),
|
||||||
|
unit_price_reg=decimal.Decimal('5.99'),
|
||||||
|
created_by=user,
|
||||||
|
)
|
||||||
|
row = handler.add_pending_product(batch, kw, 1, enum.ORDER_UOM_CASE)
|
||||||
|
self.session.add(row)
|
||||||
|
self.session.flush()
|
||||||
|
|
||||||
|
order = handler.execute(batch, user=user)
|
||||||
|
self.assertIsInstance(order, model.Order)
|
||||||
|
self.assertIs(order.created_by, user)
|
||||||
|
self.assertEqual(order.customer_id, 42)
|
||||||
|
self.assertEqual(order.customer_name, "John Doe")
|
||||||
|
self.assertEqual(len(order.items), 1)
|
||||||
|
item = order.items[0]
|
||||||
|
self.assertEqual(item.product_scancode, '07430500132')
|
||||||
|
self.assertEqual(item.product_brand, 'Bragg')
|
||||||
|
self.assertEqual(item.product_description, 'Vinegar')
|
||||||
|
self.assertEqual(item.product_size, '32oz')
|
||||||
|
self.assertEqual(item.case_size, 12)
|
||||||
|
self.assertEqual(item.unit_cost, decimal.Decimal('3.99'))
|
||||||
|
self.assertEqual(item.unit_price_reg, decimal.Decimal('5.99'))
|
0
tests/cli/__init__.py
Normal file
0
tests/cli/__init__.py
Normal file
18
tests/cli/test_install.py
Normal file
18
tests/cli/test_install.py
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
# -*- coding: utf-8; -*-
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from wuttjamaican.testing import ConfigTestCase
|
||||||
|
from wuttjamaican.install import InstallHandler
|
||||||
|
|
||||||
|
from sideshow.cli import install as mod
|
||||||
|
|
||||||
|
|
||||||
|
class TestInstall(ConfigTestCase):
|
||||||
|
|
||||||
|
def test_run(self):
|
||||||
|
ctx = MagicMock(params={})
|
||||||
|
ctx.parent.wutta_config = self.config
|
||||||
|
with patch.object(InstallHandler, 'run') as run:
|
||||||
|
mod.install(ctx)
|
||||||
|
run.assert_called_once_with()
|
0
tests/db/__init__.py
Normal file
0
tests/db/__init__.py
Normal file
0
tests/db/model/__init__.py
Normal file
0
tests/db/model/__init__.py
Normal file
0
tests/db/model/batch/__init__.py
Normal file
0
tests/db/model/batch/__init__.py
Normal file
23
tests/db/model/batch/test_neworder.py
Normal file
23
tests/db/model/batch/test_neworder.py
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
# -*- coding: utf-8; -*-
|
||||||
|
|
||||||
|
from wuttjamaican.testing import DataTestCase
|
||||||
|
|
||||||
|
from sideshow.db.model.batch import neworder as mod
|
||||||
|
from sideshow.db.model.products import PendingProduct
|
||||||
|
|
||||||
|
|
||||||
|
class TestNewOrderBatchRow(DataTestCase):
|
||||||
|
|
||||||
|
def test_str(self):
|
||||||
|
|
||||||
|
row = mod.NewOrderBatchRow()
|
||||||
|
self.assertEqual(str(row), "")
|
||||||
|
|
||||||
|
row = mod.NewOrderBatchRow(product_description="Vinegar")
|
||||||
|
self.assertEqual(str(row), "Vinegar")
|
||||||
|
|
||||||
|
product = PendingProduct(brand_name="Bragg",
|
||||||
|
description="Vinegar",
|
||||||
|
size="32oz")
|
||||||
|
row = mod.NewOrderBatchRow(pending_product=product)
|
||||||
|
self.assertEqual(str(row), "Bragg Vinegar 32oz")
|
15
tests/db/model/test_customers.py
Normal file
15
tests/db/model/test_customers.py
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
# -*- coding: utf-8; -*-
|
||||||
|
|
||||||
|
from wuttjamaican.testing import DataTestCase
|
||||||
|
|
||||||
|
from sideshow.db.model import customers as mod
|
||||||
|
|
||||||
|
|
||||||
|
class TestPendingCustomer(DataTestCase):
|
||||||
|
|
||||||
|
def test_str(self):
|
||||||
|
customer = mod.PendingCustomer()
|
||||||
|
self.assertEqual(str(customer), "")
|
||||||
|
|
||||||
|
customer.full_name = "Fred Flintstone"
|
||||||
|
self.assertEqual(str(customer), "Fred Flintstone")
|
34
tests/db/model/test_orders.py
Normal file
34
tests/db/model/test_orders.py
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
# -*- coding: utf-8; -*-
|
||||||
|
|
||||||
|
from wuttjamaican.testing import DataTestCase
|
||||||
|
|
||||||
|
from sideshow.db.model import orders as mod
|
||||||
|
from sideshow.db.model.products import PendingProduct
|
||||||
|
|
||||||
|
|
||||||
|
class TestOrder(DataTestCase):
|
||||||
|
|
||||||
|
def test_str(self):
|
||||||
|
|
||||||
|
order = mod.Order()
|
||||||
|
self.assertEqual(str(order), "None")
|
||||||
|
|
||||||
|
order = mod.Order(order_id=42)
|
||||||
|
self.assertEqual(str(order), "42")
|
||||||
|
|
||||||
|
|
||||||
|
class TestOrderItem(DataTestCase):
|
||||||
|
|
||||||
|
def test_str(self):
|
||||||
|
|
||||||
|
item = mod.OrderItem()
|
||||||
|
self.assertEqual(str(item), "")
|
||||||
|
|
||||||
|
item = mod.OrderItem(product_description="Vinegar")
|
||||||
|
self.assertEqual(str(item), "Vinegar")
|
||||||
|
|
||||||
|
product = PendingProduct(brand_name="Bragg",
|
||||||
|
description="Vinegar",
|
||||||
|
size="32oz")
|
||||||
|
item = mod.OrderItem(pending_product=product)
|
||||||
|
self.assertEqual(str(item), "Bragg Vinegar 32oz")
|
44
tests/db/model/test_products.py
Normal file
44
tests/db/model/test_products.py
Normal file
|
@ -0,0 +1,44 @@
|
||||||
|
# -*- coding: utf-8; -*-
|
||||||
|
|
||||||
|
from wuttjamaican.testing import DataTestCase
|
||||||
|
|
||||||
|
from sideshow.db.model import products as mod
|
||||||
|
|
||||||
|
|
||||||
|
class TestPendingProduct(DataTestCase):
|
||||||
|
|
||||||
|
def test_str(self):
|
||||||
|
product = mod.PendingProduct()
|
||||||
|
self.assertEqual(str(product), "")
|
||||||
|
|
||||||
|
product = mod.PendingProduct(brand_name="Bragg")
|
||||||
|
self.assertEqual(str(product), "Bragg")
|
||||||
|
|
||||||
|
product = mod.PendingProduct(description="Vinegar")
|
||||||
|
self.assertEqual(str(product), "Vinegar")
|
||||||
|
|
||||||
|
product = mod.PendingProduct(size="32oz")
|
||||||
|
self.assertEqual(str(product), "32oz")
|
||||||
|
|
||||||
|
product = mod.PendingProduct(brand_name="Bragg",
|
||||||
|
description="Vinegar",
|
||||||
|
size="32oz")
|
||||||
|
self.assertEqual(str(product), "Bragg Vinegar 32oz")
|
||||||
|
|
||||||
|
def test_full_description(self):
|
||||||
|
product = mod.PendingProduct()
|
||||||
|
self.assertEqual(product.full_description, "")
|
||||||
|
|
||||||
|
product = mod.PendingProduct(brand_name="Bragg")
|
||||||
|
self.assertEqual(product.full_description, "Bragg")
|
||||||
|
|
||||||
|
product = mod.PendingProduct(description="Vinegar")
|
||||||
|
self.assertEqual(product.full_description, "Vinegar")
|
||||||
|
|
||||||
|
product = mod.PendingProduct(size="32oz")
|
||||||
|
self.assertEqual(product.full_description, "32oz")
|
||||||
|
|
||||||
|
product = mod.PendingProduct(brand_name="Bragg",
|
||||||
|
description="Vinegar",
|
||||||
|
size="32oz")
|
||||||
|
self.assertEqual(product.full_description, "Bragg Vinegar 32oz")
|
17
tests/test_config.py
Normal file
17
tests/test_config.py
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
# -*- coding: utf-8; -*-
|
||||||
|
|
||||||
|
from unittest import TestCase
|
||||||
|
|
||||||
|
from wuttjamaican.conf import WuttaConfig
|
||||||
|
|
||||||
|
from sideshow import config as mod
|
||||||
|
|
||||||
|
|
||||||
|
class TestSideshowConfig(TestCase):
|
||||||
|
|
||||||
|
def test_configure(self):
|
||||||
|
config = WuttaConfig(files=[])
|
||||||
|
ext = mod.SideshowConfig()
|
||||||
|
ext.configure(config)
|
||||||
|
self.assertEqual(config.get('wutta.app_title'), "Sideshow")
|
||||||
|
self.assertEqual(config.get('wutta.app_dist'), "Sideshow")
|
0
tests/web/example.conf
Normal file
0
tests/web/example.conf
Normal file
88
tests/web/forms/test_schema.py
Normal file
88
tests/web/forms/test_schema.py
Normal file
|
@ -0,0 +1,88 @@
|
||||||
|
# -*- coding: utf-8; -*-
|
||||||
|
|
||||||
|
from sqlalchemy import orm
|
||||||
|
|
||||||
|
from sideshow.testing import WebTestCase
|
||||||
|
from sideshow.web.forms import schema as mod
|
||||||
|
|
||||||
|
|
||||||
|
class TestOrderRef(WebTestCase):
|
||||||
|
|
||||||
|
def test_sort_query(self):
|
||||||
|
typ = mod.OrderRef(self.request, session=self.session)
|
||||||
|
query = typ.get_query()
|
||||||
|
self.assertIsInstance(query, orm.Query)
|
||||||
|
sorted_query = typ.sort_query(query)
|
||||||
|
self.assertIsInstance(sorted_query, orm.Query)
|
||||||
|
self.assertIsNot(sorted_query, query)
|
||||||
|
|
||||||
|
def test_get_object_url(self):
|
||||||
|
self.pyramid_config.add_route('orders.view', '/orders/{uuid}')
|
||||||
|
model = self.app.model
|
||||||
|
|
||||||
|
user = model.User(username='barney')
|
||||||
|
self.session.add(user)
|
||||||
|
order = model.Order(order_id=42, created_by=user)
|
||||||
|
self.session.add(order)
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
typ = mod.OrderRef(self.request, session=self.session)
|
||||||
|
url = typ.get_object_url(order)
|
||||||
|
self.assertIsNotNone(url)
|
||||||
|
self.assertIn(f'/orders/{order.uuid}', url)
|
||||||
|
|
||||||
|
|
||||||
|
class TestPendingCustomerRef(WebTestCase):
|
||||||
|
|
||||||
|
def test_sort_query(self):
|
||||||
|
typ = mod.PendingCustomerRef(self.request, session=self.session)
|
||||||
|
query = typ.get_query()
|
||||||
|
self.assertIsInstance(query, orm.Query)
|
||||||
|
sorted_query = typ.sort_query(query)
|
||||||
|
self.assertIsInstance(sorted_query, orm.Query)
|
||||||
|
self.assertIsNot(sorted_query, query)
|
||||||
|
|
||||||
|
def test_get_object_url(self):
|
||||||
|
self.pyramid_config.add_route('pending_customers.view', '/pending/customers/{uuid}')
|
||||||
|
model = self.app.model
|
||||||
|
enum = self.app.enum
|
||||||
|
|
||||||
|
user = model.User(username='barney')
|
||||||
|
self.session.add(user)
|
||||||
|
customer = model.PendingCustomer(status=enum.PendingCustomerStatus.PENDING,
|
||||||
|
created_by=user)
|
||||||
|
self.session.add(customer)
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
typ = mod.PendingCustomerRef(self.request, session=self.session)
|
||||||
|
url = typ.get_object_url(customer)
|
||||||
|
self.assertIsNotNone(url)
|
||||||
|
self.assertIn(f'/pending/customers/{customer.uuid}', url)
|
||||||
|
|
||||||
|
|
||||||
|
class TestPendingProductRef(WebTestCase):
|
||||||
|
|
||||||
|
def test_sort_query(self):
|
||||||
|
typ = mod.PendingProductRef(self.request, session=self.session)
|
||||||
|
query = typ.get_query()
|
||||||
|
self.assertIsInstance(query, orm.Query)
|
||||||
|
sorted_query = typ.sort_query(query)
|
||||||
|
self.assertIsInstance(sorted_query, orm.Query)
|
||||||
|
self.assertIsNot(sorted_query, query)
|
||||||
|
|
||||||
|
def test_get_object_url(self):
|
||||||
|
self.pyramid_config.add_route('pending_products.view', '/pending/products/{uuid}')
|
||||||
|
model = self.app.model
|
||||||
|
enum = self.app.enum
|
||||||
|
|
||||||
|
user = model.User(username='barney')
|
||||||
|
self.session.add(user)
|
||||||
|
product = model.PendingProduct(status=enum.PendingProductStatus.PENDING,
|
||||||
|
created_by=user)
|
||||||
|
self.session.add(product)
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
typ = mod.PendingProductRef(self.request, session=self.session)
|
||||||
|
url = typ.get_object_url(product)
|
||||||
|
self.assertIsNotNone(url)
|
||||||
|
self.assertIn(f'/pending/products/{product.uuid}', url)
|
34
tests/web/test_app.py
Normal file
34
tests/web/test_app.py
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
# -*- coding: utf-8; -*-
|
||||||
|
|
||||||
|
import os
|
||||||
|
from unittest import TestCase
|
||||||
|
|
||||||
|
from asgiref.wsgi import WsgiToAsgi
|
||||||
|
from pyramid.router import Router
|
||||||
|
|
||||||
|
from sideshow.web import app as mod
|
||||||
|
|
||||||
|
|
||||||
|
here = os.path.dirname(__file__)
|
||||||
|
example_conf = os.path.join(here, 'example.conf')
|
||||||
|
|
||||||
|
|
||||||
|
class TestMain(TestCase):
|
||||||
|
|
||||||
|
def test_coverage(self):
|
||||||
|
app = mod.main({}, **{'wutta.config': example_conf})
|
||||||
|
self.assertIsInstance(app, Router)
|
||||||
|
|
||||||
|
|
||||||
|
class TestMakeWsgiApp(TestCase):
|
||||||
|
|
||||||
|
def test_coverage(self):
|
||||||
|
app = mod.make_wsgi_app()
|
||||||
|
self.assertIsInstance(app, Router)
|
||||||
|
|
||||||
|
|
||||||
|
class TestMakeAsgiApp(TestCase):
|
||||||
|
|
||||||
|
def test_coverage(self):
|
||||||
|
app = mod.make_asgi_app()
|
||||||
|
self.assertIsInstance(app, WsgiToAsgi)
|
12
tests/web/test_menus.py
Normal file
12
tests/web/test_menus.py
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
# -*- coding: utf-8; -*-
|
||||||
|
|
||||||
|
from sideshow.testing import WebTestCase
|
||||||
|
from sideshow.web import menus as mod
|
||||||
|
|
||||||
|
|
||||||
|
class TestSideshowMenuHandler(WebTestCase):
|
||||||
|
|
||||||
|
def test_make_menus(self):
|
||||||
|
handler = mod.SideshowMenuHandler(self.config)
|
||||||
|
menus = handler.make_menus(self.request)
|
||||||
|
self.assertEqual(len(menus), 4)
|
10
tests/web/test_static.py
Normal file
10
tests/web/test_static.py
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
# -*- coding: utf-8; -*-
|
||||||
|
|
||||||
|
from sideshow.testing import WebTestCase
|
||||||
|
from sideshow.web import static as mod
|
||||||
|
|
||||||
|
|
||||||
|
class TestIncludeme(WebTestCase):
|
||||||
|
|
||||||
|
def test_coverage(self):
|
||||||
|
mod.includeme(self.pyramid_config)
|
101
tests/web/views/batch/test_neworder.py
Normal file
101
tests/web/views/batch/test_neworder.py
Normal file
|
@ -0,0 +1,101 @@
|
||||||
|
# -*- coding: utf-8; -*-
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from wuttaweb.forms.schema import WuttaMoney
|
||||||
|
|
||||||
|
from sideshow.testing import WebTestCase
|
||||||
|
from sideshow.web.views.batch import neworder as mod
|
||||||
|
from sideshow.web.forms.schema import PendingCustomerRef
|
||||||
|
from sideshow.batch.neworder import NewOrderBatchHandler
|
||||||
|
|
||||||
|
|
||||||
|
class TestIncludeme(WebTestCase):
|
||||||
|
|
||||||
|
def test_coverage(self):
|
||||||
|
mod.includeme(self.pyramid_config)
|
||||||
|
|
||||||
|
|
||||||
|
class TestNewOrderBatchView(WebTestCase):
|
||||||
|
|
||||||
|
def make_view(self):
|
||||||
|
return mod.NewOrderBatchView(self.request)
|
||||||
|
|
||||||
|
def test_get_batch_handler(self):
|
||||||
|
view = self.make_view()
|
||||||
|
handler = view.get_batch_handler()
|
||||||
|
self.assertIsInstance(handler, NewOrderBatchHandler)
|
||||||
|
|
||||||
|
def test_configure_grid(self):
|
||||||
|
model = self.app.model
|
||||||
|
view = self.make_view()
|
||||||
|
grid = view.make_grid(model_class=model.NewOrderBatch)
|
||||||
|
self.assertNotIn('total_price', grid.renderers)
|
||||||
|
view.configure_grid(grid)
|
||||||
|
self.assertIn('total_price', grid.renderers)
|
||||||
|
|
||||||
|
def test_configure_form(self):
|
||||||
|
model = self.app.model
|
||||||
|
enum = self.app.enum
|
||||||
|
view = self.make_view()
|
||||||
|
handler = view.batch_handler
|
||||||
|
|
||||||
|
user = model.User(username='barney')
|
||||||
|
self.session.add(user)
|
||||||
|
customer = model.PendingCustomer(status=enum.PendingCustomerStatus.PENDING,
|
||||||
|
created_by=user)
|
||||||
|
self.session.add(customer)
|
||||||
|
batch = handler.make_batch(self.session, pending_customer=customer, created_by=user)
|
||||||
|
self.session.add(batch)
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
# viewing
|
||||||
|
with patch.object(view, 'viewing', new=True):
|
||||||
|
form = view.make_form(model_instance=batch)
|
||||||
|
view.configure_form(form)
|
||||||
|
schema = form.get_schema()
|
||||||
|
self.assertIsInstance(schema['pending_customer'].typ, PendingCustomerRef)
|
||||||
|
self.assertIsInstance(schema['total_price'].typ, WuttaMoney)
|
||||||
|
|
||||||
|
def test_configure_row_grid(self):
|
||||||
|
model = self.app.model
|
||||||
|
view = self.make_view()
|
||||||
|
grid = view.make_grid(model_class=model.NewOrderBatchRow)
|
||||||
|
self.assertNotIn('total_price', grid.renderers)
|
||||||
|
view.configure_row_grid(grid)
|
||||||
|
self.assertIn('total_price', grid.renderers)
|
||||||
|
|
||||||
|
def test_get_xref_buttons(self):
|
||||||
|
self.pyramid_config.add_route('orders.view', '/orders/{uuid}')
|
||||||
|
model = self.app.model
|
||||||
|
enum = self.app.enum
|
||||||
|
view = self.make_view()
|
||||||
|
handler = view.batch_handler
|
||||||
|
|
||||||
|
user = model.User(username='barney')
|
||||||
|
self.session.add(user)
|
||||||
|
customer = model.PendingCustomer(status=enum.PendingCustomerStatus.PENDING,
|
||||||
|
created_by=user)
|
||||||
|
self.session.add(customer)
|
||||||
|
|
||||||
|
# 1st batch has no order
|
||||||
|
batch = handler.make_batch(self.session, pending_customer=customer, created_by=user)
|
||||||
|
self.session.add(batch)
|
||||||
|
self.session.flush()
|
||||||
|
buttons = view.get_xref_buttons(batch)
|
||||||
|
self.assertEqual(len(buttons), 0)
|
||||||
|
|
||||||
|
# 2nd batch is executed; has order
|
||||||
|
batch = handler.make_batch(self.session, pending_customer=customer, created_by=user,
|
||||||
|
executed=datetime.datetime.now(), executed_by=user)
|
||||||
|
self.session.add(batch)
|
||||||
|
self.session.flush()
|
||||||
|
order = model.Order(order_id=batch.id, created_by=user)
|
||||||
|
self.session.add(order)
|
||||||
|
self.session.flush()
|
||||||
|
with patch.object(view, 'Session', return_value=self.session):
|
||||||
|
# nb. this also requires perm
|
||||||
|
with patch.object(self.request, 'is_root', new=True):
|
||||||
|
buttons = view.get_xref_buttons(batch)
|
||||||
|
self.assertEqual(len(buttons), 1)
|
184
tests/web/views/test_customers.py
Normal file
184
tests/web/views/test_customers.py
Normal file
|
@ -0,0 +1,184 @@
|
||||||
|
# -*- coding: utf-8; -*-
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from pyramid.httpexceptions import HTTPFound
|
||||||
|
|
||||||
|
from sideshow.testing import WebTestCase
|
||||||
|
from sideshow.web.views import customers as mod
|
||||||
|
from sideshow.batch.neworder import NewOrderBatchHandler
|
||||||
|
|
||||||
|
|
||||||
|
class TestIncludeme(WebTestCase):
|
||||||
|
|
||||||
|
def test_coverage(self):
|
||||||
|
mod.includeme(self.pyramid_config)
|
||||||
|
|
||||||
|
|
||||||
|
class TestPendingCustomerView(WebTestCase):
|
||||||
|
|
||||||
|
def make_view(self):
|
||||||
|
return mod.PendingCustomerView(self.request)
|
||||||
|
|
||||||
|
def test_configure_grid(self):
|
||||||
|
model = self.app.model
|
||||||
|
view = self.make_view()
|
||||||
|
# nb. mostly just getting coverage here
|
||||||
|
grid = view.make_grid(model_class=model.PendingCustomer)
|
||||||
|
view.configure_grid(grid)
|
||||||
|
self.assertIn('full_name', grid.linked_columns)
|
||||||
|
|
||||||
|
def test_configure_form(self):
|
||||||
|
model = self.app.model
|
||||||
|
enum = self.app.enum
|
||||||
|
view = self.make_view()
|
||||||
|
|
||||||
|
# creating
|
||||||
|
with patch.object(view, 'creating', new=True):
|
||||||
|
form = view.make_form(model_class=model.PendingCustomer)
|
||||||
|
view.configure_form(form)
|
||||||
|
self.assertNotIn('status', form)
|
||||||
|
self.assertNotIn('created', form)
|
||||||
|
self.assertNotIn('created_by', form)
|
||||||
|
self.assertNotIn('orders', form)
|
||||||
|
self.assertNotIn('new_order_batches', form)
|
||||||
|
|
||||||
|
user = model.User(username='barney')
|
||||||
|
self.session.add(user)
|
||||||
|
customer = model.PendingCustomer(status=enum.PendingCustomerStatus.PENDING,
|
||||||
|
created_by=user)
|
||||||
|
self.session.add(customer)
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
# viewing
|
||||||
|
with patch.object(view, 'viewing', new=True):
|
||||||
|
form = view.make_form(model_instance=customer)
|
||||||
|
view.configure_form(form)
|
||||||
|
self.assertIn('status', form)
|
||||||
|
self.assertIn('created', form)
|
||||||
|
self.assertIn('created_by', form)
|
||||||
|
self.assertIn('orders', form)
|
||||||
|
self.assertIn('new_order_batches', form)
|
||||||
|
|
||||||
|
def test_make_orders_grid(self):
|
||||||
|
model = self.app.model
|
||||||
|
enum = self.app.enum
|
||||||
|
view = self.make_view()
|
||||||
|
|
||||||
|
user = model.User(username='barney')
|
||||||
|
self.session.add(user)
|
||||||
|
customer = model.PendingCustomer(status=enum.PendingCustomerStatus.PENDING,
|
||||||
|
created_by=user)
|
||||||
|
self.session.add(customer)
|
||||||
|
order = model.Order(order_id=42, pending_customer=customer, created_by=user)
|
||||||
|
self.session.add(order)
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
# no view perm
|
||||||
|
grid = view.make_orders_grid(customer)
|
||||||
|
self.assertEqual(len(grid.actions), 0)
|
||||||
|
|
||||||
|
# with view perm
|
||||||
|
with patch.object(self.request, 'is_root', new=True):
|
||||||
|
grid = view.make_orders_grid(customer)
|
||||||
|
self.assertEqual(len(grid.actions), 1)
|
||||||
|
self.assertEqual(grid.actions[0].key, 'view')
|
||||||
|
|
||||||
|
def test_make_new_order_batches_grid(self):
|
||||||
|
model = self.app.model
|
||||||
|
enum = self.app.enum
|
||||||
|
handler = NewOrderBatchHandler(self.config)
|
||||||
|
view = self.make_view()
|
||||||
|
|
||||||
|
user = model.User(username='barney')
|
||||||
|
self.session.add(user)
|
||||||
|
customer = model.PendingCustomer(status=enum.PendingCustomerStatus.PENDING,
|
||||||
|
created_by=user)
|
||||||
|
self.session.add(customer)
|
||||||
|
batch = handler.make_batch(self.session, pending_customer=customer, created_by=user)
|
||||||
|
self.session.add(batch)
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
# no view perm
|
||||||
|
grid = view.make_new_order_batches_grid(customer)
|
||||||
|
self.assertEqual(len(grid.actions), 0)
|
||||||
|
|
||||||
|
# with view perm
|
||||||
|
with patch.object(self.request, 'is_root', new=True):
|
||||||
|
grid = view.make_new_order_batches_grid(customer)
|
||||||
|
self.assertEqual(len(grid.actions), 1)
|
||||||
|
self.assertEqual(grid.actions[0].key, 'view')
|
||||||
|
|
||||||
|
def test_objectify(self):
|
||||||
|
model = self.app.model
|
||||||
|
enum = self.app.enum
|
||||||
|
view = self.make_view()
|
||||||
|
|
||||||
|
user = model.User(username='barney')
|
||||||
|
self.session.add(user)
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
with patch.object(view, 'creating', new=True):
|
||||||
|
with patch.object(self.request, 'user', new=user):
|
||||||
|
form = view.make_model_form()
|
||||||
|
with patch.object(form, 'validated', create=True, new={
|
||||||
|
'full_name': "Fred Flinstone",
|
||||||
|
}):
|
||||||
|
customer = view.objectify(form)
|
||||||
|
self.assertIsInstance(customer, model.PendingCustomer)
|
||||||
|
self.assertIs(customer.created_by, user)
|
||||||
|
self.assertEqual(customer.status, enum.PendingCustomerStatus.PENDING)
|
||||||
|
|
||||||
|
def test_delete_instance(self):
|
||||||
|
self.pyramid_config.add_route('pending_customers.view', '/pending/customers/{uuid}')
|
||||||
|
model = self.app.model
|
||||||
|
enum = self.app.enum
|
||||||
|
handler = NewOrderBatchHandler(self.config)
|
||||||
|
view = self.make_view()
|
||||||
|
|
||||||
|
user = model.User(username='barney')
|
||||||
|
self.session.add(user)
|
||||||
|
|
||||||
|
# 1st customer is standalone, will be deleted
|
||||||
|
customer = model.PendingCustomer(status=enum.PendingCustomerStatus.PENDING,
|
||||||
|
created_by=user)
|
||||||
|
self.session.add(customer)
|
||||||
|
self.session.flush()
|
||||||
|
self.assertEqual(self.session.query(model.PendingCustomer).count(), 1)
|
||||||
|
view.delete_instance(customer)
|
||||||
|
self.session.flush()
|
||||||
|
self.assertEqual(self.session.query(model.PendingCustomer).count(), 0)
|
||||||
|
|
||||||
|
# 2nd customer is attached to new order batch, will not be deleted
|
||||||
|
customer = model.PendingCustomer(status=enum.PendingCustomerStatus.PENDING,
|
||||||
|
created_by=user)
|
||||||
|
self.session.add(customer)
|
||||||
|
batch = handler.make_batch(self.session, created_by=user, pending_customer=customer)
|
||||||
|
self.session.add(batch)
|
||||||
|
self.session.flush()
|
||||||
|
self.assertEqual(self.session.query(model.PendingCustomer).count(), 1)
|
||||||
|
self.assertRaises(HTTPFound, view.delete_instance, customer)
|
||||||
|
self.session.flush()
|
||||||
|
self.assertEqual(self.session.query(model.PendingCustomer).count(), 1)
|
||||||
|
|
||||||
|
# but after batch is executed, 2nd customer can be deleted
|
||||||
|
batch.executed = datetime.datetime.now()
|
||||||
|
batch.executed_by = user
|
||||||
|
self.session.flush()
|
||||||
|
self.assertEqual(self.session.query(model.PendingCustomer).count(), 1)
|
||||||
|
view.delete_instance(customer)
|
||||||
|
self.session.flush()
|
||||||
|
self.assertEqual(self.session.query(model.PendingCustomer).count(), 0)
|
||||||
|
|
||||||
|
# 3rd customer is attached to order, will not be deleted
|
||||||
|
customer = model.PendingCustomer(status=enum.PendingCustomerStatus.PENDING,
|
||||||
|
created_by=user)
|
||||||
|
self.session.add(customer)
|
||||||
|
order = model.Order(order_id=42, created_by=user, pending_customer=customer)
|
||||||
|
self.session.add(order)
|
||||||
|
self.session.flush()
|
||||||
|
self.assertEqual(self.session.query(model.PendingCustomer).count(), 1)
|
||||||
|
self.assertRaises(HTTPFound, view.delete_instance, customer)
|
||||||
|
self.session.flush()
|
||||||
|
self.assertEqual(self.session.query(model.PendingCustomer).count(), 1)
|
902
tests/web/views/test_orders.py
Normal file
902
tests/web/views/test_orders.py
Normal file
|
@ -0,0 +1,902 @@
|
||||||
|
# -*- coding: utf-8; -*-
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
import decimal
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from sqlalchemy import orm
|
||||||
|
from pyramid.httpexceptions import HTTPForbidden, HTTPFound
|
||||||
|
from pyramid.response import Response
|
||||||
|
|
||||||
|
from wuttaweb.forms.schema import WuttaMoney
|
||||||
|
|
||||||
|
from sideshow.batch.neworder import NewOrderBatchHandler
|
||||||
|
from sideshow.testing import WebTestCase
|
||||||
|
from sideshow.web.views import orders as mod
|
||||||
|
from sideshow.web.forms.schema import OrderRef
|
||||||
|
|
||||||
|
|
||||||
|
class TestIncludeme(WebTestCase):
|
||||||
|
|
||||||
|
def test_coverage(self):
|
||||||
|
mod.includeme(self.pyramid_config)
|
||||||
|
|
||||||
|
|
||||||
|
class TestOrderView(WebTestCase):
|
||||||
|
|
||||||
|
def make_view(self):
|
||||||
|
return mod.OrderView(self.request)
|
||||||
|
|
||||||
|
def test_configure_grid(self):
|
||||||
|
model = self.app.model
|
||||||
|
view = self.make_view()
|
||||||
|
grid = view.make_grid(model_class=model.PendingProduct)
|
||||||
|
self.assertNotIn('order_id', grid.linked_columns)
|
||||||
|
self.assertNotIn('total_price', grid.renderers)
|
||||||
|
view.configure_grid(grid)
|
||||||
|
self.assertIn('order_id', grid.linked_columns)
|
||||||
|
self.assertIn('total_price', grid.renderers)
|
||||||
|
|
||||||
|
def test_create(self):
|
||||||
|
self.pyramid_config.include('sideshow.web.views')
|
||||||
|
model = self.app.model
|
||||||
|
view = self.make_view()
|
||||||
|
|
||||||
|
user = model.User(username='barney')
|
||||||
|
self.session.add(user)
|
||||||
|
self.session.flush()
|
||||||
|
|
||||||
|
with patch.object(view, 'Session', return_value=self.session):
|
||||||
|
with patch.object(self.request, 'current_route_url', return_value='/orders/new'):
|
||||||
|
|
||||||
|
# this will require some perms
|
||||||
|
with patch.multiple(self.request, create=True,
|
||||||
|
user=user, is_root=True):
|
||||||
|
|
||||||
|
# fetch page to start things off
|
||||||
|
self.assertEqual(self.session.query(model.NewOrderBatch).count(), 0)
|
||||||
|
response = view.create()
|
||||||
|
self.assertEqual(self.session.query(model.NewOrderBatch).count(), 1)
|
||||||
|
batch1 = self.session.query(model.NewOrderBatch).one()
|
||||||
|
|
||||||
|
# start over; deletes current batch
|
||||||
|
with patch.multiple(self.request, create=True,
|
||||||
|
method='POST',
|
||||||
|
POST={'action': 'start_over'}):
|
||||||
|
response = view.create()
|
||||||
|
self.assertIsInstance(response, HTTPFound)
|
||||||
|
self.assertIn('/orders/new', response.location)
|
||||||
|
self.assertEqual(self.session.query(model.NewOrderBatch).count(), 0)
|
||||||
|
|
||||||
|
# fetch again to get new batch
|
||||||
|
response = view.create()
|
||||||
|
self.assertEqual(self.session.query(model.NewOrderBatch).count(), 1)
|
||||||
|
batch2 = self.session.query(model.NewOrderBatch).one()
|
||||||
|
self.assertIsNot(batch2, batch1)
|
||||||
|
|
||||||
|
# set pending customer
|
||||||
|
with patch.multiple(self.request, create=True,
|
||||||
|
method='POST',
|
||||||
|
json_body={'action': 'set_pending_customer',
|
||||||
|
'first_name': 'Fred',
|
||||||
|
'last_name': 'Flintstone',
|
||||||
|
'phone_number': '555-1234',
|
||||||
|
'email_address': 'fred@mailinator.com'}):
|
||||||
|
response = view.create()
|
||||||
|
self.assertIsInstance(response, Response)
|
||||||
|
self.assertEqual(response.content_type, 'application/json')
|
||||||
|
self.assertEqual(response.json_body, {
|
||||||
|
'customer_is_known': False,
|
||||||
|
'customer_id': None,
|
||||||
|
'customer_name': 'Fred Flintstone',
|
||||||
|
'phone_number': '555-1234',
|
||||||
|
'email_address': 'fred@mailinator.com',
|
||||||
|
'new_customer_name': 'Fred Flintstone',
|
||||||
|
'new_customer_first_name': 'Fred',
|
||||||
|
'new_customer_last_name': 'Flintstone',
|
||||||
|
'new_customer_phone': '555-1234',
|
||||||
|
'new_customer_email': 'fred@mailinator.com',
|
||||||
|
})
|
||||||
|
|
||||||
|
# invalid action
|
||||||
|
with patch.multiple(self.request, create=True,
|
||||||
|
method='POST',
|
||||||
|
POST={'action': 'bogus'},
|
||||||
|
json_body={'action': 'bogus'}):
|
||||||
|
response = view.create()
|
||||||
|
self.assertIsInstance(response, Response)
|
||||||
|
self.assertEqual(response.content_type, 'application/json')
|
||||||
|
self.assertEqual(response.json_body, {'error': 'unknown form action'})
|
||||||
|
|
||||||
|
def test_get_current_batch(self):
|
||||||
|
model = self.app.model
|
||||||
|
handler = NewOrderBatchHandler(self.config)
|
||||||
|
view = self.make_view()
|
||||||
|
|
||||||
|
# user is required
|
||||||
|
self.assertRaises(HTTPForbidden, view.get_current_batch)
|
||||||
|
|
||||||
|
user = model.User(username='barney')
|
||||||
|
self.session.add(user)
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
with patch.object(view, 'batch_handler', create=True, new=handler):
|
||||||
|
with patch.object(view, 'Session', return_value=self.session):
|
||||||
|
with patch.object(self.request, 'user', new=user):
|
||||||
|
|
||||||
|
# batch is auto-created
|
||||||
|
self.assertEqual(self.session.query(model.NewOrderBatch).count(), 0)
|
||||||
|
batch = view.get_current_batch()
|
||||||
|
self.session.flush()
|
||||||
|
self.assertEqual(self.session.query(model.NewOrderBatch).count(), 1)
|
||||||
|
self.assertIs(batch.created_by, user)
|
||||||
|
|
||||||
|
# same batch is returned subsequently
|
||||||
|
batch2 = view.get_current_batch()
|
||||||
|
self.session.flush()
|
||||||
|
self.assertEqual(self.session.query(model.NewOrderBatch).count(), 1)
|
||||||
|
self.assertIs(batch2, batch)
|
||||||
|
|
||||||
|
def test_get_pending_product_required_fields(self):
|
||||||
|
model = self.app.model
|
||||||
|
view = self.make_view()
|
||||||
|
|
||||||
|
# only description is required by default
|
||||||
|
fields = view.get_pending_product_required_fields()
|
||||||
|
self.assertEqual(fields, ['description'])
|
||||||
|
|
||||||
|
# but config can specify otherwise
|
||||||
|
self.config.setdefault('sideshow.orders.unknown_product.fields.brand_name.required', 'true')
|
||||||
|
self.config.setdefault('sideshow.orders.unknown_product.fields.description.required', 'false')
|
||||||
|
self.config.setdefault('sideshow.orders.unknown_product.fields.size.required', 'true')
|
||||||
|
self.config.setdefault('sideshow.orders.unknown_product.fields.unit_price_reg.required', 'true')
|
||||||
|
fields = view.get_pending_product_required_fields()
|
||||||
|
self.assertEqual(fields, ['brand_name', 'size', 'unit_price_reg'])
|
||||||
|
|
||||||
|
def test_get_context_customer(self):
|
||||||
|
self.pyramid_config.add_route('orders', '/orders/')
|
||||||
|
model = self.app.model
|
||||||
|
handler = NewOrderBatchHandler(self.config)
|
||||||
|
view = self.make_view()
|
||||||
|
|
||||||
|
user = model.User(username='barney')
|
||||||
|
self.session.add(user)
|
||||||
|
|
||||||
|
# with true customer
|
||||||
|
batch = handler.make_batch(self.session, created_by=user,
|
||||||
|
customer_id=42, customer_name='Fred Flintstone',
|
||||||
|
phone_number='555-1234', email_address='fred@mailinator.com')
|
||||||
|
self.session.add(batch)
|
||||||
|
self.session.flush()
|
||||||
|
context = view.get_context_customer(batch)
|
||||||
|
self.assertEqual(context, {
|
||||||
|
'customer_is_known': True,
|
||||||
|
'customer_id': 42,
|
||||||
|
'customer_name': 'Fred Flintstone',
|
||||||
|
'phone_number': '555-1234',
|
||||||
|
'email_address': 'fred@mailinator.com',
|
||||||
|
'new_customer_name': None,
|
||||||
|
'new_customer_first_name': None,
|
||||||
|
'new_customer_last_name': None,
|
||||||
|
'new_customer_phone': None,
|
||||||
|
'new_customer_email': None,
|
||||||
|
})
|
||||||
|
|
||||||
|
# with pending customer
|
||||||
|
batch = handler.make_batch(self.session, created_by=user)
|
||||||
|
self.session.add(batch)
|
||||||
|
handler.set_pending_customer(batch, dict(
|
||||||
|
full_name="Fred Flintstone",
|
||||||
|
first_name="Fred", last_name="Flintstone",
|
||||||
|
phone_number='555-1234', email_address='fred@mailinator.com',
|
||||||
|
created_by=user,
|
||||||
|
))
|
||||||
|
self.session.flush()
|
||||||
|
context = view.get_context_customer(batch)
|
||||||
|
self.assertEqual(context, {
|
||||||
|
'customer_is_known': False,
|
||||||
|
'customer_id': None,
|
||||||
|
'customer_name': 'Fred Flintstone',
|
||||||
|
'phone_number': '555-1234',
|
||||||
|
'email_address': 'fred@mailinator.com',
|
||||||
|
'new_customer_name': 'Fred Flintstone',
|
||||||
|
'new_customer_first_name': 'Fred',
|
||||||
|
'new_customer_last_name': 'Flintstone',
|
||||||
|
'new_customer_phone': '555-1234',
|
||||||
|
'new_customer_email': 'fred@mailinator.com',
|
||||||
|
})
|
||||||
|
|
||||||
|
# with no customer
|
||||||
|
batch = handler.make_batch(self.session, created_by=user)
|
||||||
|
self.session.add(batch)
|
||||||
|
self.session.flush()
|
||||||
|
context = view.get_context_customer(batch)
|
||||||
|
self.assertEqual(context, {
|
||||||
|
'customer_is_known': True, # nb. this is for UI default
|
||||||
|
'customer_id': None,
|
||||||
|
'customer_name': None,
|
||||||
|
'phone_number': None,
|
||||||
|
'email_address': None,
|
||||||
|
'new_customer_name': None,
|
||||||
|
'new_customer_first_name': None,
|
||||||
|
'new_customer_last_name': None,
|
||||||
|
'new_customer_phone': None,
|
||||||
|
'new_customer_email': None,
|
||||||
|
})
|
||||||
|
|
||||||
|
def test_start_over(self):
|
||||||
|
self.pyramid_config.add_route('orders.create', '/orders/new')
|
||||||
|
model = self.app.model
|
||||||
|
handler = NewOrderBatchHandler(self.config)
|
||||||
|
view = self.make_view()
|
||||||
|
|
||||||
|
user = model.User(username='barney')
|
||||||
|
self.session.add(user)
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
with patch.object(view, 'batch_handler', create=True, new=handler):
|
||||||
|
with patch.object(view, 'Session', return_value=self.session):
|
||||||
|
with patch.object(self.request, 'user', new=user):
|
||||||
|
|
||||||
|
batch = view.get_current_batch()
|
||||||
|
self.session.flush()
|
||||||
|
self.assertEqual(self.session.query(model.NewOrderBatch).count(), 1)
|
||||||
|
result = view.start_over(batch)
|
||||||
|
self.assertIsInstance(result, HTTPFound)
|
||||||
|
self.session.flush()
|
||||||
|
self.assertEqual(self.session.query(model.NewOrderBatch).count(), 0)
|
||||||
|
|
||||||
|
def test_cancel_order(self):
|
||||||
|
self.pyramid_config.add_route('orders', '/orders/')
|
||||||
|
model = self.app.model
|
||||||
|
handler = NewOrderBatchHandler(self.config)
|
||||||
|
view = self.make_view()
|
||||||
|
|
||||||
|
user = model.User(username='barney')
|
||||||
|
self.session.add(user)
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
with patch.object(view, 'batch_handler', create=True, new=handler):
|
||||||
|
with patch.object(view, 'Session', return_value=self.session):
|
||||||
|
with patch.object(self.request, 'user', new=user):
|
||||||
|
|
||||||
|
batch = view.get_current_batch()
|
||||||
|
self.session.flush()
|
||||||
|
self.assertEqual(self.session.query(model.NewOrderBatch).count(), 1)
|
||||||
|
result = view.cancel_order(batch)
|
||||||
|
self.assertIsInstance(result, HTTPFound)
|
||||||
|
self.session.flush()
|
||||||
|
self.assertEqual(self.session.query(model.NewOrderBatch).count(), 0)
|
||||||
|
|
||||||
|
def test_set_pending_customer(self):
|
||||||
|
self.pyramid_config.add_route('orders.create', '/orders/new')
|
||||||
|
model = self.app.model
|
||||||
|
handler = NewOrderBatchHandler(self.config)
|
||||||
|
view = self.make_view()
|
||||||
|
|
||||||
|
user = model.User(username='barney')
|
||||||
|
self.session.add(user)
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
data = {
|
||||||
|
'first_name': 'Fred',
|
||||||
|
'last_name': 'Flintstone',
|
||||||
|
'phone_number': '555-1234',
|
||||||
|
'email_address': 'fred@mailinator.com',
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch.object(view, 'batch_handler', create=True, new=handler):
|
||||||
|
with patch.object(view, 'Session', return_value=self.session):
|
||||||
|
with patch.object(self.request, 'user', new=user):
|
||||||
|
batch = view.get_current_batch()
|
||||||
|
self.session.flush()
|
||||||
|
|
||||||
|
# normal
|
||||||
|
self.assertIsNone(batch.pending_customer)
|
||||||
|
context = view.set_pending_customer(batch, data)
|
||||||
|
self.assertIsInstance(batch.pending_customer, model.PendingCustomer)
|
||||||
|
self.assertEqual(context, {
|
||||||
|
'customer_is_known': False,
|
||||||
|
'customer_id': None,
|
||||||
|
'customer_name': 'Fred Flintstone',
|
||||||
|
'phone_number': '555-1234',
|
||||||
|
'email_address': 'fred@mailinator.com',
|
||||||
|
'new_customer_name': 'Fred Flintstone',
|
||||||
|
'new_customer_first_name': 'Fred',
|
||||||
|
'new_customer_last_name': 'Flintstone',
|
||||||
|
'new_customer_phone': '555-1234',
|
||||||
|
'new_customer_email': 'fred@mailinator.com',
|
||||||
|
})
|
||||||
|
|
||||||
|
# error
|
||||||
|
with patch.object(handler, 'set_pending_customer', side_effect=RuntimeError):
|
||||||
|
context = view.set_pending_customer(batch, data)
|
||||||
|
self.assertEqual(context, {
|
||||||
|
'error': 'RuntimeError',
|
||||||
|
})
|
||||||
|
|
||||||
|
def test_add_item(self):
|
||||||
|
model = self.app.model
|
||||||
|
enum = self.app.enum
|
||||||
|
handler = NewOrderBatchHandler(self.config)
|
||||||
|
view = self.make_view()
|
||||||
|
|
||||||
|
user = model.User(username='barney')
|
||||||
|
self.session.add(user)
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
data = {
|
||||||
|
'pending_product': {
|
||||||
|
'scancode': '07430500132',
|
||||||
|
'brand_name': 'Bragg',
|
||||||
|
'description': 'Vinegar',
|
||||||
|
'size': '32oz',
|
||||||
|
'unit_price_reg': 5.99,
|
||||||
|
},
|
||||||
|
'order_qty': 1,
|
||||||
|
'order_uom': enum.ORDER_UOM_UNIT,
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch.object(view, 'batch_handler', create=True, new=handler):
|
||||||
|
with patch.object(view, 'Session', return_value=self.session):
|
||||||
|
with patch.object(self.request, 'user', new=user):
|
||||||
|
batch = view.get_current_batch()
|
||||||
|
self.session.flush()
|
||||||
|
self.assertEqual(len(batch.rows), 0)
|
||||||
|
|
||||||
|
# normal pending product
|
||||||
|
result = view.add_item(batch, data)
|
||||||
|
self.assertIn('batch', result)
|
||||||
|
self.assertIn('row', result)
|
||||||
|
self.session.flush()
|
||||||
|
self.assertEqual(len(batch.rows), 1)
|
||||||
|
row = batch.rows[0]
|
||||||
|
self.assertIsInstance(row.pending_product, model.PendingProduct)
|
||||||
|
|
||||||
|
# pending w/ invalid price
|
||||||
|
with patch.dict(data['pending_product'], unit_price_reg='invalid'):
|
||||||
|
result = view.add_item(batch, data)
|
||||||
|
self.assertEqual(result, {'error': "Invalid entry for field: unit_price_reg"})
|
||||||
|
self.session.flush()
|
||||||
|
self.assertEqual(len(batch.rows), 1) # still just the 1st row
|
||||||
|
|
||||||
|
# true product not yet supported
|
||||||
|
with patch.dict(data, product_is_known=True):
|
||||||
|
self.assertRaises(NotImplementedError, view.add_item, batch, data)
|
||||||
|
|
||||||
|
def test_update_item(self):
|
||||||
|
model = self.app.model
|
||||||
|
enum = self.app.enum
|
||||||
|
handler = NewOrderBatchHandler(self.config)
|
||||||
|
view = self.make_view()
|
||||||
|
|
||||||
|
user = model.User(username='barney')
|
||||||
|
self.session.add(user)
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
data = {
|
||||||
|
'pending_product': {
|
||||||
|
'scancode': '07430500132',
|
||||||
|
'brand_name': 'Bragg',
|
||||||
|
'description': 'Vinegar',
|
||||||
|
'size': '32oz',
|
||||||
|
'unit_price_reg': 5.99,
|
||||||
|
'case_size': 12,
|
||||||
|
},
|
||||||
|
'order_qty': 1,
|
||||||
|
'order_uom': enum.ORDER_UOM_CASE,
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch.object(view, 'batch_handler', create=True, new=handler):
|
||||||
|
with patch.object(view, 'Session', return_value=self.session):
|
||||||
|
with patch.object(self.request, 'user', new=user):
|
||||||
|
batch = view.get_current_batch()
|
||||||
|
self.session.flush()
|
||||||
|
self.assertEqual(len(batch.rows), 0)
|
||||||
|
|
||||||
|
# add row w/ pending product
|
||||||
|
view.add_item(batch, data)
|
||||||
|
self.session.flush()
|
||||||
|
row = batch.rows[0]
|
||||||
|
self.assertIsInstance(row.pending_product, model.PendingProduct)
|
||||||
|
self.assertEqual(row.unit_price_quoted, decimal.Decimal('5.99'))
|
||||||
|
|
||||||
|
# missing row uuid
|
||||||
|
result = view.update_item(batch, data)
|
||||||
|
self.assertEqual(result, {'error': "Must specify a row UUID"})
|
||||||
|
|
||||||
|
# row not found
|
||||||
|
with patch.dict(data, uuid=self.app.make_true_uuid()):
|
||||||
|
result = view.update_item(batch, data)
|
||||||
|
self.assertEqual(result, {'error': "Row not found"})
|
||||||
|
|
||||||
|
# row for wrong batch
|
||||||
|
batch2 = handler.make_batch(self.session, created_by=user)
|
||||||
|
self.session.add(batch2)
|
||||||
|
row2 = handler.make_row(order_qty=1, order_uom=enum.ORDER_UOM_UNIT)
|
||||||
|
handler.add_row(batch2, row2)
|
||||||
|
self.session.flush()
|
||||||
|
with patch.dict(data, uuid=row2.uuid):
|
||||||
|
result = view.update_item(batch, data)
|
||||||
|
self.assertEqual(result, {'error': "Row is for wrong batch"})
|
||||||
|
|
||||||
|
# set row for remaining tests
|
||||||
|
data['uuid'] = row.uuid
|
||||||
|
|
||||||
|
# true product not yet supported
|
||||||
|
with patch.dict(data, product_is_known=True):
|
||||||
|
self.assertRaises(NotImplementedError, view.update_item, batch, data)
|
||||||
|
|
||||||
|
# update row, pending product
|
||||||
|
with patch.dict(data, order_qty=2):
|
||||||
|
with patch.dict(data['pending_product'], scancode='07430500116'):
|
||||||
|
self.assertEqual(row.product_scancode, '07430500132')
|
||||||
|
self.assertEqual(row.order_qty, 1)
|
||||||
|
result = view.update_item(batch, data)
|
||||||
|
self.assertEqual(sorted(result), ['batch', 'row'])
|
||||||
|
self.assertEqual(row.product_scancode, '07430500116')
|
||||||
|
self.assertEqual(row.order_qty, 2)
|
||||||
|
self.assertEqual(row.pending_product.scancode, '07430500116')
|
||||||
|
self.assertEqual(result['row']['product_scancode'], '07430500116')
|
||||||
|
self.assertEqual(result['row']['order_qty'], '2')
|
||||||
|
|
||||||
|
def test_delete_item(self):
|
||||||
|
model = self.app.model
|
||||||
|
enum = self.app.enum
|
||||||
|
handler = NewOrderBatchHandler(self.config)
|
||||||
|
view = self.make_view()
|
||||||
|
|
||||||
|
user = model.User(username='barney')
|
||||||
|
self.session.add(user)
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
data = {
|
||||||
|
'pending_product': {
|
||||||
|
'scancode': '07430500132',
|
||||||
|
'brand_name': 'Bragg',
|
||||||
|
'description': 'Vinegar',
|
||||||
|
'size': '32oz',
|
||||||
|
'unit_price_reg': 5.99,
|
||||||
|
'case_size': 12,
|
||||||
|
},
|
||||||
|
'order_qty': 1,
|
||||||
|
'order_uom': enum.ORDER_UOM_CASE,
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch.object(view, 'batch_handler', create=True, new=handler):
|
||||||
|
with patch.object(view, 'Session', return_value=self.session):
|
||||||
|
with patch.object(self.request, 'user', new=user):
|
||||||
|
batch = view.get_current_batch()
|
||||||
|
self.session.flush()
|
||||||
|
self.assertEqual(len(batch.rows), 0)
|
||||||
|
|
||||||
|
# add row w/ pending product
|
||||||
|
view.add_item(batch, data)
|
||||||
|
self.session.flush()
|
||||||
|
row = batch.rows[0]
|
||||||
|
self.assertIsInstance(row.pending_product, model.PendingProduct)
|
||||||
|
self.assertEqual(row.unit_price_quoted, decimal.Decimal('5.99'))
|
||||||
|
|
||||||
|
# missing row uuid
|
||||||
|
result = view.delete_item(batch, data)
|
||||||
|
self.assertEqual(result, {'error': "Must specify a row UUID"})
|
||||||
|
|
||||||
|
# row not found
|
||||||
|
with patch.dict(data, uuid=self.app.make_true_uuid()):
|
||||||
|
result = view.delete_item(batch, data)
|
||||||
|
self.assertEqual(result, {'error': "Row not found"})
|
||||||
|
|
||||||
|
# row for wrong batch
|
||||||
|
batch2 = handler.make_batch(self.session, created_by=user)
|
||||||
|
self.session.add(batch2)
|
||||||
|
row2 = handler.make_row(order_qty=1, order_uom=enum.ORDER_UOM_UNIT)
|
||||||
|
handler.add_row(batch2, row2)
|
||||||
|
self.session.flush()
|
||||||
|
with patch.dict(data, uuid=row2.uuid):
|
||||||
|
result = view.delete_item(batch, data)
|
||||||
|
self.assertEqual(result, {'error': "Row is for wrong batch"})
|
||||||
|
|
||||||
|
# row is deleted
|
||||||
|
data['uuid'] = row.uuid
|
||||||
|
self.assertEqual(len(batch.rows), 1)
|
||||||
|
self.assertEqual(batch.row_count, 1)
|
||||||
|
result = view.delete_item(batch, data)
|
||||||
|
self.assertEqual(sorted(result), ['batch'])
|
||||||
|
self.session.refresh(batch)
|
||||||
|
self.assertEqual(len(batch.rows), 0)
|
||||||
|
self.assertEqual(batch.row_count, 0)
|
||||||
|
|
||||||
|
def test_submit_new_order(self):
|
||||||
|
self.pyramid_config.add_route('orders.view', '/orders/{uuid}')
|
||||||
|
model = self.app.model
|
||||||
|
enum = self.app.enum
|
||||||
|
handler = NewOrderBatchHandler(self.config)
|
||||||
|
view = self.make_view()
|
||||||
|
|
||||||
|
user = model.User(username='barney')
|
||||||
|
self.session.add(user)
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
data = {
|
||||||
|
'pending_product': {
|
||||||
|
'scancode': '07430500132',
|
||||||
|
'brand_name': 'Bragg',
|
||||||
|
'description': 'Vinegar',
|
||||||
|
'size': '32oz',
|
||||||
|
'unit_price_reg': 5.99,
|
||||||
|
'case_size': 12,
|
||||||
|
},
|
||||||
|
'order_qty': 1,
|
||||||
|
'order_uom': enum.ORDER_UOM_CASE,
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch.object(view, 'batch_handler', create=True, new=handler):
|
||||||
|
with patch.object(view, 'Session', return_value=self.session):
|
||||||
|
with patch.object(self.request, 'user', new=user):
|
||||||
|
batch = view.get_current_batch()
|
||||||
|
self.session.flush()
|
||||||
|
self.assertEqual(len(batch.rows), 0)
|
||||||
|
|
||||||
|
# add row w/ pending product
|
||||||
|
view.add_item(batch, data)
|
||||||
|
self.session.flush()
|
||||||
|
row = batch.rows[0]
|
||||||
|
self.assertIsInstance(row.pending_product, model.PendingProduct)
|
||||||
|
self.assertEqual(row.unit_price_quoted, decimal.Decimal('5.99'))
|
||||||
|
|
||||||
|
# execute not allowed yet (no customer)
|
||||||
|
result = view.submit_new_order(batch, {})
|
||||||
|
self.assertEqual(result, {'error': "Must assign the customer"})
|
||||||
|
|
||||||
|
# submit/execute ok
|
||||||
|
batch.customer_id = 42
|
||||||
|
result = view.submit_new_order(batch, {})
|
||||||
|
self.assertEqual(sorted(result), ['next_url'])
|
||||||
|
self.assertIn('/orders/', result['next_url'])
|
||||||
|
|
||||||
|
# error (already executed)
|
||||||
|
result = view.submit_new_order(batch, {})
|
||||||
|
self.assertEqual(result, {
|
||||||
|
'error': f"ValueError: batch has already been executed: {batch}",
|
||||||
|
})
|
||||||
|
|
||||||
|
def test_get_default_uom_choices(self):
|
||||||
|
enum = self.app.enum
|
||||||
|
view = self.make_view()
|
||||||
|
|
||||||
|
uoms = view.get_default_uom_choices()
|
||||||
|
self.assertEqual(uoms, [{'key': key, 'value': val}
|
||||||
|
for key, val in enum.ORDER_UOM.items()])
|
||||||
|
|
||||||
|
def test_normalize_batch(self):
|
||||||
|
model = self.app.model
|
||||||
|
enum = self.app.enum
|
||||||
|
handler = NewOrderBatchHandler(self.config)
|
||||||
|
view = self.make_view()
|
||||||
|
|
||||||
|
user = model.User(username='barney')
|
||||||
|
self.session.add(user)
|
||||||
|
batch = handler.make_batch(self.session, created_by=user)
|
||||||
|
self.session.add(batch)
|
||||||
|
pending = {
|
||||||
|
'scancode': '07430500132',
|
||||||
|
'brand_name': 'Bragg',
|
||||||
|
'description': 'Vinegar',
|
||||||
|
'size': '32oz',
|
||||||
|
'unit_price_reg': 5.99,
|
||||||
|
'case_size': 12,
|
||||||
|
'created_by': user,
|
||||||
|
}
|
||||||
|
row = handler.add_pending_product(batch, pending, 1, enum.ORDER_UOM_CASE)
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
data = view.normalize_batch(batch)
|
||||||
|
self.assertEqual(data, {
|
||||||
|
'uuid': batch.uuid.hex,
|
||||||
|
'total_price': '71.880',
|
||||||
|
'total_price_display': '$71.88',
|
||||||
|
'status_code': None,
|
||||||
|
'status_text': None,
|
||||||
|
})
|
||||||
|
|
||||||
|
def test_normalize_row(self):
|
||||||
|
model = self.app.model
|
||||||
|
enum = self.app.enum
|
||||||
|
handler = NewOrderBatchHandler(self.config)
|
||||||
|
view = self.make_view()
|
||||||
|
|
||||||
|
user = model.User(username='barney')
|
||||||
|
self.session.add(user)
|
||||||
|
batch = handler.make_batch(self.session, created_by=user)
|
||||||
|
self.session.add(batch)
|
||||||
|
pending = {
|
||||||
|
'scancode': '07430500132',
|
||||||
|
'brand_name': 'Bragg',
|
||||||
|
'description': 'Vinegar',
|
||||||
|
'size': '32oz',
|
||||||
|
'unit_price_reg': 5.99,
|
||||||
|
'case_size': 12,
|
||||||
|
'created_by': user,
|
||||||
|
}
|
||||||
|
row = handler.add_pending_product(batch, pending, 2, enum.ORDER_UOM_CASE)
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
# normal
|
||||||
|
data = view.normalize_row(row)
|
||||||
|
self.assertIsInstance(data, dict)
|
||||||
|
self.assertEqual(data['uuid'], row.uuid.hex)
|
||||||
|
self.assertEqual(data['sequence'], 1)
|
||||||
|
self.assertEqual(data['product_scancode'], '07430500132')
|
||||||
|
self.assertEqual(data['case_size'], '12')
|
||||||
|
self.assertEqual(data['order_qty'], '2')
|
||||||
|
self.assertEqual(data['order_uom'], 'CS')
|
||||||
|
self.assertEqual(data['order_qty_display'], '2 Cases (× 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])
|
163
tests/web/views/test_products.py
Normal file
163
tests/web/views/test_products.py
Normal file
|
@ -0,0 +1,163 @@
|
||||||
|
# -*- coding: utf-8; -*-
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from pyramid.httpexceptions import HTTPFound
|
||||||
|
|
||||||
|
from sideshow.testing import WebTestCase
|
||||||
|
from sideshow.web.views import products as mod
|
||||||
|
from sideshow.batch.neworder import NewOrderBatchHandler
|
||||||
|
|
||||||
|
|
||||||
|
class TestIncludeme(WebTestCase):
|
||||||
|
|
||||||
|
def test_coverage(self):
|
||||||
|
mod.includeme(self.pyramid_config)
|
||||||
|
|
||||||
|
|
||||||
|
class TestPendingProductView(WebTestCase):
|
||||||
|
|
||||||
|
def make_view(self):
|
||||||
|
return mod.PendingProductView(self.request)
|
||||||
|
|
||||||
|
def test_configure_grid(self):
|
||||||
|
model = self.app.model
|
||||||
|
view = self.make_view()
|
||||||
|
# nb. mostly just getting coverage here
|
||||||
|
grid = view.make_grid(model_class=model.PendingProduct)
|
||||||
|
self.assertNotIn('scancode', grid.linked_columns)
|
||||||
|
self.assertNotIn('brand_name', grid.linked_columns)
|
||||||
|
self.assertNotIn('description', grid.linked_columns)
|
||||||
|
view.configure_grid(grid)
|
||||||
|
self.assertIn('scancode', grid.linked_columns)
|
||||||
|
self.assertIn('brand_name', grid.linked_columns)
|
||||||
|
self.assertIn('description', grid.linked_columns)
|
||||||
|
|
||||||
|
def test_configure_form(self):
|
||||||
|
model = self.app.model
|
||||||
|
enum = self.app.enum
|
||||||
|
view = self.make_view()
|
||||||
|
|
||||||
|
# creating
|
||||||
|
with patch.object(view, 'creating', new=True):
|
||||||
|
form = view.make_form(model_class=model.PendingProduct)
|
||||||
|
view.configure_form(form)
|
||||||
|
self.assertNotIn('status', form)
|
||||||
|
self.assertNotIn('created', form)
|
||||||
|
self.assertNotIn('created_by', form)
|
||||||
|
|
||||||
|
user = model.User(username='barney')
|
||||||
|
self.session.add(user)
|
||||||
|
product = model.PendingProduct(status=enum.PendingProductStatus.PENDING,
|
||||||
|
created_by=user)
|
||||||
|
self.session.add(product)
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
# viewing
|
||||||
|
with patch.object(view, 'viewing', new=True):
|
||||||
|
form = view.make_form(model_instance=product)
|
||||||
|
view.configure_form(form)
|
||||||
|
self.assertIn('status', form)
|
||||||
|
self.assertIn('created', form)
|
||||||
|
self.assertIn('created_by', form)
|
||||||
|
|
||||||
|
def test_make_orders_grid(self):
|
||||||
|
model = self.app.model
|
||||||
|
enum = self.app.enum
|
||||||
|
view = self.make_view()
|
||||||
|
|
||||||
|
user = model.User(username='barney')
|
||||||
|
self.session.add(user)
|
||||||
|
order = model.Order(order_id=42, customer_id=42, created_by=user)
|
||||||
|
product = model.PendingProduct(status=enum.PendingProductStatus.PENDING,
|
||||||
|
created_by=user)
|
||||||
|
self.session.add(product)
|
||||||
|
item = model.OrderItem(pending_product=product,
|
||||||
|
order_qty=1, order_uom=enum.ORDER_UOM_UNIT,
|
||||||
|
status_code=enum.ORDER_ITEM_STATUS_INITIATED)
|
||||||
|
order.items.append(item)
|
||||||
|
self.session.add(order)
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
# no view perm
|
||||||
|
grid = view.make_orders_grid(product)
|
||||||
|
self.assertEqual(len(grid.actions), 0)
|
||||||
|
|
||||||
|
# with view perm
|
||||||
|
with patch.object(self.request, 'is_root', new=True):
|
||||||
|
grid = view.make_orders_grid(product)
|
||||||
|
self.assertEqual(len(grid.actions), 1)
|
||||||
|
self.assertEqual(grid.actions[0].key, 'view')
|
||||||
|
|
||||||
|
def test_make_new_order_batches_grid(self):
|
||||||
|
model = self.app.model
|
||||||
|
enum = self.app.enum
|
||||||
|
handler = NewOrderBatchHandler(self.config)
|
||||||
|
view = self.make_view()
|
||||||
|
|
||||||
|
user = model.User(username='barney')
|
||||||
|
self.session.add(user)
|
||||||
|
batch = handler.make_batch(self.session, created_by=user)
|
||||||
|
self.session.add(batch)
|
||||||
|
product = model.PendingProduct(status=enum.PendingProductStatus.PENDING,
|
||||||
|
created_by=user)
|
||||||
|
self.session.add(product)
|
||||||
|
row = handler.make_row(pending_product=product,
|
||||||
|
order_qty=1, order_uom=enum.ORDER_UOM_UNIT)
|
||||||
|
handler.add_row(batch, row)
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
# no view perm
|
||||||
|
grid = view.make_new_order_batches_grid(product)
|
||||||
|
self.assertEqual(len(grid.actions), 0)
|
||||||
|
|
||||||
|
# with view perm
|
||||||
|
with patch.object(self.request, 'is_root', new=True):
|
||||||
|
grid = view.make_new_order_batches_grid(product)
|
||||||
|
self.assertEqual(len(grid.actions), 1)
|
||||||
|
self.assertEqual(grid.actions[0].key, 'view')
|
||||||
|
|
||||||
|
def test_delete_instance(self):
|
||||||
|
self.pyramid_config.add_route('pending_products.view', '/pending/products/{uuid}')
|
||||||
|
model = self.app.model
|
||||||
|
enum = self.app.enum
|
||||||
|
handler = NewOrderBatchHandler(self.config)
|
||||||
|
view = self.make_view()
|
||||||
|
|
||||||
|
user = model.User(username='barney')
|
||||||
|
self.session.add(user)
|
||||||
|
|
||||||
|
# 1st product is standalone, will be deleted
|
||||||
|
product = model.PendingProduct(status=enum.PendingProductStatus.PENDING,
|
||||||
|
created_by=user)
|
||||||
|
self.session.add(product)
|
||||||
|
self.session.flush()
|
||||||
|
self.assertEqual(self.session.query(model.PendingProduct).count(), 1)
|
||||||
|
view.delete_instance(product)
|
||||||
|
self.session.flush()
|
||||||
|
self.assertEqual(self.session.query(model.PendingProduct).count(), 0)
|
||||||
|
|
||||||
|
# 2nd product is attached to new order batch, will not be deleted
|
||||||
|
batch = handler.make_batch(self.session, created_by=user)
|
||||||
|
self.session.add(batch)
|
||||||
|
product = model.PendingProduct(status=enum.PendingProductStatus.PENDING,
|
||||||
|
created_by=user)
|
||||||
|
self.session.add(product)
|
||||||
|
row = handler.make_row(pending_product=product,
|
||||||
|
order_qty=1, order_uom=enum.ORDER_UOM_UNIT)
|
||||||
|
handler.add_row(batch, row)
|
||||||
|
self.session.flush()
|
||||||
|
self.assertEqual(self.session.query(model.PendingProduct).count(), 1)
|
||||||
|
self.assertRaises(HTTPFound, view.delete_instance, product)
|
||||||
|
self.session.flush()
|
||||||
|
self.assertEqual(self.session.query(model.PendingProduct).count(), 1)
|
||||||
|
|
||||||
|
# but after batch is executed, 2nd product can be deleted
|
||||||
|
batch.executed = datetime.datetime.now()
|
||||||
|
batch.executed_by = user
|
||||||
|
self.session.flush()
|
||||||
|
self.assertEqual(self.session.query(model.PendingProduct).count(), 1)
|
||||||
|
view.delete_instance(product)
|
||||||
|
self.session.flush()
|
||||||
|
self.assertEqual(self.session.query(model.PendingProduct).count(), 0)
|
17
tox.ini
Normal file
17
tox.ini
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
|
||||||
|
[tox]
|
||||||
|
envlist = py38, py39, py310, py311
|
||||||
|
|
||||||
|
[testenv]
|
||||||
|
extras = tests
|
||||||
|
commands = pytest {posargs}
|
||||||
|
|
||||||
|
[testenv:coverage]
|
||||||
|
basepython = python3.11
|
||||||
|
commands = pytest --cov=sideshow --cov-report=html --cov-fail-under=100
|
||||||
|
|
||||||
|
[testenv:docs]
|
||||||
|
basepython = python3.11
|
||||||
|
extras = docs
|
||||||
|
changedir = docs
|
||||||
|
commands = sphinx-build -b html -d {envtmpdir}/doctrees -W -T . {envtmpdir}/docs
|
Loading…
Reference in a new issue