Compare commits

...

5 commits

Author SHA1 Message Date
Lance Edgar 3ca89a8479 feat: allow re-order past product for new orders
assuming batch has a customer set, with order history

nb. this only uses past *products* and not order qty/uom
2025-02-01 19:39:02 -06:00
Lance Edgar aa31d23fc8 feat: add per-department default item discount 2025-01-30 21:45:10 -06:00
Lance Edgar 7e1d68e2cf fix: move Pricing config to separate section, for orders/configure 2025-01-30 15:46:02 -06:00
Lance Edgar 89e3445ace feat: add config option to show/hide Store ID; default value 2025-01-27 20:33:14 -06:00
Lance Edgar 3ef84ff706 feat: add basic model, views for Stores 2025-01-27 18:15:07 -06:00
28 changed files with 1820 additions and 169 deletions

View file

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

View file

@ -0,0 +1,6 @@
``sideshow.db.model.stores``
============================
.. automodule:: sideshow.db.model.stores
:members:

View file

@ -0,0 +1,6 @@
``sideshow.web.views.stores``
=============================
.. automodule:: sideshow.web.views.stores
:members:

View file

@ -30,6 +30,7 @@ For an online demo see https://demo.wuttaproject.org/
:caption: Package API:
api/sideshow
api/sideshow.app
api/sideshow.batch
api/sideshow.batch.neworder
api/sideshow.cli
@ -43,6 +44,7 @@ For an online demo see https://demo.wuttaproject.org/
api/sideshow.db.model.customers
api/sideshow.db.model.orders
api/sideshow.db.model.products
api/sideshow.db.model.stores
api/sideshow.enum
api/sideshow.orders
api/sideshow.web
@ -58,3 +60,4 @@ For an online demo see https://demo.wuttaproject.org/
api/sideshow.web.views.customers
api/sideshow.web.views.orders
api/sideshow.web.views.products
api/sideshow.web.views.stores

View file

@ -51,6 +51,9 @@ sideshow_libcache = "sideshow.web.static:libcache"
[project.entry-points."paste.app_factory"]
"main" = "sideshow.web.app:main"
[project.entry-points."wutta.app.providers"]
sideshow = "sideshow.app:SideshowAppProvider"
[project.entry-points."wutta.batch.neworder"]
"sideshow" = "sideshow.batch.neworder:NewOrderBatchHandler"

56
src/sideshow/app.py Normal file
View file

@ -0,0 +1,56 @@
# -*- coding: utf-8; -*-
################################################################################
#
# Sideshow -- Case/Special Order Tracker
# Copyright © 2024 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 app provider
"""
from wuttjamaican import app as base
class SideshowAppProvider(base.AppProvider):
"""
The :term:`app provider` for Sideshow.
This adds the :meth:`get_order_handler()` method to the :term:`app
handler`.
"""
def get_order_handler(self, **kwargs):
"""
Get the configured :term:`order handler` for the app.
You can specify a custom handler in your :term:`config file`
like:
.. code-block:: ini
[sideshow]
orders.handler_spec = poser.orders:PoserOrderHandler
:returns: Instance of :class:`~sideshow.orders.OrderHandler`.
"""
if 'order_handler' not in self.__dict__:
spec = self.config.get('sideshow.orders.handler_spec',
default='sideshow.orders:OrderHandler')
self.order_handler = self.app.load_object(spec)(self.config)
return self.order_handler

View file

@ -26,6 +26,7 @@ New Order Batch Handler
import datetime
import decimal
from collections import OrderedDict
import sqlalchemy as sa
@ -50,6 +51,14 @@ class NewOrderBatchHandler(BatchHandler):
"""
model_class = NewOrderBatch
def get_default_store_id(self):
"""
Returns the configured default value for
:attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.store_id`,
or ``None``.
"""
return self.config.get('sideshow.orders.default_store_id')
def use_local_customers(self):
"""
Returns boolean indicating whether :term:`local customer`
@ -165,6 +174,18 @@ class NewOrderBatchHandler(BatchHandler):
'label': customer.full_name}
return [result(c) for c in customers]
def init_batch(self, batch, session=None, progress=None, **kwargs):
"""
Initialize a new batch.
This sets the
:attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.store_id`,
if the batch does not yet have one and a default is
configured.
"""
if not batch.store_id:
batch.store_id = self.get_default_store_id()
def set_customer(self, batch, customer_info, user=None):
"""
Set/update customer info for the batch.
@ -359,6 +380,21 @@ class NewOrderBatchHandler(BatchHandler):
'label': product.full_description}
return [result(c) for c in products]
def get_default_uom_choices(self):
"""
Returns a list of ordering UOM choices which should be
presented to the user by default.
The built-in logic here will return everything from
:data:`~sideshow.enum.ORDER_UOM`.
:returns: List of dicts, each with ``key`` and ``value``
corresponding to the UOM code and label, respectively.
"""
enum = self.app.enum
return [{'key': key, 'value': val}
for key, val in enum.ORDER_UOM.items()]
def get_product_info_external(self, session, product_id, user=None):
"""
Returns basic info for an :term:`external product` as pertains
@ -368,7 +404,8 @@ class NewOrderBatchHandler(BatchHandler):
choose order quantity and UOM based on case size, pricing
etc., this method is called to retrieve the product info.
There is no default logic here; subclass must implement.
There is no default logic here; subclass must implement. See
also :meth:`get_product_info_local()`.
:param session: Current app :term:`db session`.
@ -424,21 +461,58 @@ class NewOrderBatchHandler(BatchHandler):
def get_product_info_local(self, session, uuid, user=None):
"""
Returns basic info for a
:class:`~sideshow.db.model.products.LocalProduct` as pertains
to ordering.
Returns basic info for a :term:`local product` as pertains to
ordering.
When user has located a product via search, and must then
choose order quantity and UOM based on case size, pricing
etc., this method is called to retrieve the product info.
See :meth:`get_product_info_external()` for more explanation.
This method will locate the
:class:`~sideshow.db.model.products.LocalProduct` record, then
(if found) it calls :meth:`normalize_local_product()` and
returns the result.
:param session: Current :term:`db session`.
:param uuid: UUID for the desired
:class:`~sideshow.db.model.products.LocalProduct`.
:param user:
:class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who
is performing the action, if known.
:returns: Dict of product info.
"""
model = self.app.model
product = session.get(model.LocalProduct, uuid)
if not product:
raise ValueError(f"Local Product not found: {uuid}")
return self.normalize_local_product(product)
def normalize_local_product(self, product):
"""
Returns a normalized dict of info for the given :term:`local
product`.
This is called by:
* :meth:`get_product_info_local()`
* :meth:`get_past_products()`
:param product:
:class:`~sideshow.db.model.products.LocalProduct` instance.
:returns: Dict of product info.
The keys for this dict should essentially one-to-one for the
product fields, with one exception:
* ``product_id`` will be set to the product UUID as string
"""
return {
'product_id': product.uuid.hex,
'scancode': product.scancode,
@ -456,6 +530,109 @@ class NewOrderBatchHandler(BatchHandler):
'vendor_item_code': product.vendor_item_code,
}
def get_past_orders(self, batch):
"""
Retrieve a (possibly empty) list of past :term:`orders
<order>` for the batch customer.
This is called by :meth:`get_past_products()`.
:param batch:
:class:`~sideshow.db.model.batch.neworder.NewOrderBatch`
instance.
:returns: List of :class:`~sideshow.db.model.orders.Order`
records.
"""
model = self.app.model
session = self.app.get_session(batch)
orders = session.query(model.Order)
if batch.customer_id:
orders = orders.filter(model.Order.customer_id == batch.customer_id)
elif batch.local_customer:
orders = orders.filter(model.Order.local_customer == batch.local_customer)
else:
raise ValueError(f"batch has no customer: {batch}")
orders = orders.order_by(model.Order.created.desc())
return orders.all()
def get_past_products(self, batch, user=None):
"""
Retrieve a (possibly empty) list of products which have been
previously ordered by the batch customer.
Note that this does not return :term:`order items <order
item>`, nor does it return true product records, but rather it
returns a list of dicts. Each will have product info but will
*not* have order quantity etc.
This method calls :meth:`get_past_orders()` and then iterates
through each order item therein. Any duplicated products
encountered will be skipped, so the final list contains unique
products.
Each dict in the result is obtained by calling one of:
* :meth:`normalize_local_product()`
* :meth:`get_product_info_external()`
:param batch:
:class:`~sideshow.db.model.batch.neworder.NewOrderBatch`
instance.
:param user:
:class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who
is performing the action, if known.
:returns: List of product info dicts.
"""
model = self.app.model
session = self.app.get_session(batch)
use_local = self.use_local_products()
user = user or batch.created_by
products = OrderedDict()
# track down all order items for batch contact
for order in self.get_past_orders(batch):
for item in order.items:
# nb. we only need the first match for each product
if use_local:
product = item.local_product
if product and product.uuid not in products:
products[product.uuid] = self.normalize_local_product(product)
elif item.product_id and item.product_id not in products:
products[item.product_id] = self.get_product_info_external(
session, item.product_id, user=user)
products = list(products.values())
for product in products:
price = product['unit_price_reg']
if 'unit_price_reg_display' not in product:
product['unit_price_reg_display'] = self.app.render_currency(price)
if 'unit_price_quoted' not in product:
product['unit_price_quoted'] = price
if 'unit_price_quoted_display' not in product:
product['unit_price_quoted_display'] = product['unit_price_reg_display']
if ('case_price_quoted' not in product
and product.get('unit_price_quoted') is not None
and product.get('case_size') is not None):
product['case_price_quoted'] = product['unit_price_quoted'] * product['case_size']
if ('case_price_quoted_display' not in product
and 'case_price_quoted' in product):
product['case_price_quoted_display'] = self.app.render_currency(
product['case_price_quoted'])
return products
def add_item(self, batch, product_info, order_qty, order_uom,
discount_percent=None, user=None):
"""

View file

@ -0,0 +1,39 @@
"""add stores
Revision ID: a4273360d379
Revises: 7a6df83afbd4
Create Date: 2025-01-27 17:48:20.638664
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import wuttjamaican.db.util
# revision identifiers, used by Alembic.
revision: str = 'a4273360d379'
down_revision: Union[str, None] = '7a6df83afbd4'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# sideshow_store
op.create_table('sideshow_store',
sa.Column('uuid', wuttjamaican.db.util.UUID(), nullable=False),
sa.Column('store_id', sa.String(length=10), nullable=False),
sa.Column('name', sa.String(length=100), nullable=False),
sa.Column('archived', sa.Boolean(), nullable=False),
sa.PrimaryKeyConstraint('uuid', name=op.f('pk_sideshow_store')),
sa.UniqueConstraint('store_id', name=op.f('uq_sideshow_store_store_id')),
sa.UniqueConstraint('name', name=op.f('uq_sideshow_store_name'))
)
def downgrade() -> None:
# sideshow_store
op.drop_table('sideshow_store')

View file

@ -30,6 +30,7 @@ This namespace exposes everything from
Primary :term:`data models <data model>`:
* :class:`~sideshow.db.model.stores.Store`
* :class:`~sideshow.db.model.orders.Order`
* :class:`~sideshow.db.model.orders.OrderItem`
* :class:`~sideshow.db.model.orders.OrderItemEvent`
@ -48,6 +49,7 @@ And the :term:`batch` models:
from wuttjamaican.db.model import *
# sideshow models
from .stores import Store
from .customers import LocalCustomer, PendingCustomer
from .products import LocalProduct, PendingProduct
from .orders import Order, OrderItem, OrderItemEvent

View file

@ -62,6 +62,15 @@ class Order(model.Base):
ID of the store to which the order pertains, if applicable.
""")
store = orm.relationship(
'Store',
primaryjoin='Store.store_id == Order.store_id',
foreign_keys='Order.store_id',
doc="""
Reference to the :class:`~sideshow.db.model.stores.Store`
record, if applicable.
""")
customer_id = sa.Column(sa.String(length=20), nullable=True, doc="""
Proper account ID for the :term:`external customer` to which the
order pertains, if applicable.

View file

@ -0,0 +1,62 @@
# -*- 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/>.
#
################################################################################
"""
Data models for Stores
"""
import sqlalchemy as sa
from wuttjamaican.db import model
class Store(model.Base):
"""
Represents a physical location for the business.
"""
__tablename__ = 'sideshow_store'
uuid = model.uuid_column()
store_id = sa.Column(sa.String(length=10), nullable=False, unique=True, doc="""
Unique ID for the store.
""")
name = sa.Column(sa.String(length=100), nullable=False, unique=True, doc="""
Display name for the store (must be unique!).
""")
archived = sa.Column(sa.Boolean(), nullable=False, default=False, doc="""
Indicates the store has been "retired" essentially, and mostly
hidden from view.
""")
def __str__(self):
return self.get_display()
def get_display(self):
"""
Returns the display string for the store, e.g. "001 Acme Goods".
"""
return ' '.join([(self.store_id or '').strip(),
(self.name or '').strip()])\
.strip()

View file

@ -37,6 +37,14 @@ class OrderHandler(GenericHandler):
handler is responsible for creation logic.)
"""
def expose_store_id(self):
"""
Returns boolean indicating whether the ``store_id`` field
should be exposed at all. This is false by default.
"""
return self.config.get_bool('sideshow.orders.expose_store_id',
default=False)
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.

View file

@ -162,4 +162,12 @@ class SideshowMenuHandler(base.MenuHandler):
def make_admin_menu(self, request, **kwargs):
""" """
kwargs['include_people'] = True
return super().make_admin_menu(request, **kwargs)
menu = super().make_admin_menu(request, **kwargs)
menu['items'].insert(0, {
'title': "Stores",
'route': 'stores',
'perm': 'stores.list',
})
return menu

View file

@ -29,6 +29,17 @@
<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>
% if expose_store_id:
<b-field horizontal label="Store">
<span>
% if order.store:
${h.link_to(order.store.get_display(), url('stores.view', uuid=order.store.uuid))}
% elif order.store_id:
${order.store_id}
% endif
</span>
</b-field>
% endif
<b-field horizontal label="Order Qty">
<span>${order_qty_uom_text|n}</span>
</b-field>
@ -223,7 +234,10 @@
<b-field horizontal label="Sold by Weight">
<span>${app.render_boolean(item.product_weighed)}</span>
</b-field>
<b-field horizontal label="Department">
<b-field horizontal label="Department ID">
<span>${item.department_id}</span>
</b-field>
<b-field horizontal label="Department Name">
<span>${item.department_name}</span>
</b-field>
<b-field horizontal label="Special Order">

View file

@ -3,6 +3,28 @@
<%def name="form_content()">
<h3 class="block is-size-3">Stores</h3>
<div class="block" style="padding-left: 2rem;">
<b-field>
<b-checkbox name="sideshow.orders.expose_store_id"
v-model="simpleSettings['sideshow.orders.expose_store_id']"
native-value="true"
@input="settingsNeedSaved = true">
Show/choose the Store ID for each order
</b-checkbox>
</b-field>
<b-field v-show="simpleSettings['sideshow.orders.expose_store_id']"
label="Default Store ID">
<b-input name="sideshow.orders.default_store_id"
v-model="simpleSettings['sideshow.orders.default_store_id']"
@input="settingsNeedSaved = true"
style="width: 25rem;" />
</b-field>
</div>
<h3 class="block is-size-3">Customers</h3>
<div class="block" style="padding-left: 2rem;">
@ -14,41 +36,12 @@
<option value="false">External Customers (e.g. in POS)</option>
</b-select>
</b-field>
</div>
<h3 class="block is-size-3">Products</h3>
<div class="block" style="padding-left: 2rem;">
<b-field>
<b-checkbox name="sideshow.orders.allow_item_discounts"
v-model="simpleSettings['sideshow.orders.allow_item_discounts']"
native-value="true"
@input="settingsNeedSaved = true">
Allow per-item discounts
</b-checkbox>
</b-field>
<b-field v-show="simpleSettings['sideshow.orders.allow_item_discounts']">
<b-checkbox name="sideshow.orders.allow_item_discounts_if_on_sale"
v-model="simpleSettings['sideshow.orders.allow_item_discounts_if_on_sale']"
native-value="true"
@input="settingsNeedSaved = true">
Allow discount even if item is on sale
</b-checkbox>
</b-field>
<div v-show="simpleSettings['sideshow.orders.allow_item_discounts']"
class="level-left block">
<div class="level-item">Default item discount</div>
<div class="level-item">
<b-input name="sideshow.orders.default_item_discount"
v-model="simpleSettings['sideshow.orders.default_item_discount']"
@input="settingsNeedSaved = true"
style="width: 5rem;" />
</div>
<div class="level-item">%</div>
</div>
<b-field label="Product Source">
<b-select name="sideshow.orders.use_local_products"
v-model="simpleSettings['sideshow.orders.use_local_products']"
@ -92,6 +85,136 @@
</div>
</div>
<h3 class="block is-size-3">Pricing</h3>
<div class="block" style="padding-left: 2rem;">
<b-field>
<b-checkbox name="sideshow.orders.allow_item_discounts"
v-model="simpleSettings['sideshow.orders.allow_item_discounts']"
native-value="true"
@input="settingsNeedSaved = true">
Allow per-item discounts
</b-checkbox>
</b-field>
<b-field v-show="simpleSettings['sideshow.orders.allow_item_discounts']">
<b-checkbox name="sideshow.orders.allow_item_discounts_if_on_sale"
v-model="simpleSettings['sideshow.orders.allow_item_discounts_if_on_sale']"
native-value="true"
@input="settingsNeedSaved = true">
Allow discount even if item is on sale
</b-checkbox>
</b-field>
<div v-show="simpleSettings['sideshow.orders.allow_item_discounts']"
class="block"
style="display: flex; gap: 0.5rem; align-items: center;">
<span>Global default item discount</span>
<b-input name="sideshow.orders.default_item_discount"
v-model="simpleSettings['sideshow.orders.default_item_discount']"
@input="settingsNeedSaved = true"
style="width: 5rem;" />
<span>%</span>
</div>
<div v-show="simpleSettings['sideshow.orders.allow_item_discounts']"
style="width: 50%;">
<div style="display: flex; gap: 1rem; align-items: center;">
<p>Per-Department default item discounts</p>
<div>
<b-button type="is-primary"
@click="deptItemDiscountInit()"
icon-pack="fas"
icon-left="plus">
Add
</b-button>
<input type="hidden" name="dept_item_discounts" :value="JSON.stringify(deptItemDiscounts)" />
<${b}-modal has-modal-card
% if request.use_oruga:
v-model:active="deptItemDiscountShowDialog"
% else:
:active.sync="deptItemDiscountShowDialog"
% endif
>
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">Default Discount for Department</p>
</header>
<section class="modal-card-body">
<div style="display: flex; gap: 1rem;">
<b-field label="Dept. ID"
:type="deptItemDiscountDeptID ? null : 'is-danger'">
<b-input v-model="deptItemDiscountDeptID"
ref="deptItemDiscountDeptID"
style="width: 6rem;;" />
</b-field>
<b-field label="Department Name"
:type="deptItemDiscountDeptName ? null : 'is-danger'"
style="flex-grow: 1;">
<b-input v-model="deptItemDiscountDeptName" />
</b-field>
<b-field label="Discount"
:type="deptItemDiscountPercent ? null : 'is-danger'">
<div style="display: flex; gap: 0.5rem; align-items: center;">
<b-input v-model="deptItemDiscountPercent"
ref="deptItemDiscountPercent"
style="width: 6rem;" />
<span>%</span>
</div>
</b-field>
</div>
</section>
<footer class="modal-card-foot">
<b-button type="is-primary"
icon-pack="fas"
icon-left="save"
:disabled="deptItemDiscountSaveDisabled"
@click="deptItemDiscountSave()">
Save
</b-button>
<b-button @click="deptItemDiscountShowDialog = false">
Cancel
</b-button>
</footer>
</div>
</${b}-modal>
</div>
</div>
<${b}-table :data="deptItemDiscounts">
<${b}-table-column field="department_id"
label="Dept. ID"
v-slot="props">
{{ props.row.department_id }}
</${b}-table-column>
<${b}-table-column field="department_name"
label="Department Name"
v-slot="props">
{{ props.row.department_name }}
</${b}-table-column>
<${b}-table-column field="default_item_discount"
label="Discount"
v-slot="props">
{{ props.row.default_item_discount }} %
</${b}-table-column>
<${b}-table-column label="Actions"
v-slot="props">
<a href="#" @click.prevent="deptItemDiscountInit(props.row)">
<i class="fas fa-edit" />
Edit
</a>
<a href="#" @click.prevent="deptItemDiscountDelete(props.row)"
class="has-text-danger">
<i class="fas fa-trash" />
Delete
</a>
</${b}-table-column>
</${b}-table>
</div>
</div>
<h3 class="block is-size-3">Batches</h3>
<div class="block" style="padding-left: 2rem;">
@ -118,5 +241,62 @@
ThisPageData.batchHandlers = ${json.dumps(batch_handlers)|n}
ThisPageData.deptItemDiscounts = ${json.dumps(dept_item_discounts)|n}
ThisPageData.deptItemDiscountShowDialog = false
ThisPageData.deptItemDiscountRow = null
ThisPageData.deptItemDiscountDeptID = null
ThisPageData.deptItemDiscountDeptName = null
ThisPageData.deptItemDiscountPercent = null
ThisPage.computed.deptItemDiscountSaveDisabled = function() {
if (!this.deptItemDiscountDeptID) {
return true
}
if (!this.deptItemDiscountDeptName) {
return true
}
if (!this.deptItemDiscountPercent) {
return true
}
return false
}
ThisPage.methods.deptItemDiscountDelete = function(row) {
const i = this.deptItemDiscounts.indexOf(row)
this.deptItemDiscounts.splice(i, 1)
this.settingsNeedSaved = true
}
ThisPage.methods.deptItemDiscountInit = function(row) {
this.deptItemDiscountRow = row
this.deptItemDiscountDeptID = row?.department_id
this.deptItemDiscountDeptName = row?.department_name
this.deptItemDiscountPercent = row?.default_item_discount
this.deptItemDiscountShowDialog = true
this.$nextTick(() => {
if (row) {
this.$refs.deptItemDiscountPercent.focus()
} else {
this.$refs.deptItemDiscountDeptID.focus()
}
})
}
ThisPage.methods.deptItemDiscountSave = function() {
if (this.deptItemDiscountRow) {
this.deptItemDiscountRow.department_id = this.deptItemDiscountDeptID
this.deptItemDiscountRow.department_name = this.deptItemDiscountDeptName
this.deptItemDiscountRow.default_item_discount = this.deptItemDiscountPercent
} else {
this.deptItemDiscounts.push({
department_id: this.deptItemDiscountDeptID,
department_name: this.deptItemDiscountDeptName,
default_item_discount: this.deptItemDiscountPercent,
})
}
this.deptItemDiscountShowDialog = false
this.settingsNeedSaved = true
}
</script>
</%def>

View file

@ -42,7 +42,25 @@
<script type="text/x-template" id="order-creator-template">
<div>
${self.order_form_buttons()}
<div style="display: flex; justify-content: space-between; margin-bottom: 1.5rem;">
<div>
% if expose_store_id:
<b-loading v-model="storeLoading" is-full-page />
<b-field label="Store" horizontal
:type="storeID ? null : 'is-danger'">
<b-select v-model="storeID"
@input="storeChanged">
<option v-for="store in stores"
:key="store.store_id"
:value="store.store_id">
{{ store.display }}
</option>
</b-select>
</b-field>
% endif
</div>
${self.order_form_buttons()}
</div>
<${b}-collapse class="panel"
:class="customerPanelType"
@ -337,6 +355,135 @@
@click="showAddItemDialog()">
Add Item
</b-button>
% if allow_past_item_reorder:
<b-button v-if="customerIsKnown && customerID"
icon-pack="fas"
icon-left="plus"
@click="showAddPastItem()">
Add Past Item
</b-button>
<${b}-modal
% if request.use_oruga:
v-model:active="pastItemsShowDialog"
% else:
:active.sync="pastItemsShowDialog"
% endif
>
<div class="card">
<div class="card-content">
<${b}-table :data="pastItems"
icon-pack="fas"
:loading="pastItemsLoading"
% if request.use_oruga:
v-model:selected="pastItemsSelected"
% else:
:selected.sync="pastItemsSelected"
% endif
sortable
paginated
per-page="5"
## :debounce-search="1000"
>
<${b}-table-column label="Scancode"
field="key"
v-slot="props"
sortable>
{{ props.row.scancode }}
</${b}-table-column>
<${b}-table-column label="Brand"
field="brand_name"
v-slot="props"
sortable
searchable>
{{ props.row.brand_name }}
</${b}-table-column>
<${b}-table-column label="Description"
field="description"
v-slot="props"
sortable
searchable>
{{ props.row.description }}
{{ props.row.size }}
</${b}-table-column>
<${b}-table-column label="Unit Price"
field="unit_price_reg_display"
v-slot="props"
sortable>
{{ props.row.unit_price_reg_display }}
</${b}-table-column>
<${b}-table-column label="Sale Price"
field="sale_price"
v-slot="props"
sortable>
<span class="has-background-warning">
{{ props.row.sale_price_display }}
</span>
</${b}-table-column>
<${b}-table-column label="Sale Ends"
field="sale_ends"
v-slot="props"
sortable>
<span class="has-background-warning">
{{ props.row.sale_ends_display }}
</span>
</${b}-table-column>
<${b}-table-column label="Department"
field="department_name"
v-slot="props"
sortable
searchable>
{{ props.row.department_name }}
</${b}-table-column>
<${b}-table-column label="Vendor"
field="vendor_name"
v-slot="props"
sortable
searchable>
{{ props.row.vendor_name }}
</${b}-table-column>
<template #empty>
<div class="content has-text-grey has-text-centered">
<p>
<b-icon
pack="fas"
icon="sad-tear"
size="is-large">
</b-icon>
</p>
<p>Nothing here.</p>
</div>
</template>
</${b}-table>
<div class="buttons">
<b-button @click="pastItemsShowDialog = false">
Cancel
</b-button>
<b-button type="is-primary"
icon-pack="fas"
icon-left="plus"
@click="pastItemsAddSelected()"
:disabled="!pastItemsSelected">
Add Selected Item
</b-button>
</div>
</div>
</div>
</${b}-modal>
% endif
</div>
<${b}-modal
@ -486,7 +633,16 @@
<b-input v-model="pendingProduct.scancode" />
</b-field>
<b-field label="Department"
<b-field label="Dept. ID"
% if 'department_id' in pending_product_required_fields:
:type="pendingProduct.department_id ? null : 'is-danger'"
% endif
style="width: 15rem;">
<b-input v-model="pendingProduct.department_id"
@input="updateDiscount" />
</b-field>
<b-field label="Department Name"
% if 'department_name' in pending_product_required_fields:
:type="pendingProduct.department_name ? null : 'is-danger'"
% endif
@ -494,8 +650,7 @@
<b-input v-model="pendingProduct.department_name" />
</b-field>
<b-field label="Special Order"
style="width: 100%;">
<b-field label="Special Order">
<b-checkbox v-model="pendingProduct.special_order" />
</b-field>
@ -732,7 +887,7 @@
<${b}-table-column label="Department"
v-slot="props">
{{ props.row.department_display }}
{{ props.row.department_name }}
</${b}-table-column>
<${b}-table-column label="Quantity"
@ -837,6 +992,12 @@
batchTotalPriceDisplay: ${json.dumps(normalized_batch['total_price_display'])|n},
% if expose_store_id:
stores: ${json.dumps(stores)|n},
storeID: ${json.dumps(batch.store_id)|n},
storeLoading: false,
% endif
customerPanelOpen: false,
customerLoading: false,
customerIsKnown: ${json.dumps(customer_is_known)|n},
@ -898,8 +1059,10 @@
productCaseSize: null,
% if allow_item_discounts:
productDiscountPercent: ${json.dumps(default_item_discount)|n},
defaultItemDiscount: ${json.dumps(default_item_discount)|n},
deptItemDiscounts: ${json.dumps(dept_item_discounts)|n},
allowDiscountsIfOnSale: ${json.dumps(allow_item_discounts_if_on_sale)|n},
productDiscountPercent: null,
% endif
pendingProduct: {},
@ -908,6 +1071,13 @@
## departmentOptions: ${json.dumps(department_options)|n},
departmentOptions: [],
% if allow_past_item_reorder:
pastItemsShowDialog: false,
pastItemsLoading: false,
pastItems: [],
pastItemsSelected: null,
% endif
// nb. hack to force refresh for vue3
refreshProductDescription: 1,
refreshTotalPrice: 1,
@ -1160,8 +1330,31 @@
})
},
% if expose_store_id:
storeChanged(storeID) {
this.storeLoading = true
const params = {
action: 'set_store',
store_id: storeID,
}
this.submitBatchData(params, ({data}) => {
this.storeLoading = false
}, response => {
this.$buefy.toast.open({
message: "Update failed: " + (response.data.error || "(unknown error)"),
type: 'is-danger',
duration: 2000, // 2 seconds
})
this.storeLoading = false
})
},
% endif
customerChanged(customerID, callback) {
this.customerLoading = true
this.pastItems = []
const params = {}
if (customerID) {
@ -1213,6 +1406,23 @@
})
},
% if allow_item_discounts:
updateDiscount(deptID) {
if (deptID) {
// nb. our map requires ID as string
deptID = deptID.toString()
}
const i = Object.keys(this.deptItemDiscounts).indexOf(deptID)
if (i == -1) {
this.productDiscountPercent = this.defaultItemDiscount
} else {
this.productDiscountPercent = this.deptItemDiscounts[deptID]
}
},
% endif
editNewCustomerSave() {
this.editNewCustomerSaving = true
@ -1351,7 +1561,7 @@
this.productUnitChoices = this.defaultUnitChoices
% if allow_item_discounts:
this.productDiscountPercent = ${json.dumps(default_item_discount)|n}
this.productDiscountPercent = this.defaultItemDiscount
% endif
},
@ -1390,7 +1600,15 @@
this.productSaleEndsDisplay = data.sale_ends_display
% if allow_item_discounts:
this.productDiscountPercent = this.allowItemDiscount ? data.default_item_discount : null
if (this.allowItemDiscount) {
if (data?.default_item_discount != null) {
this.productDiscountPercent = data.default_item_discount
} else {
this.updateDiscount(data?.department_id)
}
} else {
this.productDiscountPercent = null
}
% endif
// this.setProductUnitChoices(data.uom_choices)
@ -1468,7 +1686,7 @@
this.productUOM = this.defaultUOM
% if allow_item_discounts:
this.productDiscountPercent = ${json.dumps(default_item_discount)|n}
this.productDiscountPercent = this.defaultItemDiscount
% endif
% if request.use_oruga:
@ -1522,7 +1740,7 @@
## this.productSpecialOrder = row.special_order
this.productQuantity = row.order_qty
this.productUnitChoices = row.order_uom_choices
this.productUnitChoices = row?.order_uom_choices || this.defaultUnitChoices
this.productUOM = row.order_uom
% if allow_item_discounts:
@ -1564,12 +1782,77 @@
})
},
% if allow_past_item_reorder:
showAddPastItem() {
this.pastItemsSelected = null
if (!this.pastItems.length) {
this.pastItemsLoading = true
const params = {action: 'get_past_products'}
this.submitBatchData(params, ({data}) => {
this.pastItems = data
this.pastItemsLoading = false
})
}
this.pastItemsShowDialog = true
},
pastItemsAddSelected() {
this.pastItemsShowDialog = false
const selected = this.pastItemsSelected
this.editItemRow = null
this.productIsKnown = true
this.productID = selected.product_id
this.selectedProduct = {
product_id: selected.product_id,
full_description: selected.full_description,
// url: selected.product_url,
}
this.productDisplay = selected.full_description
this.productScancode = selected.scancode
this.productSize = selected.size
this.productCaseQuantity = selected.case_size
this.productUnitPrice = selected.unit_price_quoted
this.productUnitPriceDisplay = selected.unit_price_quoted_display
this.productUnitRegularPriceDisplay = selected.unit_price_reg_display
this.productCasePrice = selected.case_price_quoted
this.productCasePriceDisplay = selected.case_price_quoted_display
this.productSalePrice = selected.unit_price_sale
this.productSalePriceDisplay = selected.unit_price_sale_display
this.productSaleEndsDisplay = selected.sale_ends_display
this.productSpecialOrder = selected.special_order
this.productQuantity = 1
this.productUnitChoices = selected?.order_uom_choices || this.defaultUnitChoices
this.productUOM = selected?.order_uom || this.defaultUOM
% if allow_item_discounts:
this.updateDiscount(selected.department_id)
% endif
// nb. hack to force refresh for vue3
this.refreshProductDescription += 1
this.refreshTotalPrice += 1
% if request.use_oruga:
this.itemDialogTab = 'quantity'
% else:
this.itemDialogTabIndex = 1
% endif
this.editItemShowDialog = true
},
% endif
itemDialogAttemptSave() {
this.itemDialogSaving = true
this.editItemLoading = true
const params = {
order_qty: this.productQuantity,
order_qty: parseFloat(this.productQuantity),
order_uom: this.productUOM,
}
@ -1580,7 +1863,9 @@
}
% if allow_item_discounts:
params.discount_percent = this.productDiscountPercent
if (this.productDiscountPercent) {
params.discount_percent = parseFloat(this.productDiscountPercent)
}
% endif
if (this.editItemRow) {

View file

@ -35,6 +35,7 @@ def includeme(config):
})
# sideshow views
config.include('sideshow.web.views.stores')
config.include('sideshow.web.views.customers')
config.include('sideshow.web.views.products')
config.include('sideshow.web.views.orders')

View file

@ -126,6 +126,10 @@ class NewOrderBatchView(BatchMasterView):
'status_code',
]
def __init__(self, request, context=None):
super().__init__(request, context=context)
self.order_handler = self.app.get_order_handler()
def get_batch_handler(self):
""" """
# TODO: call self.app.get_batch_handler()
@ -135,6 +139,10 @@ class NewOrderBatchView(BatchMasterView):
""" """
super().configure_grid(g)
# store_id
if not self.order_handler.expose_store_id():
g.remove('store_id')
# total_price
g.set_renderer('total_price', 'currency')
@ -142,6 +150,10 @@ class NewOrderBatchView(BatchMasterView):
""" """
super().configure_form(f)
# store_id
if not self.order_handler.expose_store_id():
f.remove('store_id')
# local_customer
f.set_node('local_customer', LocalCustomerRef(self.request))

View file

@ -25,7 +25,9 @@ Views for Orders
"""
import decimal
import json
import logging
import re
import colander
import sqlalchemy as sa
@ -35,9 +37,9 @@ from webhelpers2.html import tags, HTML
from wuttaweb.views import MasterView
from wuttaweb.forms.schema import UserRef, WuttaMoney, WuttaQuantity, WuttaEnum, WuttaDictEnum
from wuttaweb.util import make_json_safe
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,
@ -65,13 +67,13 @@ class OrderView(MasterView):
.. attribute:: order_handler
Reference to the :term:`order handler` as returned by
:meth:`get_order_handler()`. This gets set in the constructor.
:meth:`~sideshow.app.SideshowAppProvider.get_order_handler()`.
This gets set in the constructor.
.. attribute:: batch_handler
Reference to the :term:`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. This gets
set in the constructor.
"""
model_class = Order
editable = False
@ -145,6 +147,7 @@ class OrderView(MasterView):
'brand_name',
'description',
'size',
'department_id',
'department_name',
'vendor_name',
'vendor_item_code',
@ -155,41 +158,17 @@ class OrderView(MasterView):
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_batch_handler(self):
"""
Returns the configured :term:`handler` for :term:`new order
batches <new order batch>`.
You normally would not need to call this, and can use
:attr:`batch_handler` instead.
:returns:
:class:`~sideshow.batch.neworder.NewOrderBatchHandler`
instance.
"""
if hasattr(self, 'batch_handler'):
return self.batch_handler
return self.app.get_batch_handler('neworder')
self.order_handler = self.app.get_order_handler()
self.batch_handler = self.app.get_batch_handler('neworder')
def configure_grid(self, g):
""" """
super().configure_grid(g)
# store_id
if not self.order_handler.expose_store_id():
g.remove('store_id')
# order_id
g.set_link('order_id')
@ -223,6 +202,7 @@ class OrderView(MasterView):
* :meth:`start_over()`
* :meth:`cancel_order()`
* :meth:`set_store()`
* :meth:`assign_customer()`
* :meth:`unassign_customer()`
* :meth:`set_pending_customer()`
@ -232,10 +212,11 @@ class OrderView(MasterView):
* :meth:`delete_item()`
* :meth:`submit_order()`
"""
model = self.app.model
enum = self.app.enum
self.creating = True
self.batch_handler = self.get_batch_handler()
session = self.Session()
batch = self.get_current_batch()
self.creating = True
context = self.get_context_customer(batch)
@ -254,6 +235,7 @@ class OrderView(MasterView):
data = dict(self.request.json_body)
action = data.pop('action')
json_actions = [
'set_store',
'assign_customer',
'unassign_customer',
# 'update_phone_number',
@ -262,7 +244,7 @@ class OrderView(MasterView):
# 'get_customer_info',
# # 'set_customer_data',
'get_product_info',
# 'get_past_items',
'get_past_products',
'add_item',
'update_item',
'delete_item',
@ -283,20 +265,36 @@ class OrderView(MasterView):
'normalized_batch': self.normalize_batch(batch),
'order_items': [self.normalize_row(row)
for row in batch.rows],
'default_uom_choices': self.get_default_uom_choices(),
'default_uom_choices': self.batch_handler.get_default_uom_choices(),
'default_uom': None, # TODO?
'expose_store_id': self.order_handler.expose_store_id(),
'allow_item_discounts': self.batch_handler.allow_item_discounts(),
'allow_unknown_products': (self.batch_handler.allow_unknown_products()
and self.has_perm('create_unknown_product')),
'pending_product_required_fields': self.get_pending_product_required_fields(),
'allow_past_item_reorder': True, # TODO: make configurable?
})
if context['expose_store_id']:
stores = session.query(model.Store)\
.filter(model.Store.archived == False)\
.order_by(model.Store.store_id)\
.all()
context['stores'] = [{'store_id': store.store_id, 'display': store.get_display()}
for store in stores]
# set default so things just work
if not batch.store_id:
batch.store_id = self.batch_handler.get_default_store_id()
if context['allow_item_discounts']:
context['allow_item_discounts_if_on_sale'] = self.batch_handler\
.allow_item_discounts_if_on_sale()
# nb. render quantity so that '10.0' => '10'
context['default_item_discount'] = self.app.render_quantity(
self.batch_handler.get_default_item_discount())
context['dept_item_discounts'] = dict([(d['department_id'], d['default_item_discount'])
for d in self.get_dept_item_discounts()])
return self.render_to_response('create', context)
@ -352,7 +350,7 @@ class OrderView(MasterView):
if not term:
return []
handler = self.get_batch_handler()
handler = self.batch_handler
if handler.use_local_customers():
return handler.autocomplete_customers_local(session, term, user=self.request.user)
else:
@ -376,7 +374,7 @@ class OrderView(MasterView):
if not term:
return []
handler = self.get_batch_handler()
handler = self.batch_handler
if handler.use_local_products():
return handler.autocomplete_products_local(session, term, user=self.request.user)
else:
@ -394,6 +392,43 @@ class OrderView(MasterView):
required.append(field)
return required
def get_dept_item_discounts(self):
"""
Returns the list of per-department default item discount settings.
Each entry in the list will look like::
{
'department_id': '42',
'department_name': 'Grocery',
'default_item_discount': 10,
}
:returns: List of department settings as shown above.
"""
model = self.app.model
session = self.Session()
pattern = re.compile(r'^sideshow\.orders\.departments\.([^.]+)\.default_item_discount$')
dept_item_discounts = []
settings = session.query(model.Setting)\
.filter(model.Setting.name.like('sideshow.orders.departments.%.default_item_discount'))\
.all()
for setting in settings:
match = pattern.match(setting.name)
if not match:
log.warning("invalid setting name: %s", setting.name)
continue
deptid = match.group(1)
name = self.app.get_setting(session, f'sideshow.orders.departments.{deptid}.name')
dept_item_discounts.append({
'department_id': deptid,
'department_name': name,
'default_item_discount': setting.value,
})
dept_item_discounts.sort(key=lambda d: d['department_name'])
return dept_item_discounts
def start_over(self, batch):
"""
This will delete the user's current batch, then redirect user
@ -436,9 +471,26 @@ class OrderView(MasterView):
url = self.get_index_url()
return self.redirect(url)
def set_store(self, batch, data):
"""
Assign the
:attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.store_id`
for a batch.
This is a "batch action" method which may be called from
:meth:`create()`.
"""
store_id = data.get('store_id')
if not store_id:
return {'error': "Must provide store_id"}
batch.store_id = store_id
return self.get_context_customer(batch)
def get_context_customer(self, batch):
""" """
context = {
'store_id': batch.store_id,
'customer_is_known': True,
'customer_id': None,
'customer_name': batch.customer_name,
@ -531,18 +583,20 @@ class OrderView(MasterView):
def get_product_info(self, batch, data):
"""
Fetch data for a specific product. (Nothing is modified.)
Fetch data for a specific product.
Depending on config, this will fetch a :term:`local product`
or :term:`external product` to get the data.
Depending on config, this calls one of the following to get
its primary data:
This should invoke a configured handler for the query
behavior, but that is not yet implemented. For now it uses
built-in logic only, which queries the
:class:`~sideshow.db.model.products.LocalProduct` table.
* :meth:`~sideshow.batch.neworder.NewOrderBatchHandler.get_product_info_local()`
* :meth:`~sideshow.batch.neworder.NewOrderBatchHandler.get_product_info_external()`
It then may supplement the data with additional fields.
This is a "batch action" method which may be called from
:meth:`create()`.
:returns: Dict of product info.
"""
product_id = data.get('product_id')
if not product_id:
@ -574,9 +628,6 @@ class OrderView(MasterView):
if 'case_price_quoted' in data and 'case_price_quoted_display' not in data:
data['case_price_quoted_display'] = self.app.render_currency(data['case_price_quoted'])
if 'default_item_discount' not in data:
data['default_item_discount'] = self.batch_handler.get_default_item_discount()
decimal_fields = [
'case_size',
'unit_price_reg',
@ -593,6 +644,22 @@ class OrderView(MasterView):
return data
def get_past_products(self, batch, data):
"""
Fetch past products for convenient re-ordering.
This essentially calls
:meth:`~sideshow.batch.neworder.NewOrderBatchHandler.get_past_products()`
on the :attr:`batch_handler` and returns the result.
This is a "batch action" method which may be called from
:meth:`create()`.
:returns: List of product info dicts.
"""
past_products = self.batch_handler.get_past_products(batch)
return make_json_safe(past_products)
def add_item(self, batch, data):
"""
This adds a row to the user's current new order batch.
@ -709,12 +776,6 @@ class OrderView(MasterView):
'status_text': batch.status_text,
}
def get_default_uom_choices(self):
""" """
enum = self.app.enum
return [{'key': key, 'value': val}
for key, val in enum.ORDER_UOM.items()]
def normalize_row(self, row):
""" """
data = {
@ -729,12 +790,12 @@ class OrderView(MasterView):
row.product_description,
row.product_size),
'product_weighed': row.product_weighed,
'department_display': row.department_name,
'department_id': row.department_id,
'department_name': row.department_name,
'special_order': row.special_order,
'case_size': float(row.case_size) if row.case_size is not None else None,
'order_qty': float(row.order_qty),
'order_uom': row.order_uom,
'order_uom_choices': self.get_default_uom_choices(),
'discount_percent': self.app.render_quantity(row.discount_percent),
'unit_price_quoted': float(row.unit_price_quoted) if row.unit_price_quoted is not None else None,
'unit_price_quoted_display': self.app.render_currency(row.unit_price_quoted),
@ -810,6 +871,10 @@ class OrderView(MasterView):
super().configure_form(f)
order = f.model_instance
# store_id
if not self.order_handler.expose_store_id():
f.remove('store_id')
# local_customer
if order.customer_id and not order.local_customer:
f.remove('local_customer')
@ -910,8 +975,10 @@ class OrderView(MasterView):
""" """
settings = [
# batches
{'name': 'wutta.batch.neworder.handler.spec'},
# stores
{'name': 'sideshow.orders.expose_store_id',
'type': bool},
{'name': 'sideshow.orders.default_store_id'},
# customers
{'name': 'sideshow.orders.use_local_customers',
@ -920,12 +987,6 @@ class OrderView(MasterView):
'default': 'true'},
# products
{'name': 'sideshow.orders.allow_item_discounts',
'type': bool},
{'name': 'sideshow.orders.allow_item_discounts_if_on_sale',
'type': bool},
{'name': 'sideshow.orders.default_item_discount',
'type': float},
{'name': 'sideshow.orders.use_local_products',
# nb. this is really a bool but we present as string in config UI
#'type': bool,
@ -933,6 +994,17 @@ class OrderView(MasterView):
{'name': 'sideshow.orders.allow_unknown_products',
'type': bool,
'default': True},
# pricing
{'name': 'sideshow.orders.allow_item_discounts',
'type': bool},
{'name': 'sideshow.orders.allow_item_discounts_if_on_sale',
'type': bool},
{'name': 'sideshow.orders.default_item_discount',
'type': float},
# batches
{'name': 'wutta.batch.neworder.handler.spec'},
]
# required fields for new product entry
@ -955,8 +1027,39 @@ class OrderView(MasterView):
handlers = [{'spec': spec} for spec in handlers]
context['batch_handlers'] = handlers
context['dept_item_discounts'] = self.get_dept_item_discounts()
return context
def configure_gather_settings(self, data, simple_settings=None):
""" """
settings = super().configure_gather_settings(data, simple_settings=simple_settings)
for dept in json.loads(data['dept_item_discounts']):
deptid = dept['department_id']
settings.append({'name': f'sideshow.orders.departments.{deptid}.name',
'value': dept['department_name']})
settings.append({'name': f'sideshow.orders.departments.{deptid}.default_item_discount',
'value': dept['default_item_discount']})
return settings
def configure_remove_settings(self, **kwargs):
""" """
model = self.app.model
session = self.Session()
super().configure_remove_settings(**kwargs)
to_delete = session.query(model.Setting)\
.filter(sa.or_(
model.Setting.name.like('sideshow.orders.departments.%.name'),
model.Setting.name.like('sideshow.orders.departments.%.default_item_discount')))\
.all()
for setting in to_delete:
self.app.delete_setting(session, setting.name)
@classmethod
def defaults(cls, config):
cls._order_defaults(config)
@ -1037,6 +1140,7 @@ class OrderItemView(MasterView):
labels = {
'order_id': "Order ID",
'store_id': "Store ID",
'product_id': "Product ID",
'product_scancode': "Scancode",
'product_brand': "Brand",
@ -1050,6 +1154,7 @@ class OrderItemView(MasterView):
grid_columns = [
'order_id',
'store_id',
'customer_name',
# 'sequence',
'product_scancode',
@ -1099,20 +1204,7 @@ class OrderItemView(MasterView):
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)
self.order_handler = self.app.get_order_handler()
def get_fallback_templates(self, template):
""" """
@ -1132,11 +1224,19 @@ class OrderItemView(MasterView):
model = self.app.model
# enum = self.app.enum
# store_id
if not self.order_handler.expose_store_id():
g.remove('store_id')
# order_id
g.set_sorter('order_id', model.Order.order_id)
g.set_renderer('order_id', self.render_order_id)
g.set_renderer('order_id', self.render_order_attr)
g.set_link('order_id')
# store_id
g.set_sorter('store_id', model.Order.store_id)
g.set_renderer('store_id', self.render_order_attr)
# customer_name
g.set_label('customer_name', "Customer", column_only=True)
@ -1165,9 +1265,10 @@ class OrderItemView(MasterView):
# status_code
g.set_renderer('status_code', self.render_status_code)
def render_order_id(self, item, key, value):
def render_order_attr(self, item, key, value):
""" """
return item.order.order_id
order = item.order
return getattr(order, key)
def render_status_code(self, item, key, value):
""" """
@ -1237,6 +1338,8 @@ class OrderItemView(MasterView):
item = context['instance']
form = context['form']
context['expose_store_id'] = self.order_handler.expose_store_id()
context['item'] = item
context['order'] = item.order
context['order_qty_uom_text'] = self.order_handler.get_order_qty_uom_text(

View file

@ -235,6 +235,7 @@ class PendingProductView(MasterView):
url_prefix = '/pending/products'
labels = {
'department_id': "Department ID",
'product_id': "Product ID",
}

View file

@ -0,0 +1,120 @@
# -*- 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/>.
#
################################################################################
"""
Views for Stores
"""
from wuttaweb.views import MasterView
from sideshow.db.model import Store
class StoreView(MasterView):
"""
Master view for
:class:`~sideshow.db.model.stores.Store`; route prefix
is ``stores``.
Notable URLs provided by this class:
* ``/stores/``
* ``/stores/new``
* ``/stores/XXX``
* ``/stores/XXX/edit``
* ``/stores/XXX/delete``
"""
model_class = Store
labels = {
'store_id': "Store ID",
}
filter_defaults = {
'archived': {'active': True, 'verb': 'is_false'},
}
sort_defaults = 'store_id'
def configure_grid(self, g):
""" """
super().configure_grid(g)
# links
g.set_link('store_id')
g.set_link('name')
def grid_row_class(self, store, data, i):
""" """
if store.archived:
return 'has-background-warning'
def configure_form(self, f):
""" """
super().configure_form(f)
# store_id
f.set_validator('store_id', self.unique_store_id)
# name
f.set_validator('name', self.unique_name)
def unique_store_id(self, node, value):
""" """
model = self.app.model
session = self.Session()
query = session.query(model.Store)\
.filter(model.Store.store_id == value)
if self.editing:
uuid = self.request.matchdict['uuid']
query = query.filter(model.Store.uuid != uuid)
if query.count():
node.raise_invalid("Store ID must be unique")
def unique_name(self, node, value):
""" """
model = self.app.model
session = self.Session()
query = session.query(model.Store)\
.filter(model.Store.name == value)
if self.editing:
uuid = self.request.matchdict['uuid']
query = query.filter(model.Store.uuid != uuid)
if query.count():
node.raise_invalid("Name must be unique")
def defaults(config, **kwargs):
base = globals()
StoreView = kwargs.get('StoreView', base['StoreView'])
StoreView.defaults(config)
def includeme(config):
defaults(config)

View file

@ -4,6 +4,8 @@ import datetime
import decimal
from unittest.mock import patch
import sqlalchemy as sa
from wuttjamaican.testing import DataTestCase
from sideshow.batch import neworder as mod
@ -20,6 +22,16 @@ class TestNewOrderBatchHandler(DataTestCase):
def make_handler(self):
return mod.NewOrderBatchHandler(self.config)
def test_get_default_store_id(self):
handler = self.make_handler()
# null by default
self.assertIsNone(handler.get_default_store_id())
# whatever is configured
self.config.setdefault('sideshow.orders.default_store_id', '042')
self.assertEqual(handler.get_default_store_id(), '042')
def test_use_local_customers(self):
handler = self.make_handler()
@ -108,6 +120,23 @@ class TestNewOrderBatchHandler(DataTestCase):
# search for sally finds nothing
self.assertEqual(handler.autocomplete_customers_local(self.session, 'sally'), [])
def test_init_batch(self):
model = self.app.model
handler = self.make_handler()
# store_id is null by default
batch = handler.model_class()
self.assertIsNone(batch.store_id)
handler.init_batch(batch)
self.assertIsNone(batch.store_id)
# but default can be configured
self.config.setdefault('sideshow.orders.default_store_id', '042')
batch = handler.model_class()
self.assertIsNone(batch.store_id)
handler.init_batch(batch)
self.assertEqual(batch.store_id, '042')
def test_set_customer(self):
model = self.app.model
handler = self.make_handler()
@ -240,6 +269,14 @@ class TestNewOrderBatchHandler(DataTestCase):
# search for juice finds nothing
self.assertEqual(handler.autocomplete_products_local(self.session, 'juice'), [])
def test_get_default_uom_choices(self):
enum = self.app.enum
handler = self.make_handler()
uoms = handler.get_default_uom_choices()
self.assertEqual(uoms, [{'key': key, 'value': val}
for key, val in enum.ORDER_UOM.items()])
def test_get_product_info_external(self):
handler = self.make_handler()
self.assertRaises(NotImplementedError, handler.get_product_info_external,
@ -281,6 +318,174 @@ class TestNewOrderBatchHandler(DataTestCase):
mock_uuid = self.app.make_true_uuid()
self.assertRaises(ValueError, handler.get_product_info_local, self.session, mock_uuid.hex)
def test_normalize_local_product(self):
model = self.app.model
handler = self.make_handler()
product = model.LocalProduct(scancode='07430500132',
brand_name="Bragg's",
description="Apple Cider Vinegar",
size="32oz",
department_name="Grocery",
case_size=12,
unit_price_reg=5.99,
vendor_name="UNFI",
vendor_item_code='1234')
self.session.add(product)
self.session.flush()
info = handler.normalize_local_product(product)
self.assertIsInstance(info, dict)
self.assertEqual(info['product_id'], product.uuid.hex)
for prop in sa.inspect(model.LocalProduct).column_attrs:
if prop.key == 'uuid':
continue
if prop.key not in info:
continue
self.assertEqual(info[prop.key], getattr(product, prop.key))
def test_get_past_orders(self):
model = self.app.model
handler = self.make_handler()
user = model.User(username='barney')
self.session.add(user)
batch = handler.make_batch(self.session, created_by=user)
self.session.add(batch)
self.session.flush()
# ..will test local customers first
# error if no customer
self.assertRaises(ValueError, handler.get_past_orders, batch)
# empty history for customer
customer = model.LocalCustomer(full_name='Fred Flintstone')
batch.local_customer = customer
self.session.flush()
orders = handler.get_past_orders(batch)
self.assertEqual(len(orders), 0)
# mock historical order
order = model.Order(order_id=42, local_customer=customer, created_by=user)
self.session.add(order)
self.session.flush()
# that should now be returned
orders = handler.get_past_orders(batch)
self.assertEqual(len(orders), 1)
self.assertIs(orders[0], order)
# ..now we test external customers, w/ new batch
with patch.object(handler, 'use_local_customers', return_value=False):
batch2 = handler.make_batch(self.session, created_by=user)
self.session.add(batch2)
self.session.flush()
# error if no customer
self.assertRaises(ValueError, handler.get_past_orders, batch2)
# empty history for customer
batch2.customer_id = '123'
self.session.flush()
orders = handler.get_past_orders(batch2)
self.assertEqual(len(orders), 0)
# mock historical order
order2 = model.Order(order_id=42, customer_id='123', created_by=user)
self.session.add(order2)
self.session.flush()
# that should now be returned
orders = handler.get_past_orders(batch2)
self.assertEqual(len(orders), 1)
self.assertIs(orders[0], order2)
def test_get_past_products(self):
model = self.app.model
enum = self.app.enum
handler = self.make_handler()
user = model.User(username='barney')
self.session.add(user)
batch = handler.make_batch(self.session, created_by=user)
self.session.add(batch)
self.session.flush()
# (nb. this all assumes local customers)
# ..will test local products first
# error if no customer
self.assertRaises(ValueError, handler.get_past_products, batch)
# empty history for customer
customer = model.LocalCustomer(full_name='Fred Flintstone')
batch.local_customer = customer
self.session.flush()
products = handler.get_past_products(batch)
self.assertEqual(len(products), 0)
# mock historical order
order = model.Order(order_id=42, local_customer=customer, created_by=user)
product = model.LocalProduct(scancode='07430500132', description='Vinegar',
unit_price_reg=5.99, case_size=12)
item = model.OrderItem(local_product=product, order_qty=1, order_uom=enum.ORDER_UOM_UNIT,
status_code=enum.ORDER_ITEM_STATUS_READY)
order.items.append(item)
self.session.add(order)
self.session.flush()
self.session.refresh(product)
# that should now be returned
products = handler.get_past_products(batch)
self.assertEqual(len(products), 1)
self.assertEqual(products[0]['product_id'], product.uuid.hex)
self.assertEqual(products[0]['scancode'], '07430500132')
self.assertEqual(products[0]['description'], 'Vinegar')
self.assertEqual(products[0]['case_price_quoted'], decimal.Decimal('71.88'))
self.assertEqual(products[0]['case_price_quoted_display'], '$71.88')
# ..now we test external products, w/ new batch
with patch.object(handler, 'use_local_products', return_value=False):
batch2 = handler.make_batch(self.session, created_by=user)
self.session.add(batch2)
self.session.flush()
# error if no customer
self.assertRaises(ValueError, handler.get_past_products, batch2)
# empty history for customer
batch2.local_customer = customer
self.session.flush()
products = handler.get_past_products(batch2)
self.assertEqual(len(products), 0)
# mock historical order
order2 = model.Order(order_id=44, local_customer=customer, created_by=user)
self.session.add(order2)
item2 = model.OrderItem(product_id='07430500116',
order_qty=1, order_uom=enum.ORDER_UOM_UNIT,
status_code=enum.ORDER_ITEM_STATUS_READY)
order2.items.append(item2)
self.session.flush()
# its product should now be returned
with patch.object(handler, 'get_product_info_external', return_value={
'product_id': '07430500116',
'scancode': '07430500116',
'description': 'VINEGAR',
'unit_price_reg': decimal.Decimal('3.99'),
'case_size': 12,
}):
products = handler.get_past_products(batch2)
self.assertEqual(len(products), 1)
self.assertEqual(products[0]['product_id'], '07430500116')
self.assertEqual(products[0]['scancode'], '07430500116')
self.assertEqual(products[0]['description'], 'VINEGAR')
self.assertEqual(products[0]['case_price_quoted'], decimal.Decimal('47.88'))
self.assertEqual(products[0]['case_price_quoted_display'], '$47.88')
def test_add_item(self):
model = self.app.model
enum = self.app.enum

View file

@ -0,0 +1,28 @@
# -*- coding: utf-8; -*-
from wuttjamaican.testing import DataTestCase
from sideshow.db.model import stores as mod
class TestPendingCustomer(DataTestCase):
def test_str(self):
store = mod.Store()
self.assertEqual(str(store), "")
store.name = "Acme Goods"
self.assertEqual(str(store), "Acme Goods")
store.store_id = "001"
self.assertEqual(str(store), "001 Acme Goods")
def test_get_display(self):
store = mod.Store()
self.assertEqual(store.get_display(), "")
store.name = "Acme Goods"
self.assertEqual(store.get_display(), "Acme Goods")
store.store_id = "001"
self.assertEqual(store.get_display(), "001 Acme Goods")

17
tests/test_app.py Normal file
View file

@ -0,0 +1,17 @@
# -*- coding: utf-8; -*-
from wuttjamaican.testing import ConfigTestCase
from sideshow import app as mod
from sideshow.orders import OrderHandler
class TestSideshowAppProvider(ConfigTestCase):
def make_provider(self):
return mod.SideshowAppProvider(self.config)
def test_get_order_handler(self):
provider = self.make_provider()
handler = provider.get_order_handler()
self.assertIsInstance(handler, OrderHandler)

View file

@ -16,6 +16,16 @@ class TestOrderHandler(DataTestCase):
def make_handler(self):
return mod.OrderHandler(self.config)
def test_expose_store_id(self):
handler = self.make_handler()
# false by default
self.assertFalse(handler.expose_store_id())
# config can enable
self.config.setdefault('sideshow.orders.expose_store_id', 'true')
self.assertTrue(handler.expose_store_id())
def test_get_order_qty_uom_text(self):
enum = self.app.enum
handler = self.make_handler()

View file

@ -30,10 +30,19 @@ class TestNewOrderBatchView(WebTestCase):
def test_configure_grid(self):
model = self.app.model
view = self.make_view()
# store_id not exposed by default
grid = view.make_grid(model_class=model.NewOrderBatch)
self.assertNotIn('total_price', grid.renderers)
self.assertIn('store_id', grid.columns)
view.configure_grid(grid)
self.assertIn('total_price', grid.renderers)
self.assertNotIn('store_id', grid.columns)
# store_id is exposed if configured
self.config.setdefault('sideshow.orders.expose_store_id', 'true')
grid = view.make_grid(model_class=model.NewOrderBatch)
self.assertIn('store_id', grid.columns)
view.configure_grid(grid)
self.assertIn('store_id', grid.columns)
def test_configure_form(self):
model = self.app.model
@ -58,6 +67,19 @@ class TestNewOrderBatchView(WebTestCase):
self.assertIsInstance(schema['pending_customer'].typ, PendingCustomerRef)
self.assertIsInstance(schema['total_price'].typ, WuttaMoney)
# store_id not exposed by default
form = view.make_form(model_instance=batch)
self.assertIn('store_id', form)
view.configure_form(form)
self.assertNotIn('store_id', form)
# store_id is exposed if configured
self.config.setdefault('sideshow.orders.expose_store_id', 'true')
form = view.make_form(model_instance=batch)
self.assertIn('store_id', form)
view.configure_form(form)
self.assertIn('store_id', form)
def test_configure_row_grid(self):
model = self.app.model
view = self.make_view()

View file

@ -2,6 +2,7 @@
import datetime
import decimal
import json
from unittest.mock import patch
from sqlalchemy import orm
@ -15,6 +16,7 @@ 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
from sideshow.config import SideshowConfig
class TestIncludeme(WebTestCase):
@ -25,33 +27,39 @@ class TestIncludeme(WebTestCase):
class TestOrderView(WebTestCase):
def make_config(self, **kw):
config = super().make_config(**kw)
SideshowConfig().configure(config)
return config
def make_view(self):
return mod.OrderView(self.request)
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()
grid = view.make_grid(model_class=model.PendingProduct)
self.assertNotIn('order_id', grid.linked_columns)
self.assertNotIn('total_price', grid.renderers)
# store_id hidden by default
grid = view.make_grid(model_class=model.Order, columns=['store_id', 'order_id'])
self.assertIn('store_id', grid.columns)
view.configure_grid(grid)
self.assertIn('order_id', grid.linked_columns)
self.assertIn('total_price', grid.renderers)
self.assertNotIn('store_id', grid.columns)
# store_id is shown if configured
self.config.setdefault('sideshow.orders.expose_store_id', 'true')
grid = view.make_grid(model_class=model.Order, columns=['store_id', 'order_id'])
self.assertIn('store_id', grid.columns)
view.configure_grid(grid)
self.assertIn('store_id', grid.columns)
def test_create(self):
self.pyramid_config.include('sideshow.web.views')
self.config.setdefault('wutta.batch.neworder.handler.spec',
'sideshow.batch.neworder:NewOrderBatchHandler')
self.config.setdefault('sideshow.orders.expose_store_id', 'true')
self.config.setdefault('sideshow.orders.allow_item_discounts', 'true')
model = self.app.model
enum = self.app.enum
@ -59,6 +67,10 @@ class TestOrderView(WebTestCase):
user = model.User(username='barney')
self.session.add(user)
store = model.Store(store_id='001', name='Acme Goods')
self.session.add(store)
store = model.Store(store_id='002', name='Acme Services')
self.session.add(store)
self.session.flush()
with patch.object(view, 'Session', return_value=self.session):
@ -101,6 +113,7 @@ class TestOrderView(WebTestCase):
self.assertIsInstance(response, Response)
self.assertEqual(response.content_type, 'application/json')
self.assertEqual(response.json_body, {
'store_id': None,
'customer_is_known': False,
'customer_id': None,
'customer_name': 'Fred Flintstone',
@ -285,6 +298,50 @@ class TestOrderView(WebTestCase):
fields = view.get_pending_product_required_fields()
self.assertEqual(fields, ['brand_name', 'size', 'unit_price_reg'])
def test_get_dept_item_discounts(self):
model = self.app.model
view = self.make_view()
with patch.object(view, 'Session', return_value=self.session):
# empty list by default
discounts = view.get_dept_item_discounts()
self.assertEqual(discounts, [])
# mock settings
self.app.save_setting(self.session, 'sideshow.orders.departments.5.name', 'Bulk')
self.app.save_setting(self.session, 'sideshow.orders.departments.5.default_item_discount', '15')
self.app.save_setting(self.session, 'sideshow.orders.departments.6.name', 'Produce')
self.app.save_setting(self.session, 'sideshow.orders.departments.6.default_item_discount', '5')
discounts = view.get_dept_item_discounts()
self.assertEqual(len(discounts), 2)
self.assertEqual(discounts[0], {
'department_id': '5',
'department_name': 'Bulk',
'default_item_discount': '15',
})
self.assertEqual(discounts[1], {
'department_id': '6',
'department_name': 'Produce',
'default_item_discount': '5',
})
# invalid setting
self.app.save_setting(self.session, 'sideshow.orders.departments.I.N.V.A.L.I.D.name', 'Bad News')
self.app.save_setting(self.session, 'sideshow.orders.departments.I.N.V.A.L.I.D.default_item_discount', '42')
discounts = view.get_dept_item_discounts()
self.assertEqual(len(discounts), 2)
self.assertEqual(discounts[0], {
'department_id': '5',
'department_name': 'Bulk',
'default_item_discount': '15',
})
self.assertEqual(discounts[1], {
'department_id': '6',
'department_name': 'Produce',
'default_item_discount': '5',
})
def test_get_context_customer(self):
self.pyramid_config.add_route('orders', '/orders/')
model = self.app.model
@ -304,6 +361,7 @@ class TestOrderView(WebTestCase):
self.session.flush()
context = view.get_context_customer(batch)
self.assertEqual(context, {
'store_id': None,
'customer_is_known': True,
'customer_id': 42,
'customer_name': 'Fred Flintstone',
@ -321,6 +379,7 @@ class TestOrderView(WebTestCase):
self.session.flush()
context = view.get_context_customer(batch)
self.assertEqual(context, {
'store_id': None,
'customer_is_known': True,
'customer_id': local.uuid.hex,
'customer_name': 'Betty Boop',
@ -339,6 +398,7 @@ class TestOrderView(WebTestCase):
self.session.flush()
context = view.get_context_customer(batch)
self.assertEqual(context, {
'store_id': None,
'customer_is_known': False,
'customer_id': None,
'customer_name': 'Fred Flintstone',
@ -357,6 +417,7 @@ class TestOrderView(WebTestCase):
self.session.flush()
context = view.get_context_customer(batch)
self.assertEqual(context, {
'store_id': None,
'customer_is_known': True, # nb. this is for UI default
'customer_id': None,
'customer_name': None,
@ -408,6 +469,34 @@ class TestOrderView(WebTestCase):
self.session.flush()
self.assertEqual(self.session.query(model.NewOrderBatch).count(), 0)
def test_set_store(self):
model = self.app.model
view = self.make_view()
handler = NewOrderBatchHandler(self.config)
user = model.User(username='barney')
self.session.add(user)
self.session.flush()
with patch.object(view, 'batch_handler', create=True, new=handler):
with patch.object(view, 'Session', return_value=self.session):
with patch.object(self.request, 'user', new=user):
batch = view.get_current_batch()
self.assertIsNone(batch.store_id)
# store_id is required
result = view.set_store(batch, {})
self.assertEqual(result, {'error': "Must provide store_id"})
result = view.set_store(batch, {'store_id': ''})
self.assertEqual(result, {'error': "Must provide store_id"})
# store_id is set on batch
result = view.set_store(batch, {'store_id': '042'})
self.assertEqual(batch.store_id, '042')
self.assertIn('store_id', result)
self.assertEqual(result['store_id'], '042')
def test_assign_customer(self):
self.pyramid_config.add_route('orders.create', '/orders/new')
model = self.app.model
@ -432,6 +521,7 @@ class TestOrderView(WebTestCase):
self.assertIsNone(batch.pending_customer)
self.assertIs(batch.local_customer, weirdal)
self.assertEqual(context, {
'store_id': None,
'customer_is_known': True,
'customer_id': weirdal.uuid.hex,
'customer_name': 'Weird Al',
@ -470,6 +560,7 @@ class TestOrderView(WebTestCase):
self.assertIsNone(batch.customer_name)
self.assertIsNone(batch.local_customer)
self.assertEqual(context, {
'store_id': None,
'customer_is_known': True,
'customer_id': None,
'customer_name': None,
@ -510,6 +601,7 @@ class TestOrderView(WebTestCase):
context = view.set_pending_customer(batch, data)
self.assertIsInstance(batch.pending_customer, model.PendingCustomer)
self.assertEqual(context, {
'store_id': None,
'customer_is_known': False,
'customer_id': None,
'customer_name': 'Fred Flintstone',
@ -575,6 +667,51 @@ class TestOrderView(WebTestCase):
context = view.get_product_info(batch, {'product_id': '42'})
self.assertEqual(context, {'error': "something smells fishy"})
def test_get_past_products(self):
model = self.app.model
enum = self.app.enum
view = self.make_view()
handler = view.batch_handler
user = model.User(username='barney')
self.session.add(user)
batch = handler.make_batch(self.session, created_by=user)
self.session.add(batch)
self.session.flush()
# (nb. this all assumes local customers and products)
# error if no customer
self.assertRaises(ValueError, view.get_past_products, batch, {})
# empty history for customer
customer = model.LocalCustomer(full_name='Fred Flintstone')
batch.local_customer = customer
self.session.flush()
products = view.get_past_products(batch, {})
self.assertEqual(len(products), 0)
# mock historical order
order = model.Order(order_id=42, local_customer=customer, created_by=user)
product = model.LocalProduct(scancode='07430500132', description='Vinegar',
unit_price_reg=5.99, case_size=12)
item = model.OrderItem(local_product=product, order_qty=1, order_uom=enum.ORDER_UOM_UNIT,
status_code=enum.ORDER_ITEM_STATUS_READY)
order.items.append(item)
self.session.add(order)
self.session.flush()
self.session.refresh(product)
# that should now be returned
products = view.get_past_products(batch, {})
self.assertEqual(len(products), 1)
self.assertEqual(products[0]['product_id'], product.uuid.hex)
self.assertEqual(products[0]['scancode'], '07430500132')
self.assertEqual(products[0]['description'], 'Vinegar')
# nb. this is a float, since result is JSON-safe
self.assertEqual(products[0]['case_price_quoted'], 71.88)
self.assertEqual(products[0]['case_price_quoted_display'], '$71.88')
def test_add_item(self):
model = self.app.model
enum = self.app.enum
@ -825,14 +962,6 @@ class TestOrderView(WebTestCase):
'error': f"ValueError: batch has already been executed: {batch}",
})
def test_get_default_uom_choices(self):
enum = self.app.enum
view = self.make_view()
uoms = view.get_default_uom_choices()
self.assertEqual(uoms, [{'key': key, 'value': val}
for key, val in enum.ORDER_UOM.items()])
def test_normalize_batch(self):
model = self.app.model
enum = self.app.enum
@ -1078,7 +1207,11 @@ class TestOrderView(WebTestCase):
form = view.make_form(model_instance=order)
# nb. this is to avoid include/exclude ambiguity
form.remove('items')
# nb. store_id gets hidden by default
form.append('store_id')
self.assertIn('store_id', form)
view.configure_form(form)
self.assertNotIn('store_id', form)
schema = form.get_schema()
self.assertIn('pending_customer', form)
self.assertIsInstance(schema['total_price'].typ, WuttaMoney)
@ -1089,13 +1222,20 @@ class TestOrderView(WebTestCase):
self.session.add(local)
self.session.flush()
# nb. from now on we include store_id
self.config.setdefault('sideshow.orders.expose_store_id', 'true')
# viewing (local customer)
with patch.object(view, 'viewing', new=True):
with patch.object(order, 'local_customer', new=local):
form = view.make_form(model_instance=order)
# nb. this is to avoid include/exclude ambiguity
form.remove('items')
# nb. store_id will now remain
form.append('store_id')
self.assertIn('store_id', form)
view.configure_form(form)
self.assertIn('store_id', form)
self.assertNotIn('pending_customer', form)
schema = form.get_schema()
self.assertIsInstance(schema['total_price'].typ, WuttaMoney)
@ -1236,6 +1376,12 @@ class TestOrderView(WebTestCase):
model = self.app.model
view = self.make_view()
self.app.save_setting(self.session, 'sideshow.orders.departments.5.name', 'Bulk')
self.app.save_setting(self.session, 'sideshow.orders.departments.5.default_item_discount', '15')
self.app.save_setting(self.session, 'sideshow.orders.departments.6.name', 'Produce')
self.app.save_setting(self.session, 'sideshow.orders.departments.6.default_item_discount', '5')
self.session.commit()
with patch.object(view, 'Session', return_value=self.session):
with patch.multiple(self.config, usedb=True, preferdb=True):
@ -1243,7 +1389,19 @@ class TestOrderView(WebTestCase):
allowed = self.config.get_bool('sideshow.orders.allow_unknown_products',
session=self.session)
self.assertIsNone(allowed)
self.assertEqual(self.session.query(model.Setting).count(), 0)
self.assertEqual(self.session.query(model.Setting).count(), 4)
discounts = view.get_dept_item_discounts()
self.assertEqual(len(discounts), 2)
self.assertEqual(discounts[0], {
'department_id': '5',
'department_name': 'Bulk',
'default_item_discount': '15',
})
self.assertEqual(discounts[1], {
'department_id': '6',
'department_name': 'Produce',
'default_item_discount': '5',
})
# fetch initial page
response = view.configure()
@ -1253,13 +1411,18 @@ class TestOrderView(WebTestCase):
allowed = self.config.get_bool('sideshow.orders.allow_unknown_products',
session=self.session)
self.assertIsNone(allowed)
self.assertEqual(self.session.query(model.Setting).count(), 0)
self.assertEqual(self.session.query(model.Setting).count(), 4)
# post new settings
with patch.multiple(self.request, create=True,
method='POST',
POST={
'sideshow.orders.allow_unknown_products': 'true',
'dept_item_discounts': json.dumps([{
'department_id': '5',
'department_name': 'Grocery',
'default_item_discount': 10,
}])
}):
response = view.configure()
self.assertIsInstance(response, HTTPFound)
@ -1268,17 +1431,17 @@ class TestOrderView(WebTestCase):
session=self.session)
self.assertTrue(allowed)
self.assertTrue(self.session.query(model.Setting).count() > 1)
discounts = view.get_dept_item_discounts()
self.assertEqual(len(discounts), 1)
self.assertEqual(discounts[0], {
'department_id': '5',
'department_name': 'Grocery',
'default_item_discount': '10',
})
class OrderItemViewTestMixin:
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_common_get_fallback_templates(self):
view = self.make_view()
@ -1294,18 +1457,29 @@ class OrderItemViewTestMixin:
def test_common_configure_grid(self):
model = self.app.model
view = self.make_view()
grid = view.make_grid(model_class=model.OrderItem)
self.assertNotIn('order_id', grid.linked_columns)
view.configure_grid(grid)
self.assertIn('order_id', grid.linked_columns)
def test_common_render_order_id(self):
# store_id is removed by default
grid = view.make_grid(model_class=model.OrderItem)
grid.append('store_id')
self.assertIn('store_id', grid.columns)
view.configure_grid(grid)
self.assertNotIn('store_id', grid.columns)
# store_id is shown if configured
self.config.setdefault('sideshow.orders.expose_store_id', 'true')
grid = view.make_grid(model_class=model.OrderItem)
grid.append('store_id')
self.assertIn('store_id', grid.columns)
view.configure_grid(grid)
self.assertIn('store_id', grid.columns)
def test_common_render_order_attr(self):
model = self.app.model
view = self.make_view()
order = model.Order(order_id=42)
item = model.OrderItem()
order.items.append(item)
self.assertEqual(view.render_order_id(item, None, None), 42)
self.assertEqual(view.render_order_attr(item, 'order_id', None), 42)
def test_common_render_status_code(self):
enum = self.app.enum

View file

@ -0,0 +1,94 @@
# -*- coding: utf-8; -*-
from unittest.mock import patch
import colander
from sideshow.testing import WebTestCase
from sideshow.web.views import stores as mod
class TestIncludeme(WebTestCase):
def test_coverage(self):
mod.includeme(self.pyramid_config)
class TestStoreView(WebTestCase):
def make_view(self):
return mod.StoreView(self.request)
def test_configure_grid(self):
model = self.app.model
view = self.make_view()
grid = view.make_grid(model_class=model.Store)
self.assertNotIn('store_id', grid.linked_columns)
self.assertNotIn('name', grid.linked_columns)
view.configure_grid(grid)
self.assertIn('store_id', grid.linked_columns)
self.assertIn('name', grid.linked_columns)
def test_grid_row_class(self):
model = self.app.model
view = self.make_view()
store = model.Store()
self.assertFalse(store.archived)
self.assertIsNone(view.grid_row_class(store, {}, 0))
store = model.Store(archived=True)
self.assertTrue(store.archived)
self.assertEqual(view.grid_row_class(store, {}, 0), 'has-background-warning')
def test_configure_form(self):
model = self.app.model
view = self.make_view()
# unique validators are set
form = view.make_form(model_class=model.Store)
self.assertNotIn('store_id', form.validators)
self.assertNotIn('name', form.validators)
view.configure_form(form)
self.assertIn('store_id', form.validators)
self.assertIn('name', form.validators)
def test_unique_store_id(self):
model = self.app.model
view = self.make_view()
store = model.Store(store_id='001', name='whatever')
self.session.add(store)
self.session.commit()
with patch.object(view, 'Session', return_value=self.session):
# invalid if same store_id in data
node = colander.SchemaNode(colander.String(), name='store_id')
self.assertRaises(colander.Invalid, view.unique_store_id, node, '001')
# but not if store_id belongs to current store
with patch.object(self.request, 'matchdict', new={'uuid': store.uuid}):
with patch.object(view, 'editing', new=True):
node = colander.SchemaNode(colander.String(), name='store_id')
self.assertIsNone(view.unique_store_id(node, '001'))
def test_unique_name(self):
model = self.app.model
view = self.make_view()
store = model.Store(store_id='001', name='Acme Goods')
self.session.add(store)
self.session.commit()
with patch.object(view, 'Session', return_value=self.session):
# invalid if same name in data
node = colander.SchemaNode(colander.String(), name='name')
self.assertRaises(colander.Invalid, view.unique_name, node, 'Acme Goods')
# but not if name belongs to current store
with patch.object(self.request, 'matchdict', new={'uuid': store.uuid}):
with patch.object(view, 'editing', new=True):
node = colander.SchemaNode(colander.String(), name='name')
self.assertIsNone(view.unique_name(node, 'Acme Goods'))