From 3ef84ff706df4b76f85b210cb94baa0d88cece55 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 27 Jan 2025 18:15:07 -0600 Subject: [PATCH 1/5] feat: add basic model, views for Stores --- docs/api/sideshow.db.model.stores.rst | 6 + docs/api/sideshow.web.views.stores.rst | 6 + docs/index.rst | 2 + .../versions/a4273360d379_add_stores.py | 39 ++++++ src/sideshow/db/model/__init__.py | 2 + src/sideshow/db/model/stores.py | 54 ++++++++ src/sideshow/web/menus.py | 10 +- src/sideshow/web/views/__init__.py | 1 + src/sideshow/web/views/stores.py | 120 ++++++++++++++++++ tests/db/model/test_stores.py | 15 +++ tests/web/views/test_stores.py | 94 ++++++++++++++ 11 files changed, 348 insertions(+), 1 deletion(-) create mode 100644 docs/api/sideshow.db.model.stores.rst create mode 100644 docs/api/sideshow.web.views.stores.rst create mode 100644 src/sideshow/db/alembic/versions/a4273360d379_add_stores.py create mode 100644 src/sideshow/db/model/stores.py create mode 100644 src/sideshow/web/views/stores.py create mode 100644 tests/db/model/test_stores.py create mode 100644 tests/web/views/test_stores.py diff --git a/docs/api/sideshow.db.model.stores.rst b/docs/api/sideshow.db.model.stores.rst new file mode 100644 index 0000000..b114a9b --- /dev/null +++ b/docs/api/sideshow.db.model.stores.rst @@ -0,0 +1,6 @@ + +``sideshow.db.model.stores`` +============================ + +.. automodule:: sideshow.db.model.stores + :members: diff --git a/docs/api/sideshow.web.views.stores.rst b/docs/api/sideshow.web.views.stores.rst new file mode 100644 index 0000000..896a0d7 --- /dev/null +++ b/docs/api/sideshow.web.views.stores.rst @@ -0,0 +1,6 @@ + +``sideshow.web.views.stores`` +============================= + +.. automodule:: sideshow.web.views.stores + :members: diff --git a/docs/index.rst b/docs/index.rst index 29882dd..643578b 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -43,6 +43,7 @@ For an online demo see https://demo.wuttaproject.org/ api/sideshow.db.model.customers api/sideshow.db.model.orders api/sideshow.db.model.products + api/sideshow.db.model.stores api/sideshow.enum api/sideshow.orders api/sideshow.web @@ -58,3 +59,4 @@ For an online demo see https://demo.wuttaproject.org/ api/sideshow.web.views.customers api/sideshow.web.views.orders api/sideshow.web.views.products + api/sideshow.web.views.stores diff --git a/src/sideshow/db/alembic/versions/a4273360d379_add_stores.py b/src/sideshow/db/alembic/versions/a4273360d379_add_stores.py new file mode 100644 index 0000000..79e6242 --- /dev/null +++ b/src/sideshow/db/alembic/versions/a4273360d379_add_stores.py @@ -0,0 +1,39 @@ +"""add stores + +Revision ID: a4273360d379 +Revises: 7a6df83afbd4 +Create Date: 2025-01-27 17:48:20.638664 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import wuttjamaican.db.util + + +# revision identifiers, used by Alembic. +revision: str = 'a4273360d379' +down_revision: Union[str, None] = '7a6df83afbd4' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + + # sideshow_store + op.create_table('sideshow_store', + sa.Column('uuid', wuttjamaican.db.util.UUID(), nullable=False), + sa.Column('store_id', sa.String(length=10), nullable=False), + sa.Column('name', sa.String(length=100), nullable=False), + sa.Column('archived', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('uuid', name=op.f('pk_sideshow_store')), + sa.UniqueConstraint('store_id', name=op.f('uq_sideshow_store_store_id')), + sa.UniqueConstraint('name', name=op.f('uq_sideshow_store_name')) + ) + + +def downgrade() -> None: + + # sideshow_store + op.drop_table('sideshow_store') diff --git a/src/sideshow/db/model/__init__.py b/src/sideshow/db/model/__init__.py index f53dd27..056ccfc 100644 --- a/src/sideshow/db/model/__init__.py +++ b/src/sideshow/db/model/__init__.py @@ -30,6 +30,7 @@ This namespace exposes everything from Primary :term:`data models `: +* :class:`~sideshow.db.model.stores.Store` * :class:`~sideshow.db.model.orders.Order` * :class:`~sideshow.db.model.orders.OrderItem` * :class:`~sideshow.db.model.orders.OrderItemEvent` @@ -48,6 +49,7 @@ And the :term:`batch` models: from wuttjamaican.db.model import * # sideshow models +from .stores import Store from .customers import LocalCustomer, PendingCustomer from .products import LocalProduct, PendingProduct from .orders import Order, OrderItem, OrderItemEvent diff --git a/src/sideshow/db/model/stores.py b/src/sideshow/db/model/stores.py new file mode 100644 index 0000000..b1956c1 --- /dev/null +++ b/src/sideshow/db/model/stores.py @@ -0,0 +1,54 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Sideshow -- Case/Special Order Tracker +# Copyright © 2024-2025 Lance Edgar +# +# This file is part of Sideshow. +# +# Sideshow is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Sideshow is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Sideshow. If not, see . +# +################################################################################ +""" +Data models for Stores +""" + +import sqlalchemy as sa + +from wuttjamaican.db import model + + +class Store(model.Base): + """ + Represents a physical location for the business. + """ + __tablename__ = 'sideshow_store' + + uuid = model.uuid_column() + + store_id = sa.Column(sa.String(length=10), nullable=False, unique=True, doc=""" + Unique ID for the store. + """) + + name = sa.Column(sa.String(length=100), nullable=False, unique=True, doc=""" + Display name for the store (must be unique!). + """) + + archived = sa.Column(sa.Boolean(), nullable=False, default=False, doc=""" + Indicates the store has been "retired" essentially, and mostly + hidden from view. + """) + + def __str__(self): + return self.name or "" diff --git a/src/sideshow/web/menus.py b/src/sideshow/web/menus.py index 1641c72..9da61c0 100644 --- a/src/sideshow/web/menus.py +++ b/src/sideshow/web/menus.py @@ -162,4 +162,12 @@ class SideshowMenuHandler(base.MenuHandler): def make_admin_menu(self, request, **kwargs): """ """ kwargs['include_people'] = True - return super().make_admin_menu(request, **kwargs) + menu = super().make_admin_menu(request, **kwargs) + + menu['items'].insert(0, { + 'title': "Stores", + 'route': 'stores', + 'perm': 'stores.list', + }) + + return menu diff --git a/src/sideshow/web/views/__init__.py b/src/sideshow/web/views/__init__.py index 13a468c..e5a14ac 100644 --- a/src/sideshow/web/views/__init__.py +++ b/src/sideshow/web/views/__init__.py @@ -35,6 +35,7 @@ def includeme(config): }) # sideshow views + config.include('sideshow.web.views.stores') config.include('sideshow.web.views.customers') config.include('sideshow.web.views.products') config.include('sideshow.web.views.orders') diff --git a/src/sideshow/web/views/stores.py b/src/sideshow/web/views/stores.py new file mode 100644 index 0000000..0eaf41d --- /dev/null +++ b/src/sideshow/web/views/stores.py @@ -0,0 +1,120 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Sideshow -- Case/Special Order Tracker +# Copyright © 2024-2025 Lance Edgar +# +# This file is part of Sideshow. +# +# Sideshow is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Sideshow is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Sideshow. If not, see . +# +################################################################################ +""" +Views for Stores +""" + +from wuttaweb.views import MasterView + +from sideshow.db.model import Store + + +class StoreView(MasterView): + """ + Master view for + :class:`~sideshow.db.model.stores.Store`; route prefix + is ``stores``. + + Notable URLs provided by this class: + + * ``/stores/`` + * ``/stores/new`` + * ``/stores/XXX`` + * ``/stores/XXX/edit`` + * ``/stores/XXX/delete`` + """ + model_class = Store + + labels = { + 'store_id': "Store ID", + } + + filter_defaults = { + 'archived': {'active': True, 'verb': 'is_false'}, + } + + sort_defaults = 'store_id' + + def configure_grid(self, g): + """ """ + super().configure_grid(g) + + # links + g.set_link('store_id') + g.set_link('name') + + def grid_row_class(self, store, data, i): + """ """ + if store.archived: + return 'has-background-warning' + + def configure_form(self, f): + """ """ + super().configure_form(f) + + # store_id + f.set_validator('store_id', self.unique_store_id) + + # name + f.set_validator('name', self.unique_name) + + def unique_store_id(self, node, value): + """ """ + model = self.app.model + session = self.Session() + + query = session.query(model.Store)\ + .filter(model.Store.store_id == value) + + if self.editing: + uuid = self.request.matchdict['uuid'] + query = query.filter(model.Store.uuid != uuid) + + if query.count(): + node.raise_invalid("Store ID must be unique") + + def unique_name(self, node, value): + """ """ + model = self.app.model + session = self.Session() + + query = session.query(model.Store)\ + .filter(model.Store.name == value) + + if self.editing: + uuid = self.request.matchdict['uuid'] + query = query.filter(model.Store.uuid != uuid) + + if query.count(): + node.raise_invalid("Name must be unique") + + +def defaults(config, **kwargs): + base = globals() + + StoreView = kwargs.get('StoreView', base['StoreView']) + StoreView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tests/db/model/test_stores.py b/tests/db/model/test_stores.py new file mode 100644 index 0000000..3c0b5d0 --- /dev/null +++ b/tests/db/model/test_stores.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8; -*- + +from wuttjamaican.testing import DataTestCase + +from sideshow.db.model import stores as mod + + +class TestPendingCustomer(DataTestCase): + + def test_str(self): + store = mod.Store() + self.assertEqual(str(store), "") + + store.name = "Acme Goods" + self.assertEqual(str(store), "Acme Goods") diff --git a/tests/web/views/test_stores.py b/tests/web/views/test_stores.py new file mode 100644 index 0000000..ab69171 --- /dev/null +++ b/tests/web/views/test_stores.py @@ -0,0 +1,94 @@ +# -*- coding: utf-8; -*- + +from unittest.mock import patch + +import colander + +from sideshow.testing import WebTestCase +from sideshow.web.views import stores as mod + + +class TestIncludeme(WebTestCase): + + def test_coverage(self): + mod.includeme(self.pyramid_config) + + +class TestStoreView(WebTestCase): + + def make_view(self): + return mod.StoreView(self.request) + + def test_configure_grid(self): + model = self.app.model + view = self.make_view() + grid = view.make_grid(model_class=model.Store) + self.assertNotIn('store_id', grid.linked_columns) + self.assertNotIn('name', grid.linked_columns) + view.configure_grid(grid) + self.assertIn('store_id', grid.linked_columns) + self.assertIn('name', grid.linked_columns) + + def test_grid_row_class(self): + model = self.app.model + view = self.make_view() + + store = model.Store() + self.assertFalse(store.archived) + self.assertIsNone(view.grid_row_class(store, {}, 0)) + + store = model.Store(archived=True) + self.assertTrue(store.archived) + self.assertEqual(view.grid_row_class(store, {}, 0), 'has-background-warning') + + def test_configure_form(self): + model = self.app.model + view = self.make_view() + + # unique validators are set + form = view.make_form(model_class=model.Store) + self.assertNotIn('store_id', form.validators) + self.assertNotIn('name', form.validators) + view.configure_form(form) + self.assertIn('store_id', form.validators) + self.assertIn('name', form.validators) + + def test_unique_store_id(self): + model = self.app.model + view = self.make_view() + + store = model.Store(store_id='001', name='whatever') + self.session.add(store) + self.session.commit() + + with patch.object(view, 'Session', return_value=self.session): + + # invalid if same store_id in data + node = colander.SchemaNode(colander.String(), name='store_id') + self.assertRaises(colander.Invalid, view.unique_store_id, node, '001') + + # but not if store_id belongs to current store + with patch.object(self.request, 'matchdict', new={'uuid': store.uuid}): + with patch.object(view, 'editing', new=True): + node = colander.SchemaNode(colander.String(), name='store_id') + self.assertIsNone(view.unique_store_id(node, '001')) + + def test_unique_name(self): + model = self.app.model + view = self.make_view() + + store = model.Store(store_id='001', name='Acme Goods') + self.session.add(store) + self.session.commit() + + with patch.object(view, 'Session', return_value=self.session): + + # invalid if same name in data + node = colander.SchemaNode(colander.String(), name='name') + self.assertRaises(colander.Invalid, view.unique_name, node, 'Acme Goods') + + # but not if name belongs to current store + with patch.object(self.request, 'matchdict', new={'uuid': store.uuid}): + with patch.object(view, 'editing', new=True): + node = colander.SchemaNode(colander.String(), name='name') + self.assertIsNone(view.unique_name(node, 'Acme Goods')) From 89e3445acec6fec0f04fa732398c8d69b29c7b61 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 27 Jan 2025 20:33:14 -0600 Subject: [PATCH 2/5] feat: add config option to show/hide Store ID; default value --- docs/api/sideshow.app.rst | 6 + docs/index.rst | 1 + pyproject.toml | 3 + src/sideshow/app.py | 56 +++++++++ src/sideshow/batch/neworder.py | 20 ++++ src/sideshow/db/model/orders.py | 9 ++ src/sideshow/db/model/stores.py | 10 +- src/sideshow/orders.py | 8 ++ .../web/templates/order-items/view.mako | 11 ++ .../web/templates/orders/configure.mako | 23 ++++ src/sideshow/web/templates/orders/create.mako | 48 +++++++- src/sideshow/web/views/batch/neworder.py | 12 ++ src/sideshow/web/views/orders.py | 103 +++++++++++------ tests/batch/test_neworder.py | 27 +++++ tests/db/model/test_stores.py | 13 +++ tests/test_app.py | 17 +++ tests/test_orders.py | 10 ++ tests/web/views/batch/test_neworder.py | 26 ++++- tests/web/views/test_orders.py | 106 +++++++++++++----- 19 files changed, 445 insertions(+), 64 deletions(-) create mode 100644 docs/api/sideshow.app.rst create mode 100644 src/sideshow/app.py create mode 100644 tests/test_app.py diff --git a/docs/api/sideshow.app.rst b/docs/api/sideshow.app.rst new file mode 100644 index 0000000..7c738b1 --- /dev/null +++ b/docs/api/sideshow.app.rst @@ -0,0 +1,6 @@ + +``sideshow.app`` +================ + +.. automodule:: sideshow.app + :members: diff --git a/docs/index.rst b/docs/index.rst index 643578b..d91ae5e 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -30,6 +30,7 @@ For an online demo see https://demo.wuttaproject.org/ :caption: Package API: api/sideshow + api/sideshow.app api/sideshow.batch api/sideshow.batch.neworder api/sideshow.cli diff --git a/pyproject.toml b/pyproject.toml index e1d42a8..ff4a4bc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,6 +51,9 @@ sideshow_libcache = "sideshow.web.static:libcache" [project.entry-points."paste.app_factory"] "main" = "sideshow.web.app:main" +[project.entry-points."wutta.app.providers"] +sideshow = "sideshow.app:SideshowAppProvider" + [project.entry-points."wutta.batch.neworder"] "sideshow" = "sideshow.batch.neworder:NewOrderBatchHandler" diff --git a/src/sideshow/app.py b/src/sideshow/app.py new file mode 100644 index 0000000..0fbcf2e --- /dev/null +++ b/src/sideshow/app.py @@ -0,0 +1,56 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Sideshow -- Case/Special Order Tracker +# Copyright © 2024 Lance Edgar +# +# This file is part of Sideshow. +# +# Sideshow is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Sideshow is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Sideshow. If not, see . +# +################################################################################ +""" +Sideshow app provider +""" + +from wuttjamaican import app as base + + +class SideshowAppProvider(base.AppProvider): + """ + The :term:`app provider` for Sideshow. + + This adds the :meth:`get_order_handler()` method to the :term:`app + handler`. + """ + + def get_order_handler(self, **kwargs): + """ + Get the configured :term:`order handler` for the app. + + You can specify a custom handler in your :term:`config file` + like: + + .. code-block:: ini + + [sideshow] + orders.handler_spec = poser.orders:PoserOrderHandler + + :returns: Instance of :class:`~sideshow.orders.OrderHandler`. + """ + if 'order_handler' not in self.__dict__: + spec = self.config.get('sideshow.orders.handler_spec', + default='sideshow.orders:OrderHandler') + self.order_handler = self.app.load_object(spec)(self.config) + return self.order_handler diff --git a/src/sideshow/batch/neworder.py b/src/sideshow/batch/neworder.py index bfa04ea..e328501 100644 --- a/src/sideshow/batch/neworder.py +++ b/src/sideshow/batch/neworder.py @@ -50,6 +50,14 @@ class NewOrderBatchHandler(BatchHandler): """ model_class = NewOrderBatch + def get_default_store_id(self): + """ + Returns the configured default value for + :attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.store_id`, + or ``None``. + """ + return self.config.get('sideshow.orders.default_store_id') + def use_local_customers(self): """ Returns boolean indicating whether :term:`local customer` @@ -165,6 +173,18 @@ class NewOrderBatchHandler(BatchHandler): 'label': customer.full_name} return [result(c) for c in customers] + def init_batch(self, batch, session=None, progress=None, **kwargs): + """ + Initialize a new batch. + + This sets the + :attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.store_id`, + if the batch does not yet have one and a default is + configured. + """ + if not batch.store_id: + batch.store_id = self.get_default_store_id() + def set_customer(self, batch, customer_info, user=None): """ Set/update customer info for the batch. diff --git a/src/sideshow/db/model/orders.py b/src/sideshow/db/model/orders.py index 2cadeaa..5455d01 100644 --- a/src/sideshow/db/model/orders.py +++ b/src/sideshow/db/model/orders.py @@ -62,6 +62,15 @@ class Order(model.Base): ID of the store to which the order pertains, if applicable. """) + store = orm.relationship( + 'Store', + primaryjoin='Store.store_id == Order.store_id', + foreign_keys='Order.store_id', + doc=""" + Reference to the :class:`~sideshow.db.model.stores.Store` + record, if applicable. + """) + customer_id = sa.Column(sa.String(length=20), nullable=True, doc=""" Proper account ID for the :term:`external customer` to which the order pertains, if applicable. diff --git a/src/sideshow/db/model/stores.py b/src/sideshow/db/model/stores.py index b1956c1..4b01d02 100644 --- a/src/sideshow/db/model/stores.py +++ b/src/sideshow/db/model/stores.py @@ -51,4 +51,12 @@ class Store(model.Base): """) def __str__(self): - return self.name or "" + return self.get_display() + + def get_display(self): + """ + Returns the display string for the store, e.g. "001 Acme Goods". + """ + return ' '.join([(self.store_id or '').strip(), + (self.name or '').strip()])\ + .strip() diff --git a/src/sideshow/orders.py b/src/sideshow/orders.py index 9f99e53..868cada 100644 --- a/src/sideshow/orders.py +++ b/src/sideshow/orders.py @@ -37,6 +37,14 @@ class OrderHandler(GenericHandler): handler is responsible for creation logic.) """ + def expose_store_id(self): + """ + Returns boolean indicating whether the ``store_id`` field + should be exposed at all. This is false by default. + """ + return self.config.get_bool('sideshow.orders.expose_store_id', + default=False) + def get_order_qty_uom_text(self, order_qty, order_uom, case_size=None, html=False): """ Return the display text for a given order quantity. diff --git a/src/sideshow/web/templates/order-items/view.mako b/src/sideshow/web/templates/order-items/view.mako index 3cd210a..21cb8b2 100644 --- a/src/sideshow/web/templates/order-items/view.mako +++ b/src/sideshow/web/templates/order-items/view.mako @@ -29,6 +29,17 @@ ${h.link_to(f"Order ID {order.order_id}", url('orders.view', uuid=order.uuid))} — Item #${item.sequence} + % if expose_store_id: + + + % if order.store: + ${h.link_to(order.store.get_display(), url('stores.view', uuid=order.store.uuid))} + % elif order.store_id: + ${order.store_id} + % endif + + + % endif ${order_qty_uom_text|n} diff --git a/src/sideshow/web/templates/orders/configure.mako b/src/sideshow/web/templates/orders/configure.mako index e247b2f..6af30b1 100644 --- a/src/sideshow/web/templates/orders/configure.mako +++ b/src/sideshow/web/templates/orders/configure.mako @@ -3,6 +3,28 @@ <%def name="form_content()"> +

Stores

+
+ + + + Show/choose the Store ID for each order + + + + + + + +
+

Customers

@@ -14,6 +36,7 @@ +

Products

diff --git a/src/sideshow/web/templates/orders/create.mako b/src/sideshow/web/templates/orders/create.mako index 2ac6f0a..7ec015a 100644 --- a/src/sideshow/web/templates/orders/create.mako +++ b/src/sideshow/web/templates/orders/create.mako @@ -42,7 +42,25 @@ diff --git a/src/sideshow/web/templates/orders/create.mako b/src/sideshow/web/templates/orders/create.mako index 7ec015a..bc03a29 100644 --- a/src/sideshow/web/templates/orders/create.mako +++ b/src/sideshow/web/templates/orders/create.mako @@ -504,7 +504,16 @@ - + + + + - + @@ -750,7 +758,7 @@ <${b}-table-column label="Department" v-slot="props"> - {{ props.row.department_display }} + {{ props.row.department_name }} <${b}-table-column label="Quantity" @@ -922,8 +930,10 @@ productCaseSize: null, % if allow_item_discounts: - productDiscountPercent: ${json.dumps(default_item_discount)|n}, + defaultItemDiscount: ${json.dumps(default_item_discount)|n}, + deptItemDiscounts: ${json.dumps(dept_item_discounts)|n}, allowDiscountsIfOnSale: ${json.dumps(allow_item_discounts_if_on_sale)|n}, + productDiscountPercent: null, % endif pendingProduct: {}, @@ -1259,6 +1269,21 @@ }) }, + % if allow_item_discounts: + + updateDiscount(deptID) { + // nb. our map requires ID is string + deptID = deptID.toString() + const i = Object.keys(this.deptItemDiscounts).indexOf(deptID) + if (i == -1) { + this.productDiscountPercent = this.defaultItemDiscount + } else { + this.productDiscountPercent = this.deptItemDiscounts[deptID] + } + }, + + % endif + editNewCustomerSave() { this.editNewCustomerSaving = true @@ -1397,7 +1422,7 @@ this.productUnitChoices = this.defaultUnitChoices % if allow_item_discounts: - this.productDiscountPercent = ${json.dumps(default_item_discount)|n} + this.productDiscountPercent = this.defaultItemDiscount % endif }, @@ -1436,7 +1461,15 @@ this.productSaleEndsDisplay = data.sale_ends_display % if allow_item_discounts: - this.productDiscountPercent = this.allowItemDiscount ? data.default_item_discount : null + if (this.allowItemDiscount) { + if (data?.default_item_discount != null) { + this.productDiscountPercent = data.default_item_discount + } else { + this.updateDiscount(data?.department_id) + } + } else { + this.productDiscountPercent = null + } % endif // this.setProductUnitChoices(data.uom_choices) @@ -1514,7 +1547,7 @@ this.productUOM = this.defaultUOM % if allow_item_discounts: - this.productDiscountPercent = ${json.dumps(default_item_discount)|n} + this.productDiscountPercent = this.defaultItemDiscount % endif % if request.use_oruga: @@ -1615,7 +1648,7 @@ this.editItemLoading = true const params = { - order_qty: this.productQuantity, + order_qty: parseFloat(this.productQuantity), order_uom: this.productUOM, } @@ -1626,7 +1659,9 @@ } % if allow_item_discounts: - params.discount_percent = this.productDiscountPercent + if (this.productDiscountPercent) { + params.discount_percent = parseFloat(this.productDiscountPercent) + } % endif if (this.editItemRow) { diff --git a/src/sideshow/web/views/orders.py b/src/sideshow/web/views/orders.py index d238a7c..c728e71 100644 --- a/src/sideshow/web/views/orders.py +++ b/src/sideshow/web/views/orders.py @@ -25,7 +25,9 @@ Views for Orders """ import decimal +import json import logging +import re import colander import sqlalchemy as sa @@ -144,6 +146,7 @@ class OrderView(MasterView): 'brand_name', 'description', 'size', + 'department_id', 'department_name', 'vendor_name', 'vendor_item_code', @@ -304,6 +307,8 @@ class OrderView(MasterView): # nb. render quantity so that '10.0' => '10' context['default_item_discount'] = self.app.render_quantity( self.batch_handler.get_default_item_discount()) + context['dept_item_discounts'] = dict([(d['department_id'], d['default_item_discount']) + for d in self.get_dept_item_discounts()]) return self.render_to_response('create', context) @@ -401,6 +406,43 @@ class OrderView(MasterView): required.append(field) return required + def get_dept_item_discounts(self): + """ + Returns the list of per-department default item discount settings. + + Each entry in the list will look like:: + + { + 'department_id': '42', + 'department_name': 'Grocery', + 'default_item_discount': 10, + } + + :returns: List of department settings as shown above. + """ + model = self.app.model + session = self.Session() + pattern = re.compile(r'^sideshow\.orders\.departments\.([^.]+)\.default_item_discount$') + + dept_item_discounts = [] + settings = session.query(model.Setting)\ + .filter(model.Setting.name.like('sideshow.orders.departments.%.default_item_discount'))\ + .all() + for setting in settings: + match = pattern.match(setting.name) + if not match: + log.warning("invalid setting name: %s", setting.name) + continue + deptid = match.group(1) + name = self.app.get_setting(session, f'sideshow.orders.departments.{deptid}.name') + dept_item_discounts.append({ + 'department_id': deptid, + 'department_name': name, + 'default_item_discount': setting.value, + }) + dept_item_discounts.sort(key=lambda d: d['department_name']) + return dept_item_discounts + def start_over(self, batch): """ This will delete the user's current batch, then redirect user @@ -598,9 +640,6 @@ class OrderView(MasterView): if 'case_price_quoted' in data and 'case_price_quoted_display' not in data: data['case_price_quoted_display'] = self.app.render_currency(data['case_price_quoted']) - if 'default_item_discount' not in data: - data['default_item_discount'] = self.batch_handler.get_default_item_discount() - decimal_fields = [ 'case_size', 'unit_price_reg', @@ -753,7 +792,8 @@ class OrderView(MasterView): row.product_description, row.product_size), 'product_weighed': row.product_weighed, - 'department_display': row.department_name, + 'department_id': row.department_id, + 'department_name': row.department_name, 'special_order': row.special_order, 'case_size': float(row.case_size) if row.case_size is not None else None, 'order_qty': float(row.order_qty), @@ -990,8 +1030,39 @@ class OrderView(MasterView): handlers = [{'spec': spec} for spec in handlers] context['batch_handlers'] = handlers + context['dept_item_discounts'] = self.get_dept_item_discounts() + return context + def configure_gather_settings(self, data, simple_settings=None): + """ """ + settings = super().configure_gather_settings(data, simple_settings=simple_settings) + + for dept in json.loads(data['dept_item_discounts']): + deptid = dept['department_id'] + settings.append({'name': f'sideshow.orders.departments.{deptid}.name', + 'value': dept['department_name']}) + settings.append({'name': f'sideshow.orders.departments.{deptid}.default_item_discount', + 'value': dept['default_item_discount']}) + + return settings + + def configure_remove_settings(self, **kwargs): + """ """ + model = self.app.model + session = self.Session() + + super().configure_remove_settings(**kwargs) + + to_delete = session.query(model.Setting)\ + .filter(sa.or_( + model.Setting.name.like('sideshow.orders.departments.%.name'), + model.Setting.name.like('sideshow.orders.departments.%.default_item_discount')))\ + .all() + for setting in to_delete: + self.app.delete_setting(session, setting.name) + + @classmethod def defaults(cls, config): cls._order_defaults(config) diff --git a/src/sideshow/web/views/products.py b/src/sideshow/web/views/products.py index 98341b7..010bb66 100644 --- a/src/sideshow/web/views/products.py +++ b/src/sideshow/web/views/products.py @@ -235,6 +235,7 @@ class PendingProductView(MasterView): url_prefix = '/pending/products' labels = { + 'department_id': "Department ID", 'product_id': "Product ID", } diff --git a/tests/web/views/test_orders.py b/tests/web/views/test_orders.py index 1230829..827111d 100644 --- a/tests/web/views/test_orders.py +++ b/tests/web/views/test_orders.py @@ -2,6 +2,7 @@ import datetime import decimal +import json from unittest.mock import patch from sqlalchemy import orm @@ -291,6 +292,50 @@ class TestOrderView(WebTestCase): fields = view.get_pending_product_required_fields() self.assertEqual(fields, ['brand_name', 'size', 'unit_price_reg']) + def test_get_dept_item_discounts(self): + model = self.app.model + view = self.make_view() + + with patch.object(view, 'Session', return_value=self.session): + + # empty list by default + discounts = view.get_dept_item_discounts() + self.assertEqual(discounts, []) + + # mock settings + self.app.save_setting(self.session, 'sideshow.orders.departments.5.name', 'Bulk') + self.app.save_setting(self.session, 'sideshow.orders.departments.5.default_item_discount', '15') + self.app.save_setting(self.session, 'sideshow.orders.departments.6.name', 'Produce') + self.app.save_setting(self.session, 'sideshow.orders.departments.6.default_item_discount', '5') + discounts = view.get_dept_item_discounts() + self.assertEqual(len(discounts), 2) + self.assertEqual(discounts[0], { + 'department_id': '5', + 'department_name': 'Bulk', + 'default_item_discount': '15', + }) + self.assertEqual(discounts[1], { + 'department_id': '6', + 'department_name': 'Produce', + 'default_item_discount': '5', + }) + + # invalid setting + self.app.save_setting(self.session, 'sideshow.orders.departments.I.N.V.A.L.I.D.name', 'Bad News') + self.app.save_setting(self.session, 'sideshow.orders.departments.I.N.V.A.L.I.D.default_item_discount', '42') + discounts = view.get_dept_item_discounts() + self.assertEqual(len(discounts), 2) + self.assertEqual(discounts[0], { + 'department_id': '5', + 'department_name': 'Bulk', + 'default_item_discount': '15', + }) + self.assertEqual(discounts[1], { + 'department_id': '6', + 'department_name': 'Produce', + 'default_item_discount': '5', + }) + def test_get_context_customer(self): self.pyramid_config.add_route('orders', '/orders/') model = self.app.model @@ -1288,6 +1333,12 @@ class TestOrderView(WebTestCase): model = self.app.model view = self.make_view() + self.app.save_setting(self.session, 'sideshow.orders.departments.5.name', 'Bulk') + self.app.save_setting(self.session, 'sideshow.orders.departments.5.default_item_discount', '15') + self.app.save_setting(self.session, 'sideshow.orders.departments.6.name', 'Produce') + self.app.save_setting(self.session, 'sideshow.orders.departments.6.default_item_discount', '5') + self.session.commit() + with patch.object(view, 'Session', return_value=self.session): with patch.multiple(self.config, usedb=True, preferdb=True): @@ -1295,7 +1346,19 @@ class TestOrderView(WebTestCase): allowed = self.config.get_bool('sideshow.orders.allow_unknown_products', session=self.session) self.assertIsNone(allowed) - self.assertEqual(self.session.query(model.Setting).count(), 0) + self.assertEqual(self.session.query(model.Setting).count(), 4) + discounts = view.get_dept_item_discounts() + self.assertEqual(len(discounts), 2) + self.assertEqual(discounts[0], { + 'department_id': '5', + 'department_name': 'Bulk', + 'default_item_discount': '15', + }) + self.assertEqual(discounts[1], { + 'department_id': '6', + 'department_name': 'Produce', + 'default_item_discount': '5', + }) # fetch initial page response = view.configure() @@ -1305,13 +1368,18 @@ class TestOrderView(WebTestCase): allowed = self.config.get_bool('sideshow.orders.allow_unknown_products', session=self.session) self.assertIsNone(allowed) - self.assertEqual(self.session.query(model.Setting).count(), 0) + self.assertEqual(self.session.query(model.Setting).count(), 4) # post new settings with patch.multiple(self.request, create=True, method='POST', POST={ 'sideshow.orders.allow_unknown_products': 'true', + 'dept_item_discounts': json.dumps([{ + 'department_id': '5', + 'department_name': 'Grocery', + 'default_item_discount': 10, + }]) }): response = view.configure() self.assertIsInstance(response, HTTPFound) @@ -1320,6 +1388,13 @@ class TestOrderView(WebTestCase): session=self.session) self.assertTrue(allowed) self.assertTrue(self.session.query(model.Setting).count() > 1) + discounts = view.get_dept_item_discounts() + self.assertEqual(len(discounts), 1) + self.assertEqual(discounts[0], { + 'department_id': '5', + 'department_name': 'Grocery', + 'default_item_discount': '10', + }) class OrderItemViewTestMixin: From 3ca89a8479be333303eaf95b2e6f7304f809b2a1 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 1 Feb 2025 19:39:02 -0600 Subject: [PATCH 5/5] feat: allow re-order past product for new orders assuming batch has a customer set, with order history nb. this only uses past *products* and not order qty/uom --- src/sideshow/batch/neworder.py | 165 +++++++++++++- src/sideshow/web/templates/orders/create.mako | 210 +++++++++++++++++- src/sideshow/web/views/orders.py | 75 +++---- tests/batch/test_neworder.py | 178 +++++++++++++++ tests/web/views/test_orders.py | 59 ++++- 5 files changed, 633 insertions(+), 54 deletions(-) diff --git a/src/sideshow/batch/neworder.py b/src/sideshow/batch/neworder.py index e328501..28e2627 100644 --- a/src/sideshow/batch/neworder.py +++ b/src/sideshow/batch/neworder.py @@ -26,6 +26,7 @@ New Order Batch Handler import datetime import decimal +from collections import OrderedDict import sqlalchemy as sa @@ -379,6 +380,21 @@ class NewOrderBatchHandler(BatchHandler): 'label': product.full_description} return [result(c) for c in products] + def get_default_uom_choices(self): + """ + Returns a list of ordering UOM choices which should be + presented to the user by default. + + The built-in logic here will return everything from + :data:`~sideshow.enum.ORDER_UOM`. + + :returns: List of dicts, each with ``key`` and ``value`` + corresponding to the UOM code and label, respectively. + """ + enum = self.app.enum + return [{'key': key, 'value': val} + for key, val in enum.ORDER_UOM.items()] + def get_product_info_external(self, session, product_id, user=None): """ Returns basic info for an :term:`external product` as pertains @@ -388,7 +404,8 @@ class NewOrderBatchHandler(BatchHandler): choose order quantity and UOM based on case size, pricing etc., this method is called to retrieve the product info. - There is no default logic here; subclass must implement. + There is no default logic here; subclass must implement. See + also :meth:`get_product_info_local()`. :param session: Current app :term:`db session`. @@ -444,21 +461,58 @@ class NewOrderBatchHandler(BatchHandler): def get_product_info_local(self, session, uuid, user=None): """ - Returns basic info for a - :class:`~sideshow.db.model.products.LocalProduct` as pertains - to ordering. + Returns basic info for a :term:`local product` as pertains to + ordering. When user has located a product via search, and must then choose order quantity and UOM based on case size, pricing etc., this method is called to retrieve the product info. See :meth:`get_product_info_external()` for more explanation. + + This method will locate the + :class:`~sideshow.db.model.products.LocalProduct` record, then + (if found) it calls :meth:`normalize_local_product()` and + returns the result. + + :param session: Current :term:`db session`. + + :param uuid: UUID for the desired + :class:`~sideshow.db.model.products.LocalProduct`. + + :param user: + :class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who + is performing the action, if known. + + :returns: Dict of product info. """ model = self.app.model product = session.get(model.LocalProduct, uuid) if not product: raise ValueError(f"Local Product not found: {uuid}") + return self.normalize_local_product(product) + + def normalize_local_product(self, product): + """ + Returns a normalized dict of info for the given :term:`local + product`. + + This is called by: + + * :meth:`get_product_info_local()` + * :meth:`get_past_products()` + + :param product: + :class:`~sideshow.db.model.products.LocalProduct` instance. + + :returns: Dict of product info. + + The keys for this dict should essentially one-to-one for the + product fields, with one exception: + + * ``product_id`` will be set to the product UUID as string + """ return { 'product_id': product.uuid.hex, 'scancode': product.scancode, @@ -476,6 +530,109 @@ class NewOrderBatchHandler(BatchHandler): 'vendor_item_code': product.vendor_item_code, } + def get_past_orders(self, batch): + """ + Retrieve a (possibly empty) list of past :term:`orders + ` for the batch customer. + + This is called by :meth:`get_past_products()`. + + :param batch: + :class:`~sideshow.db.model.batch.neworder.NewOrderBatch` + instance. + + :returns: List of :class:`~sideshow.db.model.orders.Order` + records. + """ + model = self.app.model + session = self.app.get_session(batch) + orders = session.query(model.Order) + + if batch.customer_id: + orders = orders.filter(model.Order.customer_id == batch.customer_id) + elif batch.local_customer: + orders = orders.filter(model.Order.local_customer == batch.local_customer) + else: + raise ValueError(f"batch has no customer: {batch}") + + orders = orders.order_by(model.Order.created.desc()) + return orders.all() + + def get_past_products(self, batch, user=None): + """ + Retrieve a (possibly empty) list of products which have been + previously ordered by the batch customer. + + Note that this does not return :term:`order items `, nor does it return true product records, but rather it + returns a list of dicts. Each will have product info but will + *not* have order quantity etc. + + This method calls :meth:`get_past_orders()` and then iterates + through each order item therein. Any duplicated products + encountered will be skipped, so the final list contains unique + products. + + Each dict in the result is obtained by calling one of: + + * :meth:`normalize_local_product()` + * :meth:`get_product_info_external()` + + :param batch: + :class:`~sideshow.db.model.batch.neworder.NewOrderBatch` + instance. + + :param user: + :class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who + is performing the action, if known. + + :returns: List of product info dicts. + """ + model = self.app.model + session = self.app.get_session(batch) + use_local = self.use_local_products() + user = user or batch.created_by + products = OrderedDict() + + # track down all order items for batch contact + for order in self.get_past_orders(batch): + for item in order.items: + + # nb. we only need the first match for each product + if use_local: + product = item.local_product + if product and product.uuid not in products: + products[product.uuid] = self.normalize_local_product(product) + elif item.product_id and item.product_id not in products: + products[item.product_id] = self.get_product_info_external( + session, item.product_id, user=user) + + products = list(products.values()) + for product in products: + + price = product['unit_price_reg'] + + if 'unit_price_reg_display' not in product: + product['unit_price_reg_display'] = self.app.render_currency(price) + + if 'unit_price_quoted' not in product: + product['unit_price_quoted'] = price + + if 'unit_price_quoted_display' not in product: + product['unit_price_quoted_display'] = product['unit_price_reg_display'] + + if ('case_price_quoted' not in product + and product.get('unit_price_quoted') is not None + and product.get('case_size') is not None): + product['case_price_quoted'] = product['unit_price_quoted'] * product['case_size'] + + if ('case_price_quoted_display' not in product + and 'case_price_quoted' in product): + product['case_price_quoted_display'] = self.app.render_currency( + product['case_price_quoted']) + + return products + def add_item(self, batch, product_info, order_qty, order_uom, discount_percent=None, user=None): """ diff --git a/src/sideshow/web/templates/orders/create.mako b/src/sideshow/web/templates/orders/create.mako index bc03a29..5dd39b7 100644 --- a/src/sideshow/web/templates/orders/create.mako +++ b/src/sideshow/web/templates/orders/create.mako @@ -355,6 +355,135 @@ @click="showAddItemDialog()"> Add Item + % if allow_past_item_reorder: + + Add Past Item + + + <${b}-modal + % if request.use_oruga: + v-model:active="pastItemsShowDialog" + % else: + :active.sync="pastItemsShowDialog" + % endif + > +
+
+ + <${b}-table :data="pastItems" + icon-pack="fas" + :loading="pastItemsLoading" + % if request.use_oruga: + v-model:selected="pastItemsSelected" + % else: + :selected.sync="pastItemsSelected" + % endif + sortable + paginated + per-page="5" + ## :debounce-search="1000" + > + + <${b}-table-column label="Scancode" + field="key" + v-slot="props" + sortable> + {{ props.row.scancode }} + + + <${b}-table-column label="Brand" + field="brand_name" + v-slot="props" + sortable + searchable> + {{ props.row.brand_name }} + + + <${b}-table-column label="Description" + field="description" + v-slot="props" + sortable + searchable> + {{ props.row.description }} + {{ props.row.size }} + + + <${b}-table-column label="Unit Price" + field="unit_price_reg_display" + v-slot="props" + sortable> + {{ props.row.unit_price_reg_display }} + + + <${b}-table-column label="Sale Price" + field="sale_price" + v-slot="props" + sortable> + + {{ props.row.sale_price_display }} + + + + <${b}-table-column label="Sale Ends" + field="sale_ends" + v-slot="props" + sortable> + + {{ props.row.sale_ends_display }} + + + + <${b}-table-column label="Department" + field="department_name" + v-slot="props" + sortable + searchable> + {{ props.row.department_name }} + + + <${b}-table-column label="Vendor" + field="vendor_name" + v-slot="props" + sortable + searchable> + {{ props.row.vendor_name }} + + + + + +
+ + Cancel + + + Add Selected Item + +
+ +
+
+ + + % endif <${b}-modal @@ -942,6 +1071,13 @@ ## departmentOptions: ${json.dumps(department_options)|n}, departmentOptions: [], + % if allow_past_item_reorder: + pastItemsShowDialog: false, + pastItemsLoading: false, + pastItems: [], + pastItemsSelected: null, + % endif + // nb. hack to force refresh for vue3 refreshProductDescription: 1, refreshTotalPrice: 1, @@ -1218,6 +1354,7 @@ customerChanged(customerID, callback) { this.customerLoading = true + this.pastItems = [] const params = {} if (customerID) { @@ -1272,8 +1409,10 @@ % if allow_item_discounts: updateDiscount(deptID) { - // nb. our map requires ID is string - deptID = deptID.toString() + if (deptID) { + // nb. our map requires ID as string + deptID = deptID.toString() + } const i = Object.keys(this.deptItemDiscounts).indexOf(deptID) if (i == -1) { this.productDiscountPercent = this.defaultItemDiscount @@ -1601,7 +1740,7 @@ ## this.productSpecialOrder = row.special_order this.productQuantity = row.order_qty - this.productUnitChoices = row.order_uom_choices + this.productUnitChoices = row?.order_uom_choices || this.defaultUnitChoices this.productUOM = row.order_uom % if allow_item_discounts: @@ -1643,6 +1782,71 @@ }) }, + % if allow_past_item_reorder: + + showAddPastItem() { + this.pastItemsSelected = null + if (!this.pastItems.length) { + this.pastItemsLoading = true + const params = {action: 'get_past_products'} + this.submitBatchData(params, ({data}) => { + this.pastItems = data + this.pastItemsLoading = false + }) + } + this.pastItemsShowDialog = true + }, + + pastItemsAddSelected() { + this.pastItemsShowDialog = false + const selected = this.pastItemsSelected + + this.editItemRow = null + this.productIsKnown = true + this.productID = selected.product_id + + this.selectedProduct = { + product_id: selected.product_id, + full_description: selected.full_description, + // url: selected.product_url, + } + + this.productDisplay = selected.full_description + this.productScancode = selected.scancode + this.productSize = selected.size + this.productCaseQuantity = selected.case_size + this.productUnitPrice = selected.unit_price_quoted + this.productUnitPriceDisplay = selected.unit_price_quoted_display + this.productUnitRegularPriceDisplay = selected.unit_price_reg_display + this.productCasePrice = selected.case_price_quoted + this.productCasePriceDisplay = selected.case_price_quoted_display + this.productSalePrice = selected.unit_price_sale + this.productSalePriceDisplay = selected.unit_price_sale_display + this.productSaleEndsDisplay = selected.sale_ends_display + this.productSpecialOrder = selected.special_order + + this.productQuantity = 1 + this.productUnitChoices = selected?.order_uom_choices || this.defaultUnitChoices + this.productUOM = selected?.order_uom || this.defaultUOM + + % if allow_item_discounts: + this.updateDiscount(selected.department_id) + % endif + + // nb. hack to force refresh for vue3 + this.refreshProductDescription += 1 + this.refreshTotalPrice += 1 + + % if request.use_oruga: + this.itemDialogTab = 'quantity' + % else: + this.itemDialogTabIndex = 1 + % endif + this.editItemShowDialog = true + }, + + % endif + itemDialogAttemptSave() { this.itemDialogSaving = true this.editItemLoading = true diff --git a/src/sideshow/web/views/orders.py b/src/sideshow/web/views/orders.py index c728e71..8aa7534 100644 --- a/src/sideshow/web/views/orders.py +++ b/src/sideshow/web/views/orders.py @@ -37,6 +37,7 @@ from webhelpers2.html import tags, HTML from wuttaweb.views import MasterView from wuttaweb.forms.schema import UserRef, WuttaMoney, WuttaQuantity, WuttaEnum, WuttaDictEnum +from wuttaweb.util import make_json_safe from sideshow.db.model import Order, OrderItem from sideshow.batch.neworder import NewOrderBatchHandler @@ -66,13 +67,13 @@ class OrderView(MasterView): .. attribute:: order_handler Reference to the :term:`order handler` as returned by - :meth:`get_order_handler()`. This gets set in the constructor. + :meth:`~sideshow.app.SideshowAppProvider.get_order_handler()`. + This gets set in the constructor. .. attribute:: batch_handler - Reference to the :term:`new order batch` handler, as returned - by :meth:`get_batch_handler()`. This gets set in the - constructor. + Reference to the :term:`new order batch` handler. This gets + set in the constructor. """ model_class = Order editable = False @@ -158,22 +159,7 @@ class OrderView(MasterView): def __init__(self, request, context=None): super().__init__(request, context=context) self.order_handler = self.app.get_order_handler() - - def get_batch_handler(self): - """ - Returns the configured :term:`handler` for :term:`new order - batches `. - - You normally would not need to call this, and can use - :attr:`batch_handler` instead. - - :returns: - :class:`~sideshow.batch.neworder.NewOrderBatchHandler` - instance. - """ - if hasattr(self, 'batch_handler'): - return self.batch_handler - return self.app.get_batch_handler('neworder') + self.batch_handler = self.app.get_batch_handler('neworder') def configure_grid(self, g): """ """ @@ -229,7 +215,6 @@ class OrderView(MasterView): model = self.app.model enum = self.app.enum session = self.Session() - self.batch_handler = self.get_batch_handler() batch = self.get_current_batch() self.creating = True @@ -259,7 +244,7 @@ class OrderView(MasterView): # 'get_customer_info', # # 'set_customer_data', 'get_product_info', - # 'get_past_items', + 'get_past_products', 'add_item', 'update_item', 'delete_item', @@ -280,13 +265,14 @@ class OrderView(MasterView): 'normalized_batch': self.normalize_batch(batch), 'order_items': [self.normalize_row(row) for row in batch.rows], - 'default_uom_choices': self.get_default_uom_choices(), + 'default_uom_choices': self.batch_handler.get_default_uom_choices(), 'default_uom': None, # TODO? 'expose_store_id': self.order_handler.expose_store_id(), 'allow_item_discounts': self.batch_handler.allow_item_discounts(), 'allow_unknown_products': (self.batch_handler.allow_unknown_products() and self.has_perm('create_unknown_product')), 'pending_product_required_fields': self.get_pending_product_required_fields(), + 'allow_past_item_reorder': True, # TODO: make configurable? }) if context['expose_store_id']: @@ -364,7 +350,7 @@ class OrderView(MasterView): if not term: return [] - handler = self.get_batch_handler() + handler = self.batch_handler if handler.use_local_customers(): return handler.autocomplete_customers_local(session, term, user=self.request.user) else: @@ -388,7 +374,7 @@ class OrderView(MasterView): if not term: return [] - handler = self.get_batch_handler() + handler = self.batch_handler if handler.use_local_products(): return handler.autocomplete_products_local(session, term, user=self.request.user) else: @@ -597,18 +583,20 @@ class OrderView(MasterView): def get_product_info(self, batch, data): """ - Fetch data for a specific product. (Nothing is modified.) + Fetch data for a specific product. - Depending on config, this will fetch a :term:`local product` - or :term:`external product` to get the data. + Depending on config, this calls one of the following to get + its primary data: - This should invoke a configured handler for the query - behavior, but that is not yet implemented. For now it uses - built-in logic only, which queries the - :class:`~sideshow.db.model.products.LocalProduct` table. + * :meth:`~sideshow.batch.neworder.NewOrderBatchHandler.get_product_info_local()` + * :meth:`~sideshow.batch.neworder.NewOrderBatchHandler.get_product_info_external()` + + It then may supplement the data with additional fields. This is a "batch action" method which may be called from :meth:`create()`. + + :returns: Dict of product info. """ product_id = data.get('product_id') if not product_id: @@ -656,6 +644,22 @@ class OrderView(MasterView): return data + def get_past_products(self, batch, data): + """ + Fetch past products for convenient re-ordering. + + This essentially calls + :meth:`~sideshow.batch.neworder.NewOrderBatchHandler.get_past_products()` + on the :attr:`batch_handler` and returns the result. + + This is a "batch action" method which may be called from + :meth:`create()`. + + :returns: List of product info dicts. + """ + past_products = self.batch_handler.get_past_products(batch) + return make_json_safe(past_products) + def add_item(self, batch, data): """ This adds a row to the user's current new order batch. @@ -772,12 +776,6 @@ class OrderView(MasterView): 'status_text': batch.status_text, } - def get_default_uom_choices(self): - """ """ - enum = self.app.enum - return [{'key': key, 'value': val} - for key, val in enum.ORDER_UOM.items()] - def normalize_row(self, row): """ """ data = { @@ -798,7 +796,6 @@ class OrderView(MasterView): 'case_size': float(row.case_size) if row.case_size is not None else None, 'order_qty': float(row.order_qty), 'order_uom': row.order_uom, - 'order_uom_choices': self.get_default_uom_choices(), 'discount_percent': self.app.render_quantity(row.discount_percent), 'unit_price_quoted': float(row.unit_price_quoted) if row.unit_price_quoted is not None else None, 'unit_price_quoted_display': self.app.render_currency(row.unit_price_quoted), diff --git a/tests/batch/test_neworder.py b/tests/batch/test_neworder.py index 62d4f5b..ed2179a 100644 --- a/tests/batch/test_neworder.py +++ b/tests/batch/test_neworder.py @@ -4,6 +4,8 @@ import datetime import decimal from unittest.mock import patch +import sqlalchemy as sa + from wuttjamaican.testing import DataTestCase from sideshow.batch import neworder as mod @@ -267,6 +269,14 @@ class TestNewOrderBatchHandler(DataTestCase): # search for juice finds nothing self.assertEqual(handler.autocomplete_products_local(self.session, 'juice'), []) + def test_get_default_uom_choices(self): + enum = self.app.enum + handler = self.make_handler() + + uoms = handler.get_default_uom_choices() + self.assertEqual(uoms, [{'key': key, 'value': val} + for key, val in enum.ORDER_UOM.items()]) + def test_get_product_info_external(self): handler = self.make_handler() self.assertRaises(NotImplementedError, handler.get_product_info_external, @@ -308,6 +318,174 @@ class TestNewOrderBatchHandler(DataTestCase): mock_uuid = self.app.make_true_uuid() self.assertRaises(ValueError, handler.get_product_info_local, self.session, mock_uuid.hex) + def test_normalize_local_product(self): + model = self.app.model + handler = self.make_handler() + + product = model.LocalProduct(scancode='07430500132', + brand_name="Bragg's", + description="Apple Cider Vinegar", + size="32oz", + department_name="Grocery", + case_size=12, + unit_price_reg=5.99, + vendor_name="UNFI", + vendor_item_code='1234') + self.session.add(product) + self.session.flush() + + info = handler.normalize_local_product(product) + self.assertIsInstance(info, dict) + self.assertEqual(info['product_id'], product.uuid.hex) + for prop in sa.inspect(model.LocalProduct).column_attrs: + if prop.key == 'uuid': + continue + if prop.key not in info: + continue + self.assertEqual(info[prop.key], getattr(product, prop.key)) + + def test_get_past_orders(self): + model = self.app.model + handler = self.make_handler() + + user = model.User(username='barney') + self.session.add(user) + batch = handler.make_batch(self.session, created_by=user) + self.session.add(batch) + self.session.flush() + + # ..will test local customers first + + # error if no customer + self.assertRaises(ValueError, handler.get_past_orders, batch) + + # empty history for customer + customer = model.LocalCustomer(full_name='Fred Flintstone') + batch.local_customer = customer + self.session.flush() + orders = handler.get_past_orders(batch) + self.assertEqual(len(orders), 0) + + # mock historical order + order = model.Order(order_id=42, local_customer=customer, created_by=user) + self.session.add(order) + self.session.flush() + + # that should now be returned + orders = handler.get_past_orders(batch) + self.assertEqual(len(orders), 1) + self.assertIs(orders[0], order) + + # ..now we test external customers, w/ new batch + with patch.object(handler, 'use_local_customers', return_value=False): + batch2 = handler.make_batch(self.session, created_by=user) + self.session.add(batch2) + self.session.flush() + + # error if no customer + self.assertRaises(ValueError, handler.get_past_orders, batch2) + + # empty history for customer + batch2.customer_id = '123' + self.session.flush() + orders = handler.get_past_orders(batch2) + self.assertEqual(len(orders), 0) + + # mock historical order + order2 = model.Order(order_id=42, customer_id='123', created_by=user) + self.session.add(order2) + self.session.flush() + + # that should now be returned + orders = handler.get_past_orders(batch2) + self.assertEqual(len(orders), 1) + self.assertIs(orders[0], order2) + + def test_get_past_products(self): + model = self.app.model + enum = self.app.enum + handler = self.make_handler() + + user = model.User(username='barney') + self.session.add(user) + batch = handler.make_batch(self.session, created_by=user) + self.session.add(batch) + self.session.flush() + + # (nb. this all assumes local customers) + + # ..will test local products first + + # error if no customer + self.assertRaises(ValueError, handler.get_past_products, batch) + + # empty history for customer + customer = model.LocalCustomer(full_name='Fred Flintstone') + batch.local_customer = customer + self.session.flush() + products = handler.get_past_products(batch) + self.assertEqual(len(products), 0) + + # mock historical order + order = model.Order(order_id=42, local_customer=customer, created_by=user) + product = model.LocalProduct(scancode='07430500132', description='Vinegar', + unit_price_reg=5.99, case_size=12) + item = model.OrderItem(local_product=product, order_qty=1, order_uom=enum.ORDER_UOM_UNIT, + status_code=enum.ORDER_ITEM_STATUS_READY) + order.items.append(item) + self.session.add(order) + self.session.flush() + self.session.refresh(product) + + # that should now be returned + products = handler.get_past_products(batch) + self.assertEqual(len(products), 1) + self.assertEqual(products[0]['product_id'], product.uuid.hex) + self.assertEqual(products[0]['scancode'], '07430500132') + self.assertEqual(products[0]['description'], 'Vinegar') + self.assertEqual(products[0]['case_price_quoted'], decimal.Decimal('71.88')) + self.assertEqual(products[0]['case_price_quoted_display'], '$71.88') + + # ..now we test external products, w/ new batch + with patch.object(handler, 'use_local_products', return_value=False): + batch2 = handler.make_batch(self.session, created_by=user) + self.session.add(batch2) + self.session.flush() + + # error if no customer + self.assertRaises(ValueError, handler.get_past_products, batch2) + + # empty history for customer + batch2.local_customer = customer + self.session.flush() + products = handler.get_past_products(batch2) + self.assertEqual(len(products), 0) + + # mock historical order + order2 = model.Order(order_id=44, local_customer=customer, created_by=user) + self.session.add(order2) + item2 = model.OrderItem(product_id='07430500116', + order_qty=1, order_uom=enum.ORDER_UOM_UNIT, + status_code=enum.ORDER_ITEM_STATUS_READY) + order2.items.append(item2) + self.session.flush() + + # its product should now be returned + with patch.object(handler, 'get_product_info_external', return_value={ + 'product_id': '07430500116', + 'scancode': '07430500116', + 'description': 'VINEGAR', + 'unit_price_reg': decimal.Decimal('3.99'), + 'case_size': 12, + }): + products = handler.get_past_products(batch2) + self.assertEqual(len(products), 1) + self.assertEqual(products[0]['product_id'], '07430500116') + self.assertEqual(products[0]['scancode'], '07430500116') + self.assertEqual(products[0]['description'], 'VINEGAR') + self.assertEqual(products[0]['case_price_quoted'], decimal.Decimal('47.88')) + self.assertEqual(products[0]['case_price_quoted_display'], '$47.88') + def test_add_item(self): model = self.app.model enum = self.app.enum diff --git a/tests/web/views/test_orders.py b/tests/web/views/test_orders.py index 827111d..1af1a69 100644 --- a/tests/web/views/test_orders.py +++ b/tests/web/views/test_orders.py @@ -16,6 +16,7 @@ from sideshow.orders import OrderHandler from sideshow.testing import WebTestCase from sideshow.web.views import orders as mod from sideshow.web.forms.schema import OrderRef, PendingProductRef +from sideshow.config import SideshowConfig class TestIncludeme(WebTestCase): @@ -26,6 +27,11 @@ class TestIncludeme(WebTestCase): class TestOrderView(WebTestCase): + def make_config(self, **kw): + config = super().make_config(**kw) + SideshowConfig().configure(config) + return config + def make_view(self): return mod.OrderView(self.request) @@ -661,6 +667,51 @@ class TestOrderView(WebTestCase): context = view.get_product_info(batch, {'product_id': '42'}) self.assertEqual(context, {'error': "something smells fishy"}) + def test_get_past_products(self): + model = self.app.model + enum = self.app.enum + view = self.make_view() + handler = view.batch_handler + + user = model.User(username='barney') + self.session.add(user) + batch = handler.make_batch(self.session, created_by=user) + self.session.add(batch) + self.session.flush() + + # (nb. this all assumes local customers and products) + + # error if no customer + self.assertRaises(ValueError, view.get_past_products, batch, {}) + + # empty history for customer + customer = model.LocalCustomer(full_name='Fred Flintstone') + batch.local_customer = customer + self.session.flush() + products = view.get_past_products(batch, {}) + self.assertEqual(len(products), 0) + + # mock historical order + order = model.Order(order_id=42, local_customer=customer, created_by=user) + product = model.LocalProduct(scancode='07430500132', description='Vinegar', + unit_price_reg=5.99, case_size=12) + item = model.OrderItem(local_product=product, order_qty=1, order_uom=enum.ORDER_UOM_UNIT, + status_code=enum.ORDER_ITEM_STATUS_READY) + order.items.append(item) + self.session.add(order) + self.session.flush() + self.session.refresh(product) + + # that should now be returned + products = view.get_past_products(batch, {}) + self.assertEqual(len(products), 1) + self.assertEqual(products[0]['product_id'], product.uuid.hex) + self.assertEqual(products[0]['scancode'], '07430500132') + self.assertEqual(products[0]['description'], 'Vinegar') + # nb. this is a float, since result is JSON-safe + self.assertEqual(products[0]['case_price_quoted'], 71.88) + self.assertEqual(products[0]['case_price_quoted_display'], '$71.88') + def test_add_item(self): model = self.app.model enum = self.app.enum @@ -911,14 +962,6 @@ class TestOrderView(WebTestCase): 'error': f"ValueError: batch has already been executed: {batch}", }) - def test_get_default_uom_choices(self): - enum = self.app.enum - view = self.make_view() - - uoms = view.get_default_uom_choices() - self.assertEqual(uoms, [{'key': key, 'value': val} - for key, val in enum.ORDER_UOM.items()]) - def test_normalize_batch(self): model = self.app.model enum = self.app.enum