diff --git a/src/sideshow/enum.py b/src/sideshow/enum.py
index 2349f3d..a761d02 100644
--- a/src/sideshow/enum.py
+++ b/src/sideshow/enum.py
@@ -285,6 +285,12 @@ 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_REORDER = 210
+"""
+Indicates the item was not received with the delivery on which it was
+expected, and must be re-ordered from vendor.
+"""
+
ORDER_ITEM_EVENT_RECEIVED = 300
"""
Indicates the receiver has found the item while receiving a vendor
@@ -366,6 +372,7 @@ ORDER_ITEM_EVENT = OrderedDict([
(ORDER_ITEM_EVENT_CUSTOMER_RESOLVED, "customer resolved"),
(ORDER_ITEM_EVENT_PRODUCT_RESOLVED, "product resolved"),
(ORDER_ITEM_EVENT_PLACED, "placed with vendor"),
+ (ORDER_ITEM_EVENT_REORDER, "marked for re-order"),
(ORDER_ITEM_EVENT_RECEIVED, "received from vendor"),
(ORDER_ITEM_EVENT_CONTACTED, "customer contacted"),
(ORDER_ITEM_EVENT_CONTACT_FAILED, "contact failed"),
diff --git a/src/sideshow/orders.py b/src/sideshow/orders.py
index 0434782..9f99e53 100644
--- a/src/sideshow/orders.py
+++ b/src/sideshow/orders.py
@@ -96,3 +96,215 @@ class OrderHandler(GenericHandler):
enum.ORDER_ITEM_STATUS_EXPIRED,
enum.ORDER_ITEM_STATUS_INACTIVE):
return 'warning'
+
+ def process_placement(self, items, user, vendor_name=None, po_number=None, note=None):
+ """
+ Process the "placement" step for the given order items.
+
+ This may eventually do something involving an *actual*
+ purchase order, or at least a minimal representation of one,
+ but for now it does not.
+
+ Instead, this will simply update each item to indicate its new
+ status. A note will be attached to indicate the vendor and/or
+ PO number, if provided.
+
+ :param items: Sequence of
+ :class:`~sideshow.db.model.orders.OrderItem` records.
+
+ :param user:
+ :class:`~wuttjamaican:wuttjamaican.db.model.auth.User`
+ performing the action.
+
+ :param vendor_name: Name of the vendor to which purchase order
+ is placed, if known.
+
+ :param po_number: Purchase order number, if known.
+
+ :param note: Optional *additional* note to be attached to each
+ order item.
+ """
+ enum = self.app.enum
+
+ placed = None
+ if vendor_name:
+ placed = f"PO {po_number or ''} for vendor {vendor_name}"
+ elif po_number:
+ placed = f"PO {po_number}"
+
+ for item in items:
+ item.add_event(enum.ORDER_ITEM_EVENT_PLACED, user, note=placed)
+ if note:
+ item.add_event(enum.ORDER_ITEM_EVENT_NOTE_ADDED, user, note=note)
+ item.status_code = enum.ORDER_ITEM_STATUS_PLACED
+
+ def process_receiving(self, items, user, vendor_name=None,
+ invoice_number=None, po_number=None, note=None):
+ """
+ Process the "receiving" step for the given order items.
+
+ This will update the status for each item, to indicate they
+ are "received".
+
+ TODO: This also should email the customer notifying their
+ items are ready for pickup etc.
+
+ :param items: Sequence of
+ :class:`~sideshow.db.model.orders.OrderItem` records.
+
+ :param user:
+ :class:`~wuttjamaican:wuttjamaican.db.model.auth.User`
+ performing the action.
+
+ :param vendor_name: Name of the vendor, if known.
+
+ :param po_number: Purchase order number, if known.
+
+ :param invoice_number: Invoice number, if known.
+
+ :param note: Optional *additional* note to be attached to each
+ order item.
+ """
+ enum = self.app.enum
+
+ received = None
+ if invoice_number and po_number and vendor_name:
+ received = f"invoice {invoice_number} (PO {po_number}) from vendor {vendor_name}"
+ elif invoice_number and vendor_name:
+ received = f"invoice {invoice_number} from vendor {vendor_name}"
+ elif po_number and vendor_name:
+ received = f"PO {po_number} from vendor {vendor_name}"
+ elif vendor_name:
+ received = f"from vendor {vendor_name}"
+
+ for item in items:
+ item.add_event(enum.ORDER_ITEM_EVENT_RECEIVED, user, note=received)
+ if note:
+ item.add_event(enum.ORDER_ITEM_EVENT_NOTE_ADDED, user, note=note)
+ item.status_code = enum.ORDER_ITEM_STATUS_RECEIVED
+
+ def process_reorder(self, items, user, note=None):
+ """
+ Process the "reorder" step for the given order items.
+
+ This will update the status for each item, to indicate they
+ are "ready" (again) for placement.
+
+ :param items: Sequence of
+ :class:`~sideshow.db.model.orders.OrderItem` records.
+
+ :param user:
+ :class:`~wuttjamaican:wuttjamaican.db.model.auth.User`
+ performing the action.
+
+ :param note: Optional *additional* note to be attached to each
+ order item.
+ """
+ enum = self.app.enum
+
+ for item in items:
+ item.add_event(enum.ORDER_ITEM_EVENT_REORDER, user)
+ if note:
+ item.add_event(enum.ORDER_ITEM_EVENT_NOTE_ADDED, user, note=note)
+ item.status_code = enum.ORDER_ITEM_STATUS_READY
+
+ def process_contact_success(self, items, user, note=None):
+ """
+ Process the "successful contact" step for the given order
+ items.
+
+ This will update the status for each item, to indicate they
+ are "contacted" and awaiting delivery.
+
+ :param items: Sequence of
+ :class:`~sideshow.db.model.orders.OrderItem` records.
+
+ :param user:
+ :class:`~wuttjamaican:wuttjamaican.db.model.auth.User`
+ performing the action.
+
+ :param note: Optional *additional* note to be attached to each
+ order item.
+ """
+ enum = self.app.enum
+
+ for item in items:
+ item.add_event(enum.ORDER_ITEM_EVENT_CONTACTED, user)
+ if note:
+ item.add_event(enum.ORDER_ITEM_EVENT_NOTE_ADDED, user, note=note)
+ item.status_code = enum.ORDER_ITEM_STATUS_CONTACTED
+
+ def process_contact_failure(self, items, user, note=None):
+ """
+ Process the "failed contact" step for the given order items.
+
+ This will update the status for each item, to indicate
+ "contact failed".
+
+ :param items: Sequence of
+ :class:`~sideshow.db.model.orders.OrderItem` records.
+
+ :param user:
+ :class:`~wuttjamaican:wuttjamaican.db.model.auth.User`
+ performing the action.
+
+ :param note: Optional *additional* note to be attached to each
+ order item.
+ """
+ enum = self.app.enum
+
+ for item in items:
+ item.add_event(enum.ORDER_ITEM_EVENT_CONTACT_FAILED, user)
+ if note:
+ item.add_event(enum.ORDER_ITEM_EVENT_NOTE_ADDED, user, note=note)
+ item.status_code = enum.ORDER_ITEM_STATUS_CONTACT_FAILED
+
+ def process_delivery(self, items, user, note=None):
+ """
+ Process the "delivery" step for the given order items.
+
+ This will update the status for each item, to indicate they
+ are "delivered".
+
+ :param items: Sequence of
+ :class:`~sideshow.db.model.orders.OrderItem` records.
+
+ :param user:
+ :class:`~wuttjamaican:wuttjamaican.db.model.auth.User`
+ performing the action.
+
+ :param note: Optional *additional* note to be attached to each
+ order item.
+ """
+ enum = self.app.enum
+
+ for item in items:
+ item.add_event(enum.ORDER_ITEM_EVENT_DELIVERED, user)
+ if note:
+ item.add_event(enum.ORDER_ITEM_EVENT_NOTE_ADDED, user, note=note)
+ item.status_code = enum.ORDER_ITEM_STATUS_DELIVERED
+
+ def process_restock(self, items, user, note=None):
+ """
+ Process the "restock" step for the given order items.
+
+ This will update the status for each item, to indicate they
+ are "restocked".
+
+ :param items: Sequence of
+ :class:`~sideshow.db.model.orders.OrderItem` records.
+
+ :param user:
+ :class:`~wuttjamaican:wuttjamaican.db.model.auth.User`
+ performing the action.
+
+ :param note: Optional *additional* note to be attached to each
+ order item.
+ """
+ enum = self.app.enum
+
+ for item in items:
+ item.add_event(enum.ORDER_ITEM_EVENT_RESTOCKED, user)
+ if note:
+ item.add_event(enum.ORDER_ITEM_EVENT_NOTE_ADDED, user, note=note)
+ item.status_code = enum.ORDER_ITEM_STATUS_RESTOCKED
diff --git a/src/sideshow/web/menus.py b/src/sideshow/web/menus.py
index c8256fc..1641c72 100644
--- a/src/sideshow/web/menus.py
+++ b/src/sideshow/web/menus.py
@@ -57,6 +57,27 @@ class SideshowMenuHandler(base.MenuHandler):
'perm': 'orders.create',
},
{'type': 'sep'},
+ {
+ 'title': "Placement",
+ 'route': 'order_items_placement',
+ 'perm': 'order_items_placement.list',
+ },
+ {
+ 'title': "Receiving",
+ 'route': 'order_items_receiving',
+ 'perm': 'order_items_receiving.list',
+ },
+ {
+ 'title': "Contact",
+ 'route': 'order_items_contact',
+ 'perm': 'order_items_contact.list',
+ },
+ {
+ 'title': "Delivery",
+ 'route': 'order_items_delivery',
+ 'perm': 'order_items_delivery.list',
+ },
+ {'type': 'sep'},
{
'title': "All Order Items",
'route': 'order_items',
diff --git a/src/sideshow/web/templates/contact/index.mako b/src/sideshow/web/templates/contact/index.mako
new file mode 100644
index 0000000..bd8e05f
--- /dev/null
+++ b/src/sideshow/web/templates/contact/index.mako
@@ -0,0 +1,159 @@
+## -*- coding: utf-8; -*-
+<%inherit file="/master/index.mako" />
+
+<%def name="render_grid_tag()">
+ % if master.has_perm('process_contact'):
+ ${grid.render_vue_tag(**{'@process-contact-success': "processContactSuccessInit", '@process-contact-failure': "processContactFailureInit"})}
+ % else:
+ ${grid.render_vue_tag()}
+ % endif
+%def>
+
+<%def name="page_content()">
+ ${parent.page_content()}
+
+ % if master.has_perm('process_contact'):
+
+ <${b}-modal has-modal-card
+ % if request.use_oruga:
+ v-model:active="processContactSuccessShowDialog"
+ % else:
+ :active.sync="processContactSuccessShowDialog"
+ % endif
+ >
+
+ ${h.form(url(f'{route_prefix}.process_contact_success'), ref='processContactSuccessForm')}
+ ${h.csrf_token(request)}
+ ${h.hidden('item_uuids', **{':value': 'processContactSuccessUuids.join()'})}
+
+
+ Process Contact Success
+
+
+
+
+ This will mark {{ processContactSuccessUuids.length }}
+ item{{ processContactSuccessUuids.length > 1 ? 's' : '' }}
+ as being "contacted".
+
+
+
+
+
+
+
+
+ ${h.end_form()}
+
+ ${b}-modal>
+
+ <${b}-modal has-modal-card
+ % if request.use_oruga:
+ v-model:active="processContactFailureShowDialog"
+ % else:
+ :active.sync="processContactFailureShowDialog"
+ % endif
+ >
+
+ ${h.form(url(f'{route_prefix}.process_contact_failure'), ref='processContactFailureForm')}
+ ${h.csrf_token(request)}
+ ${h.hidden('item_uuids', **{':value': 'processContactFailureUuids.join()'})}
+
+
+ Process Contact Failure
+
+
+
+
+ This will mark {{ processContactFailureUuids.length }}
+ item{{ processContactFailureUuids.length > 1 ? 's' : '' }}
+ as being "contact failed".
+
+
+
+
+
+
+
+
+ ${h.end_form()}
+
+ ${b}-modal>
+
+ % endif
+%def>
+
+<%def name="modify_vue_vars()">
+ ${parent.modify_vue_vars()}
+ % if master.has_perm('process_contact'):
+
+ % endif
+%def>
diff --git a/src/sideshow/web/templates/delivery/index.mako b/src/sideshow/web/templates/delivery/index.mako
new file mode 100644
index 0000000..251c0f9
--- /dev/null
+++ b/src/sideshow/web/templates/delivery/index.mako
@@ -0,0 +1,173 @@
+## -*- coding: utf-8; -*-
+<%inherit file="/master/index.mako" />
+
+<%def name="render_grid_tag()">
+ % if master.has_perm('process_delivery') and master.has_perm('process_restock'):
+ ${grid.render_vue_tag(**{'@process-delivery': "processDeliveryInit", '@process-restock': "processRestockInit"})}
+ % elif master.has_perm('process_delivery'):
+ ${grid.render_vue_tag(**{'@process-delivery': "processDeliveryInit"})}
+ % elif master.has_perm('process_restock'):
+ ${grid.render_vue_tag(**{'@process-restock': "processRestockInit"})}
+ % else:
+ ${grid.render_vue_tag()}
+ % endif
+%def>
+
+<%def name="page_content()">
+ ${parent.page_content()}
+
+ % if master.has_perm('process_delivery'):
+
+ <${b}-modal has-modal-card
+ % if request.use_oruga:
+ v-model:active="processDeliveryShowDialog"
+ % else:
+ :active.sync="processDeliveryShowDialog"
+ % endif
+ >
+
+ ${h.form(url(f'{route_prefix}.process_delivery'), ref='processDeliveryForm')}
+ ${h.csrf_token(request)}
+ ${h.hidden('item_uuids', **{':value': 'processDeliveryUuids.join()'})}
+
+
+
+
+
+ This will mark {{ processDeliveryUuids.length }}
+ item{{ processDeliveryUuids.length > 1 ? 's' : '' }}
+ as being "delivered".
+
+
+
+
+
+
+
+
+ ${h.end_form()}
+
+ ${b}-modal>
+
+ % endif
+
+ % if master.has_perm('process_restock'):
+
+ <${b}-modal has-modal-card
+ % if request.use_oruga:
+ v-model:active="processRestockShowDialog"
+ % else:
+ :active.sync="processRestockShowDialog"
+ % endif
+ >
+
+ ${h.form(url(f'{route_prefix}.process_restock'), ref='processRestockForm')}
+ ${h.csrf_token(request)}
+ ${h.hidden('item_uuids', **{':value': 'processRestockUuids.join()'})}
+
+
+
+
+
+ This will mark {{ processRestockUuids.length }}
+ item{{ processRestockUuids.length > 1 ? 's' : '' }}
+ as being "restocked".
+
+
+
+
+
+
+
+
+ ${h.end_form()}
+
+ ${b}-modal>
+
+ % endif
+%def>
+
+<%def name="modify_vue_vars()">
+ ${parent.modify_vue_vars()}
+
+%def>
diff --git a/src/sideshow/web/templates/placement/index.mako b/src/sideshow/web/templates/placement/index.mako
new file mode 100644
index 0000000..b7fb1f6
--- /dev/null
+++ b/src/sideshow/web/templates/placement/index.mako
@@ -0,0 +1,104 @@
+## -*- coding: utf-8; -*-
+<%inherit file="/master/index.mako" />
+
+<%def name="render_grid_tag()">
+ % if master.has_perm('process_placement'):
+ ${grid.render_vue_tag(**{'@process-placement': "processPlacementInit"})}
+ % else:
+ ${grid.render_vue_tag()}
+ % endif
+%def>
+
+<%def name="page_content()">
+ ${parent.page_content()}
+ % if master.has_perm('process_placement'):
+
+ <${b}-modal has-modal-card
+ % if request.use_oruga:
+ v-model:active="processPlacementShowDialog"
+ % else:
+ :active.sync="processPlacementShowDialog"
+ % endif
+ >
+
+ ${h.form(url(f'{route_prefix}.process_placement'), ref='processPlacementForm')}
+ ${h.csrf_token(request)}
+ ${h.hidden('item_uuids', **{':value': 'processPlacementUuids.join()'})}
+
+
+
+
+
+ This will mark {{ processPlacementUuids.length }}
+ item{{ processPlacementUuids.length > 1 ? 's' : '' }} as
+ being "placed" on order from vendor.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${h.end_form()}
+
+ ${b}-modal>
+ % endif
+%def>
+
+<%def name="modify_vue_vars()">
+ ${parent.modify_vue_vars()}
+ % if master.has_perm('process_placement'):
+
+ % endif
+%def>
diff --git a/src/sideshow/web/templates/receiving/index.mako b/src/sideshow/web/templates/receiving/index.mako
new file mode 100644
index 0000000..08ff8e7
--- /dev/null
+++ b/src/sideshow/web/templates/receiving/index.mako
@@ -0,0 +1,192 @@
+## -*- coding: utf-8; -*-
+<%inherit file="/master/index.mako" />
+
+<%def name="render_grid_tag()">
+ % if master.has_perm('process_receiving') and master.has_perm('process_reorder'):
+ ${grid.render_vue_tag(**{'@process-receiving': "processReceivingInit", '@process-reorder': "processReorderInit"})}
+ % elif master.has_perm('process_receiving'):
+ ${grid.render_vue_tag(**{'@process-receiving': "processReceivingInit"})}
+ % elif master.has_perm('process_reorder'):
+ ${grid.render_vue_tag(**{'@process-reorder': "processReorderInit"})}
+ % else:
+ ${grid.render_vue_tag()}
+ % endif
+%def>
+
+<%def name="page_content()">
+ ${parent.page_content()}
+
+ % if master.has_perm('process_receiving'):
+
+ <${b}-modal has-modal-card
+ % if request.use_oruga:
+ v-model:active="processReceivingShowDialog"
+ % else:
+ :active.sync="processReceivingShowDialog"
+ % endif
+ >
+
+ ${h.form(url(f'{route_prefix}.process_receiving'), ref='processReceivingForm')}
+ ${h.csrf_token(request)}
+ ${h.hidden('item_uuids', **{':value': 'processReceivingUuids.join()'})}
+
+
+
+
+
+ This will mark {{ processReceivingUuids.length }}
+ item{{ processReceivingUuids.length > 1 ? 's' : '' }}
+ as being "received" from vendor.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${h.end_form()}
+
+ ${b}-modal>
+
+ % endif
+
+ % if master.has_perm('process_reorder'):
+
+ <${b}-modal has-modal-card
+ % if request.use_oruga:
+ v-model:active="processReorderShowDialog"
+ % else:
+ :active.sync="processReorderShowDialog"
+ % endif
+ >
+
+ ${h.form(url(f'{route_prefix}.process_reorder'), ref='processReorderForm')}
+ ${h.csrf_token(request)}
+ ${h.hidden('item_uuids', **{':value': 'processReorderUuids.join()'})}
+
+
+
+
+
+ This will mark {{ processReorderUuids.length }}
+ item{{ processReorderUuids.length > 1 ? 's' : '' }}
+ as being "ready" for placement (again).
+
+
+
+
+
+
+
+
+ ${h.end_form()}
+
+ ${b}-modal>
+
+ % endif
+%def>
+
+<%def name="modify_vue_vars()">
+ ${parent.modify_vue_vars()}
+
+%def>
diff --git a/src/sideshow/web/views/orders.py b/src/sideshow/web/views/orders.py
index de30a65..cf0c054 100644
--- a/src/sideshow/web/views/orders.py
+++ b/src/sideshow/web/views/orders.py
@@ -28,6 +28,7 @@ import decimal
import logging
import colander
+import sqlalchemy as sa
from sqlalchemy import orm
from webhelpers2.html import tags, HTML
@@ -977,6 +978,15 @@ class OrderItemView(MasterView):
* ``/order-items/``
* ``/order-items/XXX``
+ This class serves both as a proper master view (for "all" order
+ items) as well as a base class for other "workflow" master views,
+ each of which auto-filters by order item status:
+
+ * :class:`PlacementView`
+ * :class:`ReceivingView`
+ * :class:`ContactView`
+ * :class:`DeliveryView`
+
Note that this does not expose create, edit or delete. The user
must perform various other workflow actions to modify the item.
@@ -986,7 +996,8 @@ class OrderItemView(MasterView):
:meth:`get_order_handler()`.
"""
model_class = OrderItem
- model_title = "Order Item"
+ model_title = "Order Item (All)"
+ model_title_plural = "Order Items (All)"
route_prefix = 'order_items'
url_prefix = '/order-items'
creatable = False
@@ -1072,6 +1083,12 @@ class OrderItemView(MasterView):
return self.order_handler
return OrderHandler(self.config)
+ def get_fallback_templates(self, template):
+ """ """
+ templates = super().get_fallback_templates(template)
+ templates.insert(0, f'/order-items/{template}.mako')
+ return templates
+
def get_query(self, session=None):
""" """
query = super().get_query(session=session)
@@ -1304,13 +1321,56 @@ class OrderItemView(MasterView):
self.request.session.flash(f"Status has been updated to: {new_status_text}")
return redirect
+ def get_order_items(self, uuids):
+ """
+ This method provides common logic to fetch a list of order
+ items based on a list of UUID keys. It is used by various
+ workflow action methods.
+
+ Note that if no order items are found, this will set a flash
+ warning message and raise a redirect back to the index page.
+
+ :param uuids: List (or comma-delimited string) of UUID keys.
+
+ :returns: List of :class:`~sideshow.db.model.orders.OrderItem`
+ records.
+ """
+ model = self.app.model
+ session = self.Session()
+
+ if uuids is None:
+ uuids = []
+ elif isinstance(uuids, str):
+ uuids = uuids.split(',')
+
+ items = []
+ for uuid in uuids:
+ if isinstance(uuid, str):
+ uuid = uuid.strip()
+ if uuid:
+ try:
+ item = session.get(model.OrderItem, uuid)
+ except sa.exc.StatementError:
+ pass # nb. invalid UUID
+ else:
+ if item:
+ items.append(item)
+
+ if not items:
+ self.request.session.flash("Must specify valid order item(s).", 'warning')
+ raise self.redirect(self.get_index_url())
+
+ return items
+
@classmethod
def defaults(cls, config):
+ """ """
cls._order_item_defaults(config)
cls._defaults(config)
@classmethod
def _order_item_defaults(cls, config):
+ """ """
route_prefix = cls.get_route_prefix()
permission_prefix = cls.get_permission_prefix()
instance_url_prefix = cls.get_instance_url_prefix()
@@ -1347,6 +1407,536 @@ class OrderItemView(MasterView):
f"Change status for {model_title}")
+class PlacementView(OrderItemView):
+ """
+ Master view for the "placement" phase of
+ :class:`~sideshow.db.model.orders.OrderItem`; route prefix is
+ ``placement``. This is a subclass of :class:`OrderItemView`.
+
+ This class auto-filters so only order items with the following
+ status codes are shown:
+
+ * :data:`~sideshow.enum.ORDER_ITEM_STATUS_READY`
+
+ Notable URLs provided by this class:
+
+ * ``/placement/``
+ * ``/placement/XXX``
+ """
+ model_title = "Order Item (Placement)"
+ model_title_plural = "Order Items (Placement)"
+ route_prefix = 'order_items_placement'
+ url_prefix = '/placement'
+
+ def get_query(self, session=None):
+ """ """
+ query = super().get_query(session=session)
+ model = self.app.model
+ enum = self.app.enum
+ return query.filter(model.OrderItem.status_code == enum.ORDER_ITEM_STATUS_READY)
+
+ def configure_grid(self, g):
+ """ """
+ super().configure_grid(g)
+
+ # checkable
+ if self.has_perm('process_placement'):
+ g.checkable = True
+
+ # tool button: Order Placed
+ if self.has_perm('process_placement'):
+ button = self.make_button("Order Placed", primary=True,
+ icon_left='arrow-circle-right',
+ **{'@click': "$emit('process-placement', checkedRows)",
+ ':disabled': '!checkedRows.length'})
+ g.add_tool(button, key='process_placement')
+
+ def process_placement(self):
+ """
+ View to process the "placement" step for some order item(s).
+
+ This requires a POST request with data:
+
+ :param item_uuids: Comma-delimited list of
+ :class:`~sideshow.db.model.orders.OrderItem` UUID keys.
+
+ :param vendor_name: Optional name of vendor.
+
+ :param po_number: Optional PO number.
+
+ :param note: Optional note text from the user.
+
+ This invokes
+ :meth:`~sideshow.orders.OrderHandler.process_placement()` on
+ the :attr:`~OrderItemView.order_handler`, then redirects user
+ back to the index page.
+ """
+ items = self.get_order_items(self.request.POST.get('item_uuids', ''))
+ vendor_name = self.request.POST.get('vendor_name', '').strip() or None
+ po_number = self.request.POST.get('po_number', '').strip() or None
+ note = self.request.POST.get('note', '').strip() or None
+
+ self.order_handler.process_placement(items, self.request.user,
+ vendor_name=vendor_name,
+ po_number=po_number,
+ note=note)
+
+ self.request.session.flash(f"{len(items)} Order Items were marked as placed")
+ return self.redirect(self.get_index_url())
+
+ @classmethod
+ def defaults(cls, config):
+ cls._order_item_defaults(config)
+ cls._placement_defaults(config)
+ cls._defaults(config)
+
+ @classmethod
+ def _placement_defaults(cls, config):
+ route_prefix = cls.get_route_prefix()
+ permission_prefix = cls.get_permission_prefix()
+ url_prefix = cls.get_url_prefix()
+ model_title_plural = cls.get_model_title_plural()
+
+ # process placement
+ config.add_wutta_permission(permission_prefix,
+ f'{permission_prefix}.process_placement',
+ f"Process placement for {model_title_plural}")
+ config.add_route(f'{route_prefix}.process_placement',
+ f'{url_prefix}/process-placement',
+ request_method='POST')
+ config.add_view(cls, attr='process_placement',
+ route_name=f'{route_prefix}.process_placement',
+ permission=f'{permission_prefix}.process_placement')
+
+
+class ReceivingView(OrderItemView):
+ """
+ Master view for the "receiving" phase of
+ :class:`~sideshow.db.model.orders.OrderItem`; route prefix is
+ ``receiving``. This is a subclass of :class:`OrderItemView`.
+
+ This class auto-filters so only order items with the following
+ status codes are shown:
+
+ * :data:`~sideshow.enum.ORDER_ITEM_STATUS_PLACED`
+
+ Notable URLs provided by this class:
+
+ * ``/receiving/``
+ * ``/receiving/XXX``
+ """
+ model_title = "Order Item (Receiving)"
+ model_title_plural = "Order Items (Receiving)"
+ route_prefix = 'order_items_receiving'
+ url_prefix = '/receiving'
+
+ def get_query(self, session=None):
+ """ """
+ query = super().get_query(session=session)
+ model = self.app.model
+ enum = self.app.enum
+ return query.filter(model.OrderItem.status_code == enum.ORDER_ITEM_STATUS_PLACED)
+
+ def configure_grid(self, g):
+ """ """
+ super().configure_grid(g)
+
+ # checkable
+ if self.has_any_perm('process_receiving', 'process_reorder'):
+ g.checkable = True
+
+ # tool button: Received
+ if self.has_perm('process_receiving'):
+ button = self.make_button("Received", primary=True,
+ icon_left='arrow-circle-right',
+ **{'@click': "$emit('process-receiving', checkedRows)",
+ ':disabled': '!checkedRows.length'})
+ g.add_tool(button, key='process_receiving')
+
+ # tool button: Re-Order
+ if self.has_perm('process_reorder'):
+ button = self.make_button("Re-Order",
+ icon_left='redo',
+ **{'@click': "$emit('process-reorder', checkedRows)",
+ ':disabled': '!checkedRows.length'})
+ g.add_tool(button, key='process_reorder')
+
+ def process_receiving(self):
+ """
+ View to process the "receiving" step for some order item(s).
+
+ This requires a POST request with data:
+
+ :param item_uuids: Comma-delimited list of
+ :class:`~sideshow.db.model.orders.OrderItem` UUID keys.
+
+ :param vendor_name: Optional name of vendor.
+
+ :param invoice_number: Optional invoice number.
+
+ :param po_number: Optional PO number.
+
+ :param note: Optional note text from the user.
+
+ This invokes
+ :meth:`~sideshow.orders.OrderHandler.process_receiving()` on
+ the :attr:`~OrderItemView.order_handler`, then redirects user
+ back to the index page.
+ """
+ items = self.get_order_items(self.request.POST.get('item_uuids', ''))
+ vendor_name = self.request.POST.get('vendor_name', '').strip() or None
+ invoice_number = self.request.POST.get('invoice_number', '').strip() or None
+ po_number = self.request.POST.get('po_number', '').strip() or None
+ note = self.request.POST.get('note', '').strip() or None
+
+ self.order_handler.process_receiving(items, self.request.user,
+ vendor_name=vendor_name,
+ invoice_number=invoice_number,
+ po_number=po_number,
+ note=note)
+
+ self.request.session.flash(f"{len(items)} Order Items were marked as received")
+ return self.redirect(self.get_index_url())
+
+ def process_reorder(self):
+ """
+ View to process the "reorder" step for some order item(s).
+
+ This requires a POST request with data:
+
+ :param item_uuids: Comma-delimited list of
+ :class:`~sideshow.db.model.orders.OrderItem` UUID keys.
+
+ :param note: Optional note text from the user.
+
+ This invokes
+ :meth:`~sideshow.orders.OrderHandler.process_reorder()` on the
+ :attr:`~OrderItemView.order_handler`, then redirects user back
+ to the index page.
+ """
+ items = self.get_order_items(self.request.POST.get('item_uuids', ''))
+ note = self.request.POST.get('note', '').strip() or None
+
+ self.order_handler.process_reorder(items, self.request.user, note=note)
+
+ self.request.session.flash(f"{len(items)} Order Items were marked as ready for placement")
+ return self.redirect(self.get_index_url())
+
+ @classmethod
+ def defaults(cls, config):
+ cls._order_item_defaults(config)
+ cls._receiving_defaults(config)
+ cls._defaults(config)
+
+ @classmethod
+ def _receiving_defaults(cls, config):
+ route_prefix = cls.get_route_prefix()
+ permission_prefix = cls.get_permission_prefix()
+ url_prefix = cls.get_url_prefix()
+ model_title_plural = cls.get_model_title_plural()
+
+ # process receiving
+ config.add_wutta_permission(permission_prefix,
+ f'{permission_prefix}.process_receiving',
+ f"Process receiving for {model_title_plural}")
+ config.add_route(f'{route_prefix}.process_receiving',
+ f'{url_prefix}/process-receiving',
+ request_method='POST')
+ config.add_view(cls, attr='process_receiving',
+ route_name=f'{route_prefix}.process_receiving',
+ permission=f'{permission_prefix}.process_receiving')
+
+ # process reorder
+ config.add_wutta_permission(permission_prefix,
+ f'{permission_prefix}.process_reorder',
+ f"Process re-order for {model_title_plural}")
+ config.add_route(f'{route_prefix}.process_reorder',
+ f'{url_prefix}/process-reorder',
+ request_method='POST')
+ config.add_view(cls, attr='process_reorder',
+ route_name=f'{route_prefix}.process_reorder',
+ permission=f'{permission_prefix}.process_reorder')
+
+
+class ContactView(OrderItemView):
+ """
+ Master view for the "contact" phase of
+ :class:`~sideshow.db.model.orders.OrderItem`; route prefix is
+ ``contact``. This is a subclass of :class:`OrderItemView`.
+
+ This class auto-filters so only order items with the following
+ status codes are shown:
+
+ * :data:`~sideshow.enum.ORDER_ITEM_STATUS_RECEIVED`
+ * :data:`~sideshow.enum.ORDER_ITEM_STATUS_CONTACT_FAILED`
+
+ Notable URLs provided by this class:
+
+ * ``/contact/``
+ * ``/contact/XXX``
+ """
+ model_title = "Order Item (Contact)"
+ model_title_plural = "Order Items (Contact)"
+ route_prefix = 'order_items_contact'
+ url_prefix = '/contact'
+
+ def get_query(self, session=None):
+ """ """
+ query = super().get_query(session=session)
+ model = self.app.model
+ enum = self.app.enum
+ return query.filter(model.OrderItem.status_code.in_((
+ enum.ORDER_ITEM_STATUS_RECEIVED,
+ enum.ORDER_ITEM_STATUS_CONTACT_FAILED)))
+
+ def configure_grid(self, g):
+ """ """
+ super().configure_grid(g)
+
+ # checkable
+ if self.has_perm('process_contact'):
+ g.checkable = True
+
+ # tool button: Contact Success
+ if self.has_perm('process_contact'):
+ button = self.make_button("Contact Success", primary=True,
+ icon_left='phone',
+ **{'@click': "$emit('process-contact-success', checkedRows)",
+ ':disabled': '!checkedRows.length'})
+ g.add_tool(button, key='process_contact_success')
+
+ # tool button: Contact Failure
+ if self.has_perm('process_contact'):
+ button = self.make_button("Contact Failure", variant='is-warning',
+ icon_left='phone',
+ **{'@click': "$emit('process-contact-failure', checkedRows)",
+ ':disabled': '!checkedRows.length'})
+ g.add_tool(button, key='process_contact_failure')
+
+ def process_contact_success(self):
+ """
+ View to process the "contact success" step for some order
+ item(s).
+
+ This requires a POST request with data:
+
+ :param item_uuids: Comma-delimited list of
+ :class:`~sideshow.db.model.orders.OrderItem` UUID keys.
+
+ :param note: Optional note text from the user.
+
+ This invokes
+ :meth:`~sideshow.orders.OrderHandler.process_contact_success()`
+ on the :attr:`~OrderItemView.order_handler`, then redirects
+ user back to the index page.
+ """
+ items = self.get_order_items(self.request.POST.get('item_uuids', ''))
+ note = self.request.POST.get('note', '').strip() or None
+
+ self.order_handler.process_contact_success(items, self.request.user, note=note)
+
+ self.request.session.flash(f"{len(items)} Order Items were marked as contacted")
+ return self.redirect(self.get_index_url())
+
+ def process_contact_failure(self):
+ """
+ View to process the "contact failure" step for some order
+ item(s).
+
+ This requires a POST request with data:
+
+ :param item_uuids: Comma-delimited list of
+ :class:`~sideshow.db.model.orders.OrderItem` UUID keys.
+
+ :param note: Optional note text from the user.
+
+ This invokes
+ :meth:`~sideshow.orders.OrderHandler.process_contact_failure()`
+ on the :attr:`~OrderItemView.order_handler`, then redirects
+ user back to the index page.
+ """
+ items = self.get_order_items(self.request.POST.get('item_uuids', ''))
+ note = self.request.POST.get('note', '').strip() or None
+
+ self.order_handler.process_contact_failure(items, self.request.user, note=note)
+
+ self.request.session.flash(f"{len(items)} Order Items were marked as contact failed")
+ return self.redirect(self.get_index_url())
+
+ @classmethod
+ def defaults(cls, config):
+ cls._order_item_defaults(config)
+ cls._contact_defaults(config)
+ cls._defaults(config)
+
+ @classmethod
+ def _contact_defaults(cls, config):
+ route_prefix = cls.get_route_prefix()
+ permission_prefix = cls.get_permission_prefix()
+ url_prefix = cls.get_url_prefix()
+ model_title_plural = cls.get_model_title_plural()
+
+ # common perm for processing contact success + failure
+ config.add_wutta_permission(permission_prefix,
+ f'{permission_prefix}.process_contact',
+ f"Process contact success/failure for {model_title_plural}")
+
+ # process contact success
+ config.add_route(f'{route_prefix}.process_contact_success',
+ f'{url_prefix}/process-contact-success',
+ request_method='POST')
+ config.add_view(cls, attr='process_contact_success',
+ route_name=f'{route_prefix}.process_contact_success',
+ permission=f'{permission_prefix}.process_contact')
+
+ # process contact failure
+ config.add_route(f'{route_prefix}.process_contact_failure',
+ f'{url_prefix}/process-contact-failure',
+ request_method='POST')
+ config.add_view(cls, attr='process_contact_failure',
+ route_name=f'{route_prefix}.process_contact_failure',
+ permission=f'{permission_prefix}.process_contact')
+
+
+class DeliveryView(OrderItemView):
+ """
+ Master view for the "delivery" phase of
+ :class:`~sideshow.db.model.orders.OrderItem`; route prefix is
+ ``delivery``. This is a subclass of :class:`OrderItemView`.
+
+ This class auto-filters so only order items with the following
+ status codes are shown:
+
+ * :data:`~sideshow.enum.ORDER_ITEM_STATUS_RECEIVED`
+ * :data:`~sideshow.enum.ORDER_ITEM_STATUS_CONTACTED`
+
+ Notable URLs provided by this class:
+
+ * ``/delivery/``
+ * ``/delivery/XXX``
+ """
+ model_title = "Order Item (Delivery)"
+ model_title_plural = "Order Items (Delivery)"
+ route_prefix = 'order_items_delivery'
+ url_prefix = '/delivery'
+
+ def get_query(self, session=None):
+ """ """
+ query = super().get_query(session=session)
+ model = self.app.model
+ enum = self.app.enum
+ return query.filter(model.OrderItem.status_code.in_((
+ enum.ORDER_ITEM_STATUS_RECEIVED,
+ enum.ORDER_ITEM_STATUS_CONTACTED)))
+
+ def configure_grid(self, g):
+ """ """
+ super().configure_grid(g)
+
+ # checkable
+ if self.has_any_perm('process_delivery', 'process_restock'):
+ g.checkable = True
+
+ # tool button: Delivered
+ if self.has_perm('process_delivery'):
+ button = self.make_button("Delivered", primary=True,
+ icon_left='check',
+ **{'@click': "$emit('process-delivery', checkedRows)",
+ ':disabled': '!checkedRows.length'})
+ g.add_tool(button, key='process_delivery')
+
+ # tool button: Restocked
+ if self.has_perm('process_restock'):
+ button = self.make_button("Restocked",
+ icon_left='redo',
+ **{'@click': "$emit('process-restock', checkedRows)",
+ ':disabled': '!checkedRows.length'})
+ g.add_tool(button, key='process_restock')
+
+ def process_delivery(self):
+ """
+ View to process the "delivery" step for some order item(s).
+
+ This requires a POST request with data:
+
+ :param item_uuids: Comma-delimited list of
+ :class:`~sideshow.db.model.orders.OrderItem` UUID keys.
+
+ :param note: Optional note text from the user.
+
+ This invokes
+ :meth:`~sideshow.orders.OrderHandler.process_delivery()` on
+ the :attr:`~OrderItemView.order_handler`, then redirects user
+ back to the index page.
+ """
+ items = self.get_order_items(self.request.POST.get('item_uuids', ''))
+ note = self.request.POST.get('note', '').strip() or None
+
+ self.order_handler.process_delivery(items, self.request.user, note=note)
+
+ self.request.session.flash(f"{len(items)} Order Items were marked as delivered")
+ return self.redirect(self.get_index_url())
+
+ def process_restock(self):
+ """
+ View to process the "restock" step for some order item(s).
+
+ This requires a POST request with data:
+
+ :param item_uuids: Comma-delimited list of
+ :class:`~sideshow.db.model.orders.OrderItem` UUID keys.
+
+ :param note: Optional note text from the user.
+
+ This invokes
+ :meth:`~sideshow.orders.OrderHandler.process_restock()` on the
+ :attr:`~OrderItemView.order_handler`, then redirects user back
+ to the index page.
+ """
+ items = self.get_order_items(self.request.POST.get('item_uuids', ''))
+ note = self.request.POST.get('note', '').strip() or None
+
+ self.order_handler.process_restock(items, self.request.user, note=note)
+
+ self.request.session.flash(f"{len(items)} Order Items were marked as restocked")
+ return self.redirect(self.get_index_url())
+
+ @classmethod
+ def defaults(cls, config):
+ cls._order_item_defaults(config)
+ cls._delivery_defaults(config)
+ cls._defaults(config)
+
+ @classmethod
+ def _delivery_defaults(cls, config):
+ route_prefix = cls.get_route_prefix()
+ permission_prefix = cls.get_permission_prefix()
+ url_prefix = cls.get_url_prefix()
+ model_title_plural = cls.get_model_title_plural()
+
+ # process delivery
+ config.add_wutta_permission(permission_prefix,
+ f'{permission_prefix}.process_delivery',
+ f"Process delivery for {model_title_plural}")
+ config.add_route(f'{route_prefix}.process_delivery',
+ f'{url_prefix}/process-delivery',
+ request_method='POST')
+ config.add_view(cls, attr='process_delivery',
+ route_name=f'{route_prefix}.process_delivery',
+ permission=f'{permission_prefix}.process_delivery')
+
+ # process restock
+ config.add_wutta_permission(permission_prefix,
+ f'{permission_prefix}.process_restock',
+ f"Process restock for {model_title_plural}")
+ config.add_route(f'{route_prefix}.process_restock',
+ f'{url_prefix}/process-restock',
+ request_method='POST')
+ config.add_view(cls, attr='process_restock',
+ route_name=f'{route_prefix}.process_restock',
+ permission=f'{permission_prefix}.process_restock')
+
+
def defaults(config, **kwargs):
base = globals()
@@ -1356,6 +1946,18 @@ def defaults(config, **kwargs):
OrderItemView = kwargs.get('OrderItemView', base['OrderItemView'])
OrderItemView.defaults(config)
+ PlacementView = kwargs.get('PlacementView', base['PlacementView'])
+ PlacementView.defaults(config)
+
+ ReceivingView = kwargs.get('ReceivingView', base['ReceivingView'])
+ ReceivingView.defaults(config)
+
+ ContactView = kwargs.get('ContactView', base['ContactView'])
+ ContactView.defaults(config)
+
+ DeliveryView = kwargs.get('DeliveryView', base['DeliveryView'])
+ DeliveryView.defaults(config)
+
def includeme(config):
defaults(config)
diff --git a/tests/test_orders.py b/tests/test_orders.py
index 8fd7bf1..5937045 100644
--- a/tests/test_orders.py
+++ b/tests/test_orders.py
@@ -9,6 +9,7 @@ class TestOrderHandler(DataTestCase):
def make_config(self, **kwargs):
config = super().make_config(**kwargs)
+ config.setdefault('wutta.model_spec', 'sideshow.db.model')
config.setdefault('wutta.enum_spec', 'sideshow.enum')
return config
@@ -58,3 +59,361 @@ class TestOrderHandler(DataTestCase):
self.assertEqual(handler.item_status_to_variant(enum.ORDER_ITEM_STATUS_RESTOCKED), 'warning')
self.assertEqual(handler.item_status_to_variant(enum.ORDER_ITEM_STATUS_EXPIRED), 'warning')
self.assertEqual(handler.item_status_to_variant(enum.ORDER_ITEM_STATUS_INACTIVE), 'warning')
+
+ def test_process_placement(self):
+ model = self.app.model
+ enum = self.app.enum
+ handler = self.make_handler()
+
+ # sample data
+ user = model.User(username='barney')
+ self.session.add(user)
+ order = model.Order(order_id=42, customer_name="Fred Flintstone", created_by=user)
+ item1 = model.OrderItem(order_qty=1, order_uom=enum.ORDER_UOM_UNIT,
+ status_code=enum.ORDER_ITEM_STATUS_READY)
+ order.items.append(item1)
+ item2 = model.OrderItem(order_qty=1, order_uom=enum.ORDER_UOM_UNIT,
+ status_code=enum.ORDER_ITEM_STATUS_READY)
+ order.items.append(item2)
+ item3 = model.OrderItem(order_qty=1, order_uom=enum.ORDER_UOM_UNIT,
+ status_code=enum.ORDER_ITEM_STATUS_READY)
+ order.items.append(item3)
+ self.session.add(order)
+ self.session.flush()
+
+ # two items are updated
+ self.assertEqual(item1.status_code, enum.ORDER_ITEM_STATUS_READY)
+ self.assertEqual(item2.status_code, enum.ORDER_ITEM_STATUS_READY)
+ self.assertEqual(len(item1.events), 0)
+ self.assertEqual(len(item2.events), 0)
+ handler.process_placement([item1, item2], user,
+ vendor_name="Acme Dist", po_number='ACME123')
+ self.assertEqual(item1.status_code, enum.ORDER_ITEM_STATUS_PLACED)
+ self.assertEqual(item2.status_code, enum.ORDER_ITEM_STATUS_PLACED)
+ self.assertEqual(len(item1.events), 1)
+ self.assertEqual(len(item2.events), 1)
+ self.assertEqual(item1.events[0].note, "PO ACME123 for vendor Acme Dist")
+ self.assertEqual(item2.events[0].note, "PO ACME123 for vendor Acme Dist")
+ self.assertEqual(item1.events[0].type_code, enum.ORDER_ITEM_EVENT_PLACED)
+ self.assertEqual(item2.events[0].type_code, enum.ORDER_ITEM_EVENT_PLACED)
+
+ # update last item, without vendor name but extra note
+ self.assertEqual(item3.status_code, enum.ORDER_ITEM_STATUS_READY)
+ self.assertEqual(len(item3.events), 0)
+ handler.process_placement([item3], user, po_number="939234", note="extra note")
+ self.assertEqual(item3.status_code, enum.ORDER_ITEM_STATUS_PLACED)
+ self.assertEqual(len(item3.events), 2)
+ self.assertEqual(item3.events[0].note, "PO 939234")
+ self.assertEqual(item3.events[1].note, "extra note")
+ self.assertEqual(item3.events[0].type_code, enum.ORDER_ITEM_EVENT_PLACED)
+ self.assertEqual(item3.events[1].type_code, enum.ORDER_ITEM_EVENT_NOTE_ADDED)
+
+ def test_process_receiving(self):
+ model = self.app.model
+ enum = self.app.enum
+ handler = self.make_handler()
+
+ # sample data
+ user = model.User(username='barney')
+ self.session.add(user)
+ order = model.Order(order_id=42, customer_name="Fred Flintstone", created_by=user)
+ item1 = model.OrderItem(order_qty=1, order_uom=enum.ORDER_UOM_UNIT,
+ status_code=enum.ORDER_ITEM_STATUS_PLACED)
+ order.items.append(item1)
+ item2 = model.OrderItem(order_qty=1, order_uom=enum.ORDER_UOM_UNIT,
+ status_code=enum.ORDER_ITEM_STATUS_PLACED)
+ order.items.append(item2)
+ item3 = model.OrderItem(order_qty=1, order_uom=enum.ORDER_UOM_UNIT,
+ status_code=enum.ORDER_ITEM_STATUS_PLACED)
+ order.items.append(item3)
+ item4 = model.OrderItem(order_qty=1, order_uom=enum.ORDER_UOM_UNIT,
+ status_code=enum.ORDER_ITEM_STATUS_PLACED)
+ order.items.append(item4)
+ item5 = model.OrderItem(order_qty=1, order_uom=enum.ORDER_UOM_UNIT,
+ status_code=enum.ORDER_ITEM_STATUS_PLACED)
+ order.items.append(item5)
+ self.session.add(order)
+ self.session.flush()
+
+ # all info provided
+ self.assertEqual(item1.status_code, enum.ORDER_ITEM_STATUS_PLACED)
+ self.assertEqual(len(item1.events), 0)
+ handler.process_receiving([item1], user, vendor_name="Acme Dist",
+ invoice_number='INV123', po_number='123')
+ self.assertEqual(item1.status_code, enum.ORDER_ITEM_STATUS_RECEIVED)
+ self.assertEqual(len(item1.events), 1)
+ self.assertEqual(item1.events[0].note, "invoice INV123 (PO 123) from vendor Acme Dist")
+ self.assertEqual(item1.events[0].type_code, enum.ORDER_ITEM_EVENT_RECEIVED)
+
+ # missing PO number
+ self.assertEqual(item2.status_code, enum.ORDER_ITEM_STATUS_PLACED)
+ self.assertEqual(len(item2.events), 0)
+ handler.process_receiving([item2], user, vendor_name="Acme Dist", invoice_number='INV123')
+ self.assertEqual(item2.status_code, enum.ORDER_ITEM_STATUS_RECEIVED)
+ self.assertEqual(len(item2.events), 1)
+ self.assertEqual(item2.events[0].note, "invoice INV123 from vendor Acme Dist")
+ self.assertEqual(item2.events[0].type_code, enum.ORDER_ITEM_EVENT_RECEIVED)
+
+ # missing invoice number
+ self.assertEqual(item3.status_code, enum.ORDER_ITEM_STATUS_PLACED)
+ self.assertEqual(len(item3.events), 0)
+ handler.process_receiving([item3], user, vendor_name="Acme Dist", po_number='123')
+ self.assertEqual(item3.status_code, enum.ORDER_ITEM_STATUS_RECEIVED)
+ self.assertEqual(len(item3.events), 1)
+ self.assertEqual(item3.events[0].note, "PO 123 from vendor Acme Dist")
+ self.assertEqual(item3.events[0].type_code, enum.ORDER_ITEM_EVENT_RECEIVED)
+
+ # vendor name only
+ self.assertEqual(item4.status_code, enum.ORDER_ITEM_STATUS_PLACED)
+ self.assertEqual(len(item4.events), 0)
+ handler.process_receiving([item4], user, vendor_name="Acme Dist")
+ self.assertEqual(item4.status_code, enum.ORDER_ITEM_STATUS_RECEIVED)
+ self.assertEqual(len(item4.events), 1)
+ self.assertEqual(item4.events[0].note, "from vendor Acme Dist")
+ self.assertEqual(item4.events[0].type_code, enum.ORDER_ITEM_EVENT_RECEIVED)
+
+ # no info; extra note
+ self.assertEqual(item5.status_code, enum.ORDER_ITEM_STATUS_PLACED)
+ self.assertEqual(len(item5.events), 0)
+ handler.process_receiving([item5], user, note="extra note")
+ self.assertEqual(item5.status_code, enum.ORDER_ITEM_STATUS_RECEIVED)
+ self.assertEqual(len(item5.events), 2)
+ self.assertIsNone(item5.events[0].note)
+ self.assertEqual(item5.events[0].type_code, enum.ORDER_ITEM_EVENT_RECEIVED)
+ self.assertEqual(item5.events[1].note, "extra note")
+ self.assertEqual(item5.events[1].type_code, enum.ORDER_ITEM_EVENT_NOTE_ADDED)
+
+ def test_process_reorder(self):
+ model = self.app.model
+ enum = self.app.enum
+ handler = self.make_handler()
+
+ # sample data
+ user = model.User(username='barney')
+ self.session.add(user)
+ order = model.Order(order_id=42, customer_name="Fred Flintstone", created_by=user)
+ item1 = model.OrderItem(order_qty=1, order_uom=enum.ORDER_UOM_UNIT,
+ status_code=enum.ORDER_ITEM_STATUS_PLACED)
+ order.items.append(item1)
+ item2 = model.OrderItem(order_qty=1, order_uom=enum.ORDER_UOM_UNIT,
+ status_code=enum.ORDER_ITEM_STATUS_PLACED)
+ order.items.append(item2)
+ item3 = model.OrderItem(order_qty=1, order_uom=enum.ORDER_UOM_UNIT,
+ status_code=enum.ORDER_ITEM_STATUS_PLACED)
+ order.items.append(item3)
+ self.session.add(order)
+ self.session.flush()
+
+ # two items are updated
+ self.assertEqual(item1.status_code, enum.ORDER_ITEM_STATUS_PLACED)
+ self.assertEqual(item2.status_code, enum.ORDER_ITEM_STATUS_PLACED)
+ self.assertEqual(len(item1.events), 0)
+ self.assertEqual(len(item2.events), 0)
+ handler.process_reorder([item1, item2], user)
+ self.assertEqual(item1.status_code, enum.ORDER_ITEM_STATUS_READY)
+ self.assertEqual(item2.status_code, enum.ORDER_ITEM_STATUS_READY)
+ self.assertEqual(len(item1.events), 1)
+ self.assertEqual(len(item2.events), 1)
+ self.assertIsNone(item1.events[0].note)
+ self.assertIsNone(item2.events[0].note)
+ self.assertEqual(item1.events[0].type_code, enum.ORDER_ITEM_EVENT_REORDER)
+ self.assertEqual(item2.events[0].type_code, enum.ORDER_ITEM_EVENT_REORDER)
+
+ # update last item, with extra note
+ self.assertEqual(item3.status_code, enum.ORDER_ITEM_STATUS_PLACED)
+ self.assertEqual(len(item3.events), 0)
+ handler.process_reorder([item3], user, note="extra note")
+ self.assertEqual(item3.status_code, enum.ORDER_ITEM_STATUS_READY)
+ self.assertEqual(len(item3.events), 2)
+ self.assertIsNone(item3.events[0].note)
+ self.assertEqual(item3.events[1].note, "extra note")
+ self.assertEqual(item3.events[0].type_code, enum.ORDER_ITEM_EVENT_REORDER)
+ self.assertEqual(item3.events[1].type_code, enum.ORDER_ITEM_EVENT_NOTE_ADDED)
+
+ def test_process_contact_success(self):
+ model = self.app.model
+ enum = self.app.enum
+ handler = self.make_handler()
+
+ # sample data
+ user = model.User(username='barney')
+ self.session.add(user)
+ order = model.Order(order_id=42, customer_name="Fred Flintstone", created_by=user)
+ item1 = model.OrderItem(order_qty=1, order_uom=enum.ORDER_UOM_UNIT,
+ status_code=enum.ORDER_ITEM_STATUS_RECEIVED)
+ order.items.append(item1)
+ item2 = model.OrderItem(order_qty=1, order_uom=enum.ORDER_UOM_UNIT,
+ status_code=enum.ORDER_ITEM_STATUS_RECEIVED)
+ order.items.append(item2)
+ item3 = model.OrderItem(order_qty=1, order_uom=enum.ORDER_UOM_UNIT,
+ status_code=enum.ORDER_ITEM_STATUS_RECEIVED)
+ order.items.append(item3)
+ self.session.add(order)
+ self.session.flush()
+
+ # two items are updated
+ self.assertEqual(item1.status_code, enum.ORDER_ITEM_STATUS_RECEIVED)
+ self.assertEqual(item2.status_code, enum.ORDER_ITEM_STATUS_RECEIVED)
+ self.assertEqual(len(item1.events), 0)
+ self.assertEqual(len(item2.events), 0)
+ handler.process_contact_success([item1, item2], user)
+ self.assertEqual(item1.status_code, enum.ORDER_ITEM_STATUS_CONTACTED)
+ self.assertEqual(item2.status_code, enum.ORDER_ITEM_STATUS_CONTACTED)
+ self.assertEqual(len(item1.events), 1)
+ self.assertEqual(len(item2.events), 1)
+ self.assertIsNone(item1.events[0].note)
+ self.assertIsNone(item2.events[0].note)
+ self.assertEqual(item1.events[0].type_code, enum.ORDER_ITEM_EVENT_CONTACTED)
+ self.assertEqual(item2.events[0].type_code, enum.ORDER_ITEM_EVENT_CONTACTED)
+
+ # update last item, with extra note
+ self.assertEqual(item3.status_code, enum.ORDER_ITEM_STATUS_RECEIVED)
+ self.assertEqual(len(item3.events), 0)
+ handler.process_contact_success([item3], user, note="extra note")
+ self.assertEqual(item3.status_code, enum.ORDER_ITEM_STATUS_CONTACTED)
+ self.assertEqual(len(item3.events), 2)
+ self.assertIsNone(item3.events[0].note)
+ self.assertEqual(item3.events[1].note, "extra note")
+ self.assertEqual(item3.events[0].type_code, enum.ORDER_ITEM_EVENT_CONTACTED)
+ self.assertEqual(item3.events[1].type_code, enum.ORDER_ITEM_EVENT_NOTE_ADDED)
+
+ def test_process_contact_failure(self):
+ model = self.app.model
+ enum = self.app.enum
+ handler = self.make_handler()
+
+ # sample data
+ user = model.User(username='barney')
+ self.session.add(user)
+ order = model.Order(order_id=42, customer_name="Fred Flintstone", created_by=user)
+ item1 = model.OrderItem(order_qty=1, order_uom=enum.ORDER_UOM_UNIT,
+ status_code=enum.ORDER_ITEM_STATUS_RECEIVED)
+ order.items.append(item1)
+ item2 = model.OrderItem(order_qty=1, order_uom=enum.ORDER_UOM_UNIT,
+ status_code=enum.ORDER_ITEM_STATUS_RECEIVED)
+ order.items.append(item2)
+ item3 = model.OrderItem(order_qty=1, order_uom=enum.ORDER_UOM_UNIT,
+ status_code=enum.ORDER_ITEM_STATUS_RECEIVED)
+ order.items.append(item3)
+ self.session.add(order)
+ self.session.flush()
+
+ # two items are updated
+ self.assertEqual(item1.status_code, enum.ORDER_ITEM_STATUS_RECEIVED)
+ self.assertEqual(item2.status_code, enum.ORDER_ITEM_STATUS_RECEIVED)
+ self.assertEqual(len(item1.events), 0)
+ self.assertEqual(len(item2.events), 0)
+ handler.process_contact_failure([item1, item2], user)
+ self.assertEqual(item1.status_code, enum.ORDER_ITEM_STATUS_CONTACT_FAILED)
+ self.assertEqual(item2.status_code, enum.ORDER_ITEM_STATUS_CONTACT_FAILED)
+ self.assertEqual(len(item1.events), 1)
+ self.assertEqual(len(item2.events), 1)
+ self.assertIsNone(item1.events[0].note)
+ self.assertIsNone(item2.events[0].note)
+ self.assertEqual(item1.events[0].type_code, enum.ORDER_ITEM_EVENT_CONTACT_FAILED)
+ self.assertEqual(item2.events[0].type_code, enum.ORDER_ITEM_EVENT_CONTACT_FAILED)
+
+ # update last item, with extra note
+ self.assertEqual(item3.status_code, enum.ORDER_ITEM_STATUS_RECEIVED)
+ self.assertEqual(len(item3.events), 0)
+ handler.process_contact_failure([item3], user, note="extra note")
+ self.assertEqual(item3.status_code, enum.ORDER_ITEM_STATUS_CONTACT_FAILED)
+ self.assertEqual(len(item3.events), 2)
+ self.assertIsNone(item3.events[0].note)
+ self.assertEqual(item3.events[1].note, "extra note")
+ self.assertEqual(item3.events[0].type_code, enum.ORDER_ITEM_EVENT_CONTACT_FAILED)
+ self.assertEqual(item3.events[1].type_code, enum.ORDER_ITEM_EVENT_NOTE_ADDED)
+
+ def test_process_delivery(self):
+ model = self.app.model
+ enum = self.app.enum
+ handler = self.make_handler()
+
+ # sample data
+ user = model.User(username='barney')
+ self.session.add(user)
+ order = model.Order(order_id=42, customer_name="Fred Flintstone", created_by=user)
+ item1 = model.OrderItem(order_qty=1, order_uom=enum.ORDER_UOM_UNIT,
+ status_code=enum.ORDER_ITEM_STATUS_RECEIVED)
+ order.items.append(item1)
+ item2 = model.OrderItem(order_qty=1, order_uom=enum.ORDER_UOM_UNIT,
+ status_code=enum.ORDER_ITEM_STATUS_RECEIVED)
+ order.items.append(item2)
+ item3 = model.OrderItem(order_qty=1, order_uom=enum.ORDER_UOM_UNIT,
+ status_code=enum.ORDER_ITEM_STATUS_RECEIVED)
+ order.items.append(item3)
+ self.session.add(order)
+ self.session.flush()
+
+ # two items are updated
+ self.assertEqual(item1.status_code, enum.ORDER_ITEM_STATUS_RECEIVED)
+ self.assertEqual(item2.status_code, enum.ORDER_ITEM_STATUS_RECEIVED)
+ self.assertEqual(len(item1.events), 0)
+ self.assertEqual(len(item2.events), 0)
+ handler.process_delivery([item1, item2], user)
+ self.assertEqual(item1.status_code, enum.ORDER_ITEM_STATUS_DELIVERED)
+ self.assertEqual(item2.status_code, enum.ORDER_ITEM_STATUS_DELIVERED)
+ self.assertEqual(len(item1.events), 1)
+ self.assertEqual(len(item2.events), 1)
+ self.assertIsNone(item1.events[0].note)
+ self.assertIsNone(item2.events[0].note)
+ self.assertEqual(item1.events[0].type_code, enum.ORDER_ITEM_EVENT_DELIVERED)
+ self.assertEqual(item2.events[0].type_code, enum.ORDER_ITEM_EVENT_DELIVERED)
+
+ # update last item, with extra note
+ self.assertEqual(item3.status_code, enum.ORDER_ITEM_STATUS_RECEIVED)
+ self.assertEqual(len(item3.events), 0)
+ handler.process_delivery([item3], user, note="extra note")
+ self.assertEqual(item3.status_code, enum.ORDER_ITEM_STATUS_DELIVERED)
+ self.assertEqual(len(item3.events), 2)
+ self.assertIsNone(item3.events[0].note)
+ self.assertEqual(item3.events[1].note, "extra note")
+ self.assertEqual(item3.events[0].type_code, enum.ORDER_ITEM_EVENT_DELIVERED)
+ self.assertEqual(item3.events[1].type_code, enum.ORDER_ITEM_EVENT_NOTE_ADDED)
+
+ def test_process_restock(self):
+ model = self.app.model
+ enum = self.app.enum
+ handler = self.make_handler()
+
+ # sample data
+ user = model.User(username='barney')
+ self.session.add(user)
+ order = model.Order(order_id=42, customer_name="Fred Flintstone", created_by=user)
+ item1 = model.OrderItem(order_qty=1, order_uom=enum.ORDER_UOM_UNIT,
+ status_code=enum.ORDER_ITEM_STATUS_RECEIVED)
+ order.items.append(item1)
+ item2 = model.OrderItem(order_qty=1, order_uom=enum.ORDER_UOM_UNIT,
+ status_code=enum.ORDER_ITEM_STATUS_RECEIVED)
+ order.items.append(item2)
+ item3 = model.OrderItem(order_qty=1, order_uom=enum.ORDER_UOM_UNIT,
+ status_code=enum.ORDER_ITEM_STATUS_RECEIVED)
+ order.items.append(item3)
+ self.session.add(order)
+ self.session.flush()
+
+ # two items are updated
+ self.assertEqual(item1.status_code, enum.ORDER_ITEM_STATUS_RECEIVED)
+ self.assertEqual(item2.status_code, enum.ORDER_ITEM_STATUS_RECEIVED)
+ self.assertEqual(len(item1.events), 0)
+ self.assertEqual(len(item2.events), 0)
+ handler.process_restock([item1, item2], user)
+ self.assertEqual(item1.status_code, enum.ORDER_ITEM_STATUS_RESTOCKED)
+ self.assertEqual(item2.status_code, enum.ORDER_ITEM_STATUS_RESTOCKED)
+ self.assertEqual(len(item1.events), 1)
+ self.assertEqual(len(item2.events), 1)
+ self.assertIsNone(item1.events[0].note)
+ self.assertIsNone(item2.events[0].note)
+ self.assertEqual(item1.events[0].type_code, enum.ORDER_ITEM_EVENT_RESTOCKED)
+ self.assertEqual(item2.events[0].type_code, enum.ORDER_ITEM_EVENT_RESTOCKED)
+
+ # update last item, with extra note
+ self.assertEqual(item3.status_code, enum.ORDER_ITEM_STATUS_RECEIVED)
+ self.assertEqual(len(item3.events), 0)
+ handler.process_restock([item3], user, note="extra note")
+ self.assertEqual(item3.status_code, enum.ORDER_ITEM_STATUS_RESTOCKED)
+ self.assertEqual(len(item3.events), 2)
+ self.assertIsNone(item3.events[0].note)
+ self.assertEqual(item3.events[1].note, "extra note")
+ self.assertEqual(item3.events[0].type_code, enum.ORDER_ITEM_EVENT_RESTOCKED)
+ self.assertEqual(item3.events[1].type_code, enum.ORDER_ITEM_EVENT_NOTE_ADDED)
diff --git a/tests/web/views/test_orders.py b/tests/web/views/test_orders.py
index a11932d..3d2a8e4 100644
--- a/tests/web/views/test_orders.py
+++ b/tests/web/views/test_orders.py
@@ -1265,24 +1265,28 @@ class TestOrderView(WebTestCase):
self.assertTrue(self.session.query(model.Setting).count() > 1)
-class TestOrderItemView(WebTestCase):
+class OrderItemViewTestMixin:
- def make_view(self):
- return mod.OrderItemView(self.request)
-
- def test_order_handler(self):
+ def test_common_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):
+ def test_common_get_fallback_templates(self):
+ view = self.make_view()
+
+ templates = view.get_fallback_templates('view')
+ self.assertEqual(templates, ['/order-items/view.mako',
+ '/master/view.mako'])
+
+ def test_common_get_query(self):
view = self.make_view()
query = view.get_query(session=self.session)
self.assertIsInstance(query, orm.Query)
- def test_configure_grid(self):
+ def test_common_configure_grid(self):
model = self.app.model
view = self.make_view()
grid = view.make_grid(model_class=model.OrderItem)
@@ -1290,7 +1294,7 @@ class TestOrderItemView(WebTestCase):
view.configure_grid(grid)
self.assertIn('order_id', grid.linked_columns)
- def test_render_order_id(self):
+ def test_common_render_order_id(self):
model = self.app.model
view = self.make_view()
order = model.Order(order_id=42)
@@ -1298,13 +1302,13 @@ class TestOrderItemView(WebTestCase):
order.items.append(item)
self.assertEqual(view.render_order_id(item, None, None), 42)
- def test_render_status_code(self):
+ def test_common_render_status_code(self):
enum = self.app.enum
view = self.make_view()
self.assertEqual(view.render_status_code(None, None, enum.ORDER_ITEM_STATUS_INITIATED),
'initiated')
- def test_grid_row_class(self):
+ def test_common_grid_row_class(self):
model = self.app.model
enum = self.app.enum
view = self.make_view()
@@ -1317,7 +1321,7 @@ class TestOrderItemView(WebTestCase):
item = model.OrderItem(status_code=enum.ORDER_ITEM_STATUS_CANCELED)
self.assertEqual(view.grid_row_class(item, {}, 1), 'has-background-warning')
- def test_configure_form(self):
+ def test_common_configure_form(self):
model = self.app.model
enum = self.app.enum
view = self.make_view()
@@ -1343,7 +1347,7 @@ class TestOrderItemView(WebTestCase):
self.assertIsInstance(schema['order'].typ, OrderRef)
self.assertNotIn('pending_product', form)
- def test_get_template_context(self):
+ def test_common_get_template_context(self):
model = self.app.model
enum = self.app.enum
view = self.make_view()
@@ -1363,7 +1367,7 @@ class TestOrderItemView(WebTestCase):
self.assertIn('order_qty_uom_text', context)
self.assertEqual(context['order_qty_uom_text'], "2 Cases (× 8 = 16 Units)")
- def test_render_event_note(self):
+ def test_common_render_event_note(self):
model = self.app.model
enum = self.app.enum
view = self.make_view()
@@ -1381,7 +1385,7 @@ class TestOrderItemView(WebTestCase):
self.assertIn('class="has-background-info-light"', result)
self.assertIn('testing2', result)
- def test_get_xref_buttons(self):
+ def test_common_get_xref_buttons(self):
self.pyramid_config.add_route('orders.view', '/orders/{uuid}')
model = self.app.model
enum = self.app.enum
@@ -1404,11 +1408,12 @@ class TestOrderItemView(WebTestCase):
self.assertEqual(len(buttons), 1)
self.assertIn("View the Order", buttons[0])
- def test_add_note(self):
- self.pyramid_config.add_route('order_items.view', '/order-items/{uuid}')
+ def test_common_add_note(self):
model = self.app.model
enum = self.app.enum
view = self.make_view()
+ self.pyramid_config.add_route(f'{view.get_route_prefix()}.view',
+ f'{view.get_url_prefix()}/{{uuid}}')
user = model.User(username='barney')
self.session.add(user)
@@ -1429,11 +1434,12 @@ class TestOrderItemView(WebTestCase):
self.assertEqual(item.events[0].type_code, enum.ORDER_ITEM_EVENT_NOTE_ADDED)
self.assertEqual(item.events[0].note, 'testing')
- def test_change_status(self):
- self.pyramid_config.add_route('order_items.view', '/order-items/{uuid}')
+ def test_common_change_status(self):
model = self.app.model
enum = self.app.enum
view = self.make_view()
+ self.pyramid_config.add_route(f'{view.get_route_prefix()}.view',
+ f'{view.get_url_prefix()}/{{uuid}}')
user = model.User(username='barney')
self.session.add(user)
@@ -1459,7 +1465,7 @@ class TestOrderItemView(WebTestCase):
self.assertEqual(len(item.events), 1)
self.assertEqual(item.events[0].type_code, enum.ORDER_ITEM_EVENT_STATUS_CHANGE)
self.assertEqual(item.events[0].note,
- "status changed from 'initiated' to 'placed'")
+ 'status changed from "initiated" to "placed"')
# status change plus note
with patch.object(self.request, 'POST', new={
@@ -1472,10 +1478,10 @@ class TestOrderItemView(WebTestCase):
self.assertEqual(len(item.events), 3)
self.assertEqual(item.events[0].type_code, enum.ORDER_ITEM_EVENT_STATUS_CHANGE)
self.assertEqual(item.events[0].note,
- "status changed from 'initiated' to 'placed'")
+ 'status changed from "initiated" to "placed"')
self.assertEqual(item.events[1].type_code, enum.ORDER_ITEM_EVENT_STATUS_CHANGE)
self.assertEqual(item.events[1].note,
- "status changed from 'placed' to 'received'")
+ 'status changed from "placed" to "received"')
self.assertEqual(item.events[2].type_code, enum.ORDER_ITEM_EVENT_NOTE_ADDED)
self.assertEqual(item.events[2].note, "check it out")
@@ -1486,3 +1492,510 @@ class TestOrderItemView(WebTestCase):
self.assertIsInstance(result, HTTPFound)
self.assertTrue(self.request.session.peek_flash('error'))
self.assertEqual(len(item.events), 3)
+
+
+class TestOrderItemView(OrderItemViewTestMixin, WebTestCase):
+
+ def make_view(self):
+ return mod.OrderItemView(self.request)
+
+ def test_get_order_items(self):
+ model = self.app.model
+ enum = self.app.enum
+ view = self.make_view()
+ self.pyramid_config.add_route('order_items', '/order-items/')
+
+ user = model.User(username='barney')
+ self.session.add(user)
+ order = model.Order(order_id=42, created_by=user)
+ item1 = model.OrderItem(order_qty=1, order_uom=enum.ORDER_UOM_UNIT,
+ status_code=enum.ORDER_ITEM_STATUS_READY)
+ order.items.append(item1)
+ item2 = model.OrderItem(order_qty=1, order_uom=enum.ORDER_UOM_UNIT,
+ status_code=enum.ORDER_ITEM_STATUS_READY)
+ order.items.append(item2)
+ self.session.add(order)
+ self.session.flush()
+
+ with patch.object(view, 'Session', return_value=self.session):
+
+ # no items found
+ self.assertRaises(HTTPFound, view.get_order_items, None)
+ self.assertRaises(HTTPFound, view.get_order_items, '')
+ self.assertRaises(HTTPFound, view.get_order_items, [])
+ self.assertRaises(HTTPFound, view.get_order_items, 'invalid')
+
+ # list of UUID
+ items = view.get_order_items([item1.uuid, item2.uuid])
+ self.assertEqual(len(items), 2)
+ self.assertIs(items[0], item1)
+ self.assertIs(items[1], item2)
+
+ # list of str
+ items = view.get_order_items([item1.uuid.hex, item2.uuid.hex])
+ self.assertEqual(len(items), 2)
+ self.assertIs(items[0], item1)
+ self.assertIs(items[1], item2)
+
+ # comma-delimited str
+ items = view.get_order_items(','.join([item1.uuid.hex, item2.uuid.hex]))
+ self.assertEqual(len(items), 2)
+ self.assertIs(items[0], item1)
+ self.assertIs(items[1], item2)
+
+
+class TestPlacementView(OrderItemViewTestMixin, WebTestCase):
+
+ def make_view(self):
+ return mod.PlacementView(self.request)
+
+ def test_configure_grid(self):
+ model = self.app.model
+ view = self.make_view()
+ grid = view.make_grid(model_class=model.OrderItem)
+
+ # nothing added without perms
+ self.assertEqual(len(grid.tools), 0)
+ view.configure_grid(grid)
+ self.assertFalse(grid.checkable)
+ self.assertEqual(len(grid.tools), 0)
+
+ # button added with perm
+ with patch.object(self.request, 'is_root', new=True):
+ view.configure_grid(grid)
+ self.assertTrue(grid.checkable)
+ self.assertEqual(len(grid.tools), 1)
+ self.assertIn('process_placement', grid.tools)
+ tool = grid.tools['process_placement']
+ self.assertIn('