Compare commits

...

17 commits

Author SHA1 Message Date
988ebe29d0 fix: address all warnings from pylint 2025-09-01 20:52:34 -05:00
25b5ca127d docs: add badge for black code style 2025-08-31 13:29:46 -05:00
1b4ba2089a fix: format all code with black
and from now on should not deviate from that...
2025-08-31 13:05:00 -05:00
eb9291fce7 bump: version 0.2.1 → 0.3.0 2025-02-20 09:32:26 -06:00
f0ac1d9bd4 docs: update intersphinx doc links per server migration 2025-02-18 12:16:32 -06:00
4df38318e3 fix: bump min version for wuttjamaican
need Base.make_proxy()
2025-01-25 19:11:46 -06:00
4ee5aa5372 feat: add app db schema extension, for CoreUser
need a way to map Wutta User to CORE Employee for auth purposes
2025-01-25 17:38:21 -06:00
73192a162d fix: bump version requirement for pyCOREPOS 2025-01-24 19:59:46 -06:00
1a9929c734 fix: add get_office_employee_url() method for corepos handler 2025-01-24 19:52:14 -06:00
e6921c8533 feat: add support for lane_op and lane_trans DB sessions, models 2025-01-24 19:52:12 -06:00
bf6bf63e68 tests: fix coverage for members view 2025-01-15 17:19:59 -06:00
d1de2389a5 fix: add grid links for Members 2025-01-15 14:57:45 -06:00
34e9528f4b fix: bump min version for pycorepos dependency 2025-01-15 11:06:15 -06:00
ce145ed00f bump: version 0.2.0 → 0.2.1 2025-01-15 11:02:28 -06:00
9f779fe60f fix: add latest schema columns on app startup, unless not supported
this works around the issue where some CORE databases are too
old (etc.) and are missing some schema columns.  in such cases the
config for "latest columns" should be disabled; otherwise we add them
to schema on startup
2025-01-15 11:01:09 -06:00
Lance Edgar
9946642bf2 docs: need web extra when building docs via tox 2025-01-13 13:41:14 -06:00
Lance Edgar
93c5f57928 tests: need web extra when running tox tests 2025-01-13 13:37:40 -06:00
29 changed files with 703 additions and 188 deletions

4
.pylintrc Normal file
View file

@ -0,0 +1,4 @@
# -*- mode: conf; -*-
[MESSAGES CONTROL]
disable=fixme

View file

@ -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

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

@ -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"]

View file

@ -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

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.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"]

View file

@ -1,6 +1,9 @@
# -*- coding: utf-8; -*-
"""
Package Version
"""
from importlib.metadata import version
__version__ = version('Wutta-COREPOS')
__version__ = version("Wutta-COREPOS")

View file

@ -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"]

View file

@ -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()

View file

View file

@ -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")

View file

@ -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 <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): # 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")

View file

@ -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

View file

@ -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")

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

@ -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")

View file

@ -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")

View file

@ -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):

View file

@ -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)

View file

@ -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)

View file

@ -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/*")

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

@ -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://")

View file

@ -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",
)

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)
@ -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"
)

View file

@ -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)

View file

@ -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