diff --git a/.gitignore b/.gitignore index 906dc226..b3006f90 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,8 @@ +*~ +*.pyc .coverage .tox/ +dist/ docs/_build/ htmlcov/ Tailbone.egg-info/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..c974b3a6 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,683 @@ + +# 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/README.rst b/README.md similarity index 56% rename from README.rst rename to README.md index 0cffc62d..74c007f6 100644 --- a/README.rst +++ b/README.md @@ -1,10 +1,8 @@ -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`_ for more information. - -.. _home page: http://rattailproject.org/ +Please see Rattail's [home page](http://rattailproject.org/) for more +information. diff --git a/CHANGES.rst b/docs/OLDCHANGES.rst similarity index 98% rename from CHANGES.rst rename to docs/OLDCHANGES.rst index 38c3b959..0a802f40 100644 --- a/CHANGES.rst +++ b/docs/OLDCHANGES.rst @@ -2,8 +2,86 @@ CHANGELOG ========= -Unreleased ----------- +NB. this file contains "old" release notes only. for newer releases +see the `CHANGELOG.md` file in the source root folder. + + +0.9.96 (2024-04-25) +------------------- + +* Remove unused code for ``webhelpers2_grid``. + +* Rename setting for custom user css (remove "buefy"). + +* Fix permission checks for root user with pyramid 2.x. + +* Cleanup grid/filters logic a bit. + +* Use normal (not checkbox) button for grid filters. + +* Tweak icon for Download Results button. + +* Use v-model to track selection etc. for download results fields. + +* Allow deleting rows from executed batches. + + +0.9.95 (2024-04-19) +------------------- + +* Fix ASGI websockets when serving on sub-path under site root. + +* Fix raw query to avoid SQLAlchemy 2.x warnings. + +* Remove config "style" from appinfo page. + + +0.9.94 (2024-04-16) +------------------- + +* Fix master template bug when no form in context. + + +0.9.93 (2024-04-16) +------------------- + +* Improve form support for view supplements. + +* Prevent multi-click for grid filters "Save Defaults" button. + +* Fix typo when getting app instance. + + +0.9.92 (2024-04-16) +------------------- + +* Escape underscore char for "contains" query filter. + +* Rename custom ``user_css`` context. + +* Add support for Pyramid 2.x; new security policy. + + +0.9.91 (2024-04-15) +------------------- + +* Avoid uncaught error when updating order batch row quantities. + +* Try to return JSON error when receiving API call fails. + +* Avoid error for tax field when creating new department. + +* Show toast msg instead of silent error, when grid fetch fails. + +* Remove most references to "buefy" name in class methods, template + filenames etc. + + +0.9.90 (2024-04-01) +------------------- + +* Add basic CRUD for Person "preferred first name". + 0.9.89 (2024-03-27) ------------------- @@ -4914,7 +4992,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) @@ -5247,13 +5325,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 @@ -5261,7 +5339,7 @@ and related technologies. 0.6.11 (2017-07-18) ------------------- +------------------- * Tweak some basic styles for forms/grids @@ -5269,7 +5347,7 @@ and related technologies. 0.6.10 (2017-07-18) ------------------- +------------------- * Fix grid bug if "current page" becomes invalid diff --git a/docs/api/db.rst b/docs/api/db.rst new file mode 100644 index 00000000..ace21b68 --- /dev/null +++ b/docs/api/db.rst @@ -0,0 +1,6 @@ + +``tailbone.db`` +=============== + +.. automodule:: tailbone.db + :members: diff --git a/docs/api/subscribers.rst b/docs/api/subscribers.rst index 8b25c994..d28a1b15 100644 --- a/docs/api/subscribers.rst +++ b/docs/api/subscribers.rst @@ -3,5 +3,4 @@ ======================== .. automodule:: tailbone.subscribers - -.. autofunction:: new_request + :members: diff --git a/docs/api/util.rst b/docs/api/util.rst new file mode 100644 index 00000000..35e66ed3 --- /dev/null +++ b/docs/api/util.rst @@ -0,0 +1,6 @@ + +``tailbone.util`` +================= + +.. automodule:: tailbone.util + :members: diff --git a/docs/changelog.rst b/docs/changelog.rst new file mode 100644 index 00000000..bbf94f4b --- /dev/null +++ b/docs/changelog.rst @@ -0,0 +1,8 @@ + +Changelog Archive +================= + +.. toctree:: + :maxdepth: 1 + + OLDCHANGES diff --git a/docs/conf.py b/docs/conf.py index 505396ed..ade4c92a 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,38 +1,21 @@ -# -*- coding: utf-8; -*- +# Configuration file for the Sphinx documentation builder. # -# 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. +# For the full list of built-in configuration values, see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html -import sys -import os +# -- Project information ----------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information -import sphinx_rtd_theme +from importlib.metadata import version as get_version -exec(open(os.path.join(os.pardir, 'tailbone', '_version.py')).read()) +project = 'Tailbone' +copyright = '2010 - 2024, Lance Edgar' +author = 'Lance Edgar' +release = get_version('Tailbone') +# -- 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', @@ -40,241 +23,30 @@ extensions = [ 'sphinx.ext.viewcode', ] +templates_path = ['_templates'] +exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] + intersphinx_mapping = { - 'rattail': ('https://rattailproject.org/docs/rattail/', None), + 'rattail': ('https://docs.wuttaproject.org/rattail/', None), 'webhelpers2': ('https://webhelpers2.readthedocs.io/en/latest/', None), + 'wuttaweb': ('https://docs.wuttaproject.org/wuttaweb/', None), + 'wuttjamaican': ('https://docs.wuttaproject.org/wuttjamaican/', None), } -# 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. +# allow todo entries to show up todo_include_todos = True -# -- Options for HTML output ---------------------------------------------- +# -- Options for HTML output ------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output -# 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 +html_theme = 'furo' +html_static_path = ['_static'] # 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' - -# 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 +#html_logo = 'images/rattail_avatar.png' # Output file base name for HTML help builder. -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 +#htmlhelp_basename = 'Tailbonedoc' diff --git a/docs/index.rst b/docs/index.rst index 351e910d..d964086f 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -44,6 +44,7 @@ Package API: api/api/batch/core api/api/batch/ordering + api/db api/diffs api/forms api/forms.widgets @@ -51,6 +52,7 @@ Package API: api/grids.core api/progress api/subscribers + api/util api/views/batch api/views/batch.vendorcatalog api/views/core @@ -60,6 +62,14 @@ Package API: api/views/purchasing.ordering +Changelog: + +.. toctree:: + :maxdepth: 1 + + changelog + + Documentation To-Do =================== diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..a7214a8e --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,103 @@ + +[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 deleted file mode 100644 index 67541d96..00000000 --- a/setup.cfg +++ /dev/null @@ -1,114 +0,0 @@ -# -*- 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: apparently they jumped from 0.1 to 0.9 and that broke us... - # (0.1 was released on 2014-09-14 and then 0.9 came out on 2018-09-27) - # (i've cached 0.1 at pypi.rattailproject.org just in case it disappears) - # (still, probably a better idea is to refactor so we can use 0.9) - webhelpers2_grid==0.1 - - # TODO: remove once their bug is fixed? idk what this is about yet... - deform<2.0.15 - - # TODO: remove this cap and address warnings that follow - pyramid<2 - - asgiref - colander - ColanderAlchemy - cornice - cornice-swagger - humanize - Mako - markdown - openpyxl - paginate - paginate_sqlalchemy - passlib - Pillow - pyramid_beaker>=0.6 - 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 = nose.collector -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; fixture; mock; nose; 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/_version.py b/tailbone/_version.py index a8c7fe3a..7095f6c8 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,9 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.89' +try: + from importlib.metadata import version +except ImportError: + from importlib_metadata import version + + +__version__ = version('Tailbone') diff --git a/tailbone/api/auth.py b/tailbone/api/auth.py index 1b347b21..a710e30d 100644 --- a/tailbone/api/auth.py +++ b/tailbone/api/auth.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2023 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,8 +24,6 @@ Tailbone Web API - Auth Views """ -from rattail.db.auth import set_user_password - from cornice import Service from tailbone.api import APIView, api @@ -42,11 +40,10 @@ 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} + data = {'ok': True, 'permissions': []} if self.request.user: data['user'] = self.get_user_info(self.request.user) - - data['permissions'] = list(self.request.tailbone_cached_permissions) + data['permissions'] = list(self.request.user_permissions) # background color may be set per-request, by some apps if hasattr(self.request, 'background_color') and self.request.background_color: @@ -176,7 +173,8 @@ class AuthenticationView(APIView): return {'error': "The current/old password you provided is incorrect"} # okay then, set new password - set_user_password(self.request.user, data['new_password']) + auth = self.app.get_auth_handler() + auth.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 4787aeb9..4f154b21 100644 --- a/tailbone/api/batch/labels.py +++ b/tailbone/api/batch/labels.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,10 +24,6 @@ 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 @@ -56,10 +52,10 @@ class LabelBatchRowViews(APIBatchRowView): def normalize(self, row): batch = row.batch - data = super(LabelBatchRowViews, self).normalize(row) + data = super().normalize(row) data['item_id'] = row.item_id - data['upc'] = six.text_type(row.upc) + data['upc'] = str(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 1661d06f..204be8ad 100644 --- a/tailbone/api/batch/ordering.py +++ b/tailbone/api/batch/ordering.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2023 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -28,18 +28,23 @@ API. """ import datetime +import logging -from rattail.db import model -from rattail.util import pretty_quantity +import sqlalchemy as sa + +from rattail.db.model import PurchaseBatch, PurchaseBatchRow from cornice import Service from tailbone.api.batch import APIBatchView, APIBatchRowView +log = logging.getLogger(__name__) + + class OrderingBatchViews(APIBatchView): - model_class = model.PurchaseBatch + model_class = PurchaseBatch default_handler_spec = 'rattail.batch.purchase:PurchaseBatchHandler' route_prefix = 'orderingbatchviews' permission_prefix = 'ordering' @@ -55,12 +60,13 @@ class OrderingBatchViews(APIBatchView): Adds a condition to the query, to ensure only purchase batches with "ordering" mode are returned. """ - query = super(OrderingBatchViews, self).base_query() + model = self.model + query = super().base_query() query = query.filter(model.PurchaseBatch.mode == self.enum.PURCHASE_BATCH_MODE_ORDERING) return query def normalize(self, batch): - data = super(OrderingBatchViews, self).normalize(batch) + data = super().normalize(batch) data['vendor_uuid'] = batch.vendor.uuid data['vendor_display'] = str(batch.vendor) @@ -80,8 +86,10 @@ 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(OrderingBatchViews, self).create_object(data) + batch = super().create_object(data) return batch def worksheet(self): @@ -221,7 +229,7 @@ class OrderingBatchViews(APIBatchView): class OrderingBatchRowViews(APIBatchRowView): - model_class = model.PurchaseBatchRow + model_class = PurchaseBatchRow default_handler_spec = 'rattail.batch.purchase:PurchaseBatchHandler' route_prefix = 'ordering.rows' permission_prefix = 'ordering' @@ -231,8 +239,9 @@ class OrderingBatchRowViews(APIBatchRowView): editable = True def normalize(self, row): + data = super().normalize(row) + app = self.get_rattail_app() batch = row.batch - data = super(OrderingBatchRowViews, self).normalize(row) data['item_id'] = row.item_id data['upc'] = str(row.upc) @@ -252,8 +261,8 @@ class OrderingBatchRowViews(APIBatchRowView): data['case_quantity'] = row.case_quantity data['cases_ordered'] = row.cases_ordered data['units_ordered'] = row.units_ordered - data['cases_ordered_display'] = pretty_quantity(row.cases_ordered or 0, empty_zero=False) - data['units_ordered_display'] = pretty_quantity(row.units_ordered or 0, empty_zero=False) + data['cases_ordered_display'] = app.render_quantity(row.cases_ordered or 0, empty_zero=False) + data['units_ordered_display'] = app.render_quantity(row.units_ordered or 0, empty_zero=False) data['po_unit_cost'] = row.po_unit_cost data['po_unit_cost_display'] = "${:0.2f}".format(row.po_unit_cost) if row.po_unit_cost is not None else None @@ -281,7 +290,17 @@ class OrderingBatchRowViews(APIBatchRowView): if not self.batch_handler.is_mutable(row.batch): return {'error': "Batch is not mutable"} - self.batch_handler.update_row_quantity(row, **data) + try: + self.batch_handler.update_row_quantity(row, **data) + self.Session.flush() + except Exception as error: + log.warning("update_row_quantity failed", exc_info=True) + if isinstance(error, sa.exc.DataError) and hasattr(error, 'orig'): + error = str(error.orig) + else: + error = str(error) + return {'error': error} + return row diff --git a/tailbone/api/batch/receiving.py b/tailbone/api/batch/receiving.py index f8ce4a33..b23bff55 100644 --- a/tailbone/api/batch/receiving.py +++ b/tailbone/api/batch/receiving.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2023 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -27,9 +27,9 @@ Tailbone Web API - Receiving Batches import logging import humanize +import sqlalchemy as sa -from rattail.db import model -from rattail.util import pretty_quantity +from rattail.db.model import PurchaseBatch, PurchaseBatchRow from cornice import Service from deform import widget as dfwidget @@ -44,7 +44,7 @@ log = logging.getLogger(__name__) class ReceivingBatchViews(APIBatchView): - model_class = model.PurchaseBatch + model_class = PurchaseBatch default_handler_spec = 'rattail.batch.purchase:PurchaseBatchHandler' route_prefix = 'receivingbatchviews' permission_prefix = 'receiving' @@ -54,7 +54,8 @@ class ReceivingBatchViews(APIBatchView): supports_execute = True def base_query(self): - query = super(ReceivingBatchViews, self).base_query() + model = self.app.model + query = super().base_query() query = query.filter(model.PurchaseBatch.mode == self.enum.PURCHASE_BATCH_MODE_RECEIVING) return query @@ -84,7 +85,7 @@ class ReceivingBatchViews(APIBatchView): # assume "receive from PO" if given a PO key if data.get('purchase_key'): - data['receiving_workflow'] = 'from_po' + data['workflow'] = 'from_po' return super().create_object(data) @@ -119,6 +120,7 @@ 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: @@ -175,7 +177,7 @@ class ReceivingBatchViews(APIBatchView): class ReceivingBatchRowViews(APIBatchRowView): - model_class = model.PurchaseBatchRow + model_class = PurchaseBatchRow default_handler_spec = 'rattail.batch.purchase:PurchaseBatchHandler' route_prefix = 'receiving.rows' permission_prefix = 'receiving' @@ -184,7 +186,8 @@ class ReceivingBatchRowViews(APIBatchRowView): supports_quick_entry = True def make_filter_spec(self): - filters = super(ReceivingBatchRowViews, self).make_filter_spec() + model = self.app.model + filters = super().make_filter_spec() if filters: # must translate certain convenience filters @@ -295,11 +298,11 @@ class ReceivingBatchRowViews(APIBatchRowView): return filters def normalize(self, row): - data = super(ReceivingBatchRowViews, self).normalize(row) + data = super().normalize(row) + model = self.app.model batch = row.batch - app = self.get_rattail_app() - prodder = app.get_products_handler() + prodder = self.app.get_products_handler() data['product_uuid'] = row.product_uuid data['item_id'] = row.item_id @@ -374,7 +377,7 @@ class ReceivingBatchRowViews(APIBatchRowView): if accounted_for: # some product accounted for; button should receive "remainder" only if remainder: - remainder = pretty_quantity(remainder) + remainder = self.app.render_quantity(remainder) data['quick_receive_quantity'] = remainder data['quick_receive_text'] = "Receive Remainder ({} {})".format( remainder, data['unit_uom']) @@ -385,7 +388,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 = pretty_quantity(remainder) + remainder = self.app.render_quantity(remainder) data['quick_receive_quantity'] = remainder data['quick_receive_text'] = "Receive ALL ({} {})".format( remainder, data['unit_uom']) @@ -413,7 +416,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(app.make_utc() - row.modified)) + humanize.naturaltime(self.app.make_utc() - row.modified)) data['received_alert'] = msg return data @@ -422,6 +425,8 @@ 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) @@ -440,9 +445,17 @@ class ReceivingBatchRowViews(APIBatchRowView): # handler takes care of the row receiving logic for us kwargs = dict(form.validated) del kwargs['row'] - self.batch_handler.receive_row(row, **kwargs) + try: + self.batch_handler.receive_row(row, **kwargs) + self.Session.flush() + except Exception as error: + log.warning("receive() failed", exc_info=True) + if isinstance(error, sa.exc.DataError) and hasattr(error, 'orig'): + error = str(error.orig) + else: + error = str(error) + return {'error': error} - self.Session.flush() return self._get(obj=row) @classmethod diff --git a/tailbone/api/common.py b/tailbone/api/common.py index 30dfeab1..6cacfb06 100644 --- a/tailbone/api/common.py +++ b/tailbone/api/common.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2023 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -26,15 +26,12 @@ Tailbone Web API - "Common" Views from collections import OrderedDict -import rattail -from rattail.db import model -from rattail.mail import send_email +from rattail.util import get_pkg_version 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 @@ -66,11 +63,12 @@ class CommonView(APIView): } def get_project_title(self): - return self.rattail_config.app_title(default="Tailbone") + app = self.get_rattail_app() + return app.get_title() def get_project_version(self): - import tailbone - return tailbone.__version__ + app = self.get_rattail_app() + return app.get_version() def get_packages(self): """ @@ -78,8 +76,8 @@ class CommonView(APIView): 'about' page. """ return OrderedDict([ - ('rattail', rattail.__version__), - ('Tailbone', tailbone.__version__), + ('rattail', get_pkg_version('rattail')), + ('Tailbone', get_pkg_version('Tailbone')), ]) @api @@ -87,6 +85,8 @@ 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 - send_email(self.rattail_config, email_key, data=data) + app.send_email(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 b278d4af..0d8eec32 100644 --- a/tailbone/api/core.py +++ b/tailbone/api/core.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2023 Lance Edgar +# Copyright © 2010-2024 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 = user.is_admin() + is_admin = auth.user_is_admin(user) employee = app.get_employee(user) info = { 'uuid': user.uuid, diff --git a/tailbone/api/customers.py b/tailbone/api/customers.py index e9953572..85d28c24 100644 --- a/tailbone/api/customers.py +++ b/tailbone/api/customers.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,10 +24,6 @@ Tailbone Web API - Customer Views """ -from __future__ import unicode_literals, absolute_import - -import six - from rattail.db import model from tailbone.api import APIMasterView @@ -46,7 +42,7 @@ class CustomerView(APIMasterView): def normalize(self, customer): return { 'uuid': customer.uuid, - '_str': six.text_type(customer), + '_str': str(customer), 'id': customer.id, 'number': customer.number, 'name': customer.name, diff --git a/tailbone/api/master.py b/tailbone/api/master.py index 70616484..551d6428 100644 --- a/tailbone/api/master.py +++ b/tailbone/api/master.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2023 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -26,12 +26,11 @@ 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, api +from tailbone.api import APIView from tailbone.db import Session from tailbone.util import SortColumn @@ -185,7 +184,7 @@ class APIMasterView(APIView): if sortcol: spec = { 'field': sortcol.field_name, - 'direction': 'asc' if parse_bool(self.request.params['ascending']) else 'desc', + 'direction': 'asc' if self.config.parse_bool(self.request.params['ascending']) else 'desc', } if sortcol.model_name: spec['model'] = sortcol.model_name @@ -355,9 +354,13 @@ class APIMasterView(APIView): data = self.request.json_body # add instance to session, and return data for it - obj = self.create_object(data) - self.Session.flush() - return self._get(obj) + 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) def create_object(self, data): """ diff --git a/tailbone/api/people.py b/tailbone/api/people.py index 7e06e969..f7c08dfa 100644 --- a/tailbone/api/people.py +++ b/tailbone/api/people.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,10 +24,6 @@ Tailbone Web API - Person Views """ -from __future__ import unicode_literals, absolute_import - -import six - from rattail.db import model from tailbone.api import APIMasterView @@ -45,7 +41,7 @@ class PersonView(APIMasterView): def normalize(self, person): return { 'uuid': person.uuid, - '_str': six.text_type(person), + '_str': str(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 6ce5f778..467c8a0d 100644 --- a/tailbone/api/upgrades.py +++ b/tailbone/api/upgrades.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,10 +24,6 @@ Tailbone Web API - Upgrade Views """ -from __future__ import unicode_literals, absolute_import - -import six - from rattail.db import model from tailbone.api import APIMasterView @@ -53,7 +49,7 @@ class UpgradeView(APIMasterView): data['status_code'] = None else: data['status_code'] = self.enum.UPGRADE_STATUS.get(upgrade.status_code, - six.text_type(upgrade.status_code)) + str(upgrade.status_code)) return data diff --git a/tailbone/api/vendors.py b/tailbone/api/vendors.py index 7fa61590..64311b1b 100644 --- a/tailbone/api/vendors.py +++ b/tailbone/api/vendors.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,10 +24,6 @@ Tailbone Web API - Vendor Views """ -from __future__ import unicode_literals, absolute_import - -import six - from rattail.db import model from tailbone.api import APIMasterView @@ -44,7 +40,7 @@ class VendorView(APIMasterView): def normalize(self, vendor): return { 'uuid': vendor.uuid, - '_str': six.text_type(vendor), + '_str': str(vendor), 'id': vendor.id, 'name': vendor.name, } diff --git a/tailbone/api/workorders.py b/tailbone/api/workorders.py index eabe4cdb..19def6c4 100644 --- a/tailbone/api/workorders.py +++ b/tailbone/api/workorders.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,12 +24,8 @@ 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 @@ -44,19 +40,19 @@ class WorkOrderView(APIMasterView): object_url_prefix = '/workorder' def __init__(self, *args, **kwargs): - super(WorkOrderView, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) app = self.get_rattail_app() self.workorder_handler = app.get_workorder_handler() def normalize(self, workorder): - data = super(WorkOrderView, self).normalize(workorder) + data = super().normalize(workorder) data.update({ 'customer_name': workorder.customer.name, 'status_label': self.enum.WORKORDER_STATUS[workorder.status_code], - '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 ''), + '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 ''), }) return data @@ -87,7 +83,7 @@ class WorkOrderView(APIMasterView): if 'status_code' in data: data['status_code'] = int(data['status_code']) - return super(WorkOrderView, self).update_object(workorder, data) + return super().update_object(workorder, data) def status_codes(self): """ diff --git a/tailbone/app.py b/tailbone/app.py index ae10c9bc..d2d0c5ef 100644 --- a/tailbone/app.py +++ b/tailbone/app.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2023 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -25,21 +25,19 @@ Application Entry Point """ import os -import warnings -import sqlalchemy as sa from sqlalchemy.orm import sessionmaker, scoped_session -from rattail.config import make_config, parse_list +from wuttjamaican.util import parse_list + +from rattail.config import make_config 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 TailboneAuthorizationPolicy +from tailbone.auth import TailboneSecurityPolicy 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 @@ -61,9 +59,23 @@ 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, 'rattail_engine'): - tailbone.db.Session.configure(bind=rattail_config.rattail_engine) + if hasattr(rattail_config, 'appdb_engine'): + tailbone.db.Session.configure(bind=rattail_config.appdb_engine) if hasattr(rattail_config, 'trainwreck_engine'): tailbone.db.TrainwreckSession.configure(bind=rattail_config.trainwreck_engine) if hasattr(rattail_config, 'tempmon_engine'): @@ -123,18 +135,21 @@ def make_pyramid_config(settings, configure_csrf=True): config.set_root_factory(Root) else: + # declare this web app of the "classic" variety + settings.setdefault('tailbone.classic', '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) - # add rattail config directly to registry + # add rattail config directly to registry, for access throughout the app config.registry['rattail_config'] = rattail_config # configure user authorization / authentication - config.set_authorization_policy(TailboneAuthorizationPolicy()) - config.set_authentication_policy(SessionAuthenticationPolicy()) + config.set_security_policy(TailboneSecurityPolicy()) # maybe require CSRF token protection if configure_csrf: @@ -145,6 +160,7 @@ 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') @@ -180,9 +196,16 @@ def make_pyramid_config(settings, configure_csrf=True): for spec in includes: config.include(spec) - # 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') + # 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') # and some similar magic for certain master views config.add_directive('add_tailbone_index_page', 'tailbone.app.add_index_page') @@ -309,7 +332,8 @@ def main(global_config, **settings): """ This function returns a Pyramid WSGI application. """ - settings.setdefault('mako.directories', ['tailbone:templates']) + settings.setdefault('mako.directories', ['tailbone:templates', + 'wuttaweb:templates']) rattail_config = make_rattail_config(settings) pyramid_config = make_pyramid_config(settings) pyramid_config.include('tailbone') diff --git a/tailbone/asgi.py b/tailbone/asgi.py index f2146577..1afbe12a 100644 --- a/tailbone/asgi.py +++ b/tailbone/asgi.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,14 +24,10 @@ ASGI App Utilities """ -from __future__ import unicode_literals, absolute_import - import os +import configparser import logging -import six -from six.moves import configparser - from rattail.util import load_object from asgiref.wsgi import WsgiToAsgi @@ -49,6 +45,12 @@ class TailboneWsgiToAsgi(WsgiToAsgi): protocol = scope['type'] path = scope['path'] + # strip off the root path, if non-empty. needed for serving + # under /poser or anything other than true site root + root_path = scope['root_path'] + if root_path and path.startswith(root_path): + path = path[len(root_path):] + if protocol == 'websocket': websockets = self.wsgi_application.registry.get( 'tailbone_websockets', {}) @@ -85,7 +87,7 @@ def make_asgi_app(main_app=None): # parse the settings needed for pyramid app settings = dict(parser.items('app:main')) - if isinstance(main_app, six.string_types): + if isinstance(main_app, str): make_wsgi_app = load_object(main_app) elif callable(main_app): make_wsgi_app = main_app diff --git a/tailbone/auth.py b/tailbone/auth.py index 1f057404..95bf90ba 100644 --- a/tailbone/auth.py +++ b/tailbone/auth.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2023 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -27,29 +27,28 @@ Authentication & Authorization import logging import re -from rattail import enum -from rattail.util import prettify, NOTSET +from wuttjamaican.util import UNSPECIFIED -from zope.interface import implementer -from pyramid.interfaces import IAuthorizationPolicy -from pyramid.security import remember, forget, Everyone, Authenticated -from pyramid.authentication import SessionAuthenticationPolicy +from pyramid.security import remember, forget +from wuttaweb.auth import WuttaSecurityPolicy from tailbone.db import Session log = logging.getLogger(__name__) -def login_user(request, user, timeout=NOTSET): +def login_user(request, user, timeout=UNSPECIFIED): """ Perform the steps necessary to login the given user. Note that this returns a ``headers`` dict which you should pass to the redirect. """ - user.record_event(enum.USER_EVENT_LOGIN) + config = request.rattail_config + app = config.get_app() + user.record_event(app.enum.USER_EVENT_LOGIN) headers = remember(request, user.uuid) - if timeout is NOTSET: - timeout = session_timeout_for_user(user) + if timeout is UNSPECIFIED: + timeout = session_timeout_for_user(config, user) log.debug("setting session timeout for '{}' to {}".format(user.username, timeout)) set_session_timeout(request, timeout) return headers @@ -60,24 +59,28 @@ def logout_user(request): Perform the logout action for the given request. Note that this returns a ``headers`` dict which you should pass to the redirect. """ + app = request.rattail_config.get_app() user = request.user if user: - user.record_event(enum.USER_EVENT_LOGOUT) + user.record_event(app.enum.USER_EVENT_LOGOUT) request.session.delete() request.session.invalidate() headers = forget(request) return headers -def session_timeout_for_user(user): +def session_timeout_for_user(config, user): """ Returns the "max" session timeout for the user, according to roles """ - from rattail.db.auth import authenticated_role + app = config.get_app() + auth = app.get_auth_handler() - roles = user.roles + [authenticated_role(Session())] + authenticated = auth.get_role_authenticated(Session()) + roles = user.roles + [authenticated] 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) @@ -89,89 +92,42 @@ def set_session_timeout(request, timeout): request.session['_timeout'] = timeout or None -class TailboneAuthenticationPolicy(SessionAuthenticationPolicy): - """ - Custom authentication policy for Tailbone. +class TailboneSecurityPolicy(WuttaSecurityPolicy): - 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. + 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 - 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(TailboneAuthenticationPolicy, self).unauthenticated_userid(request) - - -@implementer(IAuthorizationPolicy) -class TailboneAuthorizationPolicy(object): - - def permits(self, context, principals, permission): - config = context.request.rattail_config - model = config.get_model() + def load_identity(self, request): + config = request.registry.settings.get('rattail_config') app = config.get_app() - auth = app.get_auth_handler() + user = None - 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 + if self.api_mode: - def principals_allowed_by_permission(self, context, permission): - raise NotImplementedError + # determine/load user 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) + auth = app.get_auth_handler() + user = auth.authenticate_user_token(self.db_session, token) + if not user: -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) + # fetch user uuid from current session + uuid = self.session_helper.authenticated_userid(request) + if not uuid: + return + # fetch user object from db + model = app.model + user = self.db_session.get(model.User, uuid) + if not user: + return -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) + # this user is responsible for data changes in current request + self.db_session.set_continuum_user(user) + return user diff --git a/tailbone/beaker.py b/tailbone/beaker.py index b5d592f1..25a450df 100644 --- a/tailbone/beaker.py +++ b/tailbone/beaker.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 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(beaker.__version__) < parse_version('1.12') + old_beaker = parse_version(get_pkg_version('beaker')) < 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 6106e87e..8392ba0a 100644 --- a/tailbone/config.py +++ b/tailbone/config.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2023 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -26,13 +26,14 @@ Rattail config extension for Tailbone import warnings -from rattail.config import ConfigExtension as BaseExtension +from wuttjamaican.conf import WuttaConfigExtension + from rattail.db.config import configure_session from tailbone.db import Session -class ConfigExtension(BaseExtension): +class ConfigExtension(WuttaConfigExtension): """ Rattail config extension for Tailbone. Does the following: @@ -49,9 +50,12 @@ class ConfigExtension(BaseExtension): configure_session(config, Session) # provide default theme selection - config.setdefault('tailbone', 'themes.keys', 'default, falafel') + config.setdefault('tailbone', 'themes.keys', 'default, butterball') 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') @@ -61,25 +65,6 @@ def csrf_header_name(config): return config.get('tailbone', 'csrf_header_name', default='X-CSRF-TOKEN') -def get_buefy_version(config): - warnings.warn("get_buefy_version() is deprecated; please use " - "tailbone.util.get_libver() instead", - DeprecationWarning, stacklevel=2) - - version = config.get('tailbone', 'libver.buefy') - if version: - return version - - return config.get('tailbone', 'buefy_version', - default='latest') - - -def get_buefy_0_8(config, version=None): - warnings.warn("get_buefy_0_8() is no longer supported", - DeprecationWarning, stacklevel=2) - return False - - def global_help_url(config): return config.get('tailbone', 'global_help_url') diff --git a/tailbone/db.py b/tailbone/db.py index 4a6821f9..8b37f399 100644 --- a/tailbone/db.py +++ b/tailbone/db.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2023 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -21,14 +21,13 @@ # ################################################################################ """ -Database Stuff +Database sessions etc. """ 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 @@ -43,23 +42,28 @@ 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 Continuum - integration to work alongside the Zope transaction integration. + + 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. """ 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 @@ -71,126 +75,120 @@ 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 is copied from upstream, and tweaked so that our custom - :class:`TailboneSessionDataManager` will be used. + + 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. """ # the upstream internals of this function has changed a little over time. # unfortunately for us, that means we must include each variant here. - 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) + 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.2'): # 1.2+ - - 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. - - .. 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 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. - - 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. +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. .. note:: - This function is copied from upstream, and tweaked so that our custom - :class:`ZopeTransactionExtension` will be used. + + 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.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_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 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. + + 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. + + .. 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. """ from sqlalchemy import event - 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, - ) + ext = ZopeTransactionEvents( + 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) @@ -199,9 +197,8 @@ def register(session, initial_state=datamanager.STATUS_ACTIVE, event.listen(session, "after_bulk_delete", ext.after_bulk_delete) event.listen(session, "before_commit", ext.before_commit) - 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) + 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 98253c57..2e582b15 100644 --- a/tailbone/diffs.py +++ b/tailbone/diffs.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2023 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -270,9 +270,21 @@ 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 beea1366..3468562a 100644 --- a/tailbone/exceptions.py +++ b/tailbone/exceptions.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2020 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,10 +24,6 @@ Tailbone Exceptions """ -from __future__ import unicode_literals, absolute_import - -import six - from rattail.exceptions import RattailError @@ -37,7 +33,6 @@ 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 e04126a3..4024557b 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2023 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -33,10 +33,9 @@ from collections import OrderedDict import sqlalchemy as sa from sqlalchemy import orm from sqlalchemy.ext.associationproxy import AssociationProxy, ASSOCIATION_PROXY +from wuttjamaican.util import UNSPECIFIED -from rattail.time import localtime -from rattail.util import prettify, pretty_boolean, pretty_quantity -from rattail.core import UNSPECIFIED +from rattail.util import pretty_boolean from rattail.db.util import get_fieldnames import colander @@ -48,12 +47,14 @@ 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, get_form_data, render_markdown -from . import types -from .widgets import (ReadonlyWidget, PlainDateWidget, - JQueryDateWidget, JQueryTimeWidget, - MultiFileUploadWidget) +from tailbone.util import raw_datetime, render_markdown +from tailbone.forms import types +from tailbone.forms.widgets import (ReadonlyWidget, PlainDateWidget, + JQueryDateWidget, JQueryTimeWidget, + FileUploadWidget, MultiFileUploadWidget) from tailbone.exceptions import TailboneJSONFieldError @@ -225,7 +226,7 @@ class CustomSchemaNode(SQLAlchemySchemaNode): if excludes: overrides['excludes'] = excludes - return super(CustomSchemaNode, self).get_schema_from_relationship(prop, overrides) + return super().get_schema_from_relationship(prop, overrides) def dictify(self, obj): """ Return a dictified version of `obj` using schema information. @@ -234,7 +235,7 @@ class CustomSchemaNode(SQLAlchemySchemaNode): This method was copied from upstream and modified to add automatic handling of "association proxy" fields. """ - dict_ = super(CustomSchemaNode, self).dictify(obj) + dict_ = super().dictify(obj) for node in self: name = node.name @@ -327,7 +328,7 @@ class Form(object): """ Base class for all forms. """ - save_label = "Save" + save_label = "Submit" update_label = "Save" show_cancel = True auto_disable = True @@ -338,10 +339,12 @@ 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, component='tailbone-form', - vuejs_component_kwargs=None, vuejs_field_converters={}, + action_url=None, cancel_url=None, + vue_tagname=None, + 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: @@ -379,21 +382,79 @@ class Form(object): self.focus_spec = focus_spec self.action_url = action_url self.cancel_url = cancel_url - self.component = component + + # 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.vuejs_component_kwargs = vuejs_component_kwargs or {} self.vuejs_field_converters = vuejs_field_converters or {} + self.json_data = json_data or {} + self.included_templates = included_templates or {} self.can_edit_help = can_edit_help 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 component_studly(self): - words = self.component.split('-') + 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) + def __contains__(self, item): return item in self.fields @@ -569,7 +630,9 @@ class Form(object): self.schema[key].title = label def get_label(self, key): - return self.labels.get(key, prettify(key)) + config = self.request.rattail_config + app = config.get_app() + return self.labels.get(key, app.make_title(key)) def set_readonly(self, key, readonly=True): if readonly: @@ -645,7 +708,7 @@ class Form(object): self.set_widget(key, dfwidget.TextAreaWidget(cols=80, rows=8)) elif type_ == 'file': tmpstore = SessionFileUploadTempStore(self.request) - kw = {'widget': dfwidget.FileUploadWidget(tmpstore), + kw = {'widget': FileUploadWidget(tmpstore, request=self.request), 'title': self.get_label(key)} if 'required' in kwargs and not kwargs['required']: kw['missing'] = colander.null @@ -794,12 +857,15 @@ class Form(object): def set_vuejs_field_converter(self, field, converter): self.vuejs_field_converters[field] = converter - def render(self, template=None, **kwargs): - if not template: - template = '/forms/form.mako' - context = kwargs - context['form'] = self - return render(template, context) + def render(self, **kwargs): + warnings.warn("Form.render() is deprecated (for now?); " + "please use Form.render_deform() instead", + 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'): @@ -839,16 +905,21 @@ 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_buefy.mako' + template = '/forms/deform.mako' if dform is None: dform = self.make_deform_form() # TODO: would perhaps be nice to leverage deform's default rendering # someday..? i.e. using Chameleon *.pt templates - # return form.render() + # return dform.render() context = kwargs context['form'] = self @@ -861,8 +932,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.component_studly) - context['form_kwargs']['@submit'] = 'submit{}'.format(self.component_studly) + context['form_kwargs'].setdefault('ref', self.vue_component) + context['form_kwargs']['@submit'] = 'submit{}'.format(self.vue_component) if self.focus_spec: context['form_kwargs']['data-focus'] = self.focus_spec context['request'] = self.request @@ -874,11 +945,13 @@ class Form(object): return dict([(field, self.get_label(field)) for field in self]) - def get_field_markdowns(self): - model = self.request.rattail_config.get_model() + def get_field_markdowns(self, session=None): + app = self.request.rattail_config.get_app() + model = app.model + session = session or Session() 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) @@ -886,6 +959,18 @@ 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 @@ -952,7 +1037,11 @@ class Form(object): def set_vuejs_component_kwargs(self, **kwargs): self.vuejs_component_kwargs.update(kwargs) - def render_vuejs_component(self): + def render_vue_tag(self, **kwargs): + """ """ + return self.render_vuejs_component(**kwargs) + + def render_vuejs_component(self, **kwargs): """ Render the Vue.js component HTML for the form. @@ -963,17 +1052,47 @@ class Form(object): """ - kwargs = dict(self.vuejs_component_kwargs) + kw = dict(self.vuejs_component_kwargs) + kw.update(kwargs) if self.can_edit_help: - kwargs.setdefault(':configure-fields-help', 'configureFieldsHelp') - return HTML.tag(self.component, **kwargs) + kw.setdefault(':configure-fields-help', 'configureFieldsHelp') + return HTML.tag(self.vue_tagname, **kw) - def render_buefy_field(self, fieldname, bfield_attrs={}): + def set_json_data(self, key, value): """ - Render the given field in a Buefy-compatible way. Note that - this is meant to render *editable* fields, i.e. showing a - widget, unless the field input is hidden. In other words it's - not for "readonly" fields. + Establish a data value for use in client-side JS. This value + will be JSON-encoded and made available to the + `` component within the client page. + """ + self.json_data[key] = value + + def include_template(self, template, context): + """ + Declare a JS template as required by the current form. This + template will then be included in the final page, so all + widgets behave correctly. + """ + self.included_templates[template] = context + + def render_included_templates(self): + templates = [] + for template, context in self.included_templates.items(): + context = dict(context) + context['form'] = self + 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): + """ + Render the given field completely, i.e. with ```` + wrapper. Note that this is meant to render *editable* fields, + i.e. showing a widget, unless the field input is hidden. In + other words it's not for "readonly" fields. """ dform = self.make_deform_form() field = dform[fieldname] if fieldname in dform else None @@ -986,7 +1105,7 @@ class Form(object): if self.field_visible(fieldname): label = self.get_label(fieldname) - markdowns = self.get_field_markdowns() + markdowns = self.get_field_markdowns(session=session) # these attrs will be for the (*not* the widget) attrs = { @@ -1019,9 +1138,17 @@ class Form(object): if field_type: attrs['type'] = field_type if messages: - attrs[':message'] = '[{}]'.format(', '.join([ - "'{}'".format(msg.replace("'", r"\'")) - for msg in messages])) + if len(messages) == 1: + msg = messages[0] + if msg.startswith('`') and msg.endswith('`'): + attrs[':message'] = msg + else: + attrs['message'] = msg + else: + # nb. must pass an array as JSON string + attrs[':message'] = '[{}]'.format(', '.join([ + "'{}'".format(msg.replace("'", r"\'")) + for msg in messages])) # merge anything caller provided attrs.update(bfield_attrs) @@ -1069,15 +1196,27 @@ class Form(object): label_contents.append(HTML.literal('   ')) label_contents.append(icon) - # nb. must apply hack to get