From 7167c6a7cc64703c582ce95510650468d2a7bb43 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 22 Jan 2025 21:32:15 -0600 Subject: [PATCH] feat: add initial workflow master views, UI features --- src/sideshow/enum.py | 7 + src/sideshow/orders.py | 212 ++++++ src/sideshow/web/menus.py | 21 + src/sideshow/web/templates/contact/index.mako | 159 +++++ .../web/templates/delivery/index.mako | 173 +++++ .../web/templates/placement/index.mako | 104 +++ .../web/templates/receiving/index.mako | 192 ++++++ src/sideshow/web/views/orders.py | 604 +++++++++++++++++- tests/test_orders.py | 359 +++++++++++ tests/web/views/test_orders.py | 555 +++++++++++++++- 10 files changed, 2364 insertions(+), 22 deletions(-) create mode 100644 src/sideshow/web/templates/contact/index.mako create mode 100644 src/sideshow/web/templates/delivery/index.mako create mode 100644 src/sideshow/web/templates/placement/index.mako create mode 100644 src/sideshow/web/templates/receiving/index.mako 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 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 + > + + + + <${b}-modal has-modal-card + % if request.use_oruga: + v-model:active="processContactFailureShowDialog" + % else: + :active.sync="processContactFailureShowDialog" + % endif + > + + + + % endif + + +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + % if master.has_perm('process_contact'): + + % endif + 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 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 + > + + + + % 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 + > + + + + % endif + + +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + + 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 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 + > + + + % endif + + +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + % if master.has_perm('process_placement'): + + % endif + 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 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 + > + + + + % 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 + > + + + + % endif + + +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + + 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('