feat: basic CORE customer/product lookup, add CORE menu entries

This commit is contained in:
Lance Edgar 2025-01-12 22:08:29 -06:00
commit ce54ca6bd6
12 changed files with 558 additions and 0 deletions

6
.gitignore vendored Normal file
View file

@ -0,0 +1,6 @@
*.pyc
*~
.coverage
dist/
docs/_build/
.tox/

6
README.md Normal file
View file

@ -0,0 +1,6 @@
# Sideshow-COREPOS
This package adds CORE-POS integration for Sideshow.
Full docs are at https://rattailproject.org/docs/sideshow-corepos/

60
pyproject.toml Normal file
View file

@ -0,0 +1,60 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "Sideshow-COREPOS"
version = "0.0.0"
description = "Case/Special Order Tracker for CORE-POS"
readme = "README.md"
authors = [
{name = "Lance Edgar", email = "lance@wuttaproject.org"}
]
maintainers = [
{name = "Lance Edgar", email = "lance@wuttaproject.org"}
]
classifiers = [
"Development Status :: 3 - Alpha",
"Environment :: Web Environment",
"Framework :: Pyramid",
"Intended Audience :: Developers",
"License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)",
"Natural Language :: English",
"Programming Language :: Python",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
]
license = {text = "GNU General Public License v3+"}
requires-python = ">= 3.8"
dependencies = [
"Sideshow",
"Wutta-COREPOS[web]",
]
[project.optional-dependencies]
docs = ["Sphinx", "furo"]
tests = ["pytest-cov", "tox"]
[project.entry-points."paste.app_factory"]
"main" = "sideshow_corepos.web.app:main"
[project.urls]
Homepage = "https://wuttaproject.org/"
Repository = "https://forgejo.wuttaproject.org/wutta/sideshow-corepos"
Issues = "https://forgejo.wuttaproject.org/wutta/sideshow-corepos/issues"
Changelog = "https://forgejo.wuttaproject.org/wutta/sideshow-corepos/src/branch/master/CHANGELOG.md"
[tool.commitizen]
version_provider = "pep621"
tag_format = "v$version"
update_changelog_on_bump = true
[tool.hatch.build.targets.wheel]
packages = ["src/sideshow_corepos"]

View file

@ -0,0 +1,25 @@
# -*- coding: utf-8; -*-
################################################################################
#
# Sideshow-COREPOS -- Case/Special Order Tracker for CORE-POS
# Copyright © 2025 Lance Edgar
#
# This file is part of Sideshow.
#
# Sideshow is free software: you can redistribute it and/or modify it
# under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Sideshow is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Sideshow. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Sideshow-COREPOS - Case/Special Order Tracker for CORE-POS
"""

View file

View file

@ -0,0 +1,220 @@
# -*- coding: utf-8; -*-
################################################################################
#
# Sideshow-COREPOS -- Case/Special Order Tracker for CORE-POS
# Copyright © 2025 Lance Edgar
#
# This file is part of Sideshow.
#
# Sideshow is free software: you can redistribute it and/or modify it
# under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Sideshow is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Sideshow. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
New Order Batch Handler for CORE-POS
"""
import decimal
import sqlalchemy as sa
from sqlalchemy import orm
from sideshow.batch import neworder as base
class NewOrderBatchHandler(base.NewOrderBatchHandler):
"""
Custom batch handler which can use CORE-POS as external data
source for customers and products.
See parent class
:class:`~sideshow:sideshow.batch.neworder.NewOrderBatchHandler`
for more info.
"""
def autocomplete_customers_external(self, session, term, user=None):
""" """
corepos = self.app.get_corepos_handler()
op_model = corepos.get_model_office_op()
op_session = corepos.make_session_office_op()
# base query
query = op_session.query(op_model.CustomerClassic)\
.join(op_model.MemberInfo,
op_model.MemberInfo.card_number == op_model.CustomerClassic.card_number)
# filter query
criteria = []
for word in term.split():
criteria.append(sa.or_(
op_model.CustomerClassic.first_name.ilike(f'%{word}%'),
op_model.CustomerClassic.last_name.ilike(f'%{word}%')))
query = query.filter(sa.and_(*criteria))
# sort query
query = query.order_by(op_model.CustomerClassic.first_name,
op_model.CustomerClassic.last_name)
# get data
# TODO: need max_results option
customers = query.all()
# get results
def result(customer):
return {'value': str(customer.card_number),
'label': str(customer)}
results = [result(c) for c in customers]
op_session.close()
return results
def refresh_batch_from_external_customer(self, batch):
""" """
corepos = self.app.get_corepos_handler()
op_model = corepos.get_model_office_op()
op_session = corepos.make_session_office_op()
if not batch.customer_id.isdigit():
raise ValueError(f"invalid CORE-POS customer card number: {batch.customer_id}")
try:
customer = op_session.query(op_model.CustomerClassic)\
.join(op_model.MemberInfo,
op_model.MemberInfo.card_number == op_model.CustomerClassic.card_number)\
.filter(op_model.CustomerClassic.card_number == int(batch.customer_id))\
.filter(op_model.CustomerClassic.person_number == 1)\
.options(orm.joinedload(op_model.CustomerClassic.member_info))\
.one()
except orm.exc.NoResultFound:
raise ValueError(f"CORE-POS Customer not found: {customer_id}")
batch.customer_name = str(customer)
batch.phone_number = customer.member_info.phone
batch.email_address = customer.member_info.email
op_session.close()
def autocomplete_products_external(self, session, term, user=None):
""" """
corepos = self.app.get_corepos_handler()
op_model = corepos.get_model_office_op()
op_session = corepos.make_session_office_op()
# base query
query = op_session.query(op_model.Product)
# filter query
criteria = []
for word in term.split():
criteria.append(sa.or_(
op_model.Product.brand.ilike(f'%{word}%'),
op_model.Product.description.ilike(f'%{word}%')))
query = query.filter(sa.and_(*criteria))
# sort query
query = query.order_by(op_model.Product.brand,
op_model.Product.description)
# get data
# TODO: need max_results option
products = query.all()
# get results
def result(product):
return {'value': product.upc,
'label': product.formatted_name}
results = [result(c) for c in products]
op_session.close()
return results
def get_product_info_external(self, session, product_id, user=None):
"""
Returns basic info for an :term:`external product` as pertains
to ordering.
There is no default logic here; subclass must implement.
"""
corepos = self.app.get_corepos_handler()
op_model = corepos.get_model_office_op()
op_session = corepos.make_session_office_op()
try:
product = op_session.query(op_model.Product)\
.filter(op_model.Product.upc == product_id)\
.one()
except orm.exc.NoResultFound:
raise ValueError(f"CORE-POS Product not found: {product_id}")
data = {
'product_id': product.upc,
'scancode': product.upc,
'brand_name': product.brand,
'description': product.description,
'size': product.size,
# 'full_description': product.formatted_name,
'full_description': self.app.make_full_name(product.brand,
product.description,
product.size),
'weighed': product.scale,
'special_order': False,
'department_id': product.department_number,
'department_name': product.department.name if product.department else None,
'case_size': self.get_case_size_for_external_product(product),
'unit_price_reg': self.get_unit_price_reg_for_external_product(product),
# TODO
# 'vendor_name': product.vendor_name,
# 'vendor_item_code': product.vendor_item_code,
}
op_session.close()
return data
def refresh_row_from_external_product(self, row):
""" """
corepos = self.app.get_corepos_handler()
op_model = corepos.get_model_office_op()
op_session = corepos.make_session_office_op()
try:
product = op_session.query(op_model.Product)\
.filter(op_model.Product.upc == row.product_id)\
.one()
except orm.exc.NoResultFound:
raise ValueError(f"CORE-POS Product not found: {row.product_id}")
row.product_scancode = product.upc
row.product_brand = product.brand
row.product_description = product.description
row.product_size = product.size
row.product_weighed = product.scale
row.department_id = product.department_number
row.department_name = product.department.name if product.department else None
row.special_order = False
row.case_size = self.get_case_size_for_external_product(product)
row.unit_cost = product.cost
row.unit_price_reg = self.get_unit_price_reg_for_external_product(product)
op_session.close()
def get_case_size_for_external_product(self, product):
""" """
if product.vendor_items:
item = product.vendor_items[0]
if item.units is not None:
return decimal.Decimal(f'{item.units:0.4f}')
def get_unit_price_reg_for_external_product(self, product):
""" """
if product.normal_price is not None:
return decimal.Decimal(f'{product.normal_price:0.3f}')

View file

@ -0,0 +1,29 @@
# -*- coding: utf-8; -*-
################################################################################
#
# Sideshow-COREPOS -- Case/Special Order Tracker for CORE-POS
# Copyright © 2025 Lance Edgar
#
# This file is part of Sideshow.
#
# Sideshow is free software: you can redistribute it and/or modify it
# under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Sideshow is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Sideshow. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Sideshow-COREPOS - Case/Special Order Tracker for CORE-POS
"""
def includeme(config):
config.include('sideshow_corepos.web.views')

View file

@ -0,0 +1,68 @@
# -*- coding: utf-8; -*-
################################################################################
#
# Sideshow-COREPOS -- Case/Special Order Tracker for CORE-POS
# Copyright © 2025 Lance Edgar
#
# This file is part of Sideshow.
#
# Sideshow is free software: you can redistribute it and/or modify it
# under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Sideshow is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Sideshow. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Sideshow-COREPOS web app
"""
from wuttaweb import app as base
from wutta_corepos.web.db import CoreOpSession
def main(global_config, **settings):
"""
Make and return the WSGI app (Paste entry point).
"""
# prefer Sideshow templates over wuttaweb
settings.setdefault('mako.directories', [
# 'sideshow_corepos.web:templates',
'sideshow.web:templates',
'wuttaweb:templates',
])
# make config objects
wutta_config = base.make_wutta_config(settings)
pyramid_config = base.make_pyramid_config(settings)
# configure DB sessions
CoreOpSession.configure(bind=wutta_config.core_office_op_engine)
# bring in the rest of Sideshow
pyramid_config.include('sideshow.web')
pyramid_config.include('sideshow_corepos.web')
return pyramid_config.make_wsgi_app()
def make_wsgi_app():
"""
Make and return the WSGI app (generic entry point).
"""
return base.make_wsgi_app(main)
def make_asgi_app():
"""
Make and return the ASGI app.
"""
return base.make_asgi_app(main)

View file

@ -0,0 +1,67 @@
# -*- coding: utf-8; -*-
################################################################################
#
# Sideshow-COREPOS -- Case/Special Order Tracker for CORE-POS
# Copyright © 2025 Lance Edgar
#
# This file is part of Sideshow.
#
# Sideshow is free software: you can redistribute it and/or modify it
# under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Sideshow is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Sideshow. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Sideshow-COREPOS - custom menus
"""
from sideshow.web import menus as base
class SideshowMenuHandler(base.SideshowMenuHandler):
"""
Custom menu handler for Sideshow, which adds CORE-POS entries.
"""
def make_customers_menu(self, request, **kwargs):
"""
This adds the entry for CORE-POS Members..
"""
menu = super().make_customers_menu(request, **kwargs)
menu['items'].extend([
{'type': 'sep'},
{
'title': "CORE-POS Members",
'route': 'corepos_members',
'perm': 'corepos_members.list',
},
])
return menu
def make_products_menu(self, request, **kwargs):
"""
This adds the entry for CORE-POS Products..
"""
menu = super().make_products_menu(request, **kwargs)
menu['items'].extend([
{'type': 'sep'},
{
'title': "CORE-POS Products",
'route': 'corepos_products',
'perm': 'corepos_products.list',
},
])
return menu

View file

@ -0,0 +1,32 @@
# -*- coding: utf-8; -*-
################################################################################
#
# Sideshow-COREPOS -- Case/Special Order Tracker for CORE-POS
# Copyright © 2025 Lance Edgar
#
# This file is part of Sideshow.
#
# Sideshow is free software: you can redistribute it and/or modify it
# under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Sideshow is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Sideshow. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Sideshow-COREPOS - custom views
"""
def includeme(config):
# CORE-POS views
config.include('wutta_corepos.web.views.corepos.members')
config.include('wutta_corepos.web.views.corepos.products')

28
tasks.py Normal file
View file

@ -0,0 +1,28 @@
# -*- coding: utf-8; -*-
"""
Tasks for Sideshow-COREPOS
"""
import os
import shutil
from invoke import task
@task
def release(c, skip_tests=False):
"""
Release a new version of Sideshow-COREPOS
"""
if not skip_tests:
c.run('pytest')
# rebuild pkg
if os.path.exists('dist'):
shutil.rmtree('dist')
if os.path.exists('Sideshow_COREPOS.egg-info'):
shutil.rmtree('Sideshow_COREPOS.egg-info')
c.run('python -m build --sdist')
# upload
c.run('twine upload dist/*')

17
tox.ini Normal file
View file

@ -0,0 +1,17 @@
[tox]
envlist = py38, py39, py310, py311
[testenv]
extras = tests
commands = pytest {posargs}
[testenv:coverage]
basepython = python3.11
commands = pytest --cov=sideshow_corepos --cov-report=html --cov-fail-under=100
[testenv:docs]
basepython = python3.11
extras = docs
changedir = docs
commands = sphinx-build -b html -d {envtmpdir}/doctrees -W -T . {envtmpdir}/docs