diff --git a/docs/api/sideshow.app.rst b/docs/api/sideshow.app.rst new file mode 100644 index 0000000..7c738b1 --- /dev/null +++ b/docs/api/sideshow.app.rst @@ -0,0 +1,6 @@ + +``sideshow.app`` +================ + +.. automodule:: sideshow.app + :members: diff --git a/docs/api/sideshow.db.model.stores.rst b/docs/api/sideshow.db.model.stores.rst new file mode 100644 index 0000000..b114a9b --- /dev/null +++ b/docs/api/sideshow.db.model.stores.rst @@ -0,0 +1,6 @@ + +``sideshow.db.model.stores`` +============================ + +.. automodule:: sideshow.db.model.stores + :members: diff --git a/docs/api/sideshow.web.views.stores.rst b/docs/api/sideshow.web.views.stores.rst new file mode 100644 index 0000000..896a0d7 --- /dev/null +++ b/docs/api/sideshow.web.views.stores.rst @@ -0,0 +1,6 @@ + +``sideshow.web.views.stores`` +============================= + +.. automodule:: sideshow.web.views.stores + :members: diff --git a/docs/index.rst b/docs/index.rst index 29882dd..d91ae5e 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -30,6 +30,7 @@ For an online demo see https://demo.wuttaproject.org/ :caption: Package API: api/sideshow + api/sideshow.app api/sideshow.batch api/sideshow.batch.neworder api/sideshow.cli @@ -43,6 +44,7 @@ For an online demo see https://demo.wuttaproject.org/ api/sideshow.db.model.customers api/sideshow.db.model.orders api/sideshow.db.model.products + api/sideshow.db.model.stores api/sideshow.enum api/sideshow.orders api/sideshow.web @@ -58,3 +60,4 @@ For an online demo see https://demo.wuttaproject.org/ api/sideshow.web.views.customers api/sideshow.web.views.orders api/sideshow.web.views.products + api/sideshow.web.views.stores diff --git a/pyproject.toml b/pyproject.toml index e1d42a8..ff4a4bc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,6 +51,9 @@ sideshow_libcache = "sideshow.web.static:libcache" [project.entry-points."paste.app_factory"] "main" = "sideshow.web.app:main" +[project.entry-points."wutta.app.providers"] +sideshow = "sideshow.app:SideshowAppProvider" + [project.entry-points."wutta.batch.neworder"] "sideshow" = "sideshow.batch.neworder:NewOrderBatchHandler" diff --git a/src/sideshow/app.py b/src/sideshow/app.py new file mode 100644 index 0000000..0fbcf2e --- /dev/null +++ b/src/sideshow/app.py @@ -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 diff --git a/src/sideshow/batch/neworder.py b/src/sideshow/batch/neworder.py index bfa04ea..28e2627 100644 --- a/src/sideshow/batch/neworder.py +++ b/src/sideshow/batch/neworder.py @@ -26,6 +26,7 @@ New Order Batch Handler import datetime import decimal +from collections import OrderedDict import sqlalchemy as sa @@ -50,6 +51,14 @@ class NewOrderBatchHandler(BatchHandler): """ model_class = NewOrderBatch + def get_default_store_id(self): + """ + Returns the configured default value for + :attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.store_id`, + or ``None``. + """ + return self.config.get('sideshow.orders.default_store_id') + def use_local_customers(self): """ Returns boolean indicating whether :term:`local customer` @@ -165,6 +174,18 @@ class NewOrderBatchHandler(BatchHandler): 'label': customer.full_name} return [result(c) for c in customers] + def init_batch(self, batch, session=None, progress=None, **kwargs): + """ + Initialize a new batch. + + This sets the + :attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.store_id`, + if the batch does not yet have one and a default is + configured. + """ + if not batch.store_id: + batch.store_id = self.get_default_store_id() + def set_customer(self, batch, customer_info, user=None): """ Set/update customer info for the batch. @@ -359,6 +380,21 @@ class NewOrderBatchHandler(BatchHandler): 'label': product.full_description} return [result(c) for c in products] + def get_default_uom_choices(self): + """ + Returns a list of ordering UOM choices which should be + presented to the user by default. + + The built-in logic here will return everything from + :data:`~sideshow.enum.ORDER_UOM`. + + :returns: List of dicts, each with ``key`` and ``value`` + corresponding to the UOM code and label, respectively. + """ + enum = self.app.enum + return [{'key': key, 'value': val} + for key, val in enum.ORDER_UOM.items()] + def get_product_info_external(self, session, product_id, user=None): """ Returns basic info for an :term:`external product` as pertains @@ -368,7 +404,8 @@ class NewOrderBatchHandler(BatchHandler): choose order quantity and UOM based on case size, pricing etc., this method is called to retrieve the product info. - There is no default logic here; subclass must implement. + There is no default logic here; subclass must implement. See + also :meth:`get_product_info_local()`. :param session: Current app :term:`db session`. @@ -424,21 +461,58 @@ class NewOrderBatchHandler(BatchHandler): def get_product_info_local(self, session, uuid, user=None): """ - Returns basic info for a - :class:`~sideshow.db.model.products.LocalProduct` as pertains - to ordering. + Returns basic info for a :term:`local product` as pertains to + ordering. When user has located a product via search, and must then choose order quantity and UOM based on case size, pricing etc., this method is called to retrieve the product info. See :meth:`get_product_info_external()` for more explanation. + + This method will locate the + :class:`~sideshow.db.model.products.LocalProduct` record, then + (if found) it calls :meth:`normalize_local_product()` and + returns the result. + + :param session: Current :term:`db session`. + + :param uuid: UUID for the desired + :class:`~sideshow.db.model.products.LocalProduct`. + + :param user: + :class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who + is performing the action, if known. + + :returns: Dict of product info. """ model = self.app.model product = session.get(model.LocalProduct, uuid) if not product: raise ValueError(f"Local Product not found: {uuid}") + return self.normalize_local_product(product) + + def normalize_local_product(self, product): + """ + Returns a normalized dict of info for the given :term:`local + product`. + + This is called by: + + * :meth:`get_product_info_local()` + * :meth:`get_past_products()` + + :param product: + :class:`~sideshow.db.model.products.LocalProduct` instance. + + :returns: Dict of product info. + + The keys for this dict should essentially one-to-one for the + product fields, with one exception: + + * ``product_id`` will be set to the product UUID as string + """ return { 'product_id': product.uuid.hex, 'scancode': product.scancode, @@ -456,6 +530,109 @@ class NewOrderBatchHandler(BatchHandler): 'vendor_item_code': product.vendor_item_code, } + def get_past_orders(self, batch): + """ + Retrieve a (possibly empty) list of past :term:`orders + <order>` for the batch customer. + + This is called by :meth:`get_past_products()`. + + :param batch: + :class:`~sideshow.db.model.batch.neworder.NewOrderBatch` + instance. + + :returns: List of :class:`~sideshow.db.model.orders.Order` + records. + """ + model = self.app.model + session = self.app.get_session(batch) + orders = session.query(model.Order) + + if batch.customer_id: + orders = orders.filter(model.Order.customer_id == batch.customer_id) + elif batch.local_customer: + orders = orders.filter(model.Order.local_customer == batch.local_customer) + else: + raise ValueError(f"batch has no customer: {batch}") + + orders = orders.order_by(model.Order.created.desc()) + return orders.all() + + def get_past_products(self, batch, user=None): + """ + Retrieve a (possibly empty) list of products which have been + previously ordered by the batch customer. + + Note that this does not return :term:`order items <order + item>`, nor does it return true product records, but rather it + returns a list of dicts. Each will have product info but will + *not* have order quantity etc. + + This method calls :meth:`get_past_orders()` and then iterates + through each order item therein. Any duplicated products + encountered will be skipped, so the final list contains unique + products. + + Each dict in the result is obtained by calling one of: + + * :meth:`normalize_local_product()` + * :meth:`get_product_info_external()` + + :param batch: + :class:`~sideshow.db.model.batch.neworder.NewOrderBatch` + instance. + + :param user: + :class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who + is performing the action, if known. + + :returns: List of product info dicts. + """ + model = self.app.model + session = self.app.get_session(batch) + use_local = self.use_local_products() + user = user or batch.created_by + products = OrderedDict() + + # track down all order items for batch contact + for order in self.get_past_orders(batch): + for item in order.items: + + # nb. we only need the first match for each product + if use_local: + product = item.local_product + if product and product.uuid not in products: + products[product.uuid] = self.normalize_local_product(product) + elif item.product_id and item.product_id not in products: + products[item.product_id] = self.get_product_info_external( + session, item.product_id, user=user) + + products = list(products.values()) + for product in products: + + price = product['unit_price_reg'] + + if 'unit_price_reg_display' not in product: + product['unit_price_reg_display'] = self.app.render_currency(price) + + if 'unit_price_quoted' not in product: + product['unit_price_quoted'] = price + + if 'unit_price_quoted_display' not in product: + product['unit_price_quoted_display'] = product['unit_price_reg_display'] + + if ('case_price_quoted' not in product + and product.get('unit_price_quoted') is not None + and product.get('case_size') is not None): + product['case_price_quoted'] = product['unit_price_quoted'] * product['case_size'] + + if ('case_price_quoted_display' not in product + and 'case_price_quoted' in product): + product['case_price_quoted_display'] = self.app.render_currency( + product['case_price_quoted']) + + return products + def add_item(self, batch, product_info, order_qty, order_uom, discount_percent=None, user=None): """ diff --git a/src/sideshow/db/alembic/versions/a4273360d379_add_stores.py b/src/sideshow/db/alembic/versions/a4273360d379_add_stores.py new file mode 100644 index 0000000..79e6242 --- /dev/null +++ b/src/sideshow/db/alembic/versions/a4273360d379_add_stores.py @@ -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') diff --git a/src/sideshow/db/model/__init__.py b/src/sideshow/db/model/__init__.py index f53dd27..056ccfc 100644 --- a/src/sideshow/db/model/__init__.py +++ b/src/sideshow/db/model/__init__.py @@ -30,6 +30,7 @@ This namespace exposes everything from Primary :term:`data models <data model>`: +* :class:`~sideshow.db.model.stores.Store` * :class:`~sideshow.db.model.orders.Order` * :class:`~sideshow.db.model.orders.OrderItem` * :class:`~sideshow.db.model.orders.OrderItemEvent` @@ -48,6 +49,7 @@ And the :term:`batch` models: from wuttjamaican.db.model import * # sideshow models +from .stores import Store from .customers import LocalCustomer, PendingCustomer from .products import LocalProduct, PendingProduct from .orders import Order, OrderItem, OrderItemEvent diff --git a/src/sideshow/db/model/orders.py b/src/sideshow/db/model/orders.py index 2cadeaa..5455d01 100644 --- a/src/sideshow/db/model/orders.py +++ b/src/sideshow/db/model/orders.py @@ -62,6 +62,15 @@ class Order(model.Base): ID of the store to which the order pertains, if applicable. """) + store = orm.relationship( + 'Store', + primaryjoin='Store.store_id == Order.store_id', + foreign_keys='Order.store_id', + doc=""" + Reference to the :class:`~sideshow.db.model.stores.Store` + record, if applicable. + """) + customer_id = sa.Column(sa.String(length=20), nullable=True, doc=""" Proper account ID for the :term:`external customer` to which the order pertains, if applicable. diff --git a/src/sideshow/db/model/stores.py b/src/sideshow/db/model/stores.py new file mode 100644 index 0000000..4b01d02 --- /dev/null +++ b/src/sideshow/db/model/stores.py @@ -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() diff --git a/src/sideshow/orders.py b/src/sideshow/orders.py index 9f99e53..868cada 100644 --- a/src/sideshow/orders.py +++ b/src/sideshow/orders.py @@ -37,6 +37,14 @@ class OrderHandler(GenericHandler): handler is responsible for creation logic.) """ + def expose_store_id(self): + """ + Returns boolean indicating whether the ``store_id`` field + should be exposed at all. This is false by default. + """ + return self.config.get_bool('sideshow.orders.expose_store_id', + default=False) + def get_order_qty_uom_text(self, order_qty, order_uom, case_size=None, html=False): """ Return the display text for a given order quantity. diff --git a/src/sideshow/web/menus.py b/src/sideshow/web/menus.py index 1641c72..9da61c0 100644 --- a/src/sideshow/web/menus.py +++ b/src/sideshow/web/menus.py @@ -162,4 +162,12 @@ class SideshowMenuHandler(base.MenuHandler): def make_admin_menu(self, request, **kwargs): """ """ kwargs['include_people'] = True - return super().make_admin_menu(request, **kwargs) + menu = super().make_admin_menu(request, **kwargs) + + menu['items'].insert(0, { + 'title': "Stores", + 'route': 'stores', + 'perm': 'stores.list', + }) + + return menu diff --git a/src/sideshow/web/templates/order-items/view.mako b/src/sideshow/web/templates/order-items/view.mako index 3cd210a..cb86977 100644 --- a/src/sideshow/web/templates/order-items/view.mako +++ b/src/sideshow/web/templates/order-items/view.mako @@ -29,6 +29,17 @@ <b-field horizontal label="ID"> <span>${h.link_to(f"Order ID {order.order_id}", url('orders.view', uuid=order.uuid))} — Item #${item.sequence}</span> </b-field> + % if expose_store_id: + <b-field horizontal label="Store"> + <span> + % if order.store: + ${h.link_to(order.store.get_display(), url('stores.view', uuid=order.store.uuid))} + % elif order.store_id: + ${order.store_id} + % endif + </span> + </b-field> + % endif <b-field horizontal label="Order Qty"> <span>${order_qty_uom_text|n}</span> </b-field> @@ -223,7 +234,10 @@ <b-field horizontal label="Sold by Weight"> <span>${app.render_boolean(item.product_weighed)}</span> </b-field> - <b-field horizontal label="Department"> + <b-field horizontal label="Department ID"> + <span>${item.department_id}</span> + </b-field> + <b-field horizontal label="Department Name"> <span>${item.department_name}</span> </b-field> <b-field horizontal label="Special Order"> diff --git a/src/sideshow/web/templates/orders/configure.mako b/src/sideshow/web/templates/orders/configure.mako index e247b2f..144e5f0 100644 --- a/src/sideshow/web/templates/orders/configure.mako +++ b/src/sideshow/web/templates/orders/configure.mako @@ -3,6 +3,28 @@ <%def name="form_content()"> + <h3 class="block is-size-3">Stores</h3> + <div class="block" style="padding-left: 2rem;"> + + <b-field> + <b-checkbox name="sideshow.orders.expose_store_id" + v-model="simpleSettings['sideshow.orders.expose_store_id']" + native-value="true" + @input="settingsNeedSaved = true"> + Show/choose the Store ID for each order + </b-checkbox> + </b-field> + + <b-field v-show="simpleSettings['sideshow.orders.expose_store_id']" + label="Default Store ID"> + <b-input name="sideshow.orders.default_store_id" + v-model="simpleSettings['sideshow.orders.default_store_id']" + @input="settingsNeedSaved = true" + style="width: 25rem;" /> + </b-field> + + </div> + <h3 class="block is-size-3">Customers</h3> <div class="block" style="padding-left: 2rem;"> @@ -14,41 +36,12 @@ <option value="false">External Customers (e.g. in POS)</option> </b-select> </b-field> + </div> <h3 class="block is-size-3">Products</h3> <div class="block" style="padding-left: 2rem;"> - <b-field> - <b-checkbox name="sideshow.orders.allow_item_discounts" - v-model="simpleSettings['sideshow.orders.allow_item_discounts']" - native-value="true" - @input="settingsNeedSaved = true"> - Allow per-item discounts - </b-checkbox> - </b-field> - - <b-field v-show="simpleSettings['sideshow.orders.allow_item_discounts']"> - <b-checkbox name="sideshow.orders.allow_item_discounts_if_on_sale" - v-model="simpleSettings['sideshow.orders.allow_item_discounts_if_on_sale']" - native-value="true" - @input="settingsNeedSaved = true"> - Allow discount even if item is on sale - </b-checkbox> - </b-field> - - <div v-show="simpleSettings['sideshow.orders.allow_item_discounts']" - class="level-left block"> - <div class="level-item">Default item discount</div> - <div class="level-item"> - <b-input name="sideshow.orders.default_item_discount" - v-model="simpleSettings['sideshow.orders.default_item_discount']" - @input="settingsNeedSaved = true" - style="width: 5rem;" /> - </div> - <div class="level-item">%</div> - </div> - <b-field label="Product Source"> <b-select name="sideshow.orders.use_local_products" v-model="simpleSettings['sideshow.orders.use_local_products']" @@ -92,6 +85,136 @@ </div> </div> + <h3 class="block is-size-3">Pricing</h3> + <div class="block" style="padding-left: 2rem;"> + + <b-field> + <b-checkbox name="sideshow.orders.allow_item_discounts" + v-model="simpleSettings['sideshow.orders.allow_item_discounts']" + native-value="true" + @input="settingsNeedSaved = true"> + Allow per-item discounts + </b-checkbox> + </b-field> + + <b-field v-show="simpleSettings['sideshow.orders.allow_item_discounts']"> + <b-checkbox name="sideshow.orders.allow_item_discounts_if_on_sale" + v-model="simpleSettings['sideshow.orders.allow_item_discounts_if_on_sale']" + native-value="true" + @input="settingsNeedSaved = true"> + Allow discount even if item is on sale + </b-checkbox> + </b-field> + + <div v-show="simpleSettings['sideshow.orders.allow_item_discounts']" + class="block" + style="display: flex; gap: 0.5rem; align-items: center;"> + <span>Global default item discount</span> + <b-input name="sideshow.orders.default_item_discount" + v-model="simpleSettings['sideshow.orders.default_item_discount']" + @input="settingsNeedSaved = true" + style="width: 5rem;" /> + <span>%</span> + </div> + + <div v-show="simpleSettings['sideshow.orders.allow_item_discounts']" + style="width: 50%;"> + <div style="display: flex; gap: 1rem; align-items: center;"> + <p>Per-Department default item discounts</p> + <div> + <b-button type="is-primary" + @click="deptItemDiscountInit()" + icon-pack="fas" + icon-left="plus"> + Add + </b-button> + <input type="hidden" name="dept_item_discounts" :value="JSON.stringify(deptItemDiscounts)" /> + <${b}-modal has-modal-card + % if request.use_oruga: + v-model:active="deptItemDiscountShowDialog" + % else: + :active.sync="deptItemDiscountShowDialog" + % endif + > + <div class="modal-card"> + + <header class="modal-card-head"> + <p class="modal-card-title">Default Discount for Department</p> + </header> + + <section class="modal-card-body"> + <div style="display: flex; gap: 1rem;"> + <b-field label="Dept. ID" + :type="deptItemDiscountDeptID ? null : 'is-danger'"> + <b-input v-model="deptItemDiscountDeptID" + ref="deptItemDiscountDeptID" + style="width: 6rem;;" /> + </b-field> + <b-field label="Department Name" + :type="deptItemDiscountDeptName ? null : 'is-danger'" + style="flex-grow: 1;"> + <b-input v-model="deptItemDiscountDeptName" /> + </b-field> + <b-field label="Discount" + :type="deptItemDiscountPercent ? null : 'is-danger'"> + <div style="display: flex; gap: 0.5rem; align-items: center;"> + <b-input v-model="deptItemDiscountPercent" + ref="deptItemDiscountPercent" + style="width: 6rem;" /> + <span>%</span> + </div> + </b-field> + </div> + </section> + + <footer class="modal-card-foot"> + <b-button type="is-primary" + icon-pack="fas" + icon-left="save" + :disabled="deptItemDiscountSaveDisabled" + @click="deptItemDiscountSave()"> + Save + </b-button> + <b-button @click="deptItemDiscountShowDialog = false"> + Cancel + </b-button> + </footer> + </div> + </${b}-modal> + </div> + </div> + <${b}-table :data="deptItemDiscounts"> + <${b}-table-column field="department_id" + label="Dept. ID" + v-slot="props"> + {{ props.row.department_id }} + </${b}-table-column> + <${b}-table-column field="department_name" + label="Department Name" + v-slot="props"> + {{ props.row.department_name }} + </${b}-table-column> + <${b}-table-column field="default_item_discount" + label="Discount" + v-slot="props"> + {{ props.row.default_item_discount }} % + </${b}-table-column> + <${b}-table-column label="Actions" + v-slot="props"> + <a href="#" @click.prevent="deptItemDiscountInit(props.row)"> + <i class="fas fa-edit" /> + Edit + </a> + <a href="#" @click.prevent="deptItemDiscountDelete(props.row)" + class="has-text-danger"> + <i class="fas fa-trash" /> + Delete + </a> + </${b}-table-column> + </${b}-table> + </div> + </div> + <h3 class="block is-size-3">Batches</h3> <div class="block" style="padding-left: 2rem;"> @@ -118,5 +241,62 @@ ThisPageData.batchHandlers = ${json.dumps(batch_handlers)|n} + ThisPageData.deptItemDiscounts = ${json.dumps(dept_item_discounts)|n} + ThisPageData.deptItemDiscountShowDialog = false + ThisPageData.deptItemDiscountRow = null + ThisPageData.deptItemDiscountDeptID = null + ThisPageData.deptItemDiscountDeptName = null + ThisPageData.deptItemDiscountPercent = null + + ThisPage.computed.deptItemDiscountSaveDisabled = function() { + if (!this.deptItemDiscountDeptID) { + return true + } + if (!this.deptItemDiscountDeptName) { + return true + } + if (!this.deptItemDiscountPercent) { + return true + } + return false + } + + ThisPage.methods.deptItemDiscountDelete = function(row) { + const i = this.deptItemDiscounts.indexOf(row) + this.deptItemDiscounts.splice(i, 1) + this.settingsNeedSaved = true + } + + ThisPage.methods.deptItemDiscountInit = function(row) { + this.deptItemDiscountRow = row + this.deptItemDiscountDeptID = row?.department_id + this.deptItemDiscountDeptName = row?.department_name + this.deptItemDiscountPercent = row?.default_item_discount + this.deptItemDiscountShowDialog = true + this.$nextTick(() => { + if (row) { + this.$refs.deptItemDiscountPercent.focus() + } else { + this.$refs.deptItemDiscountDeptID.focus() + } + }) + } + + ThisPage.methods.deptItemDiscountSave = function() { + if (this.deptItemDiscountRow) { + this.deptItemDiscountRow.department_id = this.deptItemDiscountDeptID + this.deptItemDiscountRow.department_name = this.deptItemDiscountDeptName + this.deptItemDiscountRow.default_item_discount = this.deptItemDiscountPercent + } else { + this.deptItemDiscounts.push({ + department_id: this.deptItemDiscountDeptID, + department_name: this.deptItemDiscountDeptName, + default_item_discount: this.deptItemDiscountPercent, + }) + } + this.deptItemDiscountShowDialog = false + this.settingsNeedSaved = true + } + </script> </%def> diff --git a/src/sideshow/web/templates/orders/create.mako b/src/sideshow/web/templates/orders/create.mako index 2ac6f0a..5dd39b7 100644 --- a/src/sideshow/web/templates/orders/create.mako +++ b/src/sideshow/web/templates/orders/create.mako @@ -42,7 +42,25 @@ <script type="text/x-template" id="order-creator-template"> <div> - ${self.order_form_buttons()} + <div style="display: flex; justify-content: space-between; margin-bottom: 1.5rem;"> + <div> + % if expose_store_id: + <b-loading v-model="storeLoading" is-full-page /> + <b-field label="Store" horizontal + :type="storeID ? null : 'is-danger'"> + <b-select v-model="storeID" + @input="storeChanged"> + <option v-for="store in stores" + :key="store.store_id" + :value="store.store_id"> + {{ store.display }} + </option> + </b-select> + </b-field> + % endif + </div> + ${self.order_form_buttons()} + </div> <${b}-collapse class="panel" :class="customerPanelType" @@ -337,6 +355,135 @@ @click="showAddItemDialog()"> Add Item </b-button> + % if allow_past_item_reorder: + <b-button v-if="customerIsKnown && customerID" + icon-pack="fas" + icon-left="plus" + @click="showAddPastItem()"> + Add Past Item + </b-button> + + <${b}-modal + % if request.use_oruga: + v-model:active="pastItemsShowDialog" + % else: + :active.sync="pastItemsShowDialog" + % endif + > + <div class="card"> + <div class="card-content"> + + <${b}-table :data="pastItems" + icon-pack="fas" + :loading="pastItemsLoading" + % if request.use_oruga: + v-model:selected="pastItemsSelected" + % else: + :selected.sync="pastItemsSelected" + % endif + sortable + paginated + per-page="5" + ## :debounce-search="1000" + > + + <${b}-table-column label="Scancode" + field="key" + v-slot="props" + sortable> + {{ props.row.scancode }} + </${b}-table-column> + + <${b}-table-column label="Brand" + field="brand_name" + v-slot="props" + sortable + searchable> + {{ props.row.brand_name }} + </${b}-table-column> + + <${b}-table-column label="Description" + field="description" + v-slot="props" + sortable + searchable> + {{ props.row.description }} + {{ props.row.size }} + </${b}-table-column> + + <${b}-table-column label="Unit Price" + field="unit_price_reg_display" + v-slot="props" + sortable> + {{ props.row.unit_price_reg_display }} + </${b}-table-column> + + <${b}-table-column label="Sale Price" + field="sale_price" + v-slot="props" + sortable> + <span class="has-background-warning"> + {{ props.row.sale_price_display }} + </span> + </${b}-table-column> + + <${b}-table-column label="Sale Ends" + field="sale_ends" + v-slot="props" + sortable> + <span class="has-background-warning"> + {{ props.row.sale_ends_display }} + </span> + </${b}-table-column> + + <${b}-table-column label="Department" + field="department_name" + v-slot="props" + sortable + searchable> + {{ props.row.department_name }} + </${b}-table-column> + + <${b}-table-column label="Vendor" + field="vendor_name" + v-slot="props" + sortable + searchable> + {{ props.row.vendor_name }} + </${b}-table-column> + + <template #empty> + <div class="content has-text-grey has-text-centered"> + <p> + <b-icon + pack="fas" + icon="sad-tear" + size="is-large"> + </b-icon> + </p> + <p>Nothing here.</p> + </div> + </template> + </${b}-table> + + <div class="buttons"> + <b-button @click="pastItemsShowDialog = false"> + Cancel + </b-button> + <b-button type="is-primary" + icon-pack="fas" + icon-left="plus" + @click="pastItemsAddSelected()" + :disabled="!pastItemsSelected"> + Add Selected Item + </b-button> + </div> + + </div> + </div> + </${b}-modal> + + % endif </div> <${b}-modal @@ -486,7 +633,16 @@ <b-input v-model="pendingProduct.scancode" /> </b-field> - <b-field label="Department" + <b-field label="Dept. ID" + % if 'department_id' in pending_product_required_fields: + :type="pendingProduct.department_id ? null : 'is-danger'" + % endif + style="width: 15rem;"> + <b-input v-model="pendingProduct.department_id" + @input="updateDiscount" /> + </b-field> + + <b-field label="Department Name" % if 'department_name' in pending_product_required_fields: :type="pendingProduct.department_name ? null : 'is-danger'" % endif @@ -494,8 +650,7 @@ <b-input v-model="pendingProduct.department_name" /> </b-field> - <b-field label="Special Order" - style="width: 100%;"> + <b-field label="Special Order"> <b-checkbox v-model="pendingProduct.special_order" /> </b-field> @@ -732,7 +887,7 @@ <${b}-table-column label="Department" v-slot="props"> - {{ props.row.department_display }} + {{ props.row.department_name }} </${b}-table-column> <${b}-table-column label="Quantity" @@ -837,6 +992,12 @@ batchTotalPriceDisplay: ${json.dumps(normalized_batch['total_price_display'])|n}, + % if expose_store_id: + stores: ${json.dumps(stores)|n}, + storeID: ${json.dumps(batch.store_id)|n}, + storeLoading: false, + % endif + customerPanelOpen: false, customerLoading: false, customerIsKnown: ${json.dumps(customer_is_known)|n}, @@ -898,8 +1059,10 @@ productCaseSize: null, % if allow_item_discounts: - productDiscountPercent: ${json.dumps(default_item_discount)|n}, + defaultItemDiscount: ${json.dumps(default_item_discount)|n}, + deptItemDiscounts: ${json.dumps(dept_item_discounts)|n}, allowDiscountsIfOnSale: ${json.dumps(allow_item_discounts_if_on_sale)|n}, + productDiscountPercent: null, % endif pendingProduct: {}, @@ -908,6 +1071,13 @@ ## departmentOptions: ${json.dumps(department_options)|n}, departmentOptions: [], + % if allow_past_item_reorder: + pastItemsShowDialog: false, + pastItemsLoading: false, + pastItems: [], + pastItemsSelected: null, + % endif + // nb. hack to force refresh for vue3 refreshProductDescription: 1, refreshTotalPrice: 1, @@ -1160,8 +1330,31 @@ }) }, + % if expose_store_id: + + storeChanged(storeID) { + this.storeLoading = true + const params = { + action: 'set_store', + store_id: storeID, + } + this.submitBatchData(params, ({data}) => { + this.storeLoading = false + }, response => { + this.$buefy.toast.open({ + message: "Update failed: " + (response.data.error || "(unknown error)"), + type: 'is-danger', + duration: 2000, // 2 seconds + }) + this.storeLoading = false + }) + }, + + % endif + customerChanged(customerID, callback) { this.customerLoading = true + this.pastItems = [] const params = {} if (customerID) { @@ -1213,6 +1406,23 @@ }) }, + % if allow_item_discounts: + + updateDiscount(deptID) { + if (deptID) { + // nb. our map requires ID as string + deptID = deptID.toString() + } + const i = Object.keys(this.deptItemDiscounts).indexOf(deptID) + if (i == -1) { + this.productDiscountPercent = this.defaultItemDiscount + } else { + this.productDiscountPercent = this.deptItemDiscounts[deptID] + } + }, + + % endif + editNewCustomerSave() { this.editNewCustomerSaving = true @@ -1351,7 +1561,7 @@ this.productUnitChoices = this.defaultUnitChoices % if allow_item_discounts: - this.productDiscountPercent = ${json.dumps(default_item_discount)|n} + this.productDiscountPercent = this.defaultItemDiscount % endif }, @@ -1390,7 +1600,15 @@ this.productSaleEndsDisplay = data.sale_ends_display % if allow_item_discounts: - this.productDiscountPercent = this.allowItemDiscount ? data.default_item_discount : null + if (this.allowItemDiscount) { + if (data?.default_item_discount != null) { + this.productDiscountPercent = data.default_item_discount + } else { + this.updateDiscount(data?.department_id) + } + } else { + this.productDiscountPercent = null + } % endif // this.setProductUnitChoices(data.uom_choices) @@ -1468,7 +1686,7 @@ this.productUOM = this.defaultUOM % if allow_item_discounts: - this.productDiscountPercent = ${json.dumps(default_item_discount)|n} + this.productDiscountPercent = this.defaultItemDiscount % endif % if request.use_oruga: @@ -1522,7 +1740,7 @@ ## this.productSpecialOrder = row.special_order this.productQuantity = row.order_qty - this.productUnitChoices = row.order_uom_choices + this.productUnitChoices = row?.order_uom_choices || this.defaultUnitChoices this.productUOM = row.order_uom % if allow_item_discounts: @@ -1564,12 +1782,77 @@ }) }, + % if allow_past_item_reorder: + + showAddPastItem() { + this.pastItemsSelected = null + if (!this.pastItems.length) { + this.pastItemsLoading = true + const params = {action: 'get_past_products'} + this.submitBatchData(params, ({data}) => { + this.pastItems = data + this.pastItemsLoading = false + }) + } + this.pastItemsShowDialog = true + }, + + pastItemsAddSelected() { + this.pastItemsShowDialog = false + const selected = this.pastItemsSelected + + this.editItemRow = null + this.productIsKnown = true + this.productID = selected.product_id + + this.selectedProduct = { + product_id: selected.product_id, + full_description: selected.full_description, + // url: selected.product_url, + } + + this.productDisplay = selected.full_description + this.productScancode = selected.scancode + this.productSize = selected.size + this.productCaseQuantity = selected.case_size + this.productUnitPrice = selected.unit_price_quoted + this.productUnitPriceDisplay = selected.unit_price_quoted_display + this.productUnitRegularPriceDisplay = selected.unit_price_reg_display + this.productCasePrice = selected.case_price_quoted + this.productCasePriceDisplay = selected.case_price_quoted_display + this.productSalePrice = selected.unit_price_sale + this.productSalePriceDisplay = selected.unit_price_sale_display + this.productSaleEndsDisplay = selected.sale_ends_display + this.productSpecialOrder = selected.special_order + + this.productQuantity = 1 + this.productUnitChoices = selected?.order_uom_choices || this.defaultUnitChoices + this.productUOM = selected?.order_uom || this.defaultUOM + + % if allow_item_discounts: + this.updateDiscount(selected.department_id) + % endif + + // nb. hack to force refresh for vue3 + this.refreshProductDescription += 1 + this.refreshTotalPrice += 1 + + % if request.use_oruga: + this.itemDialogTab = 'quantity' + % else: + this.itemDialogTabIndex = 1 + % endif + this.editItemShowDialog = true + }, + + % endif + itemDialogAttemptSave() { this.itemDialogSaving = true this.editItemLoading = true const params = { - order_qty: this.productQuantity, + order_qty: parseFloat(this.productQuantity), order_uom: this.productUOM, } @@ -1580,7 +1863,9 @@ } % if allow_item_discounts: - params.discount_percent = this.productDiscountPercent + if (this.productDiscountPercent) { + params.discount_percent = parseFloat(this.productDiscountPercent) + } % endif if (this.editItemRow) { diff --git a/src/sideshow/web/views/__init__.py b/src/sideshow/web/views/__init__.py index 13a468c..e5a14ac 100644 --- a/src/sideshow/web/views/__init__.py +++ b/src/sideshow/web/views/__init__.py @@ -35,6 +35,7 @@ def includeme(config): }) # sideshow views + config.include('sideshow.web.views.stores') config.include('sideshow.web.views.customers') config.include('sideshow.web.views.products') config.include('sideshow.web.views.orders') diff --git a/src/sideshow/web/views/batch/neworder.py b/src/sideshow/web/views/batch/neworder.py index fd7fbe3..5103517 100644 --- a/src/sideshow/web/views/batch/neworder.py +++ b/src/sideshow/web/views/batch/neworder.py @@ -126,6 +126,10 @@ class NewOrderBatchView(BatchMasterView): 'status_code', ] + def __init__(self, request, context=None): + super().__init__(request, context=context) + self.order_handler = self.app.get_order_handler() + def get_batch_handler(self): """ """ # TODO: call self.app.get_batch_handler() @@ -135,6 +139,10 @@ class NewOrderBatchView(BatchMasterView): """ """ super().configure_grid(g) + # store_id + if not self.order_handler.expose_store_id(): + g.remove('store_id') + # total_price g.set_renderer('total_price', 'currency') @@ -142,6 +150,10 @@ class NewOrderBatchView(BatchMasterView): """ """ super().configure_form(f) + # store_id + if not self.order_handler.expose_store_id(): + f.remove('store_id') + # local_customer f.set_node('local_customer', LocalCustomerRef(self.request)) diff --git a/src/sideshow/web/views/orders.py b/src/sideshow/web/views/orders.py index 9783de7..8aa7534 100644 --- a/src/sideshow/web/views/orders.py +++ b/src/sideshow/web/views/orders.py @@ -25,7 +25,9 @@ Views for Orders """ import decimal +import json import logging +import re import colander import sqlalchemy as sa @@ -35,9 +37,9 @@ from webhelpers2.html import tags, HTML from wuttaweb.views import MasterView from wuttaweb.forms.schema import UserRef, WuttaMoney, WuttaQuantity, WuttaEnum, WuttaDictEnum +from wuttaweb.util import make_json_safe from sideshow.db.model import Order, OrderItem -from sideshow.orders import OrderHandler from sideshow.batch.neworder import NewOrderBatchHandler from sideshow.web.forms.schema import (OrderRef, LocalCustomerRef, LocalProductRef, @@ -65,13 +67,13 @@ class OrderView(MasterView): .. attribute:: order_handler Reference to the :term:`order handler` as returned by - :meth:`get_order_handler()`. This gets set in the constructor. + :meth:`~sideshow.app.SideshowAppProvider.get_order_handler()`. + This gets set in the constructor. .. attribute:: batch_handler - Reference to the :term:`new order batch` handler, as returned - by :meth:`get_batch_handler()`. This gets set in the - constructor. + Reference to the :term:`new order batch` handler. This gets + set in the constructor. """ model_class = Order editable = False @@ -145,6 +147,7 @@ class OrderView(MasterView): 'brand_name', 'description', 'size', + 'department_id', 'department_name', 'vendor_name', 'vendor_item_code', @@ -155,41 +158,17 @@ class OrderView(MasterView): def __init__(self, request, context=None): super().__init__(request, context=context) - self.order_handler = self.get_order_handler() - - def get_order_handler(self): - """ - Returns the configured :term:`order handler`. - - You normally would not need to call this, and can use - :attr:`order_handler` instead. - - :rtype: :class:`~sideshow.orders.OrderHandler` - """ - if hasattr(self, 'order_handler'): - return self.order_handler - return OrderHandler(self.config) - - def get_batch_handler(self): - """ - Returns the configured :term:`handler` for :term:`new order - batches <new order batch>`. - - You normally would not need to call this, and can use - :attr:`batch_handler` instead. - - :returns: - :class:`~sideshow.batch.neworder.NewOrderBatchHandler` - instance. - """ - if hasattr(self, 'batch_handler'): - return self.batch_handler - return self.app.get_batch_handler('neworder') + self.order_handler = self.app.get_order_handler() + self.batch_handler = self.app.get_batch_handler('neworder') def configure_grid(self, g): """ """ super().configure_grid(g) + # store_id + if not self.order_handler.expose_store_id(): + g.remove('store_id') + # order_id g.set_link('order_id') @@ -223,6 +202,7 @@ class OrderView(MasterView): * :meth:`start_over()` * :meth:`cancel_order()` + * :meth:`set_store()` * :meth:`assign_customer()` * :meth:`unassign_customer()` * :meth:`set_pending_customer()` @@ -232,10 +212,11 @@ class OrderView(MasterView): * :meth:`delete_item()` * :meth:`submit_order()` """ + model = self.app.model enum = self.app.enum - self.creating = True - self.batch_handler = self.get_batch_handler() + session = self.Session() batch = self.get_current_batch() + self.creating = True context = self.get_context_customer(batch) @@ -254,6 +235,7 @@ class OrderView(MasterView): data = dict(self.request.json_body) action = data.pop('action') json_actions = [ + 'set_store', 'assign_customer', 'unassign_customer', # 'update_phone_number', @@ -262,7 +244,7 @@ class OrderView(MasterView): # 'get_customer_info', # # 'set_customer_data', 'get_product_info', - # 'get_past_items', + 'get_past_products', 'add_item', 'update_item', 'delete_item', @@ -283,20 +265,36 @@ class OrderView(MasterView): 'normalized_batch': self.normalize_batch(batch), 'order_items': [self.normalize_row(row) for row in batch.rows], - 'default_uom_choices': self.get_default_uom_choices(), + 'default_uom_choices': self.batch_handler.get_default_uom_choices(), 'default_uom': None, # TODO? + 'expose_store_id': self.order_handler.expose_store_id(), 'allow_item_discounts': self.batch_handler.allow_item_discounts(), 'allow_unknown_products': (self.batch_handler.allow_unknown_products() and self.has_perm('create_unknown_product')), 'pending_product_required_fields': self.get_pending_product_required_fields(), + 'allow_past_item_reorder': True, # TODO: make configurable? }) + if context['expose_store_id']: + stores = session.query(model.Store)\ + .filter(model.Store.archived == False)\ + .order_by(model.Store.store_id)\ + .all() + context['stores'] = [{'store_id': store.store_id, 'display': store.get_display()} + for store in stores] + + # set default so things just work + if not batch.store_id: + batch.store_id = self.batch_handler.get_default_store_id() + if context['allow_item_discounts']: context['allow_item_discounts_if_on_sale'] = self.batch_handler\ .allow_item_discounts_if_on_sale() # nb. render quantity so that '10.0' => '10' context['default_item_discount'] = self.app.render_quantity( self.batch_handler.get_default_item_discount()) + context['dept_item_discounts'] = dict([(d['department_id'], d['default_item_discount']) + for d in self.get_dept_item_discounts()]) return self.render_to_response('create', context) @@ -352,7 +350,7 @@ class OrderView(MasterView): if not term: return [] - handler = self.get_batch_handler() + handler = self.batch_handler if handler.use_local_customers(): return handler.autocomplete_customers_local(session, term, user=self.request.user) else: @@ -376,7 +374,7 @@ class OrderView(MasterView): if not term: return [] - handler = self.get_batch_handler() + handler = self.batch_handler if handler.use_local_products(): return handler.autocomplete_products_local(session, term, user=self.request.user) else: @@ -394,6 +392,43 @@ class OrderView(MasterView): required.append(field) return required + def get_dept_item_discounts(self): + """ + Returns the list of per-department default item discount settings. + + Each entry in the list will look like:: + + { + 'department_id': '42', + 'department_name': 'Grocery', + 'default_item_discount': 10, + } + + :returns: List of department settings as shown above. + """ + model = self.app.model + session = self.Session() + pattern = re.compile(r'^sideshow\.orders\.departments\.([^.]+)\.default_item_discount$') + + dept_item_discounts = [] + settings = session.query(model.Setting)\ + .filter(model.Setting.name.like('sideshow.orders.departments.%.default_item_discount'))\ + .all() + for setting in settings: + match = pattern.match(setting.name) + if not match: + log.warning("invalid setting name: %s", setting.name) + continue + deptid = match.group(1) + name = self.app.get_setting(session, f'sideshow.orders.departments.{deptid}.name') + dept_item_discounts.append({ + 'department_id': deptid, + 'department_name': name, + 'default_item_discount': setting.value, + }) + dept_item_discounts.sort(key=lambda d: d['department_name']) + return dept_item_discounts + def start_over(self, batch): """ This will delete the user's current batch, then redirect user @@ -436,9 +471,26 @@ class OrderView(MasterView): url = self.get_index_url() return self.redirect(url) + def set_store(self, batch, data): + """ + Assign the + :attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.store_id` + for a batch. + + This is a "batch action" method which may be called from + :meth:`create()`. + """ + store_id = data.get('store_id') + if not store_id: + return {'error': "Must provide store_id"} + + batch.store_id = store_id + return self.get_context_customer(batch) + def get_context_customer(self, batch): """ """ context = { + 'store_id': batch.store_id, 'customer_is_known': True, 'customer_id': None, 'customer_name': batch.customer_name, @@ -531,18 +583,20 @@ class OrderView(MasterView): def get_product_info(self, batch, data): """ - Fetch data for a specific product. (Nothing is modified.) + Fetch data for a specific product. - Depending on config, this will fetch a :term:`local product` - or :term:`external product` to get the data. + Depending on config, this calls one of the following to get + its primary data: - This should invoke a configured handler for the query - behavior, but that is not yet implemented. For now it uses - built-in logic only, which queries the - :class:`~sideshow.db.model.products.LocalProduct` table. + * :meth:`~sideshow.batch.neworder.NewOrderBatchHandler.get_product_info_local()` + * :meth:`~sideshow.batch.neworder.NewOrderBatchHandler.get_product_info_external()` + + It then may supplement the data with additional fields. This is a "batch action" method which may be called from :meth:`create()`. + + :returns: Dict of product info. """ product_id = data.get('product_id') if not product_id: @@ -574,9 +628,6 @@ class OrderView(MasterView): if 'case_price_quoted' in data and 'case_price_quoted_display' not in data: data['case_price_quoted_display'] = self.app.render_currency(data['case_price_quoted']) - if 'default_item_discount' not in data: - data['default_item_discount'] = self.batch_handler.get_default_item_discount() - decimal_fields = [ 'case_size', 'unit_price_reg', @@ -593,6 +644,22 @@ class OrderView(MasterView): return data + def get_past_products(self, batch, data): + """ + Fetch past products for convenient re-ordering. + + This essentially calls + :meth:`~sideshow.batch.neworder.NewOrderBatchHandler.get_past_products()` + on the :attr:`batch_handler` and returns the result. + + This is a "batch action" method which may be called from + :meth:`create()`. + + :returns: List of product info dicts. + """ + past_products = self.batch_handler.get_past_products(batch) + return make_json_safe(past_products) + def add_item(self, batch, data): """ This adds a row to the user's current new order batch. @@ -709,12 +776,6 @@ class OrderView(MasterView): 'status_text': batch.status_text, } - def get_default_uom_choices(self): - """ """ - enum = self.app.enum - return [{'key': key, 'value': val} - for key, val in enum.ORDER_UOM.items()] - def normalize_row(self, row): """ """ data = { @@ -729,12 +790,12 @@ class OrderView(MasterView): row.product_description, row.product_size), 'product_weighed': row.product_weighed, - 'department_display': row.department_name, + 'department_id': row.department_id, + 'department_name': row.department_name, 'special_order': row.special_order, 'case_size': float(row.case_size) if row.case_size is not None else None, 'order_qty': float(row.order_qty), 'order_uom': row.order_uom, - 'order_uom_choices': self.get_default_uom_choices(), 'discount_percent': self.app.render_quantity(row.discount_percent), 'unit_price_quoted': float(row.unit_price_quoted) if row.unit_price_quoted is not None else None, 'unit_price_quoted_display': self.app.render_currency(row.unit_price_quoted), @@ -810,6 +871,10 @@ class OrderView(MasterView): super().configure_form(f) order = f.model_instance + # store_id + if not self.order_handler.expose_store_id(): + f.remove('store_id') + # local_customer if order.customer_id and not order.local_customer: f.remove('local_customer') @@ -910,8 +975,10 @@ class OrderView(MasterView): """ """ settings = [ - # batches - {'name': 'wutta.batch.neworder.handler.spec'}, + # stores + {'name': 'sideshow.orders.expose_store_id', + 'type': bool}, + {'name': 'sideshow.orders.default_store_id'}, # customers {'name': 'sideshow.orders.use_local_customers', @@ -920,12 +987,6 @@ class OrderView(MasterView): 'default': 'true'}, # products - {'name': 'sideshow.orders.allow_item_discounts', - 'type': bool}, - {'name': 'sideshow.orders.allow_item_discounts_if_on_sale', - 'type': bool}, - {'name': 'sideshow.orders.default_item_discount', - 'type': float}, {'name': 'sideshow.orders.use_local_products', # nb. this is really a bool but we present as string in config UI #'type': bool, @@ -933,6 +994,17 @@ class OrderView(MasterView): {'name': 'sideshow.orders.allow_unknown_products', 'type': bool, 'default': True}, + + # pricing + {'name': 'sideshow.orders.allow_item_discounts', + 'type': bool}, + {'name': 'sideshow.orders.allow_item_discounts_if_on_sale', + 'type': bool}, + {'name': 'sideshow.orders.default_item_discount', + 'type': float}, + + # batches + {'name': 'wutta.batch.neworder.handler.spec'}, ] # required fields for new product entry @@ -955,8 +1027,39 @@ class OrderView(MasterView): handlers = [{'spec': spec} for spec in handlers] context['batch_handlers'] = handlers + context['dept_item_discounts'] = self.get_dept_item_discounts() + return context + def configure_gather_settings(self, data, simple_settings=None): + """ """ + settings = super().configure_gather_settings(data, simple_settings=simple_settings) + + for dept in json.loads(data['dept_item_discounts']): + deptid = dept['department_id'] + settings.append({'name': f'sideshow.orders.departments.{deptid}.name', + 'value': dept['department_name']}) + settings.append({'name': f'sideshow.orders.departments.{deptid}.default_item_discount', + 'value': dept['default_item_discount']}) + + return settings + + def configure_remove_settings(self, **kwargs): + """ """ + model = self.app.model + session = self.Session() + + super().configure_remove_settings(**kwargs) + + to_delete = session.query(model.Setting)\ + .filter(sa.or_( + model.Setting.name.like('sideshow.orders.departments.%.name'), + model.Setting.name.like('sideshow.orders.departments.%.default_item_discount')))\ + .all() + for setting in to_delete: + self.app.delete_setting(session, setting.name) + + @classmethod def defaults(cls, config): cls._order_defaults(config) @@ -1037,6 +1140,7 @@ class OrderItemView(MasterView): labels = { 'order_id': "Order ID", + 'store_id': "Store ID", 'product_id': "Product ID", 'product_scancode': "Scancode", 'product_brand': "Brand", @@ -1050,6 +1154,7 @@ class OrderItemView(MasterView): grid_columns = [ 'order_id', + 'store_id', 'customer_name', # 'sequence', 'product_scancode', @@ -1099,20 +1204,7 @@ class OrderItemView(MasterView): def __init__(self, request, context=None): super().__init__(request, context=context) - self.order_handler = self.get_order_handler() - - def get_order_handler(self): - """ - Returns the configured :term:`order handler`. - - You normally would not need to call this, and can use - :attr:`order_handler` instead. - - :rtype: :class:`~sideshow.orders.OrderHandler` - """ - if hasattr(self, 'order_handler'): - return self.order_handler - return OrderHandler(self.config) + self.order_handler = self.app.get_order_handler() def get_fallback_templates(self, template): """ """ @@ -1132,11 +1224,19 @@ class OrderItemView(MasterView): model = self.app.model # enum = self.app.enum + # store_id + if not self.order_handler.expose_store_id(): + g.remove('store_id') + # order_id g.set_sorter('order_id', model.Order.order_id) - g.set_renderer('order_id', self.render_order_id) + g.set_renderer('order_id', self.render_order_attr) g.set_link('order_id') + # store_id + g.set_sorter('store_id', model.Order.store_id) + g.set_renderer('store_id', self.render_order_attr) + # customer_name g.set_label('customer_name', "Customer", column_only=True) @@ -1165,9 +1265,10 @@ class OrderItemView(MasterView): # status_code g.set_renderer('status_code', self.render_status_code) - def render_order_id(self, item, key, value): + def render_order_attr(self, item, key, value): """ """ - return item.order.order_id + order = item.order + return getattr(order, key) def render_status_code(self, item, key, value): """ """ @@ -1237,6 +1338,8 @@ class OrderItemView(MasterView): item = context['instance'] form = context['form'] + context['expose_store_id'] = self.order_handler.expose_store_id() + context['item'] = item context['order'] = item.order context['order_qty_uom_text'] = self.order_handler.get_order_qty_uom_text( diff --git a/src/sideshow/web/views/products.py b/src/sideshow/web/views/products.py index 98341b7..010bb66 100644 --- a/src/sideshow/web/views/products.py +++ b/src/sideshow/web/views/products.py @@ -235,6 +235,7 @@ class PendingProductView(MasterView): url_prefix = '/pending/products' labels = { + 'department_id': "Department ID", 'product_id': "Product ID", } diff --git a/src/sideshow/web/views/stores.py b/src/sideshow/web/views/stores.py new file mode 100644 index 0000000..0eaf41d --- /dev/null +++ b/src/sideshow/web/views/stores.py @@ -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) diff --git a/tests/batch/test_neworder.py b/tests/batch/test_neworder.py index 56a3efd..ed2179a 100644 --- a/tests/batch/test_neworder.py +++ b/tests/batch/test_neworder.py @@ -4,6 +4,8 @@ import datetime import decimal from unittest.mock import patch +import sqlalchemy as sa + from wuttjamaican.testing import DataTestCase from sideshow.batch import neworder as mod @@ -20,6 +22,16 @@ class TestNewOrderBatchHandler(DataTestCase): def make_handler(self): return mod.NewOrderBatchHandler(self.config) + def test_get_default_store_id(self): + handler = self.make_handler() + + # null by default + self.assertIsNone(handler.get_default_store_id()) + + # whatever is configured + self.config.setdefault('sideshow.orders.default_store_id', '042') + self.assertEqual(handler.get_default_store_id(), '042') + def test_use_local_customers(self): handler = self.make_handler() @@ -108,6 +120,23 @@ class TestNewOrderBatchHandler(DataTestCase): # search for sally finds nothing self.assertEqual(handler.autocomplete_customers_local(self.session, 'sally'), []) + def test_init_batch(self): + model = self.app.model + handler = self.make_handler() + + # store_id is null by default + batch = handler.model_class() + self.assertIsNone(batch.store_id) + handler.init_batch(batch) + self.assertIsNone(batch.store_id) + + # but default can be configured + self.config.setdefault('sideshow.orders.default_store_id', '042') + batch = handler.model_class() + self.assertIsNone(batch.store_id) + handler.init_batch(batch) + self.assertEqual(batch.store_id, '042') + def test_set_customer(self): model = self.app.model handler = self.make_handler() @@ -240,6 +269,14 @@ class TestNewOrderBatchHandler(DataTestCase): # search for juice finds nothing self.assertEqual(handler.autocomplete_products_local(self.session, 'juice'), []) + def test_get_default_uom_choices(self): + enum = self.app.enum + handler = self.make_handler() + + uoms = handler.get_default_uom_choices() + self.assertEqual(uoms, [{'key': key, 'value': val} + for key, val in enum.ORDER_UOM.items()]) + def test_get_product_info_external(self): handler = self.make_handler() self.assertRaises(NotImplementedError, handler.get_product_info_external, @@ -281,6 +318,174 @@ class TestNewOrderBatchHandler(DataTestCase): mock_uuid = self.app.make_true_uuid() self.assertRaises(ValueError, handler.get_product_info_local, self.session, mock_uuid.hex) + def test_normalize_local_product(self): + model = self.app.model + handler = self.make_handler() + + product = model.LocalProduct(scancode='07430500132', + brand_name="Bragg's", + description="Apple Cider Vinegar", + size="32oz", + department_name="Grocery", + case_size=12, + unit_price_reg=5.99, + vendor_name="UNFI", + vendor_item_code='1234') + self.session.add(product) + self.session.flush() + + info = handler.normalize_local_product(product) + self.assertIsInstance(info, dict) + self.assertEqual(info['product_id'], product.uuid.hex) + for prop in sa.inspect(model.LocalProduct).column_attrs: + if prop.key == 'uuid': + continue + if prop.key not in info: + continue + self.assertEqual(info[prop.key], getattr(product, prop.key)) + + def test_get_past_orders(self): + model = self.app.model + handler = self.make_handler() + + user = model.User(username='barney') + self.session.add(user) + batch = handler.make_batch(self.session, created_by=user) + self.session.add(batch) + self.session.flush() + + # ..will test local customers first + + # error if no customer + self.assertRaises(ValueError, handler.get_past_orders, batch) + + # empty history for customer + customer = model.LocalCustomer(full_name='Fred Flintstone') + batch.local_customer = customer + self.session.flush() + orders = handler.get_past_orders(batch) + self.assertEqual(len(orders), 0) + + # mock historical order + order = model.Order(order_id=42, local_customer=customer, created_by=user) + self.session.add(order) + self.session.flush() + + # that should now be returned + orders = handler.get_past_orders(batch) + self.assertEqual(len(orders), 1) + self.assertIs(orders[0], order) + + # ..now we test external customers, w/ new batch + with patch.object(handler, 'use_local_customers', return_value=False): + batch2 = handler.make_batch(self.session, created_by=user) + self.session.add(batch2) + self.session.flush() + + # error if no customer + self.assertRaises(ValueError, handler.get_past_orders, batch2) + + # empty history for customer + batch2.customer_id = '123' + self.session.flush() + orders = handler.get_past_orders(batch2) + self.assertEqual(len(orders), 0) + + # mock historical order + order2 = model.Order(order_id=42, customer_id='123', created_by=user) + self.session.add(order2) + self.session.flush() + + # that should now be returned + orders = handler.get_past_orders(batch2) + self.assertEqual(len(orders), 1) + self.assertIs(orders[0], order2) + + def test_get_past_products(self): + model = self.app.model + enum = self.app.enum + handler = self.make_handler() + + user = model.User(username='barney') + self.session.add(user) + batch = handler.make_batch(self.session, created_by=user) + self.session.add(batch) + self.session.flush() + + # (nb. this all assumes local customers) + + # ..will test local products first + + # error if no customer + self.assertRaises(ValueError, handler.get_past_products, batch) + + # empty history for customer + customer = model.LocalCustomer(full_name='Fred Flintstone') + batch.local_customer = customer + self.session.flush() + products = handler.get_past_products(batch) + self.assertEqual(len(products), 0) + + # mock historical order + order = model.Order(order_id=42, local_customer=customer, created_by=user) + product = model.LocalProduct(scancode='07430500132', description='Vinegar', + unit_price_reg=5.99, case_size=12) + item = model.OrderItem(local_product=product, order_qty=1, order_uom=enum.ORDER_UOM_UNIT, + status_code=enum.ORDER_ITEM_STATUS_READY) + order.items.append(item) + self.session.add(order) + self.session.flush() + self.session.refresh(product) + + # that should now be returned + products = handler.get_past_products(batch) + self.assertEqual(len(products), 1) + self.assertEqual(products[0]['product_id'], product.uuid.hex) + self.assertEqual(products[0]['scancode'], '07430500132') + self.assertEqual(products[0]['description'], 'Vinegar') + self.assertEqual(products[0]['case_price_quoted'], decimal.Decimal('71.88')) + self.assertEqual(products[0]['case_price_quoted_display'], '$71.88') + + # ..now we test external products, w/ new batch + with patch.object(handler, 'use_local_products', return_value=False): + batch2 = handler.make_batch(self.session, created_by=user) + self.session.add(batch2) + self.session.flush() + + # error if no customer + self.assertRaises(ValueError, handler.get_past_products, batch2) + + # empty history for customer + batch2.local_customer = customer + self.session.flush() + products = handler.get_past_products(batch2) + self.assertEqual(len(products), 0) + + # mock historical order + order2 = model.Order(order_id=44, local_customer=customer, created_by=user) + self.session.add(order2) + item2 = model.OrderItem(product_id='07430500116', + order_qty=1, order_uom=enum.ORDER_UOM_UNIT, + status_code=enum.ORDER_ITEM_STATUS_READY) + order2.items.append(item2) + self.session.flush() + + # its product should now be returned + with patch.object(handler, 'get_product_info_external', return_value={ + 'product_id': '07430500116', + 'scancode': '07430500116', + 'description': 'VINEGAR', + 'unit_price_reg': decimal.Decimal('3.99'), + 'case_size': 12, + }): + products = handler.get_past_products(batch2) + self.assertEqual(len(products), 1) + self.assertEqual(products[0]['product_id'], '07430500116') + self.assertEqual(products[0]['scancode'], '07430500116') + self.assertEqual(products[0]['description'], 'VINEGAR') + self.assertEqual(products[0]['case_price_quoted'], decimal.Decimal('47.88')) + self.assertEqual(products[0]['case_price_quoted_display'], '$47.88') + def test_add_item(self): model = self.app.model enum = self.app.enum diff --git a/tests/db/model/test_stores.py b/tests/db/model/test_stores.py new file mode 100644 index 0000000..a44ebf7 --- /dev/null +++ b/tests/db/model/test_stores.py @@ -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") diff --git a/tests/test_app.py b/tests/test_app.py new file mode 100644 index 0000000..2d68e69 --- /dev/null +++ b/tests/test_app.py @@ -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) diff --git a/tests/test_orders.py b/tests/test_orders.py index 5937045..6e5609e 100644 --- a/tests/test_orders.py +++ b/tests/test_orders.py @@ -16,6 +16,16 @@ class TestOrderHandler(DataTestCase): def make_handler(self): return mod.OrderHandler(self.config) + def test_expose_store_id(self): + handler = self.make_handler() + + # false by default + self.assertFalse(handler.expose_store_id()) + + # config can enable + self.config.setdefault('sideshow.orders.expose_store_id', 'true') + self.assertTrue(handler.expose_store_id()) + def test_get_order_qty_uom_text(self): enum = self.app.enum handler = self.make_handler() diff --git a/tests/web/views/batch/test_neworder.py b/tests/web/views/batch/test_neworder.py index fbf2335..b832986 100644 --- a/tests/web/views/batch/test_neworder.py +++ b/tests/web/views/batch/test_neworder.py @@ -30,10 +30,19 @@ class TestNewOrderBatchView(WebTestCase): def test_configure_grid(self): model = self.app.model view = self.make_view() + + # store_id not exposed by default grid = view.make_grid(model_class=model.NewOrderBatch) - self.assertNotIn('total_price', grid.renderers) + self.assertIn('store_id', grid.columns) view.configure_grid(grid) - self.assertIn('total_price', grid.renderers) + self.assertNotIn('store_id', grid.columns) + + # store_id is exposed if configured + self.config.setdefault('sideshow.orders.expose_store_id', 'true') + grid = view.make_grid(model_class=model.NewOrderBatch) + self.assertIn('store_id', grid.columns) + view.configure_grid(grid) + self.assertIn('store_id', grid.columns) def test_configure_form(self): model = self.app.model @@ -58,6 +67,19 @@ class TestNewOrderBatchView(WebTestCase): self.assertIsInstance(schema['pending_customer'].typ, PendingCustomerRef) self.assertIsInstance(schema['total_price'].typ, WuttaMoney) + # store_id not exposed by default + form = view.make_form(model_instance=batch) + self.assertIn('store_id', form) + view.configure_form(form) + self.assertNotIn('store_id', form) + + # store_id is exposed if configured + self.config.setdefault('sideshow.orders.expose_store_id', 'true') + form = view.make_form(model_instance=batch) + self.assertIn('store_id', form) + view.configure_form(form) + self.assertIn('store_id', form) + def test_configure_row_grid(self): model = self.app.model view = self.make_view() diff --git a/tests/web/views/test_orders.py b/tests/web/views/test_orders.py index e4425ab..1af1a69 100644 --- a/tests/web/views/test_orders.py +++ b/tests/web/views/test_orders.py @@ -2,6 +2,7 @@ import datetime import decimal +import json from unittest.mock import patch from sqlalchemy import orm @@ -15,6 +16,7 @@ from sideshow.orders import OrderHandler from sideshow.testing import WebTestCase from sideshow.web.views import orders as mod from sideshow.web.forms.schema import OrderRef, PendingProductRef +from sideshow.config import SideshowConfig class TestIncludeme(WebTestCase): @@ -25,33 +27,39 @@ class TestIncludeme(WebTestCase): class TestOrderView(WebTestCase): + def make_config(self, **kw): + config = super().make_config(**kw) + SideshowConfig().configure(config) + return config + def make_view(self): return mod.OrderView(self.request) def make_handler(self): return NewOrderBatchHandler(self.config) - def test_order_handler(self): - view = self.make_view() - handler = view.order_handler - self.assertIsInstance(handler, OrderHandler) - handler2 = view.get_order_handler() - self.assertIs(handler2, handler) - def test_configure_grid(self): model = self.app.model view = self.make_view() - grid = view.make_grid(model_class=model.PendingProduct) - self.assertNotIn('order_id', grid.linked_columns) - self.assertNotIn('total_price', grid.renderers) + + # store_id hidden by default + grid = view.make_grid(model_class=model.Order, columns=['store_id', 'order_id']) + self.assertIn('store_id', grid.columns) view.configure_grid(grid) - self.assertIn('order_id', grid.linked_columns) - self.assertIn('total_price', grid.renderers) + self.assertNotIn('store_id', grid.columns) + + # store_id is shown if configured + self.config.setdefault('sideshow.orders.expose_store_id', 'true') + grid = view.make_grid(model_class=model.Order, columns=['store_id', 'order_id']) + self.assertIn('store_id', grid.columns) + view.configure_grid(grid) + self.assertIn('store_id', grid.columns) def test_create(self): self.pyramid_config.include('sideshow.web.views') self.config.setdefault('wutta.batch.neworder.handler.spec', 'sideshow.batch.neworder:NewOrderBatchHandler') + self.config.setdefault('sideshow.orders.expose_store_id', 'true') self.config.setdefault('sideshow.orders.allow_item_discounts', 'true') model = self.app.model enum = self.app.enum @@ -59,6 +67,10 @@ class TestOrderView(WebTestCase): user = model.User(username='barney') self.session.add(user) + store = model.Store(store_id='001', name='Acme Goods') + self.session.add(store) + store = model.Store(store_id='002', name='Acme Services') + self.session.add(store) self.session.flush() with patch.object(view, 'Session', return_value=self.session): @@ -101,6 +113,7 @@ class TestOrderView(WebTestCase): self.assertIsInstance(response, Response) self.assertEqual(response.content_type, 'application/json') self.assertEqual(response.json_body, { + 'store_id': None, 'customer_is_known': False, 'customer_id': None, 'customer_name': 'Fred Flintstone', @@ -285,6 +298,50 @@ class TestOrderView(WebTestCase): fields = view.get_pending_product_required_fields() self.assertEqual(fields, ['brand_name', 'size', 'unit_price_reg']) + def test_get_dept_item_discounts(self): + model = self.app.model + view = self.make_view() + + with patch.object(view, 'Session', return_value=self.session): + + # empty list by default + discounts = view.get_dept_item_discounts() + self.assertEqual(discounts, []) + + # mock settings + self.app.save_setting(self.session, 'sideshow.orders.departments.5.name', 'Bulk') + self.app.save_setting(self.session, 'sideshow.orders.departments.5.default_item_discount', '15') + self.app.save_setting(self.session, 'sideshow.orders.departments.6.name', 'Produce') + self.app.save_setting(self.session, 'sideshow.orders.departments.6.default_item_discount', '5') + discounts = view.get_dept_item_discounts() + self.assertEqual(len(discounts), 2) + self.assertEqual(discounts[0], { + 'department_id': '5', + 'department_name': 'Bulk', + 'default_item_discount': '15', + }) + self.assertEqual(discounts[1], { + 'department_id': '6', + 'department_name': 'Produce', + 'default_item_discount': '5', + }) + + # invalid setting + self.app.save_setting(self.session, 'sideshow.orders.departments.I.N.V.A.L.I.D.name', 'Bad News') + self.app.save_setting(self.session, 'sideshow.orders.departments.I.N.V.A.L.I.D.default_item_discount', '42') + discounts = view.get_dept_item_discounts() + self.assertEqual(len(discounts), 2) + self.assertEqual(discounts[0], { + 'department_id': '5', + 'department_name': 'Bulk', + 'default_item_discount': '15', + }) + self.assertEqual(discounts[1], { + 'department_id': '6', + 'department_name': 'Produce', + 'default_item_discount': '5', + }) + def test_get_context_customer(self): self.pyramid_config.add_route('orders', '/orders/') model = self.app.model @@ -304,6 +361,7 @@ class TestOrderView(WebTestCase): self.session.flush() context = view.get_context_customer(batch) self.assertEqual(context, { + 'store_id': None, 'customer_is_known': True, 'customer_id': 42, 'customer_name': 'Fred Flintstone', @@ -321,6 +379,7 @@ class TestOrderView(WebTestCase): self.session.flush() context = view.get_context_customer(batch) self.assertEqual(context, { + 'store_id': None, 'customer_is_known': True, 'customer_id': local.uuid.hex, 'customer_name': 'Betty Boop', @@ -339,6 +398,7 @@ class TestOrderView(WebTestCase): self.session.flush() context = view.get_context_customer(batch) self.assertEqual(context, { + 'store_id': None, 'customer_is_known': False, 'customer_id': None, 'customer_name': 'Fred Flintstone', @@ -357,6 +417,7 @@ class TestOrderView(WebTestCase): self.session.flush() context = view.get_context_customer(batch) self.assertEqual(context, { + 'store_id': None, 'customer_is_known': True, # nb. this is for UI default 'customer_id': None, 'customer_name': None, @@ -408,6 +469,34 @@ class TestOrderView(WebTestCase): self.session.flush() self.assertEqual(self.session.query(model.NewOrderBatch).count(), 0) + def test_set_store(self): + model = self.app.model + view = self.make_view() + handler = NewOrderBatchHandler(self.config) + + user = model.User(username='barney') + self.session.add(user) + self.session.flush() + + with patch.object(view, 'batch_handler', create=True, new=handler): + with patch.object(view, 'Session', return_value=self.session): + with patch.object(self.request, 'user', new=user): + + batch = view.get_current_batch() + self.assertIsNone(batch.store_id) + + # store_id is required + result = view.set_store(batch, {}) + self.assertEqual(result, {'error': "Must provide store_id"}) + result = view.set_store(batch, {'store_id': ''}) + self.assertEqual(result, {'error': "Must provide store_id"}) + + # store_id is set on batch + result = view.set_store(batch, {'store_id': '042'}) + self.assertEqual(batch.store_id, '042') + self.assertIn('store_id', result) + self.assertEqual(result['store_id'], '042') + def test_assign_customer(self): self.pyramid_config.add_route('orders.create', '/orders/new') model = self.app.model @@ -432,6 +521,7 @@ class TestOrderView(WebTestCase): self.assertIsNone(batch.pending_customer) self.assertIs(batch.local_customer, weirdal) self.assertEqual(context, { + 'store_id': None, 'customer_is_known': True, 'customer_id': weirdal.uuid.hex, 'customer_name': 'Weird Al', @@ -470,6 +560,7 @@ class TestOrderView(WebTestCase): self.assertIsNone(batch.customer_name) self.assertIsNone(batch.local_customer) self.assertEqual(context, { + 'store_id': None, 'customer_is_known': True, 'customer_id': None, 'customer_name': None, @@ -510,6 +601,7 @@ class TestOrderView(WebTestCase): context = view.set_pending_customer(batch, data) self.assertIsInstance(batch.pending_customer, model.PendingCustomer) self.assertEqual(context, { + 'store_id': None, 'customer_is_known': False, 'customer_id': None, 'customer_name': 'Fred Flintstone', @@ -575,6 +667,51 @@ class TestOrderView(WebTestCase): context = view.get_product_info(batch, {'product_id': '42'}) self.assertEqual(context, {'error': "something smells fishy"}) + def test_get_past_products(self): + model = self.app.model + enum = self.app.enum + view = self.make_view() + handler = view.batch_handler + + user = model.User(username='barney') + self.session.add(user) + batch = handler.make_batch(self.session, created_by=user) + self.session.add(batch) + self.session.flush() + + # (nb. this all assumes local customers and products) + + # error if no customer + self.assertRaises(ValueError, view.get_past_products, batch, {}) + + # empty history for customer + customer = model.LocalCustomer(full_name='Fred Flintstone') + batch.local_customer = customer + self.session.flush() + products = view.get_past_products(batch, {}) + self.assertEqual(len(products), 0) + + # mock historical order + order = model.Order(order_id=42, local_customer=customer, created_by=user) + product = model.LocalProduct(scancode='07430500132', description='Vinegar', + unit_price_reg=5.99, case_size=12) + item = model.OrderItem(local_product=product, order_qty=1, order_uom=enum.ORDER_UOM_UNIT, + status_code=enum.ORDER_ITEM_STATUS_READY) + order.items.append(item) + self.session.add(order) + self.session.flush() + self.session.refresh(product) + + # that should now be returned + products = view.get_past_products(batch, {}) + self.assertEqual(len(products), 1) + self.assertEqual(products[0]['product_id'], product.uuid.hex) + self.assertEqual(products[0]['scancode'], '07430500132') + self.assertEqual(products[0]['description'], 'Vinegar') + # nb. this is a float, since result is JSON-safe + self.assertEqual(products[0]['case_price_quoted'], 71.88) + self.assertEqual(products[0]['case_price_quoted_display'], '$71.88') + def test_add_item(self): model = self.app.model enum = self.app.enum @@ -825,14 +962,6 @@ class TestOrderView(WebTestCase): 'error': f"ValueError: batch has already been executed: {batch}", }) - def test_get_default_uom_choices(self): - enum = self.app.enum - view = self.make_view() - - uoms = view.get_default_uom_choices() - self.assertEqual(uoms, [{'key': key, 'value': val} - for key, val in enum.ORDER_UOM.items()]) - def test_normalize_batch(self): model = self.app.model enum = self.app.enum @@ -1078,7 +1207,11 @@ class TestOrderView(WebTestCase): form = view.make_form(model_instance=order) # nb. this is to avoid include/exclude ambiguity form.remove('items') + # nb. store_id gets hidden by default + form.append('store_id') + self.assertIn('store_id', form) view.configure_form(form) + self.assertNotIn('store_id', form) schema = form.get_schema() self.assertIn('pending_customer', form) self.assertIsInstance(schema['total_price'].typ, WuttaMoney) @@ -1089,13 +1222,20 @@ class TestOrderView(WebTestCase): self.session.add(local) self.session.flush() + # nb. from now on we include store_id + self.config.setdefault('sideshow.orders.expose_store_id', 'true') + # viewing (local customer) with patch.object(view, 'viewing', new=True): with patch.object(order, 'local_customer', new=local): form = view.make_form(model_instance=order) # nb. this is to avoid include/exclude ambiguity form.remove('items') + # nb. store_id will now remain + form.append('store_id') + self.assertIn('store_id', form) view.configure_form(form) + self.assertIn('store_id', form) self.assertNotIn('pending_customer', form) schema = form.get_schema() self.assertIsInstance(schema['total_price'].typ, WuttaMoney) @@ -1236,6 +1376,12 @@ class TestOrderView(WebTestCase): model = self.app.model view = self.make_view() + self.app.save_setting(self.session, 'sideshow.orders.departments.5.name', 'Bulk') + self.app.save_setting(self.session, 'sideshow.orders.departments.5.default_item_discount', '15') + self.app.save_setting(self.session, 'sideshow.orders.departments.6.name', 'Produce') + self.app.save_setting(self.session, 'sideshow.orders.departments.6.default_item_discount', '5') + self.session.commit() + with patch.object(view, 'Session', return_value=self.session): with patch.multiple(self.config, usedb=True, preferdb=True): @@ -1243,7 +1389,19 @@ class TestOrderView(WebTestCase): allowed = self.config.get_bool('sideshow.orders.allow_unknown_products', session=self.session) self.assertIsNone(allowed) - self.assertEqual(self.session.query(model.Setting).count(), 0) + self.assertEqual(self.session.query(model.Setting).count(), 4) + discounts = view.get_dept_item_discounts() + self.assertEqual(len(discounts), 2) + self.assertEqual(discounts[0], { + 'department_id': '5', + 'department_name': 'Bulk', + 'default_item_discount': '15', + }) + self.assertEqual(discounts[1], { + 'department_id': '6', + 'department_name': 'Produce', + 'default_item_discount': '5', + }) # fetch initial page response = view.configure() @@ -1253,13 +1411,18 @@ class TestOrderView(WebTestCase): allowed = self.config.get_bool('sideshow.orders.allow_unknown_products', session=self.session) self.assertIsNone(allowed) - self.assertEqual(self.session.query(model.Setting).count(), 0) + self.assertEqual(self.session.query(model.Setting).count(), 4) # post new settings with patch.multiple(self.request, create=True, method='POST', POST={ 'sideshow.orders.allow_unknown_products': 'true', + 'dept_item_discounts': json.dumps([{ + 'department_id': '5', + 'department_name': 'Grocery', + 'default_item_discount': 10, + }]) }): response = view.configure() self.assertIsInstance(response, HTTPFound) @@ -1268,17 +1431,17 @@ class TestOrderView(WebTestCase): session=self.session) self.assertTrue(allowed) self.assertTrue(self.session.query(model.Setting).count() > 1) + discounts = view.get_dept_item_discounts() + self.assertEqual(len(discounts), 1) + self.assertEqual(discounts[0], { + 'department_id': '5', + 'department_name': 'Grocery', + 'default_item_discount': '10', + }) class OrderItemViewTestMixin: - def test_common_order_handler(self): - view = self.make_view() - handler = view.order_handler - self.assertIsInstance(handler, OrderHandler) - handler2 = view.get_order_handler() - self.assertIs(handler2, handler) - def test_common_get_fallback_templates(self): view = self.make_view() @@ -1294,18 +1457,29 @@ class OrderItemViewTestMixin: def test_common_configure_grid(self): model = self.app.model view = self.make_view() - grid = view.make_grid(model_class=model.OrderItem) - self.assertNotIn('order_id', grid.linked_columns) - view.configure_grid(grid) - self.assertIn('order_id', grid.linked_columns) - def test_common_render_order_id(self): + # store_id is removed by default + grid = view.make_grid(model_class=model.OrderItem) + grid.append('store_id') + self.assertIn('store_id', grid.columns) + view.configure_grid(grid) + self.assertNotIn('store_id', grid.columns) + + # store_id is shown if configured + self.config.setdefault('sideshow.orders.expose_store_id', 'true') + grid = view.make_grid(model_class=model.OrderItem) + grid.append('store_id') + self.assertIn('store_id', grid.columns) + view.configure_grid(grid) + self.assertIn('store_id', grid.columns) + + def test_common_render_order_attr(self): model = self.app.model view = self.make_view() order = model.Order(order_id=42) item = model.OrderItem() order.items.append(item) - self.assertEqual(view.render_order_id(item, None, None), 42) + self.assertEqual(view.render_order_attr(item, 'order_id', None), 42) def test_common_render_status_code(self): enum = self.app.enum diff --git a/tests/web/views/test_stores.py b/tests/web/views/test_stores.py new file mode 100644 index 0000000..ab69171 --- /dev/null +++ b/tests/web/views/test_stores.py @@ -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'))