diff --git a/.pylintrc b/.pylintrc new file mode 100644 index 0000000..7eb5e2c --- /dev/null +++ b/.pylintrc @@ -0,0 +1,4 @@ +# -*- mode: conf; -*- + +[MESSAGES CONTROL] +disable=fixme diff --git a/CHANGELOG.md b/CHANGELOG.md index 7feebeb..9b25e7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,27 @@ 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 + +- add latest schema columns on app startup, unless not supported + ## v0.2.0 (2025-01-13) ### Feat 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..f27fe59 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -8,32 +8,32 @@ from importlib.metadata import version as get_version -project = 'Wutta-COREPOS' -copyright = '2025, Lance Edgar' -author = 'Lance Edgar' -release = get_version('Wutta-COREPOS') +project = "Wutta-COREPOS" +copyright = "2025, Lance Edgar" +author = "Lance Edgar" +release = get_version("Wutta-COREPOS") # -- General configuration --------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration extensions = [ - 'sphinx.ext.autodoc', - 'sphinx.ext.intersphinx', - 'sphinx.ext.viewcode', - 'sphinx.ext.todo', + "sphinx.ext.autodoc", + "sphinx.ext.intersphinx", + "sphinx.ext.viewcode", + "sphinx.ext.todo", ] -templates_path = ['_templates'] -exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] +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), } # -- Options for HTML output ------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output -html_theme = 'furo' -html_static_path = ['_static'] +html_theme = "furo" +html_static_path = ["_static"] diff --git a/docs/index.rst b/docs/index.rst index 4f5d57b..0effd08 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -5,13 +5,26 @@ 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/ .. _pyCOREPOS: https://pypi.org/project/pyCOREPOS/ +.. image:: https://img.shields.io/badge/linting-pylint-yellowgreen + :target: https://github.com/pylint-dev/pylint + +.. image:: https://img.shields.io/badge/code%20style-black-000000.svg + :target: https://github.com/psf/black + .. toctree:: :maxdepth: 2 @@ -26,6 +39,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 6f77e55..c252e30 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "Wutta-COREPOS" -version = "0.2.0" +version = "0.3.0" description = "Wutta Framework integration for CORE-POS" readme = "README.md" authors = [{name = "Lance Edgar", email = "lance@wuttaproject.org"}] @@ -28,15 +28,15 @@ classifiers = [ ] requires-python = ">= 3.8" dependencies = [ - "pyCOREPOS>=0.3.3", - "WuttJamaican>=0.20.1", + "pyCOREPOS>=0.5.1", + "WuttJamaican>=0.20.3", ] [project.optional-dependencies] web = ["WuttaWeb"] docs = ["Sphinx", "furo"] -tests = ["pytest-cov", "tox"] +tests = ["pylint", "pytest", "pytest-cov", "tox"] [project.entry-points."wutta.app.providers"] diff --git a/src/wutta_corepos/_version.py b/src/wutta_corepos/_version.py index 7601e62..c78b835 100644 --- a/src/wutta_corepos/_version.py +++ b/src/wutta_corepos/_version.py @@ -1,6 +1,9 @@ # -*- coding: utf-8; -*- +""" +Package Version +""" from importlib.metadata import version -__version__ = version('Wutta-COREPOS') +__version__ = version("Wutta-COREPOS") diff --git a/src/wutta_corepos/app.py b/src/wutta_corepos/app.py index 7949339..a937686 100644 --- a/src/wutta_corepos/app.py +++ b/src/wutta_corepos/app.py @@ -41,9 +41,11 @@ class WuttaCoreposAppProvider(AppProvider): :rtype: :class:`~wutta_corepos.handler.CoreposHandler` """ - if not hasattr(self, 'corepos_handler'): - spec = self.config.get(f'{self.appname}.corepos_handler', - default='wutta_corepos.handler:CoreposHandler') + if "corepos" not in self.app.handlers: + spec = self.config.get( + f"{self.appname}.corepos_handler", + default="wutta_corepos.handler:CoreposHandler", + ) factory = self.app.load_object(spec) - self.corepos_handler = factory(self.config, **kwargs) - return self.corepos_handler + self.app.handlers["corepos"] = factory(self.config, **kwargs) + return self.app.handlers["corepos"] diff --git a/src/wutta_corepos/conf.py b/src/wutta_corepos/conf.py index ef4c917..2c2a309 100644 --- a/src/wutta_corepos/conf.py +++ b/src/wutta_corepos/conf.py @@ -74,29 +74,97 @@ 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`. - """ - key = 'wutta_corepos' - def configure(self, config): + .. 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" + + def configure(self, config): # pylint: disable=empty-docstring """ """ # office_op - from corepos.db.office_op import Session - engines = get_engines(config, 'corepos.db.office_op') + from corepos.db.office_op import ( # pylint: disable=import-outside-toplevel + Session as OfficeOpSession, + ) + + engines = get_engines(config, "corepos.db.office_op") config.core_office_op_engines = engines - config.core_office_op_engine = engines.get('default') - Session.configure(bind=config.core_office_op_engine) + config.core_office_op_engine = engines.get("default") + OfficeOpSession.configure(bind=config.core_office_op_engine) # office_trans - from corepos.db.office_trans import Session - engines = get_engines(config, 'corepos.db.office_trans') + from corepos.db.office_trans import ( # pylint: disable=import-outside-toplevel + Session as OfficeTransSession, + ) + + engines = get_engines(config, "corepos.db.office_trans") config.core_office_trans_engines = engines - config.core_office_trans_engine = engines.get('default') - Session.configure(bind=config.core_office_trans_engine) + config.core_office_trans_engine = engines.get("default") + OfficeTransSession.configure(bind=config.core_office_trans_engine) # office_arch - from corepos.db.office_arch import Session - engines = get_engines(config, 'corepos.db.office_arch') + from corepos.db.office_arch import ( # pylint: disable=import-outside-toplevel + Session as OfficeArchSession, + ) + + engines = get_engines(config, "corepos.db.office_arch") config.core_office_arch_engines = engines - config.core_office_arch_engine = engines.get('default') - Session.configure(bind=config.core_office_arch_engine) + config.core_office_arch_engine = engines.get("default") + OfficeArchSession.configure(bind=config.core_office_arch_engine) + + # lane_op + from corepos.db.lane_op import ( # pylint: disable=import-outside-toplevel + Session as LaneOpSession, + ) + + engines = get_engines(config, "corepos.db.lane_op") + config.core_lane_op_engines = engines + config.core_lane_op_engine = engines.get("default") + LaneOpSession.configure(bind=config.core_lane_op_engine) + + # lane_trans + from corepos.db.lane_trans import ( # pylint: disable=import-outside-toplevel + Session as LaneTransSession, + ) + + engines = get_engines(config, "corepos.db.lane_trans") + config.core_lane_trans_engines = engines + config.core_lane_trans_engine = engines.get("default") + LaneTransSession.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 + ): + from corepos.db.office_op.model import ( # pylint: disable=import-outside-toplevel + use_latest_columns, + ) + + use_latest_columns() 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..cc5ffa5 --- /dev/null +++ b/src/wutta_corepos/db/alembic/versions/0f94089f1af1_initial_user_extension.py @@ -0,0 +1,44 @@ +"""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..9dba587 --- /dev/null +++ b/src/wutta_corepos/db/model.py @@ -0,0 +1,74 @@ +# -*- 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): # pylint: disable=too-few-public-methods + """ + 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..76f0ddc 100644 --- a/src/wutta_corepos/handler.py +++ b/src/wutta_corepos/handler.py @@ -38,7 +38,9 @@ class CoreposHandler(GenericHandler): Returns the :term:`data model` module for CORE Office 'op' DB, i.e. :mod:`pycorepos:corepos.db.office_op.model`. """ - from corepos.db.office_op import model + from corepos.db.office_op import ( # pylint: disable=import-outside-toplevel + model, + ) return model @@ -47,7 +49,9 @@ class CoreposHandler(GenericHandler): Returns the :term:`data model` module for CORE Office 'trans' DB, i.e. :mod:`pycorepos:corepos.db.office_trans.model`. """ - from corepos.db.office_trans import model + from corepos.db.office_trans import ( # pylint: disable=import-outside-toplevel + model, + ) return model @@ -56,47 +60,105 @@ class CoreposHandler(GenericHandler): Returns the :term:`data model` module for CORE Office 'arch' DB, i.e. :mod:`pycorepos:corepos.db.office_arch.model`. """ - from corepos.db.office_arch import model + from corepos.db.office_arch import ( # pylint: disable=import-outside-toplevel + model, + ) return model - def make_session_office_op(self, dbkey='default', **kwargs): + 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 # pylint: disable=import-outside-toplevel + + 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 ( # pylint: disable=import-outside-toplevel + model, + ) + + return model + + def make_session_office_op(self, dbkey="default", **kwargs): """ Make a new :term:`db session` for the CORE Office 'op' DB. :returns: Instance of :class:`pycorepos:corepos.db.office_op.Session`. """ - from corepos.db.office_op import Session + from corepos.db.office_op import ( # pylint: disable=import-outside-toplevel + Session, + ) - if 'bind' not in kwargs: - kwargs['bind'] = self.config.core_office_op_engines[dbkey] + if "bind" not in kwargs: + kwargs["bind"] = self.config.core_office_op_engines[dbkey] return Session(**kwargs) - def make_session_office_trans(self, dbkey='default', **kwargs): + def make_session_office_trans(self, dbkey="default", **kwargs): """ Make a new :term:`db session` for the CORE Office 'trans' DB. :returns: Instance of :class:`pycorepos:corepos.db.office_trans.Session`. """ - from corepos.db.office_trans import Session + from corepos.db.office_trans import ( # pylint: disable=import-outside-toplevel + Session, + ) - if 'bind' not in kwargs: - kwargs['bind'] = self.config.core_office_trans_engines[dbkey] + if "bind" not in kwargs: + kwargs["bind"] = self.config.core_office_trans_engines[dbkey] return Session(**kwargs) - def make_session_office_arch(self, dbkey='default', **kwargs): + def make_session_office_arch(self, dbkey="default", **kwargs): """ Make a new :term:`db session` for the CORE Office 'arch' DB. :returns: Instance of :class:`pycorepos:corepos.db.office_arch.Session`. """ - from corepos.db.office_arch import Session + from corepos.db.office_arch import ( # pylint: disable=import-outside-toplevel + Session, + ) - if 'bind' not in kwargs: - kwargs['bind'] = self.config.core_office_arch_engines[dbkey] + if "bind" not in kwargs: + 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 ( # pylint: disable=import-outside-toplevel + 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 ( # pylint: disable=import-outside-toplevel + 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): @@ -110,15 +172,12 @@ class CoreposHandler(GenericHandler): :returns: URL as string. """ - url = self.config.get('corepos.office.url', require=require) + url = self.config.get("corepos.office.url", require=require) if url: - return url.rstrip('/') + return url.rstrip("/") + return None - def get_office_department_url( - self, - dept_id, - office_url=None, - require=False): + def get_office_department_url(self, dept_id, office_url=None, require=False): """ Returns the CORE Office URL for a Department. @@ -134,13 +193,29 @@ class CoreposHandler(GenericHandler): if not office_url: office_url = self.get_office_url(require=require) if office_url: - return f'{office_url}/item/departments/DepartmentEditor.php?did={dept_id}' + return f"{office_url}/item/departments/DepartmentEditor.php?did={dept_id}" + return None - def get_office_likecode_url( - self, - likecode_id, - office_url=None, - require=False): + 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}" + return None + + def get_office_likecode_url(self, likecode_id, office_url=None, require=False): """ Returns the CORE Office URL for a Like Code. @@ -156,13 +231,10 @@ class CoreposHandler(GenericHandler): if not office_url: office_url = self.get_office_url(require=require) if office_url: - return f'{office_url}/item/likecodes/LikeCodeEditor.php?start={likecode_id}' + return f"{office_url}/item/likecodes/LikeCodeEditor.php?start={likecode_id}" + return None - def get_office_product_url( - self, - upc, - office_url=None, - require=False): + def get_office_product_url(self, upc, office_url=None, require=False): """ Returns the CORE Office URL for a Product. @@ -178,13 +250,10 @@ class CoreposHandler(GenericHandler): if not office_url: office_url = self.get_office_url(require=require) if office_url: - return f'{office_url}/item/ItemEditorPage.php?searchupc={upc}' + return f"{office_url}/item/ItemEditorPage.php?searchupc={upc}" + return None - def get_office_vendor_url( - self, - vend_id, - office_url=None, - require=False): + def get_office_vendor_url(self, vend_id, office_url=None, require=False): """ Returns the CORE Office URL for a Vendor. @@ -200,4 +269,5 @@ class CoreposHandler(GenericHandler): if not office_url: office_url = self.get_office_url(require=require) if office_url: - return f'{office_url}/item/vendors/VendorIndexPage.php?vid={vend_id}' + return f"{office_url}/item/vendors/VendorIndexPage.php?vid={vend_id}" + return None diff --git a/src/wutta_corepos/web/__init__.py b/src/wutta_corepos/web/__init__.py index 71a9477..d3a2ac9 100644 --- a/src/wutta_corepos/web/__init__.py +++ b/src/wutta_corepos/web/__init__.py @@ -25,5 +25,5 @@ Wutta-COREPOS -- wuttaweb features """ -def includeme(config): - config.include('wutta_corepos.web.views') +def includeme(config): # pylint: disable=missing-function-docstring + config.include("wutta_corepos.web.views") 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/__init__.py b/src/wutta_corepos/web/views/__init__.py index d4112a2..c36452d 100644 --- a/src/wutta_corepos/web/views/__init__.py +++ b/src/wutta_corepos/web/views/__init__.py @@ -25,5 +25,5 @@ Wutta-COREPOS -- wuttaweb views """ -def includeme(config): - config.include('wutta_corepos.web.views.corepos') +def includeme(config): # pylint: disable=missing-function-docstring + config.include("wutta_corepos.web.views.corepos") diff --git a/src/wutta_corepos/web/views/corepos/__init__.py b/src/wutta_corepos/web/views/corepos/__init__.py index a78e4ef..fff81f8 100644 --- a/src/wutta_corepos/web/views/corepos/__init__.py +++ b/src/wutta_corepos/web/views/corepos/__init__.py @@ -27,6 +27,6 @@ Views for CORE-POS from .master import CoreOpMasterView -def includeme(config): - config.include('wutta_corepos.web.views.corepos.members') - config.include('wutta_corepos.web.views.corepos.products') +def includeme(config): # pylint: disable=missing-function-docstring + config.include("wutta_corepos.web.views.corepos.members") + config.include("wutta_corepos.web.views.corepos.products") diff --git a/src/wutta_corepos/web/views/corepos/master.py b/src/wutta_corepos/web/views/corepos/master.py index 817a443..b0d9ab2 100644 --- a/src/wutta_corepos/web/views/corepos/master.py +++ b/src/wutta_corepos/web/views/corepos/master.py @@ -29,10 +29,11 @@ from wuttaweb.views import MasterView from wutta_corepos.web.db import CoreOpSession -class CoreOpMasterView(MasterView): +class CoreOpMasterView(MasterView): # pylint: disable=abstract-method """ Base class for master views which use the CORE Office 'op' DB. """ + Session = CoreOpSession def __init__(self, request, context=None): diff --git a/src/wutta_corepos/web/views/corepos/members.py b/src/wutta_corepos/web/views/corepos/members.py index 66c967e..ffaffd3 100644 --- a/src/wutta_corepos/web/views/corepos/members.py +++ b/src/wutta_corepos/web/views/corepos/members.py @@ -32,7 +32,7 @@ from corepos.db.office_op.model import MemberInfo from wutta_corepos.web.views.corepos import CoreOpMasterView -class MemberView(CoreOpMasterView): +class MemberView(CoreOpMasterView): # pylint: disable=abstract-method """ Master view for :class:`~pycorepos:corepos.db.office_op.model.MemberInfo`; route @@ -43,10 +43,11 @@ class MemberView(CoreOpMasterView): * ``/corepos/members/`` * ``/corepos/members/XXX`` """ + model_class = MemberInfo model_title = "CORE-POS Member" - route_prefix = 'corepos_members' - url_prefix = '/corepos/members' + route_prefix = "corepos_members" + url_prefix = "/corepos/members" # nb. this is just for readonly lookup creatable = False @@ -54,64 +55,76 @@ class MemberView(CoreOpMasterView): deletable = False grid_columns = [ - 'card_number', - 'first_name', - 'last_name', - 'street', - 'city', - 'state', - 'zip', - 'phone', - 'email', + "card_number", + "first_name", + "last_name", + "street", + "city", + "state", + "zip", + "phone", + "email", ] filter_defaults = { - 'card_number': {'active': True, 'verb': 'equal'}, - 'first_name': {'active': True, 'verb': 'contains'}, - 'last_name': {'active': True, 'verb': 'contains'}, + "card_number": {"active": True, "verb": "equal"}, + "first_name": {"active": True, "verb": "contains"}, + "last_name": {"active": True, "verb": "contains"}, } - sort_defaults = 'card_number' + sort_defaults = "card_number" - def get_query(self, session=None): + def get_query(self, session=None): # pylint: disable=empty-docstring """ """ query = super().get_query(session=session) op_model = self.corepos_handler.get_model_office_op() - query = query.outerjoin(op_model.CustomerClassic, - sa.and_( - op_model.CustomerClassic.card_number == op_model.MemberInfo.card_number, - op_model.CustomerClassic.person_number == 1, - ))\ - .options(orm.joinedload(op_model.MemberInfo.customers)) + query = query.outerjoin( + op_model.CustomerClassic, + sa.and_( + op_model.CustomerClassic.card_number == op_model.MemberInfo.card_number, + op_model.CustomerClassic.person_number == 1, + ), + ).options(orm.joinedload(op_model.MemberInfo.customers)) return query - def configure_grid(self, g): + def configure_grid(self, grid): # pylint: disable=empty-docstring """ """ + g = grid super().configure_grid(g) op_model = self.corepos_handler.get_model_office_op() # first_name - g.set_renderer('first_name', self.render_customer_attr) - g.set_sorter('first_name', op_model.CustomerClassic.first_name) + g.set_renderer("first_name", self.render_customer_attr) + g.set_sorter("first_name", op_model.CustomerClassic.first_name) # last_name - g.set_renderer('last_name', self.render_customer_attr) - g.set_sorter('last_name', op_model.CustomerClassic.last_name) + g.set_renderer("last_name", self.render_customer_attr) + g.set_sorter("last_name", op_model.CustomerClassic.last_name) - def render_customer_attr(self, member, key, value): + # 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( # pylint: disable=unused-argument,empty-docstring + self, member, key, value + ): """ """ customer = member.customers[0] return getattr(customer, key) -def defaults(config, **kwargs): +def defaults(config, **kwargs): # pylint: disable=missing-function-docstring base = globals() - MemberView = kwargs.get('MemberView', base['MemberView']) + MemberView = kwargs.get( # pylint: disable=invalid-name,redefined-outer-name + "MemberView", base["MemberView"] + ) MemberView.defaults(config) -def includeme(config): +def includeme(config): # pylint: disable=missing-function-docstring defaults(config) diff --git a/src/wutta_corepos/web/views/corepos/products.py b/src/wutta_corepos/web/views/corepos/products.py index e8f33c4..19b016d 100644 --- a/src/wutta_corepos/web/views/corepos/products.py +++ b/src/wutta_corepos/web/views/corepos/products.py @@ -29,7 +29,7 @@ from corepos.db.office_op.model import Product from wutta_corepos.web.views.corepos import CoreOpMasterView -class ProductView(CoreOpMasterView): +class ProductView(CoreOpMasterView): # pylint: disable=abstract-method """ Master view for :class:`~pycorepos:corepos.db.office_op.model.Product`; route @@ -40,10 +40,11 @@ class ProductView(CoreOpMasterView): * ``/corepos/products/`` * ``/corepos/products/XXX`` """ + model_class = Product model_title = "CORE-POS Product" - route_prefix = 'corepos_products' - url_prefix = '/corepos/products' + route_prefix = "corepos_products" + url_prefix = "/corepos/products" # nb. this is just for readonly lookup creatable = False @@ -51,47 +52,50 @@ class ProductView(CoreOpMasterView): deletable = False labels = { - 'upc': "UPC", + "upc": "UPC", } grid_columns = [ - 'upc', - 'brand', - 'description', - 'size', - 'department', - 'vendor', - 'normal_price', + "upc", + "brand", + "description", + "size", + "department", + "vendor", + "normal_price", ] filter_defaults = { - 'upc': {'active': True, 'verb': 'contains'}, - 'brand': {'active': True, 'verb': 'contains'}, - 'description': {'active': True, 'verb': 'contains'}, + "upc": {"active": True, "verb": "contains"}, + "brand": {"active": True, "verb": "contains"}, + "description": {"active": True, "verb": "contains"}, } - sort_defaults = 'upc' + sort_defaults = "upc" - def configure_grid(self, g): + def configure_grid(self, grid): # pylint: disable=empty-docstring """ """ + g = grid super().configure_grid(g) # normal_price - g.set_renderer('normal_price', 'currency') + g.set_renderer("normal_price", "currency") # links - g.set_link('upc') - g.set_link('brand') - g.set_link('description') - g.set_link('size') + g.set_link("upc") + g.set_link("brand") + g.set_link("description") + g.set_link("size") -def defaults(config, **kwargs): +def defaults(config, **kwargs): # pylint: disable=missing-function-docstring base = globals() - ProductView = kwargs.get('ProductView', base['ProductView']) + ProductView = kwargs.get( # pylint: disable=invalid-name,redefined-outer-name + "ProductView", base["ProductView"] + ) ProductView.defaults(config) -def includeme(config): +def includeme(config): # pylint: disable=missing-function-docstring defaults(config) diff --git a/tasks.py b/tasks.py index 101278d..1eaed3b 100644 --- a/tasks.py +++ b/tasks.py @@ -15,10 +15,10 @@ def release(c, skip_tests=False): Release a new version of Wutta-COREPOS """ if not skip_tests: - c.run('pytest') + c.run("pytest") - if os.path.exists('dist'): - shutil.rmtree('dist') + if os.path.exists("dist"): + shutil.rmtree("dist") - c.run('python -m build --sdist') - c.run('twine upload dist/*') + c.run("python -m build --sdist") + c.run("twine upload dist/*") diff --git a/tests/db/test_model.py b/tests/db/test_model.py new file mode 100644 index 0000000..8d76745 --- /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..db26ce3 100644 --- a/tests/test_conf.py +++ b/tests/test_conf.py @@ -13,17 +13,24 @@ class TestWuttaCoreposConfigExtension(TestCase): config = WuttaConfig() # no engines by default - 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_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.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.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..b597044 100644 --- a/tests/test_handler.py +++ b/tests/test_handler.py @@ -18,49 +18,95 @@ class TestCoreposHandler(ConfigTestCase): def test_get_model_office_op(self): from corepos.db.office_op import model + handler = self.make_handler() op_model = handler.get_model_office_op() self.assertIs(op_model, model) def test_get_model_office_trans(self): from corepos.db.office_trans import model + handler = self.make_handler() trans_model = handler.get_model_office_trans() self.assertIs(trans_model, model) def test_get_model_office_arch(self): from corepos.db.office_arch import model + handler = self.make_handler() 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://') - with patch.object(self.config, 'core_office_op_engines', create=True, - new={'default': engine}): + engine = sa.create_engine("sqlite://") + with patch.object( + self.config, "core_office_op_engines", create=True, new={"default": engine} + ): op_session = handler.make_session_office_op() self.assertIsInstance(op_session, orm.Session) self.assertIs(op_session.bind, engine) def test_make_session_office_trans(self): handler = self.make_handler() - engine = sa.create_engine('sqlite://') - with patch.object(self.config, 'core_office_trans_engines', create=True, - new={'default': engine}): + engine = sa.create_engine("sqlite://") + with patch.object( + self.config, + "core_office_trans_engines", + create=True, + new={"default": engine}, + ): trans_session = handler.make_session_office_trans() self.assertIsInstance(trans_session, orm.Session) self.assertIs(trans_session.bind, engine) def test_make_session_office_arch(self): handler = self.make_handler() - engine = sa.create_engine('sqlite://') - with patch.object(self.config, 'core_office_arch_engines', create=True, - new={'default': engine}): + engine = sa.create_engine("sqlite://") + with patch.object( + self.config, + "core_office_arch_engines", + create=True, + new={"default": engine}, + ): arch_session = handler.make_session_office_arch() 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() @@ -71,9 +117,11 @@ class TestCoreposHandler(ConfigTestCase): self.assertRaises(ConfigurationError, handler.get_office_url, require=True) # config can specify (traliing slash is stripped) - self.config.setdefault('corepos.office.url', 'http://localhost/fannie/') - self.assertEqual(handler.get_office_url(), 'http://localhost/fannie') - self.assertEqual(handler.get_office_url(require=True), 'http://localhost/fannie') + self.config.setdefault("corepos.office.url", "http://localhost/fannie/") + self.assertEqual(handler.get_office_url(), "http://localhost/fannie") + self.assertEqual( + handler.get_office_url(require=True), "http://localhost/fannie" + ) def test_get_office_department_url(self): handler = self.make_handler() @@ -82,8 +130,24 @@ class TestCoreposHandler(ConfigTestCase): self.assertIsNone(handler.get_office_department_url(7)) # typical - 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') + 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() @@ -92,18 +156,24 @@ class TestCoreposHandler(ConfigTestCase): self.assertIsNone(handler.get_office_likecode_url(7)) # typical - self.config.setdefault('corepos.office.url', 'http://localhost/fannie/') - self.assertEqual(handler.get_office_likecode_url(7), 'http://localhost/fannie/item/likecodes/LikeCodeEditor.php?start=7') + self.config.setdefault("corepos.office.url", "http://localhost/fannie/") + self.assertEqual( + handler.get_office_likecode_url(7), + "http://localhost/fannie/item/likecodes/LikeCodeEditor.php?start=7", + ) def test_get_office_product_url(self): handler = self.make_handler() # null - self.assertIsNone(handler.get_office_product_url('07430500132')) + self.assertIsNone(handler.get_office_product_url("07430500132")) # typical - self.config.setdefault('corepos.office.url', 'http://localhost/fannie/') - self.assertEqual(handler.get_office_product_url('07430500132'), 'http://localhost/fannie/item/ItemEditorPage.php?searchupc=07430500132') + self.config.setdefault("corepos.office.url", "http://localhost/fannie/") + self.assertEqual( + handler.get_office_product_url("07430500132"), + "http://localhost/fannie/item/ItemEditorPage.php?searchupc=07430500132", + ) def test_get_office_vendor_url(self): handler = self.make_handler() @@ -112,5 +182,8 @@ class TestCoreposHandler(ConfigTestCase): self.assertIsNone(handler.get_office_vendor_url(7)) # typical - self.config.setdefault('corepos.office.url', 'http://localhost/fannie/') - self.assertEqual(handler.get_office_vendor_url(7), 'http://localhost/fannie/item/vendors/VendorIndexPage.php?vid=7') + self.config.setdefault("corepos.office.url", "http://localhost/fannie/") + self.assertEqual( + handler.get_office_vendor_url(7), + "http://localhost/fannie/item/vendors/VendorIndexPage.php?vid=7", + ) diff --git a/tests/web/views/corepos/test_members.py b/tests/web/views/corepos/test_members.py index 6055ef9..9c4f557 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) @@ -27,13 +29,18 @@ class TestProductView(WebTestCase): def test_configure_grid(self): view = self.make_view() grid = view.make_grid(model_class=view.model_class) - self.assertNotIn('first_name', grid.renderers) - view.configure_grid(grid) - self.assertIn('first_name', grid.renderers) + self.assertNotIn("first_name", grid.renderers) + 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() member = op_model.MemberInfo() customer = op_model.CustomerClassic(first_name="Fred") member.customers.append(customer) - self.assertEqual(view.render_customer_attr(member, 'first_name', 'nope'), "Fred") + self.assertEqual( + view.render_customer_attr(member, "first_name", "nope"), "Fred" + ) diff --git a/tests/web/views/corepos/test_products.py b/tests/web/views/corepos/test_products.py index 8870208..e2e988e 100644 --- a/tests/web/views/corepos/test_products.py +++ b/tests/web/views/corepos/test_products.py @@ -16,6 +16,6 @@ class TestProductView(WebTestCase): def test_configure_grid(self): view = self.make_view() grid = view.make_grid(model_class=view.model_class) - self.assertNotIn('upc', grid.linked_columns) + self.assertNotIn("upc", grid.linked_columns) view.configure_grid(grid) - self.assertIn('upc', grid.linked_columns) + self.assertIn("upc", grid.linked_columns) diff --git a/tox.ini b/tox.ini index 0ff0e30..20021b1 100644 --- a/tox.ini +++ b/tox.ini @@ -3,15 +3,19 @@ envlist = py38, py39, py310, py311 [testenv] -extras = tests +extras = web,tests commands = pytest {posargs} +[testenv:pylint] +basepython = python3.11 +commands = pylint wutta_corepos + [testenv:coverage] basepython = python3.11 commands = pytest --cov=wutta_corepos --cov-report=html --cov-fail-under=100 [testenv:docs] basepython = python3.11 -extras = docs +extras = web,docs changedir = docs commands = sphinx-build -b html -d {envtmpdir}/doctrees -W -T . {envtmpdir}/docs