From b4deea76e0973a9cbb33c510770846a4e17fb701 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 15 Jan 2025 19:25:45 -0600 Subject: [PATCH] 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}')