diff --git a/CHANGELOG.md b/CHANGELOG.md index 24f220b..9b25e7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/api/wutta_corepos.db.model.rst b/docs/api/wutta_corepos.db.model.rst new file mode 100644 index 0000000..9954c44 --- /dev/null +++ b/docs/api/wutta_corepos.db.model.rst @@ -0,0 +1,6 @@ + +``wutta_corepos.db.model`` +========================== + +.. automodule:: wutta_corepos.db.model + :members: diff --git a/docs/api/wutta_corepos.db.rst b/docs/api/wutta_corepos.db.rst new file mode 100644 index 0000000..c04bb3e --- /dev/null +++ b/docs/api/wutta_corepos.db.rst @@ -0,0 +1,6 @@ + +``wutta_corepos.db`` +==================== + +.. automodule:: wutta_corepos.db + :members: diff --git a/docs/conf.py b/docs/conf.py index 4759410..2f4f53f 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -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), } diff --git a/docs/index.rst b/docs/index.rst index 4f5d57b..a9beddd 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -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 diff --git a/docs/narr/install.rst b/docs/narr/install.rst index 33e7cd2..fdff505 100644 --- a/docs/narr/install.rst +++ b/docs/narr/install.rst @@ -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. diff --git a/pyproject.toml b/pyproject.toml index afb21c9..ba60abb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", ] diff --git a/src/wutta_corepos/conf.py b/src/wutta_corepos/conf.py index 77bb6cc..7c3affb 100644 --- a/src/wutta_corepos/conf.py +++ b/src/wutta_corepos/conf.py @@ -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): diff --git a/src/wutta_corepos/db/__init__.py b/src/wutta_corepos/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/wutta_corepos/db/alembic/versions/0f94089f1af1_initial_user_extension.py b/src/wutta_corepos/db/alembic/versions/0f94089f1af1_initial_user_extension.py new file mode 100644 index 0000000..92ed240 --- /dev/null +++ b/src/wutta_corepos/db/alembic/versions/0f94089f1af1_initial_user_extension.py @@ -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') diff --git a/src/wutta_corepos/db/model.py b/src/wutta_corepos/db/model.py new file mode 100644 index 0000000..89cf90d --- /dev/null +++ b/src/wutta_corepos/db/model.py @@ -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 . +# +################################################################################ +""" +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') diff --git a/src/wutta_corepos/handler.py b/src/wutta_corepos/handler.py index 8b39795..f52ef8e 100644 --- a/src/wutta_corepos/handler.py +++ b/src/wutta_corepos/handler.py @@ -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, diff --git a/src/wutta_corepos/web/db.py b/src/wutta_corepos/web/db.py index 7faeff2..4c9e292 100644 --- a/src/wutta_corepos/web/db.py +++ b/src/wutta_corepos/web/db.py @@ -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 = {} diff --git a/src/wutta_corepos/web/views/corepos/members.py b/src/wutta_corepos/web/views/corepos/members.py index 66c967e..19a6268 100644 --- a/src/wutta_corepos/web/views/corepos/members.py +++ b/src/wutta_corepos/web/views/corepos/members.py @@ -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] diff --git a/tests/db/test_model.py b/tests/db/test_model.py new file mode 100644 index 0000000..19135d3 --- /dev/null +++ b/tests/db/test_model.py @@ -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') diff --git a/tests/test_conf.py b/tests/test_conf.py index c957dce..10f97a0 100644 --- a/tests/test_conf.py +++ b/tests/test_conf.py @@ -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://') diff --git a/tests/test_handler.py b/tests/test_handler.py index 6dc5077..f751f22 100644 --- a/tests/test_handler.py +++ b/tests/test_handler.py @@ -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() diff --git a/tests/web/views/corepos/test_members.py b/tests/web/views/corepos/test_members.py index 6055ef9..b3da7b0 100644 --- a/tests/web/views/corepos/test_members.py +++ b/tests/web/views/corepos/test_members.py @@ -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()