feat: add basic "create order" feature, docs, tests

just the package API docs so far, narrative will come later
This commit is contained in:
Lance Edgar 2025-01-06 17:03:41 -06:00
parent 89265f0240
commit ef07d30a85
86 changed files with 7749 additions and 35 deletions

25
src/sideshow/__init__.py Normal file
View file

@ -0,0 +1,25 @@
# -*- 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 - Case/Special Order Tracker
"""

View file

View file

@ -0,0 +1,471 @@
# -*- 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/>.
#
################################################################################
"""
New Order Batch Handler
"""
import datetime
import decimal
from wuttjamaican.batch import BatchHandler
from sideshow.db.model import NewOrderBatch
class NewOrderBatchHandler(BatchHandler):
"""
The :term:`batch handler` for New Order Batches.
This is responsible for business logic around the creation of new
:term:`orders <order>`. A
:class:`~sideshow.db.model.batch.neworder.NewOrderBatch` tracks
all user input until they "submit" (execute) at which point an
:class:`~sideshow.db.model.orders.Order` is created.
"""
model_class = NewOrderBatch
def set_pending_customer(self, batch, data):
"""
Set (add or update) pending customer info for the batch.
This will clear the
:attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.customer_id`
and set the
:attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.pending_customer`,
creating a new record if needed. It then updates the pending
customer record per the given ``data``.
:param batch:
:class:`~sideshow.db.model.batch.neworder.NewOrderBatch`
to be updated.
:param data: Dict of field data for the
:class:`~sideshow.db.model.customers.PendingCustomer`
record.
"""
model = self.app.model
enum = self.app.enum
# remove customer account if set
batch.customer_id = None
# create pending customer if needed
pending = batch.pending_customer
if not pending:
kw = dict(data)
kw.setdefault('status', enum.PendingCustomerStatus.PENDING)
pending = model.PendingCustomer(**kw)
batch.pending_customer = pending
# update pending customer
if 'first_name' in data:
pending.first_name = data['first_name']
if 'last_name' in data:
pending.last_name = data['last_name']
if 'full_name' in data:
pending.full_name = data['full_name']
elif 'first_name' in data or 'last_name' in data:
pending.full_name = self.app.make_full_name(data.get('first_name'),
data.get('last_name'))
if 'phone_number' in data:
pending.phone_number = data['phone_number']
if 'email_address' in data:
pending.email_address = data['email_address']
# update batch per pending customer
batch.customer_name = pending.full_name
batch.phone_number = pending.phone_number
batch.email_address = pending.email_address
def add_pending_product(self, batch, pending_info,
order_qty, order_uom):
"""
Add a new row to the batch, for the given "pending" product
and order quantity.
See also :meth:`set_pending_product()` to update an existing row.
:param batch:
:class:`~sideshow.db.model.batch.neworder.NewOrderBatch` to
which the row should be added.
:param pending_info: Dict of kwargs to use when constructing a
new :class:`~sideshow.db.model.products.PendingProduct`.
:param order_qty: Quantity of the product to be added to the
order.
:param order_uom: UOM for the order quantity; must be a code
from :data:`~sideshow.enum.ORDER_UOM`.
:returns:
:class:`~sideshow.db.model.batch.neworder.NewOrderBatchRow`
which was added to the batch.
"""
model = self.app.model
enum = self.app.enum
session = self.app.get_session(batch)
# make new pending product
kw = dict(pending_info)
kw.setdefault('status', enum.PendingProductStatus.PENDING)
product = model.PendingProduct(**kw)
session.add(product)
session.flush()
# nb. this may convert float to decimal etc.
session.refresh(product)
# make/add new row, w/ pending product
row = self.make_row(pending_product=product,
order_qty=order_qty, order_uom=order_uom)
self.add_row(batch, row)
session.add(row)
session.flush()
return row
def set_pending_product(self, row, data):
"""
Set (add or update) pending product info for the given batch row.
This will clear the
:attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.product_id`
and set the
:attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.pending_product`,
creating a new record if needed. It then updates the pending
product record per the given ``data``, and finally calls
:meth:`refresh_row()`.
Note that this does not update order quantity for the item.
See also :meth:`add_pending_product()` to add a new row
instead of updating.
:param row:
:class:`~sideshow.db.model.batch.neworder.NewOrderBatchRow`
to be updated.
:param data: Dict of field data for the
:class:`~sideshow.db.model.products.PendingProduct` record.
"""
model = self.app.model
enum = self.app.enum
session = self.app.get_session(row)
# values for these fields can be used as-is
simple_fields = [
'scancode',
'brand_name',
'description',
'size',
'weighed',
'department_id',
'department_name',
'special_order',
'vendor_name',
'vendor_item_code',
'notes',
'unit_cost',
'case_size',
'case_cost',
'unit_price_reg',
]
# clear true product id
row.product_id = None
# make pending product if needed
product = row.pending_product
if not product:
kw = dict(data)
kw.setdefault('status', enum.PendingProductStatus.PENDING)
product = model.PendingProduct(**kw)
session.add(product)
row.pending_product = product
session.flush()
# update pending product
for field in simple_fields:
if field in data:
setattr(product, field, data[field])
# nb. this may convert float to decimal etc.
session.flush()
session.refresh(product)
# refresh per new info
self.refresh_row(row)
def refresh_row(self, row, now=None):
"""
Refresh all data for the row. This is called when adding a
new row to the batch, or anytime the row is updated (e.g. when
changing order quantity).
This calls one of the following to update product-related
attributes for the row:
* :meth:`refresh_row_from_pending_product()`
* :meth:`refresh_row_from_true_product()`
It then re-calculates the row's
:attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.total_price`
and updates the batch accordingly.
It also sets the row
:attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.status_code`.
"""
enum = self.app.enum
row.status_code = None
row.status_text = None
# ensure product
if not row.product_id and not row.pending_product:
row.status_code = row.STATUS_MISSING_PRODUCT
return
# ensure order qty/uom
if not row.order_qty or not row.order_uom:
row.status_code = row.STATUS_MISSING_ORDER_QTY
return
# update product attrs on row
if row.product_id:
self.refresh_row_from_true_product(row)
else:
self.refresh_row_from_pending_product(row)
# we need to know if total price changes
old_total = row.total_price
# update quoted price
row.unit_price_quoted = None
row.case_price_quoted = None
if row.unit_price_sale is not None and (
not row.sale_ends
or row.sale_ends > (now or datetime.datetime.now())):
row.unit_price_quoted = row.unit_price_sale
else:
row.unit_price_quoted = row.unit_price_reg
if row.unit_price_quoted is not None and row.case_size:
row.case_price_quoted = row.unit_price_quoted * row.case_size
# update row total price
row.total_price = None
if row.order_uom == enum.ORDER_UOM_CASE:
if row.unit_price_quoted is not None and row.case_size is not None:
row.total_price = row.unit_price_quoted * row.case_size * row.order_qty
else: # ORDER_UOM_UNIT (or similar)
if row.unit_price_quoted is not None:
row.total_price = row.unit_price_quoted * row.order_qty
if row.total_price is not None:
row.total_price = decimal.Decimal(f'{row.total_price:0.2f}')
# update batch if total price changed
if row.total_price != old_total:
batch = row.batch
batch.total_price = ((batch.total_price or 0)
+ (row.total_price or 0)
- (old_total or 0))
# all ok
row.status_code = row.STATUS_OK
def refresh_row_from_pending_product(self, row):
"""
Update product-related attributes on the row, from its
:attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.pending_product`
record.
This is called automatically from :meth:`refresh_row()`.
"""
product = row.pending_product
row.product_scancode = product.scancode
row.product_brand = product.brand_name
row.product_description = product.description
row.product_size = product.size
row.product_weighed = product.weighed
row.department_id = product.department_id
row.department_name = product.department_name
row.special_order = product.special_order
row.case_size = product.case_size
row.unit_cost = product.unit_cost
row.unit_price_reg = product.unit_price_reg
def refresh_row_from_true_product(self, row):
"""
Update product-related attributes on the row, from its "true"
product record indicated by
:attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.product_id`.
This is called automatically from :meth:`refresh_row()`.
There is no default logic here; subclass must implement as
needed.
"""
def remove_row(self, row):
"""
Remove a row from its batch.
This also will update the batch
:attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.total_price`
accordingly.
"""
if row.total_price:
batch = row.batch
batch.total_price = (batch.total_price or 0) - row.total_price
super().remove_row(row)
def do_delete(self, batch, user, **kwargs):
"""
Delete the given batch entirely.
If the batch has a
:attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.pending_customer`
record, that is deleted also.
"""
# maybe delete pending customer record, if it only exists for
# sake of this batch
if batch.pending_customer:
if len(batch.pending_customer.new_order_batches) == 1:
# TODO: check for past orders too
session = self.app.get_session(batch)
session.delete(batch.pending_customer)
# continue with normal deletion
super().do_delete(batch, user, **kwargs)
def why_not_execute(self, batch, **kwargs):
"""
By default this checks to ensure the batch has a customer and
at least one item.
"""
if not batch.customer_id and not batch.pending_customer:
return "Must assign the customer"
rows = self.get_effective_rows(batch)
if not rows:
return "Must add at least one valid item"
def get_effective_rows(self, batch):
"""
Only rows with
:attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.STATUS_OK`
are "effective" - i.e. rows with other status codes will not
be created as proper order items.
"""
return [row for row in batch.rows
if row.status_code == row.STATUS_OK]
def execute(self, batch, user=None, progress=None, **kwargs):
"""
By default, this will call :meth:`make_new_order()` and return
the new :class:`~sideshow.db.model.orders.Order` instance.
Note that callers should use
:meth:`~wuttjamaican:wuttjamaican.batch.BatchHandler.do_execute()`
instead, which calls this method automatically.
"""
rows = self.get_effective_rows(batch)
order = self.make_new_order(batch, rows, user=user, progress=progress, **kwargs)
return order
def make_new_order(self, batch, rows, user=None, progress=None, **kwargs):
"""
Create a new :term:`order` from the batch data.
This is called automatically from :meth:`execute()`.
:param batch:
:class:`~sideshow.db.model.batch.neworder.NewOrderBatch`
instance.
:param rows: List of effective rows for the batch, i.e. which
rows should be converted to :term:`order items <order
item>`.
:returns: :class:`~sideshow.db.model.orders.Order` instance.
"""
model = self.app.model
enum = self.app.enum
session = self.app.get_session(batch)
batch_fields = [
'store_id',
'customer_id',
'pending_customer',
'customer_name',
'phone_number',
'email_address',
'total_price',
]
row_fields = [
'pending_product_uuid',
'product_scancode',
'product_brand',
'product_description',
'product_size',
'product_weighed',
'department_id',
'department_name',
'case_size',
'order_qty',
'order_uom',
'unit_cost',
'unit_price_quoted',
'case_price_quoted',
'unit_price_reg',
'unit_price_sale',
'sale_ends',
# 'discount_percent',
'total_price',
'special_order',
]
# make order
kw = dict([(field, getattr(batch, field))
for field in batch_fields])
kw['order_id'] = batch.id
kw['created_by'] = user
order = model.Order(**kw)
session.add(order)
session.flush()
def convert(row, i):
# make order item
kw = dict([(field, getattr(row, field))
for field in row_fields])
item = model.OrderItem(**kw)
order.items.append(item)
# set item status
item.status_code = enum.ORDER_ITEM_STATUS_INITIATED
self.app.progress_loop(convert, rows, progress,
message="Converting batch rows to order items")
session.flush()
return order

View file

@ -21,17 +21,16 @@
#
################################################################################
"""
Pyramid event subscribers
Sideshow - command line interface
See also :doc:`/narr/cli/index`.
This (``sideshow.cli``) namespace exposes the following:
* :data:`~sideshow.cli.base.sideshow_typer` (top-level command)
"""
import sideshow
from .base import sideshow_typer
def add_sideshow_to_context(event):
renderer_globals = event
renderer_globals['sideshow'] = sideshow
def includeme(config):
config.include('wuttaweb.subscribers')
config.add_subscriber(add_sideshow_to_context, 'pyramid.events.BeforeRender')
# nb. must bring in all modules for discovery to work
from . import install

40
src/sideshow/cli/base.py Normal file
View file

@ -0,0 +1,40 @@
# -*- 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 - core command logic
See also :doc:`/narr/cli/index`.
.. data:: sideshow_typer
This is the top-level ``sideshow`` :term:`command`, using the Typer
framework.
"""
from wuttjamaican.cli import make_typer
sideshow_typer = make_typer(
name='sideshow',
help="Sideshow -- Case/Special Order Tracker"
)

View file

@ -21,18 +21,12 @@
#
################################################################################
"""
Sideshow CLI
See also: :ref:`sideshow-install`
"""
import typer
from wuttjamaican.cli import make_typer
sideshow_typer = make_typer(
name='sideshow',
help="Sideshow -- Case/Special Order Tracker"
)
from .base import sideshow_typer
@sideshow_typer.command()

View file

@ -21,7 +21,7 @@
#
################################################################################
"""
Sideshow config extensions
Sideshow config extension
"""
from wuttjamaican.conf import WuttaConfigExtension
@ -29,18 +29,22 @@ from wuttjamaican.conf import WuttaConfigExtension
class SideshowConfig(WuttaConfigExtension):
"""
Config extension for Sideshow
Config extension for Sideshow.
This establishes some config defaults specific to Sideshow.
"""
key = 'sideshow'
def configure(self, config):
""" """
# app info
config.setdefault(f'{config.appname}.app_title', "Sideshow")
config.setdefault(f'{config.appname}.app_dist', "Sideshow")
# app model
# app model, enum
config.setdefault(f'{config.appname}.model_spec', 'sideshow.db.model')
config.setdefault(f'{config.appname}.enum_spec', 'sideshow.enum')
# web app menu
config.setdefault(f'{config.appname}.web.menus.handler_spec',

View file

@ -0,0 +1,203 @@
"""initial order tables
Revision ID: 7a6df83afbd4
Revises:
Create Date: 2024-12-30 18:53:51.358163
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
import wuttjamaican.db.util
# revision identifiers, used by Alembic.
revision: str = '7a6df83afbd4'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = ('sideshow',)
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# enums
sa.Enum('PENDING', 'READY', 'RESOLVED', name='pendingcustomerstatus').create(op.get_bind())
sa.Enum('PENDING', 'READY', 'RESOLVED', name='pendingproductstatus').create(op.get_bind())
# sideshow_pending_customer
op.create_table('sideshow_pending_customer',
sa.Column('uuid', wuttjamaican.db.util.UUID(), nullable=False),
sa.Column('customer_id', sa.String(length=20), nullable=True),
sa.Column('full_name', sa.String(length=100), nullable=True),
sa.Column('first_name', sa.String(length=50), nullable=True),
sa.Column('last_name', sa.String(length=50), nullable=True),
sa.Column('phone_number', sa.String(length=20), nullable=True),
sa.Column('email_address', sa.String(length=255), nullable=True),
sa.Column('status', postgresql.ENUM('PENDING', 'READY', 'RESOLVED', name='pendingcustomerstatus', create_type=False), nullable=False),
sa.Column('created', sa.DateTime(timezone=True), nullable=False),
sa.Column('created_by_uuid', wuttjamaican.db.util.UUID(), nullable=False),
sa.ForeignKeyConstraint(['created_by_uuid'], ['user.uuid'], name=op.f('fk_sideshow_pending_customer_created_by_uuid_user')),
sa.PrimaryKeyConstraint('uuid', name=op.f('pk_sideshow_pending_customer'))
)
# sideshow_pending_product
op.create_table('sideshow_pending_product',
sa.Column('uuid', wuttjamaican.db.util.UUID(), nullable=False),
sa.Column('product_id', sa.String(length=20), nullable=True),
sa.Column('scancode', sa.String(length=14), nullable=True),
sa.Column('department_id', sa.String(length=10), nullable=True),
sa.Column('department_name', sa.String(length=30), nullable=True),
sa.Column('brand_name', sa.String(length=100), nullable=True),
sa.Column('description', sa.String(length=255), nullable=True),
sa.Column('size', sa.String(length=30), nullable=True),
sa.Column('weighed', sa.Boolean(), nullable=True),
sa.Column('vendor_name', sa.String(length=50), nullable=True),
sa.Column('vendor_item_code', sa.String(length=20), nullable=True),
sa.Column('unit_cost', sa.Numeric(precision=9, scale=5), nullable=True),
sa.Column('case_size', sa.Numeric(precision=9, scale=4), nullable=True),
sa.Column('unit_price_reg', sa.Numeric(precision=8, scale=3), nullable=True),
sa.Column('special_order', sa.Boolean(), nullable=True),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('status', postgresql.ENUM('PENDING', 'READY', 'RESOLVED', name='pendingproductstatus', create_type=False), nullable=False),
sa.Column('created', sa.DateTime(timezone=True), nullable=False),
sa.Column('created_by_uuid', wuttjamaican.db.util.UUID(), nullable=False),
sa.ForeignKeyConstraint(['created_by_uuid'], ['user.uuid'], name=op.f('fk_sideshow_pending_product_created_by_uuid_user')),
sa.PrimaryKeyConstraint('uuid', name=op.f('pk_sideshow_pending_product'))
)
# sideshow_order
op.create_table('sideshow_order',
sa.Column('uuid', wuttjamaican.db.util.UUID(), nullable=False),
sa.Column('order_id', sa.Integer(), nullable=False),
sa.Column('store_id', sa.String(length=10), nullable=True),
sa.Column('customer_id', sa.String(length=20), nullable=True),
sa.Column('pending_customer_uuid', wuttjamaican.db.util.UUID(), nullable=True),
sa.Column('customer_name', sa.String(length=100), nullable=True),
sa.Column('phone_number', sa.String(length=20), nullable=True),
sa.Column('email_address', sa.String(length=255), nullable=True),
sa.Column('total_price', sa.Numeric(precision=10, scale=3), nullable=True),
sa.Column('created', sa.DateTime(timezone=True), nullable=False),
sa.Column('created_by_uuid', wuttjamaican.db.util.UUID(), nullable=False),
sa.ForeignKeyConstraint(['pending_customer_uuid'], ['sideshow_pending_customer.uuid'], name=op.f('fk_order_pending_customer_uuid_pending_customer')),
sa.ForeignKeyConstraint(['created_by_uuid'], ['user.uuid'], name=op.f('fk_order_created_by_uuid_user')),
sa.PrimaryKeyConstraint('uuid', name=op.f('pk_order'))
)
# sideshow_order_item
op.create_table('sideshow_order_item',
sa.Column('uuid', wuttjamaican.db.util.UUID(), nullable=False),
sa.Column('order_uuid', wuttjamaican.db.util.UUID(), nullable=False),
sa.Column('sequence', sa.Integer(), nullable=False),
sa.Column('product_id', sa.String(length=20), nullable=True),
sa.Column('pending_product_uuid', wuttjamaican.db.util.UUID(), nullable=True),
sa.Column('product_scancode', sa.String(length=14), nullable=True),
sa.Column('product_brand', sa.String(length=100), nullable=True),
sa.Column('product_description', sa.String(length=255), nullable=True),
sa.Column('product_size', sa.String(length=30), nullable=True),
sa.Column('product_weighed', sa.Boolean(), nullable=True),
sa.Column('department_id', sa.String(length=10), nullable=True),
sa.Column('department_name', sa.String(length=30), nullable=True),
sa.Column('special_order', sa.Boolean(), nullable=True),
sa.Column('case_size', sa.Numeric(precision=10, scale=4), nullable=True),
sa.Column('order_qty', sa.Numeric(precision=10, scale=4), nullable=False),
sa.Column('order_uom', sa.String(length=10), nullable=False),
sa.Column('unit_cost', sa.Numeric(precision=9, scale=5), nullable=True),
sa.Column('unit_price_reg', sa.Numeric(precision=8, scale=3), nullable=True),
sa.Column('unit_price_sale', sa.Numeric(precision=8, scale=3), nullable=True),
sa.Column('sale_ends', sa.DateTime(timezone=True), nullable=True),
sa.Column('unit_price_quoted', sa.Numeric(precision=8, scale=3), nullable=True),
sa.Column('case_price_quoted', sa.Numeric(precision=8, scale=3), nullable=True),
sa.Column('discount_percent', sa.Numeric(precision=5, scale=3), nullable=True),
sa.Column('total_price', sa.Numeric(precision=8, scale=3), nullable=True),
sa.Column('status_code', sa.Integer(), nullable=False),
sa.Column('paid_amount', sa.Numeric(precision=8, scale=3), nullable=False),
sa.Column('payment_transaction_number', sa.String(length=20), nullable=True),
sa.ForeignKeyConstraint(['order_uuid'], ['sideshow_order.uuid'], name=op.f('fk_sideshow_order_item_order_uuid_order')),
sa.ForeignKeyConstraint(['pending_product_uuid'], ['sideshow_pending_product.uuid'], name=op.f('fk_sideshow_order_item_pending_product_uuid_pending_product')),
sa.PrimaryKeyConstraint('uuid', name=op.f('pk_order_item'))
)
# sideshow_batch_neworder
op.create_table('sideshow_batch_neworder',
sa.Column('uuid', wuttjamaican.db.util.UUID(), nullable=False),
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('description', sa.String(length=255), nullable=True),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('row_count', sa.Integer(), nullable=True),
sa.Column('status_code', sa.Integer(), nullable=True),
sa.Column('status_text', sa.String(length=255), nullable=True),
sa.Column('created', sa.DateTime(timezone=True), nullable=False),
sa.Column('created_by_uuid', wuttjamaican.db.util.UUID(), nullable=False),
sa.Column('executed', sa.DateTime(timezone=True), nullable=True),
sa.Column('executed_by_uuid', wuttjamaican.db.util.UUID(), nullable=True),
sa.Column('store_id', sa.String(length=10), nullable=True),
sa.Column('customer_id', sa.String(length=20), nullable=True),
sa.Column('pending_customer_uuid', wuttjamaican.db.util.UUID(), nullable=True),
sa.Column('customer_name', sa.String(length=100), nullable=True),
sa.Column('phone_number', sa.String(length=20), nullable=True),
sa.Column('email_address', sa.String(length=255), nullable=True),
sa.Column('total_price', sa.Numeric(precision=10, scale=3), nullable=True),
sa.ForeignKeyConstraint(['created_by_uuid'], ['user.uuid'], name=op.f('fk_sideshow_batch_neworder_created_by_uuid_user')),
sa.ForeignKeyConstraint(['executed_by_uuid'], ['user.uuid'], name=op.f('fk_sideshow_batch_neworder_executed_by_uuid_user')),
sa.ForeignKeyConstraint(['pending_customer_uuid'], ['sideshow_pending_customer.uuid'], name=op.f('fk_sideshow_batch_neworder_pending_customer_uuid_pending_customer')),
sa.PrimaryKeyConstraint('uuid', name=op.f('pk_sideshow_batch_neworder'))
)
# sideshow_batch_neworder_row
op.create_table('sideshow_batch_neworder_row',
sa.Column('uuid', wuttjamaican.db.util.UUID(), nullable=False),
sa.Column('batch_uuid', wuttjamaican.db.util.UUID(), nullable=False),
sa.Column('sequence', sa.Integer(), nullable=False),
sa.Column('status_text', sa.String(length=255), nullable=True),
sa.Column('modified', sa.DateTime(timezone=True), nullable=True),
sa.Column('pending_product_uuid', wuttjamaican.db.util.UUID(), nullable=True),
sa.Column('product_id', sa.String(length=20), nullable=True),
sa.Column('product_scancode', sa.String(length=14), nullable=True),
sa.Column('product_brand', sa.String(length=100), nullable=True),
sa.Column('product_description', sa.String(length=255), nullable=True),
sa.Column('product_size', sa.String(length=30), nullable=True),
sa.Column('product_weighed', sa.Boolean(), nullable=True),
sa.Column('department_id', sa.String(length=10), nullable=True),
sa.Column('department_name', sa.String(length=30), nullable=True),
sa.Column('special_order', sa.Boolean(), nullable=True),
sa.Column('case_size', sa.Numeric(precision=10, scale=4), nullable=True),
sa.Column('order_qty', sa.Numeric(precision=10, scale=4), nullable=False),
sa.Column('order_uom', sa.String(length=10), nullable=False),
sa.Column('unit_cost', sa.Numeric(precision=9, scale=5), nullable=True),
sa.Column('unit_price_reg', sa.Numeric(precision=8, scale=3), nullable=True),
sa.Column('unit_price_sale', sa.Numeric(precision=8, scale=3), nullable=True),
sa.Column('sale_ends', sa.DateTime(timezone=True), nullable=True),
sa.Column('unit_price_quoted', sa.Numeric(precision=8, scale=3), nullable=True),
sa.Column('case_price_quoted', sa.Numeric(precision=8, scale=3), nullable=True),
sa.Column('discount_percent', sa.Numeric(precision=5, scale=3), nullable=True),
sa.Column('total_price', sa.Numeric(precision=8, scale=3), nullable=True),
sa.Column('status_code', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['batch_uuid'], ['sideshow_batch_neworder.uuid'], name=op.f('fk_sideshow_batch_neworder_row_batch_uuid_batch_neworder')),
sa.ForeignKeyConstraint(['pending_product_uuid'], ['sideshow_pending_product.uuid'], name=op.f('fk_sideshow_batch_neworder_row_pending_product_uuid_pending_product')),
sa.PrimaryKeyConstraint('uuid', name=op.f('pk_sideshow_batch_neworder_row'))
)
def downgrade() -> None:
# sideshow_batch_neworder*
op.drop_table('sideshow_batch_neworder_row')
op.drop_table('sideshow_batch_neworder')
# sideshow_order_item
op.drop_table('sideshow_order_item')
# sideshow_order
op.drop_table('sideshow_order')
# sideshow_pending_product
op.drop_table('sideshow_pending_product')
# sideshow_pending_customer
op.drop_table('sideshow_pending_customer')
# enums
sa.Enum('PENDING', 'READY', 'RESOLVED', name='pendingproductstatus').drop(op.get_bind())
sa.Enum('PENDING', 'READY', 'RESOLVED', name='pendingcustomerstatus').drop(op.get_bind())

View file

@ -22,9 +22,32 @@
################################################################################
"""
Sideshow data models
This is the default :term:`app model` module for Sideshow.
This namespace exposes everything from
:mod:`wuttjamaican:wuttjamaican.db.model`, plus the following.
Primary :term:`data models <data model>`:
* :class:`~sideshow.db.model.orders.Order`
* :class:`~sideshow.db.model.orders.OrderItem`
* :class:`~sideshow.db.model.customers.PendingCustomer`
* :class:`~sideshow.db.model.products.PendingProduct`
And the :term:`batch` models:
* :class:`~sideshow.db.model.batch.neworder.NewOrderBatch`
* :class:`~sideshow.db.model.batch.neworder.NewOrderBatchRow`
"""
# bring in all of wutta
from wuttjamaican.db.model import *
# TODO: import other/custom models here...
# sideshow models
from .customers import PendingCustomer
from .products import PendingProduct
from .orders import Order, OrderItem
# batch models
from .batch.neworder import NewOrderBatch, NewOrderBatchRow

View file

View file

@ -0,0 +1,310 @@
# -*- 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/>.
#
################################################################################
"""
Data models for New Order Batch
* :class:`NewOrderBatch`
* :class:`NewOrderBatchRow`
"""
import sqlalchemy as sa
from sqlalchemy import orm
from sqlalchemy.ext.declarative import declared_attr
from wuttjamaican.db import model
class NewOrderBatch(model.BatchMixin, model.Base):
"""
:term:`Batch <batch>` used for entering new :term:`orders <order>`
into the system. Each batch ultimately becomes an
:class:`~sideshow.db.model.orders.Order`.
See also :class:`~sideshow.batch.neworder.NewOrderBatchHandler`
which is the default :term:`batch handler` for this :term:`batch
type`.
Generic batch attributes (undocumented below) are inherited from
:class:`~wuttjamaican:wuttjamaican.db.model.batch.BatchMixin`.
"""
__tablename__ = 'sideshow_batch_neworder'
__batchrow_class__ = 'NewOrderBatchRow'
batch_type = 'neworder'
"""
Official :term:`batch type` key.
"""
@declared_attr
def __table_args__(cls):
return cls.__default_table_args__() + (
sa.ForeignKeyConstraint(['pending_customer_uuid'], ['sideshow_pending_customer.uuid']),
)
STATUS_OK = 1
STATUS = {
STATUS_OK : "ok",
}
store_id = sa.Column(sa.String(length=10), nullable=True, doc="""
ID of the store to which the order pertains, if applicable.
""")
customer_id = sa.Column(sa.String(length=20), nullable=True, doc="""
ID of the proper customer account to which the order pertains, if
applicable.
This will be set only when an "existing" customer account can be
selected for the order. See also :attr:`pending_customer`.
""")
pending_customer_uuid = sa.Column(model.UUID(), nullable=True)
@declared_attr
def pending_customer(cls):
return orm.relationship(
'PendingCustomer',
back_populates='new_order_batches',
doc="""
Reference to the
:class:`~sideshow.db.model.customers.PendingCustomer`
record for the order, if applicable.
This is set only when making an order for a "new /
unknown" customer. See also :attr:`customer_id`.
""")
customer_name = sa.Column(sa.String(length=100), nullable=True, doc="""
Name for the customer account.
""")
phone_number = sa.Column(sa.String(length=20), nullable=True, doc="""
Phone number for the customer.
""")
email_address = sa.Column(sa.String(length=255), nullable=True, doc="""
Email address for the customer.
""")
total_price = sa.Column(sa.Numeric(precision=10, scale=3), nullable=True, doc="""
Full price (not including tax etc.) for all items on the order.
""")
class NewOrderBatchRow(model.BatchRowMixin, model.Base):
"""
Row of data within a :class:`NewOrderBatch`. Each row ultimately
becomes an :class:`~sideshow.db.model.orders.OrderItem`.
Generic row attributes (undocumented below) are inherited from
:class:`~wuttjamaican:wuttjamaican.db.model.batch.BatchRowMixin`.
"""
__tablename__ = 'sideshow_batch_neworder_row'
__batch_class__ = NewOrderBatch
@declared_attr
def __table_args__(cls):
return cls.__default_table_args__() + (
sa.ForeignKeyConstraint(['pending_product_uuid'], ['sideshow_pending_product.uuid']),
)
STATUS_OK = 1
"""
This is the default value for :attr:`status_code`. All rows are
considered "OK" if they have either a :attr:`product_id` or
:attr:`pending_product`.
"""
STATUS_MISSING_PRODUCT = 2
"""
Status code indicating the row has no :attr:`product_id` or
:attr:`pending_product` set.
"""
STATUS_MISSING_ORDER_QTY = 3
"""
Status code indicating the row has no :attr:`order_qty` and/or
:attr:`order_uom` set.
"""
STATUS = {
STATUS_OK : "ok",
STATUS_MISSING_PRODUCT : "missing product",
STATUS_MISSING_ORDER_QTY : "missing order qty/uom",
}
"""
Dict of possible status code -> label options.
"""
product_id = sa.Column(sa.String(length=20), nullable=True, doc="""
ID of the true product which the order item represents, if
applicable.
This will be set only when an "existing" product can be selected
for the order. See also :attr:`pending_product`.
""")
pending_product_uuid = sa.Column(model.UUID(), nullable=True)
@declared_attr
def pending_product(cls):
return orm.relationship(
'PendingProduct',
back_populates='new_order_batch_rows',
doc="""
Reference to the
:class:`~sideshow.db.model.products.PendingProduct` record
for the order item, if applicable.
This is set only when making an order for a "new /
unknown" product. See also :attr:`product_id`.
""")
product_scancode = sa.Column(sa.String(length=14), nullable=True, doc="""
Scancode for the product, as string.
.. note::
This column allows 14 chars, so can store a full GPC with check
digit. However as of writing the actual format used here does
not matter to Sideshow logic; "anything" should work.
That may change eventually, depending on POS integration
scenarios that come up. Maybe a config option to declare
whether check digit should be included or not, etc.
""")
product_brand = sa.Column(sa.String(length=100), nullable=True, doc="""
Brand name for the product - up to 100 chars.
""")
product_description = sa.Column(sa.String(length=255), nullable=True, doc="""
Description for the product - up to 255 chars.
""")
product_size = sa.Column(sa.String(length=30), nullable=True, doc="""
Size of the product, as string - up to 30 chars.
""")
product_weighed = sa.Column(sa.Boolean(), nullable=True, doc="""
Flag indicating the product is sold by weight; default is null.
""")
department_id = sa.Column(sa.String(length=10), nullable=True, doc="""
ID of the department to which the product belongs, if known.
""")
department_name = sa.Column(sa.String(length=30), nullable=True, doc="""
Name of the department to which the product belongs, if known.
""")
special_order = sa.Column(sa.Boolean(), nullable=True, doc="""
Flag indicating the item is a "special order" - e.g. something not
normally carried by the store. Default is null.
""")
case_size = sa.Column(sa.Numeric(precision=10, scale=4), nullable=True, doc="""
Case pack count for the product, if known.
If this is not set, then customer cannot order a "case" of the item.
""")
order_qty = sa.Column(sa.Numeric(precision=10, scale=4), nullable=False, doc="""
Quantity (as decimal) of product being ordered.
This must be interpreted along with :attr:`order_uom` to determine
the *complete* order quantity, e.g. "2 cases".
""")
order_uom = sa.Column(sa.String(length=10), nullable=False, doc="""
Code indicating the unit of measure for product being ordered.
This should be one of the codes from
:data:`~sideshow.enum.ORDER_UOM`.
Sideshow will treat :data:`~sideshow.enum.ORDER_UOM_CASE`
differently but :data:`~sideshow.enum.ORDER_UOM_UNIT` and others
are all treated the same (i.e. "unit" is assumed).
""")
unit_cost = sa.Column(sa.Numeric(precision=9, scale=5), nullable=True, doc="""
Cost of goods amount for one "unit" (not "case") of the product,
as decimal to 4 places.
""")
unit_price_reg = sa.Column(sa.Numeric(precision=8, scale=3), nullable=True, doc="""
Regular price for the item unit. Unless a sale is in effect,
:attr:`unit_price_quoted` will typically match this value.
""")
unit_price_sale = sa.Column(sa.Numeric(precision=8, scale=3), nullable=True, doc="""
Sale price for the item unit, if applicable. If set, then
:attr:`unit_price_quoted` will typically match this value. See
also :attr:`sale_ends`.
""")
sale_ends = sa.Column(sa.DateTime(timezone=True), nullable=True, doc="""
End date/time for the sale in effect, if any.
This is only relevant if :attr:`unit_price_sale` is set.
""")
unit_price_quoted = sa.Column(sa.Numeric(precision=8, scale=3), nullable=True, doc="""
Quoted price for the item unit. This is the "effective" unit
price, which is used to calculate :attr:`total_price`.
This price does *not* reflect the :attr:`discount_percent`. It
normally should match either :attr:`unit_price_reg` or
:attr:`unit_price_sale`.
See also :attr:`case_price_quoted`, if applicable.
""")
case_price_quoted = sa.Column(sa.Numeric(precision=8, scale=3), nullable=True, doc="""
Quoted price for a "case" of the item, if applicable.
This is mostly for display purposes; :attr:`unit_price_quoted` is
used for calculations.
""")
discount_percent = sa.Column(sa.Numeric(precision=5, scale=3), nullable=True, doc="""
Discount percent to apply when calculating :attr:`total_price`, if
applicable.
""")
total_price = sa.Column(sa.Numeric(precision=8, scale=3), nullable=True, doc="""
Full price (not including tax etc.) which the customer is quoted
for the order item.
This is calculated using values from:
* :attr:`unit_price_quoted`
* :attr:`order_qty`
* :attr:`order_uom`
* :attr:`case_size`
* :attr:`discount_percent`
""")
def __str__(self):
return str(self.pending_product or self.product_description or "")

View file

@ -0,0 +1,112 @@
# -*- 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/>.
#
################################################################################
"""
Data models for Customers
"""
import datetime
import sqlalchemy as sa
from sqlalchemy import orm
from wuttjamaican.db import model
from sideshow.enum import PendingCustomerStatus
class PendingCustomer(model.Base):
"""
A "pending" customer record, used when entering an :term:`order`
for new/unknown customer.
"""
__tablename__ = 'sideshow_pending_customer'
uuid = model.uuid_column()
customer_id = sa.Column(sa.String(length=20), nullable=True, doc="""
ID of the proper customer account associated with this record, if
applicable.
""")
full_name = sa.Column(sa.String(length=100), nullable=True, doc="""
Full display name for the customer account.
""")
first_name = sa.Column(sa.String(length=50), nullable=True, doc="""
First name of the customer.
""")
last_name = sa.Column(sa.String(length=50), nullable=True, doc="""
Last name of the customer.
""")
phone_number = sa.Column(sa.String(length=20), nullable=True, doc="""
Phone number for the customer.
""")
email_address = sa.Column(sa.String(length=255), nullable=True, doc="""
Email address for the customer.
""")
status = sa.Column(sa.Enum(PendingCustomerStatus), nullable=False, doc="""
Status code for the customer record.
""")
created = sa.Column(sa.DateTime(timezone=True), nullable=False,
default=datetime.datetime.now, doc="""
Timestamp when the customer record was created.
""")
created_by_uuid = model.uuid_fk_column('user.uuid', nullable=False)
created_by = orm.relationship(
model.User,
cascade_backrefs=False,
doc="""
Reference to the
:class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who
created the customer record.
""")
orders = orm.relationship(
'Order',
order_by='Order.order_id.desc()',
cascade_backrefs=False,
back_populates='pending_customer',
doc="""
List of :class:`~sideshow.db.model.orders.Order` records
associated with this customer.
""")
new_order_batches = orm.relationship(
'NewOrderBatch',
order_by='NewOrderBatch.id.desc()',
cascade_backrefs=False,
back_populates='pending_customer',
doc="""
List of
:class:`~sideshow.db.model.batch.neworder.NewOrderBatch`
records associated with this customer.
""")
def __str__(self):
return self.full_name or ""

View file

@ -0,0 +1,314 @@
# -*- 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/>.
#
################################################################################
"""
Data models for Orders
"""
import datetime
import sqlalchemy as sa
from sqlalchemy import orm
from sqlalchemy.ext.orderinglist import ordering_list
from wuttjamaican.db import model
class Order(model.Base):
"""
Represents an :term:`order` for a customer. Each order has one or
more :attr:`items`.
Usually, orders are created by way of a
:class:`~sideshow.db.model.batch.neworder.NewOrderBatch`.
"""
__tablename__ = 'sideshow_order'
# TODO: this feels a bit hacky yet but it does avoid problems
# showing the Orders grid for a PendingCustomer
__colanderalchemy_config__ = {
'excludes': ['items'],
}
uuid = model.uuid_column()
order_id = sa.Column(sa.Integer(), nullable=False, doc="""
Unique ID for the order.
When the order is created from New Order Batch, this order ID will
match the batch ID.
""")
store_id = sa.Column(sa.String(length=10), nullable=True, doc="""
ID of the store to which the order pertains, if applicable.
""")
customer_id = sa.Column(sa.String(length=20), nullable=True, doc="""
ID of the proper customer account to which the order pertains, if
applicable.
This will be set only when an "existing" customer account can be
assigned for the order. See also :attr:`pending_customer`.
""")
pending_customer_uuid = model.uuid_fk_column('sideshow_pending_customer.uuid', nullable=True)
pending_customer = orm.relationship(
'PendingCustomer',
cascade_backrefs=False,
back_populates='orders',
doc="""
Reference to the
:class:`~sideshow.db.model.customers.PendingCustomer` record
for the order, if applicable.
This is set only when the order is for a "new / unknown"
customer. See also :attr:`customer_id`.
""")
customer_name = sa.Column(sa.String(length=100), nullable=True, doc="""
Name for the customer account.
""")
phone_number = sa.Column(sa.String(length=20), nullable=True, doc="""
Phone number for the customer.
""")
email_address = sa.Column(sa.String(length=255), nullable=True, doc="""
Email address for the customer.
""")
total_price = sa.Column(sa.Numeric(precision=10, scale=3), nullable=True, doc="""
Full price (not including tax etc.) for all items on the order.
""")
created = sa.Column(sa.DateTime(timezone=True), nullable=False, default=datetime.datetime.now, doc="""
Timestamp when the order was created.
If the order is created via New Order Batch, this will match the
batch execution timestamp.
""")
created_by_uuid = model.uuid_fk_column('user.uuid', nullable=False)
created_by = orm.relationship(
model.User,
cascade_backrefs=False,
doc="""
Reference to the
:class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who
created the order.
""")
items = orm.relationship(
'OrderItem',
collection_class=ordering_list('sequence', count_from=1),
cascade='all, delete-orphan',
cascade_backrefs=False,
back_populates='order',
doc="""
List of :class:`OrderItem` records belonging to the order.
""")
def __str__(self):
return str(self.order_id)
class OrderItem(model.Base):
"""
Represents an :term:`order item` within an :class:`Order`.
Usually these are created from
:class:`~sideshow.db.model.batch.neworder.NewOrderBatchRow`
records.
"""
__tablename__ = 'sideshow_order_item'
uuid = model.uuid_column()
order_uuid = model.uuid_fk_column('sideshow_order.uuid', nullable=False)
order = orm.relationship(
Order,
cascade_backrefs=False,
back_populates='items',
doc="""
Reference to the :class:`Order` to which the item belongs.
""")
sequence = sa.Column(sa.Integer(), nullable=False, doc="""
1-based numeric sequence for the item, i.e. its line number within
the order.
""")
product_id = sa.Column(sa.String(length=20), nullable=True, doc="""
ID of the true product which the order item represents, if
applicable.
This will be set only when an "existing" product can be selected
for the order. See also :attr:`pending_product`.
""")
pending_product_uuid = model.uuid_fk_column('sideshow_pending_product.uuid', nullable=True)
pending_product = orm.relationship(
'PendingProduct',
cascade_backrefs=False,
back_populates='order_items',
doc="""
Reference to the
:class:`~sideshow.db.model.products.PendingProduct` record for
the order item, if applicable.
This is set only when the order item is for a "new / unknown"
product. See also :attr:`product_id`.
""")
product_scancode = sa.Column(sa.String(length=14), nullable=True, doc="""
Scancode for the product, as string.
.. note::
This column allows 14 chars, so can store a full GPC with check
digit. However as of writing the actual format used here does
not matter to Sideshow logic; "anything" should work.
That may change eventually, depending on POS integration
scenarios that come up. Maybe a config option to declare
whether check digit should be included or not, etc.
""")
product_brand = sa.Column(sa.String(length=100), nullable=True, doc="""
Brand name for the product - up to 100 chars.
""")
product_description = sa.Column(sa.String(length=255), nullable=True, doc="""
Description for the product - up to 255 chars.
""")
product_size = sa.Column(sa.String(length=30), nullable=True, doc="""
Size of the product, as string - up to 30 chars.
""")
product_weighed = sa.Column(sa.Boolean(), nullable=True, doc="""
Flag indicating the product is sold by weight; default is null.
""")
department_id = sa.Column(sa.String(length=10), nullable=True, doc="""
ID of the department to which the product belongs, if known.
""")
department_name = sa.Column(sa.String(length=30), nullable=True, doc="""
Name of the department to which the product belongs, if known.
""")
special_order = sa.Column(sa.Boolean(), nullable=True, doc="""
Flag indicating the item is a "special order" - e.g. something not
normally carried by the store. Default is null.
""")
case_size = sa.Column(sa.Numeric(precision=10, scale=4), nullable=True, doc="""
Case pack count for the product, if known.
""")
order_qty = sa.Column(sa.Numeric(precision=10, scale=4), nullable=False, doc="""
Quantity (as decimal) of product being ordered.
This must be interpreted along with :attr:`order_uom` to determine
the *complete* order quantity, e.g. "2 cases".
""")
order_uom = sa.Column(sa.String(length=10), nullable=False, doc="""
Code indicating the unit of measure for product being ordered.
This should be one of the codes from
:data:`~sideshow.enum.ORDER_UOM`.
""")
unit_cost = sa.Column(sa.Numeric(precision=9, scale=5), nullable=True, doc="""
Cost of goods amount for one "unit" (not "case") of the product,
as decimal to 4 places.
""")
unit_price_reg = sa.Column(sa.Numeric(precision=8, scale=3), nullable=True, doc="""
Regular price for the item unit. Unless a sale is in effect,
:attr:`unit_price_quoted` will typically match this value.
""")
unit_price_sale = sa.Column(sa.Numeric(precision=8, scale=3), nullable=True, doc="""
Sale price for the item unit, if applicable. If set, then
:attr:`unit_price_quoted` will typically match this value. See
also :attr:`sale_ends`.
""")
sale_ends = sa.Column(sa.DateTime(timezone=True), nullable=True, doc="""
End date/time for the sale in effect, if any.
This is only relevant if :attr:`unit_price_sale` is set.
""")
unit_price_quoted = sa.Column(sa.Numeric(precision=8, scale=3), nullable=True, doc="""
Quoted price for the item unit. This is the "effective" unit
price, which is used to calculate :attr:`total_price`.
This price does *not* reflect the :attr:`discount_percent`. It
normally should match either :attr:`unit_price_reg` or
:attr:`unit_price_sale`.
""")
case_price_quoted = sa.Column(sa.Numeric(precision=8, scale=3), nullable=True, doc="""
Quoted price for a "case" of the item, if applicable.
This is mostly for display purposes; :attr:`unit_price_quoted` is
used for calculations.
""")
discount_percent = sa.Column(sa.Numeric(precision=5, scale=3), nullable=True, doc="""
Discount percent to apply when calculating :attr:`total_price`, if
applicable.
""")
total_price = sa.Column(sa.Numeric(precision=8, scale=3), nullable=True, doc="""
Full price (not including tax etc.) which the customer is quoted
for the order item.
This is calculated using values from:
* :attr:`unit_price_quoted`
* :attr:`order_qty`
* :attr:`order_uom`
* :attr:`case_size`
* :attr:`discount_percent`
""")
status_code = sa.Column(sa.Integer(), nullable=False, doc="""
Code indicating current status for the order item.
""")
paid_amount = sa.Column(sa.Numeric(precision=8, scale=3), nullable=False, default=0, doc="""
Amount which the customer has paid toward the :attr:`total_price`
of the item.
""")
payment_transaction_number = sa.Column(sa.String(length=20), nullable=True, doc="""
Transaction number in which payment for the order was taken, if
applicable/known.
""")
def __str__(self):
return str(self.pending_product or self.product_description or "")

View file

@ -0,0 +1,173 @@
# -*- 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/>.
#
################################################################################
"""
Data models for Products
"""
import datetime
import sqlalchemy as sa
from sqlalchemy import orm
from wuttjamaican.db import model
from sideshow.enum import PendingProductStatus
class PendingProduct(model.Base):
"""
A "pending" product record, used when entering an :term:`order
item` for new/unknown product.
"""
__tablename__ = 'sideshow_pending_product'
uuid = model.uuid_column()
product_id = sa.Column(sa.String(length=20), nullable=True, doc="""
ID of the true product associated with this record, if applicable.
""")
scancode = sa.Column(sa.String(length=14), nullable=True, doc="""
Scancode for the product, as string.
.. note::
This column allows 14 chars, so can store a full GPC with check
digit. However as of writing the actual format used here does
not matter to Sideshow logic; "anything" should work.
That may change eventually, depending on POS integration
scenarios that come up. Maybe a config option to declare
whether check digit should be included or not, etc.
""")
brand_name = sa.Column(sa.String(length=100), nullable=True, doc="""
Brand name for the product - up to 100 chars.
""")
description = sa.Column(sa.String(length=255), nullable=True, doc="""
Description for the product - up to 255 chars.
""")
size = sa.Column(sa.String(length=30), nullable=True, doc="""
Size of the product, as string - up to 30 chars.
""")
weighed = sa.Column(sa.Boolean(), nullable=True, doc="""
Flag indicating the product is sold by weight; default is null.
""")
department_id = sa.Column(sa.String(length=10), nullable=True, doc="""
ID of the department to which the product belongs, if known.
""")
department_name = sa.Column(sa.String(length=30), nullable=True, doc="""
Name of the department to which the product belongs, if known.
""")
special_order = sa.Column(sa.Boolean(), nullable=True, doc="""
Flag indicating the item is a "special order" - e.g. something not
normally carried by the store. Default is null.
""")
vendor_name = sa.Column(sa.String(length=50), nullable=True, doc="""
Name of vendor from which product may be purchased, if known. See
also :attr:`vendor_item_code`.
""")
vendor_item_code = sa.Column(sa.String(length=20), nullable=True, doc="""
Item code (SKU) to use when ordering this product from the vendor
identified by :attr:`vendor_name`, if known.
""")
case_size = sa.Column(sa.Numeric(precision=9, scale=4), nullable=True, doc="""
Case pack count for the product, if known.
""")
unit_cost = sa.Column(sa.Numeric(precision=9, scale=5), nullable=True, doc="""
Cost of goods amount for one "unit" (not "case") of the product,
as decimal to 4 places.
""")
unit_price_reg = sa.Column(sa.Numeric(precision=8, scale=3), nullable=True, doc="""
Regular price for a "unit" of the product.
""")
notes = sa.Column(sa.Text(), nullable=True, doc="""
Arbitrary notes regarding the product, if applicable.
""")
status = sa.Column(sa.Enum(PendingProductStatus), nullable=False, doc="""
Status code for the product record.
""")
created = sa.Column(sa.DateTime(timezone=True), nullable=False,
default=datetime.datetime.now, doc="""
Timestamp when the product record was created.
""")
created_by_uuid = model.uuid_fk_column('user.uuid', nullable=False)
created_by = orm.relationship(
model.User,
cascade_backrefs=False,
doc="""
Reference to the
:class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who
created the product record.
""")
order_items = orm.relationship(
'OrderItem',
# TODO
# order_by='NewOrderBatchRow.id.desc()',
cascade_backrefs=False,
back_populates='pending_product',
doc="""
List of :class:`~sideshow.db.model.orders.OrderItem` records
associated with this product.
""")
new_order_batch_rows = orm.relationship(
'NewOrderBatchRow',
# TODO
# order_by='NewOrderBatchRow.id.desc()',
cascade_backrefs=False,
back_populates='pending_product',
doc="""
List of
:class:`~sideshow.db.model.batch.neworder.NewOrderBatchRow`
records associated with this product.
""")
@property
def full_description(self):
""" """
fields = [
self.brand_name or '',
self.description or '',
self.size or '']
fields = [f.strip() for f in fields if f.strip()]
return ' '.join(fields)
def __str__(self):
return self.full_description

146
src/sideshow/enum.py Normal file
View file

@ -0,0 +1,146 @@
# -*- 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/>.
#
################################################################################
"""
Enum Values
"""
from enum import Enum
from collections import OrderedDict
from wuttjamaican.enum import *
ORDER_UOM_CASE = 'CS'
"""
UOM code for ordering a "case" of product.
Sideshow will treat "case" orders somewhat differently as compared to
"unit" orders.
"""
ORDER_UOM_UNIT = 'EA'
"""
UOM code for ordering a "unit" of product.
This is the default "unit" UOM but in practice all others are treated
the same by Sideshow, whereas "case" orders are treated somewhat
differently.
"""
ORDER_UOM_KILOGRAM = 'KG'
"""
UOM code for ordering a "kilogram" of product.
This is treated same as "unit" by Sideshow. However it should
(probably?) only be used for items where
e.g. :attr:`~sideshow.db.model.orders.OrderItem.product_weighed` is
true.
"""
ORDER_UOM_POUND = 'LB'
"""
UOM code for ordering a "pound" of product.
This is treated same as "unit" by Sideshow. However it should
(probably?) only be used for items where
e.g. :attr:`~sideshow.db.model.orders.OrderItem.product_weighed` is
true.
"""
ORDER_UOM = OrderedDict([
(ORDER_UOM_CASE, "Cases"),
(ORDER_UOM_UNIT, "Units"),
(ORDER_UOM_KILOGRAM, "Kilograms"),
(ORDER_UOM_POUND, "Pounds"),
])
"""
Dict of possible code -> label options for ordering unit of measure.
These codes are referenced by:
* :attr:`sideshow.db.model.batch.neworder.NewOrderBatchRow.order_uom`
* :attr:`sideshow.db.model.orders.OrderItem.order_uom`
"""
class PendingCustomerStatus(Enum):
"""
Enum values for
:attr:`sideshow.db.model.customers.PendingCustomer.status`.
"""
PENDING = 'pending'
READY = 'ready'
RESOLVED = 'resolved'
class PendingProductStatus(Enum):
"""
Enum values for
:attr:`sideshow.db.model.products.PendingProduct.status`.
"""
PENDING = 'pending'
READY = 'ready'
RESOLVED = 'resolved'
########################################
# Order Item Status
########################################
ORDER_ITEM_STATUS_UNINITIATED = 1
ORDER_ITEM_STATUS_INITIATED = 10
ORDER_ITEM_STATUS_PAID_BEFORE = 50
# TODO: deprecate / remove this one
ORDER_ITEM_STATUS_PAID = ORDER_ITEM_STATUS_PAID_BEFORE
ORDER_ITEM_STATUS_READY = 100
ORDER_ITEM_STATUS_PLACED = 200
ORDER_ITEM_STATUS_RECEIVED = 300
ORDER_ITEM_STATUS_CONTACTED = 350
ORDER_ITEM_STATUS_CONTACT_FAILED = 375
ORDER_ITEM_STATUS_DELIVERED = 500
ORDER_ITEM_STATUS_PAID_AFTER = 550
ORDER_ITEM_STATUS_CANCELED = 900
ORDER_ITEM_STATUS_REFUND_PENDING = 910
ORDER_ITEM_STATUS_REFUNDED = 920
ORDER_ITEM_STATUS_RESTOCKED = 930
ORDER_ITEM_STATUS_EXPIRED = 940
ORDER_ITEM_STATUS_INACTIVE = 950
ORDER_ITEM_STATUS = OrderedDict([
(ORDER_ITEM_STATUS_UNINITIATED, "uninitiated"),
(ORDER_ITEM_STATUS_INITIATED, "initiated"),
(ORDER_ITEM_STATUS_PAID_BEFORE, "paid"),
(ORDER_ITEM_STATUS_READY, "ready"),
(ORDER_ITEM_STATUS_PLACED, "placed"),
(ORDER_ITEM_STATUS_RECEIVED, "received"),
(ORDER_ITEM_STATUS_CONTACTED, "contacted"),
(ORDER_ITEM_STATUS_CONTACT_FAILED, "contact failed"),
(ORDER_ITEM_STATUS_DELIVERED, "delivered"),
(ORDER_ITEM_STATUS_PAID_AFTER, "paid"),
(ORDER_ITEM_STATUS_CANCELED, "canceled"),
(ORDER_ITEM_STATUS_REFUND_PENDING, "refund pending"),
(ORDER_ITEM_STATUS_REFUNDED, "refunded"),
(ORDER_ITEM_STATUS_RESTOCKED, "restocked"),
(ORDER_ITEM_STATUS_EXPIRED, "expired"),
(ORDER_ITEM_STATUS_INACTIVE, "inactive"),
])

36
src/sideshow/testing.py Normal file
View file

@ -0,0 +1,36 @@
# -*- 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 - test utilities
"""
from wuttaweb import testing as base
class WebTestCase(base.WebTestCase):
def make_config(self, **kwargs):
config = super().make_config(**kwargs)
config.setdefault('wutta.model_spec', 'sideshow.db.model')
config.setdefault('wutta.enum_spec', 'sideshow.enum')
return config

View file

@ -43,7 +43,7 @@ def main(global_config, **settings):
# bring in the rest of Sideshow
pyramid_config.include('sideshow.web.static')
pyramid_config.include('sideshow.web.subscribers')
pyramid_config.include('wuttaweb.subscribers')
pyramid_config.include('sideshow.web.views')
return pyramid_config.make_wsgi_app()

View file

View file

@ -0,0 +1,101 @@
# -*- 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/>.
#
################################################################################
"""
Form schema types
"""
from wuttaweb.forms.schema import ObjectRef
class OrderRef(ObjectRef):
"""
Custom schema type for an :class:`~sideshow.db.model.orders.Order`
reference field.
This is a subclass of
:class:`~wuttaweb:wuttaweb.forms.schema.ObjectRef`.
"""
@property
def model_class(self):
""" """
model = self.app.model
return model.Order
def sort_query(self, query):
""" """
return query.order_by(self.model_class.order_id)
def get_object_url(self, order):
""" """
return self.request.route_url('orders.view', uuid=order.uuid)
class PendingCustomerRef(ObjectRef):
"""
Custom schema type for a
:class:`~sideshow.db.model.customers.PendingCustomer` reference
field.
This is a subclass of
:class:`~wuttaweb:wuttaweb.forms.schema.ObjectRef`.
"""
@property
def model_class(self):
""" """
model = self.app.model
return model.PendingCustomer
def sort_query(self, query):
""" """
return query.order_by(self.model_class.full_name)
def get_object_url(self, customer):
""" """
return self.request.route_url('pending_customers.view', uuid=customer.uuid)
class PendingProductRef(ObjectRef):
"""
Custom schema type for a
:class:`~sideshow.db.model.products.PendingProduct` reference
field.
This is a subclass of
:class:`~wuttaweb:wuttaweb.forms.schema.ObjectRef`.
"""
@property
def model_class(self):
""" """
model = self.app.model
return model.PendingProduct
def sort_query(self, query):
""" """
return query.order_by(self.model_class.scancode)
def get_object_url(self, product):
""" """
return self.request.route_url('pending_products.view', uuid=product.uuid)

View file

@ -33,15 +33,79 @@ class SideshowMenuHandler(base.MenuHandler):
"""
def make_menus(self, request, **kwargs):
""" """
return [
self.make_orders_menu(request),
self.make_pending_menu(request),
self.make_batch_menu(request),
self.make_admin_menu(request),
]
# TODO: override this if you need custom menus...
def make_orders_menu(self, request, **kwargs):
"""
Generate a typical Orders menu.
"""
return {
'title': "Orders",
'type': 'menu',
'items': [
{
'title': "Create New Order",
'route': 'orders.create',
'perm': 'orders.create',
},
{'type': 'sep'},
{
'title': "All Orders",
'route': 'orders',
'perm': 'orders.list',
},
{
'title': "All Order Items",
'route': 'order_items',
'perm': 'order_items.list',
},
],
}
# menus = [
# self.make_products_menu(request),
# self.make_admin_menu(request),
# ]
def make_pending_menu(self, request, **kwargs):
"""
Generate a typical Pending menu.
"""
return {
'title': "Pending",
'type': 'menu',
'items': [
{
'title': "Customers",
'route': 'pending_customers',
'perm': 'pending_customers.list',
},
{
'title': "Products",
'route': 'pending_products',
'perm': 'pending_products.list',
},
],
}
# ...but for now this uses default menus
menus = super().make_menus(request, **kwargs)
def make_batch_menu(self, request, **kwargs):
"""
Generate a typical Batch menu.
"""
return {
'title': "Batches",
'type': 'menu',
'items': [
{
'title': "New Orders",
'route': 'neworder_batches',
'perm': 'neworder_batches.list',
},
],
}
return menus
def make_admin_menu(self, request, **kwargs):
""" """
kwargs['include_people'] = True
return super().make_admin_menu(request, **kwargs)

File diff suppressed because it is too large Load diff

View file

@ -30,5 +30,10 @@ def includeme(config):
# core views for wuttaweb
config.include('wuttaweb.views.essential')
# TODO: include your own views here
#config.include('sideshow.web.views.widgets')
# sideshow views
config.include('sideshow.web.views.customers')
config.include('sideshow.web.views.products')
config.include('sideshow.web.views.orders')
# batch views
config.include('sideshow.web.views.batch.neworder')

View file

View file

@ -0,0 +1,189 @@
# -*- 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/>.
#
################################################################################
"""
Views for New Order Batch
"""
from wuttaweb.views.batch import BatchMasterView
from wuttaweb.forms.schema import WuttaMoney
from sideshow.db.model import NewOrderBatch
from sideshow.batch.neworder import NewOrderBatchHandler
from sideshow.web.forms.schema import PendingCustomerRef
class NewOrderBatchView(BatchMasterView):
"""
Master view for :class:`~sideshow.db.model.batch.neworder.NewOrderBatch`.
Route prefix is ``neworder_batches``.
Notable URLs provided by this class:
* ``/batch/neworder/``
* ``/batch/neworder/XXX``
* ``/batch/neworder/XXX/delete``
The purpose of this class is to expose "raw" batch data, e.g. for
troubleshooting purposes by the admin. Ideally it is not very
useful.
Note that the "create" and "edit" views are not exposed here,
since those should be handled by
:class:`~sideshow.web.views.orders.OrderView` instead.
"""
model_class = NewOrderBatch
model_title = "New Order Batch"
model_title_plural = "New Order Batches"
route_prefix = 'neworder_batches'
url_prefix = '/batch/neworder'
creatable = False
editable = False
labels = {
'store_id': "Store ID",
'customer_id': "Customer ID",
}
grid_columns = [
'id',
'store_id',
'customer_id',
'customer_name',
'phone_number',
'email_address',
'total_price',
'row_count',
'created',
'created_by',
'executed',
]
filter_defaults = {
'executed': {'active': True, 'verb': 'is_null'},
}
form_fields = [
'id',
'store_id',
'customer_id',
'pending_customer',
'customer_name',
'phone_number',
'email_address',
'total_price',
'row_count',
'status_code',
'created',
'created_by',
'executed',
'executed_by',
]
row_labels = {
'product_scancode': "Scancode",
'product_brand': "Brand",
'product_description': "Description",
'product_size': "Size",
'order_uom': "Order UOM",
}
row_grid_columns = [
'sequence',
'product_scancode',
'product_brand',
'product_description',
'product_size',
'special_order',
'order_qty',
'order_uom',
'case_size',
'total_price',
'status_code',
]
def get_batch_handler(self):
""" """
# TODO: call self.app.get_batch_handler()
return NewOrderBatchHandler(self.config)
def configure_grid(self, g):
""" """
super().configure_grid(g)
# total_price
g.set_renderer('total_price', 'currency')
def configure_form(self, f):
""" """
super().configure_form(f)
# pending_customer
f.set_node('pending_customer', PendingCustomerRef(self.request))
# total_price
f.set_node('total_price', WuttaMoney(self.request))
def configure_row_grid(self, g):
""" """
super().configure_row_grid(g)
enum = self.app.enum
# TODO
# order_uom
#g.set_renderer('order_uom', self.grid_render_enum, enum=enum.ORDER_UOM)
# total_price
g.set_renderer('total_price', 'currency')
def get_xref_buttons(self, batch):
"""
Adds "View this Order" button, if batch has been executed and
a corresponding :class:`~sideshow.db.model.orders.Order` can
be located.
"""
buttons = super().get_xref_buttons(batch)
model = self.app.model
session = self.Session()
if batch.executed and self.request.has_perm('orders.view'):
order = session.query(model.Order)\
.filter(model.Order.order_id == batch.id)\
.first()
if order:
url = self.request.route_url('orders.view', uuid=order.uuid)
buttons.append(
self.make_button("View the Order", primary=True, icon_left='eye', url=url))
return buttons
def defaults(config, **kwargs):
base = globals()
NewOrderBatchView = kwargs.get('NewOrderBatchView', base['NewOrderBatchView'])
NewOrderBatchView.defaults(config)
def includeme(config):
defaults(config)

View file

@ -0,0 +1,246 @@
# -*- 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/>.
#
################################################################################
"""
Views for Customers
"""
from wuttaweb.views import MasterView
from wuttaweb.forms.schema import UserRef, WuttaEnum
from sideshow.db.model import PendingCustomer
class PendingCustomerView(MasterView):
"""
Master view for
:class:`~sideshow.db.model.customers.PendingCustomer`; route
prefix is ``pending_customers``.
Notable URLs provided by this class:
* ``/pending/customers/``
* ``/pending/customers/new``
* ``/pending/customers/XXX``
* ``/pending/customers/XXX/edit``
* ``/pending/customers/XXX/delete``
"""
model_class = PendingCustomer
model_title = "Pending Customer"
route_prefix = 'pending_customers'
url_prefix = '/pending/customers'
labels = {
'customer_id': "Customer ID",
}
grid_columns = [
'full_name',
'first_name',
'last_name',
'phone_number',
'email_address',
'customer_id',
'status',
'created',
'created_by',
]
sort_defaults = 'full_name'
form_fields = [
'customer_id',
'full_name',
'first_name',
'middle_name',
'last_name',
'phone_number',
'phone_type',
'email_address',
'email_type',
'status',
'created',
'created_by',
'orders',
'new_order_batches',
]
def configure_grid(self, g):
""" """
super().configure_grid(g)
enum = self.app.enum
# status
g.set_renderer('status', self.grid_render_enum, enum=enum.PendingCustomerStatus)
# links
g.set_link('full_name')
g.set_link('first_name')
g.set_link('last_name')
g.set_link('phone_number')
g.set_link('email_address')
def configure_form(self, f):
""" """
super().configure_form(f)
enum = self.app.enum
customer = f.model_instance
# customer_id
if self.creating:
f.remove('customer_id')
else:
f.set_readonly('customer_id')
# status
if self.creating:
f.remove('status')
else:
f.set_node('status', WuttaEnum(self.request, enum.PendingCustomerStatus))
f.set_readonly('status')
# created
if self.creating:
f.remove('created')
else:
f.set_readonly('created')
# created_by
if self.creating:
f.remove('created_by')
else:
f.set_node('created_by', UserRef(self.request))
f.set_readonly('created_by')
# orders
if self.creating or self.editing:
f.remove('orders')
else:
f.set_grid('orders', self.make_orders_grid(customer))
# new_order_batches
if self.creating or self.editing:
f.remove('new_order_batches')
else:
f.set_grid('new_order_batches', self.make_new_order_batches_grid(customer))
def make_orders_grid(self, customer):
"""
Make and return the grid for the Orders field.
"""
model = self.app.model
route_prefix = self.get_route_prefix()
grid = self.make_grid(key=f'{route_prefix}.view.orders',
model_class=model.Order,
data=customer.orders,
columns=[
'order_id',
'total_price',
'created',
'created_by',
],
labels={
'order_id': "Order ID",
})
grid.set_renderer('total_price', grid.render_currency)
if self.request.has_perm('orders.view'):
url = lambda order, i: self.request.route_url('orders.view', uuid=order.uuid)
grid.add_action('view', icon='eye', url=url)
grid.set_link('order_id')
return grid
def make_new_order_batches_grid(self, customer):
"""
Make and return the grid for the New Order Batches field.
"""
model = self.app.model
route_prefix = self.get_route_prefix()
grid = self.make_grid(key=f'{route_prefix}.view.new_order_batches',
model_class=model.NewOrderBatch,
data=customer.new_order_batches,
columns=[
'id',
'total_price',
'created',
'created_by',
'executed',
],
labels={
'id': "Batch ID",
},
renderers={
'id': 'batch_id',
'total_price': 'currency',
})
if self.request.has_perm('neworder_batches.view'):
url = lambda batch, i: self.request.route_url('neworder_batches.view', uuid=batch.uuid)
grid.add_action('view', icon='eye', url=url)
grid.set_link('id')
return grid
def objectify(self, form):
""" """
enum = self.app.enum
customer = super().objectify(form)
if self.creating:
customer.status = enum.PendingCustomerStatus.PENDING
customer.created_by = self.request.user
return customer
def delete_instance(self, customer):
""" """
model_title = self.get_model_title()
# avoid deleting if still referenced by order(s)
for order in customer.orders:
self.request.session.flash(f"Cannot delete {model_title} still attached "
"to Order(s)", 'warning')
raise self.redirect(self.get_action_url('view', customer))
# avoid deleting if still referenced by new order batch(es)
for batch in customer.new_order_batches:
if not batch.executed:
self.request.session.flash(f"Cannot delete {model_title} still attached "
"to New Order Batch(es)", 'warning')
raise self.redirect(self.get_action_url('view', customer))
# go ahead and delete per usual
super().delete_instance(customer)
def defaults(config, **kwargs):
base = globals()
PendingCustomerView = kwargs.get('PendingCustomerView', base['PendingCustomerView'])
PendingCustomerView.defaults(config)
def includeme(config):
defaults(config)

View file

@ -0,0 +1,866 @@
# -*- 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/>.
#
################################################################################
"""
Views for Orders
"""
import decimal
import logging
import colander
from sqlalchemy import orm
from wuttaweb.views import MasterView
from wuttaweb.forms.schema import UserRef, WuttaMoney, WuttaQuantity, WuttaEnum
from sideshow.db.model import Order, OrderItem
from sideshow.batch.neworder import NewOrderBatchHandler
from sideshow.web.forms.schema import OrderRef, PendingCustomerRef, PendingProductRef
log = logging.getLogger(__name__)
class OrderView(MasterView):
"""
Master view for :class:`~sideshow.db.model.orders.Order`; route
prefix is ``orders``.
Notable URLs provided by this class:
* ``/orders/``
* ``/orders/new``
* ``/orders/XXX``
* ``/orders/XXX/delete``
Note that the "edit" view is not exposed here; user must perform
various other workflow actions to modify the order.
"""
model_class = Order
editable = False
labels = {
'order_id': "Order ID",
'store_id': "Store ID",
'customer_id': "Customer ID",
}
grid_columns = [
'order_id',
'store_id',
'customer_id',
'customer_name',
'total_price',
'created',
'created_by',
]
sort_defaults = ('order_id', 'desc')
form_fields = [
'order_id',
'store_id',
'customer_id',
'pending_customer',
'customer_name',
'phone_number',
'email_address',
'total_price',
'created',
'created_by',
]
has_rows = True
row_model_class = OrderItem
rows_title = "Order Items"
rows_sort_defaults = 'sequence'
rows_viewable = True
row_labels = {
'product_scancode': "Scancode",
'product_brand': "Brand",
'product_description': "Description",
'product_size': "Size",
'department_name': "Department",
'order_uom': "Order UOM",
'status_code': "Status",
}
row_grid_columns = [
'sequence',
'product_scancode',
'product_brand',
'product_description',
'product_size',
'department_name',
'special_order',
'order_qty',
'order_uom',
'total_price',
'status_code',
]
PENDING_PRODUCT_ENTRY_FIELDS = [
'scancode',
'department_id',
'department_name',
'brand_name',
'description',
'size',
'vendor_name',
'vendor_item_code',
'unit_cost',
'case_size',
'unit_price_reg',
]
def configure_grid(self, g):
""" """
super().configure_grid(g)
# order_id
g.set_link('order_id')
# customer_id
g.set_link('customer_id')
# customer_name
g.set_link('customer_name')
# total_price
g.set_renderer('total_price', g.render_currency)
def create(self):
"""
Instead of the typical "create" view, this displays a "wizard"
of sorts.
Under the hood a
:class:`~sideshow.db.model.batch.neworder.NewOrderBatch` is
automatically created for the user when they first visit this
page. They can select a customer, add items etc.
When user is finished assembling the order (i.e. populating
the batch), they submit it. This of course executes the
batch, which in turn creates a true
:class:`~sideshow.db.model.orders.Order`, and user is
redirected to the "view order" page.
"""
enum = self.app.enum
self.creating = True
self.batch_handler = NewOrderBatchHandler(self.config)
batch = self.get_current_batch()
context = self.get_context_customer(batch)
if self.request.method == 'POST':
# first we check for traditional form post
action = self.request.POST.get('action')
post_actions = [
'start_over',
'cancel_order',
]
if action in post_actions:
return getattr(self, action)(batch)
# okay then, we'll assume newer JSON-style post params
data = dict(self.request.json_body)
action = data.pop('action')
json_actions = [
# 'assign_contact',
# 'unassign_contact',
# 'update_phone_number',
# 'update_email_address',
'set_pending_customer',
# 'get_customer_info',
# # 'set_customer_data',
# 'get_product_info',
# 'get_past_items',
'add_item',
'update_item',
'delete_item',
'submit_new_order',
]
if action in json_actions:
result = getattr(self, action)(batch, data)
return self.json_response(result)
return self.json_response({'error': "unknown form action"})
context.update({
'batch': batch,
'normalized_batch': self.normalize_batch(batch),
'order_items': [self.normalize_row(row)
for row in batch.rows],
'allow_unknown_product': True, # TODO
'default_uom_choices': self.get_default_uom_choices(),
'default_uom': None, # TODO?
'pending_product_required_fields': self.get_pending_product_required_fields(),
})
return self.render_to_response('create', context)
def get_current_batch(self):
"""
Returns the current batch for the current user.
This looks for a new order batch which was created by the
user, but not yet executed. If none is found, a new batch is
created.
:returns:
:class:`~sideshow.db.model.batch.neworder.NewOrderBatch`
instance
"""
model = self.app.model
session = self.Session()
user = self.request.user
if not user:
raise self.forbidden()
try:
# there should be at most *one* new batch per user
batch = session.query(model.NewOrderBatch)\
.filter(model.NewOrderBatch.created_by == user)\
.filter(model.NewOrderBatch.executed == None)\
.one()
except orm.exc.NoResultFound:
# no batch yet for this user, so make one
batch = self.batch_handler.make_batch(session, created_by=user)
session.add(batch)
session.flush()
return batch
def get_pending_product_required_fields(self):
""" """
required = []
for field in self.PENDING_PRODUCT_ENTRY_FIELDS:
require = self.config.get_bool(
f'sideshow.orders.unknown_product.fields.{field}.required')
if require is None and field == 'description':
require = True
if require:
required.append(field)
return required
def start_over(self, batch):
"""
This will delete the user's current batch, then redirect user
back to "Create Order" page, which in turn will auto-create a
new batch for them.
This is a "batch action" method which may be called from
:meth:`create()`.
"""
# drop current batch
self.batch_handler.do_delete(batch, self.request.user)
self.Session.flush()
# send back to "create order" which makes new batch
route_prefix = self.get_route_prefix()
url = self.request.route_url(f'{route_prefix}.create')
return self.redirect(url)
def cancel_order(self, batch):
"""
This will delete the user's current batch, then redirect user
back to "List Orders" page.
This is a "batch action" method which may be called from
:meth:`create()`.
"""
self.batch_handler.do_delete(batch, self.request.user)
self.Session.flush()
# set flash msg just to be more obvious
self.request.session.flash("New order has been deleted.")
# send user back to orders list, w/ no new batch generated
url = self.get_index_url()
return self.redirect(url)
def get_context_customer(self, batch):
""" """
context = {
'customer_id': batch.customer_id,
'customer_name': batch.customer_name,
'phone_number': batch.phone_number,
'email_address': batch.email_address,
'new_customer_name': None,
'new_customer_first_name': None,
'new_customer_last_name': None,
'new_customer_phone': None,
'new_customer_email': None,
}
pending = batch.pending_customer
if pending:
context.update({
'new_customer_first_name': pending.first_name,
'new_customer_last_name': pending.last_name,
'new_customer_name': pending.full_name,
'new_customer_phone': pending.phone_number,
'new_customer_email': pending.email_address,
})
# figure out if customer is "known" from user's perspective.
# if we have an ID then it's definitely known, otherwise if we
# have a pending customer then it's definitely *not* known,
# but if no pending customer yet then we can still "assume" it
# is known, by default, until user specifies otherwise.
if batch.customer_id:
context['customer_is_known'] = True
else:
context['customer_is_known'] = not pending
return context
def set_pending_customer(self, batch, data):
"""
This will set/update the batch pending customer info.
This calls
:meth:`~sideshow.batch.neworder.NewOrderBatchHandler.set_pending_customer()`
for the heavy lifting.
This is a "batch action" method which may be called from
:meth:`create()`.
"""
data['created_by'] = self.request.user
try:
self.batch_handler.set_pending_customer(batch, data)
except Exception as error:
return {'error': self.app.render_error(error)}
self.Session.flush()
context = self.get_context_customer(batch)
return context
def add_item(self, batch, data):
"""
This adds a row to the user's current new order batch.
This is a "batch action" method which may be called from
:meth:`create()`.
"""
order_qty = decimal.Decimal(data.get('order_qty') or '0')
order_uom = data['order_uom']
if data.get('product_is_known'):
raise NotImplementedError
else: # unknown product; add pending
pending = data['pending_product']
for field in ('unit_cost', 'unit_price_reg', 'case_size'):
if field in pending:
try:
pending[field] = decimal.Decimal(pending[field])
except decimal.InvalidOperation:
return {'error': f"Invalid entry for field: {field}"}
pending['created_by'] = self.request.user
row = self.batch_handler.add_pending_product(batch, pending,
order_qty, order_uom)
return {'batch': self.normalize_batch(batch),
'row': self.normalize_row(row)}
def update_item(self, batch, data):
"""
This updates a row in the user's current new order batch.
This is a "batch action" method which may be called from
:meth:`create()`.
"""
model = self.app.model
enum = self.app.enum
session = self.Session()
uuid = data.get('uuid')
if not uuid:
return {'error': "Must specify a row UUID"}
row = session.get(model.NewOrderBatchRow, uuid)
if not row:
return {'error': "Row not found"}
if row.batch is not batch:
return {'error': "Row is for wrong batch"}
order_qty = decimal.Decimal(data.get('order_qty') or '0')
order_uom = data['order_uom']
if data.get('product_is_known'):
raise NotImplementedError
else: # pending product
# set these first, since row will be refreshed below
row.order_qty = order_qty
row.order_uom = order_uom
# nb. this will refresh the row
self.batch_handler.set_pending_product(row, data['pending_product'])
return {'batch': self.normalize_batch(batch),
'row': self.normalize_row(row)}
def delete_item(self, batch, data):
"""
This deletes a row from the user's current new order batch.
This is a "batch action" method which may be called from
:meth:`create()`.
"""
model = self.app.model
session = self.app.get_session(batch)
uuid = data.get('uuid')
if not uuid:
return {'error': "Must specify a row UUID"}
row = session.get(model.NewOrderBatchRow, uuid)
if not row:
return {'error': "Row not found"}
if row.batch is not batch:
return {'error': "Row is for wrong batch"}
self.batch_handler.do_remove_row(row)
session.flush()
return {'batch': self.normalize_batch(batch)}
def submit_new_order(self, batch, data):
"""
This submits the user's current new order batch, hence
executing the batch and creating the true order.
This is a "batch action" method which may be called from
:meth:`create()`.
"""
user = self.request.user
reason = self.batch_handler.why_not_execute(batch, user=user)
if reason:
return {'error': reason}
try:
order = self.batch_handler.do_execute(batch, user)
except Exception as error:
log.warning("failed to execute new order batch: %s", batch,
exc_info=True)
return {'error': self.app.render_error(error)}
return {
'next_url': self.get_action_url('view', order),
}
def normalize_batch(self, batch):
""" """
return {
'uuid': batch.uuid.hex,
'total_price': str(batch.total_price or 0),
'total_price_display': self.app.render_currency(batch.total_price),
'status_code': batch.status_code,
'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):
""" """
enum = self.app.enum
data = {
'uuid': row.uuid.hex,
'sequence': row.sequence,
'product_scancode': row.product_scancode,
'product_brand': row.product_brand,
'product_description': row.product_description,
'product_size': row.product_size,
'product_weighed': row.product_weighed,
'department_display': row.department_name,
'special_order': row.special_order,
'case_size': self.app.render_quantity(row.case_size),
'order_qty': self.app.render_quantity(row.order_qty),
'order_uom': row.order_uom,
'order_uom_choices': self.get_default_uom_choices(),
'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),
'case_price_quoted': float(row.case_price_quoted) if row.case_price_quoted is not None else None,
'case_price_quoted_display': self.app.render_currency(row.case_price_quoted),
'total_price': float(row.total_price) if row.total_price is not None else None,
'total_price_display': self.app.render_currency(row.total_price),
'status_code': row.status_code,
'status_text': row.status_text,
}
if row.unit_price_reg:
data['unit_price_reg'] = float(row.unit_price_reg)
data['unit_price_reg_display'] = self.app.render_currency(row.unit_price_reg)
if row.unit_price_sale:
data['unit_price_sale'] = float(row.unit_price_sale)
data['unit_price_sale_display'] = self.app.render_currency(row.unit_price_sale)
if row.sale_ends:
sale_ends = row.sale_ends
data['sale_ends'] = str(row.sale_ends)
data['sale_ends_display'] = self.app.render_date(row.sale_ends)
# if row.unit_price_sale and row.unit_price_quoted == row.unit_price_sale:
# data['pricing_reflects_sale'] = True
# TODO
if row.pending_product:
data['product_full_description'] = row.pending_product.full_description
# else:
# data['product_full_description'] = row.product_description
# if row.pending_product:
# data['vendor_display'] = row.pending_product.vendor_name
if row.pending_product:
pending = row.pending_product
# data['vendor_display'] = pending.vendor_name
data['pending_product'] = {
'uuid': pending.uuid.hex,
'scancode': pending.scancode,
'brand_name': pending.brand_name,
'description': pending.description,
'size': pending.size,
'department_id': pending.department_id,
'department_name': pending.department_name,
'unit_price_reg': float(pending.unit_price_reg) if pending.unit_price_reg is not None else None,
'vendor_name': pending.vendor_name,
'vendor_item_code': pending.vendor_item_code,
'unit_cost': float(pending.unit_cost) if pending.unit_cost is not None else None,
'case_size': float(pending.case_size) if pending.case_size is not None else None,
'notes': pending.notes,
'special_order': pending.special_order,
}
# TODO: remove this
data['product_key'] = row.product_scancode
# display text for order qty/uom
if row.order_uom == enum.ORDER_UOM_CASE:
if row.case_size is None:
case_qty = unit_qty = '??'
else:
case_qty = data['case_size']
unit_qty = self.app.render_quantity(row.order_qty * row.case_size)
CS = enum.ORDER_UOM[enum.ORDER_UOM_CASE]
EA = enum.ORDER_UOM[enum.ORDER_UOM_UNIT]
data['order_qty_display'] = (f"{data['order_qty']} {CS} "
f"(&times; {case_qty} = {unit_qty} {EA})")
else:
unit_qty = self.app.render_quantity(row.order_qty)
EA = enum.ORDER_UOM[enum.ORDER_UOM_UNIT]
data['order_qty_display'] = f"{unit_qty} {EA}"
return data
def get_instance_title(self, order):
""" """
return f"#{order.order_id} for {order.customer_name}"
def configure_form(self, f):
""" """
super().configure_form(f)
# pending_customer
f.set_node('pending_customer', PendingCustomerRef(self.request))
# total_price
f.set_node('total_price', WuttaMoney(self.request))
# created_by
f.set_node('created_by', UserRef(self.request))
f.set_readonly('created_by')
def get_xref_buttons(self, order):
""" """
buttons = super().get_xref_buttons(order)
model = self.app.model
session = self.Session()
if self.request.has_perm('neworder_batches.view'):
batch = session.query(model.NewOrderBatch)\
.filter(model.NewOrderBatch.id == order.order_id)\
.first()
if batch:
url = self.request.route_url('neworder_batches.view', uuid=batch.uuid)
buttons.append(
self.make_button("View the Batch", primary=True, icon_left='eye', url=url))
return buttons
def get_row_grid_data(self, order):
""" """
model = self.app.model
session = self.Session()
return session.query(model.OrderItem)\
.filter(model.OrderItem.order == order)
def configure_row_grid(self, g):
""" """
super().configure_row_grid(g)
enum = self.app.enum
# sequence
g.set_label('sequence', "Seq.", column_only=True)
g.set_link('sequence')
# product_scancode
g.set_link('product_scancode')
# product_brand
g.set_link('product_brand')
# product_description
g.set_link('product_description')
# product_size
g.set_link('product_size')
# TODO
# order_uom
#g.set_renderer('order_uom', self.grid_render_enum, enum=enum.OrderUOM)
# total_price
g.set_renderer('total_price', g.render_currency)
# status_code
g.set_renderer('status_code', self.render_status_code)
def render_status_code(self, item, key, value):
""" """
enum = self.app.enum
return enum.ORDER_ITEM_STATUS[value]
def get_row_action_url_view(self, item, i):
""" """
return self.request.route_url('order_items.view', uuid=item.uuid)
class OrderItemView(MasterView):
"""
Master view for :class:`~sideshow.db.model.orders.OrderItem`;
route prefix is ``order_items``.
Notable URLs provided by this class:
* ``/order-items/``
* ``/order-items/XXX``
Note that this does not expose create, edit or delete. The user
must perform various other workflow actions to modify the item.
"""
model_class = OrderItem
model_title = "Order Item"
route_prefix = 'order_items'
url_prefix = '/order-items'
creatable = False
editable = False
deletable = False
labels = {
'order_id': "Order ID",
'product_id': "Product ID",
'product_scancode': "Scancode",
'product_brand': "Brand",
'product_description': "Description",
'product_size': "Size",
'department_name': "Department",
'order_uom': "Order UOM",
'status_code': "Status",
}
grid_columns = [
'order_id',
'customer_name',
# 'sequence',
'product_scancode',
'product_brand',
'product_description',
'product_size',
'department_name',
'special_order',
'order_qty',
'order_uom',
'total_price',
'status_code',
]
sort_defaults = ('order_id', 'desc')
form_fields = [
'order',
# 'customer_name',
'sequence',
'product_id',
'pending_product',
'product_scancode',
'product_brand',
'product_description',
'product_size',
'product_weighed',
'department_id',
'department_name',
'special_order',
'order_qty',
'order_uom',
'case_size',
'unit_cost',
'unit_price_reg',
'unit_price_sale',
'sale_ends',
'unit_price_quoted',
'case_price_quoted',
'discount_percent',
'total_price',
'status_code',
'paid_amount',
'payment_transaction_number',
]
def get_query(self, session=None):
""" """
query = super().get_query(session=session)
model = self.app.model
return query.join(model.Order)
def configure_grid(self, g):
""" """
super().configure_grid(g)
model = self.app.model
# enum = self.app.enum
# order_id
g.set_sorter('order_id', model.Order.order_id)
g.set_renderer('order_id', self.render_order_id)
g.set_link('order_id')
# customer_name
g.set_label('customer_name', "Customer", column_only=True)
# # sequence
# g.set_label('sequence', "Seq.", column_only=True)
# product_scancode
g.set_link('product_scancode')
# product_brand
g.set_link('product_brand')
# product_description
g.set_link('product_description')
# product_size
g.set_link('product_size')
# order_uom
# TODO
#g.set_renderer('order_uom', self.grid_render_enum, enum=enum.OrderUOM)
# total_price
g.set_renderer('total_price', g.render_currency)
# status_code
g.set_renderer('status_code', self.render_status_code)
def render_order_id(self, item, key, value):
""" """
return item.order.order_id
def render_status_code(self, item, key, value):
""" """
enum = self.app.enum
return enum.ORDER_ITEM_STATUS[value]
def configure_form(self, f):
""" """
super().configure_form(f)
enum = self.app.enum
# order
f.set_node('order', OrderRef(self.request))
# pending_product
f.set_node('pending_product', PendingProductRef(self.request))
# order_qty
f.set_node('order_qty', WuttaQuantity(self.request))
# order_uom
# TODO
#f.set_node('order_uom', WuttaEnum(self.request, enum.OrderUOM))
# case_size
f.set_node('case_size', WuttaQuantity(self.request))
# unit_price_quoted
f.set_node('unit_price_quoted', WuttaMoney(self.request))
# case_price_quoted
f.set_node('case_price_quoted', WuttaMoney(self.request))
# total_price
f.set_node('total_price', WuttaMoney(self.request))
# paid_amount
f.set_node('paid_amount', WuttaMoney(self.request))
def get_xref_buttons(self, item):
""" """
buttons = super().get_xref_buttons(item)
model = self.app.model
if self.request.has_perm('orders.view'):
url = self.request.route_url('orders.view', uuid=item.order_uuid)
buttons.append(
self.make_button("View the Order", primary=True, icon_left='eye', url=url))
return buttons
def defaults(config, **kwargs):
base = globals()
OrderView = kwargs.get('OrderView', base['OrderView'])
OrderView.defaults(config)
OrderItemView = kwargs.get('OrderItemView', base['OrderItemView'])
OrderItemView.defaults(config)
def includeme(config):
defaults(config)

View file

@ -0,0 +1,257 @@
# -*- 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/>.
#
################################################################################
"""
Views for Products
"""
from wuttaweb.views import MasterView
from wuttaweb.forms.schema import UserRef, WuttaEnum, WuttaMoney
from sideshow.db.model import PendingProduct
class PendingProductView(MasterView):
"""
Master view for
:class:`~sideshow.db.model.products.PendingProduct`; route
prefix is ``pending_products``.
Notable URLs provided by this class:
* ``/pending/products/``
* ``/pending/products/new``
* ``/pending/products/XXX``
* ``/pending/products/XXX/edit``
* ``/pending/products/XXX/delete``
"""
model_class = PendingProduct
model_title = "Pending Product"
route_prefix = 'pending_products'
url_prefix = '/pending/products'
labels = {
'product_id': "Product ID",
}
grid_columns = [
'scancode',
'department_name',
'brand_name',
'description',
'size',
'unit_cost',
'case_size',
'unit_price_reg',
'special_order',
'status',
'created',
'created_by',
]
sort_defaults = 'scancode'
form_fields = [
'product_id',
'scancode',
'department_id',
'department_name',
'brand_name',
'description',
'size',
'vendor_name',
'vendor_item_code',
'unit_cost',
'case_size',
'unit_price_reg',
'special_order',
'notes',
'status',
'created',
'created_by',
'orders',
'new_order_batches',
]
def configure_grid(self, g):
""" """
super().configure_grid(g)
enum = self.app.enum
# unit_cost
g.set_renderer('unit_cost', 'currency', scale=4)
# unit_price_reg
g.set_label('unit_price_reg', "Reg. Price", column_only=True)
g.set_renderer('unit_price_reg', 'currency')
# status
g.set_renderer('status', self.grid_render_enum, enum=enum.PendingProductStatus)
# links
g.set_link('scancode')
g.set_link('brand_name')
g.set_link('description')
g.set_link('size')
def configure_form(self, f):
""" """
super().configure_form(f)
enum = self.app.enum
product = f.model_instance
# product_id
if self.creating:
f.remove('product_id')
else:
f.set_readonly('product_id')
# unit_price_reg
f.set_node('unit_price_reg', WuttaMoney(self.request))
# notes
f.set_widget('notes', 'notes')
# status
if self.creating:
f.remove('status')
else:
f.set_node('status', WuttaEnum(self.request, enum.PendingProductStatus))
f.set_readonly('status')
# created
if self.creating:
f.remove('created')
else:
f.set_readonly('created')
# created_by
if self.creating:
f.remove('created_by')
else:
f.set_node('created_by', UserRef(self.request))
f.set_readonly('created_by')
# orders
if self.creating or self.editing:
f.remove('orders')
else:
f.set_grid('orders', self.make_orders_grid(product))
# new_order_batches
if self.creating or self.editing:
f.remove('new_order_batches')
else:
f.set_grid('new_order_batches', self.make_new_order_batches_grid(product))
def make_orders_grid(self, product):
"""
Make and return the grid for the Orders field.
"""
model = self.app.model
route_prefix = self.get_route_prefix()
orders = set([item.order for item in product.order_items])
orders = sorted(orders, key=lambda order: order.order_id)
grid = self.make_grid(key=f'{route_prefix}.view.orders',
model_class=model.Order,
data=orders,
columns=[
'order_id',
'total_price',
'created',
'created_by',
],
labels={
'order_id': "Order ID",
},
renderers={
'total_price': 'currency',
})
if self.request.has_perm('orders.view'):
url = lambda order, i: self.request.route_url('orders.view', uuid=order.uuid)
grid.add_action('view', icon='eye', url=url)
grid.set_link('order_id')
return grid
def make_new_order_batches_grid(self, product):
"""
Make and return the grid for the New Order Batches field.
"""
model = self.app.model
route_prefix = self.get_route_prefix()
batches = set([row.batch for row in product.new_order_batch_rows])
batches = sorted(batches, key=lambda batch: batch.id)
grid = self.make_grid(key=f'{route_prefix}.view.new_order_batches',
model_class=model.NewOrderBatch,
data=batches,
columns=[
'id',
'total_price',
'created',
'created_by',
'executed',
],
labels={
'id': "Batch ID",
'status_code': "Status",
},
renderers={
'id': 'batch_id',
})
if self.request.has_perm('neworder_batches.view'):
url = lambda batch, i: self.request.route_url('neworder_batches.view', uuid=batch.uuid)
grid.add_action('view', icon='eye', url=url)
grid.set_link('id')
return grid
def delete_instance(self, product):
""" """
# avoid deleting if still referenced by new order batch(es)
for row in product.new_order_batch_rows:
if not row.batch.executed:
model_title = self.get_model_title()
self.request.session.flash(f"Cannot delete {model_title} still attached "
"to New Order Batch(es)", 'warning')
raise self.redirect(self.get_action_url('view', product))
# go ahead and delete per usual
super().delete_instance(product)
def defaults(config, **kwargs):
base = globals()
PendingProductView = kwargs.get('PendingProductView', base['PendingProductView'])
PendingProductView.defaults(config)
def includeme(config):
defaults(config)