diff --git a/.gitignore b/.gitignore index b3006f90..906dc226 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,5 @@ -*~ -*.pyc .coverage .tox/ -dist/ docs/_build/ htmlcov/ Tailbone.egg-info/ diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index c974b3a6..00000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,683 +0,0 @@ - -# Changelog -All notable changes to Tailbone 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.22.7 (2025-02-19) - -### Fix - -- stop using old config for logo image url on login page -- fix warning msg for deprecated Grid param - -## v0.22.6 (2025-02-01) - -### Fix - -- register vue3 form component for products -> make batch - -## v0.22.5 (2024-12-16) - -### Fix - -- whoops this is latest rattail -- require newer rattail lib -- require newer wuttaweb -- let caller request safe HTML literal for rendered grid table - -## v0.22.4 (2024-11-22) - -### Fix - -- avoid error in product search for duplicated key -- use vmodel for confirm password widget input - -## v0.22.3 (2024-11-19) - -### Fix - -- avoid error for trainwreck query when not a customer - -## v0.22.2 (2024-11-18) - -### Fix - -- use local/custom enum for continuum operations -- add basic master view for Product Costs -- show continuum operation type when viewing version history -- always define `app` attr for ViewSupplement -- avoid deprecated import - -## v0.22.1 (2024-11-02) - -### Fix - -- fix submit button for running problem report -- avoid deprecated grid method - -## v0.22.0 (2024-10-22) - -### Feat - -- add support for new ordering batch from parsed file - -### Fix - -- avoid deprecated method to suggest username - -## v0.21.11 (2024-10-03) - -### Fix - -- custom method for adding grid action -- become/stop root should redirect to previous url - -## v0.21.10 (2024-09-15) - -### Fix - -- update project repo links, kallithea -> forgejo -- use better icon for submit button on login page -- wrap notes text for batch view -- expose datasync consumer batch size via configure page - -## v0.21.9 (2024-08-28) - -### Fix - -- render custom attrs in form component tag - -## v0.21.8 (2024-08-28) - -### Fix - -- ignore session kwarg for `MasterView.make_row_grid()` - -## v0.21.7 (2024-08-28) - -### Fix - -- avoid error when form value cannot be obtained - -## v0.21.6 (2024-08-28) - -### Fix - -- avoid error when grid value cannot be obtained - -## v0.21.5 (2024-08-28) - -### Fix - -- set empty string for "-new-" file configure option - -## v0.21.4 (2024-08-26) - -### Fix - -- handle differing email profile keys for appinfo/configure - -## v0.21.3 (2024-08-26) - -### Fix - -- show non-standard config values for app info configure email - -## v0.21.2 (2024-08-26) - -### Fix - -- refactor waterpark base template to use wutta feedback component -- fix input/output file upload feature for configure pages, per oruga -- tweak how grid data translates to Vue template context -- merge filters into main grid template -- add basic wutta view for users -- some fixes for wutta people view -- various fixes for waterpark theme -- avoid deprecated `component` form kwarg - -## v0.21.1 (2024-08-22) - -### Fix - -- misc. bugfixes per recent changes - -## v0.21.0 (2024-08-22) - -### Feat - -- move "most" filtering logic for grid class to wuttaweb -- inherit from wuttaweb templates for home, login pages -- inherit from wuttaweb for AppInfoView, appinfo/configure template -- add "has output file templates" config option for master view - -### Fix - -- change grid reset-view param name to match wuttaweb -- move "searchable columns" grid feature to wuttaweb -- use wuttaweb to get/render csrf token -- inherit from wuttaweb for appinfo/index template -- prefer wuttaweb config for "home redirect to login" feature -- fix master/index template rendering for waterpark theme -- fix spacing for navbar logo/title in waterpark theme - -## v0.20.1 (2024-08-20) - -### Fix - -- fix default filter verbs logic for workorder status - -## v0.20.0 (2024-08-20) - -### Feat - -- add new 'waterpark' theme, based on wuttaweb w/ vue2 + buefy -- refactor templates to simplify base/page/form structure - -### Fix - -- avoid deprecated reference to app db engine - -## v0.19.3 (2024-08-19) - -### Fix - -- add pager stats to all grid vue data (fixes view history) - -## v0.19.2 (2024-08-19) - -### Fix - -- sort on frontend for appinfo package listing grid -- prefer attr over key lookup when getting model values -- replace all occurrences of `component_studly` => `vue_component` - -## v0.19.1 (2024-08-19) - -### Fix - -- fix broken user auth for web API app - -## v0.19.0 (2024-08-18) - -### Feat - -- move multi-column grid sorting logic to wuttaweb -- move single-column grid sorting logic to wuttaweb - -### Fix - -- fix misc. errors in grid template per wuttaweb -- fix broken permission directives in web api startup - -## v0.18.0 (2024-08-16) - -### Feat - -- move "basic" grid pagination logic to wuttaweb -- inherit from wutta base class for Grid -- inherit most logic from wuttaweb, for GridAction - -### Fix - -- avoid route error in user view, when using wutta people view -- fix some more wutta compat for base template - -## v0.17.0 (2024-08-15) - -### Feat - -- use wuttaweb for `get_liburl()` logic - -## v0.16.1 (2024-08-15) - -### Fix - -- improve wutta People view a bit -- update references to `get_class_hierarchy()` -- tweak template for `people/view_profile` per wutta compat - -## v0.16.0 (2024-08-15) - -### Feat - -- add first wutta-based master, for PersonView -- refactor forms/grids/views/templates per wuttaweb compat - -## v0.15.6 (2024-08-13) - -### Fix - -- avoid `before_render` subscriber hook for web API -- simplify verbiage for batch execution panel - -## v0.15.5 (2024-08-09) - -### Fix - -- assign convenience attrs for all views (config, app, enum, model) - -## v0.15.4 (2024-08-09) - -### Fix - -- avoid bug when checking current theme - -## v0.15.3 (2024-08-08) - -### Fix - -- fix timepicker `parseTime()` when value is null - -## v0.15.2 (2024-08-06) - -### Fix - -- use auth handler, avoid legacy calls for role/perm checks - -## v0.15.1 (2024-08-05) - -### Fix - -- move magic `b` template context var to wuttaweb - -## v0.15.0 (2024-08-05) - -### Feat - -- move more subscriber logic to wuttaweb - -### Fix - -- use wuttaweb logic for `util.get_form_data()` - -## v0.14.5 (2024-08-03) - -### Fix - -- use auth handler instead of deprecated auth functions -- avoid duplicate `partial` param when grid reloads data - -## v0.14.4 (2024-07-18) - -### Fix - -- fix more settings persistence bug(s) for datasync/configure -- fix modals for luigi tasks page, per oruga - -## v0.14.3 (2024-07-17) - -### Fix - -- fix auto-collapse title for viewing trainwreck txn -- allow auto-collapse of header when viewing trainwreck txn - -## v0.14.2 (2024-07-15) - -### Fix - -- add null menu handler, for use with API apps - -## v0.14.1 (2024-07-14) - -### Fix - -- update usage of auth handler, per rattail changes -- fix model reference in menu handler -- fix bug when making "integration" menus - -## v0.14.0 (2024-07-14) - -### Feat - -- move core menu logic to wuttaweb - -## v0.13.2 (2024-07-13) - -### Fix - -- fix logic bug for datasync/config settings save - -## v0.13.1 (2024-07-13) - -### Fix - -- fix settings persistence bug(s) for datasync/configure page - -## v0.13.0 (2024-07-12) - -### Feat - -- begin integrating WuttaWeb as upstream dependency - -### Fix - -- cast enum as list to satisfy deform widget - -## v0.12.1 (2024-07-11) - -### Fix - -- refactor `config.get_model()` => `app.model` - -## v0.12.0 (2024-07-09) - -### Feat - -- drop python 3.6 support, use pyproject.toml (again) - -## v0.11.10 (2024-07-05) - -### Fix - -- make the Members tab optional, for profile view - -## v0.11.9 (2024-07-05) - -### Fix - -- do not show flash message when changing app theme - -- improve collapse panels for butterball theme - -- expand input for butterball theme - -- add xref button to customer profile, for trainwreck txn view - -- add optional Transactions tab for profile view - -## v0.11.8 (2024-07-04) - -### Fix - -- fix grid action icons for datasync/configure, per oruga - -- allow view supplements to add extra links for profile employee tab - -- leverage import handler method to determine command/subcommand - -- add tool to make user account from profile view - -## v0.11.7 (2024-07-04) - -### Fix - -- add stacklevel to deprecation warnings - -- require zope.sqlalchemy >= 1.5 - -- include edit profile email/phone dialogs only if user has perms - -- allow view supplements to add to profile member context - -- cast enum as list to satisfy deform widget - -- expand POD image URL setting input - -## v0.11.6 (2024-07-01) - -### Fix - -- set explicit referrer when changing dbkey - -- remove references, dependency for `six` package - -## v0.11.5 (2024-06-30) - -### Fix - -- allow comma in numeric filter input - -- add custom url prefix if needed, for fanstatic - -- use vue 3.4.31 and oruga 0.8.12 by default - -## v0.11.4 (2024-06-30) - -### Fix - -- start/stop being root should submit POST instead of GET - -- require vendor when making new ordering batch via api - -- don't escape each address for email attempts grid - -## v0.11.3 (2024-06-28) - -### Fix - -- add link to "resolved by" user for pending products - -- handle error when merging 2 records fails - -## v0.11.2 (2024-06-18) - -### Fix - -- hide certain custorder settings if not applicable - -- use different logic for buefy/oruga for product lookup keydown - -- product records should be touchable - -- show flash error message if resolve pending product fails - -## v0.11.1 (2024-06-14) - -### Fix - -- revert back to setup.py + setup.cfg - -## v0.11.0 (2024-06-10) - -### Feat - -- switch from setup.cfg to pyproject.toml + hatchling - -## v0.10.16 (2024-06-10) - -### Feat - -- standardize how app, package versions are determined - -### Fix - -- avoid deprecated config methods for app/node title - -## v0.10.15 (2024-06-07) - -### Fix - -- do *not* Use `pkg_resources` to determine package versions - -## v0.10.14 (2024-06-06) - -### Fix - -- use `pkg_resources` to determine package versions - -## v0.10.13 (2024-06-06) - -### Feat - -- remove old/unused scaffold for use with `pcreate` - -- add 'fanstatic' support for sake of libcache assets - -## v0.10.12 (2024-06-04) - -### Feat - -- require pyramid 2.x; remove 1.x-style auth policies - -- remove version cap for deform - -- set explicit referrer when changing app theme - -- add `` component shim - -- include extra styles from `base_meta` template for butterball - -- include butterball theme by default for new apps - -### Fix - -- fix product lookup component, per butterball - -## v0.10.11 (2024-06-03) - -### Feat - -- fix vue3 refresh bugs for various views - -- fix grid bug for tempmon appliance view, per oruga - -- fix ordering worksheet generator, per butterball - -- fix inventory worksheet generator, per butterball - -## v0.10.10 (2024-06-03) - -### Feat - -- more butterball fixes for "view profile" template - -### Fix - -- fix focus for `` shim component - -## v0.10.9 (2024-06-03) - -### Feat - -- let master view control context menu items for page - -- fix the "new custorder" page for butterball - -### Fix - -- fix panel style for PO vs. Invoice breakdown in receiving batch - -## v0.10.8 (2024-06-02) - -### Feat - -- add styling for checked grid rows, per oruga/butterball - -- fix product view template for oruga/butterball - -- allow per-user custom styles for butterball - -- use oruga 0.8.9 by default - -## v0.10.7 (2024-06-01) - -### Feat - -- add setting to allow decimal quantities for receiving - -- log error if registry has no rattail config - -- add column filters for import/export main grid - -- escape all unsafe html for grid data - -- add speedbumps for delete, set preferred email/phone in profile view - -- fix file upload widget for oruga - -### Fix - -- fix overflow when instance header title is too long (butterball) - -## v0.10.6 (2024-05-29) - -### Feat - -- add way to flag organic products within lookup dialog - -- expose db picker for butterball theme - -- expose quickie lookup for butterball theme - -- fix basic problems with people profile view, per butterball - -## v0.10.5 (2024-05-29) - -### Feat - -- add `` component for oruga - -## v0.10.4 (2024-05-12) - -### Fix - -- fix styles for grid actions, per butterball - -## v0.10.3 (2024-05-10) - -### Fix - -- fix bug with grid date filters - -## v0.10.2 (2024-05-08) - -### Feat - -- remove version restriction for pyramid_beaker dependency - -- rename some attrs etc. for buefy components used with oruga - -- fix "tools" helper for receiving batch view, per oruga - -- more data type fixes for ```` - -- fix "view receiving row" page, per oruga - -- tweak styles for grid action links, per butterball - -### Fix - -- fix employees grid when viewing department (per oruga) - -- fix login "enter" key behavior, per oruga - -- fix button text for autocomplete - -## v0.10.1 (2024-04-28) - -### Feat - -- sort list of available themes - -- update various icon names for oruga compatibility - -- show "View This" button when cloning a record - -- stop including 'falafel' as available theme - -### Fix - -- fix vertical alignment in main menu bar, for butterball - -- fix upgrade execution logic/UI per oruga - -## v0.10.0 (2024-04-28) - -This version bump is to reflect adding support for Vue 3 + Oruga via -the 'butterball' theme. There is likely more work to be done for that -yet, but it mostly works at this point. - -### Feat - -- misc. template and view logic tweaks (applicable to all themes) for - better patterns, consistency etc. - -- add initial support for Vue 3 + Oruga, via "butterball" theme - - -## Older Releases - -Please see `docs/OLDCHANGES.rst` for older release notes. diff --git a/docs/OLDCHANGES.rst b/CHANGES.rst similarity index 98% rename from docs/OLDCHANGES.rst rename to CHANGES.rst index 0a802f40..a3be0af8 100644 --- a/docs/OLDCHANGES.rst +++ b/CHANGES.rst @@ -2,8 +2,148 @@ CHANGELOG ========= -NB. this file contains "old" release notes only. for newer releases -see the `CHANGELOG.md` file in the source root folder. +Unreleased +---------- + +0.10.11 (2024-06-03) +-------------------- + +* Fix vue3 refresh bugs for various views. + +* Fix grid bug for tempmon appliance view, per oruga. + +* Fix ordering worksheet generator, per butterball. + +* Fix inventory worksheet generator, per butterball. + + +0.10.10 (2024-06-03) +-------------------- + +* Fix focus for ```` shim component. + +* More butterball fixes for "view profile" template. + + +0.10.9 (2024-06-03) +------------------- + +* Let master view control context menu items for page. + +* Fix panel style for PO vs. Invoice breakdown in receiving batch. + +* Fix the "new custorder" page for butterball. + + +0.10.8 (2024-06-02) +------------------- + +* Add styling for checked grid rows, per oruga/butterball. + +* Fix product view template for oruga/butterball. + +* Allow per-user custom styles for butterball. + +* Use oruga 0.8.9 by default. + + +0.10.7 (2024-06-01) +------------------- + +* Add setting to allow decimal quantities for receiving. + +* Log error if registry has no rattail config. + +* Add column filters for import/export main grid. + +* Fix overflow when instance header title is too long (butterball). + +* Escape all unsafe html for grid data. + +* Add speedbumps for delete, set preferred email/phone in profile view. + +* Fix file upload widget for oruga. + + +0.10.6 (2024-05-29) +------------------- + +* Add way to flag organic products within lookup dialog. + +* Expose db picker for butterball theme. + +* Expose quickie lookup for butterball theme. + +* Fix basic problems with people profile view, per butterball. + + +0.10.5 (2024-05-29) +------------------- + +* Add ```` component for oruga. + + +0.10.4 (2024-05-12) +------------------- + +* Fix styles for grid actions, per butterball. + + +0.10.3 (2024-05-10) +------------------- + +* Fix bug with grid date filters. + + +0.10.2 (2024-05-08) +------------------- + +* Fix employees grid when viewing department (per oruga). + +* Remove version restriction for pyramid_beaker dependency. + +* Fix login "enter" key behavior, per oruga. + +* Rename some attrs etc. for buefy components used with oruga. + +* Fix "tools" helper for receiving batch view, per oruga. + +* Fix button text for autocomplete. + +* More data type fixes for ````. + +* Fix "view receiving row" page, per oruga. + +* Tweak styles for grid action links, per butterball. + + +0.10.1 (2024-04-28) +------------------- + +* Sort list of available themes. + +* Update various icon names for oruga compatibility. + +* Fix vertical alignment in main menu bar, for butterball. + +* Fix upgrade execution logic/UI per oruga. + +* Show "View This" button when cloning a record. + +* Stop including 'falafel' as available theme. + + +0.10.0 (2024-04-28) +------------------- + +This version bump is to reflect adding support for Vue 3 + Oruga via +the 'butterball' theme. There is likely more work to be done for that +yet, but it mostly works at this point. + +* Misc. template and view logic tweaks (applicable to all themes) for + better patterns, consistency etc. + +* Add initial support for Vue 3 + Oruga, via "butterball" theme. 0.9.96 (2024-04-25) @@ -4992,7 +5132,7 @@ and related technologies. 0.6.47 (2017-11-08) ------------------- -* Fix manifest to include ``*.pt`` deform templates +* Fix manifest to include *.pt deform templates 0.6.46 (2017-11-08) @@ -5325,13 +5465,13 @@ and related technologies. 0.6.13 (2017-07-26) -------------------- +------------------ * Allow master view to decide whether each grid checkbox is checked 0.6.12 (2017-07-26) -------------------- +------------------ * Add basic support for product inventory and status @@ -5339,7 +5479,7 @@ and related technologies. 0.6.11 (2017-07-18) -------------------- +------------------ * Tweak some basic styles for forms/grids @@ -5347,7 +5487,7 @@ and related technologies. 0.6.10 (2017-07-18) -------------------- +------------------ * Fix grid bug if "current page" becomes invalid diff --git a/README.md b/README.rst similarity index 56% rename from README.md rename to README.rst index 74c007f6..0cffc62d 100644 --- a/README.md +++ b/README.rst @@ -1,8 +1,10 @@ -# Tailbone +Tailbone +======== Tailbone is an extensible web application based on Rattail. It provides a "back-office network environment" (BONE) for use in managing retail data. -Please see Rattail's [home page](http://rattailproject.org/) for more -information. +Please see Rattail's `home page`_ for more information. + +.. _home page: http://rattailproject.org/ diff --git a/docs/api/db.rst b/docs/api/db.rst deleted file mode 100644 index ace21b68..00000000 --- a/docs/api/db.rst +++ /dev/null @@ -1,6 +0,0 @@ - -``tailbone.db`` -=============== - -.. automodule:: tailbone.db - :members: diff --git a/docs/api/subscribers.rst b/docs/api/subscribers.rst index d28a1b15..8b25c994 100644 --- a/docs/api/subscribers.rst +++ b/docs/api/subscribers.rst @@ -3,4 +3,5 @@ ======================== .. automodule:: tailbone.subscribers - :members: + +.. autofunction:: new_request diff --git a/docs/api/util.rst b/docs/api/util.rst deleted file mode 100644 index 35e66ed3..00000000 --- a/docs/api/util.rst +++ /dev/null @@ -1,6 +0,0 @@ - -``tailbone.util`` -================= - -.. automodule:: tailbone.util - :members: diff --git a/docs/changelog.rst b/docs/changelog.rst deleted file mode 100644 index bbf94f4b..00000000 --- a/docs/changelog.rst +++ /dev/null @@ -1,8 +0,0 @@ - -Changelog Archive -================= - -.. toctree:: - :maxdepth: 1 - - OLDCHANGES diff --git a/docs/conf.py b/docs/conf.py index ade4c92a..505396ed 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,21 +1,38 @@ -# Configuration file for the Sphinx documentation builder. +# -*- coding: utf-8; -*- # -# For the full list of built-in configuration values, see the documentation: -# https://www.sphinx-doc.org/en/master/usage/configuration.html +# Tailbone documentation build configuration file, created by +# sphinx-quickstart on Sat Feb 15 23:15:27 2014. +# +# This file is exec()d with the current directory set to its +# containing dir. +# +# Note that not all possible configuration values are present in this +# autogenerated file. +# +# All configuration values have a default; values that are commented out +# serve to show the default. -# -- Project information ----------------------------------------------------- -# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information +import sys +import os -from importlib.metadata import version as get_version +import sphinx_rtd_theme -project = 'Tailbone' -copyright = '2010 - 2024, Lance Edgar' -author = 'Lance Edgar' -release = get_version('Tailbone') +exec(open(os.path.join(os.pardir, 'tailbone', '_version.py')).read()) -# -- General configuration --------------------------------------------------- -# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +#sys.path.insert(0, os.path.abspath('.')) + +# -- General configuration ------------------------------------------------ + +# If your documentation needs a minimal Sphinx version, state it here. +#needs_sphinx = '1.0' + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. extensions = [ 'sphinx.ext.autodoc', 'sphinx.ext.todo', @@ -23,30 +40,241 @@ extensions = [ 'sphinx.ext.viewcode', ] -templates_path = ['_templates'] -exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] - intersphinx_mapping = { - 'rattail': ('https://docs.wuttaproject.org/rattail/', None), + 'rattail': ('https://rattailproject.org/docs/rattail/', None), 'webhelpers2': ('https://webhelpers2.readthedocs.io/en/latest/', None), - 'wuttaweb': ('https://docs.wuttaproject.org/wuttaweb/', None), - 'wuttjamaican': ('https://docs.wuttaproject.org/wuttjamaican/', None), } -# allow todo entries to show up +# Add any paths that contain templates here, relative to this directory. +templates_path = ['_templates'] + +# The suffix of source filenames. +source_suffix = '.rst' + +# The encoding of source files. +#source_encoding = 'utf-8-sig' + +# The master toctree document. +master_doc = 'index' + +# General information about the project. +project = u'Tailbone' +copyright = u'2010 - 2020, Lance Edgar' + +# The version info for the project you're documenting, acts as replacement for +# |version| and |release|, also used in various other places throughout the +# built documents. +# +# The short X.Y version. +# version = '0.3' +version = '.'.join(__version__.split('.')[:2]) +# The full version, including alpha/beta/rc tags. +release = __version__ + +# The language for content autogenerated by Sphinx. Refer to documentation +# for a list of supported languages. +#language = None + +# There are two options for replacing |today|: either, you set today to some +# non-false value, then it is used: +#today = '' +# Else, today_fmt is used as the format for a strftime call. +#today_fmt = '%B %d, %Y' + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +exclude_patterns = ['_build'] + +# The reST default role (used for this markup: `text`) to use for all +# documents. +#default_role = None + +# If true, '()' will be appended to :func: etc. cross-reference text. +#add_function_parentheses = True + +# If true, the current module name will be prepended to all description +# unit titles (such as .. function::). +#add_module_names = True + +# If true, sectionauthor and moduleauthor directives will be shown in the +# output. They are ignored by default. +#show_authors = False + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = 'sphinx' + +# A list of ignored prefixes for module index sorting. +#modindex_common_prefix = [] + +# If true, keep warnings as "system message" paragraphs in the built documents. +#keep_warnings = False + +# Allow todo entries to show up. todo_include_todos = True -# -- Options for HTML output ------------------------------------------------- -# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output +# -- Options for HTML output ---------------------------------------------- -html_theme = 'furo' -html_static_path = ['_static'] +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +# html_theme = 'classic' +html_theme = 'sphinx_rtd_theme' + +# Theme options are theme-specific and customize the look and feel of a theme +# further. For a list of options available for each theme, see the +# documentation. +#html_theme_options = {} + +# Add any paths that contain custom themes here, relative to this directory. +#html_theme_path = [] +html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] + +# The name for this set of Sphinx documents. If None, it defaults to +# " v documentation". +#html_title = None + +# A shorter title for the navigation bar. Default is the same as html_title. +#html_short_title = None # The name of an image file (relative to this directory) to place at the top # of the sidebar. #html_logo = None -#html_logo = 'images/rattail_avatar.png' +html_logo = 'images/rattail_avatar.png' + +# The name of an image file (within the static path) to use as favicon of the +# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 +# pixels large. +#html_favicon = None + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ['_static'] + +# Add any extra paths that contain custom files (such as robots.txt or +# .htaccess) here, relative to this directory. These files are copied +# directly to the root of the documentation. +#html_extra_path = [] + +# If not '', a 'Last updated on:' timestamp is inserted at every page bottom, +# using the given strftime format. +#html_last_updated_fmt = '%b %d, %Y' + +# If true, SmartyPants will be used to convert quotes and dashes to +# typographically correct entities. +#html_use_smartypants = True + +# Custom sidebar templates, maps document names to template names. +#html_sidebars = {} + +# Additional templates that should be rendered to pages, maps page names to +# template names. +#html_additional_pages = {} + +# If false, no module index is generated. +#html_domain_indices = True + +# If false, no index is generated. +#html_use_index = True + +# If true, the index is split into individual pages for each letter. +#html_split_index = False + +# If true, links to the reST sources are added to the pages. +#html_show_sourcelink = True + +# If true, "Created using Sphinx" is shown in the HTML footer. Default is True. +#html_show_sphinx = True + +# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. +#html_show_copyright = True + +# If true, an OpenSearch description file will be output, and all pages will +# contain a tag referring to it. The value of this option must be the +# base URL from which the finished HTML is served. +#html_use_opensearch = '' + +# This is the file name suffix for HTML files (e.g. ".xhtml"). +#html_file_suffix = None # Output file base name for HTML help builder. -#htmlhelp_basename = 'Tailbonedoc' +htmlhelp_basename = 'Tailbonedoc' + + +# -- Options for LaTeX output --------------------------------------------- + +latex_elements = { +# The paper size ('letterpaper' or 'a4paper'). +#'papersize': 'letterpaper', + +# The font size ('10pt', '11pt' or '12pt'). +#'pointsize': '10pt', + +# Additional stuff for the LaTeX preamble. +#'preamble': '', +} + +# Grouping the document tree into LaTeX files. List of tuples +# (source start file, target name, title, +# author, documentclass [howto, manual, or own class]). +latex_documents = [ + ('index', 'Tailbone.tex', u'Tailbone Documentation', + u'Lance Edgar', 'manual'), +] + +# The name of an image file (relative to this directory) to place at the top of +# the title page. +#latex_logo = None + +# For "manual" documents, if this is true, then toplevel headings are parts, +# not chapters. +#latex_use_parts = False + +# If true, show page references after internal links. +#latex_show_pagerefs = False + +# If true, show URL addresses after external links. +#latex_show_urls = False + +# Documents to append as an appendix to all manuals. +#latex_appendices = [] + +# If false, no module index is generated. +#latex_domain_indices = True + + +# -- Options for manual page output --------------------------------------- + +# One entry per manual page. List of tuples +# (source start file, name, description, authors, manual section). +man_pages = [ + ('index', 'tailbone', u'Tailbone Documentation', + [u'Lance Edgar'], 1) +] + +# If true, show URL addresses after external links. +#man_show_urls = False + + +# -- Options for Texinfo output ------------------------------------------- + +# Grouping the document tree into Texinfo files. List of tuples +# (source start file, target name, title, author, +# dir menu entry, description, category) +texinfo_documents = [ + ('index', 'Tailbone', u'Tailbone Documentation', + u'Lance Edgar', 'Tailbone', 'One line description of project.', + 'Miscellaneous'), +] + +# Documents to append as an appendix to all manuals. +#texinfo_appendices = [] + +# If false, no module index is generated. +#texinfo_domain_indices = True + +# How to display URL addresses: 'footnote', 'no', or 'inline'. +#texinfo_show_urls = 'footnote' + +# If true, do not generate a @detailmenu in the "Top" node's menu. +#texinfo_no_detailmenu = False diff --git a/docs/index.rst b/docs/index.rst index d964086f..351e910d 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -44,7 +44,6 @@ Package API: api/api/batch/core api/api/batch/ordering - api/db api/diffs api/forms api/forms.widgets @@ -52,7 +51,6 @@ Package API: api/grids.core api/progress api/subscribers - api/util api/views/batch api/views/batch.vendorcatalog api/views/core @@ -62,14 +60,6 @@ Package API: api/views/purchasing.ordering -Changelog: - -.. toctree:: - :maxdepth: 1 - - changelog - - Documentation To-Do =================== diff --git a/pyproject.toml b/pyproject.toml deleted file mode 100644 index a7214a8e..00000000 --- a/pyproject.toml +++ /dev/null @@ -1,103 +0,0 @@ - -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - - -[project] -name = "Tailbone" -version = "0.22.7" -description = "Backoffice Web Application for Rattail" -readme = "README.md" -authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] -license = {text = "GNU GPL v3+"} -classifiers = [ - "Development Status :: 4 - Beta", - "Environment :: Web Environment", - "Framework :: Pyramid", - "Intended Audience :: Developers", - "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)", - "Natural Language :: English", - "Operating System :: OS Independent", - "Programming Language :: Python", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - "Topic :: Internet :: WWW/HTTP", - "Topic :: Office/Business", - "Topic :: Software Development :: Libraries :: Python Modules", -] -requires-python = ">= 3.8" -dependencies = [ - "asgiref", - "colander", - "ColanderAlchemy", - "cornice", - "cornice-swagger", - "deform", - "humanize", - "Mako", - "markdown", - "openpyxl", - "paginate", - "paginate_sqlalchemy", - "passlib", - "Pillow", - "pyramid>=2", - "pyramid_beaker", - "pyramid_deform", - "pyramid_exclog", - "pyramid_fanstatic", - "pyramid_mako", - "pyramid_retry", - "pyramid_tm", - "rattail[db,bouncer]>=0.20.1", - "sa-filters", - "simplejson", - "transaction", - "waitress", - "WebHelpers2", - "WuttaWeb>=0.21.0", - "zope.sqlalchemy>=1.5", -] - - -[project.optional-dependencies] -docs = ["Sphinx", "furo"] -tests = ["coverage", "mock", "pytest", "pytest-cov"] - - -[project.entry-points."paste.app_factory"] -main = "tailbone.app:main" -webapi = "tailbone.webapi:main" - - -[project.entry-points."rattail.cleaners"] -beaker = "tailbone.cleanup:BeakerCleaner" - - -[project.entry-points."rattail.config.extensions"] -tailbone = "tailbone.config:ConfigExtension" - - -[project.urls] -Homepage = "https://rattailproject.org" -Repository = "https://forgejo.wuttaproject.org/rattail/tailbone" -Issues = "https://forgejo.wuttaproject.org/rattail/tailbone/issues" -Changelog = "https://forgejo.wuttaproject.org/rattail/tailbone/src/branch/master/CHANGELOG.md" - - -[tool.commitizen] -version_provider = "pep621" -tag_format = "v$version" -update_changelog_on_bump = true - - -[tool.nosetests] -nocapture = 1 -cover-package = "tailbone" -cover-erase = 1 -cover-html = 1 -cover-html-dir = "htmlcov" diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 00000000..48cc994a --- /dev/null +++ b/setup.cfg @@ -0,0 +1,106 @@ +# -*- coding: utf-8; -*- + +[nosetests] +nocapture = 1 +cover-package = tailbone +cover-erase = 1 +cover-html = 1 +cover-html-dir = htmlcov + +[metadata] +name = Tailbone +version = attr: tailbone.__version__ +author = Lance Edgar +author_email = lance@edbob.org +url = http://rattailproject.org/ +license = GNU GPL v3 +description = Backoffice Web Application for Rattail +long_description = file: README.rst +classifiers = + Development Status :: 4 - Beta + Environment :: Web Environment + Framework :: Pyramid + Intended Audience :: Developers + License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+) + Natural Language :: English + Operating System :: OS Independent + Programming Language :: Python + Programming Language :: Python :: 3 + Programming Language :: Python :: 3.6 + Programming Language :: Python :: 3.7 + Programming Language :: Python :: 3.8 + Programming Language :: Python :: 3.9 + Programming Language :: Python :: 3.10 + Programming Language :: Python :: 3.11 + Topic :: Internet :: WWW/HTTP + Topic :: Office/Business + Topic :: Software Development :: Libraries :: Python Modules + + +[options] +install_requires = + + # TODO: remove once their bug is fixed? idk what this is about yet... + deform<2.0.15 + + asgiref + colander + ColanderAlchemy + cornice + cornice-swagger + humanize + Mako + markdown + openpyxl + paginate + paginate_sqlalchemy + passlib + Pillow + pyramid + pyramid_beaker + pyramid_deform + pyramid_exclog + pyramid_mako + pyramid_retry + pyramid_tm + rattail[db,bouncer] + six + sa-filters + simplejson + transaction + waitress + WebHelpers2 + zope.sqlalchemy + +tests_require = Tailbone[tests] +test_suite = tests +packages = find: +include_package_data = True +zip_safe = False + + +[options.packages.find] +exclude = + tests.* + tests + + +[options.extras_require] +docs = Sphinx; sphinx-rtd-theme +tests = coverage; mock; pytest; pytest-cov + + +[options.entry_points] + +paste.app_factory = + main = tailbone.app:main + webapi = tailbone.webapi:main + +rattail.cleaners = + beaker = tailbone.cleanup:BeakerCleaner + +rattail.config.extensions = + tailbone = tailbone.config:ConfigExtension + +pyramid.scaffold = + rattail = tailbone.scaffolds:RattailTemplate diff --git a/tailbone/views/wutta/users.py b/setup.py similarity index 51% rename from tailbone/views/wutta/users.py rename to setup.py index 3c3f8d52..5645ddff 100644 --- a/tailbone/views/wutta/users.py +++ b/setup.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2024 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -21,37 +21,9 @@ # ################################################################################ """ -User Views +Setup script for Tailbone """ -from wuttaweb.views import users as wutta -from tailbone.views import users as tailbone -from tailbone.db import Session -from rattail.db.model import User -from tailbone.grids import Grid +from setuptools import setup - -class UserView(wutta.UserView): - """ - This is the first attempt at blending newer Wutta views with - legacy Tailbone config. - - So, this is a Wutta-based view but it should be included by a - Tailbone app configurator. - """ - model_class = User - Session = Session - - # TODO: must use older grid for now, to render filters correctly - def make_grid(self, **kwargs): - """ """ - return Grid(self.request, **kwargs) - - -def defaults(config, **kwargs): - kwargs.setdefault('UserView', UserView) - tailbone.defaults(config, **kwargs) - - -def includeme(config): - defaults(config) +setup() diff --git a/tailbone/_version.py b/tailbone/_version.py index 7095f6c8..d24c3b8e 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,9 +1,3 @@ # -*- coding: utf-8; -*- -try: - from importlib.metadata import version -except ImportError: - from importlib_metadata import version - - -__version__ = version('Tailbone') +__version__ = '0.10.11' diff --git a/tailbone/api/auth.py b/tailbone/api/auth.py index a710e30d..1b347b21 100644 --- a/tailbone/api/auth.py +++ b/tailbone/api/auth.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2024 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,6 +24,8 @@ Tailbone Web API - Auth Views """ +from rattail.db.auth import set_user_password + from cornice import Service from tailbone.api import APIView, api @@ -40,10 +42,11 @@ class AuthenticationView(APIView): This will establish a server-side web session for the user if none exists. Note that this also resets the user's session timer. """ - data = {'ok': True, 'permissions': []} + data = {'ok': True} if self.request.user: data['user'] = self.get_user_info(self.request.user) - data['permissions'] = list(self.request.user_permissions) + + data['permissions'] = list(self.request.tailbone_cached_permissions) # background color may be set per-request, by some apps if hasattr(self.request, 'background_color') and self.request.background_color: @@ -173,8 +176,7 @@ class AuthenticationView(APIView): return {'error': "The current/old password you provided is incorrect"} # okay then, set new password - auth = self.app.get_auth_handler() - auth.set_user_password(self.request.user, data['new_password']) + set_user_password(self.request.user, data['new_password']) return { 'ok': True, 'user': self.get_user_info(self.request.user), diff --git a/tailbone/api/batch/labels.py b/tailbone/api/batch/labels.py index 4f154b21..4787aeb9 100644 --- a/tailbone/api/batch/labels.py +++ b/tailbone/api/batch/labels.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2024 Lance Edgar +# Copyright © 2010-2022 Lance Edgar # # This file is part of Rattail. # @@ -24,6 +24,10 @@ Tailbone Web API - Label Batches """ +from __future__ import unicode_literals, absolute_import + +import six + from rattail.db import model from tailbone.api.batch import APIBatchView, APIBatchRowView @@ -52,10 +56,10 @@ class LabelBatchRowViews(APIBatchRowView): def normalize(self, row): batch = row.batch - data = super().normalize(row) + data = super(LabelBatchRowViews, self).normalize(row) data['item_id'] = row.item_id - data['upc'] = str(row.upc) + data['upc'] = six.text_type(row.upc) data['upc_pretty'] = row.upc.pretty() if row.upc else None data['brand_name'] = row.brand_name data['description'] = row.description diff --git a/tailbone/api/batch/ordering.py b/tailbone/api/batch/ordering.py index 204be8ad..1b11194e 100644 --- a/tailbone/api/batch/ordering.py +++ b/tailbone/api/batch/ordering.py @@ -86,8 +86,6 @@ class OrderingBatchViews(APIBatchView): Sets the mode to "ordering" for the new batch. """ data = dict(data) - if not data.get('vendor_uuid'): - raise ValueError("You must specify the vendor") data['mode'] = self.enum.PURCHASE_BATCH_MODE_ORDERING batch = super().create_object(data) return batch diff --git a/tailbone/api/batch/receiving.py b/tailbone/api/batch/receiving.py index b23bff55..daa4290f 100644 --- a/tailbone/api/batch/receiving.py +++ b/tailbone/api/batch/receiving.py @@ -29,7 +29,8 @@ import logging import humanize import sqlalchemy as sa -from rattail.db.model import PurchaseBatch, PurchaseBatchRow +from rattail.db import model +from rattail.util import pretty_quantity from cornice import Service from deform import widget as dfwidget @@ -44,7 +45,7 @@ log = logging.getLogger(__name__) class ReceivingBatchViews(APIBatchView): - model_class = PurchaseBatch + model_class = model.PurchaseBatch default_handler_spec = 'rattail.batch.purchase:PurchaseBatchHandler' route_prefix = 'receivingbatchviews' permission_prefix = 'receiving' @@ -54,8 +55,7 @@ class ReceivingBatchViews(APIBatchView): supports_execute = True def base_query(self): - model = self.app.model - query = super().base_query() + query = super(ReceivingBatchViews, self).base_query() query = query.filter(model.PurchaseBatch.mode == self.enum.PURCHASE_BATCH_MODE_RECEIVING) return query @@ -85,7 +85,7 @@ class ReceivingBatchViews(APIBatchView): # assume "receive from PO" if given a PO key if data.get('purchase_key'): - data['workflow'] = 'from_po' + data['receiving_workflow'] = 'from_po' return super().create_object(data) @@ -120,7 +120,6 @@ class ReceivingBatchViews(APIBatchView): return self._get(obj=batch) def eligible_purchases(self): - model = self.app.model uuid = self.request.params.get('vendor_uuid') vendor = self.Session.get(model.Vendor, uuid) if uuid else None if not vendor: @@ -177,7 +176,7 @@ class ReceivingBatchViews(APIBatchView): class ReceivingBatchRowViews(APIBatchRowView): - model_class = PurchaseBatchRow + model_class = model.PurchaseBatchRow default_handler_spec = 'rattail.batch.purchase:PurchaseBatchHandler' route_prefix = 'receiving.rows' permission_prefix = 'receiving' @@ -186,8 +185,7 @@ class ReceivingBatchRowViews(APIBatchRowView): supports_quick_entry = True def make_filter_spec(self): - model = self.app.model - filters = super().make_filter_spec() + filters = super(ReceivingBatchRowViews, self).make_filter_spec() if filters: # must translate certain convenience filters @@ -298,11 +296,11 @@ class ReceivingBatchRowViews(APIBatchRowView): return filters def normalize(self, row): - data = super().normalize(row) - model = self.app.model + data = super(ReceivingBatchRowViews, self).normalize(row) batch = row.batch - prodder = self.app.get_products_handler() + app = self.get_rattail_app() + prodder = app.get_products_handler() data['product_uuid'] = row.product_uuid data['item_id'] = row.item_id @@ -377,7 +375,7 @@ class ReceivingBatchRowViews(APIBatchRowView): if accounted_for: # some product accounted for; button should receive "remainder" only if remainder: - remainder = self.app.render_quantity(remainder) + remainder = pretty_quantity(remainder) data['quick_receive_quantity'] = remainder data['quick_receive_text'] = "Receive Remainder ({} {})".format( remainder, data['unit_uom']) @@ -388,7 +386,7 @@ class ReceivingBatchRowViews(APIBatchRowView): else: # nothing yet accounted for, button should receive "all" if not remainder: log.warning("quick receive remainder is empty for row %s", row.uuid) - remainder = self.app.render_quantity(remainder) + remainder = pretty_quantity(remainder) data['quick_receive_quantity'] = remainder data['quick_receive_text'] = "Receive ALL ({} {})".format( remainder, data['unit_uom']) @@ -416,7 +414,7 @@ class ReceivingBatchRowViews(APIBatchRowView): data['received_alert'] = None if self.batch_handler.get_units_confirmed(row): msg = "You have already received some of this product; last update was {}.".format( - humanize.naturaltime(self.app.make_utc() - row.modified)) + humanize.naturaltime(app.make_utc() - row.modified)) data['received_alert'] = msg return data @@ -425,8 +423,6 @@ class ReceivingBatchRowViews(APIBatchRowView): """ View which handles "receiving" against a particular batch row. """ - model = self.app.model - # first do basic input validation schema = ReceiveRow().bind(session=self.Session()) form = forms.Form(schema=schema, request=self.request) diff --git a/tailbone/api/common.py b/tailbone/api/common.py index 6cacfb06..30dfeab1 100644 --- a/tailbone/api/common.py +++ b/tailbone/api/common.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2024 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -26,12 +26,15 @@ Tailbone Web API - "Common" Views from collections import OrderedDict -from rattail.util import get_pkg_version +import rattail +from rattail.db import model +from rattail.mail import send_email from cornice import Service from cornice.service import get_services from cornice_swagger import CorniceSwagger +import tailbone from tailbone import forms from tailbone.forms.common import Feedback from tailbone.api import APIView, api @@ -63,12 +66,11 @@ class CommonView(APIView): } def get_project_title(self): - app = self.get_rattail_app() - return app.get_title() + return self.rattail_config.app_title(default="Tailbone") def get_project_version(self): - app = self.get_rattail_app() - return app.get_version() + import tailbone + return tailbone.__version__ def get_packages(self): """ @@ -76,8 +78,8 @@ class CommonView(APIView): 'about' page. """ return OrderedDict([ - ('rattail', get_pkg_version('rattail')), - ('Tailbone', get_pkg_version('Tailbone')), + ('rattail', rattail.__version__), + ('Tailbone', tailbone.__version__), ]) @api @@ -85,8 +87,6 @@ class CommonView(APIView): """ View to handle user feedback form submits. """ - app = self.get_rattail_app() - model = self.model # TODO: this logic was copied from tailbone.views.common and is largely # identical; perhaps should merge somehow? schema = Feedback().bind(session=Session()) @@ -106,7 +106,7 @@ class CommonView(APIView): data['client_ip'] = self.request.client_addr email_key = data['email_key'] or self.feedback_email_key - app.send_email(email_key, data=data) + send_email(self.rattail_config, email_key, data=data) return {'ok': True} return {'error': "Form did not validate!"} diff --git a/tailbone/api/core.py b/tailbone/api/core.py index 0d8eec32..b278d4af 100644 --- a/tailbone/api/core.py +++ b/tailbone/api/core.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2024 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -102,7 +102,7 @@ class APIView(View): auth = app.get_auth_handler() # basic / default info - is_admin = auth.user_is_admin(user) + is_admin = user.is_admin() employee = app.get_employee(user) info = { 'uuid': user.uuid, diff --git a/tailbone/api/customers.py b/tailbone/api/customers.py index 85d28c24..e9953572 100644 --- a/tailbone/api/customers.py +++ b/tailbone/api/customers.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2024 Lance Edgar +# Copyright © 2010-2022 Lance Edgar # # This file is part of Rattail. # @@ -24,6 +24,10 @@ Tailbone Web API - Customer Views """ +from __future__ import unicode_literals, absolute_import + +import six + from rattail.db import model from tailbone.api import APIMasterView @@ -42,7 +46,7 @@ class CustomerView(APIMasterView): def normalize(self, customer): return { 'uuid': customer.uuid, - '_str': str(customer), + '_str': six.text_type(customer), 'id': customer.id, 'number': customer.number, 'name': customer.name, diff --git a/tailbone/api/master.py b/tailbone/api/master.py index 551d6428..70616484 100644 --- a/tailbone/api/master.py +++ b/tailbone/api/master.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2024 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -26,11 +26,12 @@ Tailbone Web API - Master View import json +from rattail.config import parse_bool from rattail.db.util import get_fieldnames from cornice import resource, Service -from tailbone.api import APIView +from tailbone.api import APIView, api from tailbone.db import Session from tailbone.util import SortColumn @@ -184,7 +185,7 @@ class APIMasterView(APIView): if sortcol: spec = { 'field': sortcol.field_name, - 'direction': 'asc' if self.config.parse_bool(self.request.params['ascending']) else 'desc', + 'direction': 'asc' if parse_bool(self.request.params['ascending']) else 'desc', } if sortcol.model_name: spec['model'] = sortcol.model_name @@ -354,13 +355,9 @@ class APIMasterView(APIView): data = self.request.json_body # add instance to session, and return data for it - try: - obj = self.create_object(data) - except Exception as error: - return self.json_response({'error': str(error)}) - else: - self.Session.flush() - return self._get(obj) + obj = self.create_object(data) + self.Session.flush() + return self._get(obj) def create_object(self, data): """ diff --git a/tailbone/api/people.py b/tailbone/api/people.py index f7c08dfa..7e06e969 100644 --- a/tailbone/api/people.py +++ b/tailbone/api/people.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2024 Lance Edgar +# Copyright © 2010-2022 Lance Edgar # # This file is part of Rattail. # @@ -24,6 +24,10 @@ Tailbone Web API - Person Views """ +from __future__ import unicode_literals, absolute_import + +import six + from rattail.db import model from tailbone.api import APIMasterView @@ -41,7 +45,7 @@ class PersonView(APIMasterView): def normalize(self, person): return { 'uuid': person.uuid, - '_str': str(person), + '_str': six.text_type(person), 'first_name': person.first_name, 'last_name': person.last_name, 'display_name': person.display_name, diff --git a/tailbone/api/upgrades.py b/tailbone/api/upgrades.py index 467c8a0d..6ce5f778 100644 --- a/tailbone/api/upgrades.py +++ b/tailbone/api/upgrades.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2024 Lance Edgar +# Copyright © 2010-2022 Lance Edgar # # This file is part of Rattail. # @@ -24,6 +24,10 @@ Tailbone Web API - Upgrade Views """ +from __future__ import unicode_literals, absolute_import + +import six + from rattail.db import model from tailbone.api import APIMasterView @@ -49,7 +53,7 @@ class UpgradeView(APIMasterView): data['status_code'] = None else: data['status_code'] = self.enum.UPGRADE_STATUS.get(upgrade.status_code, - str(upgrade.status_code)) + six.text_type(upgrade.status_code)) return data diff --git a/tailbone/api/vendors.py b/tailbone/api/vendors.py index 64311b1b..7fa61590 100644 --- a/tailbone/api/vendors.py +++ b/tailbone/api/vendors.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2024 Lance Edgar +# Copyright © 2010-2022 Lance Edgar # # This file is part of Rattail. # @@ -24,6 +24,10 @@ Tailbone Web API - Vendor Views """ +from __future__ import unicode_literals, absolute_import + +import six + from rattail.db import model from tailbone.api import APIMasterView @@ -40,7 +44,7 @@ class VendorView(APIMasterView): def normalize(self, vendor): return { 'uuid': vendor.uuid, - '_str': str(vendor), + '_str': six.text_type(vendor), 'id': vendor.id, 'name': vendor.name, } diff --git a/tailbone/api/workorders.py b/tailbone/api/workorders.py index 19def6c4..eabe4cdb 100644 --- a/tailbone/api/workorders.py +++ b/tailbone/api/workorders.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2024 Lance Edgar +# Copyright © 2010-2022 Lance Edgar # # This file is part of Rattail. # @@ -24,8 +24,12 @@ Tailbone Web API - Work Order Views """ +from __future__ import unicode_literals, absolute_import + import datetime +import six + from rattail.db.model import WorkOrder from cornice import Service @@ -40,19 +44,19 @@ class WorkOrderView(APIMasterView): object_url_prefix = '/workorder' def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) + super(WorkOrderView, self).__init__(*args, **kwargs) app = self.get_rattail_app() self.workorder_handler = app.get_workorder_handler() def normalize(self, workorder): - data = super().normalize(workorder) + data = super(WorkOrderView, self).normalize(workorder) data.update({ 'customer_name': workorder.customer.name, 'status_label': self.enum.WORKORDER_STATUS[workorder.status_code], - 'date_submitted': str(workorder.date_submitted or ''), - 'date_received': str(workorder.date_received or ''), - 'date_released': str(workorder.date_released or ''), - 'date_delivered': str(workorder.date_delivered or ''), + 'date_submitted': six.text_type(workorder.date_submitted or ''), + 'date_received': six.text_type(workorder.date_received or ''), + 'date_released': six.text_type(workorder.date_released or ''), + 'date_delivered': six.text_type(workorder.date_delivered or ''), }) return data @@ -83,7 +87,7 @@ class WorkOrderView(APIMasterView): if 'status_code' in data: data['status_code'] = int(data['status_code']) - return super().update_object(workorder, data) + return super(WorkOrderView, self).update_object(workorder, data) def status_codes(self): """ diff --git a/tailbone/app.py b/tailbone/app.py index d2d0c5ef..0519f35b 100644 --- a/tailbone/app.py +++ b/tailbone/app.py @@ -25,19 +25,21 @@ Application Entry Point """ import os +import warnings +import sqlalchemy as sa from sqlalchemy.orm import sessionmaker, scoped_session -from wuttjamaican.util import parse_list - -from rattail.config import make_config +from rattail.config import make_config, parse_list from rattail.exceptions import ConfigurationError +from rattail.db.types import GPCType from pyramid.config import Configurator +from pyramid.authentication import SessionAuthenticationPolicy from zope.sqlalchemy import register import tailbone.db -from tailbone.auth import TailboneSecurityPolicy +from tailbone.auth import TailboneAuthorizationPolicy from tailbone.config import csrf_token_name, csrf_header_name from tailbone.util import get_effective_theme, get_theme_template_path from tailbone.providers import get_all_providers @@ -59,23 +61,9 @@ def make_rattail_config(settings): rattail_config = make_config(path) settings['rattail_config'] = rattail_config - # nb. this is for compaibility with wuttaweb - settings['wutta_config'] = rattail_config - - # must import all sqlalchemy models before things get rolling, - # otherwise can have errors about continuum TransactionMeta class - # not yet mapped, when relevant pages are first requested... - # cf. https://docs.pylonsproject.org/projects/pyramid_cookbook/en/latest/database/sqlalchemy.html#importing-all-sqlalchemy-models - # hat tip to https://stackoverflow.com/a/59241485 - if getattr(rattail_config, 'tempmon_engine', None): - from rattail_tempmon.db import model as tempmon_model, Session as TempmonSession - tempmon_session = TempmonSession() - tempmon_session.query(tempmon_model.Appliance).first() - tempmon_session.close() - # configure database sessions - if hasattr(rattail_config, 'appdb_engine'): - tailbone.db.Session.configure(bind=rattail_config.appdb_engine) + if hasattr(rattail_config, 'rattail_engine'): + tailbone.db.Session.configure(bind=rattail_config.rattail_engine) if hasattr(rattail_config, 'trainwreck_engine'): tailbone.db.TrainwreckSession.configure(bind=rattail_config.trainwreck_engine) if hasattr(rattail_config, 'tempmon_engine'): @@ -141,7 +129,6 @@ def make_pyramid_config(settings, configure_csrf=True): # we want the new themes feature! establish_theme(settings) - settings.setdefault('fanstatic.versioning', 'true') settings.setdefault('pyramid_deform.template_search_path', 'tailbone:templates/deform') config = Configurator(settings=settings, root_factory=Root) @@ -149,7 +136,14 @@ def make_pyramid_config(settings, configure_csrf=True): config.registry['rattail_config'] = rattail_config # configure user authorization / authentication - config.set_security_policy(TailboneSecurityPolicy()) + # TODO: security policy should become the default, for pyramid 2.x + if rattail_config.getbool('tailbone', 'pyramid.use_security_policy', + usedb=False, default=False): + from tailbone.auth import TailboneSecurityPolicy + config.set_security_policy(TailboneSecurityPolicy()) + else: + config.set_authorization_policy(TailboneAuthorizationPolicy()) + config.set_authentication_policy(SessionAuthenticationPolicy()) # maybe require CSRF token protection if configure_csrf: @@ -160,7 +154,6 @@ def make_pyramid_config(settings, configure_csrf=True): # Bring in some Pyramid goodies. config.include('tailbone.beaker') config.include('pyramid_deform') - config.include('pyramid_fanstatic') config.include('pyramid_mako') config.include('pyramid_tm') @@ -196,16 +189,9 @@ def make_pyramid_config(settings, configure_csrf=True): for spec in includes: config.include(spec) - # add some permissions magic - config.add_directive('add_wutta_permission_group', - 'wuttaweb.auth.add_permission_group') - config.add_directive('add_wutta_permission', - 'wuttaweb.auth.add_permission') - # TODO: deprecate / remove these - config.add_directive('add_tailbone_permission_group', - 'wuttaweb.auth.add_permission_group') - config.add_directive('add_tailbone_permission', - 'wuttaweb.auth.add_permission') + # Add some permissions magic. + config.add_directive('add_tailbone_permission_group', 'tailbone.auth.add_permission_group') + config.add_directive('add_tailbone_permission', 'tailbone.auth.add_permission') # and some similar magic for certain master views config.add_directive('add_tailbone_index_page', 'tailbone.app.add_index_page') @@ -332,8 +318,7 @@ def main(global_config, **settings): """ This function returns a Pyramid WSGI application. """ - settings.setdefault('mako.directories', ['tailbone:templates', - 'wuttaweb:templates']) + settings.setdefault('mako.directories', ['tailbone:templates']) rattail_config = make_rattail_config(settings) pyramid_config = make_pyramid_config(settings) pyramid_config.include('tailbone') diff --git a/tailbone/auth.py b/tailbone/auth.py index 95bf90ba..0a5bd903 100644 --- a/tailbone/auth.py +++ b/tailbone/auth.py @@ -27,28 +27,29 @@ Authentication & Authorization import logging import re -from wuttjamaican.util import UNSPECIFIED +from rattail.util import prettify, NOTSET -from pyramid.security import remember, forget +from zope.interface import implementer +from pyramid.interfaces import IAuthorizationPolicy +from pyramid.security import remember, forget, Everyone, Authenticated +from pyramid.authentication import SessionAuthenticationPolicy -from wuttaweb.auth import WuttaSecurityPolicy from tailbone.db import Session log = logging.getLogger(__name__) -def login_user(request, user, timeout=UNSPECIFIED): +def login_user(request, user, timeout=NOTSET): """ Perform the steps necessary to login the given user. Note that this returns a ``headers`` dict which you should pass to the redirect. """ - config = request.rattail_config - app = config.get_app() + app = request.rattail_config.get_app() user.record_event(app.enum.USER_EVENT_LOGIN) headers = remember(request, user.uuid) - if timeout is UNSPECIFIED: - timeout = session_timeout_for_user(config, user) + if timeout is NOTSET: + timeout = session_timeout_for_user(user) log.debug("setting session timeout for '{}' to {}".format(user.username, timeout)) set_session_timeout(request, timeout) return headers @@ -69,18 +70,15 @@ def logout_user(request): return headers -def session_timeout_for_user(config, user): +def session_timeout_for_user(user): """ Returns the "max" session timeout for the user, according to roles """ - app = config.get_app() - auth = app.get_auth_handler() + from rattail.db.auth import authenticated_role - authenticated = auth.get_role_authenticated(Session()) - roles = user.roles + [authenticated] + roles = user.roles + [authenticated_role(Session())] timeouts = [role.session_timeout for role in roles if role.session_timeout is not None] - if timeouts and 0 not in timeouts: return max(timeouts) @@ -92,12 +90,76 @@ def set_session_timeout(request, timeout): request.session['_timeout'] = timeout or None -class TailboneSecurityPolicy(WuttaSecurityPolicy): +class TailboneAuthenticationPolicy(SessionAuthenticationPolicy): + """ + Custom authentication policy for Tailbone. + + This is mostly Pyramid's built-in session-based policy, but adds + logic to accept Rattail User API Tokens in lieu of current user + being identified via the session. + + Note that the traditional Tailbone web app does *not* use this + policy, only the Tailbone web API uses it by default. + """ + + def unauthenticated_userid(self, request): + + # figure out userid from header token if present + credentials = request.headers.get('Authorization') + if credentials: + match = re.match(r'^Bearer (\S+)$', credentials) + if match: + token = match.group(1) + rattail_config = request.registry.settings.get('rattail_config') + app = rattail_config.get_app() + auth = app.get_auth_handler() + user = auth.authenticate_user_token(Session(), token) + if user: + return user.uuid + + # otherwise do normal session-based logic + return super().unauthenticated_userid(request) + + +@implementer(IAuthorizationPolicy) +class TailboneAuthorizationPolicy(object): + + def permits(self, context, principals, permission): + config = context.request.rattail_config + model = config.get_model() + app = config.get_app() + auth = app.get_auth_handler() + + for userid in principals: + if userid not in (Everyone, Authenticated): + if context.request.user and context.request.user.uuid == userid: + return context.request.has_perm(permission) + else: + # this is pretty rare, but can happen in dev after + # re-creating the database, which means new user uuids. + # TODO: the odds of this query returning a user in that + # case, are probably nil, and we should just skip this bit? + user = Session.get(model.User, userid) + if user: + if auth.has_permission(Session(), user, permission): + return True + if Everyone in principals: + return auth.has_permission(Session(), None, permission) + return False + + def principals_allowed_by_permission(self, context, permission): + raise NotImplementedError + + +class TailboneSecurityPolicy: + + def __init__(self, api_mode=False): + from pyramid.authentication import SessionAuthenticationHelper + from pyramid.request import RequestLocalCache - def __init__(self, db_session=None, api_mode=False, **kwargs): - kwargs['db_session'] = db_session or Session() - super().__init__(**kwargs) self.api_mode = api_mode + self.session_helper = SessionAuthenticationHelper() + self.identity_cache = RequestLocalCache(self.load_identity) def load_identity(self, request): config = request.registry.settings.get('rattail_config') @@ -113,7 +175,7 @@ class TailboneSecurityPolicy(WuttaSecurityPolicy): if match: token = match.group(1) auth = app.get_auth_handler() - user = auth.authenticate_user_token(self.db_session, token) + user = auth.authenticate_user_token(Session(), token) if not user: @@ -124,10 +186,63 @@ class TailboneSecurityPolicy(WuttaSecurityPolicy): # fetch user object from db model = app.model - user = self.db_session.get(model.User, uuid) + user = Session.get(model.User, uuid) if not user: return # this user is responsible for data changes in current request - self.db_session.set_continuum_user(user) + Session().set_continuum_user(user) return user + + def identity(self, request): + return self.identity_cache.get_or_create(request) + + def authenticated_userid(self, request): + user = self.identity(request) + if user is not None: + return user.uuid + + def remember(self, request, userid, **kw): + return self.session_helper.remember(request, userid, **kw) + + def forget(self, request, **kw): + return self.session_helper.forget(request, **kw) + + def permits(self, request, context, permission): + # nb. root user can do anything + if request.is_root: + return True + + config = request.registry.settings.get('rattail_config') + app = config.get_app() + auth = app.get_auth_handler() + + user = self.identity(request) + return auth.has_permission(Session(), user, permission) + + +def add_permission_group(config, key, label=None, overwrite=True): + """ + Add a permission group to the app configuration. + """ + def action(): + perms = config.get_settings().get('tailbone_permissions', {}) + if key not in perms or overwrite: + group = perms.setdefault(key, {'key': key}) + group['label'] = label or prettify(key) + config.add_settings({'tailbone_permissions': perms}) + config.action(None, action) + + +def add_permission(config, groupkey, key, label=None): + """ + Add a permission to the app configuration. + """ + def action(): + perms = config.get_settings().get('tailbone_permissions', {}) + group = perms.setdefault(groupkey, {'key': groupkey}) + group.setdefault('label', prettify(groupkey)) + perm = group.setdefault('perms', {}).setdefault(key, {'key': key}) + perm['label'] = label or prettify(key) + config.add_settings({'tailbone_permissions': perms}) + config.action(None, action) diff --git a/tailbone/beaker.py b/tailbone/beaker.py index 25a450df..b5d592f1 100644 --- a/tailbone/beaker.py +++ b/tailbone/beaker.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2024 Lance Edgar +# Copyright © 2010-2022 Lance Edgar # # This file is part of Rattail. # @@ -27,11 +27,11 @@ Note that most of the code for this module was copied from the beaker and pyramid_beaker projects. """ +from __future__ import unicode_literals, absolute_import + import time from pkg_resources import parse_version -from rattail.util import get_pkg_version - import beaker from beaker.session import Session from beaker.util import coerce_session_params @@ -49,7 +49,7 @@ class TailboneSession(Session): "Loads the data from this session from persistent storage" # are we using older version of beaker? - old_beaker = parse_version(get_pkg_version('beaker')) < parse_version('1.12') + old_beaker = parse_version(beaker.__version__) < parse_version('1.12') self.namespace = self.namespace_class(self.id, data_dir=self.data_dir, diff --git a/tailbone/config.py b/tailbone/config.py index 8392ba0a..9326a3cb 100644 --- a/tailbone/config.py +++ b/tailbone/config.py @@ -26,14 +26,13 @@ Rattail config extension for Tailbone import warnings -from wuttjamaican.conf import WuttaConfigExtension - +from rattail.config import ConfigExtension as BaseExtension from rattail.db.config import configure_session from tailbone.db import Session -class ConfigExtension(WuttaConfigExtension): +class ConfigExtension(BaseExtension): """ Rattail config extension for Tailbone. Does the following: @@ -50,12 +49,9 @@ class ConfigExtension(WuttaConfigExtension): configure_session(config, Session) # provide default theme selection - config.setdefault('tailbone', 'themes.keys', 'default, butterball') + config.setdefault('tailbone', 'themes.keys', 'default, falafel') config.setdefault('tailbone', 'themes.expose_picker', 'true') - # override oruga detection - config.setdefault('wuttaweb.oruga_detector.spec', 'tailbone.util:should_use_oruga') - def csrf_token_name(config): return config.get('tailbone', 'csrf_token_name', default='_csrf') diff --git a/tailbone/db.py b/tailbone/db.py index 8b37f399..4a6821f9 100644 --- a/tailbone/db.py +++ b/tailbone/db.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2024 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -21,13 +21,14 @@ # ################################################################################ """ -Database sessions etc. +Database Stuff """ import sqlalchemy as sa from zope.sqlalchemy import datamanager import sqlalchemy_continuum as continuum from sqlalchemy.orm import sessionmaker, scoped_session +from pkg_resources import get_distribution, parse_version from rattail.db import SessionBase from rattail.db.continuum import versioning_manager @@ -42,28 +43,23 @@ TrainwreckSession = scoped_session(sessionmaker()) # empty dict for now, this must populated on app startup (if needed) ExtraTrainwreckSessions = {} +# some of the logic below may need to vary somewhat, based on which version of +# zope.sqlalchemy we have installed +zope_sqlalchemy_version = get_distribution('zope.sqlalchemy').version +zope_sqlalchemy_version_parsed = parse_version(zope_sqlalchemy_version) + class TailboneSessionDataManager(datamanager.SessionDataManager): - """ - Integrate a top level sqlalchemy session transaction into a zope - transaction + """Integrate a top level sqlalchemy session transaction into a zope transaction One phase variant. .. note:: - - This class appears to be necessary in order for the - SQLAlchemy-Continuum integration to work alongside the Zope - transaction integration. - - It subclasses - ``zope.sqlalchemy.datamanager.SessionDataManager`` but injects - some SQLAlchemy-Continuum logic within :meth:`tpc_vote()`, and - is sort of monkey-patched into the mix. + This class appears to be necessary in order for the Continuum + integration to work alongside the Zope transaction integration. """ def tpc_vote(self, trans): - """ """ # for a one phase data manager commit last in tpc_vote if self.tx is not None: # there may have been no work to do @@ -75,120 +71,126 @@ class TailboneSessionDataManager(datamanager.SessionDataManager): self._finish('committed') -def join_transaction( - session, - initial_state=datamanager.STATUS_ACTIVE, - transaction_manager=datamanager.zope_transaction.manager, - keep_session=False, -): - """ - Join a session to a transaction using the appropriate datamanager. +def join_transaction(session, initial_state=datamanager.STATUS_ACTIVE, transaction_manager=datamanager.zope_transaction.manager, keep_session=False): + """Join a session to a transaction using the appropriate datamanager. - It is safe to call this multiple times, if the session is already - joined then it just returns. + It is safe to call this multiple times, if the session is already joined + then it just returns. - `initial_state` is either STATUS_ACTIVE, STATUS_INVALIDATED or - STATUS_READONLY + `initial_state` is either STATUS_ACTIVE, STATUS_INVALIDATED or STATUS_READONLY - If using the default initial status of STATUS_ACTIVE, you must - ensure that mark_changed(session) is called when data is written - to the database. + If using the default initial status of STATUS_ACTIVE, you must ensure that + mark_changed(session) is called when data is written to the database. - The ZopeTransactionExtesion SessionExtension can be used to ensure - that this is called automatically after session write operations. + The ZopeTransactionExtesion SessionExtension can be used to ensure that this is + called automatically after session write operations. .. note:: - - This function appears to be necessary in order for the - SQLAlchemy-Continuum integration to work alongside the Zope - transaction integration. - - It overrides ``zope.sqlalchemy.datamanager.join_transaction()`` - to ensure the custom :class:`TailboneSessionDataManager` is - used, and is sort of monkey-patched into the mix. + This function is copied from upstream, and tweaked so that our custom + :class:`TailboneSessionDataManager` will be used. """ # the upstream internals of this function has changed a little over time. # unfortunately for us, that means we must include each variant here. - if datamanager._SESSION_STATE.get(session, None) is None: - if session.twophase: - DataManager = datamanager.TwoPhaseSessionDataManager - else: - DataManager = TailboneSessionDataManager - DataManager(session, initial_state, transaction_manager, keep_session=keep_session) + if zope_sqlalchemy_version_parsed >= parse_version('1.1'): # 1.1+ + if datamanager._SESSION_STATE.get(session, None) is None: + if session.twophase: + DataManager = datamanager.TwoPhaseSessionDataManager + else: + DataManager = TailboneSessionDataManager + DataManager(session, initial_state, transaction_manager, keep_session=keep_session) + + else: # pre-1.1 + if datamanager._SESSION_STATE.get(id(session), None) is None: + if session.twophase: + DataManager = datamanager.TwoPhaseSessionDataManager + else: + DataManager = TailboneSessionDataManager + DataManager(session, initial_state, transaction_manager, keep_session=keep_session) -class ZopeTransactionEvents(datamanager.ZopeTransactionEvents): - """ - Record that a flush has occurred on a session's connection. This - allows the DataManager to rollback rather than commit on read only - transactions. +if zope_sqlalchemy_version_parsed >= parse_version('1.2'): # 1.2+ - .. note:: + class ZopeTransactionEvents(datamanager.ZopeTransactionEvents): + """ + Record that a flush has occurred on a session's + connection. This allows the DataManager to rollback rather + than commit on read only transactions. - This class appears to be necessary in order for the - SQLAlchemy-Continuum integration to work alongside the Zope - transaction integration. + .. note:: + This class is copied from upstream, and tweaked so that our + custom :func:`join_transaction()` will be used. + """ - It subclasses - ``zope.sqlalchemy.datamanager.ZopeTransactionEvents`` but - overrides various methods to ensure the custom - :func:`join_transaction()` is called, and is sort of - monkey-patched into the mix. - """ + def after_begin(self, session, transaction, connection): + join_transaction(session, self.initial_state, + self.transaction_manager, self.keep_session) - def after_begin(self, session, transaction, connection): - """ """ - join_transaction(session, self.initial_state, - self.transaction_manager, self.keep_session) + def after_attach(self, session, instance): + join_transaction(session, self.initial_state, + self.transaction_manager, self.keep_session) - def after_attach(self, session, instance): - """ """ - join_transaction(session, self.initial_state, - self.transaction_manager, self.keep_session) + def join_transaction(self, session): + join_transaction(session, self.initial_state, + self.transaction_manager, self.keep_session) - def join_transaction(self, session): - """ """ - join_transaction(session, self.initial_state, - self.transaction_manager, self.keep_session) +else: # pre-1.2 + + class ZopeTransactionExtension(datamanager.ZopeTransactionExtension): + """ + Record that a flush has occurred on a session's + connection. This allows the DataManager to rollback rather + than commit on read only transactions. + + .. note:: + This class is copied from upstream, and tweaked so that our + custom :func:`join_transaction()` will be used. + """ + + def after_begin(self, session, transaction, connection): + join_transaction(session, self.initial_state, + self.transaction_manager, self.keep_session) + + def after_attach(self, session, instance): + join_transaction(session, self.initial_state, + self.transaction_manager, self.keep_session) -def register( - session, - initial_state=datamanager.STATUS_ACTIVE, - transaction_manager=datamanager.zope_transaction.manager, - keep_session=False, -): - """ - Register ZopeTransaction listener events on the given Session or - Session factory/class. +def register(session, initial_state=datamanager.STATUS_ACTIVE, + transaction_manager=datamanager.zope_transaction.manager, keep_session=False): + """Register ZopeTransaction listener events on the + given Session or Session factory/class. - This function requires at least SQLAlchemy 0.7 and makes use of - the newer sqlalchemy.event package in order to register event - listeners on the given Session. + This function requires at least SQLAlchemy 0.7 and makes use + of the newer sqlalchemy.event package in order to register event listeners + on the given Session. The session argument here may be a Session class or subclass, a - sessionmaker or scoped_session instance, or a specific Session - instance. Event listening will be specific to the scope of the - type of argument passed, including specificity to its subclass as - well as its identity. + sessionmaker or scoped_session instance, or a specific Session instance. + Event listening will be specific to the scope of the type of argument + passed, including specificity to its subclass as well as its identity. .. note:: - - This function appears to be necessary in order for the - SQLAlchemy-Continuum integration to work alongside the Zope - transaction integration. - - It overrides ``zope.sqlalchemy.datamanager.regsiter()`` to - ensure the custom :class:`ZopeTransactionEvents` is used. + This function is copied from upstream, and tweaked so that our custom + :class:`ZopeTransactionExtension` will be used. """ from sqlalchemy import event - ext = ZopeTransactionEvents( - initial_state=initial_state, - transaction_manager=transaction_manager, - keep_session=keep_session, - ) + if zope_sqlalchemy_version_parsed >= parse_version('1.2'): # 1.2+ + + ext = ZopeTransactionEvents( + initial_state=initial_state, + transaction_manager=transaction_manager, + keep_session=keep_session, + ) + + else: # pre-1.2 + + ext = ZopeTransactionExtension( + initial_state=initial_state, + transaction_manager=transaction_manager, + keep_session=keep_session, + ) event.listen(session, "after_begin", ext.after_begin) event.listen(session, "after_attach", ext.after_attach) @@ -197,8 +199,9 @@ def register( event.listen(session, "after_bulk_delete", ext.after_bulk_delete) event.listen(session, "before_commit", ext.before_commit) - if datamanager.SA_GE_14: - event.listen(session, "do_orm_execute", ext.do_orm_execute) + if zope_sqlalchemy_version_parsed >= parse_version('1.5'): # 1.5+ + if datamanager.SA_GE_14: + event.listen(session, "do_orm_execute", ext.do_orm_execute) register(Session) diff --git a/tailbone/diffs.py b/tailbone/diffs.py index 2e582b15..98253c57 100644 --- a/tailbone/diffs.py +++ b/tailbone/diffs.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2024 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -270,21 +270,9 @@ class VersionDiff(Diff): for field in self.fields: values[field] = {'before': self.render_old_value(field), 'after': self.render_new_value(field)} - - operation = None - if self.version.operation_type == continuum.Operation.INSERT: - operation = 'INSERT' - elif self.version.operation_type == continuum.Operation.UPDATE: - operation = 'UPDATE' - elif self.version.operation_type == continuum.Operation.DELETE: - operation = 'DELETE' - else: - operation = self.version.operation_type - return { 'key': id(self.version), 'model_title': self.title, - 'operation': operation, 'diff_class': self.nature, 'fields': self.fields, 'values': values, diff --git a/tailbone/exceptions.py b/tailbone/exceptions.py index 3468562a..beea1366 100644 --- a/tailbone/exceptions.py +++ b/tailbone/exceptions.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2024 Lance Edgar +# Copyright © 2010-2020 Lance Edgar # # This file is part of Rattail. # @@ -24,6 +24,10 @@ Tailbone Exceptions """ +from __future__ import unicode_literals, absolute_import + +import six + from rattail.exceptions import RattailError @@ -33,6 +37,7 @@ class TailboneError(RattailError): """ +@six.python_2_unicode_compatible class TailboneJSONFieldError(TailboneError): """ Error raised when JSON serialization of a form field results in an error. diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index 4024557b..d6303bb1 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.py @@ -35,7 +35,7 @@ from sqlalchemy import orm from sqlalchemy.ext.associationproxy import AssociationProxy, ASSOCIATION_PROXY from wuttjamaican.util import UNSPECIFIED -from rattail.util import pretty_boolean +from rattail.util import prettify, pretty_boolean from rattail.db.util import get_fieldnames import colander @@ -47,10 +47,8 @@ from pyramid_deform import SessionFileUploadTempStore from pyramid.renderers import render from webhelpers2.html import tags, HTML -from wuttaweb.util import FieldList, get_form_data, make_json_safe - from tailbone.db import Session -from tailbone.util import raw_datetime, render_markdown +from tailbone.util import raw_datetime, get_form_data, render_markdown from tailbone.forms import types from tailbone.forms.widgets import (ReadonlyWidget, PlainDateWidget, JQueryDateWidget, JQueryTimeWidget, @@ -328,7 +326,7 @@ class Form(object): """ Base class for all forms. """ - save_label = "Submit" + save_label = "Save" update_label = "Save" show_cancel = True auto_disable = True @@ -339,12 +337,10 @@ class Form(object): model_instance=None, model_class=None, appstruct=UNSPECIFIED, nodes={}, enums={}, labels={}, assume_local_times=False, renderers=None, renderer_kwargs={}, hidden={}, widgets={}, defaults={}, validators={}, required={}, helptext={}, focus_spec=None, - action_url=None, cancel_url=None, - vue_tagname=None, + action_url=None, cancel_url=None, component='tailbone-form', vuejs_component_kwargs=None, vuejs_field_converters={}, json_data={}, included_templates={}, # TODO: ugh this is getting out hand! can_edit_help=False, edit_help_url=None, route_prefix=None, - **kwargs ): self.fields = None if fields is not None: @@ -382,17 +378,7 @@ class Form(object): self.focus_spec = focus_spec self.action_url = action_url self.cancel_url = cancel_url - - # vue_tagname - self.vue_tagname = vue_tagname - if not self.vue_tagname and kwargs.get('component'): - warnings.warn("component kwarg is deprecated for Form(); " - "please use vue_tagname param instead", - DeprecationWarning, stacklevel=2) - self.vue_tagname = kwargs['component'] - if not self.vue_tagname: - self.vue_tagname = 'tailbone-form' - + self.component = component self.vuejs_component_kwargs = vuejs_component_kwargs or {} self.vuejs_field_converters = vuejs_field_converters or {} self.json_data = json_data or {} @@ -401,59 +387,13 @@ class Form(object): self.edit_help_url = edit_help_url self.route_prefix = route_prefix - self.button_icon_submit = kwargs.get('button_icon_submit', 'save') - def __iter__(self): return iter(self.fields) - @property - def vue_component(self): - """ - String name for the Vue component, e.g. ``'TailboneGrid'``. - - This is a generated value based on :attr:`vue_tagname`. - """ - words = self.vue_tagname.split('-') - return ''.join([word.capitalize() for word in words]) - - @property - def component(self): - """ - DEPRECATED - use :attr:`vue_tagname` instead. - """ - warnings.warn("Form.component is deprecated; " - "please use vue_tagname instead", - DeprecationWarning, stacklevel=2) - return self.vue_tagname - @property def component_studly(self): - """ - DEPRECATED - use :attr:`vue_component` instead. - """ - warnings.warn("Form.component_studly is deprecated; " - "please use vue_component instead", - DeprecationWarning, stacklevel=2) - return self.vue_component - - def get_button_label_submit(self): - """ """ - if hasattr(self, '_button_label_submit'): - return self._button_label_submit - - label = getattr(self, 'submit_label', None) - if label: - return label - - return self.save_label - - def set_button_label_submit(self, value): - """ """ - self._button_label_submit = value - - # wutta compat - button_label_submit = property(get_button_label_submit, - set_button_label_submit) + words = self.component.split('-') + return ''.join([word.capitalize() for word in words]) def __contains__(self, item): return item in self.fields @@ -630,9 +570,7 @@ class Form(object): self.schema[key].title = label def get_label(self, key): - config = self.request.rattail_config - app = config.get_app() - return self.labels.get(key, app.make_title(key)) + return self.labels.get(key, prettify(key)) def set_readonly(self, key, readonly=True): if readonly: @@ -863,10 +801,6 @@ class Form(object): DeprecationWarning, stacklevel=2) return self.render_deform(**kwargs) - def get_deform(self): - """ """ - return self.make_deform_form() - def make_deform_form(self): if not hasattr(self, 'deform_form'): @@ -905,11 +839,6 @@ class Form(object): return self.deform_form - def render_vue_template(self, template='/forms/deform.mako', **context): - """ """ - output = self.render_deform(template=template, **context) - return HTML.literal(output) - def render_deform(self, dform=None, template=None, **kwargs): if not template: template = '/forms/deform.mako' @@ -932,8 +861,8 @@ class Form(object): context.setdefault('form_kwargs', {}) # TODO: deprecate / remove the latter option here if self.auto_disable_save or self.auto_disable: - context['form_kwargs'].setdefault('ref', self.vue_component) - context['form_kwargs']['@submit'] = 'submit{}'.format(self.vue_component) + context['form_kwargs'].setdefault('ref', self.component_studly) + context['form_kwargs']['@submit'] = 'submit{}'.format(self.component_studly) if self.focus_spec: context['form_kwargs']['data-focus'] = self.focus_spec context['request'] = self.request @@ -945,13 +874,11 @@ class Form(object): return dict([(field, self.get_label(field)) for field in self]) - def get_field_markdowns(self, session=None): - app = self.request.rattail_config.get_app() - model = app.model - session = session or Session() + def get_field_markdowns(self): + model = self.request.rattail_config.get_model() if not hasattr(self, 'field_markdowns'): - infos = session.query(model.TailboneFieldInfo)\ + infos = Session.query(model.TailboneFieldInfo)\ .filter(model.TailboneFieldInfo.route_prefix == self.route_prefix)\ .all() self.field_markdowns = dict([(info.field_name, info.markdown_text) @@ -959,18 +886,6 @@ class Form(object): return self.field_markdowns - def get_vue_field_value(self, key): - """ """ - if key not in self.fields: - return - - dform = self.get_deform() - if key not in dform: - return - - field = dform[key] - return make_json_safe(field.cstruct) - def get_vuejs_model_value(self, field): """ This method must return "raw" JS which will be assigned as the initial @@ -1037,11 +952,7 @@ class Form(object): def set_vuejs_component_kwargs(self, **kwargs): self.vuejs_component_kwargs.update(kwargs) - def render_vue_tag(self, **kwargs): - """ """ - return self.render_vuejs_component(**kwargs) - - def render_vuejs_component(self, **kwargs): + def render_vuejs_component(self): """ Render the Vue.js component HTML for the form. @@ -1052,11 +963,10 @@ class Form(object): """ - kw = dict(self.vuejs_component_kwargs) - kw.update(kwargs) + kwargs = dict(self.vuejs_component_kwargs) if self.can_edit_help: - kw.setdefault(':configure-fields-help', 'configureFieldsHelp') - return HTML.tag(self.vue_tagname, **kw) + kwargs.setdefault(':configure-fields-help', 'configureFieldsHelp') + return HTML.tag(self.component, **kwargs) def set_json_data(self, key, value): """ @@ -1082,12 +992,7 @@ class Form(object): templates.append(HTML.literal(render(template, context))) return HTML.literal('\n').join(templates) - def render_vue_field(self, fieldname, **kwargs): - """ """ - return self.render_field_complete(fieldname, **kwargs) - - def render_field_complete(self, fieldname, bfield_attrs={}, - session=None): + def render_field_complete(self, fieldname, bfield_attrs={}): """ Render the given field completely, i.e. with ```` wrapper. Note that this is meant to render *editable* fields, @@ -1105,7 +1010,7 @@ class Form(object): if self.field_visible(fieldname): label = self.get_label(fieldname) - markdowns = self.get_field_markdowns(session=session) + markdowns = self.get_field_markdowns() # these attrs will be for the (*not* the widget) attrs = { @@ -1224,18 +1129,6 @@ class Form(object): # TODO: again, why does serialize() not return literal? return HTML.literal(field.serialize()) - # TODO: this was copied from wuttaweb; can remove when we align - # Form class structure - def render_vue_finalize(self): - """ """ - set_data = f"{self.vue_component}.data = function() {{ return {self.vue_component}Data }}" - make_component = f"Vue.component('{self.vue_tagname}', {self.vue_component})" - return HTML.tag('script', c=['\n', - HTML.literal(set_data), - '\n', - HTML.literal(make_component), - '\n']) - def render_field_readonly(self, field_name, **kwargs): """ Render the given field completely, but in read-only fashion. @@ -1375,19 +1268,12 @@ class Form(object): def obtain_value(self, record, field_name): if record: - - if isinstance(record, dict): - return record[field_name] - - try: - return getattr(record, field_name) - except AttributeError: - pass - try: return record[field_name] + except KeyError: + return None except TypeError: - pass + return getattr(record, field_name, None) # TODO: is this always safe to do? elif self.defaults and field_name in self.defaults: @@ -1441,6 +1327,30 @@ class Form(object): return False +class FieldList(list): + """ + Convenience wrapper for a form's field list. + """ + + def insert_before(self, field, newfield): + if field in self: + i = self.index(field) + self.insert(i, newfield) + else: + log.warning("field '%s' not found, will append new field: %s", + field, newfield) + self.append(newfield) + + def insert_after(self, field, newfield): + if field in self: + i = self.index(field) + self.insert(i + 1, newfield) + else: + log.warning("field '%s' not found, will append new field: %s", + field, newfield) + self.append(newfield) + + @colander.deferred def upload_widget(node, kw): request = kw['request'] diff --git a/tailbone/forms/widgets.py b/tailbone/forms/widgets.py index 8c16726d..2923b7ec 100644 --- a/tailbone/forms/widgets.py +++ b/tailbone/forms/widgets.py @@ -477,8 +477,7 @@ class CustomerAutocompleteWidget(JQueryAutocompleteWidget): def __init__(self, request, *args, **kwargs): super().__init__(*args, **kwargs) self.request = request - app = self.request.rattail_config.get_app() - model = app.model + model = self.request.rattail_config.get_model() # must figure out URL providing autocomplete service if 'service_url' not in kwargs: @@ -499,8 +498,7 @@ class CustomerAutocompleteWidget(JQueryAutocompleteWidget): """ """ # fetch customer to provide button label, if we have a value if cstruct: - app = self.request.rattail_config.get_app() - model = app.model + model = self.request.rattail_config.get_model() customer = Session.get(model.Customer, cstruct) if customer: self.field_display = str(customer) @@ -554,8 +552,7 @@ class DepartmentWidget(dfwidget.SelectWidget): def __init__(self, request, **kwargs): if 'values' not in kwargs: - app = request.rattail_config.get_app() - model = app.model + model = request.rattail_config.get_model() departments = Session.query(model.Department)\ .order_by(model.Department.number) values = [(dept.uuid, str(dept)) @@ -597,8 +594,7 @@ class VendorAutocompleteWidget(JQueryAutocompleteWidget): def __init__(self, request, *args, **kwargs): super().__init__(*args, **kwargs) self.request = request - app = self.request.rattail_config.get_app() - model = app.model + model = self.request.rattail_config.get_model() # must figure out URL providing autocomplete service if 'service_url' not in kwargs: @@ -619,8 +615,7 @@ class VendorAutocompleteWidget(JQueryAutocompleteWidget): """ """ # fetch vendor to provide button label, if we have a value if cstruct: - app = self.request.rattail_config.get_app() - model = app.model + model = self.request.rattail_config.get_model() vendor = Session.get(model.Vendor, cstruct) if vendor: self.field_display = str(vendor) @@ -648,8 +643,7 @@ class VendorDropdownWidget(dfwidget.SelectWidget): vendors = vendors() else: # default vendor list - app = self.request.rattail_config.get_app() - model = app.model + model = self.request.rattail_config.get_model() vendors = Session.query(model.Vendor)\ .order_by(model.Vendor.name)\ .all() diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index 56b97b86..91c3d1f5 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -24,24 +24,20 @@ Core Grid Classes """ -import inspect -import logging -import warnings from urllib.parse import urlencode +import warnings +import logging import sqlalchemy as sa from sqlalchemy import orm -from wuttjamaican.util import UNSPECIFIED from rattail.db.types import GPCType -from rattail.util import prettify, pretty_boolean +from rattail.util import prettify, pretty_boolean, pretty_quantity from pyramid.renderers import render from webhelpers2.html import HTML, tags from paginate_sqlalchemy import SqlalchemyOrmPage -from wuttaweb.grids import Grid as WuttaGrid, GridAction as WuttaGridAction, SortInfo -from wuttaweb.util import FieldList from . import filters as gridfilters from tailbone.db import Session from tailbone.util import raw_datetime @@ -50,17 +46,23 @@ from tailbone.util import raw_datetime log = logging.getLogger(__name__) -class Grid(WuttaGrid): +class FieldList(list): + """ + Convenience wrapper for a field list. """ - Base class for all grids. - This is now a subclass of - :class:`wuttaweb:wuttaweb.grids.base.Grid`, and exists to add - customizations which have traditionally been part of Tailbone. + def insert_before(self, field, newfield): + i = self.index(field) + self.insert(i, newfield) - Some of these customizations are still undocumented. Some will - eventually be moved to the upstream/parent class, and possibly - some will be removed outright. What docs we have, are shown here. + def insert_after(self, field, newfield): + i = self.index(field) + self.insert(i + 1, newfield) + + +class Grid(object): + """ + Core grid class. In sore need of documentation. .. _Buefy docs: https://buefy.org/documentation/table/ @@ -183,92 +185,31 @@ class Grid(WuttaGrid): grid.row_uuid_getter = fake_uuid """ - def __init__( - self, - request, - key=None, - data=None, - width='auto', - model_title=None, - model_title_plural=None, - enums={}, - assume_local_times=False, - invisible=[], - raw_renderers={}, - extra_row_class=None, - url='#', - use_byte_string_filters=False, - checkboxes=False, - checked=None, - check_handler=None, - check_all_handler=None, - checkable=None, - row_uuid_getter=None, - clicking_row_checks_box=False, - click_handlers=None, - main_actions=[], - more_actions=[], - delete_speedbump=False, - ajax_data_url=None, - expose_direct_link=False, - **kwargs, - ): - if 'component' in kwargs: - warnings.warn("component param is deprecated for Grid(); " - "please use vue_tagname param instead", - DeprecationWarning, stacklevel=2) - kwargs.setdefault('vue_tagname', kwargs.pop('component')) + def __init__(self, key, data, columns=None, width='auto', request=None, + model_class=None, model_title=None, model_title_plural=None, + enums={}, labels={}, assume_local_times=False, renderers={}, invisible=[], + raw_renderers={}, + extra_row_class=None, linked_columns=[], url='#', + joiners={}, filterable=False, filters={}, use_byte_string_filters=False, + searchable={}, + sortable=False, sorters={}, default_sortkey=None, default_sortdir='asc', + pageable=False, default_pagesize=None, default_page=1, + checkboxes=False, checked=None, check_handler=None, check_all_handler=None, + checkable=None, row_uuid_getter=None, + clicking_row_checks_box=False, click_handlers=None, + main_actions=[], more_actions=[], delete_speedbump=False, + ajax_data_url=None, component='tailbone-grid', + expose_direct_link=False, + **kwargs): - if 'default_sortkey' in kwargs: - warnings.warn("default_sortkey param is deprecated for Grid(); " - "please use sort_defaults param instead", - DeprecationWarning, stacklevel=2) - if 'default_sortdir' in kwargs: - warnings.warn("default_sortdir param is deprecated for Grid(); " - "please use sort_defaults param instead", - DeprecationWarning, stacklevel=2) - if 'default_sortkey' in kwargs or 'default_sortdir' in kwargs: - sortkey = kwargs.pop('default_sortkey', None) - sortdir = kwargs.pop('default_sortdir', 'asc') - if sortkey: - kwargs.setdefault('sort_defaults', [(sortkey, sortdir)]) - - if 'pageable' in kwargs: - warnings.warn("pageable param is deprecated for Grid(); " - "please use paginated param instead", - DeprecationWarning, stacklevel=2) - kwargs.setdefault('paginated', kwargs.pop('pageable')) - - if 'default_pagesize' in kwargs: - warnings.warn("default_pagesize param is deprecated for Grid(); " - "please use pagesize param instead", - DeprecationWarning, stacklevel=2) - kwargs.setdefault('pagesize', kwargs.pop('default_pagesize')) - - if 'default_page' in kwargs: - warnings.warn("default_page param is deprecated for Grid(); " - "please use page param instead", - DeprecationWarning, stacklevel=2) - kwargs.setdefault('page', kwargs.pop('default_page')) - - if 'searchable' in kwargs: - warnings.warn("searchable param is deprecated for Grid(); " - "please use searchable_columns param instead", - DeprecationWarning, stacklevel=2) - kwargs.setdefault('searchable_columns', kwargs.pop('searchable')) - - # TODO: this should not be needed once all templates correctly - # reference grid.vue_component etc. - kwargs.setdefault('vue_tagname', 'tailbone-grid') - - # nb. these must be set before super init, as they are - # referenced when constructing filters - self.assume_local_times = assume_local_times - self.use_byte_string_filters = use_byte_string_filters - - kwargs['key'] = key - kwargs['data'] = data - super().__init__(request, **kwargs) + self.key = key + self.data = data + self.columns = FieldList(columns) if columns is not None else None + self.width = width + self.request = request + self.model_class = model_class + if self.model_class and self.columns is None: + self.columns = self.make_columns() self.model_title = model_title if not self.model_title and self.model_class and hasattr(self.model_class, 'get_model_title'): @@ -281,13 +222,32 @@ class Grid(WuttaGrid): if not self.model_title_plural: self.model_title_plural = '{}s'.format(self.model_title) - self.width = width self.enums = enums or {} - self.renderers = self.make_default_renderers(self.renderers) + + self.labels = labels or {} + self.assume_local_times = assume_local_times + self.renderers = self.make_default_renderers(renderers or {}) self.raw_renderers = raw_renderers or {} self.invisible = invisible or [] self.extra_row_class = extra_row_class + self.linked_columns = linked_columns or [] self.url = url + self.joiners = joiners or {} + + self.filterable = filterable + self.use_byte_string_filters = use_byte_string_filters + self.filters = self.make_filters(filters) + + self.searchable = searchable or {} + + self.sortable = sortable + self.sorters = self.make_sorters(sorters) + self.default_sortkey = default_sortkey + self.default_sortdir = default_sortdir + + self.pageable = pageable + self.default_pagesize = default_pagesize + self.default_page = default_page self.checkboxes = checkboxes self.checked = checked @@ -301,104 +261,43 @@ class Grid(WuttaGrid): self.click_handlers = click_handlers or {} + self.main_actions = main_actions or [] + self.more_actions = more_actions or [] self.delete_speedbump = delete_speedbump if ajax_data_url: self.ajax_data_url = ajax_data_url elif self.request: - self.ajax_data_url = self.request.path_url + self.ajax_data_url = self.request.current_route_url(_query=None) else: self.ajax_data_url = '' - self.main_actions = main_actions or [] - if self.main_actions: - warnings.warn("main_actions param is deprecated for Grdi(); " - "please use actions param instead", - DeprecationWarning, stacklevel=2) - self.actions.extend(self.main_actions) - self.more_actions = more_actions or [] - if self.more_actions: - warnings.warn("more_actions param is deprecated for Grdi(); " - "please use actions param instead", - DeprecationWarning, stacklevel=2) - self.actions.extend(self.more_actions) - + self.component = component self.expose_direct_link = expose_direct_link self._whgrid_kwargs = kwargs - @property - def component(self): - """ """ - warnings.warn("Grid.component is deprecated; " - "please use vue_tagname instead", - DeprecationWarning, stacklevel=2) - return self.vue_tagname - @property def component_studly(self): - """ """ - warnings.warn("Grid.component_studly is deprecated; " - "please use vue_component instead", - DeprecationWarning, stacklevel=2) - return self.vue_component + words = self.component.split('-') + return ''.join([word.capitalize() for word in words]) - def get_default_sortkey(self): - """ """ - warnings.warn("Grid.default_sortkey is deprecated; " - "please use Grid.sort_defaults instead", - DeprecationWarning, stacklevel=2) - if self.sort_defaults: - return self.sort_defaults[0].sortkey + def make_columns(self): + """ + Return a default list of columns, based on :attr:`model_class`. + """ + if not self.model_class: + raise ValueError("Must define model_class to use make_columns()") - def set_default_sortkey(self, value): - """ """ - warnings.warn("Grid.default_sortkey is deprecated; " - "please use Grid.sort_defaults instead", - DeprecationWarning, stacklevel=2) - if self.sort_defaults: - info = self.sort_defaults[0] - self.sort_defaults[0] = SortInfo(value, info.sortdir) - else: - self.sort_defaults = [SortInfo(value, 'asc')] + mapper = orm.class_mapper(self.model_class) + return [prop.key for prop in mapper.iterate_properties] - default_sortkey = property(get_default_sortkey, set_default_sortkey) - - def get_default_sortdir(self): - """ """ - warnings.warn("Grid.default_sortdir is deprecated; " - "please use Grid.sort_defaults instead", - DeprecationWarning, stacklevel=2) - if self.sort_defaults: - return self.sort_defaults[0].sortdir - - def set_default_sortdir(self, value): - """ """ - warnings.warn("Grid.default_sortdir is deprecated; " - "please use Grid.sort_defaults instead", - DeprecationWarning, stacklevel=2) - if self.sort_defaults: - info = self.sort_defaults[0] - self.sort_defaults[0] = SortInfo(info.sortkey, value) - else: - raise ValueError("cannot set default_sortdir without default_sortkey") - - default_sortdir = property(get_default_sortdir, set_default_sortdir) - - def get_pageable(self): - """ """ - warnings.warn("Grid.pageable is deprecated; " - "please use Grid.paginated instead", - DeprecationWarning, stacklevel=2) - return self.paginated - - def set_pageable(self, value): - """ """ - warnings.warn("Grid.pageable is deprecated; " - "please use Grid.paginated instead", - DeprecationWarning, stacklevel=2) - self.paginated = value - - pageable = property(get_pageable, set_pageable) + def remove(self, *keys): + """ + This *removes* some column(s) from the grid, altogether. + """ + for key in keys: + if key in self.columns: + self.columns.remove(key) def hide_column(self, key): """ @@ -432,6 +331,9 @@ class Grid(WuttaGrid): if key in self.invisible: self.invisible.remove(key) + def append(self, field): + self.columns.append(field) + def insert_before(self, field, newfield): self.columns.insert_before(field, newfield) @@ -443,54 +345,62 @@ class Grid(WuttaGrid): self.remove(oldfield) def set_joiner(self, key, joiner): - """ """ if joiner is None: - warnings.warn("specifying None is deprecated for Grid.set_joiner(); " - "please use Grid.remove_joiner() instead", - DeprecationWarning, stacklevel=2) - self.remove_joiner(key) + self.joiners.pop(key, None) else: - super().set_joiner(key, joiner) + self.joiners[key] = joiner def set_sorter(self, key, *args, **kwargs): - """ """ - - if len(args) == 1: - if kwargs: - warnings.warn("kwargs are ignored for Grid.set_sorter(); " - "please refactor your code accordingly", - DeprecationWarning, stacklevel=2) - if args[0] is None: - warnings.warn("specifying None is deprecated for Grid.set_sorter(); " - "please use Grid.remove_sorter() instead", - DeprecationWarning, stacklevel=2) - self.remove_sorter(key) - else: - super().set_sorter(key, args[0]) - - elif len(args) == 0: - super().set_sorter(key) - + if len(args) == 1 and args[0] is None: + self.remove_sorter(key) else: - warnings.warn("multiple args are deprecated for Grid.set_sorter(); " - "please refactor your code accordingly", - DeprecationWarning, stacklevel=2) self.sorters[key] = self.make_sorter(*args, **kwargs) - def set_filter(self, key, *args, **kwargs): - """ """ - if len(args) == 1: - if args[0] is None: - warnings.warn("specifying None is deprecated for Grid.set_filter(); " - "please use Grid.remove_filter() instead", - DeprecationWarning, stacklevel=2) - self.remove_filter(key) - return + def remove_sorter(self, key): + self.sorters.pop(key, None) - # TODO: our make_filter() signature differs from upstream, - # so must call it explicitly instead of delegating to super - kwargs.setdefault('label', self.get_label(key)) - self.filters[key] = self.make_filter(key, *args, **kwargs) + def set_sort_defaults(self, sortkey, sortdir='asc'): + self.default_sortkey = sortkey + self.default_sortdir = sortdir + + def set_filter(self, key, *args, **kwargs): + if len(args) == 1 and args[0] is None: + self.remove_filter(key) + else: + if 'label' not in kwargs and key in self.labels: + kwargs['label'] = self.labels[key] + self.filters[key] = self.make_filter(key, *args, **kwargs) + + def set_searchable(self, key, searchable=True): + if searchable: + self.searchable[key] = True + else: + self.searchable.pop(key, None) + + def is_searchable(self, key): + return self.searchable.get(key, False) + + def remove_filter(self, key): + self.filters.pop(key, None) + + def set_label(self, key, label, column_only=False): + self.labels[key] = label + if not column_only and key in self.filters: + self.filters[key].label = label + + def get_label(self, key): + """ + Returns the label text for given field key. + """ + return self.labels.get(key, prettify(key)) + + def set_link(self, key, link=True): + if link: + if key not in self.linked_columns: + self.linked_columns.append(key) + else: # unlink + if self.linked_columns and key in self.linked_columns: + self.linked_columns.remove(key) def set_click_handler(self, key, handler): if handler: @@ -501,6 +411,9 @@ class Grid(WuttaGrid): def has_click_handler(self, key): return key in self.click_handlers + def set_renderer(self, key, renderer): + self.renderers[key] = renderer + def set_raw_renderer(self, key, renderer): """ Set or remove the "raw" renderer for the given field. @@ -568,18 +481,12 @@ class Grid(WuttaGrid): if isinstance(obj, sa.engine.Row): return obj._mapping[column_name] - if isinstance(obj, dict): - return obj[column_name] - try: - return getattr(obj, column_name) - except AttributeError: + return obj[column_name] + except KeyError: pass - - try: - return obj[column_name] except TypeError: - pass + return getattr(obj, column_name, None) def render_currency(self, obj, column_name): value = self.obtain_value(obj, column_name) @@ -625,8 +532,7 @@ class Grid(WuttaGrid): def render_quantity(self, obj, column_name): value = self.obtain_value(obj, column_name) - app = self.request.rattail_config.get_app() - return app.render_quantity(value) + return pretty_quantity(value) def render_duration(self, obj, column_name): seconds = self.obtain_value(obj, column_name) @@ -694,14 +600,6 @@ class Grid(WuttaGrid): def actions_column_format(self, column_number, row_number, item): return HTML.td(self.render_actions(item, row_number), class_='actions') - # TODO: upstream should handle this.. - def make_backend_filters(self, filters=None): - """ """ - final = self.get_default_filters() - if filters: - final.update(filters) - return final - def get_default_filters(self): """ Returns the default set of filters provided by the grid. @@ -726,6 +624,16 @@ class Grid(WuttaGrid): filters[prop.key] = self.make_filter(prop.key, column) return filters + def make_filters(self, filters=None): + """ + Returns an initial set of filters which will be available to the grid. + The grid itself may or may not provide some default filters, and the + ``filters`` kwarg may contain additions and/or overrides. + """ + if filters: + return filters + return self.get_default_filters() + def make_filter(self, key, column, **kwargs): """ Make a filter suitable for use with the given column. @@ -773,103 +681,95 @@ class Grid(WuttaGrid): if filtr.active: yield filtr + def make_sorters(self, sorters=None): + """ + Returns an initial set of sorters which will be available to the grid. + The grid itself may or may not provide some default sorters, and the + ``sorters`` kwarg may contain additions and/or overrides. + """ + sorters, updates = {}, sorters + if self.model_class: + mapper = orm.class_mapper(self.model_class) + for prop in mapper.iterate_properties: + if isinstance(prop, orm.ColumnProperty) and not prop.key.endswith('uuid'): + sorters[prop.key] = self.make_sorter(prop) + if updates: + sorters.update(updates) + return sorters + + def make_sorter(self, model_property): + """ + Returns a function suitable for a sort map callable, with typical logic + built in for sorting applied to ``field``. + """ + class_ = getattr(model_property, 'class_', self.model_class) + column = getattr(class_, model_property.key) + + def sorter(query, direction): + # TODO: this seems hacky..normally we expect a true query + # of course, but in some cases it may be a list instead. + # if so then we can't actually sort + if isinstance(query, list): + return query + return query.order_by(getattr(column, direction)()) + + sorter._class = class_ + sorter._column = column + + return sorter + def make_simple_sorter(self, key, foldcase=False): - """ """ - warnings.warn("Grid.make_simple_sorter() is deprecated; " - "please use Grid.make_sorter() instead", - DeprecationWarning, stacklevel=2) - return self.make_sorter(key, foldcase=foldcase) - - def get_pagesize_options(self, default=None): - """ """ - # let upstream check config - options = super().get_pagesize_options(default=UNSPECIFIED) - if options is not UNSPECIFIED: - return options - - # fallback to legacy config - options = self.config.get_list('tailbone.grid.pagesize_options') - if options: - warnings.warn("tailbone.grid.pagesize_options setting is deprecated; " - "please set wuttaweb.grids.default_pagesize_options instead", - DeprecationWarning) - options = [int(size) for size in options - if size.isdigit()] - if options: - return options - - if default: - return default - - # use upstream default - return super().get_pagesize_options() - - def get_pagesize(self, default=None): - """ """ - # let upstream check config - pagesize = super().get_pagesize(default=UNSPECIFIED) - if pagesize is not UNSPECIFIED: - return pagesize - - # fallback to legacy config - pagesize = self.config.get_int('tailbone.grid.default_pagesize') - if pagesize: - warnings.warn("tailbone.grid.default_pagesize setting is deprecated; " - "please use wuttaweb.grids.default_pagesize instead", - DeprecationWarning) - return pagesize - - if default: - return default - - # use upstream default - return super().get_pagesize() - - def get_default_pagesize(self): # pragma: no cover - """ """ - warnings.warn("Grid.get_default_pagesize() method is deprecated; " - "please use Grid.get_pagesize() of Grid.page instead", - DeprecationWarning, stacklevel=2) + """ + Returns a function suitable for a sort map callable, with typical logic + built in for sorting a data set comprised of dicts, on the given key. + """ + if foldcase: + keyfunc = lambda v: v[key].lower() + else: + keyfunc = lambda v: v[key] + return lambda q, d: sorted(q, key=keyfunc, reverse=d == 'desc') + def get_default_pagesize(self): if self.default_pagesize: return self.default_pagesize - return self.get_pagesize() + pagesize = self.request.rattail_config.getint('tailbone', + 'grid.default_pagesize', + default=0) + if pagesize: + return pagesize - def load_settings(self, **kwargs): - """ """ - if 'store' in kwargs: - warnings.warn("the 'store' param is deprecated for load_settings(); " - "please use the 'persist' param instead", - DeprecationWarning, stacklevel=2) - kwargs.setdefault('persist', kwargs.pop('store')) + options = self.get_pagesize_options() + return options[0] - persist = kwargs.get('persist', True) + def load_settings(self, store=True): + """ + Load current/effective settings for the grid, from the request query + string and/or session storage. If ``store`` is true, then once + settings have been fully read, they are stored in current session for + next time. Finally, various instance attributes of the grid and its + filters are updated in-place to reflect the settings; this is so code + needn't access the settings dict directly, but the more Pythonic + instance attributes. + """ # initial default settings settings = {} if self.sortable: - if self.sort_defaults: - # nb. as of writing neither Buefy nor Oruga support a - # multi-column *default* sort; so just use first sorter - sortinfo = self.sort_defaults[0] + if self.default_sortkey: settings['sorters.length'] = 1 - settings['sorters.1.key'] = sortinfo.sortkey - settings['sorters.1.dir'] = sortinfo.sortdir + settings['sorters.1.key'] = self.default_sortkey + settings['sorters.1.dir'] = self.default_sortdir else: settings['sorters.length'] = 0 - if self.paginated: - settings['pagesize'] = self.pagesize - settings['page'] = self.page + if self.pageable: + settings['pagesize'] = self.get_default_pagesize() + settings['page'] = self.default_page if self.filterable: for filtr in self.iter_filters(): - defaults = self.filter_defaults.get(filtr.key, {}) - settings[f'filter.{filtr.key}.active'] = defaults.get('active', - filtr.default_active) - settings[f'filter.{filtr.key}.verb'] = defaults.get('verb', - filtr.default_verb) - settings[f'filter.{filtr.key}.value'] = defaults.get('value', - filtr.default_value) + settings['filter.{}.active'.format(filtr.key)] = filtr.default_active + settings['filter.{}.verb'.format(filtr.key)] = filtr.default_verb + settings['filter.{}.value'.format(filtr.key)] = filtr.default_value # If user has default settings on file, apply those first. if self.user_has_defaults(): @@ -877,25 +777,25 @@ class Grid(WuttaGrid): # If request contains instruction to reset to default filters, then we # can skip the rest of the request/session checks. - if self.request.GET.get('reset-view'): + if self.request.GET.get('reset-to-default-filters') == 'true': pass # If request has filter settings, grab those, then grab sort/pager # settings from request or session. - elif self.request_has_settings('filter'): - self.update_filter_settings(settings, src='request') + elif self.filterable and self.request_has_settings('filter'): + self.update_filter_settings(settings, 'request') if self.request_has_settings('sort'): - self.update_sort_settings(settings, src='request') + self.update_sort_settings(settings, 'request') else: - self.update_sort_settings(settings, src='session') + self.update_sort_settings(settings, 'session') self.update_page_settings(settings) # If request has no filter settings but does have sort settings, grab # those, then grab filter settings from session, then grab pager # settings from request or session. elif self.request_has_settings('sort'): - self.update_sort_settings(settings, src='request') - self.update_filter_settings(settings, src='session') + self.update_sort_settings(settings, 'request') + self.update_filter_settings(settings, 'session') self.update_page_settings(settings) # NOTE: These next two are functionally equivalent, but are kept @@ -905,27 +805,27 @@ class Grid(WuttaGrid): # grab those, then grab filter/sort settings from session. elif self.request_has_settings('page'): self.update_page_settings(settings) - self.update_filter_settings(settings, src='session') - self.update_sort_settings(settings, src='session') + self.update_filter_settings(settings, 'session') + self.update_sort_settings(settings, 'session') # If request has no settings, grab all from session. elif self.session_has_settings(): - self.update_filter_settings(settings, src='session') - self.update_sort_settings(settings, src='session') + self.update_filter_settings(settings, 'session') + self.update_sort_settings(settings, 'session') self.update_page_settings(settings) # If no settings were found in request or session, don't store result. else: - persist = False + store = False # Maybe store settings for next time. - if persist: - self.persist_settings(settings, dest='session') + if store: + self.persist_settings(settings, 'session') # If request contained instruction to save current settings as defaults # for the current user, then do that. if self.request.GET.get('save-current-filters-as-defaults') == 'true': - self.persist_settings(settings, dest='defaults') + self.persist_settings(settings, 'defaults') # update ourself to reflect settings if self.filterable: @@ -934,14 +834,13 @@ class Grid(WuttaGrid): filtr.verb = settings['filter.{}.verb'.format(filtr.key)] filtr.value = settings['filter.{}.value'.format(filtr.key)] if self.sortable: - # and self.sort_on_backend: self.active_sorters = [] for i in range(1, settings['sorters.length'] + 1): self.active_sorters.append({ - 'key': settings[f'sorters.{i}.key'], - 'dir': settings[f'sorters.{i}.dir'], + 'field': settings[f'sorters.{i}.key'], + 'order': settings[f'sorters.{i}.dir'], }) - if self.paginated: + if self.pageable: self.pagesize = settings['pagesize'] self.page = settings['page'] @@ -1045,16 +944,23 @@ class Grid(WuttaGrid): merge(f'sorters.{i}.key') merge(f'sorters.{i}.dir') - if self.paginated: + if self.pageable: merge('pagesize', int) merge('page', int) def request_has_settings(self, type_): - """ """ - if super().request_has_settings(type_): - return True + """ + Determine if the current request (GET query string) contains any + filter/sort settings for the grid. + """ + if type_ == 'filter': + for filtr in self.iter_filters(): + if filtr.key in self.request.GET: + return True + if 'filter' in self.request.GET: # user may be applying empty filters + return True - if type_ == 'sort': + elif type_ == 'sort': # TODO: remove this eventually, but some links in the wild # may still include these params, so leave it for now @@ -1062,6 +968,14 @@ class Grid(WuttaGrid): if key in self.request.GET: return True + if 'sort1key' in self.request.GET: + return True + + elif type_ == 'page': + for key in ['pagesize', 'page']: + if key in self.request.GET: + return True + return False def session_has_settings(self): @@ -1077,19 +991,173 @@ class Grid(WuttaGrid): return any([key.startswith(f'{prefix}.filter') for key in self.request.session]) - def persist_settings(self, settings, dest='session'): - """ """ - if dest not in ('defaults', 'session'): - raise ValueError(f"invalid dest identifier: {dest}") + def get_setting(self, source, settings, key, normalize=lambda v: v, default=None): + """ + Get the effective value for a particular setting, preferring ``source`` + but falling back to existing ``settings`` and finally the ``default``. + """ + if source not in ('request', 'session'): + raise ValueError("Invalid source identifier: {}".format(source)) - app = self.request.rattail_config.get_app() - model = app.model + # If source is query string, try that first. + if source == 'request': + value = self.request.GET.get(key) + if value is not None: + try: + value = normalize(value) + except ValueError: + pass + else: + return value - def persist(key, value=lambda k: settings.get(k)): - if dest == 'defaults': + # Or, if source is session, try that first. + else: + value = self.request.session.get('grid.{}.{}'.format(self.key, key)) + if value is not None: + return normalize(value) + + # If source had nothing, try default/existing settings. + value = settings.get(key) + if value is not None: + try: + value = normalize(value) + except ValueError: + pass + else: + return value + + # Okay then, default it is. + return default + + def update_filter_settings(self, settings, source): + """ + Updates a settings dictionary according to filter settings data found + in either the GET query string, or session storage. + + :param settings: Dictionary of initial settings, which is to be updated. + + :param source: String identifying the source to consult for settings + data. Must be one of: ``('request', 'session')``. + """ + if not self.filterable: + return + + for filtr in self.iter_filters(): + prefix = 'filter.{}'.format(filtr.key) + + if source == 'request': + # consider filter active if query string contains a value for it + settings['{}.active'.format(prefix)] = filtr.key in self.request.GET + settings['{}.verb'.format(prefix)] = self.get_setting( + source, settings, '{}.verb'.format(filtr.key), default='') + settings['{}.value'.format(prefix)] = self.get_setting( + source, settings, filtr.key, default='') + + else: # source = session + settings['{}.active'.format(prefix)] = self.get_setting( + source, settings, '{}.active'.format(prefix), + normalize=lambda v: str(v).lower() == 'true', default=False) + settings['{}.verb'.format(prefix)] = self.get_setting( + source, settings, '{}.verb'.format(prefix), default='') + settings['{}.value'.format(prefix)] = self.get_setting( + source, settings, '{}.value'.format(prefix), default='') + + def update_sort_settings(self, settings, source): + """ + Updates a settings dictionary according to sort settings data found in + either the GET query string, or session storage. + + :param settings: Dictionary of initial settings, which is to be updated. + + :param source: String identifying the source to consult for settings + data. Must be one of: ``('request', 'session')``. + """ + if not self.sortable: + return + + if source == 'request': + + # TODO: remove this eventually, but some links in the wild + # may still include these params, so leave it for now + if 'sortkey' in self.request.GET: + settings['sorters.length'] = 1 + settings['sorters.1.key'] = self.get_setting(source, settings, 'sortkey') + settings['sorters.1.dir'] = self.get_setting(source, settings, 'sortdir') + + else: # the future + i = 1 + while True: + skey = f'sort{i}key' + if skey in self.request.GET: + settings[f'sorters.{i}.key'] = self.get_setting(source, settings, skey) + settings[f'sorters.{i}.dir'] = self.get_setting(source, settings, f'sort{i}dir') + else: + break + i += 1 + settings['sorters.length'] = i - 1 + + else: # session + + # TODO: definitely will remove this, but leave it for now + # so it doesn't monkey with current user sessions when + # next upgrade happens. so, remove after all are upgraded + sortkey = self.get_setting(source, settings, 'sortkey') + if sortkey: + settings['sorters.length'] = 1 + settings['sorters.1.key'] = sortkey + settings['sorters.1.dir'] = self.get_setting(source, settings, 'sortdir') + + else: # the future + settings['sorters.length'] = self.get_setting(source, settings, + 'sorters.length', int) + for i in range(1, settings['sorters.length'] + 1): + for key in ('key', 'dir'): + skey = f'sorters.{i}.{key}' + settings[skey] = self.get_setting(source, settings, skey) + + def update_page_settings(self, settings): + """ + Updates a settings dictionary according to pager settings data found in + either the GET query string, or session storage. + + Note that due to how the actual pager functions, the effective settings + will often come from *both* the request and session. This is so that + e.g. the page size will remain constant (coming from the session) while + the user jumps between pages (which only provides the single setting). + + :param settings: Dictionary of initial settings, which is to be updated. + """ + if not self.pageable: + return + + pagesize = self.request.GET.get('pagesize') + if pagesize is not None: + if pagesize.isdigit(): + settings['pagesize'] = int(pagesize) + else: + pagesize = self.request.session.get('grid.{}.pagesize'.format(self.key)) + if pagesize is not None: + settings['pagesize'] = pagesize + + page = self.request.GET.get('page') + if page is not None: + if page.isdigit(): + settings['page'] = int(page) + else: + page = self.request.session.get('grid.{}.page'.format(self.key)) + if page is not None: + settings['page'] = int(page) + + def persist_settings(self, settings, to='session'): + """ + Persist the given settings in some way, as defined by ``func``. + """ + def persist(key, value=lambda k: settings[k]): + if to == 'defaults': skey = 'tailbone.{}.grid.{}.{}'.format(self.request.user.uuid, self.key, key) + app = self.request.rattail_config.get_app() app.save_setting(Session(), skey, value(key)) - else: # dest == session + else: # to == session skey = 'grid.{}.{}'.format(self.key, key) self.request.session[skey] = value(key) @@ -1101,11 +1169,10 @@ class Grid(WuttaGrid): if self.sortable: - # first must clear all sort settings from dest. this is - # because number of sort settings will vary, so we delete - # all and then write all - - if dest == 'defaults': + # first clear existing settings for *sorting* only + # nb. this is because number of sort settings will vary + if to == 'defaults': + model = self.request.rattail_config.get_model() prefix = f'tailbone.{self.request.user.uuid}.grid.{self.key}' query = Session.query(model.Setting)\ .filter(sa.or_( @@ -1119,9 +1186,7 @@ class Grid(WuttaGrid): for setting in query.all(): Session.delete(setting) Session.flush() - else: # session - # remove sort settings from user session prefix = f'grid.{self.key}' for key in list(self.request.session): if key.startswith(f'{prefix}.sorters.'): @@ -1133,14 +1198,12 @@ class Grid(WuttaGrid): self.request.session.pop(f'{prefix}.sortkey', None) self.request.session.pop(f'{prefix}.sortdir', None) - # now save sort settings to dest - if 'sorters.length' in settings: - persist('sorters.length') - for i in range(1, settings['sorters.length'] + 1): - persist(f'sorters.{i}.key') - persist(f'sorters.{i}.dir') + persist('sorters.length') + for i in range(1, settings['sorters.length'] + 1): + persist(f'sorters.{i}.key') + persist(f'sorters.{i}.dir') - if self.paginated: + if self.pageable: persist('pagesize') persist('page') @@ -1164,27 +1227,110 @@ class Grid(WuttaGrid): return data + def sort_data(self, data): + """ + Sort the given query according to current settings, and return the result. + """ + # bail if no sort settings + if not self.active_sorters: + return data + + # TODO: is there a better way to check for SA sorting? + if self.model_class: + + # collect actual column sorters for order_by clause + sorters = [] + for sorter in self.active_sorters: + sortkey = sorter['field'] + sortfunc = self.sorters.get(sortkey) + if not sortfunc: + log.warning("unknown sorter: %s", sorter) + continue + + # join appropriate model if needed + if sortkey in self.joiners and sortkey not in self.joined: + data = self.joiners[sortkey](data) + self.joined.add(sortkey) + + # add column/dir to collection + sortdir = sorter['order'] + sorters.append(getattr(sortfunc._column, sortdir)()) + + # apply sorting to query + if sorters: + data = data.order_by(*sorters) + + return data + + else: + # not a SQLAlchemy grid, custom sorter + + assert len(self.active_sorters) < 2 + + sortkey = self.active_sorters[0]['field'] + sortdir = self.active_sorters[0]['order'] or 'asc' + + # Cannot sort unless we have a sort function. + sortfunc = self.sorters.get(sortkey) + if not sortfunc: + return data + + # apply joins needed for this sorter + if sortkey in self.joiners and sortkey not in self.joined: + data = self.joiners[sortkey](data) + self.joined.add(sortkey) + + return sortfunc(data, sortdir) + + def paginate_data(self, data): + """ + Paginate the given data set according to current settings, and return + the result. + """ + # we of course assume our current page is correct, at first + pager = self.make_pager(data) + + # if pager has detected that our current page is outside the valid + # range, we must re-orient ourself around the "new" (valid) page + if pager.page != self.page: + self.page = pager.page + self.request.session['grid.{}.page'.format(self.key)] = self.page + pager = self.make_pager(data) + + return pager + + def make_pager(self, data): + + # TODO: this seems hacky..normally we expect `data` to be a + # query of course, but in some cases it may be a list instead. + # if so then we can't use ORM pager + if isinstance(data, list): + import paginate + return paginate.Page(data, + items_per_page=self.pagesize, + page=self.page) + + return SqlalchemyOrmPage(data, + items_per_page=self.pagesize, + page=self.page, + url_maker=URLMaker(self.request)) + def make_visible_data(self): - """ """ - warnings.warn("grid.make_visible_data() method is deprecated; " - "please use grid.get_visible_data() instead", - DeprecationWarning, stacklevel=2) - return self.get_visible_data() - - def render_vue_tag(self, master=None, **kwargs): - """ """ - kwargs.setdefault('ref', 'grid') - kwargs.setdefault(':csrftoken', 'csrftoken') - - if (master and master.deletable and master.has_perm('delete') - and master.delete_confirm == 'simple'): - kwargs.setdefault('@deleteActionClicked', 'deleteObject') - - return HTML.tag(self.vue_tagname, **kwargs) - - def render_vue_template(self, template='/grids/complete.mako', **context): - """ """ - return self.render_complete(template=template, **context) + """ + Apply various settings to the raw data set, to produce a final data + set. This will page / sort / filter as necessary, according to the + grid's defaults and the current request etc. + """ + self.joined = set() + data = self.data + if self.filterable: + data = self.filter_data(data) + if self.sortable: + data = self.sort_data(data) + if self.pageable: + self.pager = self.paginate_data(data) + data = self.pager + return data def render_complete(self, template='/grids/complete.mako', **kwargs): """ @@ -1192,7 +1338,7 @@ class Grid(WuttaGrid): includes the context menu items and grid tools. """ if 'grid_columns' not in kwargs: - kwargs['grid_columns'] = self.get_vue_columns() + kwargs['grid_columns'] = self.get_table_columns() if 'grid_data' not in kwargs: kwargs['grid_data'] = self.get_table_data() @@ -1211,11 +1357,9 @@ class Grid(WuttaGrid): context['request'] = self.request context.setdefault('allow_save_defaults', True) context.setdefault('view_click_handler', self.get_view_click_handler()) - html = render(template, context) - return HTML.literal(html) + return render(template, context) def render_buefy(self, **kwargs): - """ """ warnings.warn("Grid.render_buefy() is deprecated; " "please use Grid.render_complete() instead", DeprecationWarning, stacklevel=2) @@ -1223,7 +1367,6 @@ class Grid(WuttaGrid): def render_table_element(self, template='/grids/b-table.mako', data_prop='gridData', empty_labels=False, - literal=False, **kwargs): """ This is intended for ad-hoc "small" grids with static data. Renders @@ -1235,24 +1378,30 @@ class Grid(WuttaGrid): context['data_prop'] = data_prop context['empty_labels'] = empty_labels if 'grid_columns' not in context: - context['grid_columns'] = self.get_vue_columns() + context['grid_columns'] = self.get_table_columns() context.setdefault('paginated', False) if context['paginated']: context.setdefault('per_page', 20) context['view_click_handler'] = self.get_view_click_handler() - result = render(template, context) - if literal: - result = HTML.literal(result) - return result + return render(template, context) def get_view_click_handler(self): - """ """ + # locate the 'view' action # TODO: this should be easier, and/or moved elsewhere? view = None - for action in self.actions: + for action in self.main_actions: if action.key == 'view': - return getattr(action, 'click_handler', None) + view = action + break + if not view: + for action in self.more_actions: + if action.key == 'view': + view = action + break + + if view: + return view.click_handler def set_filters_sequence(self, filters, only=False): """ @@ -1326,21 +1475,48 @@ class Grid(WuttaGrid): return data - def render_actions(self, row, i): # pragma: no cover - """ """ - warnings.warn("grid.render_actions() is deprecated!", - DeprecationWarning, stacklevel=2) + def render_filters(self, template='/grids/filters.mako', **kwargs): + """ + Render the filters to a Unicode string, using the specified template. + Additional kwargs are passed along as context to the template. + """ + # Provide default data to filters form, so renderer can do some of the + # work for us. + data = {} + for filtr in self.iter_active_filters(): + data['{}.active'.format(filtr.key)] = filtr.active + data['{}.verb'.format(filtr.key)] = filtr.verb + data[filtr.key] = filtr.value - actions = [self.render_action(a, row, i) - for a in self.actions] - actions = [a for a in actions if a] - return HTML.literal('').join(actions) + form = gridfilters.GridFiltersForm(self.filters, + request=self.request, + defaults=data) - def render_action(self, action, row, i): # pragma: no cover - """ """ - warnings.warn("grid.render_action() is deprecated!", - DeprecationWarning, stacklevel=2) + kwargs['request'] = self.request + kwargs['grid'] = self + kwargs['form'] = form + return render(template, kwargs) + def render_actions(self, row, i): + """ + Returns the rendered contents of the 'actions' column for a given row. + """ + main_actions = [self.render_action(a, row, i) + for a in self.main_actions] + main_actions = [a for a in main_actions if a] + more_actions = [self.render_action(a, row, i) + for a in self.more_actions] + more_actions = [a for a in more_actions if a] + if more_actions: + icon = HTML.tag('span', class_='ui-icon ui-icon-carat-1-e') + link = tags.link_to("More" + icon, '#', class_='more') + main_actions.append(HTML.literal('  ') + link + HTML.tag('div', class_='more', c=more_actions)) + return HTML.literal('').join(main_actions) + + def render_action(self, action, row, i): + """ + Renders an action menu item (link) for the given row. + """ url = action.get_url(row, i) if url: kwargs = {'class_': action.key, 'target': action.target} @@ -1374,6 +1550,18 @@ class Grid(WuttaGrid): return tags.checkbox('checkbox-{}-{}'.format(self.key, self.get_row_key(item)), checked=self.checked(item)) + def get_pagesize_options(self): + + # use values from config, if defined + options = self.request.rattail_config.getlist('tailbone', 'grid.pagesize_options') + if options: + options = [int(size) for size in options + if size.isdigit()] + if options: + return options + + return [5, 10, 20, 50, 100, 200] + def has_static_data(self): """ Should return ``True`` if the grid data can be considered "static" @@ -1385,21 +1573,20 @@ class Grid(WuttaGrid): return True return False - def get_vue_columns(self): - """ """ - columns = super().get_vue_columns() - - for column in columns: - column['visible'] = column['field'] not in self.invisible - - return columns - def get_table_columns(self): - """ """ - warnings.warn("grid.get_table_columns() method is deprecated; " - "please use grid.get_vue_columns() instead", - DeprecationWarning, stacklevel=2) - return self.get_vue_columns() + """ + Return a list of dicts representing all grid columns. Meant + for use with the client-side JS table. + """ + columns = [] + for name in self.columns: + columns.append({ + 'field': name, + 'label': self.get_label(name), + 'sortable': self.sortable and name in self.sorters, + 'visible': name not in self.invisible, + }) + return columns def get_uuid_for_row(self, rowobj): @@ -1411,25 +1598,13 @@ class Grid(WuttaGrid): if hasattr(rowobj, 'uuid'): return rowobj.uuid - def get_vue_context(self): - """ """ - return self.get_table_data() - - def get_vue_data(self): - """ """ - table_data = self.get_table_data() - return table_data['data'] - def get_table_data(self): """ Returns a list of data rows for the grid, for use with client-side JS table. """ - if hasattr(self, '_table_data'): - return self._table_data - # filter / sort / paginate to get "visible" data - raw_data = self.get_visible_data() + raw_data = self.make_visible_data() data = [] status_map = {} checked = [] @@ -1470,22 +1645,10 @@ class Grid(WuttaGrid): # leverage configured rendering logic where applicable; # otherwise use "raw" data value as string - value = self.obtain_value(rowobj, name) if self.renderers and name in self.renderers: - renderer = self.renderers[name] - - # TODO: legacy renderer callables require 2 args, - # but wuttaweb callables require 3 args - sig = inspect.signature(renderer) - required = [param for param in sig.parameters.values() - if param.default == param.empty] - - if len(required) == 2: - # TODO: legacy renderer - value = renderer(rowobj, name) - else: # the future - value = renderer(rowobj, name, value) - + value = self.renderers[name](rowobj, name) + else: + value = self.obtain_value(rowobj, name) if value is None: value = "" @@ -1518,8 +1681,6 @@ class Grid(WuttaGrid): results = { 'data': data, - 'row_classes': status_map, - # TODO: deprecate / remove this 'row_status_map': status_map, } @@ -1527,15 +1688,11 @@ class Grid(WuttaGrid): results['checked_rows'] = checked # TODO: this seems a bit hacky, but is required for now to # initialize things on the client side... - var = '{}CurrentData'.format(self.vue_component) + var = '{}CurrentData'.format(self.component_studly) results['checked_rows_code'] = '[{}]'.format( ', '.join(['{}[{}]'.format(var, i) for i in checked])) - if self.paginated and self.paginate_on_backend: - results['pager_stats'] = self.get_vue_pager_stats() - - # TODO: is this actually needed now that we have pager_stats? - if self.paginated and self.pager is not None: + if self.pageable and self.pager is not None: results['total_items'] = self.pager.item_count results['per_page'] = self.pager.items_per_page results['page'] = self.pager.page @@ -1545,38 +1702,41 @@ class Grid(WuttaGrid): else: results['total_items'] = count - self._table_data = results - return self._table_data - - # TODO: remove this when we use upstream GridAction - def add_action(self, key, **kwargs): - """ """ - self.actions.append(GridAction(self.request, key, **kwargs)) + return results def set_action_urls(self, row, rowobj, i): """ Pre-generate all action URLs for the given data row. Meant for use with client-side table, since we can't generate URLs from JS. """ - for action in self.actions: + for action in (self.main_actions + self.more_actions): url = action.get_url(rowobj, i) row['_action_url_{}'.format(action.key)] = url + def is_linked(self, name): + """ + Should return ``True`` if the given column name is configured to be + "linked" (i.e. table cell should contain a link to "view object"), + otherwise ``False``. + """ + if self.linked_columns: + if name in self.linked_columns: + return True + return False -class GridAction(WuttaGridAction): + +class GridAction(object): """ - Represents a "row action" hyperlink within a grid context. + Represents an action available to a grid. This is used to construct the + 'actions' column when rendering the grid. - This is a subclass of - :class:`wuttaweb:wuttaweb.grids.base.GridAction`. + :param key: Key for the action (e.g. ``'edit'``), unique within + the grid. - .. warning:: + :param label: Label to be displayed for the action. If not set, + will be a capitalized version of ``key``. - This class remains for now, to retain compatibility with - existing code. But at some point the WuttaWeb class will - supersede this one entirely. - - :param target: HTML "target" attribute for the ```` tag. + :param icon: Icon name for the action. :param click_handler: Optional JS click handler for the action. This value will be rendered as-is within the final grid @@ -1588,23 +1748,41 @@ class GridAction(WuttaGridAction): * ``$emit('do-something', props.row)`` """ - def __init__( - self, - request, - key, - target=None, - click_handler=None, - **kwargs, - ): - # TODO: previously url default was '#' - but i don't think we - # need that anymore? guess we'll see.. - #kwargs.setdefault('url', '#') - - super().__init__(request, key, **kwargs) - + def __init__(self, key, label=None, url='#', icon=None, target=None, + link_class=None, click_handler=None): + self.key = key + self.label = label or prettify(key) + self.icon = icon + self.url = url self.target = target + self.link_class = link_class self.click_handler = click_handler + def get_url(self, row, i): + """ + Returns an action URL for the given row. + """ + if callable(self.url): + return self.url(row, i) + return self.url + + def render_icon(self): + """ + Render the HTML snippet for the action link icon. + """ + return HTML.tag('i', class_='fas fa-{}'.format(self.icon)) + + def render_label(self): + """ + Render the label "text" within the actions column of a grid + row. Most actions have a static label that never varies, but + you can override this to add e.g. HTML content. Note that the + return value will be treated / rendered as HTML whether or not + it contains any, so perhaps be careful that it is trusted + content. + """ + return self.label + class URLMaker(object): """ diff --git a/tailbone/grids/filters.py b/tailbone/grids/filters.py index 7e52bb8d..3b198614 100644 --- a/tailbone/grids/filters.py +++ b/tailbone/grids/filters.py @@ -26,7 +26,6 @@ Grid Filters import re import datetime -import decimal import logging from collections import OrderedDict @@ -648,22 +647,12 @@ class AlchemyNumericFilter(AlchemyGridFilter): # first just make sure it's somewhat numeric try: - self.parse_decimal(value) - except decimal.InvalidOperation: + float(value) + except ValueError: return True return bool(value and len(str(value)) > 8) - def parse_decimal(self, value): - if value: - value = value.replace(',', '') - return decimal.Decimal(value) - - def encode_value(self, value): - if value: - value = str(self.parse_decimal(value)) - return super().encode_value(value) - def filter_equal(self, query, value): if self.value_invalid(value): return query diff --git a/tailbone/handler.py b/tailbone/handler.py index 00f41bc9..db95bc71 100644 --- a/tailbone/handler.py +++ b/tailbone/handler.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2024 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,8 +24,9 @@ Tailbone Handler """ -import warnings +from __future__ import unicode_literals, absolute_import +import six from mako.lookup import TemplateLookup from rattail.app import GenericHandler @@ -40,7 +41,7 @@ class TailboneHandler(GenericHandler): """ def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) + super(TailboneHandler, self).__init__(*args, **kwargs) # TODO: make templates dir configurable? templates = [resource_path('rattail:templates/web')] @@ -48,14 +49,11 @@ class TailboneHandler(GenericHandler): def get_menu_handler(self, **kwargs): """ - DEPRECATED; use - :meth:`wuttaweb.handler.WebHandler.get_menu_handler()` - instead. - """ - warnings.warn("TailboneHandler.get_menu_handler() is deprecated; " - "please use WebHandler.get_menu_handler() instead", - DeprecationWarning, stacklevel=2) + Get the configured "menu" handler. + :returns: The :class:`~tailbone.menus.MenuHandler` instance + for the app. + """ if not hasattr(self, 'menu_handler'): spec = self.config.get('tailbone.menus', 'handler', default='tailbone.menus:MenuHandler') @@ -69,7 +67,7 @@ class TailboneHandler(GenericHandler): Returns an iterator over all registered Tailbone providers. """ providers = get_all_providers(self.config) - return providers.values() + return six.itervalues(providers) def write_model_view(self, data, path, **kwargs): """ diff --git a/tailbone/helpers.py b/tailbone/helpers.py index 50b38c30..d4065cc5 100644 --- a/tailbone/helpers.py +++ b/tailbone/helpers.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2024 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,9 +24,6 @@ Template Context Helpers """ -# start off with all from wuttaweb -from wuttaweb.helpers import * - import os import datetime from decimal import Decimal @@ -36,9 +33,14 @@ from rattail.time import localtime, make_utc from rattail.util import pretty_quantity, pretty_hours, hours_as_decimal from rattail.db.util import maxlen -from tailbone.util import (pretty_datetime, raw_datetime, +from webhelpers2.html import * +from webhelpers2.html.tags import * + +from tailbone.util import (csrf_token, get_csrf_token, + pretty_datetime, raw_datetime, render_markdown, - route_exists) + route_exists, + get_liburl) def pretty_date(date): diff --git a/tailbone/menus.py b/tailbone/menus.py index 09d6f3f0..50dd3f4a 100644 --- a/tailbone/menus.py +++ b/tailbone/menus.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2024 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,48 +24,37 @@ App Menus """ +import re import logging import warnings +from rattail.app import GenericHandler from rattail.util import prettify, simple_error from webhelpers2.html import tags, HTML -from wuttaweb.menus import MenuHandler as WuttaMenuHandler - from tailbone.db import Session log = logging.getLogger(__name__) -class TailboneMenuHandler(WuttaMenuHandler): +class MenuHandler(GenericHandler): """ Base class and default implementation for menu handler. """ - ############################## - # internal methods - ############################## + def make_raw_menus(self, request, **kwargs): + """ + Generate a full set of "raw" menus for the app. - def _is_allowed(self, request, item): - """ - TODO: must override this until wuttaweb has proper user auth checks - """ - perm = item.get('perm') - if perm: - return request.has_perm(perm) - return True - - def _make_raw_menus(self, request, **kwargs): - """ - We are overriding this to allow for making dynamic menus from - config/settings. Which may or may not be a good idea.. + The "raw" menus are basically just a set of dicts to represent + the final menus. """ # first try to make menus from config, but this is highly # susceptible to failure, so try to warn user of problems try: - menus = self._make_menus_from_config(request) + menus = self.make_menus_from_config(request) if menus: return menus except Exception as error: @@ -82,9 +71,9 @@ class TailboneMenuHandler(WuttaMenuHandler): request.session.flash(msg, 'warning') # okay, no config, so menus will be built from code - return self.make_menus(request, **kwargs) + return self.make_menus(request) - def _make_menus_from_config(self, request, **kwargs): + def make_menus_from_config(self, request, **kwargs): """ Try to build a complete menu set from config/settings. @@ -96,7 +85,7 @@ class TailboneMenuHandler(WuttaMenuHandler): if not main_keys: return - model = self.app.model + model = self.model menus = [] # menu definition can come either from config file or db @@ -112,15 +101,16 @@ class TailboneMenuHandler(WuttaMenuHandler): query=query, key='name', normalizer=lambda s: s.value) for key in main_keys: - menus.append(self._make_single_menu_from_settings(request, key, settings)) + menus.append(self.make_single_menu_from_settings(request, key, + settings)) else: # read from config file only for key in main_keys: - menus.append(self._make_single_menu_from_config(request, key)) + menus.append(self.make_single_menu_from_config(request, key)) return menus - def _make_single_menu_from_config(self, request, key, **kwargs): + def make_single_menu_from_config(self, request, key, **kwargs): """ Makes a single top-level menu dict from config file. Note that this will read from config file(s) *only* and avoids @@ -188,7 +178,7 @@ class TailboneMenuHandler(WuttaMenuHandler): return menu - def _make_single_menu_from_settings(self, request, key, settings, **kwargs): + def make_single_menu_from_settings(self, request, key, settings, **kwargs): """ Makes a single top-level menu dict from DB settings. """ @@ -247,10 +237,6 @@ class TailboneMenuHandler(WuttaMenuHandler): return menu - ############################## - # menu defaults - ############################## - def make_menus(self, request, **kwargs): """ Make the full set of menus for the app. @@ -281,9 +267,8 @@ class TailboneMenuHandler(WuttaMenuHandler): """ Make a set of menus for all registered system integrations. """ - tb = self.app.get_tailbone_handler() menus = [] - for provider in tb.iter_providers(): + for provider in self.tb.iter_providers(): menu = provider.make_integration_menu(request) if menu: menus.append(menu) @@ -394,11 +379,6 @@ class TailboneMenuHandler(WuttaMenuHandler): 'route': 'products', 'perm': 'products.list', }, - { - 'title': "Product Costs", - 'route': 'product_costs', - 'perm': 'product_costs.list', - }, { 'title': "Departments", 'route': 'departments', @@ -456,11 +436,6 @@ class TailboneMenuHandler(WuttaMenuHandler): 'route': 'vendors', 'perm': 'vendors.list', }, - { - 'title': "Product Costs", - 'route': 'product_costs', - 'perm': 'product_costs.list', - }, {'type': 'sep'}, { 'title': "Ordering", @@ -713,7 +688,7 @@ class TailboneMenuHandler(WuttaMenuHandler): }, {'type': 'sep'}, { - 'title': "App Info", + 'title': "App Details", 'route': 'appinfo', 'perm': 'appinfo.list', }, @@ -748,25 +723,182 @@ class TailboneMenuHandler(WuttaMenuHandler): } -class MenuHandler(TailboneMenuHandler): - - def __init__(self, *args, **kwargs): - warnings.warn("tailbone.menus.MenuHandler is deprecated; " - "please use tailbone.menus.TailboneMenuHandler instead", - DeprecationWarning, stacklevel=2) - super().__init__(*args, **kwargs) - - -class NullMenuHandler(WuttaMenuHandler): +def make_simple_menus(request): """ - Null menu handler which uses an empty menu set. - - .. note: - - This class shouldn't even exist, but for the moment, it is - useful to configure non-traditional (e.g. API) web apps to use - this, in order to avoid most of the overhead. + Build the main menu list for the app. """ + app = request.rattail_config.get_app() + tailbone_handler = app.get_tailbone_handler() + menu_handler = tailbone_handler.get_menu_handler() - def make_menus(self, request, **kwargs): - return [] + raw_menus = menu_handler.make_raw_menus(request) + + # now we have "simple" (raw) menus definition, but must refine + # that somewhat to produce our final menus + mark_allowed(request, raw_menus) + final_menus = [] + for topitem in raw_menus: + + if topitem['allowed']: + + if topitem.get('type') == 'link': + final_menus.append(make_menu_entry(request, topitem)) + + else: # assuming 'menu' type + + menu_items = [] + for item in topitem['items']: + if not item['allowed']: + continue + + # nested submenu + if item.get('type') == 'menu': + submenu_items = [] + for subitem in item['items']: + if subitem['allowed']: + submenu_items.append(make_menu_entry(request, subitem)) + menu_items.append({ + 'type': 'submenu', + 'title': item['title'], + 'items': submenu_items, + 'is_menu': True, + 'is_sep': False, + }) + + elif item.get('type') == 'sep': + # we only want to add a sep, *if* we already have some + # menu items (i.e. there is something to separate) + # *and* the last menu item is not a sep (avoid doubles) + if menu_items and not menu_items[-1]['is_sep']: + menu_items.append(make_menu_entry(request, item)) + + else: # standard menu item + menu_items.append(make_menu_entry(request, item)) + + # remove final separator if present + if menu_items and menu_items[-1]['is_sep']: + menu_items.pop() + + # only add if we wound up with something + assert menu_items + if menu_items: + group = { + 'type': 'menu', + 'key': topitem.get('key'), + 'title': topitem['title'], + 'items': menu_items, + 'is_menu': True, + 'is_link': False, + } + + # topitem w/ no key likely means it did not come + # from config but rather explicit definition in + # code. so we are free to "invent" a (safe) key + # for it, since that is only for editing config + if not group['key']: + group['key'] = make_menu_key(request.rattail_config, + topitem['title']) + + final_menus.append(group) + + return final_menus + + +def make_menu_key(config, value): + """ + Generate a normalized menu key for the given value. + """ + return re.sub(r'\W', '', value.lower()) + + +def make_menu_entry(request, item): + """ + Convert a simple menu entry dict, into a proper menu-related object, for + use in constructing final menu. + """ + # separator + if item.get('type') == 'sep': + return { + 'type': 'sep', + 'is_menu': False, + 'is_sep': True, + } + + # standard menu item + entry = { + 'type': 'item', + 'title': item['title'], + 'perm': item.get('perm'), + 'target': item.get('target'), + 'is_link': True, + 'is_menu': False, + 'is_sep': False, + } + if item.get('route'): + entry['route'] = item['route'] + try: + entry['url'] = request.route_url(entry['route']) + except KeyError: # happens if no such route + log.warning("invalid route name for menu entry: %s", entry) + entry['url'] = entry['route'] + entry['key'] = entry['route'] + else: + if item.get('url'): + entry['url'] = item['url'] + entry['key'] = make_menu_key(request.rattail_config, entry['title']) + return entry + + +def is_allowed(request, item): + """ + Logic to determine if a given menu item is "allowed" for current user. + """ + perm = item.get('perm') + if perm: + return request.has_perm(perm) + return True + + +def mark_allowed(request, menus): + """ + Traverse the menu set, and mark each item as "allowed" (or not) based on + current user permissions. + """ + for topitem in menus: + + if topitem.get('type', 'menu') == 'menu': + topitem['allowed'] = False + + for item in topitem['items']: + + if item.get('type') == 'menu': + for subitem in item['items']: + subitem['allowed'] = is_allowed(request, subitem) + + item['allowed'] = False + for subitem in item['items']: + if subitem['allowed'] and subitem.get('type') != 'sep': + item['allowed'] = True + break + + else: + item['allowed'] = is_allowed(request, item) + + for item in topitem['items']: + if item['allowed'] and item.get('type') != 'sep': + topitem['allowed'] = True + break + + +def make_admin_menu(request, **kwargs): + """ + Generate a typical Admin menu + """ + warnings.warn("make_admin_menu() function is deprecated; please use " + "MenuHandler.make_admin_menu() instead", + DeprecationWarning, stacklevel=2) + + app = request.rattail_config.get_app() + tailbone_handler = app.get_tailbone_handler() + menu_handler = tailbone_handler.get_menu_handler() + return menu_handler.make_admin_menu(request, **kwargs) diff --git a/tailbone/scaffolds.py b/tailbone/scaffolds.py new file mode 100644 index 00000000..10bf9640 --- /dev/null +++ b/tailbone/scaffolds.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8 -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2017 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail 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. +# +# Rattail 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 +# Rattail. If not, see . +# +################################################################################ +""" +Pyramid scaffold templates +""" + +from __future__ import unicode_literals, absolute_import + +from rattail.files import resource_path +from rattail.util import prettify + +from pyramid.scaffolds import PyramidTemplate + + +class RattailTemplate(PyramidTemplate): + _template_dir = resource_path('rattail:data/project') + summary = "Starter project based on Rattail / Tailbone" + + def pre(self, command, output_dir, vars): + """ + Adds some more variables to the template context. + """ + vars['project_title'] = prettify(vars['project']) + vars['package_title'] = vars['package'].capitalize() + return super(RattailTemplate, self).pre(command, output_dir, vars) diff --git a/tailbone/static/__init__.py b/tailbone/static/__init__.py index 57700b80..2ad5161a 100644 --- a/tailbone/static/__init__.py +++ b/tailbone/static/__init__.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2024 Lance Edgar +# Copyright © 2010-2017 Lance Edgar # # This file is part of Rattail. # @@ -24,8 +24,9 @@ Static Assets """ +from __future__ import unicode_literals, absolute_import + def includeme(config): - config.include('wuttaweb.static') config.add_static_view('tailbone', 'tailbone:static') config.add_static_view('deform', 'deform:static') diff --git a/tailbone/subscribers.py b/tailbone/subscribers.py index 268d4818..42d3cab7 100644 --- a/tailbone/subscribers.py +++ b/tailbone/subscribers.py @@ -24,6 +24,8 @@ Event Subscribers """ +import six +import json import datetime import logging import warnings @@ -36,158 +38,191 @@ import deform from pyramid import threadlocal from webhelpers2.html import tags -from wuttaweb import subscribers as base - import tailbone from tailbone import helpers from tailbone.db import Session from tailbone.config import csrf_header_name, should_expose_websockets -from tailbone.util import get_available_themes, get_global_search_options +from tailbone.menus import make_simple_menus +from tailbone.util import (get_available_themes, get_global_search_options, + should_use_oruga) log = logging.getLogger(__name__) -def new_request(event, session=None): +def new_request(event): """ - Event hook called when processing a new request. + Identify the current user, and cache their current permissions. Also adds + the ``rattail_config`` attribute to the request. - This first invokes the upstream hooks: + A global Rattail ``config`` should already be present within the Pyramid + application registry's settings, which would normally be accessed via:: + + request.registry.settings['rattail_config'] - * :func:`wuttaweb:wuttaweb.subscribers.new_request()` - * :func:`wuttaweb:wuttaweb.subscribers.new_request_set_user()` + This function merely "promotes" that config object so that it is more + directly accessible, a la:: - It then adds more things to the request object; among them: + request.rattail_config - .. attribute:: request.rattail_config + .. note:: + This of course assumes that a Rattail ``config`` object *has* in fact + already been placed in the application registry settings. If this is + not the case, this function will do nothing. - Reference to the app :term:`config object`. Note that this - will be the same as :attr:`wuttaweb:request.wutta_config`. + Also, attach some goodies to the request object: - .. method:: request.register_component(tagname, classname) + * The currently logged-in user instance (if any), as ``user``. - Function to register a Vue component for use with the app. + * ``is_admin`` flag indicating whether user has the Administrator role. - This can be called from wherever a component is defined, and - then in the base template all registered components will be - properly loaded. + * ``is_root`` flag indicating whether user is currently elevated to root. + + * A shortcut method for permission checking, as ``has_perm()``. """ + log.debug("new request: %s", event) request = event.request + rattail_config = request.registry.settings.get('rattail_config') + # TODO: why would this ever be null? + if rattail_config: + request.rattail_config = rattail_config + else: + log.error("registry has no rattail_config ?!") - # invoke main upstream logic - # nb. this sets request.wutta_config - base.new_request(event) + def user(request): + user = None + uuid = request.authenticated_userid + if uuid: + model = request.rattail_config.get_model() + user = Session.get(model.User, uuid) + if user: + Session().set_continuum_user(user) + return user - config = request.wutta_config - app = config.get_app() - auth = app.get_auth_handler() - session = session or Session() + request.set_property(user, reify=True) - # compatibility - rattail_config = config - request.rattail_config = rattail_config + # nb. only add oruga check for "classic" web app + classic = rattail_config.parse_bool(request.registry.settings.get('tailbone.classic')) + if classic: - def user_getter(request, db_session=None): - user = base.default_user_getter(request, db_session=db_session) - if user: - # nb. we also assign continuum user to session - session = db_session or Session() - session.set_continuum_user(user) - return user + def use_oruga(request): + return should_use_oruga(request) - # invoke upstream hook to set user - base.new_request_set_user(event, user_getter=user_getter, db_session=session) + request.set_property(use_oruga, reify=True) # assign client IP address to the session, for sake of versioning - if hasattr(request, 'client_addr'): - session.continuum_remote_addr = request.client_addr + Session().continuum_remote_addr = request.client_addr - # request.register_component() - def register_component(tagname, classname): - """ - Register a Vue 3 component, so the base template knows to - declare it for use within the app (page). - """ - if not hasattr(request, '_tailbone_registered_components'): - request._tailbone_registered_components = OrderedDict() + request.is_admin = bool(request.user) and request.user.is_admin() + request.is_root = request.is_admin and request.session.get('is_root', False) - if tagname in request._tailbone_registered_components: - log.warning("component with tagname '%s' already registered " - "with class '%s' but we are replacing that with " - "class '%s'", - tagname, - request._tailbone_registered_components[tagname], - classname) + # TODO: why would this ever be null? + if rattail_config: - request._tailbone_registered_components[tagname] = classname - request.register_component = register_component + app = rattail_config.get_app() + auth = app.get_auth_handler() + request.tailbone_cached_permissions = auth.get_permissions( + Session(), request.user) + + def has_perm(name): + if name in request.tailbone_cached_permissions: + return True + return request.is_root + request.has_perm = has_perm + + def has_any_perm(*names): + for name in names: + if has_perm(name): + return True + return False + request.has_any_perm = has_any_perm + + def register_component(tagname, classname): + """ + Register a Vue 3 component, so the base template knows to + declare it for use within the app (page). + """ + if not hasattr(request, '_tailbone_registered_components'): + request._tailbone_registered_components = OrderedDict() + + if tagname in request._tailbone_registered_components: + log.warning("component with tagname '%s' already registered " + "with class '%s' but we are replacing that with " + "class '%s'", + tagname, + request._tailbone_registered_components[tagname], + classname) + + request._tailbone_registered_components[tagname] = classname + request.register_component = register_component def before_render(event): """ Adds goodies to the global template renderer context. """ - # log.debug("before_render: %s", event) - - # invoke upstream logic - base.before_render(event) + log.debug("before_render: %s", event) request = event.get('request') or threadlocal.get_current_request() - config = request.wutta_config - app = config.get_app() + rattail_config = request.rattail_config renderer_globals = event - - # overrides + renderer_globals['rattail_app'] = request.rattail_config.get_app() + renderer_globals['app_title'] = request.rattail_config.app_title() renderer_globals['h'] = helpers - - # misc. + renderer_globals['url'] = request.route_url + renderer_globals['rattail'] = rattail + renderer_globals['tailbone'] = tailbone + renderer_globals['model'] = request.rattail_config.get_model() + renderer_globals['enum'] = request.rattail_config.get_enum() + renderer_globals['six'] = six + renderer_globals['json'] = json renderer_globals['datetime'] = datetime renderer_globals['colander'] = colander renderer_globals['deform'] = deform - renderer_globals['csrf_header_name'] = csrf_header_name(config) - - # TODO: deprecate / remove these - renderer_globals['rattail_app'] = app - renderer_globals['app_title'] = app.get_title() - renderer_globals['app_version'] = app.get_version() - renderer_globals['rattail'] = rattail - renderer_globals['tailbone'] = tailbone - renderer_globals['model'] = app.model - renderer_globals['enum'] = app.enum + renderer_globals['csrf_header_name'] = csrf_header_name(request.rattail_config) # theme - we only want do this for classic web app, *not* API # TODO: so, clearly we need a better way to distinguish the two if 'tailbone.theme' in request.registry.settings: + renderer_globals['b'] = 'o' if request.use_oruga else 'b' # for buefy renderer_globals['theme'] = request.registry.settings['tailbone.theme'] # note, this is just a global flag; user still needs permission to see picker - expose_picker = config.get_bool('tailbone.themes.expose_picker', - default=False) + expose_picker = request.rattail_config.getbool('tailbone', 'themes.expose_picker', + default=False) renderer_globals['expose_theme_picker'] = expose_picker if expose_picker: # TODO: should remove 'falafel' option altogether - available = get_available_themes(config) + available = get_available_themes(request.rattail_config) options = [tags.Option(theme, value=theme) for theme in available] renderer_globals['theme_picker_options'] = options + # heck while we're assuming the classic web app here... + # (we don't want this to happen for the API either!) + # TODO: just..awful *shrug* + # note that we assume "simple" menus nowadays + if request.rattail_config.getbool('tailbone', 'menus.simple', default=True): + renderer_globals['menus'] = make_simple_menus(request) + # TODO: ugh, same deal here - renderer_globals['messaging_enabled'] = config.get_bool('tailbone.messaging.enabled', - default=False) + renderer_globals['messaging_enabled'] = request.rattail_config.getbool( + 'tailbone', 'messaging.enabled', default=False) # background color may be set per-request, by some apps if hasattr(request, 'background_color') and request.background_color: renderer_globals['background_color'] = request.background_color else: # otherwise we use the one from config - renderer_globals['background_color'] = config.get('tailbone.background_color') + renderer_globals['background_color'] = request.rattail_config.get( + 'tailbone', 'background_color') # maybe set custom stylesheet css = None if request.user: - css = config.get(f'tailbone.{request.user.uuid}', 'user_css') + css = rattail_config.get(f'tailbone.{request.user.uuid}', 'user_css') if not css: - css = config.get(f'tailbone.{request.user.uuid}', 'buefy_css') + css = rattail_config.get(f'tailbone.{request.user.uuid}', 'buefy_css') if css: warnings.warn(f"setting 'tailbone.{request.user.uuid}.buefy_css' should be" f"changed to 'tailbone.{request.user.uuid}.user_css'", @@ -198,7 +233,7 @@ def before_render(event): renderer_globals['global_search_data'] = get_global_search_options(request) # here we globally declare widths for grid filter pseudo-columns - widths = config.get('tailbone.grids.filters.column_widths') + widths = request.rattail_config.get('tailbone', 'grids.filters.column_widths') if widths: widths = widths.split(';') if len(widths) < 2: @@ -209,7 +244,7 @@ def before_render(event): renderer_globals['filter_verb_width'] = widths[1] # declare global support for websockets, or lack thereof - renderer_globals['expose_websockets'] = should_expose_websockets(config) + renderer_globals['expose_websockets'] = should_expose_websockets(rattail_config) def add_inbox_count(event): @@ -223,9 +258,8 @@ def add_inbox_count(event): request = event.get('request') or threadlocal.get_current_request() if request.user: renderer_globals = event - app = request.rattail_config.get_app() - model = app.model enum = request.rattail_config.get_enum() + model = request.rattail_config.get_model() renderer_globals['inbox_count'] = Session.query(model.Message)\ .outerjoin(model.MessageRecipient)\ .filter(model.MessageRecipient.recipient == Session.merge(request.user))\ @@ -239,10 +273,27 @@ def context_found(event): The following is attached to the request: + * ``get_referrer()`` function + * ``get_session_timeout()`` function """ request = event.request + def get_referrer(default=None, **kwargs): + if request.params.get('referrer'): + return request.params['referrer'] + if request.session.get('referrer'): + return request.session.pop('referrer') + referrer = request.referrer + if (not referrer or referrer == request.current_route_url() + or not referrer.startswith(request.host_url)): + if default: + referrer = default + else: + referrer = request.route_url('home') + return referrer + request.get_referrer = get_referrer + def get_session_timeout(): """ Returns the timeout in effect for the current session diff --git a/tailbone/templates/appinfo/configure.mako b/tailbone/templates/appinfo/configure.mako index 9d866cea..280b5cb9 100644 --- a/tailbone/templates/appinfo/configure.mako +++ b/tailbone/templates/appinfo/configure.mako @@ -1,2 +1,250 @@ ## -*- coding: utf-8; -*- -<%inherit file="wuttaweb:templates/appinfo/configure.mako" /> +<%inherit file="/configure.mako" /> + +<%def name="form_content()"> + +

Basics

+
+ + + + + + + + + + ## TODO: should be a dropdown, app handler defines choices + + + + + + + + + + + + + + Production Mode + + + +
+
+ + + Running from Source + + +
+
+ + + + +
+
+ +
+ +

Display

+
+ + + + + + + + + + +
+ +

Grids

+
+ + + + + + + + + + +
+ +

Web Libraries

+
+ + <${b}-table :data="weblibs"> + + <${b}-table-column field="title" + label="Name" + v-slot="props"> + {{ props.row.title }} + + + <${b}-table-column field="configured_version" + label="Version" + v-slot="props"> + {{ props.row.configured_version || props.row.default_version }} + + + <${b}-table-column field="configured_url" + label="URL Override" + v-slot="props"> + {{ props.row.configured_url }} + + + <${b}-table-column field="live_url" + label="Effective (Live) URL" + v-slot="props"> + + save settings and refresh page to see new URL + + + {{ props.row.live_url }} + + + + <${b}-table-column field="actions" + label="Actions" + v-slot="props"> + + % if request.use_oruga: + + % else: + + % endif + Edit + + + + + + % for weblib in weblibs: + ${h.hidden('tailbone.libver.{}'.format(weblib['key']), **{':value': "simpleSettings['tailbone.libver.{}']".format(weblib['key'])})} + ${h.hidden('tailbone.liburl.{}'.format(weblib['key']), **{':value': "simpleSettings['tailbone.liburl.{}']".format(weblib['key'])})} + % endfor + + <${b}-modal has-modal-card + % if request.use_oruga: + v-model:active="editWebLibraryShowDialog" + % else: + :active.sync="editWebLibraryShowDialog" + % endif + > + + + +
+ + +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + + + + +${parent.body()} diff --git a/tailbone/templates/appinfo/index.mako b/tailbone/templates/appinfo/index.mako index faaea935..73f53920 100644 --- a/tailbone/templates/appinfo/index.mako +++ b/tailbone/templates/appinfo/index.mako @@ -1,7 +1,8 @@ ## -*- coding: utf-8; -*- -<%inherit file="wuttaweb:templates/appinfo/index.mako" /> +<%inherit file="/master/index.mako" /> + +<%def name="render_grid_component()"> -<%def name="page_content()">
- ${parent.page_content()} + <${b}-collapse class="panel" open> + + + +
+
+ <${b}-table :data="configFiles"> + + <${b}-table-column field="priority" + label="Priority" + v-slot="props"> + {{ props.row.priority }} + + + <${b}-table-column field="path" + label="File Path" + v-slot="props"> + {{ props.row.path }} + + + +
+
+ + + <${b}-collapse class="panel" + :open="false"> + + + +
+
+ ${parent.render_grid_component()} +
+
+ + +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + + + + +${parent.body()} diff --git a/tailbone/templates/appsettings.mako b/tailbone/templates/appsettings.mako index ba667e0e..4f935956 100644 --- a/tailbone/templates/appsettings.mako +++ b/tailbone/templates/appsettings.mako @@ -15,8 +15,8 @@ -<%def name="render_vue_templates()"> - ${parent.render_vue_templates()} +<%def name="render_this_page_template()"> + ${parent.render_this_page_template()} +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + -<%def name="make_vue_components()"> - ${parent.make_vue_components()} - + + +${parent.body()} diff --git a/tailbone/templates/base.mako b/tailbone/templates/base.mako index 8228f823..1554d15d 100644 --- a/tailbone/templates/base.mako +++ b/tailbone/templates/base.mako @@ -1,5 +1,4 @@ ## -*- coding: utf-8; -*- -<%namespace file="/wutta-components.mako" import="make_wutta_components" /> <%namespace file="/grids/nav.mako" import="grid_index_nav" /> <%namespace file="/autocomplete.mako" import="tailbone_autocomplete_template" /> <%namespace name="base_meta" file="/base_meta.mako" /> @@ -35,21 +34,17 @@ -
+ ${declare_formposter_mixin()} + + ${self.body()} + +
- ## TODO: this must come before the self.body() call..but why? - ${declare_formposter_mixin()} - - ## content body from derived/child template - ${self.body()} - - ## Vue app - ${self.render_vue_templates()} - ${self.modify_vue_vars()} - ${self.make_vue_components()} - ${self.make_vue_app()} + ${self.render_whole_page_template()} + ${self.make_whole_page_component()} + ${self.make_whole_page_app()} @@ -127,16 +122,16 @@ <%def name="vuejs()"> - ${h.javascript_link(h.get_liburl(request, 'vue', prefix='tailbone'))} - ${h.javascript_link(h.get_liburl(request, 'vue_resource', prefix='tailbone'))} + ${h.javascript_link(h.get_liburl(request, 'vue'))} + ${h.javascript_link(h.get_liburl(request, 'vue_resource'))} <%def name="buefy()"> - ${h.javascript_link(h.get_liburl(request, 'buefy', prefix='tailbone'))} + ${h.javascript_link(h.get_liburl(request, 'buefy'))} <%def name="fontawesome()"> - + <%def name="extra_javascript()"> @@ -158,16 +153,12 @@ @@ -176,7 +167,7 @@ ${h.stylesheet_link(user_css)} % else: ## upstream Buefy CSS - ${h.stylesheet_link(h.get_liburl(request, 'buefy.css', prefix='tailbone'))} + ${h.stylesheet_link(h.get_liburl(request, 'buefy.css'))} % endif @@ -186,7 +177,7 @@ <%def name="head_tags()"> -<%def name="render_vue_template_whole_page()"> +<%def name="render_whole_page_template()"> +<%def name="modify_whole_page_vars()"> + + + +<%def name="finalize_whole_page_vars()"> + ## NOTE: if you override this, must use + + +<%def name="make_whole_page_app()"> + + + <%def name="wtfield(form, name, **kwargs)">
@@ -930,88 +955,3 @@
- -############################## -## vue components + app -############################## - -<%def name="render_vue_templates()"> - ${page_help.declare_vars()} - ${multi_file_upload.declare_vars()} - ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.feedback.js') + '?ver={}'.format(tailbone.__version__))} - ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.autocomplete.js') + '?ver={}'.format(tailbone.__version__))} - - ## DEPRECATED; called for back-compat - ${self.render_whole_page_template()} - - -## DEPRECATED; remains for back-compat -<%def name="render_whole_page_template()"> - ${self.render_vue_template_whole_page()} - ${self.declare_whole_page_vars()} - - -## DEPRECATED; remains for back-compat -<%def name="declare_whole_page_vars()"> - ${self.render_vue_script_whole_page()} - - -<%def name="modify_vue_vars()"> - ## DEPRECATED; called for back-compat - ${self.modify_whole_page_vars()} - - -## DEPRECATED; remains for back-compat -<%def name="modify_whole_page_vars()"> - - - -<%def name="make_vue_components()"> - ${make_wutta_components()} - ${make_grid_filter_components()} - ${page_help.make_component()} - ${multi_file_upload.make_component()} - - - ## DEPRECATED; called for back-compat - ${self.finalize_whole_page_vars()} - ${self.make_whole_page_component()} - - -## DEPRECATED; remains for back-compat -<%def name="make_whole_page_component()"> - - - -<%def name="make_vue_app()"> - ## DEPRECATED; called for back-compat - ${self.make_whole_page_app()} - - -## DEPRECATED; remains for back-compat -<%def name="make_whole_page_app()"> - - - -############################## -## DEPRECATED -############################## - -<%def name="finalize_whole_page_vars()"> diff --git a/tailbone/templates/base_meta.mako b/tailbone/templates/base_meta.mako index b6376448..07b13e61 100644 --- a/tailbone/templates/base_meta.mako +++ b/tailbone/templates/base_meta.mako @@ -1,7 +1,10 @@ ## -*- coding: utf-8; -*- -<%inherit file="wuttaweb:templates/base_meta.mako" /> -<%def name="app_title()">${app.get_node_title()} +<%def name="app_title()">${request.rattail_config.node_title(default="Rattail")} + +<%def name="global_title()">${"[STAGE] " if not request.rattail_config.production() else ''}${self.app_title()} + +<%def name="extra_styles()"> <%def name="favicon()"> @@ -10,3 +13,9 @@ <%def name="header_logo()"> ${h.image(request.rattail_config.get('tailbone', 'header_image_url', default=request.static_url('tailbone:static/img/rattail.ico')), "Header Logo", style="height: 49px;")} + +<%def name="footer()"> +

+ powered by ${h.link_to("Rattail", url('about'))} +

+ diff --git a/tailbone/templates/batch/index.mako b/tailbone/templates/batch/index.mako index bea10a97..209fbb0c 100644 --- a/tailbone/templates/batch/index.mako +++ b/tailbone/templates/batch/index.mako @@ -43,7 +43,7 @@
- ${execute_form.render_vue_tag(ref='executeResultsForm')} + <${execute_form.component} ref="executeResultsForm">
@@ -64,17 +64,10 @@ % endif -<%def name="render_vue_templates()"> - ${parent.render_vue_templates()} - % if master.results_executable and master.has_perm('execute_multiple'): - ${execute_form.render_vue_template(form_kwargs={'ref': 'actualExecuteForm'}, buttons=False)} - % endif - - -<%def name="modify_vue_vars()"> - ${parent.modify_vue_vars()} +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} % if master.results_refreshable and master.has_perm('refresh'): - % endif % if master.results_executable and master.has_perm('execute_multiple'): - % endif + +<%def name="render_this_page_template()"> + ${parent.render_this_page_template()} + % if master.results_executable and master.has_perm('execute_multiple'): + ${execute_form.render_deform(form_kwargs={'ref': 'actualExecuteForm'}, buttons=False)|n} + % endif + + + +${parent.body()} diff --git a/tailbone/templates/batch/inventory/desktop_form.mako b/tailbone/templates/batch/inventory/desktop_form.mako index cddaa2c5..7e4795a8 100644 --- a/tailbone/templates/batch/inventory/desktop_form.mako +++ b/tailbone/templates/batch/inventory/desktop_form.mako @@ -147,7 +147,7 @@ -<%def name="modify_vue_vars()"> - ${parent.modify_vue_vars()} - + + +${parent.body()} diff --git a/tailbone/templates/batch/pos/view.mako b/tailbone/templates/batch/pos/view.mako index 5ecabd4d..0da755aa 100644 --- a/tailbone/templates/batch/pos/view.mako +++ b/tailbone/templates/batch/pos/view.mako @@ -1,9 +1,13 @@ ## -*- coding: utf-8; -*- <%inherit file="/batch/view.mako" /> -<%def name="modify_vue_vars()"> - ${parent.modify_vue_vars()} - + +${parent.body()} diff --git a/tailbone/templates/batch/vendorcatalog/configure.mako b/tailbone/templates/batch/vendorcatalog/configure.mako index 4f91cb02..0d57053e 100644 --- a/tailbone/templates/batch/vendorcatalog/configure.mako +++ b/tailbone/templates/batch/vendorcatalog/configure.mako @@ -39,9 +39,14 @@
-<%def name="modify_vue_vars()"> - ${parent.modify_vue_vars()} - + + +${parent.body()} diff --git a/tailbone/templates/batch/vendorcatalog/create.mako b/tailbone/templates/batch/vendorcatalog/create.mako index d9d62bd1..d25c8f16 100644 --- a/tailbone/templates/batch/vendorcatalog/create.mako +++ b/tailbone/templates/batch/vendorcatalog/create.mako @@ -1,16 +1,16 @@ ## -*- coding: utf-8; -*- <%inherit file="/batch/create.mako" /> -<%def name="modify_vue_vars()"> - ${parent.modify_vue_vars()} - + + +${parent.body()} diff --git a/tailbone/templates/batch/view.mako b/tailbone/templates/batch/view.mako index 7c81ab0e..5e3328d9 100644 --- a/tailbone/templates/batch/view.mako +++ b/tailbone/templates/batch/view.mako @@ -85,11 +85,13 @@
% if batch.executed:

+ Batch was executed ${h.pretty_datetime(request.rattail_config, batch.executed)} by ${batch.executed_by}

% elif master.handler.executable(batch): % if master.has_perm('execute'): +

Batch has not yet been executed.

${execution_described|n}
- ${execute_form.render_vue_tag(ref='executeBatchForm')} + <${execute_form.component} ref="executeBatchForm"> +