fix: customize "view order item" page w/ panels

more to come soon..
This commit is contained in:
Lance Edgar 2025-01-15 16:57:28 -06:00
parent 13d576295e
commit c79b0262f3
9 changed files with 403 additions and 32 deletions

View file

@ -0,0 +1,6 @@
``sideshow.orders``
===================
.. automodule:: sideshow.orders
:members:

View file

@ -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 item>`.
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

View file

@ -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

View file

@ -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

77
src/sideshow/orders.py Normal file
View file

@ -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 <http://www.gnu.org/licenses/>.
#
################################################################################
"""
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 ``&times;``.
: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 = '&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}"

View file

@ -0,0 +1,154 @@
## -*- coding: utf-8; -*-
<%inherit file="/master/view.mako" />
<%def name="extra_styles()">
${parent.extra_styles()}
<style>
.field .field-label .label {
white-space: nowrap;
width: 10rem;
}
</style>
</%def>
<%def name="page_content()">
<div style="padding: 2rem; display: flex; justify-content: space-evenly; gap: 2rem;">
<div style="flex-grow: 1;">
<nav class="panel" style="width: 100%;">
<p class="panel-heading">Order Item</p>
<div class="panel-block">
<div style="width: 100%;">
<b-field horizontal label="ID">
<span>Order ID ${order.order_id} &mdash; Item #${item.sequence}</span>
</b-field>
<b-field horizontal label="Order Qty">
<span>${order_qty_uom_text|n}</span>
</b-field>
% if item.discount_percent:
<b-field horizontal label="Discount">
<span>${app.render_percent(item.discount_percent)}</span>
</b-field>
% endif
<b-field horizontal label="Total Due">
<span>${app.render_currency(item.total_price)}</span>
</b-field>
<b-field horizontal label="Total Paid">
<span>${app.render_currency(item.paid_amount)}</span>
</b-field>
<b-field horizontal label="Status">
<span>${app.enum.ORDER_ITEM_STATUS[item.status_code]}</span>
</b-field>
</div>
</div>
</nav>
<nav class="panel" style="width: 100%;">
<p class="panel-heading">Pricing</p>
<div class="panel-block">
<div style="width: 100%;">
<b-field horizontal label="Unit Cost">
<span>${app.render_currency(item.unit_cost, scale=4)}</span>
</b-field>
<b-field horizontal label="Unit Price Reg.">
<span>${app.render_currency(item.unit_price_reg)}</span>
</b-field>
<b-field horizontal label="Unit Price Sale">
<span>${app.render_currency(item.unit_price_sale)}</span>
</b-field>
<b-field horizontal label="Sale Ends">
<span>${app.render_datetime(item.sale_ends)}</span>
</b-field>
<b-field horizontal label="Unit Price Quoted">
<span>${app.render_currency(item.unit_price_quoted)}</span>
</b-field>
<b-field horizontal label="Case Size">
<span>${app.render_quantity(item.case_size)}</span>
</b-field>
<b-field horizontal label="Case Price Quoted">
<span>${app.render_currency(item.case_price_quoted)}</span>
</b-field>
</div>
</div>
</nav>
</div>
<div style="flex-grow: 1;">
<nav class="panel" style="width: 100%;">
<p class="panel-heading">Customer</p>
<div class="panel-block">
<div style="width: 100%;">
<b-field horizontal label="Customer ID">
<span>${order.customer_id}</span>
</b-field>
% if not order.customer_id and order.local_customer:
<b-field horizontal label="Local Customer">
<span>${h.link_to(order.local_customer, url('local_customers.view', uuid=order.local_customer.uuid))}</span>
</b-field>
% endif
% if not order.customer_id and order.pending_customer:
<b-field horizontal label="Pending Customer">
<span>${h.link_to(order.pending_customer, url('pending_customers.view', uuid=order.pending_customer.uuid))}</span>
</b-field>
% endif
<b-field horizontal label="Customer Name">
<span>${order.customer_name}</span>
</b-field>
<b-field horizontal label="Phone Number">
<span>${order.phone_number}</span>
</b-field>
<b-field horizontal label="Email Address">
<span>${order.email_address}</span>
</b-field>
</div>
</div>
</nav>
<nav class="panel" style="width: 100%;">
<p class="panel-heading">Product</p>
<div class="panel-block">
<div style="width: 100%;">
<b-field horizontal label="Product ID">
<span>${item.product_id}</span>
</b-field>
% if not item.product_id and item.local_product:
<b-field horizontal label="Local Product">
<span>${h.link_to(item.local_product, url('local_products.view', uuid=order.local_product.uuid))}</span>
</b-field>
% endif
% if not item.product_id and item.pending_product:
<b-field horizontal label="Pending Product">
<span>${h.link_to(item.pending_product, url('pending_products.view', uuid=order.pending_product.uuid))}</span>
</b-field>
% endif
<b-field horizontal label="Scancode">
<span>${item.product_scancode}</span>
</b-field>
<b-field horizontal label="Brand">
<span>${item.product_brand}</span>
</b-field>
<b-field horizontal label="Description">
<span>${item.product_description}</span>
</b-field>
<b-field horizontal label="Size">
<span>${item.product_size}</span>
</b-field>
<b-field horizontal label="Sold by Weight">
<span>${app.render_boolean(item.product_weighed)}</span>
</b-field>
<b-field horizontal label="Department">
<span>${item.department_name}</span>
</b-field>
<b-field horizontal label="Special Order">
<span>${app.render_boolean(item.special_order)}</span>
</b-field>
</div>
</div>
</nav>
</div>
</div>
</%def>

View file

@ -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"(&times; {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)

40
tests/test_orders.py Normal file
View file

@ -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 (&times; 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 (&times; ?? = ?? 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")

View file

@ -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 (&times; 8 = 16 Units)")
def test_get_xref_buttons(self):
self.pyramid_config.add_route('orders.view', '/orders/{uuid}')
model = self.app.model