Compare commits

...

10 commits

18 changed files with 410 additions and 11 deletions

View file

@ -5,6 +5,21 @@ All notable changes to Wutta-COREPOS will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
## v0.3.0 (2025-02-20)
### Feat
- add app db schema extension, for CoreUser
- add support for `lane_op` and `lane_trans` DB sessions, models
### Fix
- bump min version for wuttjamaican
- bump version requirement for pyCOREPOS
- add `get_office_employee_url()` method for corepos handler
- add grid links for Members
- bump min version for pycorepos dependency
## v0.2.1 (2025-01-15)
### Fix

View file

@ -0,0 +1,6 @@
``wutta_corepos.db.model``
==========================
.. automodule:: wutta_corepos.db.model
:members:

View file

@ -0,0 +1,6 @@
``wutta_corepos.db``
====================
.. automodule:: wutta_corepos.db
:members:

View file

@ -27,8 +27,8 @@ templates_path = ['_templates']
exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
intersphinx_mapping = {
'wuttaweb': ('https://rattailproject.org/docs/wuttaweb/', None),
'wuttjamaican': ('https://rattailproject.org/docs/wuttjamaican/', None),
'wuttaweb': ('https://docs.wuttaproject.org/wuttaweb/', None),
'wuttjamaican': ('https://docs.wuttaproject.org/wuttjamaican/', None),
}

View file

@ -5,8 +5,15 @@ Wutta-COREPOS
This package adds basic integration with `CORE-POS`_, using
`pyCOREPOS`_.
Its main purpose is to setup DB connections for CORE Office, but it
also contains basic readonly web views for some CORE tables.
It provides the following:
* standard configuration for CORE Office + Lane databases
* special :term:`handler` for CORE integration
(:class:`~wutta_corepos.handler.CoreposHandler`)
* readonly web views for primary CORE Office DB tables
* :term:`data model` extension to map
:class:`~wuttjamaican:wuttjamaican.db.model.auth.User` to CORE
Employee
.. _CORE-POS: https://www.core-pos.com/
@ -26,6 +33,8 @@ also contains basic readonly web views for some CORE tables.
api/wutta_corepos
api/wutta_corepos.app
api/wutta_corepos.conf
api/wutta_corepos.db
api/wutta_corepos.db.model
api/wutta_corepos.handler
api/wutta_corepos.web
api/wutta_corepos.web.db

View file

@ -12,8 +12,7 @@ Install the Wutta-COREPOS package to your virtual environment:
pip install Wutta-COREPOS
Edit your :term:`config file` to add CORE-POS DB connection info, and
related settings. Note that so far, only CORE Office DB connections
are supported.
related settings.
.. code-block:: ini
@ -29,4 +28,58 @@ are supported.
[corepos.db.office_arch]
default.url = mysql+mysqlconnector://localhost/trans_archive
[corepos.db.lane_op]
keys = 01, 02
01.url = mysql+mysqlconnector://lane01/opdata
02.url = mysql+mysqlconnector://lane02/opdata
[corepos.db.lane_trans]
keys = 01, 02
01.url = mysql+mysqlconnector://lane01/translog
02.url = mysql+mysqlconnector://lane02/translog
And that's it, the CORE-POS integration is configured.
Schema Extension
----------------
As of writing the only reason to add the schema extension is if you
need to map Wutta Users to CORE Employees, for auth (login) purposes.
So this section can be skipped if you do not need that.
This will effectively add the
:attr:`~wutta_corepos.db.model.CoreUser.corepos_employee_number`
attribute on the
:class:`~wuttjamaican:wuttjamaican.db.model.auth.User` model.
First you must override the :term:`app model` with your own. To do
this, create your own module (e.g. ``poser.db.model``) to contain::
from wuttjamaican.db.model import *
from wutta_corepos.db.model import *
Then configure your app model to override the default:
.. code-block:: ini
[wutta]
model_spec = poser.db.model
Then configure the Alembic section for schema migrations:
.. code-block:: ini
[alembic]
script_location = wuttjamaican.db:alembic
version_locations = wutta_corepos.db:alembic/versions wuttjamaican.db:alembic/versions
And finally run the Alembic command to migrate:
.. code-block:: sh
cd /path/to/env
bin/alembic -c app/wutta.conf upgrade heads
That should do it, from then on any changes will be migrated
automatically during upgrade.

View file

@ -6,7 +6,7 @@ build-backend = "hatchling.build"
[project]
name = "Wutta-COREPOS"
version = "0.2.1"
version = "0.3.0"
description = "Wutta Framework integration for CORE-POS"
readme = "README.md"
authors = [{name = "Lance Edgar", email = "lance@wuttaproject.org"}]
@ -28,8 +28,8 @@ classifiers = [
]
requires-python = ">= 3.8"
dependencies = [
"pyCOREPOS>=0.3.3",
"WuttJamaican>=0.20.1",
"pyCOREPOS>=0.5.1",
"WuttJamaican>=0.20.3",
]

View file

@ -74,6 +74,34 @@ class WuttaCoreposConfigExtension(WuttaConfigExtension):
Dict of ``office_arch`` DB engines. May be empty if no config
is found; otherwise there should at least be a ``default`` key
defined, corresonding to :data:`core_office_arch_engine`.
.. data:: core_lane_op_engine
Primary engine for the ``lane_op`` DB. May be null if no
"default" engine is configured - which is *typical* for a
multi-lane environment. See :data:`core_lane_op_engines` for
the full set.
.. data:: core_lane_op_engines
Dict of ``lane_op`` DB engines. May be empty if no config is
found; otherwise keys are typically like ``01`` and ``02`` etc.
If present, the ``default`` key will correspond to
:data:`core_lane_op_engine`.
.. data:: core_lane_trans_engine
Primary engine for the ``lane_trans`` DB. May be null if no
"default" engine is configured - which is *typical* for a
multi-lane environment. See :data:`core_lane_trans_engines`
for the full set.
.. data:: core_lane_trans_engines
Dict of ``lane_trans`` DB engines. May be empty if no config
is found; otherwise keys are typically like ``01`` and ``02``
etc. If present, the ``default`` key will correspond to
:data:`core_lane_trans_engine`.
"""
key = 'wutta_corepos'
@ -101,6 +129,20 @@ class WuttaCoreposConfigExtension(WuttaConfigExtension):
config.core_office_arch_engine = engines.get('default')
Session.configure(bind=config.core_office_arch_engine)
# lane_op
from corepos.db.lane_op import Session
engines = get_engines(config, 'corepos.db.lane_op')
config.core_lane_op_engines = engines
config.core_lane_op_engine = engines.get('default')
Session.configure(bind=config.core_lane_op_engine)
# lane_trans
from corepos.db.lane_trans import Session
engines = get_engines(config, 'corepos.db.lane_trans')
config.core_lane_trans_engines = engines
config.core_lane_trans_engine = engines.get('default')
Session.configure(bind=config.core_lane_trans_engine)
# define some schema columns "late" unless not supported
if config.get_bool('corepos.db.office_op.use_latest_columns',
default=True, usedb=False):

View file

View file

@ -0,0 +1,37 @@
"""initial user extension
Revision ID: 0f94089f1af1
Revises:
Create Date: 2025-01-24 21:13:14.359200
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import wuttjamaican.db.util
# revision identifiers, used by Alembic.
revision: str = '0f94089f1af1'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = ('wutta_corepos',)
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# corepos_user
op.create_table('corepos_user',
sa.Column('uuid', wuttjamaican.db.util.UUID(), nullable=False),
sa.Column('corepos_employee_number', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['uuid'], ['user.uuid'], name=op.f('fk_corepos_user_uuid_user')),
sa.PrimaryKeyConstraint('uuid', name=op.f('pk_corepos_user')),
sa.UniqueConstraint('corepos_employee_number', name=op.f('uq_corepos_user_corepos_employee_number'))
)
def downgrade() -> None:
# corepos_user
op.drop_table('corepos_user')

View file

@ -0,0 +1,67 @@
# -*- coding: utf-8; -*-
################################################################################
#
# Wutta-COREPOS -- Wutta Framework integration for CORE-POS
# Copyright © 2025 Lance Edgar
#
# This file is part of Wutta Framework.
#
# Wutta Framework 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.
#
# Wutta Framework 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
# Wutta Framework. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Data models for CORE-POS integration
"""
import sqlalchemy as sa
from sqlalchemy import orm
from wuttjamaican.db import model
class CoreUser(model.Base):
"""
CORE-POS extension for
:class:`~wuttjamaican:wuttjamaican.db.model.auth.User`.
"""
__tablename__ = 'corepos_user'
uuid = model.uuid_column(sa.ForeignKey('user.uuid'), default=None)
user = orm.relationship(
model.User,
cascade_backrefs=False,
doc="""
Reference to the
:class:`~wuttjamaican:wuttjamaican.db.model.auth.User` which
this record extends.
""",
backref=orm.backref(
'_corepos',
uselist=False,
cascade='all, delete-orphan',
cascade_backrefs=False,
doc="""
Reference to the CORE-POS extension record for the user.
""")
)
corepos_employee_number = sa.Column(sa.Integer(), nullable=False, unique=True, doc="""
``employees.emp_no`` value for the user within CORE-POS.
""")
def __str__(self):
return str(self.user)
CoreUser.make_proxy(model.User, '_corepos', 'corepos_employee_number')

View file

@ -60,6 +60,24 @@ class CoreposHandler(GenericHandler):
return model
def get_model_lane_op(self):
"""
Returns the :term:`data model` module for CORE Lane 'op' DB,
i.e. :mod:`pycorepos:corepos.db.lane_op.model`.
"""
from corepos.db.lane_op import model
return model
def get_model_lane_trans(self):
"""
Returns the :term:`data model` module for CORE Lane 'trans'
DB, i.e. :mod:`pycorepos:corepos.db.lane_trans.model`.
"""
from corepos.db.lane_trans import model
return model
def make_session_office_op(self, dbkey='default', **kwargs):
"""
Make a new :term:`db session` for the CORE Office 'op' DB.
@ -99,6 +117,32 @@ class CoreposHandler(GenericHandler):
kwargs['bind'] = self.config.core_office_arch_engines[dbkey]
return Session(**kwargs)
def make_session_lane_op(self, dbkey='default', **kwargs):
"""
Make a new :term:`db session` for the CORE Lane 'op' DB.
:returns: Instance of
:class:`pycorepos:corepos.db.lane_op.Session`.
"""
from corepos.db.lane_op import Session
if 'bind' not in kwargs:
kwargs['bind'] = self.config.core_lane_op_engines[dbkey]
return Session(**kwargs)
def make_session_lane_trans(self, dbkey='default', **kwargs):
"""
Make a new :term:`db session` for the CORE Lane 'trans' DB.
:returns: Instance of
:class:`pycorepos:corepos.db.lane_trans.Session`.
"""
from corepos.db.lane_trans import Session
if 'bind' not in kwargs:
kwargs['bind'] = self.config.core_lane_trans_engines[dbkey]
return Session(**kwargs)
def get_office_url(self, require=False):
"""
Returns the base URL for the CORE Office web app.
@ -136,6 +180,28 @@ class CoreposHandler(GenericHandler):
if office_url:
return f'{office_url}/item/departments/DepartmentEditor.php?did={dept_id}'
def get_office_employee_url(
self,
employee_id,
office_url=None,
require=False):
"""
Returns the CORE Office URL for an Employee.
:param employee_id: Employee ID for the URL.
:param office_url: Root URL from :meth:`get_office_url()`.
:param require: If true, an error is raised when URL cannot be
determined.
:returns: URL as string.
"""
if not office_url:
office_url = self.get_office_url(require=require)
if office_url:
return f'{office_url}/admin/Cashiers/CashierEditor.php?emp_no={employee_id}'
def get_office_likecode_url(
self,
likecode_id,

View file

@ -49,6 +49,22 @@ in general.
.. class:: ExtraCoreArchSessions
Dict of secondary CORE Office 'arch' DB sessions, if applicable.
.. class:: CoreLaneOpSession
Primary web app :term:`db session` for CORE Lane 'op' DB.
.. class:: CoreLaneTransSession
Primary web app :term:`db session` for CORE Lane 'trans' DB.
.. class:: ExtraCoreLaneOpSessions
Dict of secondary CORE Lane 'op' DB sessions, if applicable.
.. class:: ExtraCoreLaneTransSessions
Dict of secondary CORE Lane 'trans' DB sessions, if applicable.
"""
from sqlalchemy.orm import sessionmaker, scoped_session
@ -64,7 +80,15 @@ register(CoreTransSession)
CoreArchSession = scoped_session(sessionmaker())
register(CoreArchSession)
CoreLaneOpSession = scoped_session(sessionmaker())
register(CoreLaneOpSession)
CoreLaneTransSession = scoped_session(sessionmaker())
register(CoreLaneTransSession)
# nb. these start out empty but may be populated on app startup
ExtraCoreOpSessions = {}
ExtraCoreTransSessions = {}
ExtraCoreArchSessions = {}
ExtraCoreLaneOpSessions = {}
ExtraCoreLaneTransSessions = {}

View file

@ -100,6 +100,12 @@ class MemberView(CoreOpMasterView):
g.set_renderer('last_name', self.render_customer_attr)
g.set_sorter('last_name', op_model.CustomerClassic.last_name)
# links
if self.has_perm('view'):
g.set_link('card_number')
g.set_link('first_name')
g.set_link('last_name')
def render_customer_attr(self, member, key, value):
""" """
customer = member.customers[0]

16
tests/db/test_model.py Normal file
View file

@ -0,0 +1,16 @@
# -*- coding: utf-8; -*-
from wuttjamaican.testing import ConfigTestCase
from wuttjamaican.db.model import User
from wutta_corepos.db import model as mod
class TestCoreUser(ConfigTestCase):
def test_str(self):
user = User(username='barney')
self.assertEqual(str(user), 'barney')
ext = mod.CoreUser(user=user, corepos_employee_number=42)
self.assertEqual(str(ext), 'barney')

View file

@ -16,14 +16,21 @@ class TestWuttaCoreposConfigExtension(TestCase):
self.assertFalse(hasattr(config, 'core_office_op_engine'))
self.assertFalse(hasattr(config, 'core_office_trans_engine'))
self.assertFalse(hasattr(config, 'core_office_arch_engine'))
self.assertFalse(hasattr(config, 'core_lane_op_engine'))
self.assertFalse(hasattr(config, 'core_lane_trans_engine'))
ext = mod.WuttaCoreposConfigExtension()
ext.configure(config)
self.assertIsNone(config.core_office_op_engine)
self.assertIsNone(config.core_office_trans_engine)
self.assertIsNone(config.core_office_arch_engine)
self.assertIsNone(config.core_lane_op_engine)
self.assertIsNone(config.core_lane_trans_engine)
# but config can change that
config.setdefault('corepos.db.office_op.default.url', 'sqlite://')
config.setdefault('corepos.db.lane_trans.default.url', 'sqlite://')
ext.configure(config)
self.assertIsNotNone(config.core_office_op_engine)
self.assertEqual(str(config.core_office_op_engine.url), 'sqlite://')
self.assertIsNotNone(config.core_lane_trans_engine)
self.assertEqual(str(config.core_lane_trans_engine.url), 'sqlite://')

View file

@ -34,6 +34,18 @@ class TestCoreposHandler(ConfigTestCase):
arch_model = handler.get_model_office_arch()
self.assertIs(arch_model, model)
def test_get_model_lane_op(self):
from corepos.db.lane_op import model
handler = self.make_handler()
op_model = handler.get_model_lane_op()
self.assertIs(op_model, model)
def test_get_model_lane_trans(self):
from corepos.db.lane_trans import model
handler = self.make_handler()
trans_model = handler.get_model_lane_trans()
self.assertIs(trans_model, model)
def test_make_session_office_op(self):
handler = self.make_handler()
engine = sa.create_engine('sqlite://')
@ -61,6 +73,24 @@ class TestCoreposHandler(ConfigTestCase):
self.assertIsInstance(arch_session, orm.Session)
self.assertIs(arch_session.bind, engine)
def test_make_session_lane_op(self):
handler = self.make_handler()
engine = sa.create_engine('sqlite://')
with patch.object(self.config, 'core_lane_op_engines', create=True,
new={'default': engine}):
op_session = handler.make_session_lane_op()
self.assertIsInstance(op_session, orm.Session)
self.assertIs(op_session.bind, engine)
def test_make_session_lane_trans(self):
handler = self.make_handler()
engine = sa.create_engine('sqlite://')
with patch.object(self.config, 'core_lane_trans_engines', create=True,
new={'default': engine}):
trans_session = handler.make_session_lane_trans()
self.assertIsInstance(trans_session, orm.Session)
self.assertIs(trans_session.bind, engine)
def test_get_office_url(self):
handler = self.make_handler()
@ -85,6 +115,16 @@ class TestCoreposHandler(ConfigTestCase):
self.config.setdefault('corepos.office.url', 'http://localhost/fannie/')
self.assertEqual(handler.get_office_department_url(7), 'http://localhost/fannie/item/departments/DepartmentEditor.php?did=7')
def test_get_office_employee_url(self):
handler = self.make_handler()
# null
self.assertIsNone(handler.get_office_employee_url(7))
# typical
self.config.setdefault('corepos.office.url', 'http://localhost/fannie/')
self.assertEqual(handler.get_office_employee_url(7), 'http://localhost/fannie/admin/Cashiers/CashierEditor.php?emp_no=7')
def test_get_office_likecode_url(self):
handler = self.make_handler()

View file

@ -1,5 +1,7 @@
# -*- coding: utf-8; -*-
from unittest.mock import patch
from sqlalchemy import orm
from corepos.db.office_op import model as op_model
@ -9,7 +11,7 @@ from wuttaweb.testing import WebTestCase
from wutta_corepos.web.views.corepos import members as mod
class TestProductView(WebTestCase):
class TestMemberView(WebTestCase):
def make_view(self):
return mod.MemberView(self.request)
@ -28,8 +30,11 @@ class TestProductView(WebTestCase):
view = self.make_view()
grid = view.make_grid(model_class=view.model_class)
self.assertNotIn('first_name', grid.renderers)
view.configure_grid(grid)
self.assertNotIn('first_name', grid.linked_columns)
with patch.object(self.request, 'is_root', new=True):
view.configure_grid(grid)
self.assertIn('first_name', grid.renderers)
self.assertIn('first_name', grid.linked_columns)
def test_render_customer_attr(self):
view = self.make_view()