Compare commits

...

18 commits

Author SHA1 Message Date
Lance Edgar eb9291fce7 bump: version 0.2.1 → 0.3.0 2025-02-20 09:32:26 -06:00
Lance Edgar f0ac1d9bd4 docs: update intersphinx doc links per server migration 2025-02-18 12:16:32 -06:00
Lance Edgar 4df38318e3 fix: bump min version for wuttjamaican
need Base.make_proxy()
2025-01-25 19:11:46 -06:00
Lance Edgar 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
Lance Edgar 73192a162d fix: bump version requirement for pyCOREPOS 2025-01-24 19:59:46 -06:00
Lance Edgar 1a9929c734 fix: add get_office_employee_url() method for corepos handler 2025-01-24 19:52:14 -06:00
Lance Edgar e6921c8533 feat: add support for lane_op and lane_trans DB sessions, models 2025-01-24 19:52:12 -06:00
Lance Edgar bf6bf63e68 tests: fix coverage for members view 2025-01-15 17:19:59 -06:00
Lance Edgar d1de2389a5 fix: add grid links for Members 2025-01-15 14:57:45 -06:00
Lance Edgar 34e9528f4b fix: bump min version for pycorepos dependency 2025-01-15 11:06:15 -06:00
Lance Edgar ce145ed00f bump: version 0.2.0 → 0.2.1 2025-01-15 11:02:28 -06:00
Lance Edgar 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
Lance Edgar 57ec40ab20 bump: version 0.1.0 → 0.2.0 2025-01-13 13:32:19 -06:00
Lance Edgar 05f428586b feat: add basic readonly web views for CORE members, products
also adds canonical web app db sessions for CORE

also adds some methods to corepos handler, to get model / make session
2025-01-12 20:04:19 -06:00
Lance Edgar b134e340ff docs: update copyright to 2025
whoops missed that when starting project
2025-01-12 13:21:49 -06:00
Lance Edgar d11e186df9 fix: add docs, tests; tweak some handler method signatures 2025-01-12 01:10:52 -06:00
43 changed files with 1429 additions and 25 deletions

View file

@ -5,6 +5,37 @@ 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
- add basic readonly web views for CORE members, products
### Fix
- add docs, tests; tweak some handler method signatures
## v0.1.0 (2025-01-12)
### Feat

20
docs/Makefile Normal file
View file

@ -0,0 +1,20 @@
# Minimal makefile for Sphinx documentation
#
# You can set these variables from the command line, and also
# from the environment for the first two.
SPHINXOPTS ?=
SPHINXBUILD ?= sphinx-build
SOURCEDIR = .
BUILDDIR = _build
# Put it first so that "make" without argument is like "make help".
help:
@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
.PHONY: help Makefile
# Catch-all target: route all unknown targets to Sphinx using the new
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
%: Makefile
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)

0
docs/_static/.keepme vendored Normal file
View file

View file

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

View file

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

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

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,6 @@
``wutta_corepos.web.views.corepos.master``
==========================================
.. automodule:: wutta_corepos.web.views.corepos.master
:members:

View file

@ -0,0 +1,6 @@
``wutta_corepos.web.views.corepos.members``
===========================================
.. automodule:: wutta_corepos.web.views.corepos.members
:members:

View file

@ -0,0 +1,6 @@
``wutta_corepos.web.views.corepos.products``
============================================
.. automodule:: wutta_corepos.web.views.corepos.products
:members:

View file

@ -0,0 +1,6 @@
``wutta_corepos.web.views.corepos``
===================================
.. automodule:: wutta_corepos.web.views.corepos
:members:

View file

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

39
docs/conf.py Normal file
View file

@ -0,0 +1,39 @@
# Configuration file for the Sphinx documentation builder.
#
# For the full list of built-in configuration values, see the documentation:
# https://www.sphinx-doc.org/en/master/usage/configuration.html
# -- Project information -----------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
from importlib.metadata import version as get_version
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',
]
templates_path = ['_templates']
exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
intersphinx_mapping = {
'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']

45
docs/index.rst Normal file
View file

@ -0,0 +1,45 @@
Wutta-COREPOS
=============
This package adds basic integration with `CORE-POS`_, using
`pyCOREPOS`_.
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/
.. toctree::
:maxdepth: 2
:caption: Documentation
narr/install
.. toctree::
:maxdepth: 1
:caption: API
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
api/wutta_corepos.web.views
api/wutta_corepos.web.views.corepos
api/wutta_corepos.web.views.corepos.master
api/wutta_corepos.web.views.corepos.members
api/wutta_corepos.web.views.corepos.products

35
docs/make.bat Normal file
View file

@ -0,0 +1,35 @@
@ECHO OFF
pushd %~dp0
REM Command file for Sphinx documentation
if "%SPHINXBUILD%" == "" (
set SPHINXBUILD=sphinx-build
)
set SOURCEDIR=.
set BUILDDIR=_build
%SPHINXBUILD% >NUL 2>NUL
if errorlevel 9009 (
echo.
echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
echo.installed, then set the SPHINXBUILD environment variable to point
echo.to the full path of the 'sphinx-build' executable. Alternatively you
echo.may add the Sphinx directory to PATH.
echo.
echo.If you don't have Sphinx installed, grab it from
echo.https://www.sphinx-doc.org/
exit /b 1
)
if "%1" == "" goto help
%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
goto end
:help
%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
:end
popd

85
docs/narr/install.rst Normal file
View file

@ -0,0 +1,85 @@
Installation
============
This assumes you already have a :doc:`WuttJamaican app
<wuttjamaican:narr/install/index>` setup and working.
Install the Wutta-COREPOS package to your virtual environment:
.. code-block:: sh
pip install Wutta-COREPOS
Edit your :term:`config file` to add CORE-POS DB connection info, and
related settings.
.. code-block:: ini
[corepos]
office.url = http://localhost/fannie/
[corepos.db.office_op]
default.url = mysql+mysqlconnector://localhost/core_op
[corepos.db.office_trans]
default.url = mysql+mysqlconnector://localhost/core_trans
[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.1.0"
version = "0.3.0"
description = "Wutta Framework integration for CORE-POS"
readme = "README.md"
authors = [{name = "Lance Edgar", email = "lance@wuttaproject.org"}]
@ -28,12 +28,13 @@ classifiers = [
]
requires-python = ">= 3.8"
dependencies = [
"pyCOREPOS>=0.3.2",
"WuttJamaican>=0.20.0",
"pyCOREPOS>=0.5.1",
"WuttJamaican>=0.20.3",
]
[project.optional-dependencies]
web = ["WuttaWeb"]
docs = ["Sphinx", "furo"]
tests = ["pytest-cov", "tox"]

View file

@ -2,7 +2,7 @@
################################################################################
#
# Wutta-COREPOS -- Wutta Framework integration for CORE-POS
# Copyright © 2024 Lance Edgar
# Copyright © 2025 Lance Edgar
#
# This file is part of Wutta Framework.
#

View file

@ -2,7 +2,7 @@
################################################################################
#
# Wutta-COREPOS -- Wutta Framework integration for CORE-POS
# Copyright © 2024 Lance Edgar
# Copyright © 2025 Lance Edgar
#
# This file is part of Wutta Framework.
#
@ -36,6 +36,11 @@ class WuttaCoreposAppProvider(AppProvider):
"""
def get_corepos_handler(self, **kwargs):
"""
Get the configured CORE-POS integration handler.
: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')

View file

@ -2,7 +2,7 @@
################################################################################
#
# Wutta-COREPOS -- Wutta Framework integration for CORE-POS
# Copyright © 2024 Lance Edgar
# Copyright © 2025 Lance Edgar
#
# This file is part of Wutta Framework.
#
@ -40,7 +40,7 @@ class WuttaCoreposConfigExtension(WuttaConfigExtension):
* ``office_trans`` (default name ``core_trans``)
* ``office_arch`` (default name ``trans_archive``)
The config object will be given the following attributes:
The :term:`config object` will be given the following attributes:
.. data:: core_office_op_engine
@ -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'
@ -100,3 +128,23 @@ class WuttaCoreposConfigExtension(WuttaConfigExtension):
config.core_office_arch_engines = engines
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):
from corepos.db.office_op.model import use_latest_columns
use_latest_columns()

View file

View file

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

View file

@ -0,0 +1,67 @@
# -*- coding: utf-8; -*-
################################################################################
#
# Wutta-COREPOS -- Wutta Framework integration for CORE-POS
# Copyright © 2025 Lance Edgar
#
# This file is part of Wutta Framework.
#
# Wutta Framework is free software: you can redistribute it and/or modify it
# under the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option) any
# later version.
#
# Wutta Framework is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# Wutta Framework. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Data models for CORE-POS integration
"""
import sqlalchemy as sa
from sqlalchemy import orm
from wuttjamaican.db import model
class CoreUser(model.Base):
"""
CORE-POS extension for
:class:`~wuttjamaican:wuttjamaican.db.model.auth.User`.
"""
__tablename__ = 'corepos_user'
uuid = model.uuid_column(sa.ForeignKey('user.uuid'), default=None)
user = orm.relationship(
model.User,
cascade_backrefs=False,
doc="""
Reference to the
:class:`~wuttjamaican:wuttjamaican.db.model.auth.User` which
this record extends.
""",
backref=orm.backref(
'_corepos',
uselist=False,
cascade='all, delete-orphan',
cascade_backrefs=False,
doc="""
Reference to the CORE-POS extension record for the user.
""")
)
corepos_employee_number = sa.Column(sa.Integer(), nullable=False, unique=True, doc="""
``employees.emp_no`` value for the user within CORE-POS.
""")
def __str__(self):
return str(self.user)
CoreUser.make_proxy(model.User, '_corepos', 'corepos_employee_number')

View file

@ -2,7 +2,7 @@
################################################################################
#
# Wutta-COREPOS -- Wutta Framework integration for CORE-POS
# Copyright © 2024 Lance Edgar
# Copyright © 2025 Lance Edgar
#
# This file is part of Wutta Framework.
#
@ -21,7 +21,7 @@
#
################################################################################
"""
CORE-POS Handler
CORE-POS Integration Handler
"""
from wuttjamaican.app import GenericHandler
@ -33,11 +33,126 @@ class CoreposHandler(GenericHandler):
:term:`handler`.
"""
def get_model_office_op(self):
"""
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
return model
def get_model_office_trans(self):
"""
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
return model
def get_model_office_arch(self):
"""
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
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.
:returns: Instance of
:class:`pycorepos:corepos.db.office_op.Session`.
"""
from corepos.db.office_op import Session
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):
"""
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
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):
"""
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
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 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.
Note that the return value is stripped of final slash.
:param require: If true, an error is raised when URL cannot be
determined.
:returns: URL as string.
"""
url = self.config.get('corepos.office.url', require=require)
if url:
@ -45,40 +160,86 @@ class CoreposHandler(GenericHandler):
def get_office_department_url(
self,
number,
dept_id,
office_url=None,
require=False,
**kwargs):
require=False):
"""
Returns the CORE Office URL for a Department.
:param dept_id: Department 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}/item/departments/DepartmentEditor.php?did={number}'
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,
id,
likecode_id,
office_url=None,
require=False,
**kwargs):
require=False):
"""
Returns the CORE Office URL for a Like Code.
:param likecode_id: Like Code 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}/item/likecodes/LikeCodeEditor.php?start={id}'
return f'{office_url}/item/likecodes/LikeCodeEditor.php?start={likecode_id}'
def get_office_product_url(
self,
upc,
office_url=None,
require=False,
**kwargs):
require=False):
"""
Returns the CORE Office URL for a Product.
:param upc: UPC 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)
@ -87,14 +248,22 @@ class CoreposHandler(GenericHandler):
def get_office_vendor_url(
self,
id,
vend_id,
office_url=None,
require=False,
**kwargs):
require=False):
"""
Returns the CORE Office URL for a Vendor.
:param vend_id: Vendor 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}/item/vendors/VendorIndexPage.php?vid={id}'
return f'{office_url}/item/vendors/VendorIndexPage.php?vid={vend_id}'

View file

@ -0,0 +1,29 @@
# -*- 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/>.
#
################################################################################
"""
Wutta-COREPOS -- wuttaweb features
"""
def includeme(config):
config.include('wutta_corepos.web.views')

View file

@ -0,0 +1,94 @@
# -*- 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/>.
#
################################################################################
"""
Wutta-COREPOS -- wuttaweb DB sessions
See :mod:`wuttaweb:wuttaweb.db.sess` for more info on web app sessions
in general.
.. class:: CoreOpSession
Primary web app :term:`db session` for CORE Office 'op' DB.
.. class:: CoreTransSession
Primary web app :term:`db session` for CORE Office 'trans' DB.
.. class:: CoreArchSession
Primary web app :term:`db session` for CORE Office 'arch' DB.
.. class:: ExtraCoreOpSessions
Dict of secondary CORE Office 'op' DB sessions, if applicable.
.. class:: ExtraCoreTransSessions
Dict of secondary CORE Office 'trans' DB sessions, if applicable.
.. 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
from zope.sqlalchemy import register
CoreOpSession = scoped_session(sessionmaker())
register(CoreOpSession)
CoreTransSession = scoped_session(sessionmaker())
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

@ -0,0 +1,29 @@
# -*- 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/>.
#
################################################################################
"""
Wutta-COREPOS -- wuttaweb views
"""
def includeme(config):
config.include('wutta_corepos.web.views.corepos')

View file

@ -0,0 +1,32 @@
# -*- 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/>.
#
################################################################################
"""
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')

View file

@ -0,0 +1,40 @@
# -*- 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/>.
#
################################################################################
"""
CORE-POS master view base class
"""
from wuttaweb.views import MasterView
from wutta_corepos.web.db import CoreOpSession
class CoreOpMasterView(MasterView):
"""
Base class for master views which use the CORE Office 'op' DB.
"""
Session = CoreOpSession
def __init__(self, request, context=None):
super().__init__(request, context=context)
self.corepos_handler = self.app.get_corepos_handler()

View file

@ -0,0 +1,123 @@
# -*- 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/>.
#
################################################################################
"""
Views for CORE-POS Members
"""
import sqlalchemy as sa
from sqlalchemy import orm
from corepos.db.office_op.model import MemberInfo
from wutta_corepos.web.views.corepos import CoreOpMasterView
class MemberView(CoreOpMasterView):
"""
Master view for
:class:`~pycorepos:corepos.db.office_op.model.MemberInfo`; route
prefix is ``corepos_members``.
Notable URLs provided by this class:
* ``/corepos/members/``
* ``/corepos/members/XXX``
"""
model_class = MemberInfo
model_title = "CORE-POS Member"
route_prefix = 'corepos_members'
url_prefix = '/corepos/members'
# nb. this is just for readonly lookup
creatable = False
editable = False
deletable = False
grid_columns = [
'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'},
}
sort_defaults = 'card_number'
def get_query(self, session=None):
""" """
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))
return query
def configure_grid(self, g):
""" """
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)
# last_name
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]
return getattr(customer, key)
def defaults(config, **kwargs):
base = globals()
MemberView = kwargs.get('MemberView', base['MemberView'])
MemberView.defaults(config)
def includeme(config):
defaults(config)

View file

@ -0,0 +1,97 @@
# -*- 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/>.
#
################################################################################
"""
Views for CORE-POS Products
"""
from corepos.db.office_op.model import Product
from wutta_corepos.web.views.corepos import CoreOpMasterView
class ProductView(CoreOpMasterView):
"""
Master view for
:class:`~pycorepos:corepos.db.office_op.model.Product`; route
prefix is ``corepos_products``.
Notable URLs provided by this class:
* ``/corepos/products/``
* ``/corepos/products/XXX``
"""
model_class = Product
model_title = "CORE-POS Product"
route_prefix = 'corepos_products'
url_prefix = '/corepos/products'
# nb. this is just for readonly lookup
creatable = False
editable = False
deletable = False
labels = {
'upc': "UPC",
}
grid_columns = [
'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'},
}
sort_defaults = 'upc'
def configure_grid(self, g):
""" """
super().configure_grid(g)
# normal_price
g.set_renderer('normal_price', 'currency')
# links
g.set_link('upc')
g.set_link('brand')
g.set_link('description')
g.set_link('size')
def defaults(config, **kwargs):
base = globals()
ProductView = kwargs.get('ProductView', base['ProductView'])
ProductView.defaults(config)
def includeme(config):
defaults(config)

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

16
tests/test_app.py Normal file
View file

@ -0,0 +1,16 @@
# -*- coding: utf-8; -*-
from wuttjamaican.testing import ConfigTestCase
from wutta_corepos import app as mod
from wutta_corepos.handler import CoreposHandler
class TestWuttaCoreposAppProvider(ConfigTestCase):
def make_provider(self):
return mod.WuttaCoreposAppProvider(self.config)
def test_get_report_handler(self):
handler = self.app.get_corepos_handler()
self.assertIsInstance(handler, CoreposHandler)

36
tests/test_conf.py Normal file
View file

@ -0,0 +1,36 @@
# -*- coding: utf-8; -*-
from unittest import TestCase
from wuttjamaican.conf import WuttaConfig
from wutta_corepos import conf as mod
class TestWuttaCoreposConfigExtension(TestCase):
def test_configure(self):
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_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://')

156
tests/test_handler.py Normal file
View file

@ -0,0 +1,156 @@
# -*- coding: utf-8; -*-
from unittest.mock import patch
import sqlalchemy as sa
from sqlalchemy import orm
from wuttjamaican.testing import ConfigTestCase
from wuttjamaican.exc import ConfigurationError
from wutta_corepos import handler as mod
class TestCoreposHandler(ConfigTestCase):
def make_handler(self):
return mod.CoreposHandler(self.config)
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}):
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}):
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}):
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()
# null by default
self.assertIsNone(handler.get_office_url())
# error if required
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')
def test_get_office_department_url(self):
handler = self.make_handler()
# null
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')
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()
# null
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')
def test_get_office_product_url(self):
handler = self.make_handler()
# null
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')
def test_get_office_vendor_url(self):
handler = self.make_handler()
# null
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')

11
tests/web/test_init.py Normal file
View file

@ -0,0 +1,11 @@
# -*- coding: utf-8; -*-
from wuttaweb.testing import WebTestCase
from wutta_corepos import web as mod
class TestIncludeme(WebTestCase):
def test_coverage(self):
return mod.includeme(self.pyramid_config)

View file

@ -0,0 +1,44 @@
# -*- coding: utf-8; -*-
from unittest.mock import patch
from sqlalchemy import orm
from corepos.db.office_op import model as op_model
from wuttaweb.testing import WebTestCase
from wutta_corepos.web.views.corepos import members as mod
class TestMemberView(WebTestCase):
def make_view(self):
return mod.MemberView(self.request)
def test_includeme(self):
return mod.includeme(self.pyramid_config)
def test_get_query(self):
view = self.make_view()
query = view.get_query()
# TODO: not sure how to test the join other than doing data
# setup and full runn-thru...and i'm feeling lazy
self.assertIsInstance(query, orm.Query)
def test_configure_grid(self):
view = self.make_view()
grid = view.make_grid(model_class=view.model_class)
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")

View file

@ -0,0 +1,21 @@
# -*- coding: utf-8; -*-
from wuttaweb.testing import WebTestCase
from wutta_corepos.web.views.corepos import products as mod
class TestProductView(WebTestCase):
def make_view(self):
return mod.ProductView(self.request)
def test_includeme(self):
return mod.includeme(self.pyramid_config)
def test_configure_grid(self):
view = self.make_view()
grid = view.make_grid(model_class=view.model_class)
self.assertNotIn('upc', grid.linked_columns)
view.configure_grid(grid)
self.assertIn('upc', grid.linked_columns)

View file

@ -3,7 +3,7 @@
envlist = py38, py39, py310, py311
[testenv]
extras = tests
extras = web,tests
commands = pytest {posargs}
[testenv:coverage]
@ -12,6 +12,6 @@ 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