diff --git a/docs/api/sideshow.orders.rst b/docs/api/sideshow.orders.rst new file mode 100644 index 0000000..fd1850e --- /dev/null +++ b/docs/api/sideshow.orders.rst @@ -0,0 +1,6 @@ + +``sideshow.orders`` +=================== + +.. automodule:: sideshow.orders + :members: diff --git a/docs/glossary.rst b/docs/glossary.rst index 0fec4c0..647faa7 100644 --- a/docs/glossary.rst +++ b/docs/glossary.rst @@ -45,12 +45,27 @@ Glossary "submits" the order, the batch is executed which creates a true :term:`order`. + The batch handler is responsible for business logic for the order + creation step; the :term:`order handler` is responsible for + everything thereafter. + + :class:`~sideshow.batch.neworder.NewOrderBatchHandler` is the + default handler for this. + 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 handler + The :term:`handler` responsible for business logic surrounding + :term:`order` workflows *after* initial creation. (Whereas the + :term:`new order batch` handler is responsible for creation.) + + :class:`~sideshow.orders.OrderHandler` is the default handler for + this. + order item This is effectively a "line item" within an :term:`order`. It represents a particular product, with quantity and pricing diff --git a/docs/index.rst b/docs/index.rst index e1673e3..3bc3a5f 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -39,6 +39,7 @@ the narrative docs are pretty scant. That will eventually change. api/sideshow.db.model.orders api/sideshow.db.model.products api/sideshow.enum + api/sideshow.orders api/sideshow.web api/sideshow.web.app api/sideshow.web.forms diff --git a/src/sideshow/batch/neworder.py b/src/sideshow/batch/neworder.py index e2ce3a4..dce3f30 100644 --- a/src/sideshow/batch/neworder.py +++ b/src/sideshow/batch/neworder.py @@ -44,6 +44,9 @@ class NewOrderBatchHandler(BatchHandler): :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. + + After the batch has executed the :term:`order handler` takes over + responsibility for the rest of the order lifecycle. """ model_class = NewOrderBatch diff --git a/src/sideshow/orders.py b/src/sideshow/orders.py new file mode 100644 index 0000000..91425a5 --- /dev/null +++ b/src/sideshow/orders.py @@ -0,0 +1,77 @@ +# -*- 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 . +# +################################################################################ +""" +Sideshow Order Handler +""" + +from wuttjamaican.app import GenericHandler + + +class OrderHandler(GenericHandler): + """ + Base class and default implementation for the :term:`order + handler`. + + This is responsible for business logic involving customer orders + after they have been first created. (The :term:`new order batch` + handler is responsible for creation logic.) + """ + + 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. + + Default logic will return something like ``"3 Cases (x 6 = 18 + Units)"``. + + :param order_qty: Numeric quantity. + + :param order_uom: An order UOM constant; should be something + from :data:`~sideshow.enum.ORDER_UOM`. + + :param case_size: Case size for the product, if known. + + :param html: Whether the return value should include any HTML. + If false (the default), it will be plain text only. If + true, will replace the ``x`` character with ``×``. + + :returns: Display text. + """ + enum = self.app.enum + + if order_uom == enum.ORDER_UOM_CASE: + if case_size is None: + case_qty = unit_qty = '??' + else: + case_qty = self.app.render_quantity(case_size) + unit_qty = self.app.render_quantity(order_qty * case_size) + CS = enum.ORDER_UOM[enum.ORDER_UOM_CASE] + EA = enum.ORDER_UOM[enum.ORDER_UOM_UNIT] + order_qty = self.app.render_quantity(order_qty) + times = '×' if html else 'x' + return (f"{order_qty} {CS} ({times} {case_qty} = {unit_qty} {EA})") + + # units + unit_qty = self.app.render_quantity(order_qty) + EA = enum.ORDER_UOM[enum.ORDER_UOM_UNIT] + return f"{unit_qty} {EA}" diff --git a/src/sideshow/web/templates/order-items/view.mako b/src/sideshow/web/templates/order-items/view.mako new file mode 100644 index 0000000..43bf36e --- /dev/null +++ b/src/sideshow/web/templates/order-items/view.mako @@ -0,0 +1,154 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/view.mako" /> + +<%def name="extra_styles()"> + ${parent.extra_styles()} + + + +<%def name="page_content()"> +
+
+ + + + + +
+
+ + + + + +
+
+ diff --git a/src/sideshow/web/views/orders.py b/src/sideshow/web/views/orders.py index 68ad984..2764737 100644 --- a/src/sideshow/web/views/orders.py +++ b/src/sideshow/web/views/orders.py @@ -34,6 +34,7 @@ from wuttaweb.views import MasterView from wuttaweb.forms.schema import UserRef, WuttaMoney, WuttaQuantity, WuttaEnum, WuttaDictEnum from sideshow.db.model import Order, OrderItem +from sideshow.orders import OrderHandler from sideshow.batch.neworder import NewOrderBatchHandler from sideshow.web.forms.schema import (OrderRef, LocalCustomerRef, LocalProductRef, @@ -58,10 +59,16 @@ class OrderView(MasterView): Note that the "edit" view is not exposed here; user must perform various other workflow actions to modify the order. + .. attribute:: order_handler + + Reference to the :term:`order handler` as returned by + :meth:`get_order_handler()`. This gets set in the constructor. + .. attribute:: batch_handler - Reference to the 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, as returned + by :meth:`get_batch_handler()`. This gets set in the + constructor. """ model_class = Order editable = False @@ -142,21 +149,22 @@ class OrderView(MasterView): 'unit_price_reg', ] - def configure_grid(self, g): - """ """ - super().configure_grid(g) + def __init__(self, request, context=None): + super().__init__(request, context=context) + self.order_handler = self.get_order_handler() - # order_id - g.set_link('order_id') + def get_order_handler(self): + """ + Returns the configured :term:`order handler`. - # customer_id - g.set_link('customer_id') + You normally would not need to call this, and can use + :attr:`order_handler` instead. - # customer_name - g.set_link('customer_name') - - # total_price - g.set_renderer('total_price', g.render_currency) + :rtype: :class:`~sideshow.orders.OrderHandler` + """ + if hasattr(self, 'order_handler'): + return self.order_handler + return OrderHandler(self.config) def get_batch_handler(self): """ @@ -174,6 +182,22 @@ class OrderView(MasterView): return self.batch_handler return self.app.get_batch_handler('neworder') + 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" @@ -670,8 +694,6 @@ class OrderView(MasterView): def normalize_row(self, row): """ """ - enum = self.app.enum - data = { 'uuid': row.uuid.hex, 'sequence': row.sequence, @@ -750,21 +772,8 @@ class OrderView(MasterView): } # display text for order qty/uom - if row.order_uom == enum.ORDER_UOM_CASE: - order_qty = self.app.render_quantity(row.order_qty) - if row.case_size is None: - case_qty = unit_qty = '??' - else: - case_qty = self.app.render_quantity(row.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"{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}" + data['order_qty_display'] = self.order_handler.get_order_qty_uom_text( + row.order_qty, row.order_uom, case_size=row.case_size, html=True) return data @@ -823,7 +832,7 @@ class OrderView(MasterView): def configure_row_grid(self, g): """ """ super().configure_row_grid(g) - enum = self.app.enum + # enum = self.app.enum # sequence g.set_label('sequence', "Seq.", column_only=True) @@ -959,6 +968,11 @@ class OrderItemView(MasterView): Note that this does not expose create, edit or delete. The user must perform various other workflow actions to modify the item. + + .. attribute:: order_handler + + Reference to the :term:`order handler` as returned by + :meth:`get_order_handler()`. """ model_class = OrderItem model_title = "Order Item" @@ -1030,6 +1044,23 @@ class OrderItemView(MasterView): 'payment_transaction_number', ] + def __init__(self, request, context=None): + super().__init__(request, context=context) + self.order_handler = self.get_order_handler() + + def get_order_handler(self): + """ + Returns the configured :term:`order handler`. + + You normally would not need to call this, and can use + :attr:`order_handler` instead. + + :rtype: :class:`~sideshow.orders.OrderHandler` + """ + if hasattr(self, 'order_handler'): + return self.order_handler + return OrderHandler(self.config) + def get_query(self, session=None): """ """ query = super().get_query(session=session) @@ -1139,6 +1170,17 @@ class OrderItemView(MasterView): # paid_amount f.set_node('paid_amount', WuttaMoney(self.request)) + def get_template_context(self, context): + """ """ + if self.viewing: + item = context['instance'] + context['item'] = item + context['order'] = item.order + context['order_qty_uom_text'] = self.order_handler.get_order_qty_uom_text( + item.order_qty, item.order_uom, case_size=item.case_size, html=True) + + return context + def get_xref_buttons(self, item): """ """ buttons = super().get_xref_buttons(item) diff --git a/tests/test_orders.py b/tests/test_orders.py new file mode 100644 index 0000000..0a0483a --- /dev/null +++ b/tests/test_orders.py @@ -0,0 +1,40 @@ +# -*- coding: utf-8; -*- + +from wuttjamaican.testing import DataTestCase + +from sideshow import orders as mod + + +class TestOrderHandler(DataTestCase): + + def make_config(self, **kwargs): + config = super().make_config(**kwargs) + config.setdefault('wutta.enum_spec', 'sideshow.enum') + return config + + def make_handler(self): + return mod.OrderHandler(self.config) + + def test_get_order_qty_uom_text(self): + enum = self.app.enum + handler = self.make_handler() + + # typical, plain text + text = handler.get_order_qty_uom_text(2, enum.ORDER_UOM_CASE, case_size=12) + self.assertEqual(text, "2 Cases (x 12 = 24 Units)") + + # typical w/ html + text = handler.get_order_qty_uom_text(2, enum.ORDER_UOM_CASE, case_size=12, html=True) + self.assertEqual(text, "2 Cases (× 12 = 24 Units)") + + # unknown case size + text = handler.get_order_qty_uom_text(2, enum.ORDER_UOM_CASE) + self.assertEqual(text, "2 Cases (x ?? = ?? Units)") + text = handler.get_order_qty_uom_text(2, enum.ORDER_UOM_CASE, html=True) + self.assertEqual(text, "2 Cases (× ?? = ?? Units)") + + # units only + text = handler.get_order_qty_uom_text(2, enum.ORDER_UOM_UNIT) + self.assertEqual(text, "2 Units") + text = handler.get_order_qty_uom_text(2, enum.ORDER_UOM_UNIT, html=True) + self.assertEqual(text, "2 Units") diff --git a/tests/web/views/test_orders.py b/tests/web/views/test_orders.py index 89d5039..0ea6be6 100644 --- a/tests/web/views/test_orders.py +++ b/tests/web/views/test_orders.py @@ -11,6 +11,7 @@ from pyramid.response import Response from wuttaweb.forms.schema import WuttaMoney from sideshow.batch.neworder import NewOrderBatchHandler +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 @@ -30,6 +31,13 @@ class TestOrderView(WebTestCase): def make_handler(self): return NewOrderBatchHandler(self.config) + def test_order_handler(self): + view = self.make_view() + handler = view.order_handler + self.assertIsInstance(handler, OrderHandler) + handler2 = view.get_order_handler() + self.assertIs(handler2, handler) + def test_configure_grid(self): model = self.app.model view = self.make_view() @@ -1249,6 +1257,13 @@ class TestOrderItemView(WebTestCase): def make_view(self): return mod.OrderItemView(self.request) + def test_order_handler(self): + view = self.make_view() + handler = view.order_handler + self.assertIsInstance(handler, OrderHandler) + handler2 = view.get_order_handler() + self.assertIs(handler2, handler) + def test_get_query(self): view = self.make_view() query = view.get_query(session=self.session) @@ -1314,6 +1329,24 @@ class TestOrderItemView(WebTestCase): self.assertIsInstance(schema['order'].typ, OrderRef) self.assertNotIn('pending_product', form) + def test_get_template_context(self): + model = self.app.model + enum = self.app.enum + view = self.make_view() + + order = model.Order() + item = model.OrderItem(order_qty=2, order_uom=enum.ORDER_UOM_CASE, case_size=8) + order.items.append(item) + + with patch.object(view, 'viewing', new=True): + context = view.get_template_context({'instance': item}) + self.assertIn('item', context) + self.assertIs(context['item'], item) + self.assertIn('order', context) + self.assertIs(context['order'], order) + self.assertIn('order_qty_uom_text', context) + self.assertEqual(context['order_qty_uom_text'], "2 Cases (× 8 = 16 Units)") + def test_get_xref_buttons(self): self.pyramid_config.add_route('orders.view', '/orders/{uuid}') model = self.app.model