From 13d576295e6da8130f70b765c60f8f978a4c17da Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 15 Jan 2025 14:55:27 -0600 Subject: [PATCH 1/6] fix: add loading overlay for expensive calls in orders/create --- src/sideshow/web/templates/orders/create.mako | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/src/sideshow/web/templates/orders/create.mako b/src/sideshow/web/templates/orders/create.mako index 7ed78db..28f4e6b 100644 --- a/src/sideshow/web/templates/orders/create.mako +++ b/src/sideshow/web/templates/orders/create.mako @@ -81,6 +81,7 @@
+
@@ -143,7 +144,7 @@ icon-pack="fas" icon-left="redo" :disabled="refreshingCustomer"> - {{ refreshingCustomer ? "Refreshing" : "Refresh" }} + {{ refreshingCustomer ? "Working, please wait..." : "Refresh" }}
@@ -348,6 +349,7 @@ >
+ <${b}-tabs :animated="false" % if request.use_oruga: @@ -829,6 +831,7 @@ batchTotalPriceDisplay: ${json.dumps(normalized_batch['total_price_display'])|n}, customerPanelOpen: false, + customerLoading: false, customerIsKnown: ${json.dumps(customer_is_known)|n}, customerID: ${json.dumps(customer_id)|n}, customerName: ${json.dumps(customer_name)|n}, @@ -853,6 +856,7 @@ editItemRow: null, editItemShowDialog: false, + editItemLoading: false, itemDialogSaving: false, % if request.use_oruga: itemDialogTab: 'product', @@ -1132,6 +1136,7 @@ }, customerChanged(customerID, callback) { + this.customerLoading = true const params = {} if (customerID) { @@ -1149,7 +1154,9 @@ if (callback) { callback() } + this.customerLoading = false }, response => { + this.customerLoading = false this.$buefy.toast.open({ message: "Update failed: " + (response.data.error || "(unknown error)"), type: 'is-danger', @@ -1159,9 +1166,9 @@ }, refreshCustomer() { - this.refreshingContact = true + this.refreshingCustomer = true this.customerChanged(this.customerID, () => { - this.refreshingContact = false + this.refreshingCustomer = false this.$buefy.toast.open({ message: "Customer info has been refreshed.", type: 'is-success', @@ -1321,6 +1328,7 @@ productChanged(productID) { if (productID) { + this.editItemLoading = true const params = { action: 'get_product_info', product_id: productID, @@ -1364,8 +1372,11 @@ this.refreshProductDescription += 1 this.refreshTotalPrice += 1 + this.editItemLoading = false + }, response => { this.clearProduct() + this.editItemLoading = false }) } else { this.clearProduct() @@ -1514,6 +1525,7 @@ itemDialogAttemptSave() { this.itemDialogSaving = true + this.editItemLoading = true const params = { order_qty: this.productQuantity, @@ -1551,9 +1563,11 @@ this.batchTotalPriceDisplay = response.data.batch.total_price_display this.itemDialogSaving = false + this.editItemLoading = false this.editItemShowDialog = false }, response => { this.itemDialogSaving = false + this.editItemLoading = false }) }, From c79b0262f39d70bd4c7b5af74f562b8775860f4d Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 15 Jan 2025 16:57:28 -0600 Subject: [PATCH 2/6] fix: customize "view order item" page w/ panels more to come soon.. --- docs/api/sideshow.orders.rst | 6 + docs/glossary.rst | 15 ++ docs/index.rst | 1 + src/sideshow/batch/neworder.py | 3 + src/sideshow/orders.py | 77 +++++++++ .../web/templates/order-items/view.mako | 154 ++++++++++++++++++ src/sideshow/web/views/orders.py | 106 ++++++++---- tests/test_orders.py | 40 +++++ tests/web/views/test_orders.py | 33 ++++ 9 files changed, 403 insertions(+), 32 deletions(-) create mode 100644 docs/api/sideshow.orders.rst create mode 100644 src/sideshow/orders.py create mode 100644 src/sideshow/web/templates/order-items/view.mako create mode 100644 tests/test_orders.py 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 From b4deea76e0973a9cbb33c510770846a4e17fb701 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 15 Jan 2025 19:25:45 -0600 Subject: [PATCH 3/6] feat: add initial support for order item events so far just attaching events on creation, but then can view them --- src/sideshow/batch/neworder.py | 28 +- .../7a6df83afbd4_initial_order_tables.py | 16 ++ src/sideshow/db/model/__init__.py | 3 +- src/sideshow/db/model/orders.py | 61 ++++- src/sideshow/enum.py | 246 +++++++++++++++++- .../web/templates/order-items/view.mako | 38 ++- src/sideshow/web/views/orders.py | 28 ++ tests/batch/test_neworder.py | 14 + tests/db/model/test_orders.py | 17 ++ tests/web/views/test_orders.py | 18 +- 10 files changed, 456 insertions(+), 13 deletions(-) diff --git a/src/sideshow/batch/neworder.py b/src/sideshow/batch/neworder.py index dce3f30..6295407 100644 --- a/src/sideshow/batch/neworder.py +++ b/src/sideshow/batch/neworder.py @@ -994,9 +994,35 @@ class NewOrderBatchHandler(BatchHandler): order.items.append(item) # set item status - item.status_code = enum.ORDER_ITEM_STATUS_INITIATED + self.set_initial_item_status(item, user) self.app.progress_loop(convert, rows, progress, message="Converting batch rows to order items") session.flush() return order + + def set_initial_item_status(self, item, user, **kwargs): + """ + Set the initial status and attach event(s) for the given item. + + This is called from :meth:`make_new_order()` for each item + after it is added to the order. + + Default logic will set status to + :data:`~sideshow.enum.ORDER_ITEM_STATUS_READY` and attach 2 + events: + + * :data:`~sideshow.enum.ORDER_ITEM_EVENT_INITIATED` + * :data:`~sideshow.enum.ORDER_ITEM_EVENT_READY` + + :param item: :class:`~sideshow.db.model.orders.OrderItem` + being added to the new order. + + :param user: + :class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who + is performing the action. + """ + enum = self.app.enum + item.add_event(enum.ORDER_ITEM_EVENT_INITIATED, user) + item.add_event(enum.ORDER_ITEM_EVENT_READY, user) + item.status_code = enum.ORDER_ITEM_STATUS_READY diff --git a/src/sideshow/db/alembic/versions/7a6df83afbd4_initial_order_tables.py b/src/sideshow/db/alembic/versions/7a6df83afbd4_initial_order_tables.py index be6eee8..6a0a589 100644 --- a/src/sideshow/db/alembic/versions/7a6df83afbd4_initial_order_tables.py +++ b/src/sideshow/db/alembic/versions/7a6df83afbd4_initial_order_tables.py @@ -156,6 +156,19 @@ def upgrade() -> None: sa.PrimaryKeyConstraint('uuid', name=op.f('pk_order_item')) ) + # sideshow_order_item_event + op.create_table('sideshow_order_item_event', + sa.Column('uuid', wuttjamaican.db.util.UUID(), nullable=False), + sa.Column('item_uuid', wuttjamaican.db.util.UUID(), nullable=False), + sa.Column('type_code', sa.Integer(), nullable=False), + sa.Column('occurred', sa.DateTime(timezone=True), nullable=False), + sa.Column('actor_uuid', wuttjamaican.db.util.UUID(), nullable=False), + sa.Column('note', sa.Text(), nullable=True), + sa.ForeignKeyConstraint(['actor_uuid'], ['user.uuid'], name=op.f('fk_sideshow_order_item_event_actor_uuid_user')), + sa.ForeignKeyConstraint(['item_uuid'], ['sideshow_order_item.uuid'], name=op.f('fk_sideshow_order_item_event_item_uuid_sideshow_order_item')), + sa.PrimaryKeyConstraint('uuid', name=op.f('pk_sideshow_order_item_event')) + ) + # sideshow_batch_neworder op.create_table('sideshow_batch_neworder', sa.Column('uuid', wuttjamaican.db.util.UUID(), nullable=False), @@ -227,6 +240,9 @@ def downgrade() -> None: op.drop_table('sideshow_batch_neworder_row') op.drop_table('sideshow_batch_neworder') + # sideshow_order_item_event + op.drop_table('sideshow_order_item_event') + # sideshow_order_item op.drop_table('sideshow_order_item') diff --git a/src/sideshow/db/model/__init__.py b/src/sideshow/db/model/__init__.py index 28d09f3..f53dd27 100644 --- a/src/sideshow/db/model/__init__.py +++ b/src/sideshow/db/model/__init__.py @@ -32,6 +32,7 @@ Primary :term:`data models `: * :class:`~sideshow.db.model.orders.Order` * :class:`~sideshow.db.model.orders.OrderItem` +* :class:`~sideshow.db.model.orders.OrderItemEvent` * :class:`~sideshow.db.model.customers.LocalCustomer` * :class:`~sideshow.db.model.products.LocalProduct` * :class:`~sideshow.db.model.customers.PendingCustomer` @@ -49,7 +50,7 @@ from wuttjamaican.db.model import * # sideshow models from .customers import LocalCustomer, PendingCustomer from .products import LocalProduct, PendingProduct -from .orders import Order, OrderItem +from .orders import Order, OrderItem, OrderItemEvent # batch models from .batch.neworder import NewOrderBatch, NewOrderBatchRow diff --git a/src/sideshow/db/model/orders.py b/src/sideshow/db/model/orders.py index f694028..2cadeaa 100644 --- a/src/sideshow/db/model/orders.py +++ b/src/sideshow/db/model/orders.py @@ -2,7 +2,7 @@ ################################################################################ # # Sideshow -- Case/Special Order Tracker -# Copyright © 2024 Lance Edgar +# Copyright © 2024-2025 Lance Edgar # # This file is part of Sideshow. # @@ -332,6 +332,16 @@ class OrderItem(model.Base): applicable/known. """) + events = orm.relationship( + 'OrderItemEvent', + order_by='OrderItemEvent.occurred, OrderItemEvent.uuid', + cascade='all, delete-orphan', + cascade_backrefs=False, + back_populates='item', + doc=""" + List of :class:`OrderItemEvent` records for the item. + """) + @property def full_description(self): """ """ @@ -344,3 +354,52 @@ class OrderItem(model.Base): def __str__(self): return self.full_description + + def add_event(self, type_code, user, **kwargs): + """ + Convenience method to add a new :class:`OrderItemEvent` for + the item. + """ + kwargs['type_code'] = type_code + kwargs['actor'] = user + self.events.append(OrderItemEvent(**kwargs)) + + +class OrderItemEvent(model.Base): + """ + An event in the life of an :term:`order item`. + """ + __tablename__ = 'sideshow_order_item_event' + + uuid = model.uuid_column() + + item_uuid = model.uuid_fk_column('sideshow_order_item.uuid', nullable=False) + item = orm.relationship( + OrderItem, + cascade_backrefs=False, + back_populates='events', + doc=""" + Reference to the :class:`OrderItem` to which the event + pertains. + """) + + type_code = sa.Column(sa.Integer, nullable=False, doc=""" + Code indicating the type of event; values must be defined in + :data:`~sideshow.enum.ORDER_ITEM_EVENT`. + """) + + occurred = sa.Column(sa.DateTime(timezone=True), nullable=False, default=datetime.datetime.now, doc=""" + Date and time when the event occurred. + """) + + actor_uuid = model.uuid_fk_column('user.uuid', nullable=False) + actor = orm.relationship( + model.User, + doc=""" + :class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who + performed the action. + """) + + note = sa.Column(sa.Text(), nullable=True, doc=""" + Optional note recorded for the event. + """) diff --git a/src/sideshow/enum.py b/src/sideshow/enum.py index 2bd1e1a..66c79ea 100644 --- a/src/sideshow/enum.py +++ b/src/sideshow/enum.py @@ -2,7 +2,7 @@ ################################################################################ # # Sideshow -- Case/Special Order Tracker -# Copyright © 2024 Lance Edgar +# Copyright © 2024-2025 Lance Edgar # # This file is part of Sideshow. # @@ -108,23 +108,101 @@ class PendingProductStatus(Enum): ######################################## ORDER_ITEM_STATUS_UNINITIATED = 1 +""" +Indicates the item is "not yet initiated" - this probably is not +useful but exists as a possibility just in case. +""" + ORDER_ITEM_STATUS_INITIATED = 10 +""" +Indicates the item is "initiated" (aka. created) but not yet "ready" +for buyer/PO. This may imply the price needs confirmation etc. +""" + ORDER_ITEM_STATUS_PAID_BEFORE = 50 +""" +Indicates the customer has fully paid for the item, up-front before +the buyer places PO etc. It implies the item is not yet "ready" for +some reason. +""" + # TODO: deprecate / remove this one ORDER_ITEM_STATUS_PAID = ORDER_ITEM_STATUS_PAID_BEFORE + ORDER_ITEM_STATUS_READY = 100 +""" +Indicates the item is "ready" for buyer to include it on a vendor +purchase order. +""" + ORDER_ITEM_STATUS_PLACED = 200 +""" +Indicates the buyer has placed a vendor purchase order which includes +this item. The item is thereby "on order" until the truck arrives. +""" + ORDER_ITEM_STATUS_RECEIVED = 300 +""" +Indicates the item has been received as part of a vendor delivery. +The item is thereby "on hand" until customer comes in for pickup. +""" + ORDER_ITEM_STATUS_CONTACTED = 350 +""" +Indicates the customer has been notified that the item is "on hand" +and awaiting their pickup. +""" + ORDER_ITEM_STATUS_CONTACT_FAILED = 375 +""" +Indicates the attempt(s) to notify customer have failed. The item is +on hand but the customer does not know to pickup. +""" + ORDER_ITEM_STATUS_DELIVERED = 500 +""" +Indicates the customer has picked up the item. +""" + ORDER_ITEM_STATUS_PAID_AFTER = 550 +""" +Indicates the customer has fully paid for the item, as part of their +pickup. This completes the cycle for orders which require payment on +the tail end. +""" + ORDER_ITEM_STATUS_CANCELED = 900 +""" +Indicates the order item has been canceled. +""" + ORDER_ITEM_STATUS_REFUND_PENDING = 910 +""" +Indicates the order item has been canceled, and the customer is due a +(pending) refund. +""" + ORDER_ITEM_STATUS_REFUNDED = 920 +""" +Indicates the order item has been canceled, and the customer has been +given a refund. +""" + ORDER_ITEM_STATUS_RESTOCKED = 930 +""" +Indicates the product has been restocked, e.g. after the order item +was canceled. +""" + ORDER_ITEM_STATUS_EXPIRED = 940 +""" +Indicates the order item and/or product has expired. +""" + ORDER_ITEM_STATUS_INACTIVE = 950 +""" +Indicates the order item has become inactive. +""" ORDER_ITEM_STATUS = OrderedDict([ (ORDER_ITEM_STATUS_UNINITIATED, "uninitiated"), @@ -144,3 +222,169 @@ ORDER_ITEM_STATUS = OrderedDict([ (ORDER_ITEM_STATUS_EXPIRED, "expired"), (ORDER_ITEM_STATUS_INACTIVE, "inactive"), ]) +""" +Dict of possible code -> label options for :term:`order item` status. + +These codes are referenced by: + +* :attr:`sideshow.db.model.orders.OrderItem.status_code` +""" + + +######################################## +# Order Item Event Type +######################################## + +ORDER_ITEM_EVENT_INITIATED = 10 +""" +Indicates the item was "initiated" - this occurs when the +:term:`order` is first created. +""" + +ORDER_ITEM_EVENT_PRICE_CONFIRMED = 20 +""" +Indicates the item's price was confirmed by a user who is authorized +to do that. +""" + +ORDER_ITEM_EVENT_PAYMENT_RECEIVED = 50 +""" +Indicates payment was received for the item. This may occur toward +the beginning, or toward the end, of the item's life cycle depending +on app configuration etc. +""" + +# TODO: deprecate / remove this +ORDER_ITEM_EVENT_PAID = ORDER_ITEM_EVENT_PAYMENT_RECEIVED + +ORDER_ITEM_EVENT_READY = 100 +""" +Indicates the item has become "ready" for buyer placement on a new +vendor purchase order. Often this will occur when the :term:`order` +is first created, if the data is suitable. However this may be +delayed if e.g. the price needs confirmation. +""" + +ORDER_ITEM_EVENT_CUSTOMER_RESOLVED = 120 +""" +Indicates the customer for the :term:`order` has been assigned to a +"proper" (existing) account. This may happen (after the fact) if the +order was first created with a new/unknown customer. +""" + +ORDER_ITEM_EVENT_PRODUCT_RESOLVED = 140 +""" +Indicates the product for the :term:`order item` has been assigned to +a "proper" (existing) product record. This may happen (after the +fact) if the order was first created with a new/unknown product. +""" + +ORDER_ITEM_EVENT_PLACED = 200 +""" +Indicates the buyer has placed a vendor purchase order which includes +this item. So the item is "on order" until the truck arrives. +""" + +ORDER_ITEM_EVENT_RECEIVED = 300 +""" +Indicates the receiver has found the item while receiving a vendor +delivery. The item is set aside and is "on hand" until customer comes +in to pick it up. +""" + +ORDER_ITEM_EVENT_CONTACTED = 350 +""" +Indicates the customer has been contacted, to notify them of the item +being on hand and ready for pickup. +""" + +ORDER_ITEM_EVENT_CONTACT_FAILED = 375 +""" +Indicates an attempt was made to contact the customer, to notify them +of item being on hand, but the attempt failed, e.g. due to bad phone +or email on file. +""" + +ORDER_ITEM_EVENT_DELIVERED = 500 +""" +Indicates the customer has picked up the item. +""" + +ORDER_ITEM_EVENT_STATUS_CHANGE = 700 +""" +Indicates a manual status change was made. Such an event should +ideally contain a note with further explanation. +""" + +ORDER_ITEM_EVENT_NOTE_ADDED = 750 +""" +Indicates an arbitrary note was added. +""" + +ORDER_ITEM_EVENT_CANCELED = 900 +""" +Indicates the :term:`order item` was canceled. +""" + +ORDER_ITEM_EVENT_REFUND_PENDING = 910 +""" +Indicates the customer is due a (pending) refund for the item. +""" + +ORDER_ITEM_EVENT_REFUNDED = 920 +""" +Indicates the customer has been refunded for the item. +""" + +ORDER_ITEM_EVENT_RESTOCKED = 930 +""" +Indicates the product has been restocked, e.g. due to the order item +being canceled. +""" + +ORDER_ITEM_EVENT_EXPIRED = 940 +""" +Indicates the order item (or its product) has expired. +""" + +ORDER_ITEM_EVENT_INACTIVE = 950 +""" +Indicates the order item has become inactive. +""" + +ORDER_ITEM_EVENT_OTHER = 999 +""" +Arbitrary event type which does not signify anything in particular. +If used, the event should be given an explanatory note. +""" + +ORDER_ITEM_EVENT = OrderedDict([ + (ORDER_ITEM_EVENT_INITIATED, "initiated"), + (ORDER_ITEM_EVENT_PRICE_CONFIRMED, "price confirmed"), + (ORDER_ITEM_EVENT_PAYMENT_RECEIVED, "payment received"), + (ORDER_ITEM_EVENT_READY, "ready to proceed"), + (ORDER_ITEM_EVENT_CUSTOMER_RESOLVED, "customer resolved"), + (ORDER_ITEM_EVENT_PRODUCT_RESOLVED, "product resolved"), + (ORDER_ITEM_EVENT_PLACED, "placed with vendor"), + (ORDER_ITEM_EVENT_RECEIVED, "received from vendor"), + (ORDER_ITEM_EVENT_CONTACTED, "customer contacted"), + (ORDER_ITEM_EVENT_CONTACT_FAILED, "contact failed"), + (ORDER_ITEM_EVENT_DELIVERED, "delivered"), + (ORDER_ITEM_EVENT_STATUS_CHANGE, "status change"), + (ORDER_ITEM_EVENT_NOTE_ADDED, "note added"), + (ORDER_ITEM_EVENT_CANCELED, "canceled"), + (ORDER_ITEM_EVENT_REFUND_PENDING, "refund pending"), + (ORDER_ITEM_EVENT_REFUNDED, "refunded"), + (ORDER_ITEM_EVENT_RESTOCKED, "restocked"), + (ORDER_ITEM_EVENT_EXPIRED, "expired"), + (ORDER_ITEM_EVENT_INACTIVE, "inactive"), + (ORDER_ITEM_EVENT_OTHER, "other"), +]) +""" +Dict of possible code -> label options for :term:`order item` event +types. + +These codes are referenced by: + +* :attr:`sideshow.db.model.orders.OrderItemEvent.type_code` +""" diff --git a/src/sideshow/web/templates/order-items/view.mako b/src/sideshow/web/templates/order-items/view.mako index 43bf36e..da161f9 100644 --- a/src/sideshow/web/templates/order-items/view.mako +++ b/src/sideshow/web/templates/order-items/view.mako @@ -22,7 +22,7 @@
- Order ID ${order.order_id} — Item #${item.sequence} + ${h.link_to(f"Order ID {order.order_id}", url('orders.view', uuid=order.uuid))} — Item #${item.sequence} ${order_qty_uom_text|n} @@ -151,4 +151,40 @@
+ +
+ +
+ + + +<%def name="render_vue_templates()"> + ${parent.render_vue_templates()} + ${events_grid.render_vue_template()} + + +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + + + +<%def name="make_vue_components()"> + ${parent.make_vue_components()} + ${events_grid.render_vue_finalize()} diff --git a/src/sideshow/web/views/orders.py b/src/sideshow/web/views/orders.py index 2764737..f60f11c 100644 --- a/src/sideshow/web/views/orders.py +++ b/src/sideshow/web/views/orders.py @@ -30,6 +30,8 @@ import logging import colander from sqlalchemy import orm +from webhelpers2.html import tags + from wuttaweb.views import MasterView from wuttaweb.forms.schema import UserRef, WuttaMoney, WuttaQuantity, WuttaEnum, WuttaDictEnum @@ -1173,12 +1175,38 @@ class OrderItemView(MasterView): def get_template_context(self, context): """ """ if self.viewing: + model = self.app.model + enum = self.app.enum + route_prefix = self.get_route_prefix() item = context['instance'] + form = context['form'] + 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) + grid = self.make_grid(key=f'{route_prefix}.view.events', + model_class=model.OrderItemEvent, + data=item.events, + columns=[ + 'occurred', + 'actor', + 'type_code', + 'note', + ], + labels={ + 'occurred': "Date/Time", + 'actor': "User", + 'type_code': "Event Type", + }) + grid.set_renderer('type_code', lambda e, k, v: enum.ORDER_ITEM_EVENT[v]) + if self.request.has_perm('users.view'): + grid.set_renderer('actor', lambda e, k, v: tags.link_to( + e.actor, self.request.route_url('users.view', uuid=e.actor.uuid))) + form.add_grid_vue_context(grid) + context['events_grid'] = grid + return context def get_xref_buttons(self, item): diff --git a/tests/batch/test_neworder.py b/tests/batch/test_neworder.py index b3fbf4a..42e42dc 100644 --- a/tests/batch/test_neworder.py +++ b/tests/batch/test_neworder.py @@ -1109,6 +1109,20 @@ class TestNewOrderBatchHandler(DataTestCase): self.assertEqual(item.unit_cost, decimal.Decimal('3.99')) self.assertEqual(item.unit_price_reg, decimal.Decimal('5.99')) + def test_set_initial_item_status(self): + model = self.app.model + enum = self.app.enum + handler = self.make_handler() + user = model.User(username='barney') + item = model.OrderItem() + self.assertIsNone(item.status_code) + self.assertEqual(len(item.events), 0) + handler.set_initial_item_status(item, user) + self.assertEqual(item.status_code, enum.ORDER_ITEM_STATUS_READY) + self.assertEqual(len(item.events), 2) + self.assertEqual(item.events[0].type_code, enum.ORDER_ITEM_EVENT_INITIATED) + self.assertEqual(item.events[1].type_code, enum.ORDER_ITEM_EVENT_READY) + def test_execute(self): model = self.app.model enum = self.app.enum diff --git a/tests/db/model/test_orders.py b/tests/db/model/test_orders.py index 7169991..21ee153 100644 --- a/tests/db/model/test_orders.py +++ b/tests/db/model/test_orders.py @@ -19,6 +19,11 @@ class TestOrder(DataTestCase): class TestOrderItem(DataTestCase): + def make_config(self, **kw): + config = super().make_config(**kw) + config.setdefault('wutta.enum_spec', 'sideshow.enum') + return config + def test_full_description(self): item = mod.OrderItem() @@ -44,3 +49,15 @@ class TestOrderItem(DataTestCase): product_description='Vinegar', product_size='32oz') self.assertEqual(str(item), "Bragg Vinegar 32oz") + + def test_add_event(self): + model = self.app.model + enum = self.app.enum + user = model.User(username='barney') + item = mod.OrderItem() + self.assertEqual(item.events, []) + item.add_event(enum.ORDER_ITEM_EVENT_INITIATED, user) + item.add_event(enum.ORDER_ITEM_EVENT_READY, user) + self.assertEqual(len(item.events), 2) + self.assertEqual(item.events[0].type_code, enum.ORDER_ITEM_EVENT_INITIATED) + self.assertEqual(item.events[1].type_code, enum.ORDER_ITEM_EVENT_READY) diff --git a/tests/web/views/test_orders.py b/tests/web/views/test_orders.py index 0ea6be6..d01c90b 100644 --- a/tests/web/views/test_orders.py +++ b/tests/web/views/test_orders.py @@ -1338,14 +1338,16 @@ class TestOrderItemView(WebTestCase): 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)") + with patch.object(self.request, 'is_root', new=True): + with patch.object(view, 'viewing', new=True): + form = view.make_model_form(model_instance=item) + context = view.get_template_context({'instance': item, 'form': form}) + 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}') From 9d378a0c5fd562a4535babc1973c9cb5f8136e72 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 15 Jan 2025 21:49:17 -0600 Subject: [PATCH 4/6] feat: add tools to change order item status; add notes --- src/sideshow/enum.py | 4 +- src/sideshow/orders.py | 21 ++ .../web/templates/order-items/view.mako | 227 +++++++++++++++++- src/sideshow/web/views/orders.py | 137 ++++++++++- tests/test_orders.py | 20 ++ tests/web/views/test_orders.py | 129 +++++++++- 6 files changed, 520 insertions(+), 18 deletions(-) diff --git a/src/sideshow/enum.py b/src/sideshow/enum.py index 66c79ea..2349f3d 100644 --- a/src/sideshow/enum.py +++ b/src/sideshow/enum.py @@ -370,8 +370,8 @@ ORDER_ITEM_EVENT = OrderedDict([ (ORDER_ITEM_EVENT_CONTACTED, "customer contacted"), (ORDER_ITEM_EVENT_CONTACT_FAILED, "contact failed"), (ORDER_ITEM_EVENT_DELIVERED, "delivered"), - (ORDER_ITEM_EVENT_STATUS_CHANGE, "status change"), - (ORDER_ITEM_EVENT_NOTE_ADDED, "note added"), + (ORDER_ITEM_EVENT_STATUS_CHANGE, "changed status"), + (ORDER_ITEM_EVENT_NOTE_ADDED, "added note"), (ORDER_ITEM_EVENT_CANCELED, "canceled"), (ORDER_ITEM_EVENT_REFUND_PENDING, "refund pending"), (ORDER_ITEM_EVENT_REFUNDED, "refunded"), diff --git a/src/sideshow/orders.py b/src/sideshow/orders.py index 91425a5..0434782 100644 --- a/src/sideshow/orders.py +++ b/src/sideshow/orders.py @@ -75,3 +75,24 @@ class OrderHandler(GenericHandler): unit_qty = self.app.render_quantity(order_qty) EA = enum.ORDER_UOM[enum.ORDER_UOM_UNIT] return f"{unit_qty} {EA}" + + def item_status_to_variant(self, status_code): + """ + Return a Buefy style variant for the given status code. + + Default logic will return ``None`` for "normal" item status, + but may return ``'warning'`` for some (e.g. canceled). + + :param status_code: The status code for an order item. + + :returns: Style variant string (e.g. ``'warning'``) or + ``None``. + """ + enum = self.app.enum + if status_code in (enum.ORDER_ITEM_STATUS_CANCELED, + enum.ORDER_ITEM_STATUS_REFUND_PENDING, + enum.ORDER_ITEM_STATUS_REFUNDED, + enum.ORDER_ITEM_STATUS_RESTOCKED, + enum.ORDER_ITEM_STATUS_EXPIRED, + enum.ORDER_ITEM_STATUS_INACTIVE): + return 'warning' diff --git a/src/sideshow/web/templates/order-items/view.mako b/src/sideshow/web/templates/order-items/view.mako index da161f9..201540a 100644 --- a/src/sideshow/web/templates/order-items/view.mako +++ b/src/sideshow/web/templates/order-items/view.mako @@ -1,11 +1,16 @@ ## -*- coding: utf-8; -*- <%inherit file="/master/view.mako" /> +<%def name="content_title()"> + (${app.enum.ORDER_ITEM_STATUS[item.status_code]}) + ${instance_title} + + <%def name="extra_styles()"> ${parent.extra_styles()}