Compare commits
5 commits
76075f146c
...
3ca89a8479
Author | SHA1 | Date | |
---|---|---|---|
|
3ca89a8479 | ||
|
aa31d23fc8 | ||
|
7e1d68e2cf | ||
|
89e3445ace | ||
|
3ef84ff706 |
6
docs/api/sideshow.app.rst
Normal file
6
docs/api/sideshow.app.rst
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
|
||||||
|
``sideshow.app``
|
||||||
|
================
|
||||||
|
|
||||||
|
.. automodule:: sideshow.app
|
||||||
|
:members:
|
6
docs/api/sideshow.db.model.stores.rst
Normal file
6
docs/api/sideshow.db.model.stores.rst
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
|
||||||
|
``sideshow.db.model.stores``
|
||||||
|
============================
|
||||||
|
|
||||||
|
.. automodule:: sideshow.db.model.stores
|
||||||
|
:members:
|
6
docs/api/sideshow.web.views.stores.rst
Normal file
6
docs/api/sideshow.web.views.stores.rst
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
|
||||||
|
``sideshow.web.views.stores``
|
||||||
|
=============================
|
||||||
|
|
||||||
|
.. automodule:: sideshow.web.views.stores
|
||||||
|
:members:
|
|
@ -30,6 +30,7 @@ For an online demo see https://demo.wuttaproject.org/
|
||||||
:caption: Package API:
|
:caption: Package API:
|
||||||
|
|
||||||
api/sideshow
|
api/sideshow
|
||||||
|
api/sideshow.app
|
||||||
api/sideshow.batch
|
api/sideshow.batch
|
||||||
api/sideshow.batch.neworder
|
api/sideshow.batch.neworder
|
||||||
api/sideshow.cli
|
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.customers
|
||||||
api/sideshow.db.model.orders
|
api/sideshow.db.model.orders
|
||||||
api/sideshow.db.model.products
|
api/sideshow.db.model.products
|
||||||
|
api/sideshow.db.model.stores
|
||||||
api/sideshow.enum
|
api/sideshow.enum
|
||||||
api/sideshow.orders
|
api/sideshow.orders
|
||||||
api/sideshow.web
|
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.customers
|
||||||
api/sideshow.web.views.orders
|
api/sideshow.web.views.orders
|
||||||
api/sideshow.web.views.products
|
api/sideshow.web.views.products
|
||||||
|
api/sideshow.web.views.stores
|
||||||
|
|
|
@ -51,6 +51,9 @@ sideshow_libcache = "sideshow.web.static:libcache"
|
||||||
[project.entry-points."paste.app_factory"]
|
[project.entry-points."paste.app_factory"]
|
||||||
"main" = "sideshow.web.app:main"
|
"main" = "sideshow.web.app:main"
|
||||||
|
|
||||||
|
[project.entry-points."wutta.app.providers"]
|
||||||
|
sideshow = "sideshow.app:SideshowAppProvider"
|
||||||
|
|
||||||
[project.entry-points."wutta.batch.neworder"]
|
[project.entry-points."wutta.batch.neworder"]
|
||||||
"sideshow" = "sideshow.batch.neworder:NewOrderBatchHandler"
|
"sideshow" = "sideshow.batch.neworder:NewOrderBatchHandler"
|
||||||
|
|
||||||
|
|
56
src/sideshow/app.py
Normal file
56
src/sideshow/app.py
Normal 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
|
|
@ -26,6 +26,7 @@ New Order Batch Handler
|
||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
import decimal
|
import decimal
|
||||||
|
from collections import OrderedDict
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
@ -50,6 +51,14 @@ class NewOrderBatchHandler(BatchHandler):
|
||||||
"""
|
"""
|
||||||
model_class = NewOrderBatch
|
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):
|
def use_local_customers(self):
|
||||||
"""
|
"""
|
||||||
Returns boolean indicating whether :term:`local customer`
|
Returns boolean indicating whether :term:`local customer`
|
||||||
|
@ -165,6 +174,18 @@ class NewOrderBatchHandler(BatchHandler):
|
||||||
'label': customer.full_name}
|
'label': customer.full_name}
|
||||||
return [result(c) for c in customers]
|
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):
|
def set_customer(self, batch, customer_info, user=None):
|
||||||
"""
|
"""
|
||||||
Set/update customer info for the batch.
|
Set/update customer info for the batch.
|
||||||
|
@ -359,6 +380,21 @@ class NewOrderBatchHandler(BatchHandler):
|
||||||
'label': product.full_description}
|
'label': product.full_description}
|
||||||
return [result(c) for c in products]
|
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):
|
def get_product_info_external(self, session, product_id, user=None):
|
||||||
"""
|
"""
|
||||||
Returns basic info for an :term:`external product` as pertains
|
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
|
choose order quantity and UOM based on case size, pricing
|
||||||
etc., this method is called to retrieve the product info.
|
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`.
|
:param session: Current app :term:`db session`.
|
||||||
|
|
||||||
|
@ -424,21 +461,58 @@ class NewOrderBatchHandler(BatchHandler):
|
||||||
|
|
||||||
def get_product_info_local(self, session, uuid, user=None):
|
def get_product_info_local(self, session, uuid, user=None):
|
||||||
"""
|
"""
|
||||||
Returns basic info for a
|
Returns basic info for a :term:`local product` as pertains to
|
||||||
:class:`~sideshow.db.model.products.LocalProduct` as pertains
|
ordering.
|
||||||
to ordering.
|
|
||||||
|
|
||||||
When user has located a product via search, and must then
|
When user has located a product via search, and must then
|
||||||
choose order quantity and UOM based on case size, pricing
|
choose order quantity and UOM based on case size, pricing
|
||||||
etc., this method is called to retrieve the product info.
|
etc., this method is called to retrieve the product info.
|
||||||
|
|
||||||
See :meth:`get_product_info_external()` for more explanation.
|
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
|
model = self.app.model
|
||||||
product = session.get(model.LocalProduct, uuid)
|
product = session.get(model.LocalProduct, uuid)
|
||||||
if not product:
|
if not product:
|
||||||
raise ValueError(f"Local Product not found: {uuid}")
|
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 {
|
return {
|
||||||
'product_id': product.uuid.hex,
|
'product_id': product.uuid.hex,
|
||||||
'scancode': product.scancode,
|
'scancode': product.scancode,
|
||||||
|
@ -456,6 +530,109 @@ class NewOrderBatchHandler(BatchHandler):
|
||||||
'vendor_item_code': product.vendor_item_code,
|
'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,
|
def add_item(self, batch, product_info, order_qty, order_uom,
|
||||||
discount_percent=None, user=None):
|
discount_percent=None, user=None):
|
||||||
"""
|
"""
|
||||||
|
|
39
src/sideshow/db/alembic/versions/a4273360d379_add_stores.py
Normal file
39
src/sideshow/db/alembic/versions/a4273360d379_add_stores.py
Normal 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')
|
|
@ -30,6 +30,7 @@ This namespace exposes everything from
|
||||||
|
|
||||||
Primary :term:`data models <data model>`:
|
Primary :term:`data models <data model>`:
|
||||||
|
|
||||||
|
* :class:`~sideshow.db.model.stores.Store`
|
||||||
* :class:`~sideshow.db.model.orders.Order`
|
* :class:`~sideshow.db.model.orders.Order`
|
||||||
* :class:`~sideshow.db.model.orders.OrderItem`
|
* :class:`~sideshow.db.model.orders.OrderItem`
|
||||||
* :class:`~sideshow.db.model.orders.OrderItemEvent`
|
* :class:`~sideshow.db.model.orders.OrderItemEvent`
|
||||||
|
@ -48,6 +49,7 @@ And the :term:`batch` models:
|
||||||
from wuttjamaican.db.model import *
|
from wuttjamaican.db.model import *
|
||||||
|
|
||||||
# sideshow models
|
# sideshow models
|
||||||
|
from .stores import Store
|
||||||
from .customers import LocalCustomer, PendingCustomer
|
from .customers import LocalCustomer, PendingCustomer
|
||||||
from .products import LocalProduct, PendingProduct
|
from .products import LocalProduct, PendingProduct
|
||||||
from .orders import Order, OrderItem, OrderItemEvent
|
from .orders import Order, OrderItem, OrderItemEvent
|
||||||
|
|
|
@ -62,6 +62,15 @@ class Order(model.Base):
|
||||||
ID of the store to which the order pertains, if applicable.
|
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="""
|
customer_id = sa.Column(sa.String(length=20), nullable=True, doc="""
|
||||||
Proper account ID for the :term:`external customer` to which the
|
Proper account ID for the :term:`external customer` to which the
|
||||||
order pertains, if applicable.
|
order pertains, if applicable.
|
||||||
|
|
62
src/sideshow/db/model/stores.py
Normal file
62
src/sideshow/db/model/stores.py
Normal 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()
|
|
@ -37,6 +37,14 @@ class OrderHandler(GenericHandler):
|
||||||
handler is responsible for creation logic.)
|
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):
|
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.
|
Return the display text for a given order quantity.
|
||||||
|
|
|
@ -162,4 +162,12 @@ class SideshowMenuHandler(base.MenuHandler):
|
||||||
def make_admin_menu(self, request, **kwargs):
|
def make_admin_menu(self, request, **kwargs):
|
||||||
""" """
|
""" """
|
||||||
kwargs['include_people'] = True
|
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
|
||||||
|
|
|
@ -29,6 +29,17 @@
|
||||||
<b-field horizontal label="ID">
|
<b-field horizontal label="ID">
|
||||||
<span>${h.link_to(f"Order ID {order.order_id}", url('orders.view', uuid=order.uuid))} — Item #${item.sequence}</span>
|
<span>${h.link_to(f"Order ID {order.order_id}", url('orders.view', uuid=order.uuid))} — Item #${item.sequence}</span>
|
||||||
</b-field>
|
</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">
|
<b-field horizontal label="Order Qty">
|
||||||
<span>${order_qty_uom_text|n}</span>
|
<span>${order_qty_uom_text|n}</span>
|
||||||
</b-field>
|
</b-field>
|
||||||
|
@ -223,7 +234,10 @@
|
||||||
<b-field horizontal label="Sold by Weight">
|
<b-field horizontal label="Sold by Weight">
|
||||||
<span>${app.render_boolean(item.product_weighed)}</span>
|
<span>${app.render_boolean(item.product_weighed)}</span>
|
||||||
</b-field>
|
</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>
|
<span>${item.department_name}</span>
|
||||||
</b-field>
|
</b-field>
|
||||||
<b-field horizontal label="Special Order">
|
<b-field horizontal label="Special Order">
|
||||||
|
|
|
@ -3,6 +3,28 @@
|
||||||
|
|
||||||
<%def name="form_content()">
|
<%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>
|
<h3 class="block is-size-3">Customers</h3>
|
||||||
<div class="block" style="padding-left: 2rem;">
|
<div class="block" style="padding-left: 2rem;">
|
||||||
|
|
||||||
|
@ -14,41 +36,12 @@
|
||||||
<option value="false">External Customers (e.g. in POS)</option>
|
<option value="false">External Customers (e.g. in POS)</option>
|
||||||
</b-select>
|
</b-select>
|
||||||
</b-field>
|
</b-field>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 class="block is-size-3">Products</h3>
|
<h3 class="block is-size-3">Products</h3>
|
||||||
<div class="block" style="padding-left: 2rem;">
|
<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-field label="Product Source">
|
||||||
<b-select name="sideshow.orders.use_local_products"
|
<b-select name="sideshow.orders.use_local_products"
|
||||||
v-model="simpleSettings['sideshow.orders.use_local_products']"
|
v-model="simpleSettings['sideshow.orders.use_local_products']"
|
||||||
|
@ -92,6 +85,136 @@
|
||||||
</div>
|
</div>
|
||||||
</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>
|
<h3 class="block is-size-3">Batches</h3>
|
||||||
<div class="block" style="padding-left: 2rem;">
|
<div class="block" style="padding-left: 2rem;">
|
||||||
|
|
||||||
|
@ -118,5 +241,62 @@
|
||||||
|
|
||||||
ThisPageData.batchHandlers = ${json.dumps(batch_handlers)|n}
|
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>
|
</script>
|
||||||
</%def>
|
</%def>
|
||||||
|
|
|
@ -42,7 +42,25 @@
|
||||||
<script type="text/x-template" id="order-creator-template">
|
<script type="text/x-template" id="order-creator-template">
|
||||||
<div>
|
<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"
|
<${b}-collapse class="panel"
|
||||||
:class="customerPanelType"
|
:class="customerPanelType"
|
||||||
|
@ -337,6 +355,135 @@
|
||||||
@click="showAddItemDialog()">
|
@click="showAddItemDialog()">
|
||||||
Add Item
|
Add Item
|
||||||
</b-button>
|
</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>
|
</div>
|
||||||
|
|
||||||
<${b}-modal
|
<${b}-modal
|
||||||
|
@ -486,7 +633,16 @@
|
||||||
<b-input v-model="pendingProduct.scancode" />
|
<b-input v-model="pendingProduct.scancode" />
|
||||||
</b-field>
|
</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:
|
% if 'department_name' in pending_product_required_fields:
|
||||||
:type="pendingProduct.department_name ? null : 'is-danger'"
|
:type="pendingProduct.department_name ? null : 'is-danger'"
|
||||||
% endif
|
% endif
|
||||||
|
@ -494,8 +650,7 @@
|
||||||
<b-input v-model="pendingProduct.department_name" />
|
<b-input v-model="pendingProduct.department_name" />
|
||||||
</b-field>
|
</b-field>
|
||||||
|
|
||||||
<b-field label="Special Order"
|
<b-field label="Special Order">
|
||||||
style="width: 100%;">
|
|
||||||
<b-checkbox v-model="pendingProduct.special_order" />
|
<b-checkbox v-model="pendingProduct.special_order" />
|
||||||
</b-field>
|
</b-field>
|
||||||
|
|
||||||
|
@ -732,7 +887,7 @@
|
||||||
|
|
||||||
<${b}-table-column label="Department"
|
<${b}-table-column label="Department"
|
||||||
v-slot="props">
|
v-slot="props">
|
||||||
{{ props.row.department_display }}
|
{{ props.row.department_name }}
|
||||||
</${b}-table-column>
|
</${b}-table-column>
|
||||||
|
|
||||||
<${b}-table-column label="Quantity"
|
<${b}-table-column label="Quantity"
|
||||||
|
@ -837,6 +992,12 @@
|
||||||
|
|
||||||
batchTotalPriceDisplay: ${json.dumps(normalized_batch['total_price_display'])|n},
|
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,
|
customerPanelOpen: false,
|
||||||
customerLoading: false,
|
customerLoading: false,
|
||||||
customerIsKnown: ${json.dumps(customer_is_known)|n},
|
customerIsKnown: ${json.dumps(customer_is_known)|n},
|
||||||
|
@ -898,8 +1059,10 @@
|
||||||
productCaseSize: null,
|
productCaseSize: null,
|
||||||
|
|
||||||
% if allow_item_discounts:
|
% 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},
|
allowDiscountsIfOnSale: ${json.dumps(allow_item_discounts_if_on_sale)|n},
|
||||||
|
productDiscountPercent: null,
|
||||||
% endif
|
% endif
|
||||||
|
|
||||||
pendingProduct: {},
|
pendingProduct: {},
|
||||||
|
@ -908,6 +1071,13 @@
|
||||||
## departmentOptions: ${json.dumps(department_options)|n},
|
## departmentOptions: ${json.dumps(department_options)|n},
|
||||||
departmentOptions: [],
|
departmentOptions: [],
|
||||||
|
|
||||||
|
% if allow_past_item_reorder:
|
||||||
|
pastItemsShowDialog: false,
|
||||||
|
pastItemsLoading: false,
|
||||||
|
pastItems: [],
|
||||||
|
pastItemsSelected: null,
|
||||||
|
% endif
|
||||||
|
|
||||||
// nb. hack to force refresh for vue3
|
// nb. hack to force refresh for vue3
|
||||||
refreshProductDescription: 1,
|
refreshProductDescription: 1,
|
||||||
refreshTotalPrice: 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) {
|
customerChanged(customerID, callback) {
|
||||||
this.customerLoading = true
|
this.customerLoading = true
|
||||||
|
this.pastItems = []
|
||||||
|
|
||||||
const params = {}
|
const params = {}
|
||||||
if (customerID) {
|
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() {
|
editNewCustomerSave() {
|
||||||
this.editNewCustomerSaving = true
|
this.editNewCustomerSaving = true
|
||||||
|
|
||||||
|
@ -1351,7 +1561,7 @@
|
||||||
this.productUnitChoices = this.defaultUnitChoices
|
this.productUnitChoices = this.defaultUnitChoices
|
||||||
|
|
||||||
% if allow_item_discounts:
|
% if allow_item_discounts:
|
||||||
this.productDiscountPercent = ${json.dumps(default_item_discount)|n}
|
this.productDiscountPercent = this.defaultItemDiscount
|
||||||
% endif
|
% endif
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -1390,7 +1600,15 @@
|
||||||
this.productSaleEndsDisplay = data.sale_ends_display
|
this.productSaleEndsDisplay = data.sale_ends_display
|
||||||
|
|
||||||
% if allow_item_discounts:
|
% 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
|
% endif
|
||||||
|
|
||||||
// this.setProductUnitChoices(data.uom_choices)
|
// this.setProductUnitChoices(data.uom_choices)
|
||||||
|
@ -1468,7 +1686,7 @@
|
||||||
this.productUOM = this.defaultUOM
|
this.productUOM = this.defaultUOM
|
||||||
|
|
||||||
% if allow_item_discounts:
|
% if allow_item_discounts:
|
||||||
this.productDiscountPercent = ${json.dumps(default_item_discount)|n}
|
this.productDiscountPercent = this.defaultItemDiscount
|
||||||
% endif
|
% endif
|
||||||
|
|
||||||
% if request.use_oruga:
|
% if request.use_oruga:
|
||||||
|
@ -1522,7 +1740,7 @@
|
||||||
## this.productSpecialOrder = row.special_order
|
## this.productSpecialOrder = row.special_order
|
||||||
|
|
||||||
this.productQuantity = row.order_qty
|
this.productQuantity = row.order_qty
|
||||||
this.productUnitChoices = row.order_uom_choices
|
this.productUnitChoices = row?.order_uom_choices || this.defaultUnitChoices
|
||||||
this.productUOM = row.order_uom
|
this.productUOM = row.order_uom
|
||||||
|
|
||||||
% if allow_item_discounts:
|
% 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() {
|
itemDialogAttemptSave() {
|
||||||
this.itemDialogSaving = true
|
this.itemDialogSaving = true
|
||||||
this.editItemLoading = true
|
this.editItemLoading = true
|
||||||
|
|
||||||
const params = {
|
const params = {
|
||||||
order_qty: this.productQuantity,
|
order_qty: parseFloat(this.productQuantity),
|
||||||
order_uom: this.productUOM,
|
order_uom: this.productUOM,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1580,7 +1863,9 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
% if allow_item_discounts:
|
% if allow_item_discounts:
|
||||||
params.discount_percent = this.productDiscountPercent
|
if (this.productDiscountPercent) {
|
||||||
|
params.discount_percent = parseFloat(this.productDiscountPercent)
|
||||||
|
}
|
||||||
% endif
|
% endif
|
||||||
|
|
||||||
if (this.editItemRow) {
|
if (this.editItemRow) {
|
||||||
|
|
|
@ -35,6 +35,7 @@ def includeme(config):
|
||||||
})
|
})
|
||||||
|
|
||||||
# sideshow views
|
# sideshow views
|
||||||
|
config.include('sideshow.web.views.stores')
|
||||||
config.include('sideshow.web.views.customers')
|
config.include('sideshow.web.views.customers')
|
||||||
config.include('sideshow.web.views.products')
|
config.include('sideshow.web.views.products')
|
||||||
config.include('sideshow.web.views.orders')
|
config.include('sideshow.web.views.orders')
|
||||||
|
|
|
@ -126,6 +126,10 @@ class NewOrderBatchView(BatchMasterView):
|
||||||
'status_code',
|
'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):
|
def get_batch_handler(self):
|
||||||
""" """
|
""" """
|
||||||
# TODO: call self.app.get_batch_handler()
|
# TODO: call self.app.get_batch_handler()
|
||||||
|
@ -135,6 +139,10 @@ class NewOrderBatchView(BatchMasterView):
|
||||||
""" """
|
""" """
|
||||||
super().configure_grid(g)
|
super().configure_grid(g)
|
||||||
|
|
||||||
|
# store_id
|
||||||
|
if not self.order_handler.expose_store_id():
|
||||||
|
g.remove('store_id')
|
||||||
|
|
||||||
# total_price
|
# total_price
|
||||||
g.set_renderer('total_price', 'currency')
|
g.set_renderer('total_price', 'currency')
|
||||||
|
|
||||||
|
@ -142,6 +150,10 @@ class NewOrderBatchView(BatchMasterView):
|
||||||
""" """
|
""" """
|
||||||
super().configure_form(f)
|
super().configure_form(f)
|
||||||
|
|
||||||
|
# store_id
|
||||||
|
if not self.order_handler.expose_store_id():
|
||||||
|
f.remove('store_id')
|
||||||
|
|
||||||
# local_customer
|
# local_customer
|
||||||
f.set_node('local_customer', LocalCustomerRef(self.request))
|
f.set_node('local_customer', LocalCustomerRef(self.request))
|
||||||
|
|
||||||
|
|
|
@ -25,7 +25,9 @@ Views for Orders
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import decimal
|
import decimal
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
|
|
||||||
import colander
|
import colander
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
@ -35,9 +37,9 @@ from webhelpers2.html import tags, HTML
|
||||||
|
|
||||||
from wuttaweb.views import MasterView
|
from wuttaweb.views import MasterView
|
||||||
from wuttaweb.forms.schema import UserRef, WuttaMoney, WuttaQuantity, WuttaEnum, WuttaDictEnum
|
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.db.model import Order, OrderItem
|
||||||
from sideshow.orders import OrderHandler
|
|
||||||
from sideshow.batch.neworder import NewOrderBatchHandler
|
from sideshow.batch.neworder import NewOrderBatchHandler
|
||||||
from sideshow.web.forms.schema import (OrderRef,
|
from sideshow.web.forms.schema import (OrderRef,
|
||||||
LocalCustomerRef, LocalProductRef,
|
LocalCustomerRef, LocalProductRef,
|
||||||
|
@ -65,13 +67,13 @@ class OrderView(MasterView):
|
||||||
.. attribute:: order_handler
|
.. attribute:: order_handler
|
||||||
|
|
||||||
Reference to the :term:`order handler` as returned by
|
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
|
.. attribute:: batch_handler
|
||||||
|
|
||||||
Reference to the :term:`new order batch` handler, as returned
|
Reference to the :term:`new order batch` handler. This gets
|
||||||
by :meth:`get_batch_handler()`. This gets set in the
|
set in the constructor.
|
||||||
constructor.
|
|
||||||
"""
|
"""
|
||||||
model_class = Order
|
model_class = Order
|
||||||
editable = False
|
editable = False
|
||||||
|
@ -145,6 +147,7 @@ class OrderView(MasterView):
|
||||||
'brand_name',
|
'brand_name',
|
||||||
'description',
|
'description',
|
||||||
'size',
|
'size',
|
||||||
|
'department_id',
|
||||||
'department_name',
|
'department_name',
|
||||||
'vendor_name',
|
'vendor_name',
|
||||||
'vendor_item_code',
|
'vendor_item_code',
|
||||||
|
@ -155,41 +158,17 @@ class OrderView(MasterView):
|
||||||
|
|
||||||
def __init__(self, request, context=None):
|
def __init__(self, request, context=None):
|
||||||
super().__init__(request, context=context)
|
super().__init__(request, context=context)
|
||||||
self.order_handler = self.get_order_handler()
|
self.order_handler = self.app.get_order_handler()
|
||||||
|
self.batch_handler = self.app.get_batch_handler('neworder')
|
||||||
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')
|
|
||||||
|
|
||||||
def configure_grid(self, g):
|
def configure_grid(self, g):
|
||||||
""" """
|
""" """
|
||||||
super().configure_grid(g)
|
super().configure_grid(g)
|
||||||
|
|
||||||
|
# store_id
|
||||||
|
if not self.order_handler.expose_store_id():
|
||||||
|
g.remove('store_id')
|
||||||
|
|
||||||
# order_id
|
# order_id
|
||||||
g.set_link('order_id')
|
g.set_link('order_id')
|
||||||
|
|
||||||
|
@ -223,6 +202,7 @@ class OrderView(MasterView):
|
||||||
|
|
||||||
* :meth:`start_over()`
|
* :meth:`start_over()`
|
||||||
* :meth:`cancel_order()`
|
* :meth:`cancel_order()`
|
||||||
|
* :meth:`set_store()`
|
||||||
* :meth:`assign_customer()`
|
* :meth:`assign_customer()`
|
||||||
* :meth:`unassign_customer()`
|
* :meth:`unassign_customer()`
|
||||||
* :meth:`set_pending_customer()`
|
* :meth:`set_pending_customer()`
|
||||||
|
@ -232,10 +212,11 @@ class OrderView(MasterView):
|
||||||
* :meth:`delete_item()`
|
* :meth:`delete_item()`
|
||||||
* :meth:`submit_order()`
|
* :meth:`submit_order()`
|
||||||
"""
|
"""
|
||||||
|
model = self.app.model
|
||||||
enum = self.app.enum
|
enum = self.app.enum
|
||||||
self.creating = True
|
session = self.Session()
|
||||||
self.batch_handler = self.get_batch_handler()
|
|
||||||
batch = self.get_current_batch()
|
batch = self.get_current_batch()
|
||||||
|
self.creating = True
|
||||||
|
|
||||||
context = self.get_context_customer(batch)
|
context = self.get_context_customer(batch)
|
||||||
|
|
||||||
|
@ -254,6 +235,7 @@ class OrderView(MasterView):
|
||||||
data = dict(self.request.json_body)
|
data = dict(self.request.json_body)
|
||||||
action = data.pop('action')
|
action = data.pop('action')
|
||||||
json_actions = [
|
json_actions = [
|
||||||
|
'set_store',
|
||||||
'assign_customer',
|
'assign_customer',
|
||||||
'unassign_customer',
|
'unassign_customer',
|
||||||
# 'update_phone_number',
|
# 'update_phone_number',
|
||||||
|
@ -262,7 +244,7 @@ class OrderView(MasterView):
|
||||||
# 'get_customer_info',
|
# 'get_customer_info',
|
||||||
# # 'set_customer_data',
|
# # 'set_customer_data',
|
||||||
'get_product_info',
|
'get_product_info',
|
||||||
# 'get_past_items',
|
'get_past_products',
|
||||||
'add_item',
|
'add_item',
|
||||||
'update_item',
|
'update_item',
|
||||||
'delete_item',
|
'delete_item',
|
||||||
|
@ -283,20 +265,36 @@ class OrderView(MasterView):
|
||||||
'normalized_batch': self.normalize_batch(batch),
|
'normalized_batch': self.normalize_batch(batch),
|
||||||
'order_items': [self.normalize_row(row)
|
'order_items': [self.normalize_row(row)
|
||||||
for row in batch.rows],
|
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?
|
'default_uom': None, # TODO?
|
||||||
|
'expose_store_id': self.order_handler.expose_store_id(),
|
||||||
'allow_item_discounts': self.batch_handler.allow_item_discounts(),
|
'allow_item_discounts': self.batch_handler.allow_item_discounts(),
|
||||||
'allow_unknown_products': (self.batch_handler.allow_unknown_products()
|
'allow_unknown_products': (self.batch_handler.allow_unknown_products()
|
||||||
and self.has_perm('create_unknown_product')),
|
and self.has_perm('create_unknown_product')),
|
||||||
'pending_product_required_fields': self.get_pending_product_required_fields(),
|
'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']:
|
if context['allow_item_discounts']:
|
||||||
context['allow_item_discounts_if_on_sale'] = self.batch_handler\
|
context['allow_item_discounts_if_on_sale'] = self.batch_handler\
|
||||||
.allow_item_discounts_if_on_sale()
|
.allow_item_discounts_if_on_sale()
|
||||||
# nb. render quantity so that '10.0' => '10'
|
# nb. render quantity so that '10.0' => '10'
|
||||||
context['default_item_discount'] = self.app.render_quantity(
|
context['default_item_discount'] = self.app.render_quantity(
|
||||||
self.batch_handler.get_default_item_discount())
|
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)
|
return self.render_to_response('create', context)
|
||||||
|
|
||||||
|
@ -352,7 +350,7 @@ class OrderView(MasterView):
|
||||||
if not term:
|
if not term:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
handler = self.get_batch_handler()
|
handler = self.batch_handler
|
||||||
if handler.use_local_customers():
|
if handler.use_local_customers():
|
||||||
return handler.autocomplete_customers_local(session, term, user=self.request.user)
|
return handler.autocomplete_customers_local(session, term, user=self.request.user)
|
||||||
else:
|
else:
|
||||||
|
@ -376,7 +374,7 @@ class OrderView(MasterView):
|
||||||
if not term:
|
if not term:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
handler = self.get_batch_handler()
|
handler = self.batch_handler
|
||||||
if handler.use_local_products():
|
if handler.use_local_products():
|
||||||
return handler.autocomplete_products_local(session, term, user=self.request.user)
|
return handler.autocomplete_products_local(session, term, user=self.request.user)
|
||||||
else:
|
else:
|
||||||
|
@ -394,6 +392,43 @@ class OrderView(MasterView):
|
||||||
required.append(field)
|
required.append(field)
|
||||||
return required
|
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):
|
def start_over(self, batch):
|
||||||
"""
|
"""
|
||||||
This will delete the user's current batch, then redirect user
|
This will delete the user's current batch, then redirect user
|
||||||
|
@ -436,9 +471,26 @@ class OrderView(MasterView):
|
||||||
url = self.get_index_url()
|
url = self.get_index_url()
|
||||||
return self.redirect(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):
|
def get_context_customer(self, batch):
|
||||||
""" """
|
""" """
|
||||||
context = {
|
context = {
|
||||||
|
'store_id': batch.store_id,
|
||||||
'customer_is_known': True,
|
'customer_is_known': True,
|
||||||
'customer_id': None,
|
'customer_id': None,
|
||||||
'customer_name': batch.customer_name,
|
'customer_name': batch.customer_name,
|
||||||
|
@ -531,18 +583,20 @@ class OrderView(MasterView):
|
||||||
|
|
||||||
def get_product_info(self, batch, data):
|
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`
|
Depending on config, this calls one of the following to get
|
||||||
or :term:`external product` to get the data.
|
its primary data:
|
||||||
|
|
||||||
This should invoke a configured handler for the query
|
* :meth:`~sideshow.batch.neworder.NewOrderBatchHandler.get_product_info_local()`
|
||||||
behavior, but that is not yet implemented. For now it uses
|
* :meth:`~sideshow.batch.neworder.NewOrderBatchHandler.get_product_info_external()`
|
||||||
built-in logic only, which queries the
|
|
||||||
:class:`~sideshow.db.model.products.LocalProduct` table.
|
It then may supplement the data with additional fields.
|
||||||
|
|
||||||
This is a "batch action" method which may be called from
|
This is a "batch action" method which may be called from
|
||||||
:meth:`create()`.
|
:meth:`create()`.
|
||||||
|
|
||||||
|
:returns: Dict of product info.
|
||||||
"""
|
"""
|
||||||
product_id = data.get('product_id')
|
product_id = data.get('product_id')
|
||||||
if not 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:
|
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'])
|
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 = [
|
decimal_fields = [
|
||||||
'case_size',
|
'case_size',
|
||||||
'unit_price_reg',
|
'unit_price_reg',
|
||||||
|
@ -593,6 +644,22 @@ class OrderView(MasterView):
|
||||||
|
|
||||||
return data
|
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):
|
def add_item(self, batch, data):
|
||||||
"""
|
"""
|
||||||
This adds a row to the user's current new order batch.
|
This adds a row to the user's current new order batch.
|
||||||
|
@ -709,12 +776,6 @@ class OrderView(MasterView):
|
||||||
'status_text': batch.status_text,
|
'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):
|
def normalize_row(self, row):
|
||||||
""" """
|
""" """
|
||||||
data = {
|
data = {
|
||||||
|
@ -729,12 +790,12 @@ class OrderView(MasterView):
|
||||||
row.product_description,
|
row.product_description,
|
||||||
row.product_size),
|
row.product_size),
|
||||||
'product_weighed': row.product_weighed,
|
'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,
|
'special_order': row.special_order,
|
||||||
'case_size': float(row.case_size) if row.case_size is not None else None,
|
'case_size': float(row.case_size) if row.case_size is not None else None,
|
||||||
'order_qty': float(row.order_qty),
|
'order_qty': float(row.order_qty),
|
||||||
'order_uom': row.order_uom,
|
'order_uom': row.order_uom,
|
||||||
'order_uom_choices': self.get_default_uom_choices(),
|
|
||||||
'discount_percent': self.app.render_quantity(row.discount_percent),
|
'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': 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),
|
'unit_price_quoted_display': self.app.render_currency(row.unit_price_quoted),
|
||||||
|
@ -810,6 +871,10 @@ class OrderView(MasterView):
|
||||||
super().configure_form(f)
|
super().configure_form(f)
|
||||||
order = f.model_instance
|
order = f.model_instance
|
||||||
|
|
||||||
|
# store_id
|
||||||
|
if not self.order_handler.expose_store_id():
|
||||||
|
f.remove('store_id')
|
||||||
|
|
||||||
# local_customer
|
# local_customer
|
||||||
if order.customer_id and not order.local_customer:
|
if order.customer_id and not order.local_customer:
|
||||||
f.remove('local_customer')
|
f.remove('local_customer')
|
||||||
|
@ -910,8 +975,10 @@ class OrderView(MasterView):
|
||||||
""" """
|
""" """
|
||||||
settings = [
|
settings = [
|
||||||
|
|
||||||
# batches
|
# stores
|
||||||
{'name': 'wutta.batch.neworder.handler.spec'},
|
{'name': 'sideshow.orders.expose_store_id',
|
||||||
|
'type': bool},
|
||||||
|
{'name': 'sideshow.orders.default_store_id'},
|
||||||
|
|
||||||
# customers
|
# customers
|
||||||
{'name': 'sideshow.orders.use_local_customers',
|
{'name': 'sideshow.orders.use_local_customers',
|
||||||
|
@ -920,12 +987,6 @@ class OrderView(MasterView):
|
||||||
'default': 'true'},
|
'default': 'true'},
|
||||||
|
|
||||||
# products
|
# 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',
|
{'name': 'sideshow.orders.use_local_products',
|
||||||
# nb. this is really a bool but we present as string in config UI
|
# nb. this is really a bool but we present as string in config UI
|
||||||
#'type': bool,
|
#'type': bool,
|
||||||
|
@ -933,6 +994,17 @@ class OrderView(MasterView):
|
||||||
{'name': 'sideshow.orders.allow_unknown_products',
|
{'name': 'sideshow.orders.allow_unknown_products',
|
||||||
'type': bool,
|
'type': bool,
|
||||||
'default': True},
|
'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
|
# required fields for new product entry
|
||||||
|
@ -955,8 +1027,39 @@ class OrderView(MasterView):
|
||||||
handlers = [{'spec': spec} for spec in handlers]
|
handlers = [{'spec': spec} for spec in handlers]
|
||||||
context['batch_handlers'] = handlers
|
context['batch_handlers'] = handlers
|
||||||
|
|
||||||
|
context['dept_item_discounts'] = self.get_dept_item_discounts()
|
||||||
|
|
||||||
return context
|
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
|
@classmethod
|
||||||
def defaults(cls, config):
|
def defaults(cls, config):
|
||||||
cls._order_defaults(config)
|
cls._order_defaults(config)
|
||||||
|
@ -1037,6 +1140,7 @@ class OrderItemView(MasterView):
|
||||||
|
|
||||||
labels = {
|
labels = {
|
||||||
'order_id': "Order ID",
|
'order_id': "Order ID",
|
||||||
|
'store_id': "Store ID",
|
||||||
'product_id': "Product ID",
|
'product_id': "Product ID",
|
||||||
'product_scancode': "Scancode",
|
'product_scancode': "Scancode",
|
||||||
'product_brand': "Brand",
|
'product_brand': "Brand",
|
||||||
|
@ -1050,6 +1154,7 @@ class OrderItemView(MasterView):
|
||||||
|
|
||||||
grid_columns = [
|
grid_columns = [
|
||||||
'order_id',
|
'order_id',
|
||||||
|
'store_id',
|
||||||
'customer_name',
|
'customer_name',
|
||||||
# 'sequence',
|
# 'sequence',
|
||||||
'product_scancode',
|
'product_scancode',
|
||||||
|
@ -1099,20 +1204,7 @@ class OrderItemView(MasterView):
|
||||||
|
|
||||||
def __init__(self, request, context=None):
|
def __init__(self, request, context=None):
|
||||||
super().__init__(request, context=context)
|
super().__init__(request, context=context)
|
||||||
self.order_handler = self.get_order_handler()
|
self.order_handler = self.app.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):
|
def get_fallback_templates(self, template):
|
||||||
""" """
|
""" """
|
||||||
|
@ -1132,11 +1224,19 @@ class OrderItemView(MasterView):
|
||||||
model = self.app.model
|
model = self.app.model
|
||||||
# enum = self.app.enum
|
# enum = self.app.enum
|
||||||
|
|
||||||
|
# store_id
|
||||||
|
if not self.order_handler.expose_store_id():
|
||||||
|
g.remove('store_id')
|
||||||
|
|
||||||
# order_id
|
# order_id
|
||||||
g.set_sorter('order_id', model.Order.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')
|
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
|
# customer_name
|
||||||
g.set_label('customer_name', "Customer", column_only=True)
|
g.set_label('customer_name', "Customer", column_only=True)
|
||||||
|
|
||||||
|
@ -1165,9 +1265,10 @@ class OrderItemView(MasterView):
|
||||||
# status_code
|
# status_code
|
||||||
g.set_renderer('status_code', self.render_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):
|
def render_status_code(self, item, key, value):
|
||||||
""" """
|
""" """
|
||||||
|
@ -1237,6 +1338,8 @@ class OrderItemView(MasterView):
|
||||||
item = context['instance']
|
item = context['instance']
|
||||||
form = context['form']
|
form = context['form']
|
||||||
|
|
||||||
|
context['expose_store_id'] = self.order_handler.expose_store_id()
|
||||||
|
|
||||||
context['item'] = item
|
context['item'] = item
|
||||||
context['order'] = item.order
|
context['order'] = item.order
|
||||||
context['order_qty_uom_text'] = self.order_handler.get_order_qty_uom_text(
|
context['order_qty_uom_text'] = self.order_handler.get_order_qty_uom_text(
|
||||||
|
|
|
@ -235,6 +235,7 @@ class PendingProductView(MasterView):
|
||||||
url_prefix = '/pending/products'
|
url_prefix = '/pending/products'
|
||||||
|
|
||||||
labels = {
|
labels = {
|
||||||
|
'department_id': "Department ID",
|
||||||
'product_id': "Product ID",
|
'product_id': "Product ID",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
120
src/sideshow/web/views/stores.py
Normal file
120
src/sideshow/web/views/stores.py
Normal 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)
|
|
@ -4,6 +4,8 @@ import datetime
|
||||||
import decimal
|
import decimal
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
from wuttjamaican.testing import DataTestCase
|
from wuttjamaican.testing import DataTestCase
|
||||||
|
|
||||||
from sideshow.batch import neworder as mod
|
from sideshow.batch import neworder as mod
|
||||||
|
@ -20,6 +22,16 @@ class TestNewOrderBatchHandler(DataTestCase):
|
||||||
def make_handler(self):
|
def make_handler(self):
|
||||||
return mod.NewOrderBatchHandler(self.config)
|
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):
|
def test_use_local_customers(self):
|
||||||
handler = self.make_handler()
|
handler = self.make_handler()
|
||||||
|
|
||||||
|
@ -108,6 +120,23 @@ class TestNewOrderBatchHandler(DataTestCase):
|
||||||
# search for sally finds nothing
|
# search for sally finds nothing
|
||||||
self.assertEqual(handler.autocomplete_customers_local(self.session, 'sally'), [])
|
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):
|
def test_set_customer(self):
|
||||||
model = self.app.model
|
model = self.app.model
|
||||||
handler = self.make_handler()
|
handler = self.make_handler()
|
||||||
|
@ -240,6 +269,14 @@ class TestNewOrderBatchHandler(DataTestCase):
|
||||||
# search for juice finds nothing
|
# search for juice finds nothing
|
||||||
self.assertEqual(handler.autocomplete_products_local(self.session, 'juice'), [])
|
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):
|
def test_get_product_info_external(self):
|
||||||
handler = self.make_handler()
|
handler = self.make_handler()
|
||||||
self.assertRaises(NotImplementedError, handler.get_product_info_external,
|
self.assertRaises(NotImplementedError, handler.get_product_info_external,
|
||||||
|
@ -281,6 +318,174 @@ class TestNewOrderBatchHandler(DataTestCase):
|
||||||
mock_uuid = self.app.make_true_uuid()
|
mock_uuid = self.app.make_true_uuid()
|
||||||
self.assertRaises(ValueError, handler.get_product_info_local, self.session, mock_uuid.hex)
|
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):
|
def test_add_item(self):
|
||||||
model = self.app.model
|
model = self.app.model
|
||||||
enum = self.app.enum
|
enum = self.app.enum
|
||||||
|
|
28
tests/db/model/test_stores.py
Normal file
28
tests/db/model/test_stores.py
Normal 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
17
tests/test_app.py
Normal 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)
|
|
@ -16,6 +16,16 @@ class TestOrderHandler(DataTestCase):
|
||||||
def make_handler(self):
|
def make_handler(self):
|
||||||
return mod.OrderHandler(self.config)
|
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):
|
def test_get_order_qty_uom_text(self):
|
||||||
enum = self.app.enum
|
enum = self.app.enum
|
||||||
handler = self.make_handler()
|
handler = self.make_handler()
|
||||||
|
|
|
@ -30,10 +30,19 @@ class TestNewOrderBatchView(WebTestCase):
|
||||||
def test_configure_grid(self):
|
def test_configure_grid(self):
|
||||||
model = self.app.model
|
model = self.app.model
|
||||||
view = self.make_view()
|
view = self.make_view()
|
||||||
|
|
||||||
|
# store_id not exposed by default
|
||||||
grid = view.make_grid(model_class=model.NewOrderBatch)
|
grid = view.make_grid(model_class=model.NewOrderBatch)
|
||||||
self.assertNotIn('total_price', grid.renderers)
|
self.assertIn('store_id', grid.columns)
|
||||||
view.configure_grid(grid)
|
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):
|
def test_configure_form(self):
|
||||||
model = self.app.model
|
model = self.app.model
|
||||||
|
@ -58,6 +67,19 @@ class TestNewOrderBatchView(WebTestCase):
|
||||||
self.assertIsInstance(schema['pending_customer'].typ, PendingCustomerRef)
|
self.assertIsInstance(schema['pending_customer'].typ, PendingCustomerRef)
|
||||||
self.assertIsInstance(schema['total_price'].typ, WuttaMoney)
|
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):
|
def test_configure_row_grid(self):
|
||||||
model = self.app.model
|
model = self.app.model
|
||||||
view = self.make_view()
|
view = self.make_view()
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
import decimal
|
import decimal
|
||||||
|
import json
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from sqlalchemy import orm
|
from sqlalchemy import orm
|
||||||
|
@ -15,6 +16,7 @@ from sideshow.orders import OrderHandler
|
||||||
from sideshow.testing import WebTestCase
|
from sideshow.testing import WebTestCase
|
||||||
from sideshow.web.views import orders as mod
|
from sideshow.web.views import orders as mod
|
||||||
from sideshow.web.forms.schema import OrderRef, PendingProductRef
|
from sideshow.web.forms.schema import OrderRef, PendingProductRef
|
||||||
|
from sideshow.config import SideshowConfig
|
||||||
|
|
||||||
|
|
||||||
class TestIncludeme(WebTestCase):
|
class TestIncludeme(WebTestCase):
|
||||||
|
@ -25,33 +27,39 @@ class TestIncludeme(WebTestCase):
|
||||||
|
|
||||||
class TestOrderView(WebTestCase):
|
class TestOrderView(WebTestCase):
|
||||||
|
|
||||||
|
def make_config(self, **kw):
|
||||||
|
config = super().make_config(**kw)
|
||||||
|
SideshowConfig().configure(config)
|
||||||
|
return config
|
||||||
|
|
||||||
def make_view(self):
|
def make_view(self):
|
||||||
return mod.OrderView(self.request)
|
return mod.OrderView(self.request)
|
||||||
|
|
||||||
def make_handler(self):
|
def make_handler(self):
|
||||||
return NewOrderBatchHandler(self.config)
|
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):
|
def test_configure_grid(self):
|
||||||
model = self.app.model
|
model = self.app.model
|
||||||
view = self.make_view()
|
view = self.make_view()
|
||||||
grid = view.make_grid(model_class=model.PendingProduct)
|
|
||||||
self.assertNotIn('order_id', grid.linked_columns)
|
# store_id hidden by default
|
||||||
self.assertNotIn('total_price', grid.renderers)
|
grid = view.make_grid(model_class=model.Order, columns=['store_id', 'order_id'])
|
||||||
|
self.assertIn('store_id', grid.columns)
|
||||||
view.configure_grid(grid)
|
view.configure_grid(grid)
|
||||||
self.assertIn('order_id', grid.linked_columns)
|
self.assertNotIn('store_id', grid.columns)
|
||||||
self.assertIn('total_price', grid.renderers)
|
|
||||||
|
# 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):
|
def test_create(self):
|
||||||
self.pyramid_config.include('sideshow.web.views')
|
self.pyramid_config.include('sideshow.web.views')
|
||||||
self.config.setdefault('wutta.batch.neworder.handler.spec',
|
self.config.setdefault('wutta.batch.neworder.handler.spec',
|
||||||
'sideshow.batch.neworder:NewOrderBatchHandler')
|
'sideshow.batch.neworder:NewOrderBatchHandler')
|
||||||
|
self.config.setdefault('sideshow.orders.expose_store_id', 'true')
|
||||||
self.config.setdefault('sideshow.orders.allow_item_discounts', 'true')
|
self.config.setdefault('sideshow.orders.allow_item_discounts', 'true')
|
||||||
model = self.app.model
|
model = self.app.model
|
||||||
enum = self.app.enum
|
enum = self.app.enum
|
||||||
|
@ -59,6 +67,10 @@ class TestOrderView(WebTestCase):
|
||||||
|
|
||||||
user = model.User(username='barney')
|
user = model.User(username='barney')
|
||||||
self.session.add(user)
|
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()
|
self.session.flush()
|
||||||
|
|
||||||
with patch.object(view, 'Session', return_value=self.session):
|
with patch.object(view, 'Session', return_value=self.session):
|
||||||
|
@ -101,6 +113,7 @@ class TestOrderView(WebTestCase):
|
||||||
self.assertIsInstance(response, Response)
|
self.assertIsInstance(response, Response)
|
||||||
self.assertEqual(response.content_type, 'application/json')
|
self.assertEqual(response.content_type, 'application/json')
|
||||||
self.assertEqual(response.json_body, {
|
self.assertEqual(response.json_body, {
|
||||||
|
'store_id': None,
|
||||||
'customer_is_known': False,
|
'customer_is_known': False,
|
||||||
'customer_id': None,
|
'customer_id': None,
|
||||||
'customer_name': 'Fred Flintstone',
|
'customer_name': 'Fred Flintstone',
|
||||||
|
@ -285,6 +298,50 @@ class TestOrderView(WebTestCase):
|
||||||
fields = view.get_pending_product_required_fields()
|
fields = view.get_pending_product_required_fields()
|
||||||
self.assertEqual(fields, ['brand_name', 'size', 'unit_price_reg'])
|
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):
|
def test_get_context_customer(self):
|
||||||
self.pyramid_config.add_route('orders', '/orders/')
|
self.pyramid_config.add_route('orders', '/orders/')
|
||||||
model = self.app.model
|
model = self.app.model
|
||||||
|
@ -304,6 +361,7 @@ class TestOrderView(WebTestCase):
|
||||||
self.session.flush()
|
self.session.flush()
|
||||||
context = view.get_context_customer(batch)
|
context = view.get_context_customer(batch)
|
||||||
self.assertEqual(context, {
|
self.assertEqual(context, {
|
||||||
|
'store_id': None,
|
||||||
'customer_is_known': True,
|
'customer_is_known': True,
|
||||||
'customer_id': 42,
|
'customer_id': 42,
|
||||||
'customer_name': 'Fred Flintstone',
|
'customer_name': 'Fred Flintstone',
|
||||||
|
@ -321,6 +379,7 @@ class TestOrderView(WebTestCase):
|
||||||
self.session.flush()
|
self.session.flush()
|
||||||
context = view.get_context_customer(batch)
|
context = view.get_context_customer(batch)
|
||||||
self.assertEqual(context, {
|
self.assertEqual(context, {
|
||||||
|
'store_id': None,
|
||||||
'customer_is_known': True,
|
'customer_is_known': True,
|
||||||
'customer_id': local.uuid.hex,
|
'customer_id': local.uuid.hex,
|
||||||
'customer_name': 'Betty Boop',
|
'customer_name': 'Betty Boop',
|
||||||
|
@ -339,6 +398,7 @@ class TestOrderView(WebTestCase):
|
||||||
self.session.flush()
|
self.session.flush()
|
||||||
context = view.get_context_customer(batch)
|
context = view.get_context_customer(batch)
|
||||||
self.assertEqual(context, {
|
self.assertEqual(context, {
|
||||||
|
'store_id': None,
|
||||||
'customer_is_known': False,
|
'customer_is_known': False,
|
||||||
'customer_id': None,
|
'customer_id': None,
|
||||||
'customer_name': 'Fred Flintstone',
|
'customer_name': 'Fred Flintstone',
|
||||||
|
@ -357,6 +417,7 @@ class TestOrderView(WebTestCase):
|
||||||
self.session.flush()
|
self.session.flush()
|
||||||
context = view.get_context_customer(batch)
|
context = view.get_context_customer(batch)
|
||||||
self.assertEqual(context, {
|
self.assertEqual(context, {
|
||||||
|
'store_id': None,
|
||||||
'customer_is_known': True, # nb. this is for UI default
|
'customer_is_known': True, # nb. this is for UI default
|
||||||
'customer_id': None,
|
'customer_id': None,
|
||||||
'customer_name': None,
|
'customer_name': None,
|
||||||
|
@ -408,6 +469,34 @@ class TestOrderView(WebTestCase):
|
||||||
self.session.flush()
|
self.session.flush()
|
||||||
self.assertEqual(self.session.query(model.NewOrderBatch).count(), 0)
|
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):
|
def test_assign_customer(self):
|
||||||
self.pyramid_config.add_route('orders.create', '/orders/new')
|
self.pyramid_config.add_route('orders.create', '/orders/new')
|
||||||
model = self.app.model
|
model = self.app.model
|
||||||
|
@ -432,6 +521,7 @@ class TestOrderView(WebTestCase):
|
||||||
self.assertIsNone(batch.pending_customer)
|
self.assertIsNone(batch.pending_customer)
|
||||||
self.assertIs(batch.local_customer, weirdal)
|
self.assertIs(batch.local_customer, weirdal)
|
||||||
self.assertEqual(context, {
|
self.assertEqual(context, {
|
||||||
|
'store_id': None,
|
||||||
'customer_is_known': True,
|
'customer_is_known': True,
|
||||||
'customer_id': weirdal.uuid.hex,
|
'customer_id': weirdal.uuid.hex,
|
||||||
'customer_name': 'Weird Al',
|
'customer_name': 'Weird Al',
|
||||||
|
@ -470,6 +560,7 @@ class TestOrderView(WebTestCase):
|
||||||
self.assertIsNone(batch.customer_name)
|
self.assertIsNone(batch.customer_name)
|
||||||
self.assertIsNone(batch.local_customer)
|
self.assertIsNone(batch.local_customer)
|
||||||
self.assertEqual(context, {
|
self.assertEqual(context, {
|
||||||
|
'store_id': None,
|
||||||
'customer_is_known': True,
|
'customer_is_known': True,
|
||||||
'customer_id': None,
|
'customer_id': None,
|
||||||
'customer_name': None,
|
'customer_name': None,
|
||||||
|
@ -510,6 +601,7 @@ class TestOrderView(WebTestCase):
|
||||||
context = view.set_pending_customer(batch, data)
|
context = view.set_pending_customer(batch, data)
|
||||||
self.assertIsInstance(batch.pending_customer, model.PendingCustomer)
|
self.assertIsInstance(batch.pending_customer, model.PendingCustomer)
|
||||||
self.assertEqual(context, {
|
self.assertEqual(context, {
|
||||||
|
'store_id': None,
|
||||||
'customer_is_known': False,
|
'customer_is_known': False,
|
||||||
'customer_id': None,
|
'customer_id': None,
|
||||||
'customer_name': 'Fred Flintstone',
|
'customer_name': 'Fred Flintstone',
|
||||||
|
@ -575,6 +667,51 @@ class TestOrderView(WebTestCase):
|
||||||
context = view.get_product_info(batch, {'product_id': '42'})
|
context = view.get_product_info(batch, {'product_id': '42'})
|
||||||
self.assertEqual(context, {'error': "something smells fishy"})
|
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):
|
def test_add_item(self):
|
||||||
model = self.app.model
|
model = self.app.model
|
||||||
enum = self.app.enum
|
enum = self.app.enum
|
||||||
|
@ -825,14 +962,6 @@ class TestOrderView(WebTestCase):
|
||||||
'error': f"ValueError: batch has already been executed: {batch}",
|
'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):
|
def test_normalize_batch(self):
|
||||||
model = self.app.model
|
model = self.app.model
|
||||||
enum = self.app.enum
|
enum = self.app.enum
|
||||||
|
@ -1078,7 +1207,11 @@ class TestOrderView(WebTestCase):
|
||||||
form = view.make_form(model_instance=order)
|
form = view.make_form(model_instance=order)
|
||||||
# nb. this is to avoid include/exclude ambiguity
|
# nb. this is to avoid include/exclude ambiguity
|
||||||
form.remove('items')
|
form.remove('items')
|
||||||
|
# nb. store_id gets hidden by default
|
||||||
|
form.append('store_id')
|
||||||
|
self.assertIn('store_id', form)
|
||||||
view.configure_form(form)
|
view.configure_form(form)
|
||||||
|
self.assertNotIn('store_id', form)
|
||||||
schema = form.get_schema()
|
schema = form.get_schema()
|
||||||
self.assertIn('pending_customer', form)
|
self.assertIn('pending_customer', form)
|
||||||
self.assertIsInstance(schema['total_price'].typ, WuttaMoney)
|
self.assertIsInstance(schema['total_price'].typ, WuttaMoney)
|
||||||
|
@ -1089,13 +1222,20 @@ class TestOrderView(WebTestCase):
|
||||||
self.session.add(local)
|
self.session.add(local)
|
||||||
self.session.flush()
|
self.session.flush()
|
||||||
|
|
||||||
|
# nb. from now on we include store_id
|
||||||
|
self.config.setdefault('sideshow.orders.expose_store_id', 'true')
|
||||||
|
|
||||||
# viewing (local customer)
|
# viewing (local customer)
|
||||||
with patch.object(view, 'viewing', new=True):
|
with patch.object(view, 'viewing', new=True):
|
||||||
with patch.object(order, 'local_customer', new=local):
|
with patch.object(order, 'local_customer', new=local):
|
||||||
form = view.make_form(model_instance=order)
|
form = view.make_form(model_instance=order)
|
||||||
# nb. this is to avoid include/exclude ambiguity
|
# nb. this is to avoid include/exclude ambiguity
|
||||||
form.remove('items')
|
form.remove('items')
|
||||||
|
# nb. store_id will now remain
|
||||||
|
form.append('store_id')
|
||||||
|
self.assertIn('store_id', form)
|
||||||
view.configure_form(form)
|
view.configure_form(form)
|
||||||
|
self.assertIn('store_id', form)
|
||||||
self.assertNotIn('pending_customer', form)
|
self.assertNotIn('pending_customer', form)
|
||||||
schema = form.get_schema()
|
schema = form.get_schema()
|
||||||
self.assertIsInstance(schema['total_price'].typ, WuttaMoney)
|
self.assertIsInstance(schema['total_price'].typ, WuttaMoney)
|
||||||
|
@ -1236,6 +1376,12 @@ class TestOrderView(WebTestCase):
|
||||||
model = self.app.model
|
model = self.app.model
|
||||||
view = self.make_view()
|
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.object(view, 'Session', return_value=self.session):
|
||||||
with patch.multiple(self.config, usedb=True, preferdb=True):
|
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',
|
allowed = self.config.get_bool('sideshow.orders.allow_unknown_products',
|
||||||
session=self.session)
|
session=self.session)
|
||||||
self.assertIsNone(allowed)
|
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
|
# fetch initial page
|
||||||
response = view.configure()
|
response = view.configure()
|
||||||
|
@ -1253,13 +1411,18 @@ class TestOrderView(WebTestCase):
|
||||||
allowed = self.config.get_bool('sideshow.orders.allow_unknown_products',
|
allowed = self.config.get_bool('sideshow.orders.allow_unknown_products',
|
||||||
session=self.session)
|
session=self.session)
|
||||||
self.assertIsNone(allowed)
|
self.assertIsNone(allowed)
|
||||||
self.assertEqual(self.session.query(model.Setting).count(), 0)
|
self.assertEqual(self.session.query(model.Setting).count(), 4)
|
||||||
|
|
||||||
# post new settings
|
# post new settings
|
||||||
with patch.multiple(self.request, create=True,
|
with patch.multiple(self.request, create=True,
|
||||||
method='POST',
|
method='POST',
|
||||||
POST={
|
POST={
|
||||||
'sideshow.orders.allow_unknown_products': 'true',
|
'sideshow.orders.allow_unknown_products': 'true',
|
||||||
|
'dept_item_discounts': json.dumps([{
|
||||||
|
'department_id': '5',
|
||||||
|
'department_name': 'Grocery',
|
||||||
|
'default_item_discount': 10,
|
||||||
|
}])
|
||||||
}):
|
}):
|
||||||
response = view.configure()
|
response = view.configure()
|
||||||
self.assertIsInstance(response, HTTPFound)
|
self.assertIsInstance(response, HTTPFound)
|
||||||
|
@ -1268,17 +1431,17 @@ class TestOrderView(WebTestCase):
|
||||||
session=self.session)
|
session=self.session)
|
||||||
self.assertTrue(allowed)
|
self.assertTrue(allowed)
|
||||||
self.assertTrue(self.session.query(model.Setting).count() > 1)
|
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:
|
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):
|
def test_common_get_fallback_templates(self):
|
||||||
view = self.make_view()
|
view = self.make_view()
|
||||||
|
|
||||||
|
@ -1294,18 +1457,29 @@ class OrderItemViewTestMixin:
|
||||||
def test_common_configure_grid(self):
|
def test_common_configure_grid(self):
|
||||||
model = self.app.model
|
model = self.app.model
|
||||||
view = self.make_view()
|
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
|
model = self.app.model
|
||||||
view = self.make_view()
|
view = self.make_view()
|
||||||
order = model.Order(order_id=42)
|
order = model.Order(order_id=42)
|
||||||
item = model.OrderItem()
|
item = model.OrderItem()
|
||||||
order.items.append(item)
|
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):
|
def test_common_render_status_code(self):
|
||||||
enum = self.app.enum
|
enum = self.app.enum
|
||||||
|
|
94
tests/web/views/test_stores.py
Normal file
94
tests/web/views/test_stores.py
Normal 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'))
|
Loading…
Reference in a new issue