Compare commits

...

6 commits

23 changed files with 3749 additions and 63 deletions

View file

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

View file

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

View file

@ -45,12 +45,27 @@ Glossary
"submits" the order, the batch is executed which creates a true
:term:`order`.
The batch handler is responsible for business logic for the order
creation step; the :term:`order handler` is responsible for
everything thereafter.
:class:`~sideshow.batch.neworder.NewOrderBatchHandler` is the
default handler for this.
order
This is the central focus of the app; it refers to a customer
case/special order which is tracked over time, from placement to
fulfillment. Each order may have one or more :term:`order items
<order item>`.
order handler
The :term:`handler` responsible for business logic surrounding
:term:`order` workflows *after* initial creation. (Whereas the
:term:`new order batch` handler is responsible for creation.)
:class:`~sideshow.orders.OrderHandler` is the default handler for
this.
order item
This is effectively a "line item" within an :term:`order`. It
represents a particular product, with quantity and pricing

View file

@ -39,6 +39,7 @@ the narrative docs are pretty scant. That will eventually change.
api/sideshow.db.model.orders
api/sideshow.db.model.products
api/sideshow.enum
api/sideshow.orders
api/sideshow.web
api/sideshow.web.app
api/sideshow.web.forms

View file

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

View file

@ -44,6 +44,9 @@ class NewOrderBatchHandler(BatchHandler):
:class:`~sideshow.db.model.batch.neworder.NewOrderBatch` tracks
all user input until they "submit" (execute) at which point an
:class:`~sideshow.db.model.orders.Order` is created.
After the batch has executed the :term:`order handler` takes over
responsibility for the rest of the order lifecycle.
"""
model_class = NewOrderBatch
@ -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

View file

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

View file

@ -32,6 +32,7 @@ Primary :term:`data models <data model>`:
* :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

View file

@ -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.
""")

View file

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

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

@ -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 <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Sideshow Order Handler
"""
from wuttjamaican.app import GenericHandler
class OrderHandler(GenericHandler):
"""
Base class and default implementation for the :term:`order
handler`.
This is responsible for business logic involving customer orders
after they have been first created. (The :term:`new order batch`
handler is responsible for creation logic.)
"""
def get_order_qty_uom_text(self, order_qty, order_uom, case_size=None, html=False):
"""
Return the display text for a given order quantity.
Default logic will return something like ``"3 Cases (x 6 = 18
Units)"``.
:param order_qty: Numeric quantity.
:param order_uom: An order UOM constant; should be something
from :data:`~sideshow.enum.ORDER_UOM`.
:param case_size: Case size for the product, if known.
:param html: Whether the return value should include any HTML.
If false (the default), it will be plain text only. If
true, will replace the ``x`` character with ``&times;``.
:returns: Display text.
"""
enum = self.app.enum
if order_uom == enum.ORDER_UOM_CASE:
if case_size is None:
case_qty = unit_qty = '??'
else:
case_qty = self.app.render_quantity(case_size)
unit_qty = self.app.render_quantity(order_qty * case_size)
CS = enum.ORDER_UOM[enum.ORDER_UOM_CASE]
EA = enum.ORDER_UOM[enum.ORDER_UOM_UNIT]
order_qty = self.app.render_quantity(order_qty)
times = '&times;' if html else 'x'
return (f"{order_qty} {CS} ({times} {case_qty} = {unit_qty} {EA})")
# units
unit_qty = self.app.render_quantity(order_qty)
EA = enum.ORDER_UOM[enum.ORDER_UOM_UNIT]
return f"{unit_qty} {EA}"
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

View file

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

View file

@ -0,0 +1,159 @@
## -*- coding: utf-8; -*-
<%inherit file="/master/index.mako" />
<%def name="render_grid_tag()">
% if master.has_perm('process_contact'):
${grid.render_vue_tag(**{'@process-contact-success': "processContactSuccessInit", '@process-contact-failure': "processContactFailureInit"})}
% else:
${grid.render_vue_tag()}
% endif
</%def>
<%def name="page_content()">
${parent.page_content()}
% if master.has_perm('process_contact'):
<${b}-modal has-modal-card
% if request.use_oruga:
v-model:active="processContactSuccessShowDialog"
% else:
:active.sync="processContactSuccessShowDialog"
% endif
>
<div class="modal-card">
${h.form(url(f'{route_prefix}.process_contact_success'), ref='processContactSuccessForm')}
${h.csrf_token(request)}
${h.hidden('item_uuids', **{':value': 'processContactSuccessUuids.join()'})}
<header class="modal-card-head">
<p class="modal-card-title">Process Contact Success</p>
</header>
<section class="modal-card-body">
<p class="block">
This will mark {{ processContactSuccessUuids.length }}
item{{ processContactSuccessUuids.length > 1 ? 's' : '' }}
as being "contacted".
</p>
<b-field horizontal label="Note">
<b-input name="note"
v-model="processContactSuccessNote"
ref="processContactSuccessNote"
type="textarea" />
</b-field>
</section>
<footer class="modal-card-foot">
<b-button type="is-primary"
@click="processContactSuccessSubmit()"
:disabled="processContactSuccessSubmitting"
icon-pack="fas"
icon-left="save">
{{ processContactSuccessSubmitting ? "Working, please wait..." : "Save" }}
</b-button>
<b-button @click="processContactSuccessShowDialog = false">
Cancel
</b-button>
</footer>
${h.end_form()}
</div>
</${b}-modal>
<${b}-modal has-modal-card
% if request.use_oruga:
v-model:active="processContactFailureShowDialog"
% else:
:active.sync="processContactFailureShowDialog"
% endif
>
<div class="modal-card">
${h.form(url(f'{route_prefix}.process_contact_failure'), ref='processContactFailureForm')}
${h.csrf_token(request)}
${h.hidden('item_uuids', **{':value': 'processContactFailureUuids.join()'})}
<header class="modal-card-head">
<p class="modal-card-title">Process Contact Failure</p>
</header>
<section class="modal-card-body">
<p class="block">
This will mark {{ processContactFailureUuids.length }}
item{{ processContactFailureUuids.length > 1 ? 's' : '' }}
as being "contact failed".
</p>
<b-field horizontal label="Note">
<b-input name="note"
v-model="processContactFailureNote"
ref="processContactFailureNote"
type="textarea" />
</b-field>
</section>
<footer class="modal-card-foot">
<b-button type="is-primary"
@click="processContactFailureSubmit()"
:disabled="processContactFailureSubmitting"
icon-pack="fas"
icon-left="save">
{{ processContactFailureSubmitting ? "Working, please wait..." : "Save" }}
</b-button>
<b-button @click="processContactFailureShowDialog = false">
Cancel
</b-button>
</footer>
${h.end_form()}
</div>
</${b}-modal>
% endif
</%def>
<%def name="modify_vue_vars()">
${parent.modify_vue_vars()}
% if master.has_perm('process_contact'):
<script>
ThisPageData.processContactSuccessShowDialog = false
ThisPageData.processContactSuccessUuids = []
ThisPageData.processContactSuccessNote = null
ThisPageData.processContactSuccessSubmitting = false
ThisPage.methods.processContactSuccessInit = function(items) {
this.processContactSuccessUuids = items.map((item) => item.uuid)
this.processContactSuccessNote = null
this.processContactSuccessShowDialog = true
this.$nextTick(() => {
this.$refs.processContactSuccessNote.focus()
})
}
ThisPage.methods.processContactSuccessSubmit = function() {
this.processContactSuccessSubmitting = true
this.$refs.processContactSuccessForm.submit()
}
ThisPageData.processContactFailureShowDialog = false
ThisPageData.processContactFailureUuids = []
ThisPageData.processContactFailureNote = null
ThisPageData.processContactFailureSubmitting = false
ThisPage.methods.processContactFailureInit = function(items) {
this.processContactFailureUuids = items.map((item) => item.uuid)
this.processContactFailureNote = null
this.processContactFailureShowDialog = true
this.$nextTick(() => {
this.$refs.processContactFailureNote.focus()
})
}
ThisPage.methods.processContactFailureSubmit = function() {
this.processContactFailureSubmitting = true
this.$refs.processContactFailureForm.submit()
}
</script>
% endif
</%def>

View file

@ -0,0 +1,173 @@
## -*- coding: utf-8; -*-
<%inherit file="/master/index.mako" />
<%def name="render_grid_tag()">
% if master.has_perm('process_delivery') and master.has_perm('process_restock'):
${grid.render_vue_tag(**{'@process-delivery': "processDeliveryInit", '@process-restock': "processRestockInit"})}
% elif master.has_perm('process_delivery'):
${grid.render_vue_tag(**{'@process-delivery': "processDeliveryInit"})}
% elif master.has_perm('process_restock'):
${grid.render_vue_tag(**{'@process-restock': "processRestockInit"})}
% else:
${grid.render_vue_tag()}
% endif
</%def>
<%def name="page_content()">
${parent.page_content()}
% if master.has_perm('process_delivery'):
<${b}-modal has-modal-card
% if request.use_oruga:
v-model:active="processDeliveryShowDialog"
% else:
:active.sync="processDeliveryShowDialog"
% endif
>
<div class="modal-card">
${h.form(url(f'{route_prefix}.process_delivery'), ref='processDeliveryForm')}
${h.csrf_token(request)}
${h.hidden('item_uuids', **{':value': 'processDeliveryUuids.join()'})}
<header class="modal-card-head">
<p class="modal-card-title">Process Delivery</p>
</header>
<section class="modal-card-body">
<p class="block">
This will mark {{ processDeliveryUuids.length }}
item{{ processDeliveryUuids.length > 1 ? 's' : '' }}
as being "delivered".
</p>
<b-field horizontal label="Note">
<b-input name="note"
v-model="processDeliveryNote"
ref="processDeliveryNote"
type="textarea" />
</b-field>
</section>
<footer class="modal-card-foot">
<b-button type="is-primary"
@click="processDeliverySubmit()"
:disabled="processDeliverySubmitting"
icon-pack="fas"
icon-left="save">
{{ processDeliverySubmitting ? "Working, please wait..." : "Save" }}
</b-button>
<b-button @click="processDeliveryShowDialog = false">
Cancel
</b-button>
</footer>
${h.end_form()}
</div>
</${b}-modal>
% endif
% if master.has_perm('process_restock'):
<${b}-modal has-modal-card
% if request.use_oruga:
v-model:active="processRestockShowDialog"
% else:
:active.sync="processRestockShowDialog"
% endif
>
<div class="modal-card">
${h.form(url(f'{route_prefix}.process_restock'), ref='processRestockForm')}
${h.csrf_token(request)}
${h.hidden('item_uuids', **{':value': 'processRestockUuids.join()'})}
<header class="modal-card-head">
<p class="modal-card-title">Process Restock</p>
</header>
<section class="modal-card-body">
<p class="block">
This will mark {{ processRestockUuids.length }}
item{{ processRestockUuids.length > 1 ? 's' : '' }}
as being "restocked".
</p>
<b-field horizontal label="Note">
<b-input name="note"
v-model="processRestockNote"
ref="processRestockNote"
type="textarea" />
</b-field>
</section>
<footer class="modal-card-foot">
<b-button type="is-primary"
@click="processRestockSubmit()"
:disabled="processRestockSubmitting"
icon-pack="fas"
icon-left="save">
{{ processRestockSubmitting ? "Working, please wait..." : "Save" }}
</b-button>
<b-button @click="processRestockShowDialog = false">
Cancel
</b-button>
</footer>
${h.end_form()}
</div>
</${b}-modal>
% endif
</%def>
<%def name="modify_vue_vars()">
${parent.modify_vue_vars()}
<script>
% if master.has_perm('process_delivery'):
ThisPageData.processDeliveryShowDialog = false
ThisPageData.processDeliveryUuids = []
ThisPageData.processDeliveryNote = null
ThisPageData.processDeliverySubmitting = false
ThisPage.methods.processDeliveryInit = function(items) {
this.processDeliveryUuids = items.map((item) => item.uuid)
this.processDeliveryNote = null
this.processDeliveryShowDialog = true
this.$nextTick(() => {
this.$refs.processDeliveryNote.focus()
})
}
ThisPage.methods.processDeliverySubmit = function() {
this.processDeliverySubmitting = true
this.$refs.processDeliveryForm.submit()
}
% endif
% if master.has_perm('process_restock'):
ThisPageData.processRestockShowDialog = false
ThisPageData.processRestockUuids = []
ThisPageData.processRestockNote = null
ThisPageData.processRestockSubmitting = false
ThisPage.methods.processRestockInit = function(items) {
this.processRestockUuids = items.map((item) => item.uuid)
this.processRestockNote = null
this.processRestockShowDialog = true
this.$nextTick(() => {
this.$refs.processRestockNote.focus()
})
}
ThisPage.methods.processRestockSubmit = function() {
this.processRestockSubmitting = true
this.$refs.processRestockForm.submit()
}
% endif
</script>
</%def>

View file

@ -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>
<%def name="extra_styles()">
${parent.extra_styles()}
<style>
nav .field .field-label .label {
white-space: nowrap;
width: 10rem;
}
</style>
</%def>
<%def name="page_content()">
<div style="padding: 2rem; display: flex; justify-content: space-evenly; gap: 2rem;">
<div style="flex-grow: 1;">
<nav class="panel" style="width: 100%;">
<p class="panel-heading">Order Item</p>
<div class="panel-block">
<div style="width: 100%;">
<b-field horizontal label="ID">
<span>${h.link_to(f"Order ID {order.order_id}", url('orders.view', uuid=order.uuid))} &mdash; Item #${item.sequence}</span>
</b-field>
<b-field horizontal label="Order Qty">
<span>${order_qty_uom_text|n}</span>
</b-field>
% if item.discount_percent:
<b-field horizontal label="Discount">
<span>${app.render_percent(item.discount_percent)}</span>
</b-field>
% endif
<b-field horizontal label="Total Due">
<span>${app.render_currency(item.total_price)}</span>
</b-field>
<b-field horizontal label="Total Paid">
<span>${app.render_currency(item.paid_amount)}</span>
</b-field>
<b-field horizontal label="Status">
<div style="display: flex; gap: 1rem; align-items: center;">
<span
% if item_status_variant:
class="has-background-${item_status_variant}"
% endif
% if master.has_perm('change_status'):
style="padding: 0.25rem;"
% endif
>
${app.enum.ORDER_ITEM_STATUS[item.status_code]}
</span>
% if master.has_perm('change_status'):
<b-button type="is-primary"
icon-pack="fas"
icon-left="edit"
@click="changeStatusInit()">
Change Status
</b-button>
<${b}-modal
% if request.use_oruga:
v-model:active="changeStatusShowDialog"
% else:
:active.sync="changeStatusShowDialog"
% endif
>
<div class="card">
<div class="card-content">
<h4 class="block is-size-4">Change Item Status</h4>
<b-field horizontal label="Current Status">
<span>{{ changeStatusCodes[changeStatusOldCode] }}</span>
</b-field>
<br />
<b-field horizontal label="New Status"
:type="changeStatusNewCode ? null : 'is-danger'">
<b-select v-model="changeStatusNewCode">
<option v-for="status in changeStatusCodeOptions"
:key="status.key"
:value="status.key">
{{ status.label }}
</option>
</b-select>
</b-field>
<b-field label="Note">
<b-input v-model="changeStatusNote"
type="textarea" rows="4" />
</b-field>
<br />
<div class="buttons">
<b-button type="is-primary"
:disabled="changeStatusSaveDisabled"
icon-pack="fas"
icon-left="save"
@click="changeStatusSave()">
{{ changeStatusSubmitting ? "Working, please wait..." : "Update Status" }}
</b-button>
<b-button @click="changeStatusShowDialog = false">
Cancel
</b-button>
</div>
</div>
</div>
</${b}-modal>
${h.form(master.get_action_url('change_status', item), ref='changeStatusForm')}
${h.csrf_token(request)}
${h.hidden('new_status', **{'v-model': 'changeStatusNewCode'})}
## ${h.hidden('uuids', **{':value': 'changeStatusCheckedRows.map((row) => {return row.uuid}).join()'})}
${h.hidden('note', **{':value': 'changeStatusNote'})}
${h.end_form()}
% endif
</div>
</b-field>
</div>
</div>
</nav>
<nav class="panel" style="width: 100%;">
<p class="panel-heading">Pricing</p>
<div class="panel-block">
<div style="width: 100%;">
<b-field horizontal label="Unit Cost">
<span>${app.render_currency(item.unit_cost, scale=4)}</span>
</b-field>
<b-field horizontal label="Unit Price Reg.">
<span>${app.render_currency(item.unit_price_reg)}</span>
</b-field>
<b-field horizontal label="Unit Price Sale">
<span>${app.render_currency(item.unit_price_sale)}</span>
</b-field>
<b-field horizontal label="Sale Ends">
<span>${app.render_datetime(item.sale_ends)}</span>
</b-field>
<b-field horizontal label="Unit Price Quoted">
<span>${app.render_currency(item.unit_price_quoted)}</span>
</b-field>
<b-field horizontal label="Case Size">
<span>${app.render_quantity(item.case_size)}</span>
</b-field>
<b-field horizontal label="Case Price Quoted">
<span>${app.render_currency(item.case_price_quoted)}</span>
</b-field>
</div>
</div>
</nav>
</div>
<div style="flex-grow: 1;">
<nav class="panel" style="width: 100%;">
<p class="panel-heading">Customer</p>
<div class="panel-block">
<div style="width: 100%;">
<b-field horizontal label="Customer ID">
<span>${order.customer_id}</span>
</b-field>
% if not order.customer_id and order.local_customer:
<b-field horizontal label="Local Customer">
<span>${h.link_to(order.local_customer, url('local_customers.view', uuid=order.local_customer.uuid))}</span>
</b-field>
% endif
% if not order.customer_id and order.pending_customer:
<b-field horizontal label="Pending Customer">
<span>${h.link_to(order.pending_customer, url('pending_customers.view', uuid=order.pending_customer.uuid))}</span>
</b-field>
% endif
<b-field horizontal label="Customer Name">
<span>${order.customer_name}</span>
</b-field>
<b-field horizontal label="Phone Number">
<span>${order.phone_number}</span>
</b-field>
<b-field horizontal label="Email Address">
<span>${order.email_address}</span>
</b-field>
</div>
</div>
</nav>
<nav class="panel" style="width: 100%;">
<p class="panel-heading">Product</p>
<div class="panel-block">
<div style="width: 100%;">
<b-field horizontal label="Product ID">
<span>${item.product_id}</span>
</b-field>
% if not item.product_id and item.local_product:
<b-field horizontal label="Local Product">
<span>${h.link_to(item.local_product, url('local_products.view', uuid=order.local_product.uuid))}</span>
</b-field>
% endif
% if not item.product_id and item.pending_product:
<b-field horizontal label="Pending Product">
<span>${h.link_to(item.pending_product, url('pending_products.view', uuid=order.pending_product.uuid))}</span>
</b-field>
% endif
<b-field horizontal label="Scancode">
<span>${item.product_scancode}</span>
</b-field>
<b-field horizontal label="Brand">
<span>${item.product_brand}</span>
</b-field>
<b-field horizontal label="Description">
<span>${item.product_description}</span>
</b-field>
<b-field horizontal label="Size">
<span>${item.product_size}</span>
</b-field>
<b-field horizontal label="Sold by Weight">
<span>${app.render_boolean(item.product_weighed)}</span>
</b-field>
<b-field horizontal label="Department">
<span>${item.department_name}</span>
</b-field>
<b-field horizontal label="Special Order">
<span>${app.render_boolean(item.special_order)}</span>
</b-field>
</div>
</div>
</nav>
</div>
</div>
<div style="padding: 0 2rem;">
<nav class="panel" style="width: 100%;">
<p class="panel-heading"
% if master.has_perm('add_note'):
style="display: flex; gap: 2rem; align-items: center;"
% endif
>
<span>Events</span>
% if master.has_perm('add_note'):
<b-button type="is-primary"
icon-pack="fas"
icon-left="plus"
@click="addNoteInit()">
Add Note
</b-button>
<${b}-modal has-modal-card
% if request.use_oruga:
v-model:active="addNoteShowDialog"
% else:
:active.sync="addNoteShowDialog"
% endif
>
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">Add Note</p>
</header>
<section class="modal-card-body">
<b-field>
<b-input type="textarea" rows="8"
v-model="addNoteText"
ref="addNoteText"
expanded />
</b-field>
## <b-field>
## <b-checkbox v-model="addNoteApplyAll">
## Apply to all products on this order
## </b-checkbox>
## </b-field>
</section>
<footer class="modal-card-foot">
<b-button type="is-primary"
@click="addNoteSave()"
:disabled="addNoteSaveDisabled"
icon-pack="fas"
icon-left="save">
{{ addNoteSubmitting ? "Working, please wait..." : "Add Note" }}
</b-button>
<b-button @click="addNoteShowDialog = false">
Cancel
</b-button>
</footer>
</div>
</${b}-modal>
${h.form(master.get_action_url('add_note', item), ref='addNoteForm')}
${h.csrf_token(request)}
${h.hidden('note', **{':value': 'addNoteText'})}
## ${h.hidden('uuids', **{':value': 'changeStatusCheckedRows.map((row) => {return row.uuid}).join()'})}
${h.end_form()}
% endif
</p>
<div class="panel-block">
<div style="width: 100%;">
${events_grid.render_table_element()}
</div>
</div>
</nav>
</div>
</%def>
<%def name="render_vue_templates()">
${parent.render_vue_templates()}
${events_grid.render_vue_template()}
</%def>
<%def name="modify_vue_vars()">
${parent.modify_vue_vars()}
<script>
% if master.has_perm('add_note'):
ThisPageData.addNoteShowDialog = false
ThisPageData.addNoteText = null
## ThisPageData.addNoteApplyAll = false
ThisPageData.addNoteSubmitting = false
ThisPage.computed.addNoteSaveDisabled = function() {
if (!this.addNoteText) {
return true
}
if (this.addNoteSubmitting) {
return true
}
return false
}
ThisPage.methods.addNoteInit = function() {
this.addNoteText = null
## this.addNoteApplyAll = false
this.addNoteShowDialog = true
this.$nextTick(() => {
this.$refs.addNoteText.focus()
})
}
ThisPage.methods.addNoteSave = function() {
this.addNoteSubmitting = true
this.$refs.addNoteForm.submit()
}
% endif
% if master.has_perm('change_status'):
ThisPageData.changeStatusCodes = ${json.dumps(app.enum.ORDER_ITEM_STATUS)|n}
ThisPageData.changeStatusCodeOptions = ${json.dumps([dict(key=k, label=v) for k, v in app.enum.ORDER_ITEM_STATUS.items()])|n}
ThisPageData.changeStatusShowDialog = false
ThisPageData.changeStatusOldCode = ${instance.status_code}
ThisPageData.changeStatusNewCode = null
ThisPageData.changeStatusNote = null
ThisPageData.changeStatusSubmitting = false
ThisPage.computed.changeStatusSaveDisabled = function() {
if (!this.changeStatusNewCode) {
return true
}
if (this.changeStatusSubmitting) {
return true
}
return false
}
ThisPage.methods.changeStatusInit = function() {
this.changeStatusNewCode = null
// clear out any checked rows
// this.changeStatusCheckedRows.length = 0
this.changeStatusNote = null
this.changeStatusShowDialog = true
}
ThisPage.methods.changeStatusSave = function() {
if (this.changeStatusNewCode == this.changeStatusOldCode) {
alert("You chose the same status it already had...")
return
}
this.changeStatusSubmitting = true
this.$refs.changeStatusForm.submit()
}
% endif
## TODO: ugh the hackiness
ThisPageData.gridContext = {
% for key, data in form.grid_vue_context.items():
'${key}': ${json.dumps(data)|n},
% endfor
}
</script>
</%def>
<%def name="make_vue_components()">
${parent.make_vue_components()}
${events_grid.render_vue_finalize()}
</%def>

View file

@ -81,6 +81,7 @@
</template>
<div class="panel-block">
<b-loading v-model="customerLoading" :is-full-page="false" />
<div style="width: 100%;">
<div style="display: flex; flex-direction: row;">
@ -143,7 +144,7 @@
icon-pack="fas"
icon-left="redo"
:disabled="refreshingCustomer">
{{ refreshingCustomer ? "Refreshing" : "Refresh" }}
{{ refreshingCustomer ? "Working, please wait..." : "Refresh" }}
</b-button>
</div>
</b-field>
@ -348,6 +349,7 @@
>
<div class="card">
<div class="card-content">
<b-loading v-model="editItemLoading" :is-full-page="false" />
<${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
})
},

View file

@ -0,0 +1,104 @@
## -*- coding: utf-8; -*-
<%inherit file="/master/index.mako" />
<%def name="render_grid_tag()">
% if master.has_perm('process_placement'):
${grid.render_vue_tag(**{'@process-placement': "processPlacementInit"})}
% else:
${grid.render_vue_tag()}
% endif
</%def>
<%def name="page_content()">
${parent.page_content()}
% if master.has_perm('process_placement'):
<${b}-modal has-modal-card
% if request.use_oruga:
v-model:active="processPlacementShowDialog"
% else:
:active.sync="processPlacementShowDialog"
% endif
>
<div class="modal-card">
${h.form(url(f'{route_prefix}.process_placement'), ref='processPlacementForm')}
${h.csrf_token(request)}
${h.hidden('item_uuids', **{':value': 'processPlacementUuids.join()'})}
<header class="modal-card-head">
<p class="modal-card-title">Process Placement</p>
</header>
<section class="modal-card-body">
<p class="block">
This will mark {{ processPlacementUuids.length }}
item{{ processPlacementUuids.length > 1 ? 's' : '' }} as
being "placed" on order from vendor.
</p>
<b-field horizontal label="Vendor"
:type="processPlacementVendor ? null : 'is-danger'">
<b-input name="vendor_name"
v-model="processPlacementVendor"
ref="processPlacementVendor" />
</b-field>
<b-field horizontal label="PO Number">
<b-input name="po_number"
v-model="processPlacementNumber" />
</b-field>
<b-field horizontal label="Note">
<b-input name="note"
v-model="processPlacementNote"
type="textarea" />
</b-field>
</section>
<footer class="modal-card-foot">
<b-button type="is-primary"
@click="processPlacementSubmit()"
:disabled="!processPlacementVendor || processPlacementSubmitting"
icon-pack="fas"
icon-left="save">
{{ processPlacementSubmitting ? "Working, please wait..." : "Save" }}
</b-button>
<b-button @click="processPlacementShowDialog = false">
Cancel
</b-button>
</footer>
${h.end_form()}
</div>
</${b}-modal>
% endif
</%def>
<%def name="modify_vue_vars()">
${parent.modify_vue_vars()}
% if master.has_perm('process_placement'):
<script>
ThisPageData.processPlacementShowDialog = false
ThisPageData.processPlacementUuids = []
ThisPageData.processPlacementVendor = null
ThisPageData.processPlacementNumber = null
ThisPageData.processPlacementNote = null
ThisPageData.processPlacementSubmitting = false
ThisPage.methods.processPlacementInit = function(items) {
this.processPlacementUuids = items.map((item) => item.uuid)
this.processPlacementVendor = null
this.processPlacementNumber = null
this.processPlacementNote = null
this.processPlacementShowDialog = true
this.$nextTick(() => {
this.$refs.processPlacementVendor.focus()
})
}
ThisPage.methods.processPlacementSubmit = function() {
this.processPlacementSubmitting = true
this.$refs.processPlacementForm.submit()
}
</script>
% endif
</%def>

View file

@ -0,0 +1,192 @@
## -*- coding: utf-8; -*-
<%inherit file="/master/index.mako" />
<%def name="render_grid_tag()">
% if master.has_perm('process_receiving') and master.has_perm('process_reorder'):
${grid.render_vue_tag(**{'@process-receiving': "processReceivingInit", '@process-reorder': "processReorderInit"})}
% elif master.has_perm('process_receiving'):
${grid.render_vue_tag(**{'@process-receiving': "processReceivingInit"})}
% elif master.has_perm('process_reorder'):
${grid.render_vue_tag(**{'@process-reorder': "processReorderInit"})}
% else:
${grid.render_vue_tag()}
% endif
</%def>
<%def name="page_content()">
${parent.page_content()}
% if master.has_perm('process_receiving'):
<${b}-modal has-modal-card
% if request.use_oruga:
v-model:active="processReceivingShowDialog"
% else:
:active.sync="processReceivingShowDialog"
% endif
>
<div class="modal-card">
${h.form(url(f'{route_prefix}.process_receiving'), ref='processReceivingForm')}
${h.csrf_token(request)}
${h.hidden('item_uuids', **{':value': 'processReceivingUuids.join()'})}
<header class="modal-card-head">
<p class="modal-card-title">Process Receiving</p>
</header>
<section class="modal-card-body">
<p class="block">
This will mark {{ processReceivingUuids.length }}
item{{ processReceivingUuids.length > 1 ? 's' : '' }}
as being "received" from vendor.
</p>
<b-field horizontal label="Vendor"
:type="processReceivingVendor ? null : 'is-danger'">
<b-input name="vendor_name"
v-model="processReceivingVendor"
ref="processReceivingVendor" />
</b-field>
<b-field horizontal label="Invoice Number">
<b-input name="invoice_number"
v-model="processReceivingInvoiceNumber" />
</b-field>
<b-field horizontal label="PO Number">
<b-input name="po_number"
v-model="processReceivingPoNumber" />
</b-field>
<b-field horizontal label="Note">
<b-input name="note"
v-model="processReceivingNote"
type="textarea" />
</b-field>
</section>
<footer class="modal-card-foot">
<b-button type="is-primary"
@click="processReceivingSubmit()"
:disabled="!processReceivingVendor || processReceivingSubmitting"
icon-pack="fas"
icon-left="save">
{{ processReceivingSubmitting ? "Working, please wait..." : "Save" }}
</b-button>
<b-button @click="processReceivingShowDialog = false">
Cancel
</b-button>
</footer>
${h.end_form()}
</div>
</${b}-modal>
% endif
% if master.has_perm('process_reorder'):
<${b}-modal has-modal-card
% if request.use_oruga:
v-model:active="processReorderShowDialog"
% else:
:active.sync="processReorderShowDialog"
% endif
>
<div class="modal-card">
${h.form(url(f'{route_prefix}.process_reorder'), ref='processReorderForm')}
${h.csrf_token(request)}
${h.hidden('item_uuids', **{':value': 'processReorderUuids.join()'})}
<header class="modal-card-head">
<p class="modal-card-title">Process Re-Order</p>
</header>
<section class="modal-card-body">
<p class="block">
This will mark {{ processReorderUuids.length }}
item{{ processReorderUuids.length > 1 ? 's' : '' }}
as being "ready" for placement (again).
</p>
<b-field horizontal label="Note">
<b-input name="note"
v-model="processReorderNote"
ref="processReorderNote"
type="textarea" />
</b-field>
</section>
<footer class="modal-card-foot">
<b-button type="is-primary"
@click="processReorderSubmit()"
:disabled="processReorderSubmitting"
icon-pack="fas"
icon-left="save">
{{ processReorderSubmitting ? "Working, please wait..." : "Save" }}
</b-button>
<b-button @click="processReorderShowDialog = false">
Cancel
</b-button>
</footer>
${h.end_form()}
</div>
</${b}-modal>
% endif
</%def>
<%def name="modify_vue_vars()">
${parent.modify_vue_vars()}
<script>
% if master.has_perm('process_receiving'):
ThisPageData.processReceivingShowDialog = false
ThisPageData.processReceivingUuids = []
ThisPageData.processReceivingVendor = null
ThisPageData.processReceivingInvoiceNumber = null
ThisPageData.processReceivingPoNumber = null
ThisPageData.processReceivingNote = null
ThisPageData.processReceivingSubmitting = false
ThisPage.methods.processReceivingInit = function(items) {
this.processReceivingUuids = items.map((item) => item.uuid)
this.processReceivingVendor = null
this.processReceivingInvoiceNumber = null
this.processReceivingPoNumber = null
this.processReceivingNote = null
this.processReceivingShowDialog = true
this.$nextTick(() => {
this.$refs.processReceivingVendor.focus()
})
}
ThisPage.methods.processReceivingSubmit = function() {
this.processReceivingSubmitting = true
this.$refs.processReceivingForm.submit()
}
% endif
% if master.has_perm('process_reorder'):
ThisPageData.processReorderShowDialog = false
ThisPageData.processReorderUuids = []
ThisPageData.processReorderNote = null
ThisPageData.processReorderSubmitting = false
ThisPage.methods.processReorderInit = function(items) {
this.processReorderUuids = items.map((item) => item.uuid)
this.processReorderNote = null
this.processReorderShowDialog = true
this.$nextTick(() => {
this.$refs.processReorderNote.focus()
})
}
ThisPage.methods.processReorderSubmit = function() {
this.processReorderSubmitting = true
this.$refs.processReorderForm.submit()
}
% endif
</script>
</%def>

View file

@ -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"(&times; {case_qty} = {unit_qty} {EA})")
else:
unit_qty = self.app.render_quantity(row.order_qty)
EA = enum.ORDER_UOM[enum.ORDER_UOM_UNIT]
data['order_qty_display'] = f"{unit_qty} {EA}"
data['order_qty_display'] = self.order_handler.get_order_qty_uom_text(
row.order_qty, row.order_uom, case_size=row.case_size, html=True)
return data
@ -823,7 +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)

View file

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

View file

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

419
tests/test_orders.py Normal file
View file

@ -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 (&times; 12 = 24 Units)")
# unknown case size
text = handler.get_order_qty_uom_text(2, enum.ORDER_UOM_CASE)
self.assertEqual(text, "2 Cases (x ?? = ?? Units)")
text = handler.get_order_qty_uom_text(2, enum.ORDER_UOM_CASE, html=True)
self.assertEqual(text, "2 Cases (&times; ?? = ?? Units)")
# units only
text = handler.get_order_qty_uom_text(2, enum.ORDER_UOM_UNIT)
self.assertEqual(text, "2 Units")
text = handler.get_order_qty_uom_text(2, enum.ORDER_UOM_UNIT, html=True)
self.assertEqual(text, "2 Units")
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)

View file

@ -11,6 +11,7 @@ from pyramid.response import Response
from wuttaweb.forms.schema import WuttaMoney
from sideshow.batch.neworder import NewOrderBatchHandler
from sideshow.orders import OrderHandler
from sideshow.testing import WebTestCase
from sideshow.web.views import orders as mod
from sideshow.web.forms.schema import OrderRef, PendingProductRef
@ -30,6 +31,13 @@ class TestOrderView(WebTestCase):
def make_handler(self):
return NewOrderBatchHandler(self.config)
def test_order_handler(self):
view = self.make_view()
handler = view.order_handler
self.assertIsInstance(handler, OrderHandler)
handler2 = view.get_order_handler()
self.assertIs(handler2, handler)
def test_configure_grid(self):
model = self.app.model
view = self.make_view()
@ -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 (&times; 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('<span', result)
self.assertIn('class="has-background-info-light"', result)
self.assertIn('testing2', result)
def test_common_get_xref_buttons(self):
self.pyramid_config.add_route('orders.view', '/orders/{uuid}')
model = self.app.model
enum = self.app.enum
@ -1336,3 +1407,595 @@ class TestOrderItemView(WebTestCase):
buttons = view.get_xref_buttons(item)
self.assertEqual(len(buttons), 1)
self.assertIn("View the Order", buttons[0])
def test_common_add_note(self):
model = self.app.model
enum = self.app.enum
view = self.make_view()
self.pyramid_config.add_route(f'{view.get_route_prefix()}.view',
f'{view.get_url_prefix()}/{{uuid}}')
user = model.User(username='barney')
self.session.add(user)
order = model.Order(order_id=42, created_by=user)
self.session.add(order)
item = model.OrderItem(order_qty=1, order_uom=enum.ORDER_UOM_UNIT,
status_code=enum.ORDER_ITEM_STATUS_INITIATED)
order.items.append(item)
self.session.flush()
with patch.object(view, 'Session', return_value=self.session):
with patch.object(self.request, 'matchdict', new={'uuid': item.uuid}):
with patch.object(self.request, 'POST', new={'note': 'testing'}):
self.assertEqual(len(item.events), 0)
result = view.add_note()
self.assertEqual(len(item.events), 1)
self.assertEqual(item.events[0].type_code, enum.ORDER_ITEM_EVENT_NOTE_ADDED)
self.assertEqual(item.events[0].note, 'testing')
def test_common_change_status(self):
model = self.app.model
enum = self.app.enum
view = self.make_view()
self.pyramid_config.add_route(f'{view.get_route_prefix()}.view',
f'{view.get_url_prefix()}/{{uuid}}')
user = model.User(username='barney')
self.session.add(user)
order = model.Order(order_id=42, created_by=user)
self.session.add(order)
item = model.OrderItem(order_qty=1, order_uom=enum.ORDER_UOM_UNIT,
status_code=enum.ORDER_ITEM_STATUS_INITIATED)
order.items.append(item)
self.session.flush()
with patch.object(view, 'Session', return_value=self.session):
with patch.object(self.request, 'user', new=user):
with patch.object(self.request, 'matchdict', new={'uuid': item.uuid}):
# just status change, no note
with patch.object(self.request, 'POST', new={
'new_status': enum.ORDER_ITEM_STATUS_PLACED}):
self.assertEqual(len(item.events), 0)
result = view.change_status()
self.assertIsInstance(result, HTTPFound)
self.assertFalse(self.request.session.peek_flash('error'))
self.assertEqual(len(item.events), 1)
self.assertEqual(item.events[0].type_code, enum.ORDER_ITEM_EVENT_STATUS_CHANGE)
self.assertEqual(item.events[0].note,
'status changed from "initiated" to "placed"')
# status change plus note
with patch.object(self.request, 'POST', new={
'new_status': enum.ORDER_ITEM_STATUS_RECEIVED,
'note': 'check it out'}):
self.assertEqual(len(item.events), 1)
result = view.change_status()
self.assertIsInstance(result, HTTPFound)
self.assertFalse(self.request.session.peek_flash('error'))
self.assertEqual(len(item.events), 3)
self.assertEqual(item.events[0].type_code, enum.ORDER_ITEM_EVENT_STATUS_CHANGE)
self.assertEqual(item.events[0].note,
'status changed from "initiated" to "placed"')
self.assertEqual(item.events[1].type_code, enum.ORDER_ITEM_EVENT_STATUS_CHANGE)
self.assertEqual(item.events[1].note,
'status changed from "placed" to "received"')
self.assertEqual(item.events[2].type_code, enum.ORDER_ITEM_EVENT_NOTE_ADDED)
self.assertEqual(item.events[2].note, "check it out")
# invalid status
with patch.object(self.request, 'POST', new={'new_status': 23432143}):
self.assertEqual(len(item.events), 3)
result = view.change_status()
self.assertIsInstance(result, HTTPFound)
self.assertTrue(self.request.session.peek_flash('error'))
self.assertEqual(len(item.events), 3)
class TestOrderItemView(OrderItemViewTestMixin, WebTestCase):
def make_view(self):
return mod.OrderItemView(self.request)
def test_get_order_items(self):
model = self.app.model
enum = self.app.enum
view = self.make_view()
self.pyramid_config.add_route('order_items', '/order-items/')
user = model.User(username='barney')
self.session.add(user)
order = model.Order(order_id=42, created_by=user)
item1 = model.OrderItem(order_qty=1, order_uom=enum.ORDER_UOM_UNIT,
status_code=enum.ORDER_ITEM_STATUS_READY)
order.items.append(item1)
item2 = model.OrderItem(order_qty=1, order_uom=enum.ORDER_UOM_UNIT,
status_code=enum.ORDER_ITEM_STATUS_READY)
order.items.append(item2)
self.session.add(order)
self.session.flush()
with patch.object(view, 'Session', return_value=self.session):
# no items found
self.assertRaises(HTTPFound, view.get_order_items, None)
self.assertRaises(HTTPFound, view.get_order_items, '')
self.assertRaises(HTTPFound, view.get_order_items, [])
self.assertRaises(HTTPFound, view.get_order_items, 'invalid')
# list of UUID
items = view.get_order_items([item1.uuid, item2.uuid])
self.assertEqual(len(items), 2)
self.assertIs(items[0], item1)
self.assertIs(items[1], item2)
# list of str
items = view.get_order_items([item1.uuid.hex, item2.uuid.hex])
self.assertEqual(len(items), 2)
self.assertIs(items[0], item1)
self.assertIs(items[1], item2)
# comma-delimited str
items = view.get_order_items(','.join([item1.uuid.hex, item2.uuid.hex]))
self.assertEqual(len(items), 2)
self.assertIs(items[0], item1)
self.assertIs(items[1], item2)
class TestPlacementView(OrderItemViewTestMixin, WebTestCase):
def make_view(self):
return mod.PlacementView(self.request)
def test_configure_grid(self):
model = self.app.model
view = self.make_view()
grid = view.make_grid(model_class=model.OrderItem)
# nothing added without perms
self.assertEqual(len(grid.tools), 0)
view.configure_grid(grid)
self.assertFalse(grid.checkable)
self.assertEqual(len(grid.tools), 0)
# button added with perm
with patch.object(self.request, 'is_root', new=True):
view.configure_grid(grid)
self.assertTrue(grid.checkable)
self.assertEqual(len(grid.tools), 1)
self.assertIn('process_placement', grid.tools)
tool = grid.tools['process_placement']
self.assertIn('<b-button ', tool)
self.assertIn('Order Placed', tool)
def test_process_placement(self):
self.pyramid_config.add_route('order_items_placement', '/placement/')
model = self.app.model
enum = self.app.enum
view = self.make_view()
grid = view.make_grid(model_class=model.OrderItem)
# 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()
# view only configured for POST
with patch.multiple(self.request, method='POST', user=user):
with patch.object(view, 'Session', return_value=self.session):
# redirect if items not specified
with patch.object(view.order_handler, 'process_placement') as process_placement:
self.assertRaises(HTTPFound, view.process_placement)
process_placement.assert_not_called()
self.assertTrue(self.request.session.pop_flash('warning'))
self.assertFalse(self.request.session.peek_flash())
# 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(item3.status_code, enum.ORDER_ITEM_STATUS_READY)
self.assertEqual(len(item1.events), 0)
self.assertEqual(len(item2.events), 0)
self.assertEqual(len(item3.events), 0)
with patch.object(self.request, 'POST', new={
'item_uuids': ','.join([item1.uuid.hex, item2.uuid.hex]),
'vendor_name': 'Acme Dist',
'po_number': 'ACME123',
}):
view.process_placement()
self.assertEqual(item1.status_code, enum.ORDER_ITEM_STATUS_PLACED)
self.assertEqual(item2.status_code, enum.ORDER_ITEM_STATUS_PLACED)
self.assertEqual(item3.status_code, enum.ORDER_ITEM_STATUS_READY)
self.assertEqual(len(item1.events), 1)
self.assertEqual(len(item2.events), 1)
self.assertEqual(len(item3.events), 0)
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.assertFalse(self.request.session.peek_flash('warning'))
self.assertTrue(self.request.session.pop_flash())
class TestReceivingView(OrderItemViewTestMixin, WebTestCase):
def make_view(self):
return mod.ReceivingView(self.request)
def test_configure_grid(self):
model = self.app.model
view = self.make_view()
grid = view.make_grid(model_class=model.OrderItem)
# nothing added without perms
self.assertEqual(len(grid.tools), 0)
view.configure_grid(grid)
self.assertFalse(grid.checkable)
self.assertEqual(len(grid.tools), 0)
# buttons added with perm
with patch.object(self.request, 'is_root', new=True):
view.configure_grid(grid)
self.assertEqual(len(grid.tools), 2)
self.assertTrue(grid.checkable)
self.assertIn('process_receiving', grid.tools)
tool = grid.tools['process_receiving']
self.assertIn('<b-button ', tool)
self.assertIn('Received', tool)
self.assertIn('process_reorder', grid.tools)
tool = grid.tools['process_reorder']
self.assertIn('<b-button ', tool)
self.assertIn('Re-Order', tool)
def test_process_receiving(self):
self.pyramid_config.add_route('order_items_receiving', '/receiving/')
model = self.app.model
enum = self.app.enum
view = self.make_view()
grid = view.make_grid(model_class=model.OrderItem)
# 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)
self.session.add(order)
self.session.flush()
# view only configured for POST
with patch.multiple(self.request, method='POST', user=user):
with patch.object(view, 'Session', return_value=self.session):
# redirect if items not specified
with patch.object(view.order_handler, 'process_receiving') as process_receiving:
self.assertRaises(HTTPFound, view.process_receiving)
process_receiving.assert_not_called()
self.assertTrue(self.request.session.pop_flash('warning'))
self.assertFalse(self.request.session.peek_flash())
# all info provided
self.assertEqual(item1.status_code, enum.ORDER_ITEM_STATUS_PLACED)
self.assertEqual(len(item1.events), 0)
with patch.object(self.request, 'POST', new={
'item_uuids': item1.uuid.hex,
'vendor_name': "Acme Dist",
'invoice_number': 'INV123',
'po_number': '123',
'note': 'extra note',
}):
view.process_receiving()
self.assertFalse(self.request.session.peek_flash('warning'))
self.assertTrue(self.request.session.pop_flash())
self.assertEqual(item1.status_code, enum.ORDER_ITEM_STATUS_RECEIVED)
self.assertEqual(len(item1.events), 2)
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)
self.assertEqual(item1.events[1].note, "extra note")
self.assertEqual(item1.events[1].type_code, enum.ORDER_ITEM_EVENT_NOTE_ADDED)
def test_process_reorder(self):
self.pyramid_config.add_route('order_items_receiving', '/receiving/')
model = self.app.model
enum = self.app.enum
view = self.make_view()
grid = view.make_grid(model_class=model.OrderItem)
# 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)
self.session.add(order)
self.session.flush()
# view only configured for POST
with patch.multiple(self.request, method='POST', user=user):
with patch.object(view, 'Session', return_value=self.session):
# redirect if items not specified
with patch.object(view.order_handler, 'process_reorder') as process_reorder:
self.assertRaises(HTTPFound, view.process_reorder)
process_reorder.assert_not_called()
self.assertTrue(self.request.session.pop_flash('warning'))
self.assertFalse(self.request.session.peek_flash())
# all info provided
self.assertEqual(item1.status_code, enum.ORDER_ITEM_STATUS_PLACED)
self.assertEqual(len(item1.events), 0)
with patch.object(self.request, 'POST', new={
'item_uuids': item1.uuid.hex,
'note': 'extra note',
}):
view.process_reorder()
self.assertFalse(self.request.session.peek_flash('warning'))
self.assertTrue(self.request.session.pop_flash())
self.assertEqual(item1.status_code, enum.ORDER_ITEM_STATUS_READY)
self.assertEqual(len(item1.events), 2)
self.assertIsNone(item1.events[0].note)
self.assertEqual(item1.events[0].type_code, enum.ORDER_ITEM_EVENT_REORDER)
self.assertEqual(item1.events[1].note, "extra note")
self.assertEqual(item1.events[1].type_code, enum.ORDER_ITEM_EVENT_NOTE_ADDED)
class TestContactView(OrderItemViewTestMixin, WebTestCase):
def make_view(self):
return mod.ContactView(self.request)
def test_configure_grid(self):
model = self.app.model
view = self.make_view()
grid = view.make_grid(model_class=model.OrderItem)
# nothing added without perms
self.assertEqual(len(grid.tools), 0)
view.configure_grid(grid)
self.assertFalse(grid.checkable)
self.assertEqual(len(grid.tools), 0)
# buttons added with perm
with patch.object(self.request, 'is_root', new=True):
view.configure_grid(grid)
self.assertEqual(len(grid.tools), 2)
self.assertTrue(grid.checkable)
self.assertIn('process_contact_success', grid.tools)
tool = grid.tools['process_contact_success']
self.assertIn('<b-button ', tool)
self.assertIn('Contact Success', tool)
self.assertIn('process_contact_failure', grid.tools)
tool = grid.tools['process_contact_failure']
self.assertIn('<b-button ', tool)
self.assertIn('Contact Failure', tool)
def test_process_contact_success(self):
self.pyramid_config.add_route('order_items_contact', '/contact/')
model = self.app.model
enum = self.app.enum
view = self.make_view()
grid = view.make_grid(model_class=model.OrderItem)
# 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)
self.session.add(order)
self.session.flush()
# view only configured for POST
with patch.multiple(self.request, method='POST', user=user):
with patch.object(view, 'Session', return_value=self.session):
# redirect if items not specified
with patch.object(view.order_handler, 'process_contact_success') as process_contact_success:
self.assertRaises(HTTPFound, view.process_contact_success)
process_contact_success.assert_not_called()
self.assertTrue(self.request.session.pop_flash('warning'))
self.assertFalse(self.request.session.peek_flash())
# all info provided
self.assertEqual(item1.status_code, enum.ORDER_ITEM_STATUS_RECEIVED)
self.assertEqual(len(item1.events), 0)
with patch.object(self.request, 'POST', new={
'item_uuids': item1.uuid.hex,
'note': 'extra note',
}):
view.process_contact_success()
self.assertFalse(self.request.session.peek_flash('warning'))
self.assertTrue(self.request.session.pop_flash())
self.assertEqual(item1.status_code, enum.ORDER_ITEM_STATUS_CONTACTED)
self.assertEqual(len(item1.events), 2)
self.assertIsNone(item1.events[0].note)
self.assertEqual(item1.events[0].type_code, enum.ORDER_ITEM_EVENT_CONTACTED)
self.assertEqual(item1.events[1].note, "extra note")
self.assertEqual(item1.events[1].type_code, enum.ORDER_ITEM_EVENT_NOTE_ADDED)
def test_process_contact_failure(self):
self.pyramid_config.add_route('order_items_contact', '/contact/')
model = self.app.model
enum = self.app.enum
view = self.make_view()
grid = view.make_grid(model_class=model.OrderItem)
# 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)
self.session.add(order)
self.session.flush()
# view only configured for POST
with patch.multiple(self.request, method='POST', user=user):
with patch.object(view, 'Session', return_value=self.session):
# redirect if items not specified
with patch.object(view.order_handler, 'process_contact_failure') as process_contact_failure:
self.assertRaises(HTTPFound, view.process_contact_failure)
process_contact_failure.assert_not_called()
self.assertTrue(self.request.session.pop_flash('warning'))
self.assertFalse(self.request.session.peek_flash())
# all info provided
self.assertEqual(item1.status_code, enum.ORDER_ITEM_STATUS_RECEIVED)
self.assertEqual(len(item1.events), 0)
with patch.object(self.request, 'POST', new={
'item_uuids': item1.uuid.hex,
'note': 'extra note',
}):
view.process_contact_failure()
self.assertFalse(self.request.session.peek_flash('warning'))
self.assertTrue(self.request.session.pop_flash())
self.assertEqual(item1.status_code, enum.ORDER_ITEM_STATUS_CONTACT_FAILED)
self.assertEqual(len(item1.events), 2)
self.assertIsNone(item1.events[0].note)
self.assertEqual(item1.events[0].type_code, enum.ORDER_ITEM_EVENT_CONTACT_FAILED)
self.assertEqual(item1.events[1].note, "extra note")
self.assertEqual(item1.events[1].type_code, enum.ORDER_ITEM_EVENT_NOTE_ADDED)
class TestDeliveryView(OrderItemViewTestMixin, WebTestCase):
def make_view(self):
return mod.DeliveryView(self.request)
def test_configure_grid(self):
model = self.app.model
view = self.make_view()
grid = view.make_grid(model_class=model.OrderItem)
# nothing added without perms
self.assertEqual(len(grid.tools), 0)
view.configure_grid(grid)
self.assertFalse(grid.checkable)
self.assertEqual(len(grid.tools), 0)
# buttons added with perm
with patch.object(self.request, 'is_root', new=True):
view.configure_grid(grid)
self.assertEqual(len(grid.tools), 2)
self.assertTrue(grid.checkable)
self.assertIn('process_delivery', grid.tools)
tool = grid.tools['process_delivery']
self.assertIn('<b-button ', tool)
self.assertIn('Delivered', tool)
self.assertIn('process_restock', grid.tools)
tool = grid.tools['process_restock']
self.assertIn('<b-button ', tool)
self.assertIn('Restocked', tool)
def test_process_delivery(self):
self.pyramid_config.add_route('order_items_delivery', '/delivery/')
model = self.app.model
enum = self.app.enum
view = self.make_view()
grid = view.make_grid(model_class=model.OrderItem)
# 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_CONTACTED)
order.items.append(item1)
self.session.add(order)
self.session.flush()
# view only configured for POST
with patch.multiple(self.request, method='POST', user=user):
with patch.object(view, 'Session', return_value=self.session):
# redirect if items not specified
with patch.object(view.order_handler, 'process_delivery') as process_delivery:
self.assertRaises(HTTPFound, view.process_delivery)
process_delivery.assert_not_called()
self.assertTrue(self.request.session.pop_flash('warning'))
self.assertFalse(self.request.session.peek_flash())
# all info provided
self.assertEqual(item1.status_code, enum.ORDER_ITEM_STATUS_CONTACTED)
self.assertEqual(len(item1.events), 0)
with patch.object(self.request, 'POST', new={
'item_uuids': item1.uuid.hex,
'note': 'extra note',
}):
view.process_delivery()
self.assertFalse(self.request.session.peek_flash('warning'))
self.assertTrue(self.request.session.pop_flash())
self.assertEqual(item1.status_code, enum.ORDER_ITEM_STATUS_DELIVERED)
self.assertEqual(len(item1.events), 2)
self.assertIsNone(item1.events[0].note)
self.assertEqual(item1.events[0].type_code, enum.ORDER_ITEM_EVENT_DELIVERED)
self.assertEqual(item1.events[1].note, "extra note")
self.assertEqual(item1.events[1].type_code, enum.ORDER_ITEM_EVENT_NOTE_ADDED)
def test_process_restock(self):
self.pyramid_config.add_route('order_items_delivery', '/delivery/')
model = self.app.model
enum = self.app.enum
view = self.make_view()
grid = view.make_grid(model_class=model.OrderItem)
# 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_CONTACTED)
order.items.append(item1)
self.session.add(order)
self.session.flush()
# view only configured for POST
with patch.multiple(self.request, method='POST', user=user):
with patch.object(view, 'Session', return_value=self.session):
# redirect if items not specified
with patch.object(view.order_handler, 'process_restock') as process_restock:
self.assertRaises(HTTPFound, view.process_restock)
process_restock.assert_not_called()
self.assertTrue(self.request.session.pop_flash('warning'))
self.assertFalse(self.request.session.peek_flash())
# all info provided
self.assertEqual(item1.status_code, enum.ORDER_ITEM_STATUS_CONTACTED)
self.assertEqual(len(item1.events), 0)
with patch.object(self.request, 'POST', new={
'item_uuids': item1.uuid.hex,
'note': 'extra note',
}):
view.process_restock()
self.assertFalse(self.request.session.peek_flash('warning'))
self.assertTrue(self.request.session.pop_flash())
self.assertEqual(item1.status_code, enum.ORDER_ITEM_STATUS_RESTOCKED)
self.assertEqual(len(item1.events), 2)
self.assertIsNone(item1.events[0].note)
self.assertEqual(item1.events[0].type_code, enum.ORDER_ITEM_EVENT_RESTOCKED)
self.assertEqual(item1.events[1].note, "extra note")
self.assertEqual(item1.events[1].type_code, enum.ORDER_ITEM_EVENT_NOTE_ADDED)