diff --git a/CHANGELOG.md b/CHANGELOG.md index 04e0a4d..3945002 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,17 @@ +## v0.4.0 (2025-01-23) + +### Feat + +- add initial workflow master views, UI features +- add tools to change order item status; add notes +- add initial support for order item events + +### Fix + +- customize "view order item" page w/ panels +- add loading overlay for expensive calls in orders/create +- hide local customer when not applicable, for order view + ## v0.3.0 (2025-01-13) ### Feat diff --git a/docs/api/sideshow.orders.rst b/docs/api/sideshow.orders.rst new file mode 100644 index 0000000..fd1850e --- /dev/null +++ b/docs/api/sideshow.orders.rst @@ -0,0 +1,6 @@ + +``sideshow.orders`` +=================== + +.. automodule:: sideshow.orders + :members: diff --git a/docs/glossary.rst b/docs/glossary.rst index 0fec4c0..647faa7 100644 --- a/docs/glossary.rst +++ b/docs/glossary.rst @@ -45,12 +45,27 @@ Glossary "submits" the order, the batch is executed which creates a true :term:`order`. + The batch handler is responsible for business logic for the order + creation step; the :term:`order handler` is responsible for + everything thereafter. + + :class:`~sideshow.batch.neworder.NewOrderBatchHandler` is the + default handler for this. + order This is the central focus of the app; it refers to a customer case/special order which is tracked over time, from placement to fulfillment. Each order may have one or more :term:`order items `. + order handler + The :term:`handler` responsible for business logic surrounding + :term:`order` workflows *after* initial creation. (Whereas the + :term:`new order batch` handler is responsible for creation.) + + :class:`~sideshow.orders.OrderHandler` is the default handler for + this. + order item This is effectively a "line item" within an :term:`order`. It represents a particular product, with quantity and pricing diff --git a/docs/index.rst b/docs/index.rst index e1673e3..3bc3a5f 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -39,6 +39,7 @@ the narrative docs are pretty scant. That will eventually change. api/sideshow.db.model.orders api/sideshow.db.model.products api/sideshow.enum + api/sideshow.orders api/sideshow.web api/sideshow.web.app api/sideshow.web.forms diff --git a/pyproject.toml b/pyproject.toml index 15d4539..5b65704 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "hatchling.build" [project] name = "Sideshow" -version = "0.3.0" +version = "0.4.0" description = "Case/Special Order Tracker" readme = "README.md" authors = [ @@ -33,7 +33,7 @@ license = {text = "GNU General Public License v3+"} requires-python = ">= 3.8" dependencies = [ "psycopg2", - "WuttaWeb>=0.20.1", + "WuttaWeb>=0.20.5", ] [project.optional-dependencies] diff --git a/src/sideshow/batch/neworder.py b/src/sideshow/batch/neworder.py index e2ce3a4..6295407 100644 --- a/src/sideshow/batch/neworder.py +++ b/src/sideshow/batch/neworder.py @@ -44,6 +44,9 @@ class NewOrderBatchHandler(BatchHandler): :class:`~sideshow.db.model.batch.neworder.NewOrderBatch` tracks all user input until they "submit" (execute) at which point an :class:`~sideshow.db.model.orders.Order` is created. + + After the batch has executed the :term:`order handler` takes over + responsibility for the rest of the order lifecycle. """ model_class = NewOrderBatch @@ -991,9 +994,35 @@ class NewOrderBatchHandler(BatchHandler): order.items.append(item) # set item status - item.status_code = enum.ORDER_ITEM_STATUS_INITIATED + self.set_initial_item_status(item, user) self.app.progress_loop(convert, rows, progress, message="Converting batch rows to order items") session.flush() return order + + def set_initial_item_status(self, item, user, **kwargs): + """ + Set the initial status and attach event(s) for the given item. + + This is called from :meth:`make_new_order()` for each item + after it is added to the order. + + Default logic will set status to + :data:`~sideshow.enum.ORDER_ITEM_STATUS_READY` and attach 2 + events: + + * :data:`~sideshow.enum.ORDER_ITEM_EVENT_INITIATED` + * :data:`~sideshow.enum.ORDER_ITEM_EVENT_READY` + + :param item: :class:`~sideshow.db.model.orders.OrderItem` + being added to the new order. + + :param user: + :class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who + is performing the action. + """ + enum = self.app.enum + item.add_event(enum.ORDER_ITEM_EVENT_INITIATED, user) + item.add_event(enum.ORDER_ITEM_EVENT_READY, user) + item.status_code = enum.ORDER_ITEM_STATUS_READY diff --git a/src/sideshow/db/alembic/versions/7a6df83afbd4_initial_order_tables.py b/src/sideshow/db/alembic/versions/7a6df83afbd4_initial_order_tables.py index be6eee8..6a0a589 100644 --- a/src/sideshow/db/alembic/versions/7a6df83afbd4_initial_order_tables.py +++ b/src/sideshow/db/alembic/versions/7a6df83afbd4_initial_order_tables.py @@ -156,6 +156,19 @@ def upgrade() -> None: sa.PrimaryKeyConstraint('uuid', name=op.f('pk_order_item')) ) + # sideshow_order_item_event + op.create_table('sideshow_order_item_event', + sa.Column('uuid', wuttjamaican.db.util.UUID(), nullable=False), + sa.Column('item_uuid', wuttjamaican.db.util.UUID(), nullable=False), + sa.Column('type_code', sa.Integer(), nullable=False), + sa.Column('occurred', sa.DateTime(timezone=True), nullable=False), + sa.Column('actor_uuid', wuttjamaican.db.util.UUID(), nullable=False), + sa.Column('note', sa.Text(), nullable=True), + sa.ForeignKeyConstraint(['actor_uuid'], ['user.uuid'], name=op.f('fk_sideshow_order_item_event_actor_uuid_user')), + sa.ForeignKeyConstraint(['item_uuid'], ['sideshow_order_item.uuid'], name=op.f('fk_sideshow_order_item_event_item_uuid_sideshow_order_item')), + sa.PrimaryKeyConstraint('uuid', name=op.f('pk_sideshow_order_item_event')) + ) + # sideshow_batch_neworder op.create_table('sideshow_batch_neworder', sa.Column('uuid', wuttjamaican.db.util.UUID(), nullable=False), @@ -227,6 +240,9 @@ def downgrade() -> None: op.drop_table('sideshow_batch_neworder_row') op.drop_table('sideshow_batch_neworder') + # sideshow_order_item_event + op.drop_table('sideshow_order_item_event') + # sideshow_order_item op.drop_table('sideshow_order_item') diff --git a/src/sideshow/db/model/__init__.py b/src/sideshow/db/model/__init__.py index 28d09f3..f53dd27 100644 --- a/src/sideshow/db/model/__init__.py +++ b/src/sideshow/db/model/__init__.py @@ -32,6 +32,7 @@ Primary :term:`data models `: * :class:`~sideshow.db.model.orders.Order` * :class:`~sideshow.db.model.orders.OrderItem` +* :class:`~sideshow.db.model.orders.OrderItemEvent` * :class:`~sideshow.db.model.customers.LocalCustomer` * :class:`~sideshow.db.model.products.LocalProduct` * :class:`~sideshow.db.model.customers.PendingCustomer` @@ -49,7 +50,7 @@ from wuttjamaican.db.model import * # sideshow models from .customers import LocalCustomer, PendingCustomer from .products import LocalProduct, PendingProduct -from .orders import Order, OrderItem +from .orders import Order, OrderItem, OrderItemEvent # batch models from .batch.neworder import NewOrderBatch, NewOrderBatchRow diff --git a/src/sideshow/db/model/orders.py b/src/sideshow/db/model/orders.py index f694028..2cadeaa 100644 --- a/src/sideshow/db/model/orders.py +++ b/src/sideshow/db/model/orders.py @@ -2,7 +2,7 @@ ################################################################################ # # Sideshow -- Case/Special Order Tracker -# Copyright © 2024 Lance Edgar +# Copyright © 2024-2025 Lance Edgar # # This file is part of Sideshow. # @@ -332,6 +332,16 @@ class OrderItem(model.Base): applicable/known. """) + events = orm.relationship( + 'OrderItemEvent', + order_by='OrderItemEvent.occurred, OrderItemEvent.uuid', + cascade='all, delete-orphan', + cascade_backrefs=False, + back_populates='item', + doc=""" + List of :class:`OrderItemEvent` records for the item. + """) + @property def full_description(self): """ """ @@ -344,3 +354,52 @@ class OrderItem(model.Base): def __str__(self): return self.full_description + + def add_event(self, type_code, user, **kwargs): + """ + Convenience method to add a new :class:`OrderItemEvent` for + the item. + """ + kwargs['type_code'] = type_code + kwargs['actor'] = user + self.events.append(OrderItemEvent(**kwargs)) + + +class OrderItemEvent(model.Base): + """ + An event in the life of an :term:`order item`. + """ + __tablename__ = 'sideshow_order_item_event' + + uuid = model.uuid_column() + + item_uuid = model.uuid_fk_column('sideshow_order_item.uuid', nullable=False) + item = orm.relationship( + OrderItem, + cascade_backrefs=False, + back_populates='events', + doc=""" + Reference to the :class:`OrderItem` to which the event + pertains. + """) + + type_code = sa.Column(sa.Integer, nullable=False, doc=""" + Code indicating the type of event; values must be defined in + :data:`~sideshow.enum.ORDER_ITEM_EVENT`. + """) + + occurred = sa.Column(sa.DateTime(timezone=True), nullable=False, default=datetime.datetime.now, doc=""" + Date and time when the event occurred. + """) + + actor_uuid = model.uuid_fk_column('user.uuid', nullable=False) + actor = orm.relationship( + model.User, + doc=""" + :class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who + performed the action. + """) + + note = sa.Column(sa.Text(), nullable=True, doc=""" + Optional note recorded for the event. + """) diff --git a/src/sideshow/enum.py b/src/sideshow/enum.py index 2bd1e1a..a761d02 100644 --- a/src/sideshow/enum.py +++ b/src/sideshow/enum.py @@ -2,7 +2,7 @@ ################################################################################ # # Sideshow -- Case/Special Order Tracker -# Copyright © 2024 Lance Edgar +# Copyright © 2024-2025 Lance Edgar # # This file is part of Sideshow. # @@ -108,23 +108,101 @@ class PendingProductStatus(Enum): ######################################## ORDER_ITEM_STATUS_UNINITIATED = 1 +""" +Indicates the item is "not yet initiated" - this probably is not +useful but exists as a possibility just in case. +""" + ORDER_ITEM_STATUS_INITIATED = 10 +""" +Indicates the item is "initiated" (aka. created) but not yet "ready" +for buyer/PO. This may imply the price needs confirmation etc. +""" + ORDER_ITEM_STATUS_PAID_BEFORE = 50 +""" +Indicates the customer has fully paid for the item, up-front before +the buyer places PO etc. It implies the item is not yet "ready" for +some reason. +""" + # TODO: deprecate / remove this one ORDER_ITEM_STATUS_PAID = ORDER_ITEM_STATUS_PAID_BEFORE + ORDER_ITEM_STATUS_READY = 100 +""" +Indicates the item is "ready" for buyer to include it on a vendor +purchase order. +""" + ORDER_ITEM_STATUS_PLACED = 200 +""" +Indicates the buyer has placed a vendor purchase order which includes +this item. The item is thereby "on order" until the truck arrives. +""" + ORDER_ITEM_STATUS_RECEIVED = 300 +""" +Indicates the item has been received as part of a vendor delivery. +The item is thereby "on hand" until customer comes in for pickup. +""" + ORDER_ITEM_STATUS_CONTACTED = 350 +""" +Indicates the customer has been notified that the item is "on hand" +and awaiting their pickup. +""" + ORDER_ITEM_STATUS_CONTACT_FAILED = 375 +""" +Indicates the attempt(s) to notify customer have failed. The item is +on hand but the customer does not know to pickup. +""" + ORDER_ITEM_STATUS_DELIVERED = 500 +""" +Indicates the customer has picked up the item. +""" + ORDER_ITEM_STATUS_PAID_AFTER = 550 +""" +Indicates the customer has fully paid for the item, as part of their +pickup. This completes the cycle for orders which require payment on +the tail end. +""" + ORDER_ITEM_STATUS_CANCELED = 900 +""" +Indicates the order item has been canceled. +""" + ORDER_ITEM_STATUS_REFUND_PENDING = 910 +""" +Indicates the order item has been canceled, and the customer is due a +(pending) refund. +""" + ORDER_ITEM_STATUS_REFUNDED = 920 +""" +Indicates the order item has been canceled, and the customer has been +given a refund. +""" + ORDER_ITEM_STATUS_RESTOCKED = 930 +""" +Indicates the product has been restocked, e.g. after the order item +was canceled. +""" + ORDER_ITEM_STATUS_EXPIRED = 940 +""" +Indicates the order item and/or product has expired. +""" + ORDER_ITEM_STATUS_INACTIVE = 950 +""" +Indicates the order item has become inactive. +""" ORDER_ITEM_STATUS = OrderedDict([ (ORDER_ITEM_STATUS_UNINITIATED, "uninitiated"), @@ -144,3 +222,176 @@ ORDER_ITEM_STATUS = OrderedDict([ (ORDER_ITEM_STATUS_EXPIRED, "expired"), (ORDER_ITEM_STATUS_INACTIVE, "inactive"), ]) +""" +Dict of possible code -> label options for :term:`order item` status. + +These codes are referenced by: + +* :attr:`sideshow.db.model.orders.OrderItem.status_code` +""" + + +######################################## +# Order Item Event Type +######################################## + +ORDER_ITEM_EVENT_INITIATED = 10 +""" +Indicates the item was "initiated" - this occurs when the +:term:`order` is first created. +""" + +ORDER_ITEM_EVENT_PRICE_CONFIRMED = 20 +""" +Indicates the item's price was confirmed by a user who is authorized +to do that. +""" + +ORDER_ITEM_EVENT_PAYMENT_RECEIVED = 50 +""" +Indicates payment was received for the item. This may occur toward +the beginning, or toward the end, of the item's life cycle depending +on app configuration etc. +""" + +# TODO: deprecate / remove this +ORDER_ITEM_EVENT_PAID = ORDER_ITEM_EVENT_PAYMENT_RECEIVED + +ORDER_ITEM_EVENT_READY = 100 +""" +Indicates the item has become "ready" for buyer placement on a new +vendor purchase order. Often this will occur when the :term:`order` +is first created, if the data is suitable. However this may be +delayed if e.g. the price needs confirmation. +""" + +ORDER_ITEM_EVENT_CUSTOMER_RESOLVED = 120 +""" +Indicates the customer for the :term:`order` has been assigned to a +"proper" (existing) account. This may happen (after the fact) if the +order was first created with a new/unknown customer. +""" + +ORDER_ITEM_EVENT_PRODUCT_RESOLVED = 140 +""" +Indicates the product for the :term:`order item` has been assigned to +a "proper" (existing) product record. This may happen (after the +fact) if the order was first created with a new/unknown product. +""" + +ORDER_ITEM_EVENT_PLACED = 200 +""" +Indicates the buyer has placed a vendor purchase order which includes +this item. So the item is "on order" until the truck arrives. +""" + +ORDER_ITEM_EVENT_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 +delivery. The item is set aside and is "on hand" until customer comes +in to pick it up. +""" + +ORDER_ITEM_EVENT_CONTACTED = 350 +""" +Indicates the customer has been contacted, to notify them of the item +being on hand and ready for pickup. +""" + +ORDER_ITEM_EVENT_CONTACT_FAILED = 375 +""" +Indicates an attempt was made to contact the customer, to notify them +of item being on hand, but the attempt failed, e.g. due to bad phone +or email on file. +""" + +ORDER_ITEM_EVENT_DELIVERED = 500 +""" +Indicates the customer has picked up the item. +""" + +ORDER_ITEM_EVENT_STATUS_CHANGE = 700 +""" +Indicates a manual status change was made. Such an event should +ideally contain a note with further explanation. +""" + +ORDER_ITEM_EVENT_NOTE_ADDED = 750 +""" +Indicates an arbitrary note was added. +""" + +ORDER_ITEM_EVENT_CANCELED = 900 +""" +Indicates the :term:`order item` was canceled. +""" + +ORDER_ITEM_EVENT_REFUND_PENDING = 910 +""" +Indicates the customer is due a (pending) refund for the item. +""" + +ORDER_ITEM_EVENT_REFUNDED = 920 +""" +Indicates the customer has been refunded for the item. +""" + +ORDER_ITEM_EVENT_RESTOCKED = 930 +""" +Indicates the product has been restocked, e.g. due to the order item +being canceled. +""" + +ORDER_ITEM_EVENT_EXPIRED = 940 +""" +Indicates the order item (or its product) has expired. +""" + +ORDER_ITEM_EVENT_INACTIVE = 950 +""" +Indicates the order item has become inactive. +""" + +ORDER_ITEM_EVENT_OTHER = 999 +""" +Arbitrary event type which does not signify anything in particular. +If used, the event should be given an explanatory note. +""" + +ORDER_ITEM_EVENT = OrderedDict([ + (ORDER_ITEM_EVENT_INITIATED, "initiated"), + (ORDER_ITEM_EVENT_PRICE_CONFIRMED, "price confirmed"), + (ORDER_ITEM_EVENT_PAYMENT_RECEIVED, "payment received"), + (ORDER_ITEM_EVENT_READY, "ready to proceed"), + (ORDER_ITEM_EVENT_CUSTOMER_RESOLVED, "customer resolved"), + (ORDER_ITEM_EVENT_PRODUCT_RESOLVED, "product resolved"), + (ORDER_ITEM_EVENT_PLACED, "placed with vendor"), + (ORDER_ITEM_EVENT_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"), + (ORDER_ITEM_EVENT_DELIVERED, "delivered"), + (ORDER_ITEM_EVENT_STATUS_CHANGE, "changed status"), + (ORDER_ITEM_EVENT_NOTE_ADDED, "added note"), + (ORDER_ITEM_EVENT_CANCELED, "canceled"), + (ORDER_ITEM_EVENT_REFUND_PENDING, "refund pending"), + (ORDER_ITEM_EVENT_REFUNDED, "refunded"), + (ORDER_ITEM_EVENT_RESTOCKED, "restocked"), + (ORDER_ITEM_EVENT_EXPIRED, "expired"), + (ORDER_ITEM_EVENT_INACTIVE, "inactive"), + (ORDER_ITEM_EVENT_OTHER, "other"), +]) +""" +Dict of possible code -> label options for :term:`order item` event +types. + +These codes are referenced by: + +* :attr:`sideshow.db.model.orders.OrderItemEvent.type_code` +""" diff --git a/src/sideshow/orders.py b/src/sideshow/orders.py new file mode 100644 index 0000000..9f99e53 --- /dev/null +++ b/src/sideshow/orders.py @@ -0,0 +1,310 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Sideshow -- Case/Special Order Tracker +# Copyright © 2024-2025 Lance Edgar +# +# This file is part of Sideshow. +# +# Sideshow is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Sideshow is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Sideshow. If not, see . +# +################################################################################ +""" +Sideshow Order Handler +""" + +from wuttjamaican.app import GenericHandler + + +class OrderHandler(GenericHandler): + """ + Base class and default implementation for the :term:`order + handler`. + + This is responsible for business logic involving customer orders + after they have been first created. (The :term:`new order batch` + handler is responsible for creation logic.) + """ + + def get_order_qty_uom_text(self, order_qty, order_uom, case_size=None, html=False): + """ + Return the display text for a given order quantity. + + Default logic will return something like ``"3 Cases (x 6 = 18 + Units)"``. + + :param order_qty: Numeric quantity. + + :param order_uom: An order UOM constant; should be something + from :data:`~sideshow.enum.ORDER_UOM`. + + :param case_size: Case size for the product, if known. + + :param html: Whether the return value should include any HTML. + If false (the default), it will be plain text only. If + true, will replace the ``x`` character with ``×``. + + :returns: Display text. + """ + enum = self.app.enum + + if order_uom == enum.ORDER_UOM_CASE: + if case_size is None: + case_qty = unit_qty = '??' + else: + case_qty = self.app.render_quantity(case_size) + unit_qty = self.app.render_quantity(order_qty * case_size) + CS = enum.ORDER_UOM[enum.ORDER_UOM_CASE] + EA = enum.ORDER_UOM[enum.ORDER_UOM_UNIT] + order_qty = self.app.render_quantity(order_qty) + times = '×' if html else 'x' + return (f"{order_qty} {CS} ({times} {case_qty} = {unit_qty} {EA})") + + # units + unit_qty = self.app.render_quantity(order_qty) + EA = enum.ORDER_UOM[enum.ORDER_UOM_UNIT] + return f"{unit_qty} {EA}" + + def item_status_to_variant(self, status_code): + """ + Return a Buefy style variant for the given status code. + + Default logic will return ``None`` for "normal" item status, + but may return ``'warning'`` for some (e.g. canceled). + + :param status_code: The status code for an order item. + + :returns: Style variant string (e.g. ``'warning'``) or + ``None``. + """ + enum = self.app.enum + if status_code in (enum.ORDER_ITEM_STATUS_CANCELED, + enum.ORDER_ITEM_STATUS_REFUND_PENDING, + enum.ORDER_ITEM_STATUS_REFUNDED, + enum.ORDER_ITEM_STATUS_RESTOCKED, + enum.ORDER_ITEM_STATUS_EXPIRED, + enum.ORDER_ITEM_STATUS_INACTIVE): + return 'warning' + + 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/order-items/view.mako b/src/sideshow/web/templates/order-items/view.mako new file mode 100644 index 0000000..201540a --- /dev/null +++ b/src/sideshow/web/templates/order-items/view.mako @@ -0,0 +1,411 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/view.mako" /> + +<%def name="content_title()"> + (${app.enum.ORDER_ITEM_STATUS[item.status_code]}) + ${instance_title} + + +<%def name="extra_styles()"> + ${parent.extra_styles()} + + + +<%def name="page_content()"> +
+
+ + + + + +
+
+ + + + + +
+
+ +
+ +
+ + + +<%def name="render_vue_templates()"> + ${parent.render_vue_templates()} + ${events_grid.render_vue_template()} + + +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + + + +<%def name="make_vue_components()"> + ${parent.make_vue_components()} + ${events_grid.render_vue_finalize()} + diff --git a/src/sideshow/web/templates/orders/create.mako b/src/sideshow/web/templates/orders/create.mako index 7ed78db..28f4e6b 100644 --- a/src/sideshow/web/templates/orders/create.mako +++ b/src/sideshow/web/templates/orders/create.mako @@ -81,6 +81,7 @@
+
@@ -143,7 +144,7 @@ icon-pack="fas" icon-left="redo" :disabled="refreshingCustomer"> - {{ refreshingCustomer ? "Refreshing" : "Refresh" }} + {{ refreshingCustomer ? "Working, please wait..." : "Refresh" }}
@@ -348,6 +349,7 @@ >
+ <${b}-tabs :animated="false" % if request.use_oruga: @@ -829,6 +831,7 @@ batchTotalPriceDisplay: ${json.dumps(normalized_batch['total_price_display'])|n}, customerPanelOpen: false, + customerLoading: false, customerIsKnown: ${json.dumps(customer_is_known)|n}, customerID: ${json.dumps(customer_id)|n}, customerName: ${json.dumps(customer_name)|n}, @@ -853,6 +856,7 @@ editItemRow: null, editItemShowDialog: false, + editItemLoading: false, itemDialogSaving: false, % if request.use_oruga: itemDialogTab: 'product', @@ -1132,6 +1136,7 @@ }, customerChanged(customerID, callback) { + this.customerLoading = true const params = {} if (customerID) { @@ -1149,7 +1154,9 @@ if (callback) { callback() } + this.customerLoading = false }, response => { + this.customerLoading = false this.$buefy.toast.open({ message: "Update failed: " + (response.data.error || "(unknown error)"), type: 'is-danger', @@ -1159,9 +1166,9 @@ }, refreshCustomer() { - this.refreshingContact = true + this.refreshingCustomer = true this.customerChanged(this.customerID, () => { - this.refreshingContact = false + this.refreshingCustomer = false this.$buefy.toast.open({ message: "Customer info has been refreshed.", type: 'is-success', @@ -1321,6 +1328,7 @@ productChanged(productID) { if (productID) { + this.editItemLoading = true const params = { action: 'get_product_info', product_id: productID, @@ -1364,8 +1372,11 @@ this.refreshProductDescription += 1 this.refreshTotalPrice += 1 + this.editItemLoading = false + }, response => { this.clearProduct() + this.editItemLoading = false }) } else { this.clearProduct() @@ -1514,6 +1525,7 @@ itemDialogAttemptSave() { this.itemDialogSaving = true + this.editItemLoading = true const params = { order_qty: this.productQuantity, @@ -1551,9 +1563,11 @@ this.batchTotalPriceDisplay = response.data.batch.total_price_display this.itemDialogSaving = false + this.editItemLoading = false this.editItemShowDialog = false }, response => { this.itemDialogSaving = false + this.editItemLoading = false }) }, 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 68ad984..cf0c054 100644 --- a/src/sideshow/web/views/orders.py +++ b/src/sideshow/web/views/orders.py @@ -28,12 +28,16 @@ import decimal import logging import colander +import sqlalchemy as sa from sqlalchemy import orm +from webhelpers2.html import tags, HTML + from wuttaweb.views import MasterView from wuttaweb.forms.schema import UserRef, WuttaMoney, WuttaQuantity, WuttaEnum, WuttaDictEnum from sideshow.db.model import Order, OrderItem +from sideshow.orders import OrderHandler from sideshow.batch.neworder import NewOrderBatchHandler from sideshow.web.forms.schema import (OrderRef, LocalCustomerRef, LocalProductRef, @@ -58,10 +62,16 @@ class OrderView(MasterView): Note that the "edit" view is not exposed here; user must perform various other workflow actions to modify the order. + .. attribute:: order_handler + + Reference to the :term:`order handler` as returned by + :meth:`get_order_handler()`. This gets set in the constructor. + .. attribute:: batch_handler - Reference to the new order batch handler, as returned by - :meth:`get_batch_handler()`. This gets set in the constructor. + Reference to the :term:`new order batch` handler, as returned + by :meth:`get_batch_handler()`. This gets set in the + constructor. """ model_class = Order editable = False @@ -142,21 +152,22 @@ class OrderView(MasterView): 'unit_price_reg', ] - def configure_grid(self, g): - """ """ - super().configure_grid(g) + def __init__(self, request, context=None): + super().__init__(request, context=context) + self.order_handler = self.get_order_handler() - # order_id - g.set_link('order_id') + def get_order_handler(self): + """ + Returns the configured :term:`order handler`. - # customer_id - g.set_link('customer_id') + You normally would not need to call this, and can use + :attr:`order_handler` instead. - # customer_name - g.set_link('customer_name') - - # total_price - g.set_renderer('total_price', g.render_currency) + :rtype: :class:`~sideshow.orders.OrderHandler` + """ + if hasattr(self, 'order_handler'): + return self.order_handler + return OrderHandler(self.config) def get_batch_handler(self): """ @@ -174,6 +185,22 @@ class OrderView(MasterView): return self.batch_handler return self.app.get_batch_handler('neworder') + def configure_grid(self, g): + """ """ + super().configure_grid(g) + + # order_id + g.set_link('order_id') + + # customer_id + g.set_link('customer_id') + + # customer_name + g.set_link('customer_name') + + # total_price + g.set_renderer('total_price', g.render_currency) + def create(self): """ Instead of the typical "create" view, this displays a "wizard" @@ -670,8 +697,6 @@ class OrderView(MasterView): def normalize_row(self, row): """ """ - enum = self.app.enum - data = { 'uuid': row.uuid.hex, 'sequence': row.sequence, @@ -750,21 +775,8 @@ class OrderView(MasterView): } # display text for order qty/uom - if row.order_uom == enum.ORDER_UOM_CASE: - order_qty = self.app.render_quantity(row.order_qty) - if row.case_size is None: - case_qty = unit_qty = '??' - else: - case_qty = self.app.render_quantity(row.case_size) - unit_qty = self.app.render_quantity(row.order_qty * row.case_size) - CS = enum.ORDER_UOM[enum.ORDER_UOM_CASE] - EA = enum.ORDER_UOM[enum.ORDER_UOM_UNIT] - data['order_qty_display'] = (f"{order_qty} {CS} " - f"(× {case_qty} = {unit_qty} {EA})") - else: - unit_qty = self.app.render_quantity(row.order_qty) - EA = enum.ORDER_UOM[enum.ORDER_UOM_UNIT] - data['order_qty_display'] = f"{unit_qty} {EA}" + data['order_qty_display'] = self.order_handler.get_order_qty_uom_text( + row.order_qty, row.order_uom, case_size=row.case_size, html=True) return data @@ -823,7 +835,7 @@ class OrderView(MasterView): def configure_row_grid(self, g): """ """ super().configure_row_grid(g) - enum = self.app.enum + # enum = self.app.enum # sequence g.set_label('sequence', "Seq.", column_only=True) @@ -851,6 +863,15 @@ class OrderView(MasterView): # status_code g.set_renderer('status_code', self.render_status_code) + # TODO: upstream should set this automatically + g.row_class = self.row_grid_row_class + + def row_grid_row_class(self, item, data, i): + """ """ + variant = self.order_handler.item_status_to_variant(item.status_code) + if variant: + return f'has-background-{variant}' + def render_status_code(self, item, key, value): """ """ enum = self.app.enum @@ -957,11 +978,26 @@ 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. + + .. attribute:: order_handler + + Reference to the :term:`order handler` as returned by + :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 @@ -1030,6 +1066,29 @@ class OrderItemView(MasterView): 'payment_transaction_number', ] + def __init__(self, request, context=None): + super().__init__(request, context=context) + self.order_handler = self.get_order_handler() + + def get_order_handler(self): + """ + Returns the configured :term:`order handler`. + + You normally would not need to call this, and can use + :attr:`order_handler` instead. + + :rtype: :class:`~sideshow.orders.OrderHandler` + """ + if hasattr(self, 'order_handler'): + return self.order_handler + return OrderHandler(self.config) + + def get_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) @@ -1084,12 +1143,11 @@ class OrderItemView(MasterView): enum = self.app.enum return enum.ORDER_ITEM_STATUS[value] - def get_instance_title(self, item): + def grid_row_class(self, item, data, i): """ """ - enum = self.app.enum - title = str(item) - status = enum.ORDER_ITEM_STATUS[item.status_code] - return f"({status}) {title}" + variant = self.order_handler.item_status_to_variant(item.status_code) + if variant: + return f'has-background-{variant}' def configure_form(self, f): """ """ @@ -1139,6 +1197,54 @@ class OrderItemView(MasterView): # paid_amount f.set_node('paid_amount', WuttaMoney(self.request)) + def get_template_context(self, context): + """ """ + if self.viewing: + model = self.app.model + enum = self.app.enum + route_prefix = self.get_route_prefix() + item = context['instance'] + form = context['form'] + + context['item'] = item + context['order'] = item.order + context['order_qty_uom_text'] = self.order_handler.get_order_qty_uom_text( + item.order_qty, item.order_uom, case_size=item.case_size, html=True) + context['item_status_variant'] = self.order_handler.item_status_to_variant(item.status_code) + + grid = self.make_grid(key=f'{route_prefix}.view.events', + model_class=model.OrderItemEvent, + data=item.events, + columns=[ + 'occurred', + 'actor', + 'type_code', + 'note', + ], + labels={ + 'occurred': "Date/Time", + 'actor': "User", + 'type_code': "Event Type", + }) + grid.set_renderer('type_code', lambda e, k, v: enum.ORDER_ITEM_EVENT[v]) + grid.set_renderer('note', self.render_event_note) + if self.request.has_perm('users.view'): + grid.set_renderer('actor', lambda e, k, v: tags.link_to( + e.actor, self.request.route_url('users.view', uuid=e.actor.uuid))) + form.add_grid_vue_context(grid) + context['events_grid'] = grid + + return context + + def render_event_note(self, event, key, value): + """ """ + enum = self.app.enum + if event.type_code == enum.ORDER_ITEM_EVENT_NOTE_ADDED: + return HTML.tag('span', class_='has-background-info-light', + style='padding: 0.25rem 0.5rem;', + c=[value]) + return value + def get_xref_buttons(self, item): """ """ buttons = super().get_xref_buttons(item) @@ -1151,6 +1257,685 @@ class OrderItemView(MasterView): return buttons + def add_note(self): + """ + View which adds a note to an order item. This is POST-only; + will redirect back to the item view. + """ + enum = self.app.enum + item = self.get_instance() + + item.add_event(enum.ORDER_ITEM_EVENT_NOTE_ADDED, self.request.user, + note=self.request.POST['note']) + + return self.redirect(self.get_action_url('view', item)) + + def change_status(self): + """ + View which changes status for an order item. This is + POST-only; will redirect back to the item view. + """ + model = self.app.model + enum = self.app.enum + main_item = self.get_instance() + session = self.Session() + redirect = self.redirect(self.get_action_url('view', main_item)) + + extra_note = self.request.POST.get('note') + + # validate new status + new_status_code = int(self.request.POST['new_status']) + if new_status_code not in enum.ORDER_ITEM_STATUS: + self.request.session.flash("Invalid status code", 'error') + return redirect + new_status_text = enum.ORDER_ITEM_STATUS[new_status_code] + + # locate all items to which new status will be applied + items = [main_item] + # uuids = self.request.POST.get('uuids') + # if uuids: + # for uuid in uuids.split(','): + # item = Session.get(model.OrderItem, uuid) + # if item: + # items.append(item) + + # update item(s) + for item in items: + if item.status_code != new_status_code: + + # event: change status + note = 'status changed from "{}" to "{}"'.format( + enum.ORDER_ITEM_STATUS[item.status_code], + new_status_text) + item.add_event(enum.ORDER_ITEM_EVENT_STATUS_CHANGE, + self.request.user, note=note) + + # event: add note + if extra_note: + item.add_event(enum.ORDER_ITEM_EVENT_NOTE_ADDED, + self.request.user, note=extra_note) + + # new status + item.status_code = new_status_code + + 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() + model_title = cls.get_model_title() + model_title_plural = cls.get_model_title_plural() + + # fix perm group + config.add_wutta_permission_group(permission_prefix, + model_title_plural, + overwrite=False) + + # add note + config.add_route(f'{route_prefix}.add_note', + f'{instance_url_prefix}/add_note', + request_method='POST') + config.add_view(cls, attr='add_note', + route_name=f'{route_prefix}.add_note', + renderer='json', + permission=f'{permission_prefix}.add_note') + config.add_wutta_permission(permission_prefix, + f'{permission_prefix}.add_note', + f"Add note for {model_title}") + + # change status + config.add_route(f'{route_prefix}.change_status', + f'{instance_url_prefix}/change-status', + request_method='POST') + config.add_view(cls, attr='change_status', + route_name=f'{route_prefix}.change_status', + renderer='json', + permission=f'{permission_prefix}.change_status') + config.add_wutta_permission(permission_prefix, + f'{permission_prefix}.change_status', + 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() @@ -1161,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/batch/test_neworder.py b/tests/batch/test_neworder.py index b3fbf4a..42e42dc 100644 --- a/tests/batch/test_neworder.py +++ b/tests/batch/test_neworder.py @@ -1109,6 +1109,20 @@ class TestNewOrderBatchHandler(DataTestCase): self.assertEqual(item.unit_cost, decimal.Decimal('3.99')) self.assertEqual(item.unit_price_reg, decimal.Decimal('5.99')) + def test_set_initial_item_status(self): + model = self.app.model + enum = self.app.enum + handler = self.make_handler() + user = model.User(username='barney') + item = model.OrderItem() + self.assertIsNone(item.status_code) + self.assertEqual(len(item.events), 0) + handler.set_initial_item_status(item, user) + self.assertEqual(item.status_code, enum.ORDER_ITEM_STATUS_READY) + self.assertEqual(len(item.events), 2) + self.assertEqual(item.events[0].type_code, enum.ORDER_ITEM_EVENT_INITIATED) + self.assertEqual(item.events[1].type_code, enum.ORDER_ITEM_EVENT_READY) + def test_execute(self): model = self.app.model enum = self.app.enum diff --git a/tests/db/model/test_orders.py b/tests/db/model/test_orders.py index 7169991..21ee153 100644 --- a/tests/db/model/test_orders.py +++ b/tests/db/model/test_orders.py @@ -19,6 +19,11 @@ class TestOrder(DataTestCase): class TestOrderItem(DataTestCase): + def make_config(self, **kw): + config = super().make_config(**kw) + config.setdefault('wutta.enum_spec', 'sideshow.enum') + return config + def test_full_description(self): item = mod.OrderItem() @@ -44,3 +49,15 @@ class TestOrderItem(DataTestCase): product_description='Vinegar', product_size='32oz') self.assertEqual(str(item), "Bragg Vinegar 32oz") + + def test_add_event(self): + model = self.app.model + enum = self.app.enum + user = model.User(username='barney') + item = mod.OrderItem() + self.assertEqual(item.events, []) + item.add_event(enum.ORDER_ITEM_EVENT_INITIATED, user) + item.add_event(enum.ORDER_ITEM_EVENT_READY, user) + self.assertEqual(len(item.events), 2) + self.assertEqual(item.events[0].type_code, enum.ORDER_ITEM_EVENT_INITIATED) + self.assertEqual(item.events[1].type_code, enum.ORDER_ITEM_EVENT_READY) diff --git a/tests/test_orders.py b/tests/test_orders.py new file mode 100644 index 0000000..5937045 --- /dev/null +++ b/tests/test_orders.py @@ -0,0 +1,419 @@ +# -*- coding: utf-8; -*- + +from wuttjamaican.testing import DataTestCase + +from sideshow import orders as mod + + +class TestOrderHandler(DataTestCase): + + def make_config(self, **kwargs): + config = super().make_config(**kwargs) + config.setdefault('wutta.model_spec', 'sideshow.db.model') + config.setdefault('wutta.enum_spec', 'sideshow.enum') + return config + + def make_handler(self): + return mod.OrderHandler(self.config) + + def test_get_order_qty_uom_text(self): + enum = self.app.enum + handler = self.make_handler() + + # typical, plain text + text = handler.get_order_qty_uom_text(2, enum.ORDER_UOM_CASE, case_size=12) + self.assertEqual(text, "2 Cases (x 12 = 24 Units)") + + # typical w/ html + text = handler.get_order_qty_uom_text(2, enum.ORDER_UOM_CASE, case_size=12, html=True) + self.assertEqual(text, "2 Cases (× 12 = 24 Units)") + + # unknown case size + text = handler.get_order_qty_uom_text(2, enum.ORDER_UOM_CASE) + self.assertEqual(text, "2 Cases (x ?? = ?? Units)") + text = handler.get_order_qty_uom_text(2, enum.ORDER_UOM_CASE, html=True) + self.assertEqual(text, "2 Cases (× ?? = ?? Units)") + + # units only + text = handler.get_order_qty_uom_text(2, enum.ORDER_UOM_UNIT) + self.assertEqual(text, "2 Units") + text = handler.get_order_qty_uom_text(2, enum.ORDER_UOM_UNIT, html=True) + self.assertEqual(text, "2 Units") + + def test_item_status_to_variant(self): + enum = self.app.enum + handler = self.make_handler() + + # typical + self.assertIsNone(handler.item_status_to_variant(enum.ORDER_ITEM_STATUS_INITIATED)) + self.assertIsNone(handler.item_status_to_variant(enum.ORDER_ITEM_STATUS_READY)) + self.assertIsNone(handler.item_status_to_variant(enum.ORDER_ITEM_STATUS_PLACED)) + self.assertIsNone(handler.item_status_to_variant(enum.ORDER_ITEM_STATUS_RECEIVED)) + self.assertIsNone(handler.item_status_to_variant(enum.ORDER_ITEM_STATUS_CONTACTED)) + self.assertIsNone(handler.item_status_to_variant(enum.ORDER_ITEM_STATUS_PAID)) + + # warning + self.assertEqual(handler.item_status_to_variant(enum.ORDER_ITEM_STATUS_CANCELED), 'warning') + self.assertEqual(handler.item_status_to_variant(enum.ORDER_ITEM_STATUS_REFUND_PENDING), 'warning') + self.assertEqual(handler.item_status_to_variant(enum.ORDER_ITEM_STATUS_REFUNDED), 'warning') + 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 89d5039..3d2a8e4 100644 --- a/tests/web/views/test_orders.py +++ b/tests/web/views/test_orders.py @@ -11,6 +11,7 @@ from pyramid.response import Response from wuttaweb.forms.schema import WuttaMoney from sideshow.batch.neworder import NewOrderBatchHandler +from sideshow.orders import OrderHandler from sideshow.testing import WebTestCase from sideshow.web.views import orders as mod from sideshow.web.forms.schema import OrderRef, PendingProductRef @@ -30,6 +31,13 @@ class TestOrderView(WebTestCase): def make_handler(self): return NewOrderBatchHandler(self.config) + def test_order_handler(self): + view = self.make_view() + handler = view.order_handler + self.assertIsInstance(handler, OrderHandler) + handler2 = view.get_order_handler() + self.assertIs(handler2, handler) + def test_configure_grid(self): model = self.app.model view = self.make_view() @@ -1175,6 +1183,19 @@ class TestOrderView(WebTestCase): view.configure_row_grid(grid) self.assertIn('product_scancode', grid.linked_columns) + def test_row_grid_row_class(self): + model = self.app.model + enum = self.app.enum + view = self.make_view() + + # typical + item = model.OrderItem(status_code=enum.ORDER_ITEM_STATUS_READY) + self.assertIsNone(view.row_grid_row_class(item, {}, 1)) + + # warning + item = model.OrderItem(status_code=enum.ORDER_ITEM_STATUS_CANCELED) + self.assertEqual(view.row_grid_row_class(item, {}, 1), 'has-background-warning') + def test_render_status_code(self): enum = self.app.enum view = self.make_view() @@ -1244,17 +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_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) @@ -1262,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) @@ -1270,25 +1302,26 @@ 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_get_instance_title(self): + def test_common_grid_row_class(self): model = self.app.model enum = self.app.enum view = self.make_view() - item = model.OrderItem(product_brand='Bragg', - product_description='Vinegar', - product_size='32oz', - status_code=enum.ORDER_ITEM_STATUS_INITIATED) - title = view.get_instance_title(item) - self.assertEqual(title, "(initiated) Bragg Vinegar 32oz") + # typical + item = model.OrderItem(status_code=enum.ORDER_ITEM_STATUS_READY) + self.assertIsNone(view.grid_row_class(item, {}, 1)) - def test_configure_form(self): + # warning + item = model.OrderItem(status_code=enum.ORDER_ITEM_STATUS_CANCELED) + self.assertEqual(view.grid_row_class(item, {}, 1), 'has-background-warning') + + def test_common_configure_form(self): model = self.app.model enum = self.app.enum view = self.make_view() @@ -1314,7 +1347,45 @@ class TestOrderItemView(WebTestCase): self.assertIsInstance(schema['order'].typ, OrderRef) self.assertNotIn('pending_product', form) - def test_get_xref_buttons(self): + def test_common_get_template_context(self): + model = self.app.model + enum = self.app.enum + view = self.make_view() + + order = model.Order() + item = model.OrderItem(order_qty=2, order_uom=enum.ORDER_UOM_CASE, case_size=8) + order.items.append(item) + + with patch.object(self.request, 'is_root', new=True): + with patch.object(view, 'viewing', new=True): + form = view.make_model_form(model_instance=item) + context = view.get_template_context({'instance': item, 'form': form}) + self.assertIn('item', context) + self.assertIs(context['item'], item) + self.assertIn('order', context) + self.assertIs(context['order'], order) + self.assertIn('order_qty_uom_text', context) + self.assertEqual(context['order_qty_uom_text'], "2 Cases (× 8 = 16 Units)") + + def test_common_render_event_note(self): + model = self.app.model + enum = self.app.enum + view = self.make_view() + + # typical + event = model.OrderItemEvent(type_code=enum.ORDER_ITEM_EVENT_READY, note='testing') + result = view.render_event_note(event, 'note', 'testing') + self.assertEqual(result, 'testing') + + # user note + event = model.OrderItemEvent(type_code=enum.ORDER_ITEM_EVENT_NOTE_ADDED, note='testing2') + result = view.render_event_note(event, 'note', 'testing2') + self.assertNotEqual(result, 'testing2') + self.assertIn('