commit ce54ca6bd62d384da84c72ab0c7699f63d48e9d1 Author: Lance Edgar Date: Sun Jan 12 22:08:29 2025 -0600 feat: basic CORE customer/product lookup, add CORE menu entries diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..acd9fcd --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +*.pyc +*~ +.coverage +dist/ +docs/_build/ +.tox/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..93b3bc9 --- /dev/null +++ b/README.md @@ -0,0 +1,6 @@ + +# Sideshow-COREPOS + +This package adds CORE-POS integration for Sideshow. + +Full docs are at https://rattailproject.org/docs/sideshow-corepos/ diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..5058bef --- /dev/null +++ b/pyproject.toml @@ -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"] diff --git a/src/sideshow_corepos/__init__.py b/src/sideshow_corepos/__init__.py new file mode 100644 index 0000000..b963b99 --- /dev/null +++ b/src/sideshow_corepos/__init__.py @@ -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 . +# +################################################################################ +""" +Sideshow-COREPOS - Case/Special Order Tracker for CORE-POS +""" diff --git a/src/sideshow_corepos/batch/__init__.py b/src/sideshow_corepos/batch/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/sideshow_corepos/batch/neworder.py b/src/sideshow_corepos/batch/neworder.py new file mode 100644 index 0000000..4b94ba8 --- /dev/null +++ b/src/sideshow_corepos/batch/neworder.py @@ -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 . +# +################################################################################ +""" +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}') diff --git a/src/sideshow_corepos/web/__init__.py b/src/sideshow_corepos/web/__init__.py new file mode 100644 index 0000000..a8e854d --- /dev/null +++ b/src/sideshow_corepos/web/__init__.py @@ -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 . +# +################################################################################ +""" +Sideshow-COREPOS - Case/Special Order Tracker for CORE-POS +""" + + +def includeme(config): + config.include('sideshow_corepos.web.views') diff --git a/src/sideshow_corepos/web/app.py b/src/sideshow_corepos/web/app.py new file mode 100644 index 0000000..0ff4fbd --- /dev/null +++ b/src/sideshow_corepos/web/app.py @@ -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 . +# +################################################################################ +""" +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) diff --git a/src/sideshow_corepos/web/menus.py b/src/sideshow_corepos/web/menus.py new file mode 100644 index 0000000..342974b --- /dev/null +++ b/src/sideshow_corepos/web/menus.py @@ -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 . +# +################################################################################ +""" +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 diff --git a/src/sideshow_corepos/web/views.py b/src/sideshow_corepos/web/views.py new file mode 100644 index 0000000..2edda1e --- /dev/null +++ b/src/sideshow_corepos/web/views.py @@ -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 . +# +################################################################################ +""" +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') diff --git a/tasks.py b/tasks.py new file mode 100644 index 0000000..e60cc5d --- /dev/null +++ b/tasks.py @@ -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/*') diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..f278fdb --- /dev/null +++ b/tox.ini @@ -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