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 `<b-tooltip>` 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 `<b-select>` 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 `<tailbone-timepicker>` 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 ``<tailbone-datepicker>`` + +- 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 97% rename from CHANGES.rst rename to docs/OLDCHANGES.rst index 27908253..0a802f40 100644 --- a/CHANGES.rst +++ b/docs/OLDCHANGES.rst @@ -2,6 +2,191 @@ CHANGELOG ========= +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) +------------------- + +* Fix bulk-delete rows for import/export batch. + + +0.9.88 (2024-03-26) +------------------- + +* Update some SQLAlchemy logic per upcoming 2.0 changes. + + +0.9.87 (2023-12-26) +------------------- + +* Auto-disable submit button for login form. + +* Hide single invoice file field for multi-invoice receiving batch. + +* Use common logic to render invoice total for receiving. + +* Expose default custorder discount for Departments. + + +0.9.86 (2023-12-12) +------------------- + +* Use ``ltrim(rtrim())`` instead of just ``trim()`` in grid filters. + + +0.9.85 (2023-12-01) +------------------- + +* Use clientele handler to populate customer dropdown widget. + + +0.9.84 (2023-11-30) +------------------- + +* Provide a way to show enum display text for some version diff fields. + + +0.9.83 (2023-11-30) +------------------- + +* Avoid error when editing a department. + + +0.9.82 (2023-11-19) +------------------- + +* Fix DB picker, theme picker per Buefy conventions. + + +0.9.81 (2023-11-15) +------------------- + +* Log warning instead of error for batch population error. + +* Remove reference to ``pytz`` library. + +* Avoid outright error if user scans barcode for inventory count. + + +0.9.80 (2023-11-05) +------------------- + +* Expose status code for equity payments. + + +0.9.79 (2023-11-01) +------------------- + +* Add button to confirm all costs for receiving. + + +0.9.78 (2023-11-01) +------------------- + +* Use shared logic to get batch handler. + +* Fix config key for default themes list. + + +0.9.77 (2023-11-01) +------------------- + +* Encode values for "between" query filter. + +* Avoid error when rendering version diff. + + +0.9.76 (2023-11-01) +------------------- + +* Fix missing import. + + +0.9.75 (2023-11-01) +------------------- + +* Add deprecation warnings for ambgiguous config keys. + + 0.9.74 (2023-10-30) ------------------- @@ -4807,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) @@ -5140,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 @@ -5154,7 +5339,7 @@ and related technologies. 0.6.11 (2017-07-18) ------------------- +------------------- * Tweak some basic styles for forms/grids @@ -5162,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/diffs.rst b/docs/api/diffs.rst new file mode 100644 index 00000000..fb1bba71 --- /dev/null +++ b/docs/api/diffs.rst @@ -0,0 +1,6 @@ + +``tailbone.diffs`` +================== + +.. automodule:: tailbone.diffs + :members: diff --git a/docs/api/forms.widgets.rst b/docs/api/forms.widgets.rst new file mode 100644 index 00000000..33316903 --- /dev/null +++ b/docs/api/forms.widgets.rst @@ -0,0 +1,6 @@ + +``tailbone.forms.widgets`` +========================== + +.. automodule:: tailbone.forms.widgets + :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/api/views/master.rst b/docs/api/views/master.rst index 44278e0a..e7de7170 100644 --- a/docs/api/views/master.rst +++ b/docs/api/views/master.rst @@ -81,6 +81,12 @@ override when defining your subclass. override this for certain views, if so that should be done within :meth:`get_help_url()`. + .. attribute:: MasterView.version_diff_factory + + Optional factory to use for version diff objects. By default + this is *not set* but a subclass is free to set it. See also + :meth:`get_version_diff_factory()`. + Methods to Override ------------------- @@ -100,6 +106,14 @@ subclass. .. automethod:: MasterView.get_model_key + .. automethod:: MasterView.get_version_diff_enums + + .. automethod:: MasterView.get_version_diff_factory + + .. automethod:: MasterView.make_version_diff + + .. automethod:: MasterView.title_for_version + Support Methods --------------- diff --git a/docs/api/views/members.rst b/docs/api/views/members.rst new file mode 100644 index 00000000..6a9e9168 --- /dev/null +++ b/docs/api/views/members.rst @@ -0,0 +1,6 @@ + +``tailbone.views.members`` +========================== + +.. automodule:: tailbone.views.members + :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 -# "<project> v<release> 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 <link> 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 b19d859f..d964086f 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -44,19 +44,32 @@ Package API: api/api/batch/core api/api/batch/ordering + api/db + api/diffs api/forms + api/forms.widgets api/grids api/grids.core api/progress api/subscribers + api/util api/views/batch api/views/batch.vendorcatalog api/views/core api/views/master + api/views/members api/views/purchasing.batch 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 85501357..00000000 --- a/setup.cfg +++ /dev/null @@ -1,108 +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 - 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 23ed7e0c..7095f6c8 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,9 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.74' +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/core.py b/tailbone/api/batch/core.py index c98e01f1..f7bc9333 100644 --- a/tailbone/api/batch/core.py +++ b/tailbone/api/batch/core.py @@ -66,9 +66,7 @@ class APIBatchMixin(object): """ app = self.get_rattail_app() key = self.get_batch_class().batch_key - spec = self.rattail_config.get('rattail.batch', '{}.handler'.format(key), - default=self.default_handler_spec) - return app.load_object(spec)(self.rattail_config) + return app.get_batch_handler(key, default=self.default_handler_spec) class APIBatchView(APIBatchMixin, APIMasterView): diff --git a/tailbone/api/batch/inventory.py b/tailbone/api/batch/inventory.py index 5e56fe46..22b67e54 100644 --- a/tailbone/api/batch/inventory.py +++ b/tailbone/api/batch/inventory.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,15 +24,12 @@ Tailbone Web API - Inventory Batches """ -from __future__ import unicode_literals, absolute_import - import decimal -import six +import sqlalchemy as sa from rattail import pod -from rattail.db import model -from rattail.util import pretty_quantity +from rattail.db.model import InventoryBatch, InventoryBatchRow from cornice import Service @@ -41,7 +38,7 @@ from tailbone.api.batch import APIBatchView, APIBatchRowView class InventoryBatchViews(APIBatchView): - model_class = model.InventoryBatch + model_class = InventoryBatch default_handler_spec = 'rattail.batch.inventory:InventoryBatchHandler' route_prefix = 'inventory' permission_prefix = 'batch.inventory' @@ -50,12 +47,12 @@ class InventoryBatchViews(APIBatchView): supports_toggle_complete = True def normalize(self, batch): - data = super(InventoryBatchViews, self).normalize(batch) + data = super().normalize(batch) data['mode'] = batch.mode data['mode_display'] = self.enum.INVENTORY_MODE.get(batch.mode) if data['mode_display'] is None and batch.mode is not None: - data['mode_display'] = six.text_type(batch.mode) + data['mode_display'] = str(batch.mode) data['reason_code'] = batch.reason_code @@ -119,7 +116,7 @@ class InventoryBatchViews(APIBatchView): class InventoryBatchRowViews(APIBatchRowView): - model_class = model.InventoryBatchRow + model_class = InventoryBatchRow default_handler_spec = 'rattail.batch.inventory:InventoryBatchHandler' route_prefix = 'inventory.rows' permission_prefix = 'batch.inventory' @@ -130,23 +127,24 @@ class InventoryBatchRowViews(APIBatchRowView): def normalize(self, row): batch = row.batch - data = super(InventoryBatchRowViews, self).normalize(row) + data = super().normalize(row) + app = self.get_rattail_app() 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 data['size'] = row.size data['full_description'] = row.product.full_description if row.product else row.description data['image_url'] = pod.get_image_url(self.rattail_config, row.upc) if row.upc else None - data['case_quantity'] = pretty_quantity(row.case_quantity or 1) + data['case_quantity'] = app.render_quantity(row.case_quantity or 1) data['cases'] = row.cases data['units'] = row.units data['unit_uom'] = 'LB' if row.product and row.product.weighed else 'EA' data['quantity_display'] = "{} {}".format( - pretty_quantity(row.cases or row.units), + app.render_quantity(row.cases or row.units), 'CS' if row.cases else data['unit_uom']) data['allow_cases'] = self.batch_handler.allow_cases(batch) @@ -174,7 +172,17 @@ class InventoryBatchRowViews(APIBatchRowView): data['units'] = decimal.Decimal(data['units']) # update row per usual - row = super(InventoryBatchRowViews, self).update_object(row, data) + try: + row = super().update_object(row, data) + except sa.exc.DataError as error: + # detect when user scans barcode for cases/units field + if hasattr(error, 'orig'): + orig = type(error.orig) + if hasattr(orig, '__name__'): + # nb. this particular error is from psycopg2 + if orig.__name__ == 'NumericValueOutOfRange': + return {'error': "Numeric value out of range"} + raise return row 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 be8f2dc2..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. # @@ -24,17 +24,16 @@ Rattail config extension for Tailbone """ -from __future__ import unicode_literals, absolute_import - 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: @@ -51,9 +50,12 @@ class ConfigExtension(BaseExtension): configure_session(config, Session) # provide default theme selection - config.setdefault('tailbone', 'themes', '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') @@ -63,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 1c73635a..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. # @@ -34,35 +34,38 @@ from webhelpers2.html import HTML class Diff(object): """ Core diff class. In sore need of documentation. + + You must provide the old and new data sets, and the set of + relevant fields as well, if they cannot be easily introspected. + + :param old_data: Dict of "old" data values. + + :param new_data: Dict of "old" data values. + + :param fields: Sequence of relevant field names. Note that + both data dicts are expected to have keys which match these + field names. If you do not specify the fields then they + will (hopefully) be introspected from the old or new data + sets; however this will not work if they are both empty. + + :param monospace: If true, this flag will cause the value + columns to be rendered in monospace font. This is assumed + to be helpful when comparing "raw" data values which are + shown as e.g. ``repr(val)``. + + :param enums: Optional dict of enums for use when displaying field + values. If specified, keys should be field names and values + should be enum dicts. """ - def __init__(self, old_data, new_data, columns=None, fields=None, + def __init__(self, old_data, new_data, columns=None, fields=None, enums=None, render_field=None, render_value=None, nature='dirty', monospace=False, extra_row_attrs=None): - """ - Constructor. You must provide the old and new data sets, and - the set of relevant fields as well, if they cannot be easily - introspected. - - :param old_data: Dict of "old" data values. - - :param new_data: Dict of "old" data values. - - :param fields: Sequence of relevant field names. Note that - both data dicts are expected to have keys which match these - field names. If you do not specify the fields then they - will (hopefully) be introspected from the old or new data - sets; however this will not work if they are both empty. - - :param monospace: If true, this flag will cause the value - columns to be rendered in monospace font. This is assumed - to be helpful when comparing "raw" data values which are - shown as e.g. ``repr(val)``. - """ self.old_data = old_data self.new_data = new_data self.columns = columns or ["field name", "old value", "new value"] self.fields = fields or self.make_fields() + self.enums = enums or {} self._render_field = render_field or self.render_field_default self.render_value = render_value or self.render_value_default self.nature = nature @@ -92,7 +95,7 @@ class Diff(object): for the given field. May be an empty string, or a snippet of HTML attribute syntax, e.g.: - .. code-highlight:: none + .. code-block:: none class="diff" foo="bar" @@ -132,7 +135,21 @@ class Diff(object): class VersionDiff(Diff): """ - Special diff class, for use with version history views + Special diff class, for use with version history views. Note that + while based on :class:`Diff`, this class uses a different + signature for the constructor. + + :param version: Reference to a Continuum version record (object). + + :param \*args: Typical usage will not require positional args + beyond the ``version`` param, in which case ``old_data`` and + ``new_data`` params will be auto-determined based on the + ``version``. But if you specify positional args then nothing + automatic is done, they are passed as-is to the parent + :class:`Diff` constructor. + + :param \*\*kwargs: Remaining kwargs are passed as-is to the + :class:`Diff` constructor. """ def __init__(self, version, *args, **kwargs): @@ -176,9 +193,40 @@ class VersionDiff(Diff): if field not in unwanted] def render_version_value(self, field, value, version): + """ + Render the cell value text for the given version/field info. + + Note that this method is used to render both sides of the diff + (before and after values). + + :param field: Name of the field, as string. + + :param value: Raw value for the field, as obtained from ``version``. + + :param version: Reference to the Continuum version object. + + :returns: Rendered text as string, or ``None``. + """ text = HTML.tag('span', c=[repr(value)], style='font-family: monospace;') + # assume the enum display is all we need, if enum exists for the field + if field in self.enums: + + # but skip the enum display if None + display = self.enums[field].get(value) + if display is None and value is None: + return text + + # otherwise show enum display to the right of raw value + display = self.enums[field].get(value, str(value)) + return HTML.tag('span', c=[ + text, + HTML.tag('span', c=[display], + style='margin-left: 2rem; font-style: italic; font-weight: bold;'), + ]) + + # next we look for a relationship and may render the foreign object for prop in self.mapper.relationships: if prop.uselist: continue @@ -195,7 +243,7 @@ class VersionDiff(Diff): ref = getattr(version, prop.key) if ref: - ref = ref.version_parent + ref = getattr(ref, 'version_parent', None) if ref: return HTML.tag('span', c=[ text, @@ -222,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): <tailbone-form :configure-fields-help="configureFieldsHelp"> </tailbone-form> """ - 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 + `<tailbone-form>` 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 ``<b-field>`` + 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 <b-field> (*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 <template #label> as final result - label_template = HTML.tag('template', c=label_contents, - **{'#label': 1}) - label_template = label_template.replace( - HTML.literal('<template #label="1"'), - HTML.literal('<template #label')) + # only declare label template if it's complex + html = [html] + # TODO: figure out why complex label does not work for oruga + if self.request.use_oruga: + attrs['label'] = label + else: + if len(label_contents) > 1: + + # nb. must apply hack to get <template #label> as final result + label_template = HTML.tag('template', c=label_contents, + **{'#label': 1}) + label_template = label_template.replace( + HTML.literal('<template #label="1"'), + HTML.literal('<template #label')) + html.insert(0, label_template) + + else: # simple label + attrs['label'] = label # and finally wrap it all in a <b-field> - return HTML.tag('b-field', c=[label_template, html], **attrs) + return HTML.tag('b-field', c=html, **attrs) elif field: # hidden field @@ -1085,6 +1224,18 @@ class Form(object): # TODO: again, why does serialize() not return literal? return HTML.literal(field.serialize()) + # TODO: this was copied from wuttaweb; can remove when we align + # Form class structure + def render_vue_finalize(self): + """ """ + set_data = f"{self.vue_component}.data = function() {{ return {self.vue_component}Data }}" + make_component = f"Vue.component('{self.vue_tagname}', {self.vue_component})" + return HTML.tag('script', c=['\n', + HTML.literal(set_data), + '\n', + HTML.literal(make_component), + '\n']) + def render_field_readonly(self, field_name, **kwargs): """ Render the given field completely, but in read-only fashion. @@ -1095,20 +1246,30 @@ class Form(object): if field_name not in self.fields: return '' - # TODO: fair bit of duplication here, should merge with deform.mako label = kwargs.get('label') if not label: label = self.get_label(field_name) - label = HTML.tag('label', label, for_=field_name) - field = self.render_field_value(field_name) or '' - field_div = HTML.tag('div', class_='field', c=[field]) - contents = [label, field_div] - if self.has_helptext(field_name): - contents.append(HTML.tag('span', class_='instructions', - c=[self.render_helptext(field_name)])) + value = self.render_field_value(field_name) or '' - return HTML.tag('div', class_='field-wrapper {}'.format(field_name), c=contents) + if not self.request.use_oruga: + + label = HTML.tag('label', label, for_=field_name) + field_div = HTML.tag('div', class_='field', c=[value]) + contents = [label, field_div] + + if self.has_helptext(field_name): + contents.append(HTML.tag('span', class_='instructions', + c=[self.render_helptext(field_name)])) + + return HTML.tag('div', class_='field-wrapper {}'.format(field_name), c=contents) + + # nb. for some reason we must wrap once more for oruga, + # otherwise it splits up the field?! + value = HTML.tag('span', c=[value]) + + # oruga uses <o-field> + return HTML.tag('o-field', label=label, c=[value], **{':horizontal': 'true'}) def render_field_value(self, field_name): record = self.model_instance @@ -1132,7 +1293,8 @@ class Form(object): value = self.obtain_value(record, field_name) if value is None: return "" - value = localtime(self.request.rattail_config, value) + app = self.request.rattail_config.get_app() + value = app.localtime(value) return raw_datetime(self.request.rattail_config, value) def render_duration(self, record, field_name): @@ -1161,7 +1323,8 @@ class Form(object): value = self.obtain_value(obj, field) if value is None: return "" - return pretty_quantity(value) + app = self.request.rattail_config.get_app() + return app.render_quantity(value) def render_percent(self, obj, field): app = self.request.rattail_config.get_app() @@ -1212,12 +1375,19 @@ class Form(object): def obtain_value(self, record, field_name): if record: + + if isinstance(record, dict): + return record[field_name] + + try: + return getattr(record, field_name) + except AttributeError: + pass + try: return record[field_name] - except KeyError: - return None except TypeError: - return getattr(record, field_name, None) + pass # TODO: is this always safe to do? elif self.defaults and field_name in self.defaults: @@ -1271,30 +1441,6 @@ class Form(object): return False -class FieldList(list): - """ - Convenience wrapper for a form's field list. - """ - - def insert_before(self, field, newfield): - if field in self: - i = self.index(field) - self.insert(i, newfield) - else: - log.warning("field '%s' not found, will append new field: %s", - field, newfield) - self.append(newfield) - - def insert_after(self, field, newfield): - if field in self: - i = self.index(field) - self.insert(i + 1, newfield) - else: - log.warning("field '%s' not found, will append new field: %s", - field, newfield) - self.append(newfield) - - @colander.deferred def upload_widget(node, kw): request = kw['request'] diff --git a/tailbone/forms/widgets.py b/tailbone/forms/widgets.py index 0b8d3dc9..8c16726d 100644 --- a/tailbone/forms/widgets.py +++ b/tailbone/forms/widgets.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,6 +27,7 @@ Form Widgets import json import datetime import decimal +import re import colander from deform import widget as dfwidget @@ -40,6 +41,7 @@ class ReadonlyWidget(dfwidget.HiddenWidget): readonly = True def serialize(self, field, cstruct, **kw): + """ """ if cstruct in (colander.null, None): cstruct = '' # TODO: is this hacky? @@ -56,11 +58,11 @@ class NumberInputWidget(dfwidget.TextInputWidget): class NumericInputWidget(NumberInputWidget): """ - This widget only supports Buefy themes for now. It uses a - ``<numeric-input>`` component, which will leverage the ``numeric.js`` - functions to ensure user doesn't enter any non-numeric values. Note that - this still uses a normal "text" input on the HTML side, as opposed to a - "number" input, since the latter is a bit ugly IMHO. + This widget uses a ``<numeric-input>`` component, which will + leverage the ``numeric.js`` functions to ensure user doesn't enter + any non-numeric values. Note that this still uses a normal "text" + input on the HTML side, as opposed to a "number" input, since the + latter is a bit ugly IMHO. """ template = 'numericinput' allow_enter = True @@ -77,15 +79,17 @@ class PercentInputWidget(dfwidget.TextInputWidget): autocomplete = 'off' def serialize(self, field, cstruct, **kw): + """ """ if cstruct not in (colander.null, None): # convert "traditional" value to "human-friendly" value = decimal.Decimal(cstruct) * 100 value = value.quantize(decimal.Decimal('0.001')) cstruct = str(value) - return super(PercentInputWidget, self).serialize(field, cstruct, **kw) + return super().serialize(field, cstruct, **kw) def deserialize(self, field, pstruct): - pstruct = super(PercentInputWidget, self).deserialize(field, pstruct) + """ """ + pstruct = super().deserialize(field, pstruct) if pstruct is colander.null: return colander.null # convert "human-friendly" value to "traditional" @@ -108,6 +112,7 @@ class CasesUnitsWidget(dfwidget.Widget): one_amount_only = False def serialize(self, field, cstruct, **kw): + """ """ if cstruct in (colander.null, None): cstruct = '' readonly = kw.get('readonly', self.readonly) @@ -118,6 +123,7 @@ class CasesUnitsWidget(dfwidget.Widget): return field.renderer(template, **values) def deserialize(self, field, pstruct): + """ """ from tailbone.forms.types import ProductQuantity if pstruct is colander.null: @@ -148,6 +154,7 @@ class DynamicCheckboxWidget(dfwidget.CheckboxWidget): template = 'checkbox_dynamic' +# TODO: deprecate / remove this class PlainSelectWidget(dfwidget.SelectWidget): template = 'select_plain' @@ -166,7 +173,7 @@ class CustomSelectWidget(dfwidget.SelectWidget): self.extra_template_values.update(kw) def get_template_values(self, field, cstruct, kw): - values = super(CustomSelectWidget, self).get_template_values(field, cstruct, kw) + values = super().get_template_values(field, cstruct, kw) if hasattr(self, 'extra_template_values'): values.update(self.extra_template_values) return values @@ -209,6 +216,7 @@ class JQueryDateWidget(dfwidget.DateInputWidget): ) def serialize(self, field, cstruct, **kw): + """ """ if cstruct in (colander.null, None): cstruct = '' readonly = kw.get('readonly', self.readonly) @@ -242,15 +250,26 @@ class FalafelDateTimeWidget(dfwidget.DateTimeInputWidget): """ template = 'datetime_falafel' + new_pattern = re.compile(r'^\d\d?:\d\d:\d\d [AP]M$') + def serialize(self, field, cstruct, **kw): + """ """ readonly = kw.get('readonly', self.readonly) values = self.get_template_values(field, cstruct, kw) template = self.readonly_template if readonly else self.template return field.renderer(template, **values) def deserialize(self, field, pstruct): + """ """ if pstruct == '': return colander.null + + # nb. we now allow '4:20:00 PM' on the widget side, but the + # true node needs it to be '16:20:00' instead + if self.new_pattern.match(pstruct['time']): + time = datetime.datetime.strptime(pstruct['time'], '%I:%M:%S %p') + pstruct['time'] = time.strftime('%H:%M:%S') + return pstruct @@ -261,6 +280,7 @@ class FalafelTimeWidget(dfwidget.TimeInputWidget): template = 'time_falafel' def deserialize(self, field, pstruct): + """ """ if pstruct == '': return colander.null return pstruct @@ -288,6 +308,7 @@ class JQueryAutocompleteWidget(dfwidget.AutocompleteInputWidget): options = None def serialize(self, field, cstruct, **kw): + """ """ if 'delay' in kw or getattr(self, 'delay', None): raise ValueError( 'AutocompleteWidget does not support *delay* parameter ' @@ -316,6 +337,23 @@ class JQueryAutocompleteWidget(dfwidget.AutocompleteInputWidget): return field.renderer(template, **tmpl_values) +class FileUploadWidget(dfwidget.FileUploadWidget): + """ + Widget to handle file upload. Must override to add ``use_oruga`` + to field template context. + """ + + def __init__(self, *args, **kwargs): + self.request = kwargs.pop('request') + super().__init__(*args, **kwargs) + + def get_template_values(self, field, cstruct, kw): + values = super().get_template_values(field, cstruct, kw) + if self.request: + values['use_oruga'] = self.request.use_oruga + return values + + class MultiFileUploadWidget(dfwidget.FileUploadWidget): """ Widget to handle multiple (arbitrary number) of file uploads. @@ -324,6 +362,7 @@ class MultiFileUploadWidget(dfwidget.FileUploadWidget): requirements = () def serialize(self, field, cstruct, **kw): + """ """ if cstruct in (colander.null, None): cstruct = [] @@ -339,6 +378,7 @@ class MultiFileUploadWidget(dfwidget.FileUploadWidget): return field.renderer(template, **values) def deserialize(self, field, pstruct): + """ """ if pstruct is colander.null: return colander.null @@ -359,6 +399,7 @@ class MultiFileUploadWidget(dfwidget.FileUploadWidget): return files_data def deserialize_upload(self, upload): + """ """ # nb. this logic was copied from parent class and adapted # to allow for multiple files. needs some more love. @@ -428,13 +469,16 @@ def make_customer_widget(request, **kwargs): class CustomerAutocompleteWidget(JQueryAutocompleteWidget): """ - Autocomplete widget for a Customer reference field. + Autocomplete widget for a + :class:`~rattail:rattail.db.model.customers.Customer` reference + field. """ def __init__(self, request, *args, **kwargs): - super(CustomerAutocompleteWidget, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) self.request = request - model = self.request.rattail_config.get_model() + app = self.request.rattail_config.get_app() + model = app.model # must figure out URL providing autocomplete service if 'service_url' not in kwargs: @@ -452,26 +496,30 @@ class CustomerAutocompleteWidget(JQueryAutocompleteWidget): self.input_callback = input_handler def serialize(self, field, cstruct, **kw): - + """ """ # fetch customer to provide button label, if we have a value if cstruct: - model = self.request.rattail_config.get_model() + app = self.request.rattail_config.get_app() + model = app.model customer = Session.get(model.Customer, cstruct) if customer: self.field_display = str(customer) - return super(CustomerAutocompleteWidget, self).serialize( + return super().serialize( field, cstruct, **kw) class CustomerDropdownWidget(dfwidget.SelectWidget): """ - Dropdown widget for a Customer reference field. + Dropdown widget for a + :class:`~rattail:rattail.db.model.customers.Customer` reference + field. """ def __init__(self, request, *args, **kwargs): - super(CustomerDropdownWidget, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) self.request = request + app = self.request.rattail_config.get_app() # must figure out dropdown values, if they weren't given if 'values' not in kwargs: @@ -483,10 +531,8 @@ class CustomerDropdownWidget(dfwidget.SelectWidget): customers = customers() else: # default customer list - model = self.request.rattail_config.get_model() - customers = Session.query(model.Customer)\ - .order_by(model.Customer.name)\ - .all() + customers = app.get_clientele_handler()\ + .get_all_customers(Session()) # convert customer list to option values self.values = [(c.uuid, c.name) @@ -508,7 +554,8 @@ class DepartmentWidget(dfwidget.SelectWidget): def __init__(self, request, **kwargs): if 'values' not in kwargs: - model = request.rattail_config.get_model() + app = request.rattail_config.get_app() + model = app.model departments = Session.query(model.Department)\ .order_by(model.Department.number) values = [(dept.uuid, str(dept)) @@ -517,7 +564,7 @@ class DepartmentWidget(dfwidget.SelectWidget): values.insert(0, ('', "(none)")) kwargs['values'] = values - super(DepartmentWidget, self).__init__(**kwargs) + super().__init__(**kwargs) def make_vendor_widget(request, **kwargs): @@ -548,9 +595,10 @@ class VendorAutocompleteWidget(JQueryAutocompleteWidget): """ def __init__(self, request, *args, **kwargs): - super(VendorAutocompleteWidget, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) self.request = request - model = self.request.rattail_config.get_model() + app = self.request.rattail_config.get_app() + model = app.model # must figure out URL providing autocomplete service if 'service_url' not in kwargs: @@ -568,15 +616,16 @@ class VendorAutocompleteWidget(JQueryAutocompleteWidget): # self.input_callback = input_handler def serialize(self, field, cstruct, **kw): - + """ """ # fetch vendor to provide button label, if we have a value if cstruct: - model = self.request.rattail_config.get_model() + app = self.request.rattail_config.get_app() + model = app.model vendor = Session.get(model.Vendor, cstruct) if vendor: self.field_display = str(vendor) - return super(VendorAutocompleteWidget, self).serialize( + return super().serialize( field, cstruct, **kw) @@ -586,7 +635,7 @@ class VendorDropdownWidget(dfwidget.SelectWidget): """ def __init__(self, request, *args, **kwargs): - super(VendorDropdownWidget, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) self.request = request # must figure out dropdown values, if they weren't given @@ -599,7 +648,8 @@ class VendorDropdownWidget(dfwidget.SelectWidget): vendors = vendors() else: # default vendor list - model = self.request.rattail_config.get_model() + app = self.request.rattail_config.get_app() + model = app.model vendors = Session.query(model.Vendor)\ .order_by(model.Vendor.name)\ .all() diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index 7a0d00e3..56b97b86 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/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. # @@ -24,22 +24,24 @@ Core Grid Classes """ -from urllib.parse import urlencode -import warnings +import inspect import logging +import warnings +from urllib.parse import urlencode import sqlalchemy as sa from sqlalchemy import orm +from wuttjamaican.util import UNSPECIFIED from rattail.db.types import GPCType -from rattail.util import prettify, pretty_boolean, pretty_quantity -from rattail.time import localtime +from rattail.util import prettify, pretty_boolean -import webhelpers2_grid from pyramid.renderers import render from webhelpers2.html import HTML, tags from paginate_sqlalchemy import SqlalchemyOrmPage +from wuttaweb.grids import Grid as WuttaGrid, GridAction as WuttaGridAction, SortInfo +from wuttaweb.util import FieldList from . import filters as gridfilters from tailbone.db import Session from tailbone.util import raw_datetime @@ -48,23 +50,17 @@ from tailbone.util import raw_datetime log = logging.getLogger(__name__) -class FieldList(list): - """ - Convenience wrapper for a field list. +class Grid(WuttaGrid): """ + Base class for all grids. - def insert_before(self, field, newfield): - i = self.index(field) - self.insert(i, newfield) + This is now a subclass of + :class:`wuttaweb:wuttaweb.grids.base.Grid`, and exists to add + customizations which have traditionally been part of Tailbone. - def insert_after(self, field, newfield): - i = self.index(field) - self.insert(i + 1, newfield) - - -class Grid(object): - """ - Core grid class. In sore need of documentation. + Some of these customizations are still undocumented. Some will + eventually be moved to the upstream/parent class, and possibly + some will be removed outright. What docs we have, are shown here. .. _Buefy docs: https://buefy.org/documentation/table/ @@ -187,31 +183,92 @@ class Grid(object): grid.row_uuid_getter = fake_uuid """ - def __init__(self, key, data, columns=None, width='auto', request=None, - model_class=None, model_title=None, model_title_plural=None, - enums={}, labels={}, assume_local_times=False, renderers={}, invisible=[], - raw_renderers={}, - extra_row_class=None, linked_columns=[], url='#', - joiners={}, filterable=False, filters={}, use_byte_string_filters=False, - searchable={}, - sortable=False, sorters={}, default_sortkey=None, default_sortdir='asc', - pageable=False, default_pagesize=None, default_page=1, - checkboxes=False, checked=None, check_handler=None, check_all_handler=None, - checkable=None, row_uuid_getter=None, - clicking_row_checks_box=False, click_handlers=None, - main_actions=[], more_actions=[], delete_speedbump=False, - ajax_data_url=None, component='tailbone-grid', - expose_direct_link=False, - **kwargs): + def __init__( + self, + request, + key=None, + data=None, + width='auto', + model_title=None, + model_title_plural=None, + enums={}, + assume_local_times=False, + invisible=[], + raw_renderers={}, + extra_row_class=None, + url='#', + use_byte_string_filters=False, + checkboxes=False, + checked=None, + check_handler=None, + check_all_handler=None, + checkable=None, + row_uuid_getter=None, + clicking_row_checks_box=False, + click_handlers=None, + main_actions=[], + more_actions=[], + delete_speedbump=False, + ajax_data_url=None, + expose_direct_link=False, + **kwargs, + ): + if 'component' in kwargs: + warnings.warn("component param is deprecated for Grid(); " + "please use vue_tagname param instead", + DeprecationWarning, stacklevel=2) + kwargs.setdefault('vue_tagname', kwargs.pop('component')) - self.key = key - self.data = data - self.columns = FieldList(columns) if columns is not None else None - self.width = width - self.request = request - self.model_class = model_class - if self.model_class and self.columns is None: - self.columns = self.make_columns() + if 'default_sortkey' in kwargs: + warnings.warn("default_sortkey param is deprecated for Grid(); " + "please use sort_defaults param instead", + DeprecationWarning, stacklevel=2) + if 'default_sortdir' in kwargs: + warnings.warn("default_sortdir param is deprecated for Grid(); " + "please use sort_defaults param instead", + DeprecationWarning, stacklevel=2) + if 'default_sortkey' in kwargs or 'default_sortdir' in kwargs: + sortkey = kwargs.pop('default_sortkey', None) + sortdir = kwargs.pop('default_sortdir', 'asc') + if sortkey: + kwargs.setdefault('sort_defaults', [(sortkey, sortdir)]) + + if 'pageable' in kwargs: + warnings.warn("pageable param is deprecated for Grid(); " + "please use paginated param instead", + DeprecationWarning, stacklevel=2) + kwargs.setdefault('paginated', kwargs.pop('pageable')) + + if 'default_pagesize' in kwargs: + warnings.warn("default_pagesize param is deprecated for Grid(); " + "please use pagesize param instead", + DeprecationWarning, stacklevel=2) + kwargs.setdefault('pagesize', kwargs.pop('default_pagesize')) + + if 'default_page' in kwargs: + warnings.warn("default_page param is deprecated for Grid(); " + "please use page param instead", + DeprecationWarning, stacklevel=2) + kwargs.setdefault('page', kwargs.pop('default_page')) + + if 'searchable' in kwargs: + warnings.warn("searchable param is deprecated for Grid(); " + "please use searchable_columns param instead", + DeprecationWarning, stacklevel=2) + kwargs.setdefault('searchable_columns', kwargs.pop('searchable')) + + # TODO: this should not be needed once all templates correctly + # reference grid.vue_component etc. + kwargs.setdefault('vue_tagname', 'tailbone-grid') + + # nb. these must be set before super init, as they are + # referenced when constructing filters + self.assume_local_times = assume_local_times + self.use_byte_string_filters = use_byte_string_filters + + kwargs['key'] = key + kwargs['data'] = data + super().__init__(request, **kwargs) self.model_title = model_title if not self.model_title and self.model_class and hasattr(self.model_class, 'get_model_title'): @@ -224,32 +281,13 @@ class Grid(object): if not self.model_title_plural: self.model_title_plural = '{}s'.format(self.model_title) + self.width = width self.enums = enums or {} - - self.labels = labels or {} - self.assume_local_times = assume_local_times - self.renderers = self.make_default_renderers(renderers or {}) + self.renderers = self.make_default_renderers(self.renderers) self.raw_renderers = raw_renderers or {} self.invisible = invisible or [] self.extra_row_class = extra_row_class - self.linked_columns = linked_columns or [] self.url = url - self.joiners = joiners or {} - - self.filterable = filterable - self.use_byte_string_filters = use_byte_string_filters - self.filters = self.make_filters(filters) - - self.searchable = searchable or {} - - self.sortable = sortable - self.sorters = self.make_sorters(sorters) - self.default_sortkey = default_sortkey - self.default_sortdir = default_sortdir - - self.pageable = pageable - self.default_pagesize = default_pagesize - self.default_page = default_page self.checkboxes = checkboxes self.checked = checked @@ -263,43 +301,104 @@ class Grid(object): self.click_handlers = click_handlers or {} - self.main_actions = main_actions or [] - self.more_actions = more_actions or [] self.delete_speedbump = delete_speedbump if ajax_data_url: self.ajax_data_url = ajax_data_url elif self.request: - self.ajax_data_url = self.request.current_route_url(_query=None) + self.ajax_data_url = self.request.path_url else: self.ajax_data_url = '' - self.component = component + self.main_actions = main_actions or [] + if self.main_actions: + warnings.warn("main_actions param is deprecated for Grdi(); " + "please use actions param instead", + DeprecationWarning, stacklevel=2) + self.actions.extend(self.main_actions) + self.more_actions = more_actions or [] + if self.more_actions: + warnings.warn("more_actions param is deprecated for Grdi(); " + "please use actions param instead", + DeprecationWarning, stacklevel=2) + self.actions.extend(self.more_actions) + self.expose_direct_link = expose_direct_link self._whgrid_kwargs = kwargs + @property + def component(self): + """ """ + warnings.warn("Grid.component is deprecated; " + "please use vue_tagname instead", + DeprecationWarning, stacklevel=2) + return self.vue_tagname + @property def component_studly(self): - words = self.component.split('-') - return ''.join([word.capitalize() for word in words]) + """ """ + warnings.warn("Grid.component_studly is deprecated; " + "please use vue_component instead", + DeprecationWarning, stacklevel=2) + return self.vue_component - def make_columns(self): - """ - Return a default list of columns, based on :attr:`model_class`. - """ - if not self.model_class: - raise ValueError("Must define model_class to use make_columns()") + def get_default_sortkey(self): + """ """ + warnings.warn("Grid.default_sortkey is deprecated; " + "please use Grid.sort_defaults instead", + DeprecationWarning, stacklevel=2) + if self.sort_defaults: + return self.sort_defaults[0].sortkey - mapper = orm.class_mapper(self.model_class) - return [prop.key for prop in mapper.iterate_properties] + def set_default_sortkey(self, value): + """ """ + warnings.warn("Grid.default_sortkey is deprecated; " + "please use Grid.sort_defaults instead", + DeprecationWarning, stacklevel=2) + if self.sort_defaults: + info = self.sort_defaults[0] + self.sort_defaults[0] = SortInfo(value, info.sortdir) + else: + self.sort_defaults = [SortInfo(value, 'asc')] - def remove(self, *keys): - """ - This *removes* some column(s) from the grid, altogether. - """ - for key in keys: - if key in self.columns: - self.columns.remove(key) + default_sortkey = property(get_default_sortkey, set_default_sortkey) + + def get_default_sortdir(self): + """ """ + warnings.warn("Grid.default_sortdir is deprecated; " + "please use Grid.sort_defaults instead", + DeprecationWarning, stacklevel=2) + if self.sort_defaults: + return self.sort_defaults[0].sortdir + + def set_default_sortdir(self, value): + """ """ + warnings.warn("Grid.default_sortdir is deprecated; " + "please use Grid.sort_defaults instead", + DeprecationWarning, stacklevel=2) + if self.sort_defaults: + info = self.sort_defaults[0] + self.sort_defaults[0] = SortInfo(info.sortkey, value) + else: + raise ValueError("cannot set default_sortdir without default_sortkey") + + default_sortdir = property(get_default_sortdir, set_default_sortdir) + + def get_pageable(self): + """ """ + warnings.warn("Grid.pageable is deprecated; " + "please use Grid.paginated instead", + DeprecationWarning, stacklevel=2) + return self.paginated + + def set_pageable(self, value): + """ """ + warnings.warn("Grid.pageable is deprecated; " + "please use Grid.paginated instead", + DeprecationWarning, stacklevel=2) + self.paginated = value + + pageable = property(get_pageable, set_pageable) def hide_column(self, key): """ @@ -333,9 +432,6 @@ class Grid(object): if key in self.invisible: self.invisible.remove(key) - def append(self, field): - self.columns.append(field) - def insert_before(self, field, newfield): self.columns.insert_before(field, newfield) @@ -347,62 +443,54 @@ class Grid(object): self.remove(oldfield) def set_joiner(self, key, joiner): + """ """ if joiner is None: - self.joiners.pop(key, None) + warnings.warn("specifying None is deprecated for Grid.set_joiner(); " + "please use Grid.remove_joiner() instead", + DeprecationWarning, stacklevel=2) + self.remove_joiner(key) else: - self.joiners[key] = joiner + super().set_joiner(key, joiner) def set_sorter(self, key, *args, **kwargs): - if len(args) == 1 and args[0] is None: - self.remove_sorter(key) + """ """ + + if len(args) == 1: + if kwargs: + warnings.warn("kwargs are ignored for Grid.set_sorter(); " + "please refactor your code accordingly", + DeprecationWarning, stacklevel=2) + if args[0] is None: + warnings.warn("specifying None is deprecated for Grid.set_sorter(); " + "please use Grid.remove_sorter() instead", + DeprecationWarning, stacklevel=2) + self.remove_sorter(key) + else: + super().set_sorter(key, args[0]) + + elif len(args) == 0: + super().set_sorter(key) + else: + warnings.warn("multiple args are deprecated for Grid.set_sorter(); " + "please refactor your code accordingly", + DeprecationWarning, stacklevel=2) self.sorters[key] = self.make_sorter(*args, **kwargs) - def remove_sorter(self, key): - self.sorters.pop(key, None) - - def set_sort_defaults(self, sortkey, sortdir='asc'): - self.default_sortkey = sortkey - self.default_sortdir = sortdir - def set_filter(self, key, *args, **kwargs): - if len(args) == 1 and args[0] is None: - self.remove_filter(key) - else: - if 'label' not in kwargs and key in self.labels: - kwargs['label'] = self.labels[key] - self.filters[key] = self.make_filter(key, *args, **kwargs) + """ """ + if len(args) == 1: + if args[0] is None: + warnings.warn("specifying None is deprecated for Grid.set_filter(); " + "please use Grid.remove_filter() instead", + DeprecationWarning, stacklevel=2) + self.remove_filter(key) + return - def set_searchable(self, key, searchable=True): - if searchable: - self.searchable[key] = True - else: - self.searchable.pop(key, None) - - def is_searchable(self, key): - return self.searchable.get(key, False) - - def remove_filter(self, key): - self.filters.pop(key, None) - - def set_label(self, key, label, column_only=False): - self.labels[key] = label - if not column_only and key in self.filters: - self.filters[key].label = label - - def get_label(self, key): - """ - Returns the label text for given field key. - """ - return self.labels.get(key, prettify(key)) - - def set_link(self, key, link=True): - if link: - if key not in self.linked_columns: - self.linked_columns.append(key) - else: # unlink - if self.linked_columns and key in self.linked_columns: - self.linked_columns.remove(key) + # TODO: our make_filter() signature differs from upstream, + # so must call it explicitly instead of delegating to super + kwargs.setdefault('label', self.get_label(key)) + self.filters[key] = self.make_filter(key, *args, **kwargs) def set_click_handler(self, key, handler): if handler: @@ -413,9 +501,6 @@ class Grid(object): def has_click_handler(self, key): return key in self.click_handlers - def set_renderer(self, key, renderer): - self.renderers[key] = renderer - def set_raw_renderer(self, key, renderer): """ Set or remove the "raw" renderer for the given field. @@ -478,12 +563,23 @@ class Grid(object): :returns: The value, or ``None`` if no value was found. """ + # TODO: this seems a little hacky, is there a better way? + # nb. this may only be relevant for import/export batch view? + if isinstance(obj, sa.engine.Row): + return obj._mapping[column_name] + + if isinstance(obj, dict): + return obj[column_name] + + try: + return getattr(obj, column_name) + except AttributeError: + pass + try: return obj[column_name] - except KeyError: - pass except TypeError: - return getattr(obj, column_name, None) + pass def render_currency(self, obj, column_name): value = self.obtain_value(obj, column_name) @@ -503,7 +599,8 @@ class Grid(object): value = self.obtain_value(obj, column_name) if value is None: return "" - value = localtime(self.request.rattail_config, value) + app = self.request.rattail_config.get_app() + value = app.localtime(value) return raw_datetime(self.request.rattail_config, value) def render_enum(self, obj, column_name): @@ -528,7 +625,8 @@ class Grid(object): def render_quantity(self, obj, column_name): value = self.obtain_value(obj, column_name) - return pretty_quantity(value) + app = self.request.rattail_config.get_app() + return app.render_quantity(value) def render_duration(self, obj, column_name): seconds = self.obtain_value(obj, column_name) @@ -596,6 +694,14 @@ class Grid(object): def actions_column_format(self, column_number, row_number, item): return HTML.td(self.render_actions(item, row_number), class_='actions') + # TODO: upstream should handle this.. + def make_backend_filters(self, filters=None): + """ """ + final = self.get_default_filters() + if filters: + final.update(filters) + return final + def get_default_filters(self): """ Returns the default set of filters provided by the grid. @@ -620,16 +726,6 @@ class Grid(object): filters[prop.key] = self.make_filter(prop.key, column) return filters - def make_filters(self, filters=None): - """ - Returns an initial set of filters which will be available to the grid. - The grid itself may or may not provide some default filters, and the - ``filters`` kwarg may contain additions and/or overrides. - """ - if filters: - return filters - return self.get_default_filters() - def make_filter(self, key, column, **kwargs): """ Make a filter suitable for use with the given column. @@ -677,95 +773,103 @@ class Grid(object): if filtr.active: yield filtr - def make_sorters(self, sorters=None): - """ - Returns an initial set of sorters which will be available to the grid. - The grid itself may or may not provide some default sorters, and the - ``sorters`` kwarg may contain additions and/or overrides. - """ - sorters, updates = {}, sorters - if self.model_class: - mapper = orm.class_mapper(self.model_class) - for prop in mapper.iterate_properties: - if isinstance(prop, orm.ColumnProperty) and not prop.key.endswith('uuid'): - sorters[prop.key] = self.make_sorter(prop) - if updates: - sorters.update(updates) - return sorters - - def make_sorter(self, model_property): - """ - Returns a function suitable for a sort map callable, with typical logic - built in for sorting applied to ``field``. - """ - class_ = getattr(model_property, 'class_', self.model_class) - column = getattr(class_, model_property.key) - - def sorter(query, direction): - # TODO: this seems hacky..normally we expect a true query - # of course, but in some cases it may be a list instead. - # if so then we can't actually sort - if isinstance(query, list): - return query - return query.order_by(getattr(column, direction)()) - - sorter._class = class_ - sorter._column = column - - return sorter - def make_simple_sorter(self, key, foldcase=False): - """ - Returns a function suitable for a sort map callable, with typical logic - built in for sorting a data set comprised of dicts, on the given key. - """ - if foldcase: - keyfunc = lambda v: v[key].lower() - else: - keyfunc = lambda v: v[key] - return lambda q, d: sorted(q, key=keyfunc, reverse=d == 'desc') + """ """ + warnings.warn("Grid.make_simple_sorter() is deprecated; " + "please use Grid.make_sorter() instead", + DeprecationWarning, stacklevel=2) + return self.make_sorter(key, foldcase=foldcase) + + def get_pagesize_options(self, default=None): + """ """ + # let upstream check config + options = super().get_pagesize_options(default=UNSPECIFIED) + if options is not UNSPECIFIED: + return options + + # fallback to legacy config + options = self.config.get_list('tailbone.grid.pagesize_options') + if options: + warnings.warn("tailbone.grid.pagesize_options setting is deprecated; " + "please set wuttaweb.grids.default_pagesize_options instead", + DeprecationWarning) + options = [int(size) for size in options + if size.isdigit()] + if options: + return options + + if default: + return default + + # use upstream default + return super().get_pagesize_options() + + def get_pagesize(self, default=None): + """ """ + # let upstream check config + pagesize = super().get_pagesize(default=UNSPECIFIED) + if pagesize is not UNSPECIFIED: + return pagesize + + # fallback to legacy config + pagesize = self.config.get_int('tailbone.grid.default_pagesize') + if pagesize: + warnings.warn("tailbone.grid.default_pagesize setting is deprecated; " + "please use wuttaweb.grids.default_pagesize instead", + DeprecationWarning) + return pagesize + + if default: + return default + + # use upstream default + return super().get_pagesize() + + def get_default_pagesize(self): # pragma: no cover + """ """ + warnings.warn("Grid.get_default_pagesize() method is deprecated; " + "please use Grid.get_pagesize() of Grid.page instead", + DeprecationWarning, stacklevel=2) - def get_default_pagesize(self): if self.default_pagesize: return self.default_pagesize - pagesize = self.request.rattail_config.getint('tailbone', - 'grid.default_pagesize', - default=0) - if pagesize: - return pagesize + return self.get_pagesize() - options = self.get_pagesize_options() - return options[0] + def load_settings(self, **kwargs): + """ """ + if 'store' in kwargs: + warnings.warn("the 'store' param is deprecated for load_settings(); " + "please use the 'persist' param instead", + DeprecationWarning, stacklevel=2) + kwargs.setdefault('persist', kwargs.pop('store')) - def load_settings(self, store=True): - """ - Load current/effective settings for the grid, from the request query - string and/or session storage. If ``store`` is true, then once - settings have been fully read, they are stored in current session for - next time. Finally, various instance attributes of the grid and its - filters are updated in-place to reflect the settings; this is so code - needn't access the settings dict directly, but the more Pythonic - instance attributes. - """ + persist = kwargs.get('persist', True) # initial default settings settings = {} if self.sortable: - if self.default_sortkey: + if self.sort_defaults: + # nb. as of writing neither Buefy nor Oruga support a + # multi-column *default* sort; so just use first sorter + sortinfo = self.sort_defaults[0] settings['sorters.length'] = 1 - settings['sorters.1.key'] = self.default_sortkey - settings['sorters.1.dir'] = self.default_sortdir + settings['sorters.1.key'] = sortinfo.sortkey + settings['sorters.1.dir'] = sortinfo.sortdir else: settings['sorters.length'] = 0 - if self.pageable: - settings['pagesize'] = self.get_default_pagesize() - settings['page'] = self.default_page + if self.paginated: + settings['pagesize'] = self.pagesize + settings['page'] = self.page if self.filterable: for filtr in self.iter_filters(): - settings['filter.{}.active'.format(filtr.key)] = filtr.default_active - settings['filter.{}.verb'.format(filtr.key)] = filtr.default_verb - settings['filter.{}.value'.format(filtr.key)] = filtr.default_value + defaults = self.filter_defaults.get(filtr.key, {}) + settings[f'filter.{filtr.key}.active'] = defaults.get('active', + filtr.default_active) + settings[f'filter.{filtr.key}.verb'] = defaults.get('verb', + filtr.default_verb) + settings[f'filter.{filtr.key}.value'] = defaults.get('value', + filtr.default_value) # If user has default settings on file, apply those first. if self.user_has_defaults(): @@ -773,25 +877,25 @@ class Grid(object): # If request contains instruction to reset to default filters, then we # can skip the rest of the request/session checks. - if self.request.GET.get('reset-to-default-filters') == 'true': + if self.request.GET.get('reset-view'): pass # If request has filter settings, grab those, then grab sort/pager # settings from request or session. - elif self.filterable and self.request_has_settings('filter'): - self.update_filter_settings(settings, 'request') + elif self.request_has_settings('filter'): + self.update_filter_settings(settings, src='request') if self.request_has_settings('sort'): - self.update_sort_settings(settings, 'request') + self.update_sort_settings(settings, src='request') else: - self.update_sort_settings(settings, 'session') + self.update_sort_settings(settings, src='session') self.update_page_settings(settings) # If request has no filter settings but does have sort settings, grab # those, then grab filter settings from session, then grab pager # settings from request or session. elif self.request_has_settings('sort'): - self.update_sort_settings(settings, 'request') - self.update_filter_settings(settings, 'session') + self.update_sort_settings(settings, src='request') + self.update_filter_settings(settings, src='session') self.update_page_settings(settings) # NOTE: These next two are functionally equivalent, but are kept @@ -801,27 +905,27 @@ class Grid(object): # grab those, then grab filter/sort settings from session. elif self.request_has_settings('page'): self.update_page_settings(settings) - self.update_filter_settings(settings, 'session') - self.update_sort_settings(settings, 'session') + self.update_filter_settings(settings, src='session') + self.update_sort_settings(settings, src='session') # If request has no settings, grab all from session. elif self.session_has_settings(): - self.update_filter_settings(settings, 'session') - self.update_sort_settings(settings, 'session') + self.update_filter_settings(settings, src='session') + self.update_sort_settings(settings, src='session') self.update_page_settings(settings) # If no settings were found in request or session, don't store result. else: - store = False + persist = False # Maybe store settings for next time. - if store: - self.persist_settings(settings, 'session') + if persist: + self.persist_settings(settings, dest='session') # If request contained instruction to save current settings as defaults # for the current user, then do that. if self.request.GET.get('save-current-filters-as-defaults') == 'true': - self.persist_settings(settings, 'defaults') + self.persist_settings(settings, dest='defaults') # update ourself to reflect settings if self.filterable: @@ -830,13 +934,14 @@ class Grid(object): filtr.verb = settings['filter.{}.verb'.format(filtr.key)] filtr.value = settings['filter.{}.value'.format(filtr.key)] if self.sortable: + # and self.sort_on_backend: self.active_sorters = [] for i in range(1, settings['sorters.length'] + 1): self.active_sorters.append({ - 'field': settings[f'sorters.{i}.key'], - 'order': settings[f'sorters.{i}.dir'], + 'key': settings[f'sorters.{i}.key'], + 'dir': settings[f'sorters.{i}.dir'], }) - if self.pageable: + if self.paginated: self.pagesize = settings['pagesize'] self.page = settings['page'] @@ -940,23 +1045,16 @@ class Grid(object): merge(f'sorters.{i}.key') merge(f'sorters.{i}.dir') - if self.pageable: + if self.paginated: merge('pagesize', int) merge('page', int) def request_has_settings(self, type_): - """ - Determine if the current request (GET query string) contains any - filter/sort settings for the grid. - """ - if type_ == 'filter': - for filtr in self.iter_filters(): - if filtr.key in self.request.GET: - return True - if 'filter' in self.request.GET: # user may be applying empty filters - return True + """ """ + if super().request_has_settings(type_): + return True - elif type_ == 'sort': + if type_ == 'sort': # TODO: remove this eventually, but some links in the wild # may still include these params, so leave it for now @@ -964,14 +1062,6 @@ class Grid(object): if key in self.request.GET: return True - if 'sort1key' in self.request.GET: - return True - - elif type_ == 'page': - for key in ['pagesize', 'page']: - if key in self.request.GET: - return True - return False def session_has_settings(self): @@ -987,173 +1077,19 @@ class Grid(object): return any([key.startswith(f'{prefix}.filter') for key in self.request.session]) - def get_setting(self, source, settings, key, normalize=lambda v: v, default=None): - """ - Get the effective value for a particular setting, preferring ``source`` - but falling back to existing ``settings`` and finally the ``default``. - """ - if source not in ('request', 'session'): - raise ValueError("Invalid source identifier: {}".format(source)) + def persist_settings(self, settings, dest='session'): + """ """ + if dest not in ('defaults', 'session'): + raise ValueError(f"invalid dest identifier: {dest}") - # If source is query string, try that first. - if source == 'request': - value = self.request.GET.get(key) - if value is not None: - try: - value = normalize(value) - except ValueError: - pass - else: - return value + app = self.request.rattail_config.get_app() + model = app.model - # Or, if source is session, try that first. - else: - value = self.request.session.get('grid.{}.{}'.format(self.key, key)) - if value is not None: - return normalize(value) - - # If source had nothing, try default/existing settings. - value = settings.get(key) - if value is not None: - try: - value = normalize(value) - except ValueError: - pass - else: - return value - - # Okay then, default it is. - return default - - def update_filter_settings(self, settings, source): - """ - Updates a settings dictionary according to filter settings data found - in either the GET query string, or session storage. - - :param settings: Dictionary of initial settings, which is to be updated. - - :param source: String identifying the source to consult for settings - data. Must be one of: ``('request', 'session')``. - """ - if not self.filterable: - return - - for filtr in self.iter_filters(): - prefix = 'filter.{}'.format(filtr.key) - - if source == 'request': - # consider filter active if query string contains a value for it - settings['{}.active'.format(prefix)] = filtr.key in self.request.GET - settings['{}.verb'.format(prefix)] = self.get_setting( - source, settings, '{}.verb'.format(filtr.key), default='') - settings['{}.value'.format(prefix)] = self.get_setting( - source, settings, filtr.key, default='') - - else: # source = session - settings['{}.active'.format(prefix)] = self.get_setting( - source, settings, '{}.active'.format(prefix), - normalize=lambda v: str(v).lower() == 'true', default=False) - settings['{}.verb'.format(prefix)] = self.get_setting( - source, settings, '{}.verb'.format(prefix), default='') - settings['{}.value'.format(prefix)] = self.get_setting( - source, settings, '{}.value'.format(prefix), default='') - - def update_sort_settings(self, settings, source): - """ - Updates a settings dictionary according to sort settings data found in - either the GET query string, or session storage. - - :param settings: Dictionary of initial settings, which is to be updated. - - :param source: String identifying the source to consult for settings - data. Must be one of: ``('request', 'session')``. - """ - if not self.sortable: - return - - if source == 'request': - - # TODO: remove this eventually, but some links in the wild - # may still include these params, so leave it for now - if 'sortkey' in self.request.GET: - settings['sorters.length'] = 1 - settings['sorters.1.key'] = self.get_setting(source, settings, 'sortkey') - settings['sorters.1.dir'] = self.get_setting(source, settings, 'sortdir') - - else: # the future - i = 1 - while True: - skey = f'sort{i}key' - if skey in self.request.GET: - settings[f'sorters.{i}.key'] = self.get_setting(source, settings, skey) - settings[f'sorters.{i}.dir'] = self.get_setting(source, settings, f'sort{i}dir') - else: - break - i += 1 - settings['sorters.length'] = i - 1 - - else: # session - - # TODO: definitely will remove this, but leave it for now - # so it doesn't monkey with current user sessions when - # next upgrade happens. so, remove after all are upgraded - sortkey = self.get_setting(source, settings, 'sortkey') - if sortkey: - settings['sorters.length'] = 1 - settings['sorters.1.key'] = sortkey - settings['sorters.1.dir'] = self.get_setting(source, settings, 'sortdir') - - else: # the future - settings['sorters.length'] = self.get_setting(source, settings, - 'sorters.length', int) - for i in range(1, settings['sorters.length'] + 1): - for key in ('key', 'dir'): - skey = f'sorters.{i}.{key}' - settings[skey] = self.get_setting(source, settings, skey) - - def update_page_settings(self, settings): - """ - Updates a settings dictionary according to pager settings data found in - either the GET query string, or session storage. - - Note that due to how the actual pager functions, the effective settings - will often come from *both* the request and session. This is so that - e.g. the page size will remain constant (coming from the session) while - the user jumps between pages (which only provides the single setting). - - :param settings: Dictionary of initial settings, which is to be updated. - """ - if not self.pageable: - return - - pagesize = self.request.GET.get('pagesize') - if pagesize is not None: - if pagesize.isdigit(): - settings['pagesize'] = int(pagesize) - else: - pagesize = self.request.session.get('grid.{}.pagesize'.format(self.key)) - if pagesize is not None: - settings['pagesize'] = pagesize - - page = self.request.GET.get('page') - if page is not None: - if page.isdigit(): - settings['page'] = int(page) - else: - page = self.request.session.get('grid.{}.page'.format(self.key)) - if page is not None: - settings['page'] = int(page) - - def persist_settings(self, settings, to='session'): - """ - Persist the given settings in some way, as defined by ``func``. - """ - def persist(key, value=lambda k: settings[k]): - if to == 'defaults': + def persist(key, value=lambda k: settings.get(k)): + if dest == 'defaults': skey = 'tailbone.{}.grid.{}.{}'.format(self.request.user.uuid, self.key, key) - app = self.request.rattail_config.get_app() app.save_setting(Session(), skey, value(key)) - else: # to == session + else: # dest == session skey = 'grid.{}.{}'.format(self.key, key) self.request.session[skey] = value(key) @@ -1165,10 +1101,11 @@ class Grid(object): if self.sortable: - # first clear existing settings for *sorting* only - # nb. this is because number of sort settings will vary - if to == 'defaults': - model = self.request.rattail_config.get_model() + # first must clear all sort settings from dest. this is + # because number of sort settings will vary, so we delete + # all and then write all + + if dest == 'defaults': prefix = f'tailbone.{self.request.user.uuid}.grid.{self.key}' query = Session.query(model.Setting)\ .filter(sa.or_( @@ -1182,7 +1119,9 @@ class Grid(object): for setting in query.all(): Session.delete(setting) Session.flush() + else: # session + # remove sort settings from user session prefix = f'grid.{self.key}' for key in list(self.request.session): if key.startswith(f'{prefix}.sorters.'): @@ -1194,12 +1133,14 @@ class Grid(object): self.request.session.pop(f'{prefix}.sortkey', None) self.request.session.pop(f'{prefix}.sortdir', None) - persist('sorters.length') - for i in range(1, settings['sorters.length'] + 1): - persist(f'sorters.{i}.key') - persist(f'sorters.{i}.dir') + # now save sort settings to dest + if 'sorters.length' in settings: + persist('sorters.length') + for i in range(1, settings['sorters.length'] + 1): + persist(f'sorters.{i}.key') + persist(f'sorters.{i}.dir') - if self.pageable: + if self.paginated: persist('pagesize') persist('page') @@ -1223,132 +1164,38 @@ class Grid(object): return data - def sort_data(self, data): - """ - Sort the given query according to current settings, and return the result. - """ - # bail if no sort settings - if not self.active_sorters: - return data - - # TODO: is there a better way to check for SA sorting? - if self.model_class: - - # collect actual column sorters for order_by clause - sorters = [] - for sorter in self.active_sorters: - sortkey = sorter['field'] - sortfunc = self.sorters.get(sortkey) - if not sortfunc: - log.warning("unknown sorter: %s", sorter) - continue - - # join appropriate model if needed - if sortkey in self.joiners and sortkey not in self.joined: - data = self.joiners[sortkey](data) - self.joined.add(sortkey) - - # add column/dir to collection - sortdir = sorter['order'] - sorters.append(getattr(sortfunc._column, sortdir)()) - - # apply sorting to query - if sorters: - data = data.order_by(*sorters) - - return data - - else: - # not a SQLAlchemy grid, custom sorter - - assert len(self.active_sorters) < 2 - - sortkey = self.active_sorters[0]['field'] - sortdir = self.active_sorters[0]['order'] or 'asc' - - # Cannot sort unless we have a sort function. - sortfunc = self.sorters.get(sortkey) - if not sortfunc: - return data - - # apply joins needed for this sorter - if sortkey in self.joiners and sortkey not in self.joined: - data = self.joiners[sortkey](data) - self.joined.add(sortkey) - - return sortfunc(data, sortdir) - - def paginate_data(self, data): - """ - Paginate the given data set according to current settings, and return - the result. - """ - # we of course assume our current page is correct, at first - pager = self.make_pager(data) - - # if pager has detected that our current page is outside the valid - # range, we must re-orient ourself around the "new" (valid) page - if pager.page != self.page: - self.page = pager.page - self.request.session['grid.{}.page'.format(self.key)] = self.page - pager = self.make_pager(data) - - return pager - - def make_pager(self, data): - - # TODO: this seems hacky..normally we expect `data` to be a - # query of course, but in some cases it may be a list instead. - # if so then we can't use ORM pager - if isinstance(data, list): - import paginate - return paginate.Page(data, - items_per_page=self.pagesize, - page=self.page) - - return SqlalchemyOrmPage(data, - items_per_page=self.pagesize, - page=self.page, - url_maker=URLMaker(self.request)) - def make_visible_data(self): - """ - Apply various settings to the raw data set, to produce a final data - set. This will page / sort / filter as necessary, according to the - grid's defaults and the current request etc. - """ - self.joined = set() - data = self.data - if self.filterable: - data = self.filter_data(data) - if self.sortable: - data = self.sort_data(data) - if self.pageable: - self.pager = self.paginate_data(data) - data = self.pager - return data + """ """ + warnings.warn("grid.make_visible_data() method is deprecated; " + "please use grid.get_visible_data() instead", + DeprecationWarning, stacklevel=2) + return self.get_visible_data() - def render_complete(self, template='/grids/buefy.mako', **kwargs): - """ - Render the complete grid, including filters. - """ - context = kwargs - context['grid'] = self - context['request'] = self.request - context.setdefault('allow_save_defaults', True) - context.setdefault('view_click_handler', self.get_view_click_handler()) - return render(template, context) + def render_vue_tag(self, master=None, **kwargs): + """ """ + kwargs.setdefault('ref', 'grid') + kwargs.setdefault(':csrftoken', 'csrftoken') - def render_buefy(self, template='/grids/buefy.mako', **kwargs): + if (master and master.deletable and master.has_perm('delete') + and master.delete_confirm == 'simple'): + kwargs.setdefault('@deleteActionClicked', 'deleteObject') + + return HTML.tag(self.vue_tagname, **kwargs) + + def render_vue_template(self, template='/grids/complete.mako', **context): + """ """ + return self.render_complete(template=template, **context) + + def render_complete(self, template='/grids/complete.mako', **kwargs): """ - Render the Buefy grid, complete with filters. Note that this also + Render the grid, complete with filters. Note that this also includes the context menu items and grid tools. """ if 'grid_columns' not in kwargs: - kwargs['grid_columns'] = self.get_buefy_columns() + kwargs['grid_columns'] = self.get_vue_columns() if 'grid_data' not in kwargs: - kwargs['grid_data'] = self.get_buefy_data() + kwargs['grid_data'] = self.get_table_data() if 'static_data' not in kwargs: kwargs['static_data'] = self.has_static_data() @@ -1359,44 +1206,53 @@ class Grid(object): if self.filterable and 'filters_sequence' not in kwargs: kwargs['filters_sequence'] = self.get_filters_sequence() - return self.render_complete(template=template, **kwargs) + context = kwargs + context['grid'] = self + context['request'] = self.request + context.setdefault('allow_save_defaults', True) + context.setdefault('view_click_handler', self.get_view_click_handler()) + html = render(template, context) + return HTML.literal(html) - def render_buefy_table_element(self, template='/grids/b-table.mako', - data_prop='gridData', empty_labels=False, - **kwargs): + def render_buefy(self, **kwargs): + """ """ + warnings.warn("Grid.render_buefy() is deprecated; " + "please use Grid.render_complete() instead", + DeprecationWarning, stacklevel=2) + return self.render_complete(**kwargs) + + def render_table_element(self, template='/grids/b-table.mako', + data_prop='gridData', empty_labels=False, + literal=False, + **kwargs): """ This is intended for ad-hoc "small" grids with static data. Renders just a ``<b-table>`` element instead of the typical "full" grid. """ context = dict(kwargs) context['grid'] = self + context['request'] = self.request context['data_prop'] = data_prop context['empty_labels'] = empty_labels if 'grid_columns' not in context: - context['grid_columns'] = self.get_buefy_columns() + context['grid_columns'] = self.get_vue_columns() context.setdefault('paginated', False) if context['paginated']: context.setdefault('per_page', 20) context['view_click_handler'] = self.get_view_click_handler() - return render(template, context) + result = render(template, context) + if literal: + result = HTML.literal(result) + return result def get_view_click_handler(self): - + """ """ # locate the 'view' action # TODO: this should be easier, and/or moved elsewhere? view = None - for action in self.main_actions: + for action in self.actions: if action.key == 'view': - view = action - break - if not view: - for action in self.more_actions: - if action.key == 'view': - view = action - break - - if view: - return view.click_handler + return getattr(action, 'click_handler', None) def set_filters_sequence(self, filters, only=False): """ @@ -1432,7 +1288,7 @@ class Grid(object): def get_filters_data(self): """ - Returns a dict of current filters data, for use with Buefy grid view. + Returns a dict of current filters data, for use with index view. """ data = {} for filtr in self.filters.values(): @@ -1470,48 +1326,21 @@ class Grid(object): return data - def render_filters(self, template='/grids/filters.mako', **kwargs): - """ - Render the filters to a Unicode string, using the specified template. - Additional kwargs are passed along as context to the template. - """ - # Provide default data to filters form, so renderer can do some of the - # work for us. - data = {} - for filtr in self.iter_active_filters(): - data['{}.active'.format(filtr.key)] = filtr.active - data['{}.verb'.format(filtr.key)] = filtr.verb - data[filtr.key] = filtr.value + def render_actions(self, row, i): # pragma: no cover + """ """ + warnings.warn("grid.render_actions() is deprecated!", + DeprecationWarning, stacklevel=2) - form = gridfilters.GridFiltersForm(self.filters, - request=self.request, - defaults=data) + actions = [self.render_action(a, row, i) + for a in self.actions] + actions = [a for a in actions if a] + return HTML.literal('').join(actions) - kwargs['request'] = self.request - kwargs['grid'] = self - kwargs['form'] = form - return render(template, kwargs) + def render_action(self, action, row, i): # pragma: no cover + """ """ + warnings.warn("grid.render_action() is deprecated!", + DeprecationWarning, stacklevel=2) - def render_actions(self, row, i): - """ - Returns the rendered contents of the 'actions' column for a given row. - """ - main_actions = [self.render_action(a, row, i) - for a in self.main_actions] - main_actions = [a for a in main_actions if a] - more_actions = [self.render_action(a, row, i) - for a in self.more_actions] - more_actions = [a for a in more_actions if a] - if more_actions: - icon = HTML.tag('span', class_='ui-icon ui-icon-carat-1-e') - link = tags.link_to("More" + icon, '#', class_='more') - main_actions.append(HTML.literal(' ') + link + HTML.tag('div', class_='more', c=more_actions)) - return HTML.literal('').join(main_actions) - - def render_action(self, action, row, i): - """ - Renders an action menu item (link) for the given row. - """ url = action.get_url(row, i) if url: kwargs = {'class_': action.key, 'target': action.target} @@ -1545,18 +1374,6 @@ class Grid(object): return tags.checkbox('checkbox-{}-{}'.format(self.key, self.get_row_key(item)), checked=self.checked(item)) - def get_pagesize_options(self): - - # use values from config, if defined - options = self.request.rattail_config.getlist('tailbone', 'grid.pagesize_options') - if options: - options = [int(size) for size in options - if size.isdigit()] - if options: - return options - - return [5, 10, 20, 50, 100, 200] - def has_static_data(self): """ Should return ``True`` if the grid data can be considered "static" @@ -1568,21 +1385,22 @@ class Grid(object): return True return False - def get_buefy_columns(self): - """ - Return a list of dicts representing all grid columns. Meant for use - with Buefy table. - """ - columns = [] - for name in self.columns: - columns.append({ - 'field': name, - 'label': self.get_label(name), - 'sortable': self.sortable and name in self.sorters, - 'visible': name not in self.invisible, - }) + def get_vue_columns(self): + """ """ + columns = super().get_vue_columns() + + for column in columns: + column['visible'] = column['field'] not in self.invisible + return columns + def get_table_columns(self): + """ """ + warnings.warn("grid.get_table_columns() method is deprecated; " + "please use grid.get_vue_columns() instead", + DeprecationWarning, stacklevel=2) + return self.get_vue_columns() + def get_uuid_for_row(self, rowobj): # use custom getter if set @@ -1593,12 +1411,25 @@ class Grid(object): if hasattr(rowobj, 'uuid'): return rowobj.uuid - def get_buefy_data(self): + def get_vue_context(self): + """ """ + return self.get_table_data() + + def get_vue_data(self): + """ """ + table_data = self.get_table_data() + return table_data['data'] + + def get_table_data(self): """ - Returns a list of data rows for the grid, for use with Buefy table. + Returns a list of data rows for the grid, for use with + client-side JS table. """ + if hasattr(self, '_table_data'): + return self._table_data + # filter / sort / paginate to get "visible" data - raw_data = self.make_visible_data() + raw_data = self.get_visible_data() data = [] status_map = {} checked = [] @@ -1631,21 +1462,37 @@ class Grid(object): # instance, when the "display" version is different than raw data. # here is the hack we use for that. columns = list(self.columns) - if hasattr(self, 'buefy_data_columns'): - columns.extend(self.buefy_data_columns) + if hasattr(self, 'raw_data_columns'): + columns.extend(self.raw_data_columns) # iterate over data fields for name in columns: # leverage configured rendering logic where applicable; # otherwise use "raw" data value as string + value = self.obtain_value(rowobj, name) if self.renderers and name in self.renderers: - value = self.renderers[name](rowobj, name) - else: - value = self.obtain_value(rowobj, name) + renderer = self.renderers[name] + + # TODO: legacy renderer callables require 2 args, + # but wuttaweb callables require 3 args + sig = inspect.signature(renderer) + required = [param for param in sig.parameters.values() + if param.default == param.empty] + + if len(required) == 2: + # TODO: legacy renderer + value = renderer(rowobj, name) + else: # the future + value = renderer(rowobj, name, value) + if value is None: value = "" - row[name] = str(value) + + # this value will ultimately be inserted into table + # cell a la <td v-html="..."> so we must escape it + # here to be safe + row[name] = HTML.literal.escape(value) # maybe add UUID for convenience if 'uuid' not in self.columns: @@ -1671,6 +1518,8 @@ class Grid(object): results = { 'data': data, + 'row_classes': status_map, + # TODO: deprecate / remove this 'row_status_map': status_map, } @@ -1678,11 +1527,15 @@ class Grid(object): results['checked_rows'] = checked # TODO: this seems a bit hacky, but is required for now to # initialize things on the client side... - var = '{}CurrentData'.format(self.component_studly) + var = '{}CurrentData'.format(self.vue_component) results['checked_rows_code'] = '[{}]'.format( ', '.join(['{}[{}]'.format(var, i) for i in checked])) - if self.pageable and self.pager is not None: + if self.paginated and self.paginate_on_backend: + results['pager_stats'] = self.get_vue_pager_stats() + + # TODO: is this actually needed now that we have pager_stats? + if self.paginated and self.pager is not None: results['total_items'] = self.pager.item_count results['per_page'] = self.pager.items_per_page results['page'] = self.pager.page @@ -1692,104 +1545,38 @@ class Grid(object): else: results['total_items'] = count - return results + self._table_data = results + return self._table_data + + # TODO: remove this when we use upstream GridAction + def add_action(self, key, **kwargs): + """ """ + self.actions.append(GridAction(self.request, key, **kwargs)) def set_action_urls(self, row, rowobj, i): """ Pre-generate all action URLs for the given data row. Meant for use - with Buefy table, since we can't generate URLs from JS. + with client-side table, since we can't generate URLs from JS. """ - for action in (self.main_actions + self.more_actions): + for action in self.actions: url = action.get_url(rowobj, i) row['_action_url_{}'.format(action.key)] = url - def is_linked(self, name): - """ - Should return ``True`` if the given column name is configured to be - "linked" (i.e. table cell should contain a link to "view object"), - otherwise ``False``. - """ - if self.linked_columns: - if name in self.linked_columns: - return True - return False - -class CustomWebhelpersGrid(webhelpers2_grid.Grid): - """ - Implement column sorting links etc. for webhelpers2_grid +class GridAction(WuttaGridAction): """ + Represents a "row action" hyperlink within a grid context. - def __init__(self, itemlist, columns, **kwargs): - self.renderers = kwargs.pop('renderers', {}) - self.linked_columns = kwargs.pop('linked_columns', []) - self.extra_record_class = kwargs.pop('extra_record_class', None) - super(CustomWebhelpersGrid, self).__init__(itemlist, columns, **kwargs) + This is a subclass of + :class:`wuttaweb:wuttaweb.grids.base.GridAction`. - def generate_header_link(self, column_number, column, label_text): + .. warning:: - # display column header as simple no-op link; client-side JS takes care - # of the rest for us - label_text = tags.link_to(label_text, '#', data_sortkey=column) + This class remains for now, to retain compatibility with + existing code. But at some point the WuttaWeb class will + supersede this one entirely. - # Is the current column the one we're ordering on? - if (column == self.order_column): - return self.default_header_ordered_column_format(column_number, - column, - label_text) - else: - return self.default_header_column_format(column_number, column, - label_text) - - def default_record_format(self, i, record, columns): - kwargs = { - 'class_': self.get_record_class(i, record, columns), - } - if hasattr(record, 'uuid'): - kwargs['data_uuid'] = record.uuid - return HTML.tag('tr', columns, **kwargs) - - def get_record_class(self, i, record, columns): - if i % 2 == 0: - cls = 'even r{}'.format(i) - else: - cls = 'odd r{}'.format(i) - if self.extra_record_class: - extra = self.extra_record_class(record, i) - if extra: - cls = '{} {}'.format(cls, extra) - return cls - - def get_column_value(self, column_number, i, record, column_name): - if self.renderers and column_name in self.renderers: - return self.renderers[column_name](record, column_name) - try: - return record[column_name] - except TypeError: - return getattr(record, column_name) - - def default_column_format(self, column_number, i, record, column_name): - value = self.get_column_value(column_number, i, record, column_name) - if self.linked_columns and column_name in self.linked_columns and ( - value is not None and value != ''): - url = self.url_generator(record, i) - value = tags.link_to(value, url) - class_name = 'c{} {}'.format(column_number, column_name) - return HTML.tag('td', value, class_=class_name) - - -class GridAction(object): - """ - Represents an action available to a grid. This is used to construct the - 'actions' column when rendering the grid. - - :param key: Key for the action (e.g. ``'edit'``), unique within - the grid. - - :param label: Label to be displayed for the action. If not set, - will be a capitalized version of ``key``. - - :param icon: Icon name for the action. + :param target: HTML "target" attribute for the ``<a>`` tag. :param click_handler: Optional JS click handler for the action. This value will be rendered as-is within the final grid @@ -1801,41 +1588,23 @@ class GridAction(object): * ``$emit('do-something', props.row)`` """ - def __init__(self, key, label=None, url='#', icon=None, target=None, - link_class=None, click_handler=None): - self.key = key - self.label = label or prettify(key) - self.icon = icon - self.url = url + def __init__( + self, + request, + key, + target=None, + click_handler=None, + **kwargs, + ): + # TODO: previously url default was '#' - but i don't think we + # need that anymore? guess we'll see.. + #kwargs.setdefault('url', '#') + + super().__init__(request, key, **kwargs) + self.target = target - self.link_class = link_class self.click_handler = click_handler - def get_url(self, row, i): - """ - Returns an action URL for the given row. - """ - if callable(self.url): - return self.url(row, i) - return self.url - - def render_icon(self): - """ - Render the HTML snippet for the action link icon. - """ - return HTML.tag('i', class_='fas fa-{}'.format(self.icon)) - - def render_label(self): - """ - Render the label "text" within the actions column of a grid - row. Most actions have a static label that never varies, but - you can override this to add e.g. HTML content. Note that the - return value will be treated / rendered as HTML whether or not - it contains any, so perhaps be careful that it is trusted - content. - """ - return self.label - class URLMaker(object): """ diff --git a/tailbone/grids/filters.py b/tailbone/grids/filters.py index 41a3c1fa..7e52bb8d 100644 --- a/tailbone/grids/filters.py +++ b/tailbone/grids/filters.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,6 +26,7 @@ Grid Filters import re import datetime +import decimal import logging from collections import OrderedDict @@ -313,7 +314,7 @@ class AlchemyGridFilter(GridFilter): def __init__(self, *args, **kwargs): self.column = kwargs.pop('column') - super(AlchemyGridFilter, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) def filter_equal(self, query, value): """ @@ -447,12 +448,12 @@ class AlchemyGridFilter(GridFilter): if start_value: if self.value_invalid(start_value): return query - query = query.filter(self.column >= start_value) + query = query.filter(self.column >= self.encode_value(start_value)) if end_value: if self.value_invalid(end_value): return query - query = query.filter(self.column <= end_value) + query = query.filter(self.column <= self.encode_value(end_value)) return query @@ -484,9 +485,13 @@ class AlchemyStringFilter(AlchemyGridFilter): """ if value is None or value == '': return query - return query.filter(sa.and_( - *[self.column.ilike(self.encode_value('%{}%'.format(v))) - for v in value.split()])) + + criteria = [] + for val in value.split(): + val = val.replace('_', r'\_') + val = self.encode_value(f'%{val}%') + criteria.append(self.column.ilike(val)) + return query.filter(sa.and_(*criteria)) def filter_does_not_contain(self, query, value): """ @@ -495,14 +500,17 @@ class AlchemyStringFilter(AlchemyGridFilter): if value is None or value == '': return query + criteria = [] + for val in value.split(): + val = val.replace('_', r'\_') + val = self.encode_value(f'%{val}%') + criteria.append(~self.column.ilike(val)) + # When saying something is 'not like' something else, we must also # include things which are nothing at all, in our result set. return query.filter(sa.or_( self.column == None, - sa.and_( - *[~self.column.ilike(self.encode_value('%{}%'.format(v))) - for v in value.split()]), - )) + sa.and_(*criteria))) def filter_contains_any_of(self, query, value): """ @@ -531,24 +539,28 @@ class AlchemyStringFilter(AlchemyGridFilter): conditions = [] for value in values: - conditions.append(sa.and_( - *[self.column.ilike(self.encode_value('%{}%'.format(v))) - for v in value.split()])) + criteria = [] + for val in value.split(): + val = val.replace('_', r'\_') + val = self.encode_value(f'%{val}%') + criteria.append(self.column.ilike(val)) + conditions.append(sa.and_(*criteria)) return query.filter(sa.or_(*conditions)) def filter_is_empty(self, query, value): - return query.filter(sa.func.trim(self.column) == self.encode_value('')) + return query.filter(sa.func.ltrim(sa.func.rtrim(self.column)) == self.encode_value('')) def filter_is_not_empty(self, query, value): - return query.filter(sa.func.trim(self.column) != self.encode_value('')) + return query.filter(sa.func.ltrim(sa.func.rtrim(self.column)) != self.encode_value('')) def filter_is_empty_or_null(self, query, value): return query.filter( sa.or_( - sa.func.trim(self.column) == self.encode_value(''), + sa.func.ltrim(sa.func.rtrim(self.column)) == self.encode_value(''), self.column == None)) + class AlchemyEmptyStringFilter(AlchemyStringFilter): """ String filter with special logic to treat empty string values as NULL @@ -558,13 +570,13 @@ class AlchemyEmptyStringFilter(AlchemyStringFilter): return query.filter( sa.or_( self.column == None, - sa.func.trim(self.column) == self.encode_value(''))) + sa.func.ltrim(sa.func.rtrim(self.column)) == self.encode_value(''))) def filter_is_not_null(self, query, value): return query.filter( sa.and_( self.column != None, - sa.func.trim(self.column) != self.encode_value(''))) + sa.func.ltrim(sa.func.rtrim(self.column)) != self.encode_value(''))) class AlchemyByteStringFilter(AlchemyStringFilter): @@ -576,7 +588,7 @@ class AlchemyByteStringFilter(AlchemyStringFilter): value_encoding = 'utf-8' def get_value(self, value=UNSPECIFIED): - value = super(AlchemyByteStringFilter, self).get_value(value) + value = super().get_value(value) if isinstance(value, str): value = value.encode(self.value_encoding) return value @@ -587,8 +599,13 @@ class AlchemyByteStringFilter(AlchemyStringFilter): """ if value is None or value == '': return query - return query.filter(sa.and_( - *[self.column.ilike(b'%{}%'.format(v)) for v in value.split()])) + + criteria = [] + for val in value.split(): + val = val.replace('_', r'\_') + val = b'%{}%'.format(val) + criteria.append(self.column.ilike(val)) + return query.filters(sa.and_(*criteria)) def filter_does_not_contain(self, query, value): """ @@ -597,13 +614,16 @@ class AlchemyByteStringFilter(AlchemyStringFilter): if value is None or value == '': return query + for val in value.split(): + val = val.replace('_', '\_') + val = b'%{}%'.format(val) + criteria.append(~self.column.ilike(val)) + # When saying something is 'not like' something else, we must also # include things which are nothing at all, in our result set. return query.filter(sa.or_( self.column == None, - sa.and_( - *[~self.column.ilike(b'%{}%'.format(v)) for v in value.split()]), - )) + sa.and_(*criteria))) class AlchemyNumericFilter(AlchemyGridFilter): @@ -628,41 +648,51 @@ class AlchemyNumericFilter(AlchemyGridFilter): # first just make sure it's somewhat numeric try: - float(value) - except ValueError: + self.parse_decimal(value) + except decimal.InvalidOperation: return True return bool(value and len(str(value)) > 8) + def parse_decimal(self, value): + if value: + value = value.replace(',', '') + return decimal.Decimal(value) + + def encode_value(self, value): + if value: + value = str(self.parse_decimal(value)) + return super().encode_value(value) + def filter_equal(self, query, value): if self.value_invalid(value): return query - return super(AlchemyNumericFilter, self).filter_equal(query, value) + return super().filter_equal(query, value) def filter_not_equal(self, query, value): if self.value_invalid(value): return query - return super(AlchemyNumericFilter, self).filter_not_equal(query, value) + return super().filter_not_equal(query, value) def filter_greater_than(self, query, value): if self.value_invalid(value): return query - return super(AlchemyNumericFilter, self).filter_greater_than(query, value) + return super().filter_greater_than(query, value) def filter_greater_equal(self, query, value): if self.value_invalid(value): return query - return super(AlchemyNumericFilter, self).filter_greater_equal(query, value) + return super().filter_greater_equal(query, value) def filter_less_than(self, query, value): if self.value_invalid(value): return query - return super(AlchemyNumericFilter, self).filter_less_than(query, value) + return super().filter_less_than(query, value) def filter_less_equal(self, query, value): if self.value_invalid(value): return query - return super(AlchemyNumericFilter, self).filter_less_equal(query, value) + return super().filter_less_equal(query, value) class AlchemyIntegerFilter(AlchemyNumericFilter): @@ -1193,7 +1223,7 @@ class AlchemyPhoneNumberFilter(AlchemyStringFilter): 'ILIKE' query with those parts. """ value = self.parse_value(value) - return super(AlchemyPhoneNumberFilter, self).filter_contains(query, value) + return super().filter_contains(query, value) def filter_does_not_contain(self, query, value): """ @@ -1201,7 +1231,7 @@ class AlchemyPhoneNumberFilter(AlchemyStringFilter): 'NOT ILIKE' query with those parts. """ value = self.parse_value(value) - return super(AlchemyPhoneNumberFilter, self).filter_does_not_contain(query, value) + return super().filter_does_not_contain(query, value) class GridFilterSet(OrderedDict): @@ -1245,7 +1275,7 @@ class GridFiltersForm(forms.Form): node = colander.SchemaNode(colander.String(), name=key) schema.add(node) kwargs['schema'] = schema - super(GridFiltersForm, self).__init__(**kwargs) + super().__init__(**kwargs) def iter_filters(self): return self.filters.values() diff --git a/tailbone/handler.py b/tailbone/handler.py index db95bc71..00f41bc9 100644 --- a/tailbone/handler.py +++ b/tailbone/handler.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,9 +24,8 @@ Tailbone Handler """ -from __future__ import unicode_literals, absolute_import +import warnings -import six from mako.lookup import TemplateLookup from rattail.app import GenericHandler @@ -41,7 +40,7 @@ class TailboneHandler(GenericHandler): """ def __init__(self, *args, **kwargs): - super(TailboneHandler, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) # TODO: make templates dir configurable? templates = [resource_path('rattail:templates/web')] @@ -49,11 +48,14 @@ class TailboneHandler(GenericHandler): def get_menu_handler(self, **kwargs): """ - Get the configured "menu" handler. - - :returns: The :class:`~tailbone.menus.MenuHandler` instance - for the app. + DEPRECATED; use + :meth:`wuttaweb.handler.WebHandler.get_menu_handler()` + instead. """ + warnings.warn("TailboneHandler.get_menu_handler() is deprecated; " + "please use WebHandler.get_menu_handler() instead", + DeprecationWarning, stacklevel=2) + if not hasattr(self, 'menu_handler'): spec = self.config.get('tailbone.menus', 'handler', default='tailbone.menus:MenuHandler') @@ -67,7 +69,7 @@ class TailboneHandler(GenericHandler): Returns an iterator over all registered Tailbone providers. """ providers = get_all_providers(self.config) - return six.itervalues(providers) + return providers.values() def write_model_view(self, data, path, **kwargs): """ diff --git a/tailbone/helpers.py b/tailbone/helpers.py index d4065cc5..50b38c30 100644 --- a/tailbone/helpers.py +++ b/tailbone/helpers.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,6 +24,9 @@ Template Context Helpers """ +# start off with all from wuttaweb +from wuttaweb.helpers import * + import os import datetime from decimal import Decimal @@ -33,14 +36,9 @@ from rattail.time import localtime, make_utc from rattail.util import pretty_quantity, pretty_hours, hours_as_decimal from rattail.db.util import maxlen -from webhelpers2.html import * -from webhelpers2.html.tags import * - -from tailbone.util import (csrf_token, get_csrf_token, - pretty_datetime, raw_datetime, +from tailbone.util import (pretty_datetime, raw_datetime, render_markdown, - route_exists, - get_liburl) + route_exists) def pretty_date(date): diff --git a/tailbone/menus.py b/tailbone/menus.py index 50dd3f4a..09d6f3f0 100644 --- a/tailbone/menus.py +++ b/tailbone/menus.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,37 +24,48 @@ App Menus """ -import re import logging import warnings -from rattail.app import GenericHandler from rattail.util import prettify, simple_error from webhelpers2.html import tags, HTML +from wuttaweb.menus import MenuHandler as WuttaMenuHandler + from tailbone.db import Session log = logging.getLogger(__name__) -class MenuHandler(GenericHandler): +class TailboneMenuHandler(WuttaMenuHandler): """ Base class and default implementation for menu handler. """ - def make_raw_menus(self, request, **kwargs): - """ - Generate a full set of "raw" menus for the app. + ############################## + # internal methods + ############################## - The "raw" menus are basically just a set of dicts to represent - the final menus. + def _is_allowed(self, request, item): + """ + TODO: must override this until wuttaweb has proper user auth checks + """ + perm = item.get('perm') + if perm: + return request.has_perm(perm) + return True + + def _make_raw_menus(self, request, **kwargs): + """ + We are overriding this to allow for making dynamic menus from + config/settings. Which may or may not be a good idea.. """ # first try to make menus from config, but this is highly # susceptible to failure, so try to warn user of problems try: - menus = self.make_menus_from_config(request) + menus = self._make_menus_from_config(request) if menus: return menus except Exception as error: @@ -71,9 +82,9 @@ class MenuHandler(GenericHandler): request.session.flash(msg, 'warning') # okay, no config, so menus will be built from code - return self.make_menus(request) + return self.make_menus(request, **kwargs) - def make_menus_from_config(self, request, **kwargs): + def _make_menus_from_config(self, request, **kwargs): """ Try to build a complete menu set from config/settings. @@ -85,7 +96,7 @@ class MenuHandler(GenericHandler): if not main_keys: return - model = self.model + model = self.app.model menus = [] # menu definition can come either from config file or db @@ -101,16 +112,15 @@ class MenuHandler(GenericHandler): query=query, key='name', normalizer=lambda s: s.value) for key in main_keys: - menus.append(self.make_single_menu_from_settings(request, key, - settings)) + menus.append(self._make_single_menu_from_settings(request, key, settings)) else: # read from config file only for key in main_keys: - menus.append(self.make_single_menu_from_config(request, key)) + menus.append(self._make_single_menu_from_config(request, key)) return menus - def make_single_menu_from_config(self, request, key, **kwargs): + def _make_single_menu_from_config(self, request, key, **kwargs): """ Makes a single top-level menu dict from config file. Note that this will read from config file(s) *only* and avoids @@ -178,7 +188,7 @@ class MenuHandler(GenericHandler): return menu - def make_single_menu_from_settings(self, request, key, settings, **kwargs): + def _make_single_menu_from_settings(self, request, key, settings, **kwargs): """ Makes a single top-level menu dict from DB settings. """ @@ -237,6 +247,10 @@ class MenuHandler(GenericHandler): return menu + ############################## + # menu defaults + ############################## + def make_menus(self, request, **kwargs): """ Make the full set of menus for the app. @@ -267,8 +281,9 @@ class MenuHandler(GenericHandler): """ Make a set of menus for all registered system integrations. """ + tb = self.app.get_tailbone_handler() menus = [] - for provider in self.tb.iter_providers(): + for provider in tb.iter_providers(): menu = provider.make_integration_menu(request) if menu: menus.append(menu) @@ -379,6 +394,11 @@ class MenuHandler(GenericHandler): 'route': 'products', 'perm': 'products.list', }, + { + 'title': "Product Costs", + 'route': 'product_costs', + 'perm': 'product_costs.list', + }, { 'title': "Departments", 'route': 'departments', @@ -436,6 +456,11 @@ class MenuHandler(GenericHandler): 'route': 'vendors', 'perm': 'vendors.list', }, + { + 'title': "Product Costs", + 'route': 'product_costs', + 'perm': 'product_costs.list', + }, {'type': 'sep'}, { 'title': "Ordering", @@ -688,7 +713,7 @@ class MenuHandler(GenericHandler): }, {'type': 'sep'}, { - 'title': "App Details", + 'title': "App Info", 'route': 'appinfo', 'perm': 'appinfo.list', }, @@ -723,182 +748,25 @@ class MenuHandler(GenericHandler): } -def make_simple_menus(request): +class MenuHandler(TailboneMenuHandler): + + def __init__(self, *args, **kwargs): + warnings.warn("tailbone.menus.MenuHandler is deprecated; " + "please use tailbone.menus.TailboneMenuHandler instead", + DeprecationWarning, stacklevel=2) + super().__init__(*args, **kwargs) + + +class NullMenuHandler(WuttaMenuHandler): """ - Build the main menu list for the app. + Null menu handler which uses an empty menu set. + + .. note: + + This class shouldn't even exist, but for the moment, it is + useful to configure non-traditional (e.g. API) web apps to use + this, in order to avoid most of the overhead. """ - app = request.rattail_config.get_app() - tailbone_handler = app.get_tailbone_handler() - menu_handler = tailbone_handler.get_menu_handler() - raw_menus = menu_handler.make_raw_menus(request) - - # now we have "simple" (raw) menus definition, but must refine - # that somewhat to produce our final menus - mark_allowed(request, raw_menus) - final_menus = [] - for topitem in raw_menus: - - if topitem['allowed']: - - if topitem.get('type') == 'link': - final_menus.append(make_menu_entry(request, topitem)) - - else: # assuming 'menu' type - - menu_items = [] - for item in topitem['items']: - if not item['allowed']: - continue - - # nested submenu - if item.get('type') == 'menu': - submenu_items = [] - for subitem in item['items']: - if subitem['allowed']: - submenu_items.append(make_menu_entry(request, subitem)) - menu_items.append({ - 'type': 'submenu', - 'title': item['title'], - 'items': submenu_items, - 'is_menu': True, - 'is_sep': False, - }) - - elif item.get('type') == 'sep': - # we only want to add a sep, *if* we already have some - # menu items (i.e. there is something to separate) - # *and* the last menu item is not a sep (avoid doubles) - if menu_items and not menu_items[-1]['is_sep']: - menu_items.append(make_menu_entry(request, item)) - - else: # standard menu item - menu_items.append(make_menu_entry(request, item)) - - # remove final separator if present - if menu_items and menu_items[-1]['is_sep']: - menu_items.pop() - - # only add if we wound up with something - assert menu_items - if menu_items: - group = { - 'type': 'menu', - 'key': topitem.get('key'), - 'title': topitem['title'], - 'items': menu_items, - 'is_menu': True, - 'is_link': False, - } - - # topitem w/ no key likely means it did not come - # from config but rather explicit definition in - # code. so we are free to "invent" a (safe) key - # for it, since that is only for editing config - if not group['key']: - group['key'] = make_menu_key(request.rattail_config, - topitem['title']) - - final_menus.append(group) - - return final_menus - - -def make_menu_key(config, value): - """ - Generate a normalized menu key for the given value. - """ - return re.sub(r'\W', '', value.lower()) - - -def make_menu_entry(request, item): - """ - Convert a simple menu entry dict, into a proper menu-related object, for - use in constructing final menu. - """ - # separator - if item.get('type') == 'sep': - return { - 'type': 'sep', - 'is_menu': False, - 'is_sep': True, - } - - # standard menu item - entry = { - 'type': 'item', - 'title': item['title'], - 'perm': item.get('perm'), - 'target': item.get('target'), - 'is_link': True, - 'is_menu': False, - 'is_sep': False, - } - if item.get('route'): - entry['route'] = item['route'] - try: - entry['url'] = request.route_url(entry['route']) - except KeyError: # happens if no such route - log.warning("invalid route name for menu entry: %s", entry) - entry['url'] = entry['route'] - entry['key'] = entry['route'] - else: - if item.get('url'): - entry['url'] = item['url'] - entry['key'] = make_menu_key(request.rattail_config, entry['title']) - return entry - - -def is_allowed(request, item): - """ - Logic to determine if a given menu item is "allowed" for current user. - """ - perm = item.get('perm') - if perm: - return request.has_perm(perm) - return True - - -def mark_allowed(request, menus): - """ - Traverse the menu set, and mark each item as "allowed" (or not) based on - current user permissions. - """ - for topitem in menus: - - if topitem.get('type', 'menu') == 'menu': - topitem['allowed'] = False - - for item in topitem['items']: - - if item.get('type') == 'menu': - for subitem in item['items']: - subitem['allowed'] = is_allowed(request, subitem) - - item['allowed'] = False - for subitem in item['items']: - if subitem['allowed'] and subitem.get('type') != 'sep': - item['allowed'] = True - break - - else: - item['allowed'] = is_allowed(request, item) - - for item in topitem['items']: - if item['allowed'] and item.get('type') != 'sep': - topitem['allowed'] = True - break - - -def make_admin_menu(request, **kwargs): - """ - Generate a typical Admin menu - """ - warnings.warn("make_admin_menu() function is deprecated; please use " - "MenuHandler.make_admin_menu() instead", - DeprecationWarning, stacklevel=2) - - app = request.rattail_config.get_app() - tailbone_handler = app.get_tailbone_handler() - menu_handler = tailbone_handler.get_menu_handler() - return menu_handler.make_admin_menu(request, **kwargs) + def make_menus(self, request, **kwargs): + return [] diff --git a/tailbone/scaffolds.py b/tailbone/scaffolds.py deleted file mode 100644 index 10bf9640..00000000 --- a/tailbone/scaffolds.py +++ /dev/null @@ -1,45 +0,0 @@ -# -*- coding: utf-8 -*- -################################################################################ -# -# Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar -# -# This file is part of Rattail. -# -# Rattail is free software: you can redistribute it and/or modify it under the -# terms of the GNU General Public License as published by the Free Software -# Foundation, either version 3 of the License, or (at your option) any later -# version. -# -# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY -# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more -# details. -# -# You should have received a copy of the GNU General Public License along with -# Rattail. If not, see <http://www.gnu.org/licenses/>. -# -################################################################################ -""" -Pyramid scaffold templates -""" - -from __future__ import unicode_literals, absolute_import - -from rattail.files import resource_path -from rattail.util import prettify - -from pyramid.scaffolds import PyramidTemplate - - -class RattailTemplate(PyramidTemplate): - _template_dir = resource_path('rattail:data/project') - summary = "Starter project based on Rattail / Tailbone" - - def pre(self, command, output_dir, vars): - """ - Adds some more variables to the template context. - """ - vars['project_title'] = prettify(vars['project']) - vars['package_title'] = vars['package'].capitalize() - return super(RattailTemplate, self).pre(command, output_dir, vars) diff --git a/tailbone/static/__init__.py b/tailbone/static/__init__.py index 2ad5161a..57700b80 100644 --- a/tailbone/static/__init__.py +++ b/tailbone/static/__init__.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,9 +24,8 @@ Static Assets """ -from __future__ import unicode_literals, absolute_import - def includeme(config): + config.include('wuttaweb.static') config.add_static_view('tailbone', 'tailbone:static') config.add_static_view('deform', 'deform:static') diff --git a/tailbone/static/css/filters.css b/tailbone/static/css/filters.css index 6deff7b0..72506a06 100644 --- a/tailbone/static/css/filters.css +++ b/tailbone/static/css/filters.css @@ -3,10 +3,6 @@ * Grid Filters ******************************/ -.filters .filter { - margin-bottom: 0.5rem; -} - .filters .filter-fieldname .field, .filters .filter-fieldname .field label { width: 100%; diff --git a/tailbone/static/css/grids.css b/tailbone/static/css/grids.css index da5814c4..42da832c 100644 --- a/tailbone/static/css/grids.css +++ b/tailbone/static/css/grids.css @@ -25,6 +25,11 @@ margin: 0; } +.grid-tools { + display: flex; + gap: 0.5rem; +} + .grid-wrapper .grid-header td.tools { margin: 0; padding: 0; diff --git a/tailbone/static/css/grids.rowstatus.css b/tailbone/static/css/grids.rowstatus.css index 9335b827..bfd73404 100644 --- a/tailbone/static/css/grids.rowstatus.css +++ b/tailbone/static/css/grids.rowstatus.css @@ -2,7 +2,7 @@ /******************************************************************************** * grids.rowstatus.css * - * Add "row status" styles for Buefy grid tables. + * Add "row status" styles for grid tables. ********************************************************************************/ /************************************************** diff --git a/tailbone/static/css/layout.css b/tailbone/static/css/layout.css index 20dbf6b7..ef5c5352 100644 --- a/tailbone/static/css/layout.css +++ b/tailbone/static/css/layout.css @@ -90,6 +90,11 @@ header span.header-text { * "object helper" panel ******************************/ +.object-helpers .panel { + margin: 1rem; + margin-bottom: 1.5rem; +} + .object-helpers .panel-heading { white-space: nowrap; } @@ -136,6 +141,12 @@ header span.header-text { overflow: visible !important; } +/* TODO: a simpler option we might try sometime instead? */ +/* cf. https://github.com/buefy/buefy/issues/292#issuecomment-1073851313 */ + +/* .dropdown-content{ */ +/* position: fixed; */ +/* } */ /****************************** * feedback diff --git a/tailbone/static/js/tailbone.buefy.datepicker.js b/tailbone/static/js/tailbone.buefy.datepicker.js index fe649380..0b861fd6 100644 --- a/tailbone/static/js/tailbone.buefy.datepicker.js +++ b/tailbone/static/js/tailbone.buefy.datepicker.js @@ -11,7 +11,7 @@ const TailboneDatepicker = { 'icon="calendar-alt"', ':date-formatter="formatDate"', ':date-parser="parseDate"', - ':value="value ? parseDate(value) : null"', + ':value="buefyValue"', '@input="dateChanged"', ':disabled="disabled"', 'ref="trueDatePicker"', @@ -26,6 +26,18 @@ const TailboneDatepicker = { disabled: Boolean, }, + data() { + return { + buefyValue: this.parseDate(this.value), + } + }, + + watch: { + value(to, from) { + this.buefyValue = this.parseDate(to) + }, + }, + methods: { formatDate(date) { @@ -43,9 +55,12 @@ const TailboneDatepicker = { }, parseDate(date) { - // note, this assumes classic YYYY-MM-DD (i.e. ISO?) format - var parts = date.split('-') - return new Date(parts[0], parseInt(parts[1]) - 1, parts[2]) + if (typeof(date) == 'string') { + // note, this assumes classic YYYY-MM-DD (i.e. ISO?) format + var parts = date.split('-') + return new Date(parts[0], parseInt(parts[1]) - 1, parts[2]) + } + return date }, dateChanged(date) { diff --git a/tailbone/static/js/tailbone.buefy.grid.js b/tailbone/static/js/tailbone.buefy.grid.js deleted file mode 100644 index 6be28f41..00000000 --- a/tailbone/static/js/tailbone.buefy.grid.js +++ /dev/null @@ -1,167 +0,0 @@ - -const GridFilterNumericValue = { - template: '#grid-filter-numeric-value-template', - props: { - value: String, - wantsRange: Boolean, - }, - data() { - return { - startValue: null, - endValue: null, - } - }, - mounted() { - if (this.wantsRange) { - if (this.value.includes('|')) { - let values = this.value.split('|') - if (values.length == 2) { - this.startValue = values[0] - this.endValue = values[1] - } else { - this.startValue = this.value - } - } else { - this.startValue = this.value - } - } else { - this.startValue = this.value - } - }, - watch: { - // when changing from e.g. 'equal' to 'between' filter verbs, - // must proclaim new filter value, to reflect (lack of) range - wantsRange(val) { - if (val) { - this.$emit('input', this.startValue + '|' + this.endValue) - } else { - this.$emit('input', this.startValue) - } - }, - }, - methods: { - focus() { - this.$refs.startValue.focus() - }, - startValueChanged(value) { - if (this.wantsRange) { - value += '|' + this.endValue - } - this.$emit('input', value) - }, - endValueChanged(value) { - value = this.startValue + '|' + value - this.$emit('input', value) - }, - }, -} - -Vue.component('grid-filter-numeric-value', GridFilterNumericValue) - - -const GridFilterDateValue = { - template: '#grid-filter-date-value-template', - props: { - value: String, - dateRange: Boolean, - }, - data() { - return { - startDate: null, - endDate: null, - } - }, - mounted() { - if (this.dateRange) { - if (this.value.includes('|')) { - let values = this.value.split('|') - if (values.length == 2) { - this.startDate = values[0] - this.endDate = values[1] - } else { - this.startDate = this.value - } - } else { - this.startDate = this.value - } - } else { - this.startDate = this.value - } - }, - methods: { - focus() { - this.$refs.startDate.focus() - }, - startDateChanged(value) { - if (this.dateRange) { - value += '|' + this.endDate - } - this.$emit('input', value) - }, - endDateChanged(value) { - value = this.startDate + '|' + value - this.$emit('input', value) - }, - }, -} - -Vue.component('grid-filter-date-value', GridFilterDateValue) - - -const GridFilter = { - template: '#grid-filter-template', - props: { - filter: Object - }, - - methods: { - - changeVerb() { - // set focus to value input, "as quickly as we can" - this.$nextTick(function() { - this.focusValue() - }) - }, - - valuedVerb() { - /* this returns true if the filter's current verb should expose value input(s) */ - - // if filter has no "valueless" verbs, then all verbs should expose value inputs - if (!this.filter.valueless_verbs) { - return true - } - - // if filter *does* have valueless verbs, check if "current" verb is valueless - if (this.filter.valueless_verbs.includes(this.filter.verb)) { - return false - } - - // current verb is *not* valueless - return true - }, - - multiValuedVerb() { - /* this returns true if the filter's current verb should expose a multi-value input */ - - // if filter has no "multi-value" verbs then we safely assume false - if (!this.filter.multiple_value_verbs) { - return false - } - - // if filter *does* have multi-value verbs, see if "current" is one - if (this.filter.multiple_value_verbs.includes(this.filter.verb)) { - return true - } - - // current verb is not multi-value - return false - }, - - focusValue: function() { - this.$refs.valueInput.focus() - // this.$refs.valueInput.select() - } - } -} - -Vue.component('grid-filter', GridFilter) diff --git a/tailbone/subscribers.py b/tailbone/subscribers.py index 1143b510..268d4818 100644 --- a/tailbone/subscribers.py +++ b/tailbone/subscribers.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,9 +24,10 @@ Event Subscribers """ -import six -import json import datetime +import logging +import warnings +from collections import OrderedDict import rattail @@ -35,167 +36,169 @@ import deform from pyramid import threadlocal from webhelpers2.html import tags +from wuttaweb import subscribers as base + import tailbone from tailbone import helpers from tailbone.db import Session from tailbone.config import csrf_header_name, should_expose_websockets -from tailbone.menus import make_simple_menus -from tailbone.util import get_global_search_options +from tailbone.util import get_available_themes, get_global_search_options -def new_request(event): +log = logging.getLogger(__name__) + + +def new_request(event, session=None): """ - Identify the current user, and cache their current permissions. Also adds - the ``rattail_config`` attribute to the request. + Event hook called when processing a new request. - A global Rattail ``config`` should already be present within the Pyramid - application registry's settings, which would normally be accessed via:: - - request.registry.settings['rattail_config'] + This first invokes the upstream hooks: - This function merely "promotes" that config object so that it is more - directly accessible, a la:: + * :func:`wuttaweb:wuttaweb.subscribers.new_request()` + * :func:`wuttaweb:wuttaweb.subscribers.new_request_set_user()` - request.rattail_config + It then adds more things to the request object; among them: - .. note:: - This of course assumes that a Rattail ``config`` object *has* in fact - already been placed in the application registry settings. If this is - not the case, this function will do nothing. + .. attribute:: request.rattail_config - Also, attach some goodies to the request object: + Reference to the app :term:`config object`. Note that this + will be the same as :attr:`wuttaweb:request.wutta_config`. - * The currently logged-in user instance (if any), as ``user``. + .. method:: request.register_component(tagname, classname) - * ``is_admin`` flag indicating whether user has the Administrator role. + Function to register a Vue component for use with the app. - * ``is_root`` flag indicating whether user is currently elevated to root. - - * A shortcut method for permission checking, as ``has_perm()``. + This can be called from wherever a component is defined, and + then in the base template all registered components will be + properly loaded. """ request = event.request - rattail_config = request.registry.settings.get('rattail_config') - # TODO: why would this ever be null? - if rattail_config: - request.rattail_config = rattail_config - def user(request): - user = None - uuid = request.authenticated_userid - if uuid: - model = request.rattail_config.get_model() - user = Session.get(model.User, uuid) - if user: - Session().set_continuum_user(user) - return user + # invoke main upstream logic + # nb. this sets request.wutta_config + base.new_request(event) - request.set_property(user, reify=True) + config = request.wutta_config + app = config.get_app() + auth = app.get_auth_handler() + session = session or Session() + + # compatibility + rattail_config = config + request.rattail_config = rattail_config + + def user_getter(request, db_session=None): + user = base.default_user_getter(request, db_session=db_session) + if user: + # nb. we also assign continuum user to session + session = db_session or Session() + session.set_continuum_user(user) + return user + + # invoke upstream hook to set user + base.new_request_set_user(event, user_getter=user_getter, db_session=session) # assign client IP address to the session, for sake of versioning - Session().continuum_remote_addr = request.client_addr + if hasattr(request, 'client_addr'): + session.continuum_remote_addr = request.client_addr - request.is_admin = bool(request.user) and request.user.is_admin() - request.is_root = request.is_admin and request.session.get('is_root', False) + # request.register_component() + def register_component(tagname, classname): + """ + Register a Vue 3 component, so the base template knows to + declare it for use within the app (page). + """ + if not hasattr(request, '_tailbone_registered_components'): + request._tailbone_registered_components = OrderedDict() - # TODO: why would this ever be null? - if rattail_config: + if tagname in request._tailbone_registered_components: + log.warning("component with tagname '%s' already registered " + "with class '%s' but we are replacing that with " + "class '%s'", + tagname, + request._tailbone_registered_components[tagname], + classname) - app = rattail_config.get_app() - auth = app.get_auth_handler() - request.tailbone_cached_permissions = auth.get_permissions( - Session(), request.user) - - def has_perm(name): - if name in request.tailbone_cached_permissions: - return True - return request.is_root - request.has_perm = has_perm - - def has_any_perm(*names): - for name in names: - if has_perm(name): - return True - return False - request.has_any_perm = has_any_perm + request._tailbone_registered_components[tagname] = classname + request.register_component = register_component def before_render(event): """ Adds goodies to the global template renderer context. """ + # log.debug("before_render: %s", event) + + # invoke upstream logic + base.before_render(event) request = event.get('request') or threadlocal.get_current_request() - rattail_config = request.rattail_config + config = request.wutta_config + app = config.get_app() renderer_globals = event - renderer_globals['rattail_app'] = request.rattail_config.get_app() - renderer_globals['app_title'] = request.rattail_config.app_title() + + # overrides renderer_globals['h'] = helpers - renderer_globals['url'] = request.route_url - renderer_globals['rattail'] = rattail - renderer_globals['tailbone'] = tailbone - renderer_globals['model'] = request.rattail_config.get_model() - renderer_globals['enum'] = request.rattail_config.get_enum() - renderer_globals['six'] = six - renderer_globals['json'] = json + + # misc. renderer_globals['datetime'] = datetime renderer_globals['colander'] = colander renderer_globals['deform'] = deform - renderer_globals['csrf_header_name'] = csrf_header_name(request.rattail_config) + renderer_globals['csrf_header_name'] = csrf_header_name(config) + + # TODO: deprecate / remove these + renderer_globals['rattail_app'] = app + renderer_globals['app_title'] = app.get_title() + renderer_globals['app_version'] = app.get_version() + renderer_globals['rattail'] = rattail + renderer_globals['tailbone'] = tailbone + renderer_globals['model'] = app.model + renderer_globals['enum'] = app.enum # theme - we only want do this for classic web app, *not* API # TODO: so, clearly we need a better way to distinguish the two if 'tailbone.theme' in request.registry.settings: renderer_globals['theme'] = request.registry.settings['tailbone.theme'] # note, this is just a global flag; user still needs permission to see picker - expose_picker = request.rattail_config.getbool('tailbone', 'themes.expose_picker', - default=False) + expose_picker = config.get_bool('tailbone.themes.expose_picker', + default=False) renderer_globals['expose_theme_picker'] = expose_picker if expose_picker: - # tailbone's config extension provides a default theme selection, - # so the default we specify here *probably* should not matter - available = request.rattail_config.getlist('tailbone', 'themes', - default=['falafel']) - if 'default' not in available: - available.insert(0, 'default') + + # TODO: should remove 'falafel' option altogether + available = get_available_themes(config) + options = [tags.Option(theme, value=theme) for theme in available] renderer_globals['theme_picker_options'] = options - # heck while we're assuming the classic web app here... - # (we don't want this to happen for the API either!) - # TODO: just..awful *shrug* - # note that we assume "simple" menus nowadays - if request.rattail_config.getbool('tailbone', 'menus.simple', default=True): - renderer_globals['menus'] = make_simple_menus(request) - # TODO: ugh, same deal here - renderer_globals['messaging_enabled'] = request.rattail_config.getbool( - 'tailbone', 'messaging.enabled', default=False) + renderer_globals['messaging_enabled'] = config.get_bool('tailbone.messaging.enabled', + default=False) # background color may be set per-request, by some apps if hasattr(request, 'background_color') and request.background_color: renderer_globals['background_color'] = request.background_color else: # otherwise we use the one from config - renderer_globals['background_color'] = request.rattail_config.get( - 'tailbone', 'background_color') - - # TODO: remove this hack once nothing references it - renderer_globals['buefy_0_8'] = False + renderer_globals['background_color'] = config.get('tailbone.background_color') # maybe set custom stylesheet css = None if request.user: - css = request.rattail_config.get('tailbone.{}'.format(request.user.uuid), - 'buefy_css') - if not css: - css = request.rattail_config.get('tailbone', 'theme.falafel.buefy_css') - renderer_globals['buefy_css'] = css + css = config.get(f'tailbone.{request.user.uuid}', 'user_css') + if not css: + css = config.get(f'tailbone.{request.user.uuid}', 'buefy_css') + if css: + warnings.warn(f"setting 'tailbone.{request.user.uuid}.buefy_css' should be" + f"changed to 'tailbone.{request.user.uuid}.user_css'", + DeprecationWarning) + renderer_globals['user_css'] = css # add global search data for quick access renderer_globals['global_search_data'] = get_global_search_options(request) # here we globally declare widths for grid filter pseudo-columns - widths = request.rattail_config.get('tailbone', 'grids.filters.column_widths') + widths = config.get('tailbone.grids.filters.column_widths') if widths: widths = widths.split(';') if len(widths) < 2: @@ -206,7 +209,7 @@ def before_render(event): renderer_globals['filter_verb_width'] = widths[1] # declare global support for websockets, or lack thereof - renderer_globals['expose_websockets'] = should_expose_websockets(rattail_config) + renderer_globals['expose_websockets'] = should_expose_websockets(config) def add_inbox_count(event): @@ -220,8 +223,9 @@ def add_inbox_count(event): request = event.get('request') or threadlocal.get_current_request() if request.user: renderer_globals = event + app = request.rattail_config.get_app() + model = app.model enum = request.rattail_config.get_enum() - model = request.rattail_config.get_model() renderer_globals['inbox_count'] = Session.query(model.Message)\ .outerjoin(model.MessageRecipient)\ .filter(model.MessageRecipient.recipient == Session.merge(request.user))\ @@ -235,27 +239,10 @@ def context_found(event): The following is attached to the request: - * ``get_referrer()`` function - * ``get_session_timeout()`` function """ request = event.request - def get_referrer(default=None, **kwargs): - if request.params.get('referrer'): - return request.params['referrer'] - if request.session.get('referrer'): - return request.session.pop('referrer') - referrer = request.referrer - if (not referrer or referrer == request.current_route_url() - or not referrer.startswith(request.host_url)): - if default: - referrer = default - else: - referrer = request.route_url('home') - return referrer - request.get_referrer = get_referrer - def get_session_timeout(): """ Returns the timeout in effect for the current session diff --git a/tailbone/templates/appinfo/configure.mako b/tailbone/templates/appinfo/configure.mako index 8483a7a2..9d866cea 100644 --- a/tailbone/templates/appinfo/configure.mako +++ b/tailbone/templates/appinfo/configure.mako @@ -1,241 +1,2 @@ ## -*- coding: utf-8; -*- -<%inherit file="/configure.mako" /> - -<%def name="form_content()"> - - <h3 class="block is-size-3">Basics</h3> - <div class="block" style="padding-left: 2rem;"> - - <b-field grouped> - - <b-field label="App Title"> - <b-input name="rattail.app_title" - v-model="simpleSettings['rattail.app_title']" - @input="settingsNeedSaved = true"> - </b-input> - </b-field> - - <b-field label="Node Type"> - ## TODO: should be a dropdown, app handler defines choices - <b-input name="rattail.node_type" - v-model="simpleSettings['rattail.node_type']" - @input="settingsNeedSaved = true"> - </b-input> - </b-field> - - <b-field label="Node Title"> - <b-input name="rattail.node_title" - v-model="simpleSettings['rattail.node_title']" - @input="settingsNeedSaved = true"> - </b-input> - </b-field> - - </b-field> - - <b-field> - <b-checkbox name="rattail.production" - v-model="simpleSettings['rattail.production']" - native-value="true" - @input="settingsNeedSaved = true"> - Production Mode - </b-checkbox> - </b-field> - - <div class="level-left"> - <div class="level-item"> - <b-field> - <b-checkbox name="rattail.running_from_source" - v-model="simpleSettings['rattail.running_from_source']" - native-value="true" - @input="settingsNeedSaved = true"> - Running from Source - </b-checkbox> - </b-field> - </div> - <div class="level-item"> - <b-field label="Top-Level Package" horizontal - v-if="simpleSettings['rattail.running_from_source']"> - <b-input name="rattail.running_from_source.rootpkg" - v-model="simpleSettings['rattail.running_from_source.rootpkg']" - @input="settingsNeedSaved = true"> - </b-input> - </b-field> - </div> - </div> - - </div> - - <h3 class="block is-size-3">Display</h3> - <div class="block" style="padding-left: 2rem;"> - - <b-field grouped> - - <b-field label="Background Color"> - <b-input name="tailbone.background_color" - v-model="simpleSettings['tailbone.background_color']" - @input="settingsNeedSaved = true"> - </b-input> - </b-field> - - </b-field> - - </div> - - <h3 class="block is-size-3">Grids</h3> - <div class="block" style="padding-left: 2rem;"> - - <b-field grouped> - - <b-field label="Default Page Size"> - <b-input name="tailbone.grid.default_pagesize" - v-model="simpleSettings['tailbone.grid.default_pagesize']" - @input="settingsNeedSaved = true"> - </b-input> - </b-field> - - </b-field> - - </div> - - <h3 class="block is-size-3">Web Libraries</h3> - <div class="block" style="padding-left: 2rem;"> - - <b-table :data="weblibs"> - - <b-table-column field="title" - label="Name" - v-slot="props"> - {{ props.row.title }} - </b-table-column> - - <b-table-column field="configured_version" - label="Version" - v-slot="props"> - {{ props.row.configured_version || props.row.default_version }} - </b-table-column> - - <b-table-column field="configured_url" - label="URL Override" - v-slot="props"> - {{ props.row.configured_url }} - </b-table-column> - - <b-table-column field="live_url" - label="Effective (Live) URL" - v-slot="props"> - <span v-if="props.row.modified" - class="has-text-warning"> - save settings and refresh page to see new URL - </span> - <span v-if="!props.row.modified"> - {{ props.row.live_url }} - </span> - </b-table-column> - - <b-table-column field="actions" - label="Actions" - v-slot="props"> - <a href="#" - @click.prevent="editWebLibraryInit(props.row)"> - <i class="fas fa-edit"></i> - Edit - </a> - </b-table-column> - - </b-table> - - % for weblib in weblibs: - ${h.hidden('tailbone.libver.{}'.format(weblib['key']), **{':value': "simpleSettings['tailbone.libver.{}']".format(weblib['key'])})} - ${h.hidden('tailbone.liburl.{}'.format(weblib['key']), **{':value': "simpleSettings['tailbone.liburl.{}']".format(weblib['key'])})} - % endfor - - <b-modal has-modal-card - :active.sync="editWebLibraryShowDialog"> - <div class="modal-card"> - - <header class="modal-card-head"> - <p class="modal-card-title">Web Library: {{ editWebLibraryRecord.title }}</p> - </header> - - <section class="modal-card-body"> - - <b-field grouped> - - <b-field label="Default Version"> - <b-input v-model="editWebLibraryRecord.default_version" - disabled> - </b-input> - </b-field> - - <b-field label="Override Version"> - <b-input v-model="editWebLibraryVersion"> - </b-input> - </b-field> - - </b-field> - - <b-field label="Override URL"> - <b-input v-model="editWebLibraryURL"> - </b-input> - </b-field> - - <b-field label="Effective URL (as of last page load)"> - <b-input v-model="editWebLibraryRecord.live_url" - disabled> - </b-input> - </b-field> - - </section> - - <footer class="modal-card-foot"> - <b-button type="is-primary" - @click="editWebLibrarySave()" - icon-pack="fas" - icon-left="save"> - Save - </b-button> - <b-button @click="editWebLibraryShowDialog = false"> - Cancel - </b-button> - </footer> - </div> - </b-modal> - - </div> -</%def> - -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> - - ThisPageData.weblibs = ${json.dumps(weblibs)|n} - - ThisPageData.editWebLibraryShowDialog = false - ThisPageData.editWebLibraryRecord = {} - ThisPageData.editWebLibraryVersion = null - ThisPageData.editWebLibraryURL = null - - ThisPage.methods.editWebLibraryInit = function(row) { - this.editWebLibraryRecord = row - this.editWebLibraryVersion = row.configured_version - this.editWebLibraryURL = row.configured_url - this.editWebLibraryShowDialog = true - } - - ThisPage.methods.editWebLibrarySave = function() { - this.editWebLibraryRecord.configured_version = this.editWebLibraryVersion - this.editWebLibraryRecord.configured_url = this.editWebLibraryURL - this.editWebLibraryRecord.modified = true - - this.simpleSettings[`tailbone.libver.${'$'}{this.editWebLibraryRecord.key}`] = this.editWebLibraryVersion - this.simpleSettings[`tailbone.liburl.${'$'}{this.editWebLibraryRecord.key}`] = this.editWebLibraryURL - - this.settingsNeedSaved = true - this.editWebLibraryShowDialog = false - } - - </script> -</%def> - - -${parent.body()} +<%inherit file="wuttaweb:templates/appinfo/configure.mako" /> diff --git a/tailbone/templates/appinfo/index.mako b/tailbone/templates/appinfo/index.mako index 62a911ee..faaea935 100644 --- a/tailbone/templates/appinfo/index.mako +++ b/tailbone/templates/appinfo/index.mako @@ -1,8 +1,7 @@ ## -*- coding: utf-8; -*- -<%inherit file="/master/index.mako" /> - -<%def name="render_grid_component()"> +<%inherit file="wuttaweb:templates/appinfo/index.mako" /> +<%def name="page_content()"> <div class="buttons"> <once-button type="is-primary" @@ -28,98 +27,5 @@ </div> - <b-collapse class="panel" open> - - <template #trigger="props"> - <div class="panel-heading" - role="button"> - - ## TODO: for some reason buefy will "reuse" the icon - ## element in such a way that its display does not - ## refresh. so to work around that, we use different - ## structure for the two icons, so buefy is forced to - ## re-draw - - <b-icon v-if="props.open" - pack="fas" - icon="angle-down"> - </b-icon> - - <span v-if="!props.open"> - <b-icon pack="fas" - icon="angle-right"> - </b-icon> - </span> - - <span>Configuration Files (style: ${request.rattail_config._style})</span> - </div> - </template> - - <div class="panel-block"> - <div style="width: 100%;"> - <b-table :data="configFiles"> - - <b-table-column field="priority" - label="Priority" - v-slot="props"> - {{ props.row.priority }} - </b-table-column> - - <b-table-column field="path" - label="File Path" - v-slot="props"> - {{ props.row.path }} - </b-table-column> - - </b-table> - </div> - </div> - </b-collapse> - - <b-collapse class="panel" - :open="false"> - - <template #trigger="props"> - <div class="panel-heading" - role="button"> - - ## TODO: for some reason buefy will "reuse" the icon - ## element in such a way that its display does not - ## refresh. so to work around that, we use different - ## structure for the two icons, so buefy is forced to - ## re-draw - - <b-icon v-if="props.open" - pack="fas" - icon="angle-down"> - </b-icon> - - <span v-if="!props.open"> - <b-icon pack="fas" - icon="angle-right"> - </b-icon> - </span> - - <strong>Installed Packages</strong> - </div> - </template> - - <div class="panel-block"> - <div style="width: 100%;"> - ${parent.render_grid_component()} - </div> - </div> - </b-collapse> + ${parent.page_content()} </%def> - -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> - - ThisPageData.configFiles = ${json.dumps([dict(path=p, priority=i) for i, p in enumerate(request.rattail_config.prioritized_files, 1)])|n} - - </script> -</%def> - - -${parent.body()} diff --git a/tailbone/templates/appsettings.mako b/tailbone/templates/appsettings.mako index 46f4a7e3..ba667e0e 100644 --- a/tailbone/templates/appsettings.mako +++ b/tailbone/templates/appsettings.mako @@ -15,8 +15,8 @@ <app-settings :groups="groups" :showing-group="showingGroup"></app-settings> </%def> -<%def name="render_this_page_template()"> - ${parent.render_this_page_template()} +<%def name="render_vue_templates()"> + ${parent.render_vue_templates()} <script type="text/x-template" id="app-settings-template"> <div class="form"> @@ -150,19 +150,18 @@ </script> </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> - ThisPageData.groups = ${json.dumps(buefy_data)|n} +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> + ThisPageData.groups = ${json.dumps(settings_data)|n} ThisPageData.showingGroup = ${json.dumps(current_group or '')|n} - </script> </%def> -<%def name="make_this_page_component()"> - ${parent.make_this_page_component()} - <script type="text/javascript"> +<%def name="make_vue_components()"> + ${parent.make_vue_components()} + <script> Vue.component('app-settings', { template: '#app-settings-template', @@ -193,6 +192,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/base.mako b/tailbone/templates/base.mako index 53dc3423..8228f823 100644 --- a/tailbone/templates/base.mako +++ b/tailbone/templates/base.mako @@ -1,8 +1,10 @@ ## -*- coding: utf-8; -*- +<%namespace file="/wutta-components.mako" import="make_wutta_components" /> <%namespace file="/grids/nav.mako" import="grid_index_nav" /> <%namespace file="/autocomplete.mako" import="tailbone_autocomplete_template" /> <%namespace name="base_meta" file="/base_meta.mako" /> <%namespace file="/formposter.mako" import="declare_formposter_mixin" /> +<%namespace file="/grids/filter-components.mako" import="make_grid_filter_components" /> <%namespace name="page_help" file="/page_help.mako" /> <%namespace name="multi_file_upload" file="/multi_file_upload.mako" /> <!DOCTYPE html> @@ -33,17 +35,21 @@ </head> <body> - ${declare_formposter_mixin()} - - ${self.body()} - - <div id="whole-page-app"> + <div id="app" style="height: 100%;"> <whole-page></whole-page> </div> - ${self.render_whole_page_template()} - ${self.make_whole_page_component()} - ${self.make_whole_page_app()} + ## TODO: this must come before the self.body() call..but why? + ${declare_formposter_mixin()} + + ## content body from derived/child template + ${self.body()} + + ## Vue app + ${self.render_vue_templates()} + ${self.modify_vue_vars()} + ${self.make_vue_components()} + ${self.make_vue_app()} </body> </html> @@ -90,7 +96,6 @@ ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.numericinput.js') + '?ver={}'.format(tailbone.__version__))} ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.oncebutton.js') + '?ver={}'.format(tailbone.__version__))} ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.timepicker.js') + '?ver={}'.format(tailbone.__version__))} - ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.grid.js') + '?ver={}'.format(tailbone.__version__))} <script type="text/javascript"> @@ -122,16 +127,16 @@ </%def> <%def name="vuejs()"> - ${h.javascript_link(h.get_liburl(request, 'vue'))} - ${h.javascript_link(h.get_liburl(request, 'vue_resource'))} + ${h.javascript_link(h.get_liburl(request, 'vue', prefix='tailbone'))} + ${h.javascript_link(h.get_liburl(request, 'vue_resource', prefix='tailbone'))} </%def> <%def name="buefy()"> - ${h.javascript_link(h.get_liburl(request, 'buefy'))} + ${h.javascript_link(h.get_liburl(request, 'buefy', prefix='tailbone'))} </%def> <%def name="fontawesome()"> - <script defer src="${h.get_liburl(request, 'fontawesome')}"></script> + <script defer src="${h.get_liburl(request, 'fontawesome', prefix='tailbone')}"></script> </%def> <%def name="extra_javascript()"></%def> @@ -151,31 +156,37 @@ ${h.stylesheet_link(request.static_url('tailbone:static/css/codehilite.css') + '?ver={}'.format(tailbone.__version__))} <style type="text/css"> - .filters .filter-fieldname { + .filters .filter-fieldname, + .filters .filter-fieldname .button { + % if filter_fieldname_width is not Undefined: min-width: ${filter_fieldname_width}; + % endif justify-content: left; } + % if filter_fieldname_width is not Undefined: .filters .filter-verb { min-width: ${filter_verb_width}; } + % endif </style> </%def> <%def name="buefy_styles()"> - % if buefy_css: - ## custom Buefy CSS - ${h.stylesheet_link(buefy_css)} + % if user_css: + ${h.stylesheet_link(user_css)} % else: ## upstream Buefy CSS - ${h.stylesheet_link(h.get_liburl(request, 'buefy.css'))} + ${h.stylesheet_link(h.get_liburl(request, 'buefy.css', prefix='tailbone'))} % endif </%def> -<%def name="extra_styles()"></%def> +<%def name="extra_styles()"> + ${base_meta.extra_styles()} +</%def> <%def name="head_tags()"></%def> -<%def name="render_whole_page_template()"> +<%def name="render_vue_template_whole_page()"> <script type="text/x-template" id="whole-page-template"> <div> <header> @@ -274,7 +285,7 @@ <span class="header-text"> ${index_title} </span> - % if master.creatable and master.show_create_link and master.has_perm('create'): + % if master.creatable and getattr(master, 'show_create_link', True) and master.has_perm('create'): <once-button type="is-primary" tag="a" href="${url('{}.create'.format(route_prefix))}" icon-left="plus" @@ -300,7 +311,7 @@ <span class="header-text"> ${h.link_to(instance_title, instance_url)} </span> - % elif master.creatable and master.show_create_link and master.has_perm('create'): + % elif master.creatable and getattr(master, 'show_create_link', True) and master.has_perm('create'): % if not request.matched_route.name.endswith('.create'): <once-button type="is-primary" tag="a" href="${url('{}.create'.format(route_prefix))}" @@ -339,9 +350,15 @@ ${h.form(url('change_db_engine'), ref='dbPickerForm')} ${h.csrf_token(request)} ${h.hidden('engine_type', value=master.engine_type_key)} - <div class="select"> - ${h.select('dbkey', db_picker_selected, db_picker_options, **{'@change': 'changeDB()'})} - </div> + <b-select name="dbkey" + value="${db_picker_selected}" + @input="changeDB()"> + % for option in db_picker_options: + <option value="${option.value}"> + ${option.label} + </option> + % endfor + </b-select> ${h.end_form()} </div> % endif @@ -393,11 +410,12 @@ <div class="level-item"> ${h.form(url('change_theme'), method="post", ref='themePickerForm')} ${h.csrf_token(request)} + <input type="hidden" name="referrer" :value="referrer" /> <div style="display: flex; align-items: center; gap: 0.5rem;"> <span>Theme:</span> <b-select name="theme" v-model="globalTheme" - @change="changeTheme()"> + @input="changeTheme()"> % for option in theme_picker_options: <option value="${option.value}"> ${option.label} @@ -505,7 +523,7 @@ <b-button type="is-primary" @click="showFeedback()" icon-pack="fas" - icon-left="fas fa-comment"> + icon-left="comment"> Feedback </b-button> </div> @@ -614,9 +632,23 @@ % endif <div class="navbar-dropdown"> % if request.is_root: - ${h.link_to("Stop being root", url('stop_root'), class_='navbar-item root-user')} + ${h.form(url('stop_root'), ref='stopBeingRootForm')} + ${h.csrf_token(request)} + <input type="hidden" name="referrer" value="${request.current_route_url()}" /> + <a @click="$refs.stopBeingRootForm.submit()" + class="navbar-item root-user"> + Stop being root + </a> + ${h.end_form()} % elif request.is_admin: - ${h.link_to("Become root", url('become_root'), class_='navbar-item root-user')} + ${h.form(url('become_root'), ref='startBeingRootForm')} + ${h.csrf_token(request)} + <input type="hidden" name="referrer" value="${request.current_route_url()}" /> + <a @click="$refs.startBeingRootForm.submit()" + class="navbar-item root-user"> + Become root + </a> + ${h.end_form()} % endif % if messaging_enabled: ${h.link_to("Messages{}".format(" ({})".format(inbox_count) if inbox_count else ''), url('messages.inbox'), class_='navbar-item')} @@ -624,7 +656,11 @@ % if request.is_root or not request.user.prevent_password_change: ${h.link_to("Change Password", url('change_password'), class_='navbar-item')} % endif - ${h.link_to("Edit Preferences", url('my.preferences'), class_='navbar-item')} + % try: + ## nb. does not exist yet for wuttaweb + ${h.link_to("Edit Preferences", url('my.preferences'), class_='navbar-item')} + % except: + % endtry ${h.link_to("Logout", url('logout'), class_='navbar-item')} </div> </div> @@ -645,19 +681,19 @@ ## TODO: is there a better way to check if viewing parent? % if parent_instance is Undefined: % if master.editable and instance_editable and master.has_perm('edit'): - <once-button tag="a" href="${action_url('edit', instance)}" + <once-button tag="a" href="${master.get_action_url('edit', instance)}" icon-left="edit" text="Edit This"> </once-button> % endif - % if master.cloneable and master.has_perm('clone'): - <once-button tag="a" href="${action_url('clone', instance)}" + % if getattr(master, 'cloneable', False) and not master.cloning and master.has_perm('clone'): + <once-button tag="a" href="${master.get_action_url('clone', instance)}" icon-left="object-ungroup" text="Clone This"> </once-button> % endif % if master.deletable and instance_deletable and master.has_perm('delete'): - <once-button tag="a" href="${action_url('delete', instance)}" + <once-button tag="a" href="${master.get_action_url('delete', instance)}" type="is-danger" icon-left="trash" text="Delete This"> @@ -666,7 +702,7 @@ % else: ## viewing row % if instance_deletable and master.has_perm('delete_row'): - <once-button tag="a" href="${action_url('delete', instance)}" + <once-button tag="a" href="${master.get_action_url('delete', instance)}" type="is-danger" icon-left="trash" text="Delete This"> @@ -675,13 +711,13 @@ % endif % elif master and master.editing: % if master.viewable and master.has_perm('view'): - <once-button tag="a" href="${action_url('view', instance)}" + <once-button tag="a" href="${master.get_action_url('view', instance)}" icon-left="eye" text="View This"> </once-button> % endif % if master.deletable and instance_deletable and master.has_perm('delete'): - <once-button tag="a" href="${action_url('delete', instance)}" + <once-button tag="a" href="${master.get_action_url('delete', instance)}" type="is-danger" icon-left="trash" text="Delete This"> @@ -689,13 +725,13 @@ % endif % elif master and master.deleting: % if master.viewable and master.has_perm('view'): - <once-button tag="a" href="${action_url('view', instance)}" + <once-button tag="a" href="${master.get_action_url('view', instance)}" icon-left="eye" text="View This"> </once-button> % endif % if master.editable and instance_editable and master.has_perm('edit'): - <once-button tag="a" href="${action_url('edit', instance)}" + <once-button tag="a" href="${master.get_action_url('edit', instance)}" icon-left="edit" text="Edit This"> </once-button> @@ -736,11 +772,8 @@ % endif </%def> -<%def name="declare_whole_page_vars()"> - ${page_help.declare_vars()} - ${multi_file_upload.declare_vars()} - ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.feedback.js') + '?ver={}'.format(tailbone.__version__))} - <script type="text/javascript"> +<%def name="render_vue_script_whole_page()"> + <script> let WholePage = { template: '#whole-page-template', @@ -847,7 +880,8 @@ feedbackMessage: "", % if expose_theme_picker and request.has_perm('common.change_app_theme'): - globalTheme: ${json.dumps(theme)|n}, + globalTheme: ${json.dumps(theme or None)|n}, + referrer: location.href, % endif % if can_edit_help: @@ -856,7 +890,7 @@ globalSearchActive: false, globalSearchTerm: '', - globalSearchData: ${json.dumps(global_search_data)|n}, + globalSearchData: ${json.dumps(global_search_data or [])|n}, mountedHooks: [], } @@ -875,54 +909,6 @@ </script> </%def> -<%def name="modify_whole_page_vars()"> - <script type="text/javascript"> - - % if request.user: - FeedbackFormData.userUUID = ${json.dumps(request.user.uuid)|n} - FeedbackFormData.userName = ${json.dumps(six.text_type(request.user))|n} - % endif - - </script> -</%def> - -<%def name="finalize_whole_page_vars()"> - ## NOTE: if you override this, must use <script> tags -</%def> - -<%def name="make_whole_page_component()"> - ${self.declare_whole_page_vars()} - ${self.modify_whole_page_vars()} - ${self.finalize_whole_page_vars()} - - ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.autocomplete.js') + '?ver={}'.format(tailbone.__version__))} - - ${page_help.make_component()} - ${multi_file_upload.make_component()} - - <script type="text/javascript"> - - FeedbackForm.data = function() { return FeedbackFormData } - - Vue.component('feedback-form', FeedbackForm) - - WholePage.data = function() { return WholePageData } - - Vue.component('whole-page', WholePage) - - </script> -</%def> - -<%def name="make_whole_page_app()"> - <script type="text/javascript"> - - new Vue({ - el: '#whole-page-app' - }) - - </script> -</%def> - <%def name="wtfield(form, name, **kwargs)"> <div class="field-wrapper${' error' if form[name].errors else ''}"> <label for="${name}">${form[name].label}</label> @@ -944,3 +930,88 @@ </div> </div> </%def> + +############################## +## vue components + app +############################## + +<%def name="render_vue_templates()"> + ${page_help.declare_vars()} + ${multi_file_upload.declare_vars()} + ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.feedback.js') + '?ver={}'.format(tailbone.__version__))} + ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.autocomplete.js') + '?ver={}'.format(tailbone.__version__))} + + ## DEPRECATED; called for back-compat + ${self.render_whole_page_template()} +</%def> + +## DEPRECATED; remains for back-compat +<%def name="render_whole_page_template()"> + ${self.render_vue_template_whole_page()} + ${self.declare_whole_page_vars()} +</%def> + +## DEPRECATED; remains for back-compat +<%def name="declare_whole_page_vars()"> + ${self.render_vue_script_whole_page()} +</%def> + +<%def name="modify_vue_vars()"> + ## DEPRECATED; called for back-compat + ${self.modify_whole_page_vars()} +</%def> + +## DEPRECATED; remains for back-compat +<%def name="modify_whole_page_vars()"> + <script> + + % if request.user: + FeedbackFormData.userUUID = ${json.dumps(request.user.uuid)|n} + FeedbackFormData.userName = ${json.dumps(str(request.user))|n} + % endif + + </script> +</%def> + +<%def name="make_vue_components()"> + ${make_wutta_components()} + ${make_grid_filter_components()} + ${page_help.make_component()} + ${multi_file_upload.make_component()} + <script> + FeedbackForm.data = function() { return FeedbackFormData } + Vue.component('feedback-form', FeedbackForm) + </script> + + ## DEPRECATED; called for back-compat + ${self.finalize_whole_page_vars()} + ${self.make_whole_page_component()} +</%def> + +## DEPRECATED; remains for back-compat +<%def name="make_whole_page_component()"> + <script> + WholePage.data = function() { return WholePageData } + Vue.component('whole-page', WholePage) + </script> +</%def> + +<%def name="make_vue_app()"> + ## DEPRECATED; called for back-compat + ${self.make_whole_page_app()} +</%def> + +## DEPRECATED; remains for back-compat +<%def name="make_whole_page_app()"> + <script> + new Vue({ + el: '#app' + }) + </script> +</%def> + +############################## +## DEPRECATED +############################## + +<%def name="finalize_whole_page_vars()"></%def> diff --git a/tailbone/templates/base_meta.mako b/tailbone/templates/base_meta.mako index 568782b7..b6376448 100644 --- a/tailbone/templates/base_meta.mako +++ b/tailbone/templates/base_meta.mako @@ -1,8 +1,7 @@ ## -*- coding: utf-8; -*- +<%inherit file="wuttaweb:templates/base_meta.mako" /> -<%def name="app_title()">${request.rattail_config.node_title(default="Rattail")}</%def> - -<%def name="global_title()">${"[STAGE] " if not request.rattail_config.production() else ''}${self.app_title()}</%def> +<%def name="app_title()">${app.get_node_title()}</%def> <%def name="favicon()"> <link rel="icon" type="image/x-icon" href="${request.rattail_config.get('tailbone', 'favicon_url', default=request.static_url('tailbone:static/img/rattail.ico'))}" /> @@ -11,9 +10,3 @@ <%def name="header_logo()"> ${h.image(request.rattail_config.get('tailbone', 'header_image_url', default=request.static_url('tailbone:static/img/rattail.ico')), "Header Logo", style="height: 49px;")} </%def> - -<%def name="footer()"> - <p class="has-text-centered"> - powered by ${h.link_to("Rattail", url('about'))} - </p> -</%def> diff --git a/tailbone/templates/batch/importer/view_row.mako b/tailbone/templates/batch/importer/view_row.mako index 9e08cf43..7d6f121f 100644 --- a/tailbone/templates/batch/importer/view_row.mako +++ b/tailbone/templates/batch/importer/view_row.mako @@ -68,7 +68,7 @@ % endif </%def> -<%def name="render_buefy_form()"> +<%def name="render_form()"> <div class="form"> <tailbone-form></tailbone-form> <br /> diff --git a/tailbone/templates/batch/index.mako b/tailbone/templates/batch/index.mako index 3ea76641..bea10a97 100644 --- a/tailbone/templates/batch/index.mako +++ b/tailbone/templates/batch/index.mako @@ -9,7 +9,7 @@ <b-button type="is-primary" :disabled="refreshResultsButtonDisabled" icon-pack="fas" - icon-left="fas fa-redo" + icon-left="redo" @click="refreshResults()"> {{ refreshResultsButtonText }} </b-button> @@ -43,7 +43,7 @@ <br /> <div class="form-wrapper"> <div class="form"> - <${execute_form.component} ref="executeResultsForm"></${execute_form.component}> + ${execute_form.render_vue_tag(ref='executeResultsForm')} </div> </div> </section> @@ -64,10 +64,17 @@ % endif </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} +<%def name="render_vue_templates()"> + ${parent.render_vue_templates()} + % if master.results_executable and master.has_perm('execute_multiple'): + ${execute_form.render_vue_template(form_kwargs={'ref': 'actualExecuteForm'}, buttons=False)} + % endif +</%def> + +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} % if master.results_refreshable and master.has_perm('refresh'): - <script type="text/javascript"> + <script> TailboneGridData.refreshResultsButtonText = "Refresh Results" TailboneGridData.refreshResultsButtonDisabled = false @@ -81,9 +88,9 @@ </script> % endif % if master.results_executable and master.has_perm('execute_multiple'): - <script type="text/javascript"> + <script> - ${execute_form.component_studly}.methods.submit = function() { + ${execute_form.vue_component}.methods.submit = function() { this.$refs.actualExecuteForm.submit() } @@ -118,25 +125,9 @@ % endif </%def> -<%def name="make_this_page_component()"> - ${parent.make_this_page_component()} +<%def name="make_vue_components()"> + ${parent.make_vue_components()} % if master.results_executable and master.has_perm('execute_multiple'): - <script type="text/javascript"> - - ${execute_form.component_studly}.data = function() { return ${execute_form.component_studly}Data } - - Vue.component('${execute_form.component}', ${execute_form.component_studly}) - - </script> + ${execute_form.render_vue_finalize()} % endif </%def> - -<%def name="render_this_page_template()"> - ${parent.render_this_page_template()} - % if master.results_executable and master.has_perm('execute_multiple'): - ${execute_form.render_deform(form_kwargs={'ref': 'actualExecuteForm'}, buttons=False)|n} - % endif -</%def> - - -${parent.body()} diff --git a/tailbone/templates/batch/inventory/desktop_form.mako b/tailbone/templates/batch/inventory/desktop_form.mako index 9f13cbf9..cddaa2c5 100644 --- a/tailbone/templates/batch/inventory/desktop_form.mako +++ b/tailbone/templates/batch/inventory/desktop_form.mako @@ -34,7 +34,7 @@ </nav> </%def> -<%def name="render_form()"> +<%def name="render_form_template()"> <script type="text/x-template" id="${form.component}-template"> <div class="product-info"> @@ -147,7 +147,7 @@ <script type="text/javascript"> - let ${form.component_studly} = { + let ${form.vue_component} = { template: '#${form.component}-template', mixins: [SimpleRequestMixin], @@ -278,7 +278,7 @@ }, } - let ${form.component_studly}Data = { + let ${form.vue_component}Data = { submitting: false, productUPC: null, @@ -297,14 +297,9 @@ </script> </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> - +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> ThisPageData.toggleCompleteSubmitting = false - </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/batch/pos/view.mako b/tailbone/templates/batch/pos/view.mako index 0da755aa..5ecabd4d 100644 --- a/tailbone/templates/batch/pos/view.mako +++ b/tailbone/templates/batch/pos/view.mako @@ -1,13 +1,9 @@ ## -*- coding: utf-8; -*- <%inherit file="/batch/view.mako" /> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> - - ${form.component_studly}Data.taxesData = ${json.dumps(taxes_data)|n} - +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> + ${form.vue_component}Data.taxesData = ${json.dumps(taxes_data)|n} </script> </%def> - -${parent.body()} diff --git a/tailbone/templates/batch/vendorcatalog/configure.mako b/tailbone/templates/batch/vendorcatalog/configure.mako index 0d57053e..4f91cb02 100644 --- a/tailbone/templates/batch/vendorcatalog/configure.mako +++ b/tailbone/templates/batch/vendorcatalog/configure.mako @@ -39,14 +39,9 @@ </div> </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> - +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> ThisPageData.catalogParsers = ${json.dumps(catalog_parsers_data)|n} - </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/batch/vendorcatalog/create.mako b/tailbone/templates/batch/vendorcatalog/create.mako index d25c8f16..d9d62bd1 100644 --- a/tailbone/templates/batch/vendorcatalog/create.mako +++ b/tailbone/templates/batch/vendorcatalog/create.mako @@ -1,16 +1,16 @@ ## -*- coding: utf-8; -*- <%inherit file="/batch/create.mako" /> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> - ${form.component_studly}Data.parsers = ${json.dumps(parsers_data)|n} + ${form.vue_component}Data.parsers = ${json.dumps(parsers_data)|n} - ${form.component_studly}Data.vendorName = null - ${form.component_studly}Data.vendorNameReplacement = null + ${form.vue_component}Data.vendorName = null + ${form.vue_component}Data.vendorNameReplacement = null - ${form.component_studly}.watch.field_model_parser_key = function(val) { + ${form.vue_component}.watch.field_model_parser_key = function(val) { let parser = this.parsers[val] if (parser.vendor_uuid) { if (this.field_model_vendor_uuid != parser.vendor_uuid) { @@ -24,11 +24,11 @@ } } - ${form.component_studly}.methods.vendorLabelChanging = function(label) { + ${form.vue_component}.methods.vendorLabelChanging = function(label) { this.vendorNameReplacement = label } - ${form.component_studly}.methods.vendorChanged = function(uuid) { + ${form.vue_component}.methods.vendorChanged = function(uuid) { if (uuid) { this.vendorName = this.vendorNameReplacement this.vendorNameReplacement = null @@ -37,6 +37,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/batch/vendorcatalog/view_row.mako b/tailbone/templates/batch/vendorcatalog/view_row.mako index 6aaf9bf4..0128e3b3 100644 --- a/tailbone/templates/batch/vendorcatalog/view_row.mako +++ b/tailbone/templates/batch/vendorcatalog/view_row.mako @@ -1,7 +1,7 @@ ## -*- coding: utf-8; -*- <%inherit file="/master/view_row.mako" /> -<%def name="render_buefy_form()"> +<%def name="render_form()"> <div class="form"> <tailbone-form></tailbone-form> <br /> diff --git a/tailbone/templates/batch/view.mako b/tailbone/templates/batch/view.mako index fa8fa19f..7c81ab0e 100644 --- a/tailbone/templates/batch/view.mako +++ b/tailbone/templates/batch/view.mako @@ -50,12 +50,12 @@ <b-button tag="a" href="${master.get_action_url('download_worksheet', batch)}" icon-pack="fas" - icon-left="fas fa-download"> + icon-left="download"> Download Worksheet </b-button> <b-button type="is-primary" icon-pack="fas" - icon-left="fas fa-upload" + icon-left="upload" @click="$emit('show-upload')"> Upload Worksheet </b-button> @@ -68,28 +68,28 @@ </%def> <%def name="render_status_breakdown()"> - <div class="object-helper"> - <h3>Row Status Breakdown</h3> - <div class="object-helper-content"> - ${status_breakdown_grid} + <nav class="panel"> + <p class="panel-heading">Row Status</p> + <div class="panel-block"> + <div style="width: 100%;"> + ${status_breakdown_grid} + </div> </div> - </div> + </nav> </%def> <%def name="render_execute_helper()"> - <div class="object-helper"> - <h3>Batch Execution</h3> - <div class="object-helper-content"> + <nav class="panel"> + <p class="panel-heading">Execution</p> + <div class="panel-block"> + <div style="display: flex; flex-direction: column; gap: 0.5rem;"> % if batch.executed: <p> - Batch was executed ${h.pretty_datetime(request.rattail_config, batch.executed)} by ${batch.executed_by} </p> % elif master.handler.executable(batch): % if master.has_perm('execute'): - <p>Batch has not yet been executed.</p> - <br /> <b-button type="is-primary" % if not execute_enabled: disabled @@ -119,8 +119,7 @@ <div class="markdown"> ${execution_described|n} </div> - <${execute_form.component} ref="executeBatchForm"> - </${execute_form.component}> + ${execute_form.render_vue_tag(ref='executeBatchForm')} </section> <footer class="modal-card-foot"> @@ -144,14 +143,9 @@ % else: <p>TODO: batch cannot be executed..?</p> % endif + </div> </div> - </div> -</%def> - -<%def name="render_form()"> - ## TODO: should use self.render_form_buttons() - ## ${form.render(form_id='batch-form', buttons=capture(self.render_form_buttons))|n} - ${form.render(form_id='batch-form', buttons=capture(buttons))|n} + </nav> </%def> <%def name="render_this_page()"> @@ -173,8 +167,7 @@ Please be certain to use the right one! </p> <br /> - <${upload_worksheet_form.component} ref="uploadForm"> - </${upload_worksheet_form.component}> + ${upload_worksheet_form.render_vue_tag(ref='uploadForm')} </section> <footer class="modal-card-foot"> @@ -184,7 +177,7 @@ <b-button type="is-primary" @click="submitUpload()" icon-pack="fas" - icon-left="fas fa-upload" + icon-left="upload" :disabled="uploadButtonDisabled"> {{ uploadButtonText }} </b-button> @@ -196,17 +189,7 @@ </%def> -<%def name="render_this_page_template()"> - ${parent.render_this_page_template()} - % if master.has_worksheet_file and master.allow_worksheet(batch) and master.has_perm('worksheet'): - ${upload_worksheet_form.render_deform(buttons=False, form_kwargs={'ref': 'actualUploadForm'})|n} - % endif - % if master.handler.executable(batch) and master.has_perm('execute'): - ${execute_form.render_deform(form_kwargs={'ref': 'actualExecuteForm'}, buttons=False)|n} - % endif -</%def> - -<%def name="render_buefy_form()"> +<%def name="render_form()"> <div class="form"> <${form.component} @show-upload="showUploadDialog = true"> </${form.component}> @@ -266,9 +249,27 @@ % endif </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="render_vue_templates()"> + ${parent.render_vue_templates()} + % if master.has_worksheet_file and master.allow_worksheet(batch) and master.has_perm('worksheet'): + ${upload_worksheet_form.render_vue_template(buttons=False, form_kwargs={'ref': 'actualUploadForm'})} + % endif + % if master.handler.executable(batch) and master.has_perm('execute'): + ${execute_form.render_vue_template(form_kwargs={'ref': 'actualExecuteForm'}, buttons=False)} + % endif +</%def> + +## DEPRECATED; remains for back-compat +## nb. this is called by parent template, /form.mako +<%def name="render_form_template()"> + ## TODO: should use self.render_form_buttons() + ## ${form.render_deform(form_id='batch-form', buttons=capture(self.render_form_buttons))|n} + ${form.render_deform(form_id='batch-form', buttons=capture(buttons))|n} +</%def> + +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> ThisPageData.statusBreakdownData = ${json.dumps(status_breakdown_data)|n} @@ -284,7 +285,7 @@ } % if not batch.executed and master.has_perm('edit'): - ${form.component_studly}Data.togglingBatchComplete = false + ${form.vue_component}Data.togglingBatchComplete = false % endif % if master.has_worksheet_file and master.allow_worksheet(batch) and master.has_perm('worksheet'): @@ -305,7 +306,7 @@ form.submit() } - ${upload_worksheet_form.component_studly}.methods.submit = function() { + ${upload_worksheet_form.vue_component}.methods.submit = function() { this.$refs.actualUploadForm.submit() } @@ -320,7 +321,7 @@ this.$refs.executeBatchForm.submit() } - ${execute_form.component_studly}.methods.submit = function() { + ${execute_form.vue_component}.methods.submit = function() { this.$refs.actualExecuteForm.submit() } @@ -328,9 +329,9 @@ % if master.rows_bulk_deletable and not batch.executed and master.has_perm('delete_rows'): - ${rows_grid.component_studly}Data.deleteResultsShowDialog = false + ${rows_grid.vue_component}Data.deleteResultsShowDialog = false - ${rows_grid.component_studly}.methods.deleteResultsInit = function() { + ${rows_grid.vue_component}.methods.deleteResultsInit = function() { this.deleteResultsShowDialog = true } @@ -339,28 +340,12 @@ </script> </%def> -<%def name="make_this_page_component()"> - ${parent.make_this_page_component()} +<%def name="make_vue_components()"> + ${parent.make_vue_components()} % if master.has_worksheet_file and master.allow_worksheet(batch) and master.has_perm('worksheet'): - <script type="text/javascript"> - - ## UploadForm - ${upload_worksheet_form.component_studly}.data = function() { return ${upload_worksheet_form.component_studly}Data } - Vue.component('${upload_worksheet_form.component}', ${upload_worksheet_form.component_studly}) - - </script> + ${upload_worksheet_form.render_vue_finalize()} % endif - % if execute_enabled and master.has_perm('execute'): - <script type="text/javascript"> - - ## ExecuteForm - ${execute_form.component_studly}.data = function() { return ${execute_form.component_studly}Data } - Vue.component('${execute_form.component}', ${execute_form.component_studly}) - - </script> + ${execute_form.render_vue_finalize()} % endif </%def> - - -${parent.body()} diff --git a/tailbone/templates/configure-menus.mako b/tailbone/templates/configure-menus.mako index c0200912..c7f46d21 100644 --- a/tailbone/templates/configure-menus.mako +++ b/tailbone/templates/configure-menus.mako @@ -208,9 +208,9 @@ % endif </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> ThisPageData.menuSequence = ${json.dumps([m['key'] for m in menus])|n} @@ -443,6 +443,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/configure.mako b/tailbone/templates/configure.mako index 3aa60f31..e6b128fc 100644 --- a/tailbone/templates/configure.mako +++ b/tailbone/templates/configure.mako @@ -92,7 +92,7 @@ <b-select name="${tmpl['setting_file']}" v-model="inputFileTemplateSettings['${tmpl['setting_file']}']" @input="settingsNeedSaved = true"> - <option :value="null">-new-</option> + <option value="">-new-</option> <option v-for="option in inputFileTemplateFileOptions['${tmpl['key']}']" :key="option" :value="option"> @@ -104,22 +104,40 @@ <b-field label="Upload" v-show="inputFileTemplateSettings['${tmpl['setting_mode']}'] == 'hosted' && !inputFileTemplateSettings['${tmpl['setting_file']}']"> - <b-field class="file is-primary" - :class="{'has-name': !!inputFileTemplateSettings['${tmpl['setting_file']}']}"> - <b-upload name="${tmpl['setting_file']}.upload" - v-model="inputFileTemplateUploads['${tmpl['key']}']" - class="file-label" - @input="settingsNeedSaved = true"> - <span class="file-cta"> - <b-icon class="file-icon" pack="fas" icon="upload"></b-icon> - <span class="file-label">Click to upload</span> - </span> - </b-upload> - <span v-if="inputFileTemplateUploads['${tmpl['key']}']" - class="file-name"> - {{ inputFileTemplateUploads['${tmpl['key']}'].name }} - </span> - </b-field> + % if request.use_oruga: + <o-field class="file"> + <o-upload name="${tmpl['setting_file']}.upload" + v-model="inputFileTemplateUploads['${tmpl['key']}']" + v-slot="{ onclick }" + @input="settingsNeedSaved = true"> + <o-button variant="primary" + @click="onclick"> + <o-icon icon="upload" /> + <span>Click to upload</span> + </o-button> + <span class="file-name" v-if="inputFileTemplateUploads['${tmpl['key']}']"> + {{ inputFileTemplateUploads['${tmpl['key']}'].name }} + </span> + </o-upload> + </o-field> + % else: + <b-field class="file is-primary" + :class="{'has-name': !!inputFileTemplateSettings['${tmpl['setting_file']}']}"> + <b-upload name="${tmpl['setting_file']}.upload" + v-model="inputFileTemplateUploads['${tmpl['key']}']" + class="file-label" + @input="settingsNeedSaved = true"> + <span class="file-cta"> + <b-icon class="file-icon" pack="fas" icon="upload"></b-icon> + <span class="file-label">Click to upload</span> + </span> + </b-upload> + <span v-if="inputFileTemplateUploads['${tmpl['key']}']" + class="file-name"> + {{ inputFileTemplateUploads['${tmpl['key']}'].name }} + </span> + </b-field> + % endif </b-field> @@ -143,6 +161,85 @@ </div> </%def> +<%def name="output_file_template_field(key)"> + <% tmpl = output_file_templates[key] %> + <b-field grouped> + + <b-field label="${tmpl['label']}"> + <b-select name="${tmpl['setting_mode']}" + v-model="outputFileTemplateSettings['${tmpl['setting_mode']}']" + @input="settingsNeedSaved = true"> + <option value="default">use default</option> + <option value="hosted">use uploaded file</option> + </b-select> + </b-field> + + <b-field label="File" + v-show="outputFileTemplateSettings['${tmpl['setting_mode']}'] == 'hosted'" + :message="outputFileTemplateSettings['${tmpl['setting_file']}'] ? 'This file lives on disk at: ${output_file_option_dirs[tmpl['key']]}' : null"> + <b-select name="${tmpl['setting_file']}" + v-model="outputFileTemplateSettings['${tmpl['setting_file']}']" + @input="settingsNeedSaved = true"> + <option value="">-new-</option> + <option v-for="option in outputFileTemplateFileOptions['${tmpl['key']}']" + :key="option" + :value="option"> + {{ option }} + </option> + </b-select> + </b-field> + + <b-field label="Upload" + v-show="outputFileTemplateSettings['${tmpl['setting_mode']}'] == 'hosted' && !outputFileTemplateSettings['${tmpl['setting_file']}']"> + + % if request.use_oruga: + <o-field class="file"> + <o-upload name="${tmpl['setting_file']}.upload" + v-model="outputFileTemplateUploads['${tmpl['key']}']" + v-slot="{ onclick }" + @input="settingsNeedSaved = true"> + <o-button variant="primary" + @click="onclick"> + <o-icon icon="upload" /> + <span>Click to upload</span> + </o-button> + <span class="file-name" v-if="outputFileTemplateUploads['${tmpl['key']}']"> + {{ outputFileTemplateUploads['${tmpl['key']}'].name }} + </span> + </o-upload> + </o-field> + % else: + <b-field class="file is-primary" + :class="{'has-name': !!outputFileTemplateSettings['${tmpl['setting_file']}']}"> + <b-upload name="${tmpl['setting_file']}.upload" + v-model="outputFileTemplateUploads['${tmpl['key']}']" + class="file-label" + @input="settingsNeedSaved = true"> + <span class="file-cta"> + <b-icon class="file-icon" pack="fas" icon="upload"></b-icon> + <span class="file-label">Click to upload</span> + </span> + </b-upload> + <span v-if="outputFileTemplateUploads['${tmpl['key']}']" + class="file-name"> + {{ outputFileTemplateUploads['${tmpl['key']}'].name }} + </span> + </b-field> + % endif + </b-field> + + </b-field> +</%def> + +<%def name="output_file_templates_section()"> + <h3 class="block is-size-3">Output File Templates</h3> + <div class="block" style="padding-left: 2rem;"> + % for key in output_file_templates: + ${self.output_file_template_field(key)} + % endfor + </div> +</%def> + <%def name="form_content()"></%def> <%def name="page_content()"> @@ -183,15 +280,14 @@ <b-button @click="purgeSettingsShowDialog = false"> Cancel </b-button> - ${h.form(request.current_route_url())} + ${h.form(request.current_route_url(), **{'@submit': 'purgingSettings = true'})} ${h.csrf_token(request)} ${h.hidden('remove_settings', 'true')} <b-button type="is-danger" native-type="submit" :disabled="purgingSettings" icon-pack="fas" - icon-left="trash" - @click="purgingSettings = true"> + icon-left="trash"> {{ purgingSettings ? "Working, please wait..." : "Remove All Settings" }} </b-button> ${h.end_form()} @@ -205,62 +301,42 @@ ${h.end_form()} </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> % if simple_settings is not Undefined: ThisPageData.simpleSettings = ${json.dumps(simple_settings)|n} % endif - % if input_file_template_settings is not Undefined: - ThisPageData.inputFileTemplateSettings = ${json.dumps(input_file_template_settings)|n} - ThisPageData.inputFileTemplateFileOptions = ${json.dumps(input_file_options)|n} - ThisPageData.inputFileTemplateUploads = { - % for key in input_file_templates: - '${key}': null, - % endfor - } - % endif - ThisPageData.purgeSettingsShowDialog = false ThisPageData.purgingSettings = false ThisPageData.settingsNeedSaved = false ThisPageData.undoChanges = false ThisPageData.savingSettings = false + ThisPageData.validators = [] ThisPage.methods.purgeSettingsInit = function() { this.purgeSettingsShowDialog = true } - % if input_file_template_settings is not Undefined: - ThisPage.methods.validateInputFileTemplateSettings = function() { - % for tmpl in six.itervalues(input_file_templates): - if (this.inputFileTemplateSettings['${tmpl['setting_mode']}'] == 'hosted') { - if (!this.inputFileTemplateSettings['${tmpl['setting_file']}']) { - if (!this.inputFileTemplateUploads['${tmpl['key']}']) { - return "You must provide a file to upload for the ${tmpl['label']} template." - } - } - } - % endfor - } - % endif - - ThisPage.methods.validateSettings = function() { - let msg - - % if input_file_template_settings is not Undefined: - msg = this.validateInputFileTemplateSettings() - if (msg) { - return msg - } - % endif - } + ThisPage.methods.validateSettings = function() {} ThisPage.methods.saveSettings = function() { - let msg = this.validateSettings() + let msg + + // nb. this is the future + for (let validator of this.validators) { + msg = validator.call(this) + if (msg) { + alert(msg) + return + } + } + + // nb. legacy method + msg = this.validateSettings() if (msg) { alert(msg) return @@ -291,8 +367,65 @@ window.addEventListener('beforeunload', this.beforeWindowUnload) } + ############################## + ## input file templates + ############################## + + % if input_file_template_settings is not Undefined: + + ThisPageData.inputFileTemplateSettings = ${json.dumps(input_file_template_settings)|n} + ThisPageData.inputFileTemplateFileOptions = ${json.dumps(input_file_options)|n} + ThisPageData.inputFileTemplateUploads = { + % for key in input_file_templates: + '${key}': null, + % endfor + } + + ThisPage.methods.validateInputFileTemplateSettings = function() { + % for tmpl in input_file_templates.values(): + if (this.inputFileTemplateSettings['${tmpl['setting_mode']}'] == 'hosted') { + if (!this.inputFileTemplateSettings['${tmpl['setting_file']}']) { + if (!this.inputFileTemplateUploads['${tmpl['key']}']) { + return "You must provide a file to upload for the ${tmpl['label']} template." + } + } + } + % endfor + } + + ThisPageData.validators.push(ThisPage.methods.validateInputFileTemplateSettings) + + % endif + + ############################## + ## output file templates + ############################## + + % if output_file_template_settings is not Undefined: + + ThisPageData.outputFileTemplateSettings = ${json.dumps(output_file_template_settings)|n} + ThisPageData.outputFileTemplateFileOptions = ${json.dumps(output_file_options)|n} + ThisPageData.outputFileTemplateUploads = { + % for key in output_file_templates: + '${key}': null, + % endfor + } + + ThisPage.methods.validateOutputFileTemplateSettings = function() { + % for tmpl in output_file_templates.values(): + if (this.outputFileTemplateSettings['${tmpl['setting_mode']}'] == 'hosted') { + if (!this.outputFileTemplateSettings['${tmpl['setting_file']}']) { + if (!this.outputFileTemplateUploads['${tmpl['key']}']) { + return "You must provide a file to upload for the ${tmpl['label']} template." + } + } + } + % endfor + } + + ThisPageData.validators.push(ThisPage.methods.validateOutputFileTemplateSettings) + + % endif + </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/customers/configure.mako b/tailbone/templates/customers/configure.mako index e68f4543..1a6dca8b 100644 --- a/tailbone/templates/customers/configure.mako +++ b/tailbone/templates/customers/configure.mako @@ -88,9 +88,9 @@ </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> ThisPage.methods.getLabelForKey = function(key) { switch (key) { @@ -111,6 +111,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/customers/pending/view.mako b/tailbone/templates/customers/pending/view.mako index e9e54c99..1cea9d1f 100644 --- a/tailbone/templates/customers/pending/view.mako +++ b/tailbone/templates/customers/pending/view.mako @@ -106,9 +106,9 @@ % endif </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> ThisPageData.resolvePersonShowDialog = false ThisPageData.resolvePersonUUID = null @@ -139,5 +139,3 @@ </script> </%def> - -${parent.body()} diff --git a/tailbone/templates/customers/view.mako b/tailbone/templates/customers/view.mako index 85ec0055..490e4757 100644 --- a/tailbone/templates/customers/view.mako +++ b/tailbone/templates/customers/view.mako @@ -9,28 +9,26 @@ % endif </%def> -<%def name="render_buefy_form()"> +<%def name="render_form()"> <div class="form"> <tailbone-form @detach-person="detachPerson"> </tailbone-form> </div> </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> % if expose_shoppers: - ${form.component_studly}Data.shoppers = ${json.dumps(shoppers_data)|n} + ${form.vue_component}Data.shoppers = ${json.dumps(shoppers_data)|n} % endif % if expose_people: - ${form.component_studly}Data.peopleData = ${json.dumps(people_data)|n} + ${form.vue_component}Data.peopleData = ${json.dumps(people_data)|n} % endif ThisPage.methods.detachPerson = function(url) { - ## TODO: this should require POST, but we will add that once - ## we can assume a Buefy theme is present, to avoid having to - ## implement the logic in old jquery... + ## TODO: this should require POST! but for now we just redirect.. if (confirm("Are you sure you want to detach this person from this customer account?")) { location.href = url } @@ -38,5 +36,3 @@ </script> </%def> - -${parent.body()} diff --git a/tailbone/templates/custorders/configure.mako b/tailbone/templates/custorders/configure.mako index d2f6610d..16d26d21 100644 --- a/tailbone/templates/custorders/configure.mako +++ b/tailbone/templates/custorders/configure.mako @@ -24,29 +24,38 @@ </b-checkbox> </b-field> - <b-field message="Only applies if user is allowed to choose contact info."> - <b-checkbox name="rattail.custorders.new_orders.allow_contact_info_create" - v-model="simpleSettings['rattail.custorders.new_orders.allow_contact_info_create']" - native-value="true" - @input="settingsNeedSaved = true"> - Allow user to enter new contact info - </b-checkbox> - </b-field> + <div v-show="simpleSettings['rattail.custorders.new_orders.allow_contact_info_choice']" + style="padding-left: 2rem;"> - <p class="block"> - If you allow users to enter new contact info, the default action - when the order is submitted, is to send email with details of - the new contact info. Settings for these are at: - </p> + <b-field message="Only applies if user is allowed to choose contact info."> + <b-checkbox name="rattail.custorders.new_orders.allow_contact_info_create" + v-model="simpleSettings['rattail.custorders.new_orders.allow_contact_info_create']" + native-value="true" + @input="settingsNeedSaved = true"> + Allow user to enter new contact info + </b-checkbox> + </b-field> - <ul class="list"> - <li class="list-item"> - ${h.link_to("New Phone Request", url('emailprofiles.view', key='new_phone_requested'))} - </li> - <li class="list-item"> - ${h.link_to("New Email Request", url('emailprofiles.view', key='new_email_requested'))} - </li> - </ul> + <div v-show="simpleSettings['rattail.custorders.new_orders.allow_contact_info_create']" + style="padding-left: 2rem;"> + + <p class="block"> + If you allow users to enter new contact info, the default action + when the order is submitted, is to send email with details of + the new contact info. Settings for these are at: + </p> + + <ul class="list"> + <li class="list-item"> + ${h.link_to("New Phone Request", url('emailprofiles.view', key='new_phone_requested'))} + </li> + <li class="list-item"> + ${h.link_to("New Email Request", url('emailprofiles.view', key='new_email_requested'))} + </li> + </ul> + + </div> + </div> </div> <h3 class="block is-size-3">Product Handling</h3> diff --git a/tailbone/templates/custorders/create.mako b/tailbone/templates/custorders/create.mako index 399c1a6b..382a121f 100644 --- a/tailbone/templates/custorders/create.mako +++ b/tailbone/templates/custorders/create.mako @@ -27,18 +27,18 @@ @click="submitOrder()" :disabled="submittingOrder" icon-pack="fas" - icon-left="fas fa-upload"> + icon-left="upload"> {{ submittingOrder ? "Working, please wait..." : "Submit this Order" }} </b-button> <b-button @click="startOverEntirely()" icon-pack="fas" - icon-left="fas fa-redo"> + icon-left="redo"> Start Over Entirely </b-button> <b-button @click="cancelOrder()" type="is-danger" icon-pack="fas" - icon-left="fas fa-trash"> + icon-left="trash"> Cancel this Order </b-button> </div> @@ -47,21 +47,27 @@ </div> </%def> -<%def name="render_this_page_template()"> - ${parent.render_this_page_template()} +<%def name="render_vue_templates()"> + ${parent.render_vue_templates()} ${product_lookup.tailbone_product_lookup_template()} - <script type="text/x-template" id="customer-order-creator-template"> <div> ${self.order_form_buttons()} - <b-collapse class="panel" :class="customerPanelType" - :open.sync="customerPanelOpen"> + <${b}-collapse class="panel" + :class="customerPanelType" + % if request.use_oruga: + v-model:open="customerPanelOpen" + % else: + :open.sync="customerPanelOpen" + % endif + > <template #trigger="props"> <div class="panel-heading" - role="button"> + role="button" + style="cursor: pointer;"> ## TODO: for some reason buefy will "reuse" the icon ## element in such a way that its display does not @@ -71,15 +77,16 @@ <b-icon v-if="props.open" pack="fas" - icon="angle-down"> + icon="caret-down"> </b-icon> <span v-if="!props.open"> <b-icon pack="fas" - icon="angle-right"> + icon="caret-right"> </b-icon> </span> + <strong v-html="customerPanelHeader"></strong> </div> </template> @@ -89,11 +96,33 @@ <div style="display: flex; flex-direction: row;"> <div style="flex-grow: 1; margin-right: 1rem;"> - <b-notification :type="customerStatusType" - position="is-bottom-right" - :closable="false"> - {{ customerStatusText }} - </b-notification> + % if request.use_oruga: + ## TODO: for some reason o-notification variant is not + ## being updated properly, so for now the workaround is + ## to maintain a separate component for each variant + ## i tried to reproduce the problem in a simple page + ## but was unable; this is really a hack but it works.. + <o-notification v-if="customerStatusType == null" + :closable="false"> + {{ customerStatusText }} + </o-notification> + <o-notification v-if="customerStatusType == 'is-warning'" + variant="warning" + :closable="false"> + {{ customerStatusText }} + </o-notification> + <o-notification v-if="customerStatusType == 'is-danger'" + variant="danger" + :closable="false"> + {{ customerStatusText }} + </o-notification> + % else: + <b-notification :type="customerStatusType" + position="is-bottom-right" + :closable="false"> + {{ customerStatusText }} + </b-notification> + % endif </div> <!-- <div class="buttons"> --> <!-- <b-button @click="startOverCustomer()" --> @@ -117,23 +146,28 @@ <div :style="{'flex-grow': contactNotes.length ? 0 : 1}"> - <b-field label="Customer" grouped> - <b-field style="margin-left: 1rem;" - :expanded="!contactUUID"> + <b-field label="Customer"> + <div style="display: flex; gap: 1rem; width: 100%;"> <tailbone-autocomplete ref="contactAutocomplete" v-model="contactUUID" + :style="{'flex-grow': contactUUID ? '0' : '1'}" + expanded placeholder="Enter name or phone number" - :initial-label="contactDisplay" % if new_order_requires_customer: serviceUrl="${url('{}.customer_autocomplete'.format(route_prefix))}" % else: serviceUrl="${url('{}.person_autocomplete'.format(route_prefix))}" % endif - @input="contactChanged"> + % if request.use_oruga: + :assigned-label="contactDisplay" + @update:model-value="contactChanged" + % else: + :initial-label="contactDisplay" + @input="contactChanged" + % endif + > </tailbone-autocomplete> - </b-field> - <div v-if="contactUUID"> - <b-button v-if="contactProfileURL" + <b-button v-if="contactUUID && contactProfileURL" type="is-primary" tag="a" target="_blank" :href="contactProfileURL" @@ -141,8 +175,8 @@ icon-left="external-link-alt"> View Profile </b-button> - - <b-button @click="refreshContact" + <b-button v-if="contactUUID" + @click="refreshContact" icon-pack="fas" icon-left="redo" :disabled="refreshingContact"> @@ -186,8 +220,13 @@ Edit </b-button> - <b-modal has-modal-card - :active.sync="editPhoneNumberShowDialog"> + <${b}-modal has-modal-card + % if request.use_oruga: + v-model:active="editPhoneNumberShowDialog" + % else: + :active.sync="editPhoneNumberShowDialog" + % endif + > <div class="modal-card"> <header class="modal-card-head"> @@ -241,7 +280,7 @@ </b-button> </footer> </div> - </b-modal> + </${b}-modal> </div> % endif @@ -279,8 +318,13 @@ icon-left="edit"> Edit </b-button> - <b-modal has-modal-card - :active.sync="editEmailAddressShowDialog"> + <${b}-modal has-modal-card + % if request.use_oruga: + v-model:active.sync="editEmailAddressShowDialog" + % else: + :active.sync="editEmailAddressShowDialog" + % endif + > <div class="modal-card"> <header class="modal-card-head"> @@ -334,7 +378,7 @@ </b-button> </footer> </div> - </b-modal> + </${b}-modal> </div> % endif </div> @@ -409,8 +453,13 @@ </b-notification> </div> - <b-modal has-modal-card - :active.sync="editNewCustomerShowDialog"> + <${b}-modal has-modal-card + % if request.use_oruga: + v-model:active="editNewCustomerShowDialog" + % else: + :active.sync="editNewCustomerShowDialog" + % endif + > <div class="modal-card"> <header class="modal-card-head"> @@ -452,20 +501,21 @@ </b-button> </footer> </div> - </b-modal> + </${b}-modal> </div> </div> </div> <!-- panel-block --> - </b-collapse> + </${b}-collapse> - <b-collapse class="panel" + <${b}-collapse class="panel" open> <template #trigger="props"> <div class="panel-heading" - role="button"> + role="button" + style="cursor: pointer;"> ## TODO: for some reason buefy will "reuse" the icon ## element in such a way that its display does not @@ -475,15 +525,16 @@ <b-icon v-if="props.open" pack="fas" - icon="angle-down"> + icon="caret-down"> </b-icon> <span v-if="!props.open"> <b-icon pack="fas" - icon="angle-right"> + icon="caret-right"> </b-icon> </span> + <strong v-html="itemsPanelHeader"></strong> </div> </template> @@ -493,29 +544,42 @@ <div class="buttons"> <b-button type="is-primary" icon-pack="fas" - icon-left="fas fa-plus" + icon-left="plus" @click="showAddItemDialog()"> Add Item </b-button> % if allow_past_item_reorder: <b-button v-if="contactUUID" icon-pack="fas" - icon-left="fas fa-plus" + icon-left="plus" @click="showAddPastItem()"> Add Past Item </b-button> % endif </div> - <b-modal :active.sync="showingItemDialog"> + <${b}-modal + % if request.use_oruga: + v-model:active="showingItemDialog" + % else: + :active.sync="showingItemDialog" + % endif + > <div class="card"> <div class="card-content"> - <b-tabs type="is-boxed is-toggle" - v-model="itemDialogTabIndex" - :animated="false"> + <${b}-tabs :animated="false" + % if request.use_oruga: + v-model="itemDialogTab" + type="toggle" + % else: + v-model="itemDialogTabIndex" + type="is-boxed is-toggle" + % endif + > - <b-tab-item label="Product"> + <${b}-tab-item label="Product" + value="product"> <div class="field"> <b-radio v-model="productIsKnown" @@ -525,84 +589,82 @@ </div> <div v-show="productIsKnown" - style="padding-left: 5rem;"> - - <b-field grouped> - <p class="label control"> - Product - </p> - <tailbone-product-lookup ref="productLookup" - :product="selectedProduct" - @selected="productLookupSelected" - autocomplete-url="${url(f'{route_prefix}.product_autocomplete')}"> - </tailbone-product-lookup> - </b-field> - - <div v-if="productUUID"> - - <div class="is-pulled-right has-text-centered"> - <img :src="productImageURL" - style="max-height: 150px; max-width: 150px; "/> - </div> - - <b-field grouped> - <b-field :label="productKeyLabel"> - <span>{{ productKey }}</span> - </b-field> - - <b-field label="Unit Size"> - <span>{{ productSize || '' }}</span> - </b-field> - - <b-field label="Case Size"> - <span>{{ productCaseQuantity }}</span> - </b-field> - - <b-field label="Reg. Price" - v-if="productSalePriceDisplay"> - <span>{{ productUnitRegularPriceDisplay }}</span> - </b-field> - - <b-field label="Unit Price" - v-if="!productSalePriceDisplay"> - <span - % if product_price_may_be_questionable: - :class="productPriceNeedsConfirmation ? 'has-background-warning' : ''" - % endif - > - {{ productUnitPriceDisplay }} - </span> - </b-field> - <!-- <b-field label="Last Changed"> --> - <!-- <span>2021-01-01</span> --> - <!-- </b-field> --> - - <b-field label="Sale Price" - v-if="productSalePriceDisplay"> - <span class="has-background-warning"> - {{ productSalePriceDisplay }} - </span> - </b-field> - - <b-field label="Sale Ends" - v-if="productSaleEndsDisplay"> - <span class="has-background-warning"> - {{ productSaleEndsDisplay }} - </span> - </b-field> + style="padding-left: 3rem; display: flex; gap: 1rem;"> + <div style="flex-grow: 1;"> + <b-field label="Product"> + <tailbone-product-lookup ref="productLookup" + :product="selectedProduct" + @selected="productLookupSelected" + autocomplete-url="${url(f'{route_prefix}.product_autocomplete')}"> + </tailbone-product-lookup> </b-field> - % if product_price_may_be_questionable: - <b-checkbox v-model="productPriceNeedsConfirmation" - type="is-warning" - size="is-small"> - This price is questionable and should be confirmed - by someone before order proceeds. - </b-checkbox> - % endif + <div v-if="productUUID"> + + <b-field grouped> + <b-field :label="productKeyLabel"> + <span>{{ productKey }}</span> + </b-field> + + <b-field label="Unit Size"> + <span>{{ productSize || '' }}</span> + </b-field> + + <b-field label="Case Size"> + <span>{{ productCaseQuantity }}</span> + </b-field> + + <b-field label="Reg. Price" + v-if="productSalePriceDisplay"> + <span>{{ productUnitRegularPriceDisplay }}</span> + </b-field> + + <b-field label="Unit Price" + v-if="!productSalePriceDisplay"> + <span + % if product_price_may_be_questionable: + :class="productPriceNeedsConfirmation ? 'has-background-warning' : ''" + % endif + > + {{ productUnitPriceDisplay }} + </span> + </b-field> + <!-- <b-field label="Last Changed"> --> + <!-- <span>2021-01-01</span> --> + <!-- </b-field> --> + + <b-field label="Sale Price" + v-if="productSalePriceDisplay"> + <span class="has-background-warning"> + {{ productSalePriceDisplay }} + </span> + </b-field> + + <b-field label="Sale Ends" + v-if="productSaleEndsDisplay"> + <span class="has-background-warning"> + {{ productSaleEndsDisplay }} + </span> + </b-field> + + </b-field> + + % if product_price_may_be_questionable: + <b-checkbox v-model="productPriceNeedsConfirmation" + type="is-warning" + size="is-small"> + This price is questionable and should be confirmed + by someone before order proceeds. + </b-checkbox> + % endif + </div> </div> + <img v-if="productUUID" + :src="productImageURL" + style="max-height: 150px; max-width: 150px; "/> + </div> <br /> @@ -744,132 +806,148 @@ <b-field label="Notes"> <b-input v-model="pendingProduct.notes" - type="textarea"> - </b-input> + type="textarea" + expanded /> </b-field> </div> - </b-tab-item> - <b-tab-item label="Quantity"> + </${b}-tab-item> + <${b}-tab-item label="Quantity" + value="quantity"> - <div class="is-pulled-right has-text-centered"> - <img :src="productImageURL" - style="max-height: 150px; max-width: 150px; "/> - </div> + <div style="display: flex; gap: 1rem; white-space: nowrap;"> - <b-field grouped> - <b-field label="Product" horizontal> - <span :class="productIsKnown ? null : 'has-text-success'"> - {{ productIsKnown ? productDisplay : (pendingProduct.brand_name || '') + ' ' + (pendingProduct.description || '') + ' ' + (pendingProduct.size || '') }} - </span> - </b-field> - </b-field> - - <b-field grouped> - - <b-field label="Unit Size"> - <span :class="productIsKnown ? null : 'has-text-success'"> - {{ productIsKnown ? productSize : pendingProduct.size }} - </span> - </b-field> - - <b-field label="Reg. Price" - v-if="productSalePriceDisplay"> - <span> - {{ productUnitRegularPriceDisplay }} - </span> - </b-field> - - <b-field label="Unit Price" - v-if="!productSalePriceDisplay"> - <span :class="productIsKnown ? null : 'has-text-success'" - % if product_price_may_be_questionable: - :class="productPriceNeedsConfirmation ? 'has-background-warning' : ''" - % endif - > - {{ productIsKnown ? productUnitPriceDisplay : (pendingProduct.regular_price_amount ? '$' + pendingProduct.regular_price_amount : '') }} - </span> - </b-field> - - <b-field label="Sale Price" - v-if="productSalePriceDisplay"> - <span class="has-background-warning" - :class="productIsKnown ? null : 'has-text-success'"> - {{ productSalePriceDisplay }} - </span> - </b-field> - - <b-field label="Sale Ends" - v-if="productSaleEndsDisplay"> - <span class="has-background-warning" - :class="productIsKnown ? null : 'has-text-success'"> - {{ productSaleEndsDisplay }} - </span> - </b-field> - - <b-field label="Case Size"> - <span :class="productIsKnown ? null : 'has-text-success'"> - {{ productIsKnown ? productCaseQuantity : pendingProduct.case_size }} - </span> - </b-field> - - <b-field label="Case Price"> - <span - % if product_price_may_be_questionable: - :class="{'has-text-success': !productIsKnown, 'has-background-warning': productPriceNeedsConfirmation || productSalePriceDisplay}" - % else: - :class="{'has-text-success': !productIsKnown, 'has-background-warning': !!productSalePriceDisplay}" - % endif - > - {{ getCasePriceDisplay() }} - </span> - </b-field> - - </b-field> - - <b-field grouped> - - <b-field label="Quantity" horizontal> - <numeric-input v-model="productQuantity" - style="width: 5rem;"> - </numeric-input> - </b-field> - - <b-select v-model="productUOM"> - <option v-for="choice in productUnitChoices" - :key="choice.key" - :value="choice.key" - v-html="choice.value"> - </option> - </b-select> - - </b-field> - - <b-field grouped> - % if allow_item_discounts: - <b-field label="Discount" horizontal> - <div class="level"> - <div class="level-item"> - <numeric-input v-model="productDiscountPercent" - style="width: 5rem;" - :disabled="!allowItemDiscount"> - </numeric-input> - </div> - <div class="level-item"> - <span> %</span> - </div> - </div> + <div style="flex-grow: 1;"> + <b-field grouped> + <b-field label="Product" horizontal> + <span :class="productIsKnown ? null : 'has-text-success'" + ## nb. hack to force refresh for vue3 + :key="refreshProductDescription"> + {{ productIsKnown ? productDisplay : (pendingProduct.brand_name || '') + ' ' + (pendingProduct.description || '') + ' ' + (pendingProduct.size || '') }} + </span> </b-field> - % endif - <b-field label="Total Price" horizontal expanded> - <span :class="productSalePriceDisplay ? 'has-background-warning': null"> - {{ getItemTotalPriceDisplay() }} - </span> - </b-field> - </b-field> + </b-field> - </b-tab-item> - </b-tabs> + <b-field grouped> + + <b-field label="Unit Size"> + <span :class="productIsKnown ? null : 'has-text-success'"> + {{ productIsKnown ? productSize : pendingProduct.size }} + </span> + </b-field> + + <b-field label="Reg. Price" + v-if="productSalePriceDisplay"> + <span> + {{ productUnitRegularPriceDisplay }} + </span> + </b-field> + + <b-field label="Unit Price" + v-if="!productSalePriceDisplay"> + <span :class="productIsKnown ? null : 'has-text-success'" + % if product_price_may_be_questionable: + :class="productPriceNeedsConfirmation ? 'has-background-warning' : ''" + % endif + > + {{ productIsKnown ? productUnitPriceDisplay : (pendingProduct.regular_price_amount ? '$' + pendingProduct.regular_price_amount : '') }} + </span> + </b-field> + + <b-field label="Sale Price" + v-if="productSalePriceDisplay"> + <span class="has-background-warning" + :class="productIsKnown ? null : 'has-text-success'"> + {{ productSalePriceDisplay }} + </span> + </b-field> + + <b-field label="Sale Ends" + v-if="productSaleEndsDisplay"> + <span class="has-background-warning" + :class="productIsKnown ? null : 'has-text-success'"> + {{ productSaleEndsDisplay }} + </span> + </b-field> + + <b-field label="Case Size"> + <span :class="productIsKnown ? null : 'has-text-success'"> + {{ productIsKnown ? productCaseQuantity : pendingProduct.case_size }} + </span> + </b-field> + + <b-field label="Case Price"> + <span + % if product_price_may_be_questionable: + :class="{'has-text-success': !productIsKnown, 'has-background-warning': productPriceNeedsConfirmation || productSalePriceDisplay}" + % else: + :class="{'has-text-success': !productIsKnown, 'has-background-warning': !!productSalePriceDisplay}" + % endif + > + {{ getCasePriceDisplay() }} + </span> + </b-field> + + </b-field> + + <b-field grouped> + + <b-field label="Quantity" horizontal> + <numeric-input v-model="productQuantity" + @input="refreshTotalPrice += 1" + style="width: 5rem;"> + </numeric-input> + </b-field> + + <b-select v-model="productUOM" + @input="refreshTotalPrice += 1"> + <option v-for="choice in productUnitChoices" + :key="choice.key" + :value="choice.key" + v-html="choice.value"> + </option> + </b-select> + + </b-field> + + <div style="display: flex; gap: 1rem;"> + % if allow_item_discounts: + <b-field label="Discount" horizontal> + <div class="level"> + <div class="level-item"> + <numeric-input v-model="productDiscountPercent" + @input="refreshTotalPrice += 1" + style="width: 5rem;" + :disabled="!allowItemDiscount"> + </numeric-input> + </div> + <div class="level-item"> + <span> %</span> + </div> + </div> + </b-field> + % endif + <b-field label="Total Price" horizontal expanded + :key="refreshTotalPrice"> + <span :class="productSalePriceDisplay ? 'has-background-warning': null"> + {{ getItemTotalPriceDisplay() }} + </span> + </b-field> + </div> + + <!-- <b-field grouped> --> + <!-- </b-field> --> + </div> + + <!-- <div class="is-pulled-right has-text-centered"> --> + <img :src="productImageURL" + style="max-height: 150px; max-width: 150px; "/> + <!-- </div> --> + + </div> + + </${b}-tab-item> + </${b}-tabs> <div class="buttons"> <b-button @click="showingItemDialog = false"> @@ -886,11 +964,16 @@ </div> </div> - </b-modal> + </${b}-modal> % if unknown_product_confirm_price: - <b-modal has-modal-card - :active.sync="confirmPriceShowDialog"> + <${b}-modal has-modal-card + % if request.use_oruga: + v-model:active="confirmPriceShowDialog" + % else: + :active.sync="confirmPriceShowDialog" + % endif + > <div class="modal-card"> <header class="modal-card-head"> @@ -931,101 +1014,111 @@ </b-button> </footer> </div> - </b-modal> + </${b}-modal> % endif % if allow_past_item_reorder: - <b-modal :active.sync="pastItemsShowDialog"> + <${b}-modal + % if request.use_oruga: + v-model:active="pastItemsShowDialog" + % else: + :active.sync="pastItemsShowDialog" + % endif + > <div class="card"> <div class="card-content"> - <b-table :data="pastItems" - icon-pack="fas" - :loading="pastItemsLoading" - :selected.sync="pastItemsSelected" - sortable - paginated - per-page="5" - :debounce-search="1000"> + <${b}-table :data="pastItems" + icon-pack="fas" + :loading="pastItemsLoading" + % if request.use_oruga: + v-model:selected="pastItemsSelected" + % else: + :selected.sync="pastItemsSelected" + % endif + sortable + paginated + per-page="5" + :debounce-search="1000"> - <b-table-column :label="productKeyLabel" + <${b}-table-column :label="productKeyLabel" field="key" v-slot="props" sortable> {{ props.row.key }} - </b-table-column> + </${b}-table-column> - <b-table-column label="Brand" + <${b}-table-column label="Brand" field="brand_name" v-slot="props" sortable searchable> {{ props.row.brand_name }} - </b-table-column> + </${b}-table-column> - <b-table-column label="Description" + <${b}-table-column label="Description" field="description" v-slot="props" sortable searchable> {{ props.row.description }} {{ props.row.size }} - </b-table-column> + </${b}-table-column> - <b-table-column label="Unit Price" + <${b}-table-column label="Unit Price" field="unit_price" v-slot="props" sortable> {{ props.row.unit_price_display }} - </b-table-column> + </${b}-table-column> - <b-table-column label="Sale Price" + <${b}-table-column label="Sale Price" field="sale_price" v-slot="props" sortable> <span class="has-background-warning"> {{ props.row.sale_price_display }} </span> - </b-table-column> + </${b}-table-column> - <b-table-column label="Sale Ends" + <${b}-table-column label="Sale Ends" field="sale_ends" v-slot="props" sortable> <span class="has-background-warning"> {{ props.row.sale_ends_display }} </span> - </b-table-column> + </${b}-table-column> - <b-table-column label="Department" + <${b}-table-column label="Department" field="department_name" v-slot="props" sortable searchable> {{ props.row.department_name }} - </b-table-column> + </${b}-table-column> - <b-table-column label="Vendor" + <${b}-table-column label="Vendor" field="vendor_name" v-slot="props" sortable searchable> {{ props.row.vendor_name }} - </b-table-column> + </${b}-table-column> - <template slot="empty"> + <template #empty> <div class="content has-text-grey has-text-centered"> <p> <b-icon pack="fas" - icon="fas fa-sad-tear" + icon="sad-tear" size="is-large"> </b-icon> </p> <p>Nothing here.</p> </div> </template> - </b-table> + </${b}-table> <div class="buttons"> <b-button @click="pastItemsShowDialog = false"> @@ -1042,44 +1135,44 @@ </div> </div> - </b-modal> + </${b}-modal> % endif - <b-table v-if="items.length" + <${b}-table v-if="items.length" :data="items" :row-class="(row, i) => row.product_uuid ? null : 'has-text-success'"> - <b-table-column :label="productKeyLabel" + <${b}-table-column :label="productKeyLabel" v-slot="props"> {{ props.row.product_key }} - </b-table-column> + </${b}-table-column> - <b-table-column label="Brand" + <${b}-table-column label="Brand" v-slot="props"> {{ props.row.product_brand }} - </b-table-column> + </${b}-table-column> - <b-table-column label="Description" + <${b}-table-column label="Description" v-slot="props"> {{ props.row.product_description }} - </b-table-column> + </${b}-table-column> - <b-table-column label="Size" + <${b}-table-column label="Size" v-slot="props"> {{ props.row.product_size }} - </b-table-column> + </${b}-table-column> - <b-table-column label="Department" + <${b}-table-column label="Department" v-slot="props"> {{ props.row.department_display }} - </b-table-column> + </${b}-table-column> - <b-table-column label="Quantity" + <${b}-table-column label="Quantity" v-slot="props"> <span v-html="props.row.order_quantity_display"></span> - </b-table-column> + </${b}-table-column> - <b-table-column label="Unit Price" + <${b}-table-column label="Unit Price" v-slot="props"> <span % if product_price_may_be_questionable: @@ -1090,16 +1183,16 @@ > {{ props.row.unit_price_display }} </span> - </b-table-column> + </${b}-table-column> % if allow_item_discounts: - <b-table-column label="Discount" + <${b}-table-column label="Discount" v-slot="props"> {{ props.row.discount_percent }}{{ props.row.discount_percent ? " %" : "" }} - </b-table-column> + </${b}-table-column> % endif - <b-table-column label="Total" + <${b}-table-column label="Total" v-slot="props"> <span % if product_price_may_be_questionable: @@ -1110,35 +1203,57 @@ > {{ props.row.total_price_display }} </span> - </b-table-column> + </${b}-table-column> - <b-table-column label="Vendor" + <${b}-table-column label="Vendor" v-slot="props"> {{ props.row.vendor_display }} - </b-table-column> + </${b}-table-column> - <b-table-column field="actions" + <${b}-table-column field="actions" label="Actions" v-slot="props"> - <a href="#" class="grid-action" + <a href="#" + % if not request.use_oruga: + class="grid-action" + % endif @click.prevent="showEditItemDialog(props.row)"> - <i class="fas fa-edit"></i> - Edit + % if request.use_oruga: + <span class="icon-text"> + <o-icon icon="edit" /> + <span>Edit</span> + </span> + % else: + <i class="fas fa-edit"></i> + Edit + % endif </a> - <a href="#" class="grid-action has-text-danger" + <a href="#" + % if request.use_oruga: + class="has-text-danger" + % else: + class="grid-action has-text-danger" + % endif @click.prevent="deleteItem(props.index)"> - <i class="fas fa-trash"></i> - Delete + % if request.use_oruga: + <span class="icon-text"> + <o-icon icon="trash" /> + <span>Delete</span> + </span> + % else: + <i class="fas fa-trash"></i> + Delete + % endif </a> - </b-table-column> + </${b}-table-column> - </b-table> + </${b}-table> </div> </div> - </b-collapse> + </${b}-collapse> ${self.order_form_buttons()} @@ -1149,12 +1264,7 @@ </div> </script> -</%def> - -<%def name="make_this_page_component()"> - ${parent.make_this_page_component()} - ${product_lookup.tailbone_product_lookup_component()} - <script type="text/javascript"> + <script> const CustomerOrderCreator = { template: '#customer-order-creator-template', @@ -1222,7 +1332,11 @@ editingItem: null, showingItemDialog: false, itemDialogSaving: false, - itemDialogTabIndex: 0, + % if request.use_oruga: + itemDialogTab: 'product', + % else: + itemDialogTabIndex: 0, + % endif % if allow_past_item_reorder: pastItemsShowDialog: false, pastItemsLoading: false, @@ -1271,6 +1385,10 @@ confirmPriceShowDialog: false, % endif + // nb. hack to force refresh for vue3 + refreshProductDescription: 1, + refreshTotalPrice: 1, + submittingOrder: false, } }, @@ -1632,22 +1750,21 @@ uuid: this.contactUUID, } } - let that = this - this.submitBatchData(params, function(response) { + this.submitBatchData(params, response => { % if new_order_requires_customer: - that.contactUUID = response.data.customer_uuid + this.contactUUID = response.data.customer_uuid % else: - that.contactUUID = response.data.person_uuid + this.contactUUID = response.data.person_uuid % endif - that.contactDisplay = response.data.contact_display - that.orderPhoneNumber = response.data.phone_number - that.orderEmailAddress = response.data.email_address - that.addOtherPhoneNumber = response.data.add_phone_number - that.addOtherEmailAddress = response.data.add_email_address - that.contactProfileURL = response.data.contact_profile_url - that.contactPhones = response.data.contact_phones - that.contactEmails = response.data.contact_emails - that.contactNotes = response.data.contact_notes + this.contactDisplay = response.data.contact_display + this.orderPhoneNumber = response.data.phone_number + this.orderEmailAddress = response.data.email_address + this.addOtherPhoneNumber = response.data.add_phone_number + this.addOtherEmailAddress = response.data.add_email_address + this.contactProfileURL = response.data.contact_profile_url + this.contactPhones = response.data.contact_phones + this.contactEmails = response.data.contact_emails + this.contactNotes = response.data.contact_notes if (callback) { callback() } @@ -1937,7 +2054,11 @@ this.productDiscountPercent = ${json.dumps(default_item_discount)|n} % endif - this.itemDialogTabIndex = 0 + % if request.use_oruga: + this.itemDialogTab = 'product' + % else: + this.itemDialogTabIndex = 0 + % endif this.showingItemDialog = true this.$nextTick(() => { this.$refs.productLookup.focus() @@ -1993,7 +2114,15 @@ this.productPriceNeedsConfirmation = false % endif - this.itemDialogTabIndex = 1 + // nb. hack to force refresh for vue3 + this.refreshProductDescription += 1 + this.refreshTotalPrice += 1 + + % if request.use_oruga: + this.itemDialogTab = 'quantity' + % else: + this.itemDialogTabIndex = 1 + % endif this.showingItemDialog = true }, @@ -2050,7 +2179,15 @@ this.productDiscountPercent = row.discount_percent % endif - this.itemDialogTabIndex = 1 + // nb. hack to force refresh for vue3 + this.refreshProductDescription += 1 + this.refreshTotalPrice += 1 + + % if request.use_oruga: + this.itemDialogTab = 'quantity' + % else: + this.itemDialogTabIndex = 1 + % endif this.showingItemDialog = true }, @@ -2160,7 +2297,15 @@ this.productPriceNeedsConfirmation = false % endif - this.itemDialogTabIndex = 1 + % if request.use_oruga: + this.itemDialogTab = 'quantity' + % else: + this.itemDialogTabIndex = 1 + % endif + + // nb. hack to force refresh for vue3 + this.refreshProductDescription += 1 + this.refreshTotalPrice += 1 }, response => { this.clearProduct() @@ -2250,9 +2395,12 @@ } Vue.component('customer-order-creator', CustomerOrderCreator) + <% request.register_component('customer-order-creator', 'CustomerOrderCreator') %> </script> </%def> - -${parent.body()} +<%def name="make_vue_components()"> + ${parent.make_vue_components()} + ${product_lookup.tailbone_product_lookup_component()} +</%def> diff --git a/tailbone/templates/custorders/items/view.mako b/tailbone/templates/custorders/items/view.mako index 592095ff..4cc92bbf 100644 --- a/tailbone/templates/custorders/items/view.mako +++ b/tailbone/templates/custorders/items/view.mako @@ -1,7 +1,7 @@ ## -*- coding: utf-8; -*- <%inherit file="/master/view.mako" /> -<%def name="render_buefy_form()"> +<%def name="render_form()"> <div class="form"> <${form.component} ref="mainForm" % if master.has_perm('confirm_price'): @@ -291,11 +291,11 @@ % endif </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> - ${form.component_studly}Data.eventsData = ${json.dumps(events_data)|n} + ${form.vue_component}Data.eventsData = ${json.dumps(events_data)|n} % if master.has_perm('confirm_price'): @@ -347,7 +347,7 @@ } ThisPageData.orderItemStatuses = ${json.dumps(enum.CUSTORDER_ITEM_STATUS)|n} - ThisPageData.orderItemStatusOptions = ${json.dumps([dict(key=k, label=v) for k, v in six.iteritems(enum.CUSTORDER_ITEM_STATUS)])|n} + ThisPageData.orderItemStatusOptions = ${json.dumps([dict(key=k, label=v) for k, v in enum.CUSTORDER_ITEM_STATUS.items()])|n} ThisPageData.oldStatusCode = ${instance.status_code} @@ -392,9 +392,9 @@ this.$refs.changeStatusForm.submit() } - ${form.component_studly}Data.changeFlaggedSubmitting = false + ${form.vue_component}Data.changeFlaggedSubmitting = false - ${form.component_studly}.methods.changeFlaggedSubmit = function() { + ${form.vue_component}.methods.changeFlaggedSubmit = function() { this.changeFlaggedSubmitting = true } @@ -448,5 +448,3 @@ </script> </%def> - -${parent.body()} diff --git a/tailbone/templates/datasync/changes/index.mako b/tailbone/templates/datasync/changes/index.mako index b5aeb79a..86f5c121 100644 --- a/tailbone/templates/datasync/changes/index.mako +++ b/tailbone/templates/datasync/changes/index.mako @@ -1,13 +1,6 @@ ## -*- coding: utf-8; -*- <%inherit file="/master/index.mako" /> -<%def name="context_menu_items()"> - ${parent.context_menu_items()} - % if request.has_perm('datasync.status'): - <li>${h.link_to("View DataSync Status", url('datasync.status'))}</li> - % endif -</%def> - <%def name="grid_tools()"> ${parent.grid_tools()} @@ -33,9 +26,9 @@ </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> % if request.has_perm('datasync.restart'): TailboneGridData.restartDatasyncFormSubmitting = false @@ -57,6 +50,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/datasync/configure.mako b/tailbone/templates/datasync/configure.mako index 6dc13e14..2e444fb5 100644 --- a/tailbone/templates/datasync/configure.mako +++ b/tailbone/templates/datasync/configure.mako @@ -1,6 +1,15 @@ ## -*- coding: utf-8; -*- <%inherit file="/configure.mako" /> +<%def name="extra_styles()"> + ${parent.extra_styles()} + <style> + .invisible-watcher { + display: none; + } + </style> +</%def> + <%def name="buttons_row()"> <div class="level"> <div class="level-left"> @@ -48,7 +57,12 @@ ${h.hidden('profiles', **{':value': 'JSON.stringify(profilesData)'})} <b-notification type="is-warning" - :active.sync="showConfigFilesNote"> + % if request.use_oruga: + v-model:active="showConfigFilesNote" + % else: + :active.sync="showConfigFilesNote" + % endif + > ## TODO: should link to some ratman page here, yes? <p class="block"> This tool works by modifying settings in the DB. It @@ -69,8 +83,8 @@ </b-notification> <b-field> - <b-checkbox name="use_profile_settings" - v-model="useProfileSettings" + <b-checkbox name="rattail.datasync.use_profile_settings" + v-model="simpleSettings['rattail.datasync.use_profile_settings']" native-value="true" @input="settingsNeedSaved = true"> Use these Settings to configure watchers and consumers @@ -85,7 +99,7 @@ </div> <div class="level-right"> <div class="level-item" - v-show="useProfileSettings"> + v-show="simpleSettings['rattail.datasync.use_profile_settings']"> <b-button type="is-primary" @click="newProfile()" icon-pack="fas" @@ -101,75 +115,89 @@ </div> </div> - <b-table :data="filteredProfilesData" - :row-class="(row, i) => row.enabled ? null : 'has-background-warning'"> - <b-table-column field="key" + <${b}-table :data="profilesData" + :row-class="getWatcherRowClass"> + <${b}-table-column field="key" label="Watcher Key" v-slot="props"> {{ props.row.key }} - </b-table-column> - <b-table-column field="watcher_spec" + </${b}-table-column> + <${b}-table-column field="watcher_spec" label="Watcher Spec" v-slot="props"> {{ props.row.watcher_spec }} - </b-table-column> - <b-table-column field="watcher_dbkey" + </${b}-table-column> + <${b}-table-column field="watcher_dbkey" label="DB Key" v-slot="props"> {{ props.row.watcher_dbkey }} - </b-table-column> - <b-table-column field="watcher_delay" + </${b}-table-column> + <${b}-table-column field="watcher_delay" label="Loop Delay" v-slot="props"> {{ props.row.watcher_delay }} sec - </b-table-column> - <b-table-column field="watcher_retry_attempts" + </${b}-table-column> + <${b}-table-column field="watcher_retry_attempts" label="Attempts / Delay" v-slot="props"> {{ props.row.watcher_retry_attempts }} / {{ props.row.watcher_retry_delay }} sec - </b-table-column> - <b-table-column field="watcher_default_runas" + </${b}-table-column> + <${b}-table-column field="watcher_default_runas" label="Default Runas" v-slot="props"> {{ props.row.watcher_default_runas }} - </b-table-column> - <b-table-column label="Consumers" + </${b}-table-column> + <${b}-table-column label="Consumers" v-slot="props"> {{ consumerShortList(props.row) }} - </b-table-column> -## <b-table-column field="notes" label="Notes"> + </${b}-table-column> +## <${b}-table-column field="notes" label="Notes"> ## TODO ## ## {{ props.row.notes }} -## </b-table-column> - <b-table-column field="enabled" +## </${b}-table-column> + <${b}-table-column field="enabled" label="Enabled" v-slot="props"> {{ props.row.enabled ? "Yes" : "No" }} - </b-table-column> - <b-table-column label="Actions" + </${b}-table-column> + <${b}-table-column label="Actions" v-slot="props" - v-if="useProfileSettings"> + v-if="simpleSettings['rattail.datasync.use_profile_settings']"> <a href="#" class="grid-action" @click.prevent="editProfile(props.row)"> - <i class="fas fa-edit"></i> - Edit + % if request.use_oruga: + <span class="icon-text"> + <o-icon icon="edit" /> + <span>Edit</span> + </span> + % else: + <i class="fas fa-edit"></i> + Edit + % endif </a> <a href="#" class="grid-action has-text-danger" @click.prevent="deleteProfile(props.row)"> - <i class="fas fa-trash"></i> - Delete + % if request.use_oruga: + <span class="icon-text"> + <o-icon icon="trash" /> + <span>Delete</span> + </span> + % else: + <i class="fas fa-trash"></i> + Delete + % endif </a> - </b-table-column> - <template slot="empty"> + </${b}-table-column> + <template #empty> <section class="section"> <div class="content has-text-grey has-text-centered"> <p> <b-icon pack="fas" - icon="fas fa-sad-tear" + icon="sad-tear" size="is-large"> </b-icon> </p> @@ -177,7 +205,7 @@ </div> </section> </template> - </b-table> + </${b}-table> <b-modal :active.sync="editProfileShowDialog"> <div class="card"> @@ -199,12 +227,12 @@ </b-field> - <b-field grouped> + <b-field grouped expanded> <b-field label="Watcher Spec" :type="editingProfileWatcherSpec ? null : 'is-danger'" expanded> - <b-input v-model="editingProfileWatcherSpec"> + <b-input v-model="editingProfileWatcherSpec" expanded> </b-input> </b-field> @@ -293,40 +321,54 @@ </div> - <b-table :data="editingProfilePendingWatcherKwargs" + <${b}-table :data="editingProfilePendingWatcherKwargs" style="margin-left: 1rem;"> - <b-table-column field="key" + <${b}-table-column field="key" label="Key" v-slot="props"> {{ props.row.key }} - </b-table-column> - <b-table-column field="value" + </${b}-table-column> + <${b}-table-column field="value" label="Value" v-slot="props"> {{ props.row.value }} - </b-table-column> - <b-table-column label="Actions" + </${b}-table-column> + <${b}-table-column label="Actions" v-slot="props"> <a href="#" @click.prevent="editProfileWatcherKwarg(props.row)"> - <i class="fas fa-edit"></i> - Edit + % if request.use_oruga: + <span class="icon-text"> + <o-icon icon="edit" /> + <span>Edit</span> + </span> + % else: + <i class="fas fa-edit"></i> + Edit + % endif </a> <a href="#" class="has-text-danger" @click.prevent="deleteProfileWatcherKwarg(props.row)"> - <i class="fas fa-trash"></i> - Delete + % if request.use_oruga: + <span class="icon-text"> + <o-icon icon="trash" /> + <span>Delete</span> + </span> + % else: + <i class="fas fa-trash"></i> + Delete + % endif </a> - </b-table-column> - <template slot="empty"> + </${b}-table-column> + <template #empty> <section class="section"> <div class="content has-text-grey has-text-centered"> <p> <b-icon pack="fas" - icon="fas fa-sad-tear" + icon="sad-tear" size="is-large"> </b-icon> </p> @@ -334,7 +376,7 @@ </div> </section> </template> - </b-table> + </${b}-table> </div> @@ -350,41 +392,55 @@ </b-checkbox> </b-field> - <b-table :data="editingProfilePendingConsumers" + <${b}-table :data="editingProfilePendingConsumers" v-if="!editingProfileWatcherConsumesSelf" :row-class="(row, i) => row.enabled ? null : 'has-background-warning'"> - <b-table-column field="key" + <${b}-table-column field="key" label="Consumer" v-slot="props"> {{ props.row.key }} - </b-table-column> - <b-table-column style="white-space: nowrap;" + </${b}-table-column> + <${b}-table-column style="white-space: nowrap;" v-slot="props"> {{ props.row.consumer_delay }} / {{ props.row.consumer_retry_attempts }} / {{ props.row.consumer_retry_delay }} - </b-table-column> - <b-table-column label="Actions" + </${b}-table-column> + <${b}-table-column label="Actions" v-slot="props"> <a href="#" class="grid-action" @click.prevent="editProfileConsumer(props.row)"> - <i class="fas fa-edit"></i> - Edit + % if request.use_oruga: + <span class="icon-text"> + <o-icon icon="edit" /> + <span>Edit</span> + </span> + % else: + <i class="fas fa-edit"></i> + Edit + % endif </a> <a href="#" class="grid-action has-text-danger" @click.prevent="deleteProfileConsumer(props.row)"> - <i class="fas fa-trash"></i> - Delete + % if request.use_oruga: + <span class="icon-text"> + <o-icon icon="trash" /> + <span>Delete</span> + </span> + % else: + <i class="fas fa-trash"></i> + Delete + % endif </a> - </b-table-column> - <template slot="empty"> + </${b}-table-column> + <template #empty> <section class="section"> <div class="content has-text-grey has-text-centered"> <p> <b-icon pack="fas" - icon="fas fa-sad-tear" + icon="sad-tear" size="is-large"> </b-icon> </p> @@ -392,7 +448,7 @@ </div> </section> </template> - </b-table> + </${b}-table> </div> @@ -524,31 +580,41 @@ <b-field label="Supervisor Process Name" message="This should be the complete name, including group - e.g. poser:poser_datasync" expanded> - <b-input name="supervisor_process_name" - v-model="supervisorProcessName" - @input="settingsNeedSaved = true"> + <b-input name="rattail.datasync.supervisor_process_name" + v-model="simpleSettings['rattail.datasync.supervisor_process_name']" + @input="settingsNeedSaved = true" + expanded> </b-input> </b-field> + <b-field label="Consumer Batch Size" + message="Max number of changes to be consumed at once." + expanded> + <numeric-input name="rattail.datasync.batch_size_limit" + v-model="simpleSettings['rattail.datasync.batch_size_limit']" + @input="settingsNeedSaved = true" /> + </b-field> + + <h3 class="is-size-3">Legacy</h3> <b-field label="Restart Command" message="This will run as '${system_user}' system user - please configure sudoers as needed. Typical command is like: sudo supervisorctl restart poser:poser_datasync" expanded> - <b-input name="restart_command" - v-model="restartCommand" - @input="settingsNeedSaved = true"> + <b-input name="tailbone.datasync.restart" + v-model="simpleSettings['tailbone.datasync.restart']" + @input="settingsNeedSaved = true" + expanded> </b-input> </b-field> </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> ThisPageData.showConfigFilesNote = false ThisPageData.profilesData = ${json.dumps(profiles_data)|n} ThisPageData.showDisabledProfiles = false - ThisPageData.useProfileSettings = ${json.dumps(use_profile_settings)|n} ThisPageData.editProfileShowDialog = false ThisPageData.editingProfile = null @@ -573,22 +639,6 @@ ThisPageData.editingConsumerRunas = null ThisPageData.editingConsumerEnabled = true - ThisPageData.supervisorProcessName = ${json.dumps(supervisor_process_name)|n} - ThisPageData.restartCommand = ${json.dumps(restart_command)|n} - - ThisPage.computed.filteredProfilesData = function() { - if (this.showDisabledProfiles) { - return this.profilesData - } - let data = [] - for (let row of this.profilesData) { - if (row.enabled) { - data.push(row) - } - } - return data - } - ThisPage.computed.updateConsumerDisabled = function() { if (!this.editingConsumerKey) { return true @@ -616,6 +666,15 @@ this.showDisabledProfiles = !this.showDisabledProfiles } + ThisPage.methods.getWatcherRowClass = function(row, i) { + if (!row.enabled) { + if (!this.showDisabledProfiles) { + return 'invisible-watcher' + } + return 'has-background-warning' + } + } + ThisPage.methods.consumerShortList = function(row) { let keys = [] if (row.watcher_consumes_self) { @@ -680,16 +739,9 @@ this.editingProfilePendingConsumers = [] for (let consumer of row.consumers_data) { - let pending = { + const pending = { + ...consumer, original_key: consumer.key, - key: consumer.key, - consumer_spec: consumer.consumer_spec, - consumer_dbkey: consumer.consumer_dbkey, - consumer_delay: consumer.consumer_delay, - consumer_retry_attempts: consumer.consumer_retry_attempts, - consumer_retry_delay: consumer.consumer_retry_delay, - consumer_runas: consumer.consumer_runas, - enabled: consumer.enabled, } this.editingProfilePendingConsumers.push(pending) } @@ -737,8 +789,8 @@ this.editingProfilePendingWatcherKwargs.splice(i, 1) } - ThisPage.methods.findOriginalConsumer = function(key) { - for (let consumer of this.editingProfile.consumers_data) { + ThisPage.methods.findConsumer = function(profileConsumers, key) { + for (const consumer of profileConsumers) { if (consumer.key == key) { return consumer } @@ -746,11 +798,15 @@ } ThisPage.methods.updateProfile = function() { - let row = this.editingProfile + const row = this.editingProfile - if (!row.key) { + const newRow = !row.key + let originalProfile = null + if (newRow) { row.consumers_data = [] this.profilesData.push(row) + } else { + originalProfile = this.findProfile(row) } row.key = this.editingProfileKey @@ -798,7 +854,8 @@ for (let pending of this.editingProfilePendingConsumers) { persistentConsumers.push(pending.key) if (pending.original_key) { - let consumer = this.findOriginalConsumer(pending.original_key) + const consumer = this.findConsumer(originalProfile.consumers_data, + pending.original_key) consumer.key = pending.key consumer.consumer_spec = pending.consumer_spec consumer.consumer_dbkey = pending.consumer_dbkey @@ -825,10 +882,31 @@ row.consumers_data.splice(i, 1) } + if (!newRow) { + + // nb. must explicitly update the original data row; + // otherwise (with vue3) it will remain stale and + // submitting the form will keep same settings! + // TODO: this probably means i am doing something + // sloppy, but at least this hack fixes for now. + const profile = this.findProfile(row) + for (const key of Object.keys(row)) { + profile[key] = row[key] + } + } + this.settingsNeedSaved = true this.editProfileShowDialog = false } + ThisPage.methods.findProfile = function(row) { + for (const profile of this.profilesData) { + if (profile.key == row.key) { + return profile + } + } + } + ThisPage.methods.deleteProfile = function(row) { if (confirm("Are you sure you want to delete the '" + row.key + "' profile?")) { let i = this.profilesData.indexOf(row) @@ -865,8 +943,10 @@ } ThisPage.methods.updateConsumer = function() { - let pending = this.editingConsumer - let isNew = !pending.key + const pending = this.findConsumer( + this.editingProfilePendingConsumers, + this.editingConsumer.key) + const isNew = !pending.key pending.key = this.editingConsumerKey pending.consumer_spec = this.editingConsumerSpec @@ -907,6 +987,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/datasync/status.mako b/tailbone/templates/datasync/status.mako index 6df35bbb..e14686f8 100644 --- a/tailbone/templates/datasync/status.mako +++ b/tailbone/templates/datasync/status.mako @@ -5,13 +5,6 @@ <%def name="content_title()"></%def> -<%def name="context_menu_items()"> - ${parent.context_menu_items()} - % if request.has_perm('datasync_changes.list'): - <li>${h.link_to("View DataSync Changes", url('datasyncchanges'))}</li> - % endif -</%def> - <%def name="page_content()"> % if expose_websockets and not supervisor_error: <b-notification type="is-warning" @@ -47,83 +40,84 @@ </div> </b-field> - <b-field label="Watcher Status"> - <b-table :data="watchers"> - <b-table-column field="key" + <h3 class="is-size-3">Watcher Status</h3> + + <${b}-table :data="watchers"> + <${b}-table-column field="key" label="Watcher" v-slot="props"> {{ props.row.key }} - </b-table-column> - <b-table-column field="spec" + </${b}-table-column> + <${b}-table-column field="spec" label="Spec" v-slot="props"> {{ props.row.spec }} - </b-table-column> - <b-table-column field="dbkey" + </${b}-table-column> + <${b}-table-column field="dbkey" label="DB Key" v-slot="props"> {{ props.row.dbkey }} - </b-table-column> - <b-table-column field="delay" + </${b}-table-column> + <${b}-table-column field="delay" label="Delay" v-slot="props"> {{ props.row.delay }} second(s) - </b-table-column> - <b-table-column field="lastrun" + </${b}-table-column> + <${b}-table-column field="lastrun" label="Last Watched" v-slot="props"> <span v-html="props.row.lastrun"></span> - </b-table-column> - <b-table-column field="status" + </${b}-table-column> + <${b}-table-column field="status" label="Status" v-slot="props"> <span :class="props.row.status == 'okay' ? 'has-background-success' : 'has-background-warning'"> {{ props.row.status }} </span> - </b-table-column> - </b-table> - </b-field> + </${b}-table-column> + </${b}-table> - <b-field label="Consumer Status"> - <b-table :data="consumers"> - <b-table-column field="key" + <h3 class="is-size-3">Consumer Status</h3> + + <${b}-table :data="consumers"> + <${b}-table-column field="key" label="Consumer" v-slot="props"> {{ props.row.key }} - </b-table-column> - <b-table-column field="spec" + </${b}-table-column> + <${b}-table-column field="spec" label="Spec" v-slot="props"> {{ props.row.spec }} - </b-table-column> - <b-table-column field="dbkey" + </${b}-table-column> + <${b}-table-column field="dbkey" label="DB Key" v-slot="props"> {{ props.row.dbkey }} - </b-table-column> - <b-table-column field="delay" + </${b}-table-column> + <${b}-table-column field="delay" label="Delay" v-slot="props"> {{ props.row.delay }} second(s) - </b-table-column> - <b-table-column field="changes" + </${b}-table-column> + <${b}-table-column field="changes" label="Pending Changes" v-slot="props"> {{ props.row.changes }} - </b-table-column> - <b-table-column field="status" + </${b}-table-column> + <${b}-table-column field="status" label="Status" v-slot="props"> <span :class="props.row.status == 'okay' ? 'has-background-success' : 'has-background-warning'"> {{ props.row.status }} </span> - </b-table-column> - </b-table> - </b-field> + </${b}-table-column> + </${b}-table> </%def> -<%def name="modify_this_page_vars()"> - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> ThisPageData.processInfo = ${json.dumps(process_info)|n} @@ -178,6 +172,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/deform/checked_password.pt b/tailbone/templates/deform/checked_password.pt index f78c0b85..2121f01d 100644 --- a/tailbone/templates/deform/checked_password.pt +++ b/tailbone/templates/deform/checked_password.pt @@ -1,6 +1,7 @@ <div i18n:domain="deform" tal:omit-tag="" tal:define="oid oid|field.oid; name name|field.name; + vmodel vmodel|'field_model_' + name; css_class css_class|field.widget.css_class; style style|field.widget.style;"> @@ -8,7 +9,7 @@ ${field.start_mapping()} <b-input type="password" name="${name}" - value="${field.widget.redisplay and cstruct or ''}" + v-model="${vmodel}" tal:attributes="class string: form-control ${css_class or ''}; style style; attributes|field.widget.attributes|{};" @@ -18,7 +19,6 @@ </b-input> <b-input type="password" name="${name}-confirm" - value="${field.widget.redisplay and confirm or ''}" tal:attributes="class string: form-control ${css_class or ''}; style style; confirm_attributes|field.widget.confirm_attributes|{};" diff --git a/tailbone/templates/deform/file_upload.pt b/tailbone/templates/deform/file_upload.pt index e165fdfa..af78eaf9 100644 --- a/tailbone/templates/deform/file_upload.pt +++ b/tailbone/templates/deform/file_upload.pt @@ -2,11 +2,14 @@ <tal:block tal:define="oid oid|field.oid; css_class css_class|field.widget.css_class; style style|field.widget.style; - field_name field_name|field.name;"> + field_name field_name|field.name; + use_oruga use_oruga;"> <div tal:define="vmodel vmodel|'field_model_' + field_name;"> ${field.start_mapping()} - <b-field class="file"> + + <b-field class="file" + tal:condition="not use_oruga"> <b-upload name="upload" v-model="${vmodel}"> <a class="button is-primary"> @@ -18,6 +21,23 @@ {{ ${vmodel}.name }} </span> </b-field> + + <o-field class="file" + tal:condition="use_oruga"> + <o-upload name="upload" + v-slot="{ onclick }" + v-model="${vmodel}"> + <o-button variant="primary" + @click="onclick"> + <o-icon icon="upload" /> + <span>Click to upload</span> + </o-button> + </o-upload> + <span class="file-name" v-if="${vmodel}"> + {{ ${vmodel}.name }} + </span> + </o-field> + ${field.end_mapping()} </div> diff --git a/tailbone/templates/deform/message_recipients_buefy.pt b/tailbone/templates/deform/message_recipients.pt similarity index 100% rename from tailbone/templates/deform/message_recipients_buefy.pt rename to tailbone/templates/deform/message_recipients.pt diff --git a/tailbone/templates/departments/view.mako b/tailbone/templates/departments/view.mako index 442f045f..c5c39cbb 100644 --- a/tailbone/templates/departments/view.mako +++ b/tailbone/templates/departments/view.mako @@ -1,13 +1,9 @@ ## -*- coding: utf-8; -*- <%inherit file="/master/view.mako" /> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> - - ${form.component_studly}Data.employeesData = ${json.dumps(employees_data)|n} - +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> + ${form.vue_component}Data.employeesData = ${json.dumps(employees_data)|n} </script> </%def> - -${parent.body()} diff --git a/tailbone/templates/form.mako b/tailbone/templates/form.mako index 5878e030..e3a4d5dc 100644 --- a/tailbone/templates/form.mako +++ b/tailbone/templates/form.mako @@ -5,21 +5,64 @@ <%def name="render_form_buttons()"></%def> -<%def name="render_form()"> - ${form.render(buttons=capture(self.render_form_buttons))|n} +<%def name="render_form_template()"> + ${form.render_vue_template(buttons=capture(self.render_form_buttons))|n} </%def> -<%def name="render_buefy_form()"> +<%def name="render_form()"> <div class="form"> - ${form.render_vuejs_component()} + ${form.render_vue_tag()} </div> </%def> <%def name="page_content()"> - <div class="form-wrapper"> - <br /> - ${self.render_buefy_form()} - </div> + % if main_form_collapsible: + <${b}-collapse class="panel" + % if request.use_oruga: + v-model:open="mainFormPanelOpen" + % else: + :open.sync="mainFormPanelOpen" + % endif + > + <template #trigger="props"> + <div class="panel-heading" + role="button" + style="cursor: pointer;"> + + ## TODO: for some reason buefy will "reuse" the icon + ## element in such a way that its display does not + ## refresh. so to work around that, we use different + ## structure for the two icons, so buefy is forced to + ## re-draw + + <b-icon v-if="props.open" + pack="fas" + icon="caret-down"> + </b-icon> + + <span v-if="!props.open"> + <b-icon pack="fas" + icon="caret-right"> + </b-icon> + </span> + + + <strong>${main_form_title}</strong> + </div> + </template> + <div class="panel-block"> + <div class="form-wrapper"> + <br /> + ${self.render_form()} + </div> + </div> + </${b}-collapse> + % else: + <div class="form-wrapper"> + <br /> + ${self.render_form()} + </div> + % endif </%def> <%def name="render_this_page()"> @@ -47,25 +90,25 @@ <%def name="before_object_helpers()"></%def> -<%def name="render_this_page_template()"> +<%def name="render_vue_templates()"> + ${parent.render_vue_templates()} % if form is not Undefined: - ${self.render_form()} + ${self.render_form_template()} % endif - ${parent.render_this_page_template()} </%def> -<%def name="finalize_this_page_vars()"> - ${parent.finalize_this_page_vars()} - % if form is not Undefined: - <script type="text/javascript"> - - ${form.component_studly}.data = function() { return ${form.component_studly}Data } - - Vue.component('${form.component}', ${form.component_studly}) - +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + % if main_form_collapsible: + <script> + ThisPageData.mainFormPanelOpen = ${'false' if main_form_autocollapse else 'true'} </script> % endif </%def> - -${parent.body()} +<%def name="make_vue_components()"> + ${parent.make_vue_components()} + % if form is not Undefined: + ${form.render_vue_finalize()} + % endif +</%def> diff --git a/tailbone/templates/formposter.mako b/tailbone/templates/formposter.mako index ab9c720d..d566a467 100644 --- a/tailbone/templates/formposter.mako +++ b/tailbone/templates/formposter.mako @@ -39,7 +39,7 @@ simplePOST(action, params, success, failure) { - let csrftoken = ${json.dumps(request.session.get_csrf_token() or request.session.new_csrf_token())|n} + let csrftoken = ${json.dumps(h.get_csrf_token(request))|n} let headers = { '${csrf_header_name}': csrftoken, diff --git a/tailbone/templates/forms/deform_buefy.mako b/tailbone/templates/forms/deform.mako similarity index 78% rename from tailbone/templates/forms/deform_buefy.mako rename to tailbone/templates/forms/deform.mako index 39633117..2100b460 100644 --- a/tailbone/templates/forms/deform_buefy.mako +++ b/tailbone/templates/forms/deform.mako @@ -1,32 +1,34 @@ ## -*- coding: utf-8; -*- -<script type="text/x-template" id="${form.component}-template"> +<% request.register_component(form.vue_tagname, form.vue_component) %> + +<script type="text/x-template" id="${form.vue_tagname}-template"> <div> % if not form.readonly: - ${h.form(form.action_url, id=dform.formid, method='post', enctype='multipart/form-data', **form_kwargs)} + ${h.form(form.action_url, id=dform.formid, method='post', enctype='multipart/form-data', **(form_kwargs or {}))} ${h.csrf_token(request)} % endif <section> % if form_body is not Undefined and form_body: ${form_body|n} - % elif form.grouping: + % elif getattr(form, 'grouping', None): % for group in form.grouping: <nav class="panel"> <p class="panel-heading">${group}</p> <div class="panel-block"> <div> % for field in form.grouping[group]: - ${form.render_buefy_field(field)} + ${form.render_field_complete(field)} % endfor </div> </div> </nav> % endfor % else: - % for field in form.fields: - ${form.render_buefy_field(field)} + % for fieldname in form.fields: + ${form.render_vue_field(fieldname, session=session)} % endfor % endif </section> @@ -52,16 +54,20 @@ <input type="reset" value="Reset" class="button" /> % endif ## TODO: deprecate / remove the latter option here - % if form.auto_disable_save or form.auto_disable: + % if getattr(form, 'auto_disable_submit', False) or form.auto_disable_save or form.auto_disable: <b-button type="is-primary" native-type="submit" - :disabled="${form.component_studly}Submitting"> - {{ ${form.component_studly}ButtonText }} + :disabled="${form.vue_component}Submitting" + icon-pack="fas" + icon-left="${form.button_icon_submit}"> + {{ ${form.vue_component}Submitting ? "Working, please wait..." : "${form.button_label_submit}" }} </b-button> % else: <b-button type="is-primary" - native-type="submit"> - ${getattr(form, 'submit_label', getattr(form, 'save_label', "Submit"))} + native-type="submit" + icon-pack="fas" + icon-left="save"> + ${form.button_label_submit} </b-button> % endif </div> @@ -116,8 +122,8 @@ <script type="text/javascript"> - let ${form.component_studly} = { - template: '#${form.component}-template', + let ${form.vue_component} = { + template: '#${form.vue_tagname}-template', mixins: [FormPosterMixin], components: {}, props: { @@ -130,10 +136,9 @@ methods: { ## TODO: deprecate / remove the latter option here - % if form.auto_disable_save or form.auto_disable: - submit${form.component_studly}() { - this.${form.component_studly}Submitting = true - this.${form.component_studly}ButtonText = "Working, please wait..." + % if getattr(form, 'auto_disable_submit', False) or form.auto_disable_save or form.auto_disable: + submit${form.vue_component}() { + this.${form.vue_component}Submitting = true }, % endif @@ -172,10 +177,10 @@ } } - let ${form.component_studly}Data = { + let ${form.vue_component}Data = { ## TODO: should find a better way to handle CSRF token - csrftoken: ${json.dumps(request.session.get_csrf_token() or request.session.new_csrf_token())|n}, + csrftoken: ${json.dumps(h.get_csrf_token(request))|n}, % if can_edit_help: fieldLabels: ${json.dumps(field_labels)|n}, @@ -192,16 +197,14 @@ % if not form.readonly: % for field in form.fields: % if field in dform: - <% field = dform[field] %> - field_model_${field.name}: ${form.get_vuejs_model_value(field)|n}, + field_model_${field}: ${json.dumps(form.get_vue_field_value(field))|n}, % endif % endfor % endif ## TODO: deprecate / remove the latter option here - % if form.auto_disable_save or form.auto_disable: - ${form.component_studly}Submitting: false, - ${form.component_studly}ButtonText: ${json.dumps(getattr(form, 'submit_label', getattr(form, 'save_label', "Submit")))|n}, + % if getattr(form, 'auto_disable_submit', False) or form.auto_disable_save or form.auto_disable: + ${form.vue_component}Submitting: false, % endif } diff --git a/tailbone/templates/forms/form.mako b/tailbone/templates/forms/form.mako deleted file mode 100644 index cd8fecc8..00000000 --- a/tailbone/templates/forms/form.mako +++ /dev/null @@ -1,2 +0,0 @@ -## -*- coding: utf-8; -*- -${form.render_deform(buttons=buttons)|n} diff --git a/tailbone/templates/forms/util.mako b/tailbone/templates/forms/util.mako deleted file mode 100644 index 22e7f918..00000000 --- a/tailbone/templates/forms/util.mako +++ /dev/null @@ -1,7 +0,0 @@ -## -*- coding: utf-8; -*- - -## TODO: deprecate / remove this -## (tried to add deprecation warning here but it didn't seem to work) -<%def name="render_buefy_field(field, bfield_kwargs={})"> - ${form.render_buefy_field(field.name, bfield_attrs=bfield_kwargs)} -</%def> diff --git a/tailbone/templates/forms/vue_template.mako b/tailbone/templates/forms/vue_template.mako new file mode 100644 index 00000000..ac096f67 --- /dev/null +++ b/tailbone/templates/forms/vue_template.mako @@ -0,0 +1,3 @@ +## -*- coding: utf-8; -*- +<%inherit file="/forms/deform.mako" /> +${parent.body()} diff --git a/tailbone/templates/generate_feature.mako b/tailbone/templates/generate_feature.mako index 18c9a7a2..0f2a9f7b 100644 --- a/tailbone/templates/generate_feature.mako +++ b/tailbone/templates/generate_feature.mako @@ -87,7 +87,7 @@ <div class="level-item"> <b-button type="is-primary" icon-pack="fas" - icon-left="fas fa-plus" + icon-left="plus" @click="addColumn()"> New Column </b-button> @@ -97,7 +97,7 @@ <div class="level-item"> <b-button type="is-danger" icon-pack="fas" - icon-left="fas fa-trash" + icon-left="trash" @click="new_table.columns = []" :disabled="!new_table.columns.length"> Delete All @@ -106,55 +106,68 @@ </div> </div> - <b-table + <${b}-table :data="new_table.columns"> - <b-table-column field="name" + <${b}-table-column field="name" label="Name" v-slot="props"> {{ props.row.name }} - </b-table-column> + </${b}-table-column> - <b-table-column field="data_type" + <${b}-table-column field="data_type" label="Data Type" v-slot="props"> {{ props.row.data_type }} - </b-table-column> + </${b}-table-column> - <b-table-column field="nullable" + <${b}-table-column field="nullable" label="Nullable" v-slot="props"> {{ props.row.nullable }} - </b-table-column> + </${b}-table-column> - <b-table-column field="description" + <${b}-table-column field="description" label="Description" v-slot="props"> {{ props.row.description }} - </b-table-column> + </${b}-table-column> - <b-table-column field="actions" + <${b}-table-column field="actions" label="Actions" v-slot="props"> <a href="#" class="grid-action" - @click.prevent="editColumnRow(props.row)"> - <i class="fas fa-edit"></i> + @click.prevent="editColumnRow(props)"> + % if request.use_oruga: + <o-icon icon="edit" /> + % else: + <i class="fas fa-edit"></i> + % endif Edit </a> <a href="#" class="grid-action has-text-danger" @click.prevent="deleteColumn(props.index)"> - <i class="fas fa-trash"></i> + % if request.use_oruga: + <o-icon icon="trash" /> + % else: + <i class="fas fa-trash"></i> + % endif Delete </a> - </b-table-column> + </${b}-table-column> - </b-table> + </${b}-table> - <b-modal has-modal-card - :active.sync="showingEditColumn"> + <${b}-modal has-modal-card + % if request.use_oruga: + v-model:active="showingEditColumn" + % else: + :active.sync="showingEditColumn" + % endif + > <div class="modal-card"> <header class="modal-card-head"> @@ -164,11 +177,13 @@ <section class="modal-card-body"> <b-field label="Name"> - <b-input v-model="editingColumnName"></b-input> + <b-input v-model="editingColumnName" + expanded /> </b-field> <b-field label="Data Type"> - <b-input v-model="editingColumnDataType"></b-input> + <b-input v-model="editingColumnDataType" + expanded /> </b-field> <b-field label="Nullable"> @@ -179,7 +194,8 @@ </b-field> <b-field label="Description"> - <b-input v-model="editingColumnDescription"></b-input> + <b-input v-model="editingColumnDescription" + expanded /> </b-field> </section> @@ -194,7 +210,7 @@ </b-button> </footer> </div> - </b-modal> + </${b}-modal> </div> </b-field> @@ -260,9 +276,9 @@ </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> ThisPageData.featureType = ${json.dumps(feature_type)|n} ThisPageData.resultGenerated = ${json.dumps(bool(result))|n} @@ -280,7 +296,7 @@ % endfor } - % for key, form in six.iteritems(feature_forms): + % for key, form in feature_forms.items(): <% safekey = key.replace('-', '_') %> ThisPageData.${safekey} = { <% dform = feature_forms[key].make_deform_form() %> @@ -315,6 +331,7 @@ ThisPageData.showingEditColumn = false ThisPageData.editingColumn = null + ThisPageData.editingColumnIndex = null ThisPageData.editingColumnName = null ThisPageData.editingColumnDataType = null ThisPageData.editingColumnNullable = null @@ -322,6 +339,7 @@ ThisPage.methods.addColumn = function(column) { this.editingColumn = null + this.editingColumnIndex = null this.editingColumnName = null this.editingColumnDataType = null this.editingColumnNullable = true @@ -329,8 +347,10 @@ this.showingEditColumn = true } - ThisPage.methods.editColumnRow = function(column) { + ThisPage.methods.editColumnRow = function(props) { + const column = props.row this.editingColumn = column + this.editingColumnIndex = props.index this.editingColumnName = column.name this.editingColumnDataType = column.data_type this.editingColumnNullable = column.nullable @@ -340,7 +360,7 @@ ThisPage.methods.saveColumn = function() { if (this.editingColumn) { - column = this.editingColumn + column = this.new_table.columns[this.editingColumnIndex] } else { column = {} this.new_table.columns.push(column) @@ -365,6 +385,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/generated-projects/create.mako b/tailbone/templates/generated-projects/create.mako index 32d205a0..6c3af299 100644 --- a/tailbone/templates/generated-projects/create.mako +++ b/tailbone/templates/generated-projects/create.mako @@ -8,7 +8,8 @@ <%def name="page_content()"> % if project_type: <b-field grouped> - <b-field horizontal expanded label="Project Type"> + <b-field horizontal expanded label="Project Type" + class="is-expanded"> ${project_type} </b-field> <once-button type="is-primary" diff --git a/tailbone/templates/grids/b-table.mako b/tailbone/templates/grids/b-table.mako index fbd36cbb..da9f2aae 100644 --- a/tailbone/templates/grids/b-table.mako +++ b/tailbone/templates/grids/b-table.mako @@ -1,5 +1,5 @@ ## -*- coding: utf-8; -*- -<b-table +<${b}-table :data="${data_prop}" icon-pack="fas" striped @@ -21,7 +21,7 @@ > % for i, column in enumerate(grid_columns): - <b-table-column field="${column['field']}" + <${b}-table-column field="${column['field']}" % if not empty_labels: label="${column['label']}" % elif i > 0: @@ -50,14 +50,14 @@ % else: <span v-html="props.row.${column['field']}"></span> % endif - </b-table-column> + </${b}-table-column> % endfor - % if grid.main_actions or grid.more_actions: - <b-table-column field="actions" + % if grid.actions: + <${b}-table-column field="actions" label="Actions" v-slot="props"> - % for action in grid.main_actions: + % for action in grid.actions: <a :href="props.row._action_url_${action.key}" % if action.link_class: class="${action.link_class}" @@ -68,20 +68,19 @@ @click.prevent="${action.click_handler}" % endif > - <i class="fas fa-${action.icon}"></i> - ${action.label} + ${action.render_icon_and_label()} </a> % endfor - </b-table-column> + </${b}-table-column> % endif - <template slot="empty"> + <template #empty> <div class="content has-text-grey has-text-centered"> <p> <b-icon pack="fas" - icon="fas fa-sad-tear" + icon="sad-tear" size="is-large"> </b-icon> </p> @@ -99,4 +98,4 @@ </template> % endif -</b-table> +</${b}-table> diff --git a/tailbone/templates/grids/buefy.mako b/tailbone/templates/grids/buefy.mako deleted file mode 100644 index a3e6e229..00000000 --- a/tailbone/templates/grids/buefy.mako +++ /dev/null @@ -1,842 +0,0 @@ -## -*- coding: utf-8; -*- - -<script type="text/x-template" id="grid-filter-numeric-value-template"> - <div class="level"> - <div class="level-left"> - <div class="level-item"> - <b-input v-model="startValue" - ref="startValue" - @input="startValueChanged"> - </b-input> - </div> - <div v-show="wantsRange" - class="level-item"> - and - </div> - <div v-show="wantsRange" - class="level-item"> - <b-input v-model="endValue" - ref="endValue" - @input="endValueChanged"> - </b-input> - </div> - </div> - </div> -</script> - -<script type="text/x-template" id="grid-filter-date-value-template"> - <div class="level"> - <div class="level-left"> - <div class="level-item"> - <tailbone-datepicker v-model="startDate" - ref="startDate" - @input="startDateChanged"> - </tailbone-datepicker> - </div> - <div v-show="dateRange" - class="level-item"> - and - </div> - <div v-show="dateRange" - class="level-item"> - <tailbone-datepicker v-model="endDate" - ref="endDate" - @input="endDateChanged"> - </tailbone-datepicker> - </div> - </div> - </div> -</script> - -<script type="text/x-template" id="grid-filter-template"> - - <div class="level filter" v-show="filter.visible"> - <div class="level-left" - style="align-items: start;"> - - <div class="level-item filter-fieldname"> - - <b-field> - <b-checkbox-button v-model="filter.active" native-value="IGNORED"> - <b-icon pack="fas" icon="check" v-show="filter.active"></b-icon> - <span>{{ filter.label }}</span> - </b-checkbox-button> - </b-field> - - </div> - - <b-field grouped v-show="filter.active" - class="level-item" - style="align-items: start;"> - - <b-select v-model="filter.verb" - @input="focusValue()" - class="filter-verb"> - <option v-for="verb in filter.verbs" - :key="verb" - :value="verb"> - {{ filter.verb_labels[verb] }} - </option> - </b-select> - - ## only one of the following "value input" elements will be rendered - - <grid-filter-date-value v-if="filter.data_type == 'date'" - v-model="filter.value" - v-show="valuedVerb()" - :date-range="filter.verb == 'between'" - ref="valueInput"> - </grid-filter-date-value> - - <b-select v-if="filter.data_type == 'choice'" - v-model="filter.value" - v-show="valuedVerb()" - ref="valueInput"> - <option v-for="choice in filter.choices" - :key="choice" - :value="choice"> - {{ filter.choice_labels[choice] || choice }} - </option> - </b-select> - - <grid-filter-numeric-value v-if="filter.data_type == 'number'" - v-model="filter.value" - v-show="valuedVerb()" - :wants-range="filter.verb == 'between'" - ref="valueInput"> - </grid-filter-numeric-value> - - <b-input v-if="filter.data_type == 'string' && !multiValuedVerb()" - v-model="filter.value" - v-show="valuedVerb()" - ref="valueInput"> - </b-input> - - <b-input v-if="filter.data_type == 'string' && multiValuedVerb()" - type="textarea" - v-model="filter.value" - v-show="valuedVerb()" - ref="valueInput"> - </b-input> - - </b-field> - - </div><!-- level-left --> - </div><!-- level --> - -</script> - -<script type="text/x-template" id="${grid.component}-template"> - <div> - - <div style="display: flex; justify-content: space-between; margin-bottom: 0.5em;"> - - <div style="display: flex; flex-direction: column; justify-content: space-between;"> - <div></div> - <div class="filters"> - % if grid.filterable: - ## TODO: stop using |n filter - ${grid.render_filters(template='/grids/filters_buefy.mako', allow_save_defaults=allow_save_defaults)|n} - % endif - </div> - </div> - - <div style="display: flex; flex-direction: column; justify-content: space-between;"> - - <div class="context-menu"> - % if context_menu: - <ul id="context-menu"> - ## TODO: stop using |n filter - ${context_menu|n} - </ul> - % endif - </div> - - <div class="grid-tools-wrapper"> - % if tools: - <div class="grid-tools field buttons is-grouped is-pulled-right"> - ## TODO: stop using |n filter - ${tools|n} - </div> - % endif - </div> - - </div> - - </div> - - <b-table - :data="visibleData" - ## :columns="columns" - :loading="loading" - :row-class="getRowClass" - - ## TODO: this should be more configurable, maybe auto-detect based - ## on buefy version?? probably cannot do that, but this feature - ## is only supported with buefy 0.8.13 and newer - % if request.rattail_config.getbool('tailbone', 'sticky_headers'): - sticky-header - height="600px" - % endif - - :checkable="checkable" - - % if grid.checkboxes: - :checked-rows.sync="checkedRows" - % if grid.clicking_row_checks_box: - @click="rowClick" - % endif - % endif - - % if grid.check_handler: - @check="${grid.check_handler}" - % endif - % if grid.check_all_handler: - @check-all="${grid.check_all_handler}" - % endif - - % if isinstance(grid.checkable, str): - :is-row-checkable="${grid.row_checkable}" - % elif grid.checkable: - :is-row-checkable="row => row._checkable" - % endif - - % if grid.sortable: - backend-sorting - @sort="onSort" - @sorting-priority-removed="sortingPriorityRemoved" - - ## TODO: there is a bug (?) which prevents the arrow from - ## displaying for simple default single-column sort. so to - ## work around that, we *disable* multi-sort until the - ## component is mounted. seems to work for now..see also - ## https://github.com/buefy/buefy/issues/2584 - :sort-multiple="allowMultiSort" - - ## nb. specify default sort only if single-column - :default-sort="backendSorters.length == 1 ? [backendSorters[0].field, backendSorters[0].order] : null" - - ## nb. otherwise there may be default multi-column sort - :sort-multiple-data="sortingPriority" - - ## user must ctrl-click column header to do multi-sort - sort-multiple-key="ctrlKey" - % endif - - % if grid.click_handlers: - @cellclick="cellClick" - % endif - - :paginated="paginated" - :per-page="perPage" - :current-page="currentPage" - backend-pagination - :total="total" - @page-change="onPageChange" - - ## TODO: should let grid (or master view) decide how to set these? - icon-pack="fas" - ## note that :striped="true" was interfering with row status (e.g. warning) styles - :striped="false" - :hoverable="true" - :narrowed="true"> - - % for column in grid_columns: - <b-table-column field="${column['field']}" - label="${column['label']}" - v-slot="props" - :sortable="${json.dumps(column['sortable'])}" - % if grid.is_searchable(column['field']): - searchable - % endif - cell-class="c_${column['field']}" - :visible="${json.dumps(column['visible'])}"> - % if column['field'] in grid.raw_renderers: - ${grid.raw_renderers[column['field']]()} - % elif grid.is_linked(column['field']): - <a :href="props.row._action_url_view" - % if view_click_handler: - @click.prevent="${view_click_handler}" - % endif - v-html="props.row.${column['field']}"> - </a> - % else: - <span v-html="props.row.${column['field']}"></span> - % endif - </b-table-column> - % endfor - - % if grid.main_actions or grid.more_actions: - <b-table-column field="actions" - label="Actions" - v-slot="props"> - ## TODO: we do not currently differentiate for "main vs. more" - ## here, but ideally we would tuck "more" away in a drawer etc. - % for action in grid.main_actions + grid.more_actions: - <a v-if="props.row._action_url_${action.key}" - :href="props.row._action_url_${action.key}" - class="grid-action${' has-text-danger' if action.key == 'delete' else ''} ${action.link_class or ''}" - % if action.click_handler: - @click.prevent="${action.click_handler}" - % endif - % if action.target: - target="${action.target}" - % endif - > - ${action.render_icon()|n} - ${action.render_label()|n} - </a> - - % endfor - </b-table-column> - % endif - - <template #empty> - <section class="section"> - <div class="content has-text-grey has-text-centered"> - <p> - <b-icon - pack="fas" - icon="fas fa-sad-tear" - size="is-large"> - </b-icon> - </p> - <p>Nothing here.</p> - </div> - </section> - </template> - - <template #footer> - <div style="display: flex; justify-content: space-between;"> - - % if grid.expose_direct_link: - <b-button type="is-primary" - size="is-small" - @click="copyDirectLink()" - title="Copy link to clipboard"> - <span><i class="fa fa-share-alt"></i></span> - </b-button> - % else: - <div></div> - % endif - - % if grid.pageable: - <b-field grouped - v-if="firstItem"> - <span class="control"> - showing {{ firstItem.toLocaleString('en') }} - {{ lastItem.toLocaleString('en') }} of {{ total.toLocaleString('en') }} results; - </span> - <b-select v-model="perPage" - size="is-small" - @input="loadAsyncData()"> - % for value in grid.get_pagesize_options(): - <option value="${value}">${value}</option> - % endfor - </b-select> - <span class="control"> - per page - </span> - </b-field> - % endif - - </div> - </template> - - </b-table> - - ## dummy input field needed for sharing links on *insecure* sites - % if request.scheme == 'http': - <b-input v-model="shareLink" ref="shareLink" v-show="shareLink"></b-input> - % endif - - </div> -</script> - -<script type="text/javascript"> - - let ${grid.component_studly}CurrentData = ${json.dumps(grid_data['data'])|n} - - let ${grid.component_studly}Data = { - loading: false, - ajaxDataUrl: ${json.dumps(grid.ajax_data_url)|n}, - - data: ${grid.component_studly}CurrentData, - rowStatusMap: ${json.dumps(grid_data['row_status_map'])|n}, - - checkable: ${json.dumps(grid.checkboxes)|n}, - % if grid.checkboxes: - checkedRows: ${grid_data['checked_rows_code']|n}, - % endif - - paginated: ${json.dumps(grid.pageable)|n}, - total: ${len(grid_data['data']) if static_data else grid_data['total_items']}, - perPage: ${json.dumps(grid.pagesize if grid.pageable else None)|n}, - currentPage: ${json.dumps(grid.page if grid.pageable else None)|n}, - firstItem: ${json.dumps(grid_data['first_item'] if grid.pageable else None)|n}, - lastItem: ${json.dumps(grid_data['last_item'] if grid.pageable else None)|n}, - - % if grid.sortable: - - ## TODO: there is a bug (?) which prevents the arrow from - ## displaying for simple default single-column sort. so to - ## work around that, we *disable* multi-sort until the - ## component is mounted. seems to work for now..see also - ## https://github.com/buefy/buefy/issues/2584 - allowMultiSort: false, - - ## nb. this contains all truly active sorters - backendSorters: ${json.dumps(grid.active_sorters)|n}, - - ## nb. whereas this will only contain multi-column sorters, - ## but will be *empty* for single-column sorting - % if len(grid.active_sorters) > 1: - sortingPriority: ${json.dumps(grid.active_sorters)|n}, - % else: - sortingPriority: [], - % endif - - % endif - - ## filterable: ${json.dumps(grid.filterable)|n}, - filters: ${json.dumps(filters_data if grid.filterable else None)|n}, - filtersSequence: ${json.dumps(filters_sequence if grid.filterable else None)|n}, - addFilterTerm: '', - addFilterShow: false, - - ## dummy input value needed for sharing links on *insecure* sites - % if request.scheme == 'http': - shareLink: null, - % endif - } - - let ${grid.component_studly} = { - template: '#${grid.component}-template', - - mixins: [FormPosterMixin], - - props: { - csrftoken: String, - }, - - computed: { - - addFilterChoices() { - - // collect all filters, which are *not* already shown - let choices = [] - for (let field of this.filtersSequence) { - let filtr = this.filters[field] - if (!filtr.visible) { - choices.push(filtr) - } - } - - // parse list of search terms - let terms = [] - for (let term of this.addFilterTerm.toLowerCase().split(' ')) { - term = term.trim() - if (term) { - terms.push(term) - } - } - - // only filters matching all search terms are presented - // as choices to the user - return choices.filter(option => { - let label = option.label.toLowerCase() - for (let term of terms) { - if (label.indexOf(term) < 0) { - return false - } - } - return true - }) - }, - - // note, can use this with v-model for hidden 'uuids' fields - selected_uuids: function() { - return this.checkedRowUUIDs().join(',') - }, - - // nb. this can be overridden if needed, e.g. to dynamically - // show/hide certain records in a static data set - visibleData() { - return this.data - }, - - directLink() { - let params = new URLSearchParams(this.getAllParams()) - return `${request.current_route_url(_query=None)}?${'$'}{params}` - }, - }, - - mounted() { - ## TODO: there is a bug (?) which prevents the arrow from - ## displaying for simple default single-column sort. so to - ## work around that, we *disable* multi-sort until the - ## component is mounted. seems to work for now..see also - ## https://github.com/buefy/buefy/issues/2584 - this.allowMultiSort = true - }, - - methods: { - - % if grid.click_handlers: - cellClick(row, column, rowIndex, columnIndex) { - % for key in grid.click_handlers: - if (column._props.field == '${key}') { - ${grid.click_handlers[key]}(row) - } - % endfor - }, - % endif - - copyDirectLink() { - - if (navigator.clipboard) { - // this is the way forward, but requires HTTPS - navigator.clipboard.writeText(this.directLink) - - } else { - // use deprecated 'copy' command, but this just - // tells the browser to copy currently-selected - // text..which means we first must "add" some text - // to screen, and auto-select that, before copying - // to clipboard - this.shareLink = this.directLink - this.$nextTick(() => { - let input = this.$refs.shareLink.$el.firstChild - input.select() - document.execCommand('copy') - // re-hide the dummy input - this.shareLink = null - }) - } - - this.$buefy.toast.open({ - message: "Link was copied to clipboard", - type: 'is-info', - duration: 2000, // 2 seconds - }) - }, - - addRowClass(index, className) { - - // TODO: this may add duplicated name to class string - // (not a serious problem i think, but could be improved) - this.rowStatusMap[index] = (this.rowStatusMap[index] || '') - + ' ' + className - - // nb. for some reason b-table does not always "notice" - // when we update status; so we force it to refresh - this.$forceUpdate() - }, - - getRowClass(row, index) { - return this.rowStatusMap[index] - }, - - getBasicParams() { - let params = {} - % if grid.sortable: - for (let i = 1; i <= this.backendSorters.length; i++) { - params['sort'+i+'key'] = this.backendSorters[i-1].field - params['sort'+i+'dir'] = this.backendSorters[i-1].order - } - % endif - % if grid.pageable: - params.pagesize = this.perPage - params.page = this.currentPage - % endif - return params - }, - - getFilterParams() { - let params = {} - for (var key in this.filters) { - var filter = this.filters[key] - if (filter.active) { - params[key] = filter.value - params[key+'.verb'] = filter.verb - } - } - if (Object.keys(params).length) { - params.filter = true - } - return params - }, - - getAllParams() { - return {...this.getBasicParams(), - ...this.getFilterParams()} - }, - - ## TODO: i noticed buefy docs show using `async` keyword here, - ## so now i am too. knowing nothing at all of if/how this is - ## supposed to improve anything. we shall see i guess - async loadAsyncData(params, success, failure) { - - if (params === undefined || params === null) { - params = new URLSearchParams(this.getBasicParams()) - params.append('partial', true) - params = params.toString() - } - - this.loading = true - this.$http.get(`${'$'}{this.ajaxDataUrl}?${'$'}{params}`).then(({ data }) => { - ${grid.component_studly}CurrentData = data.data - this.data = ${grid.component_studly}CurrentData - this.rowStatusMap = data.row_status_map - this.total = data.total_items - this.firstItem = data.first_item - this.lastItem = data.last_item - this.loading = false - this.checkedRows = this.locateCheckedRows(data.checked_rows) - if (success) { - success() - } - }) - .catch((error) => { - this.data = [] - this.total = 0 - this.loading = false - if (failure) { - failure() - } - throw error - }) - }, - - locateCheckedRows(checked) { - let rows = [] - if (checked) { - for (let i = 0; i < this.data.length; i++) { - if (checked.includes(i)) { - rows.push(this.data[i]) - } - } - } - return rows - }, - - onPageChange(page) { - this.currentPage = page - this.loadAsyncData() - }, - - onSort(field, order, event) { - - if (event.ctrlKey) { - - // engage or enhance multi-column sorting - let sorter = this.backendSorters.filter(i => i.field === field)[0] - if (sorter) { - sorter.order = sorter.order === 'desc' ? 'asc' : 'desc' - } else { - this.backendSorters.push({field, order}) - } - this.sortingPriority = this.backendSorters - - } else { - - // sort by single column only - this.backendSorters = [{field, order}] - this.sortingPriority = [] - } - - // always reset to first page when changing sort options - // TODO: i mean..right? would we ever not want that? - this.currentPage = 1 - this.loadAsyncData() - }, - - sortingPriorityRemoved(field) { - - // prune field from active sorters - this.backendSorters = this.backendSorters.filter( - (sorter) => sorter.field !== field) - - // nb. must keep active sorter list "as-is" even if - // there is only one sorter; buefy seems to expect it - this.sortingPriority = this.backendSorters - - this.loadAsyncData() - }, - - resetView() { - this.loading = true - - // use current url proper, plus reset param - let url = '?reset-to-default-filters=true' - - // add current hash, to preserve that in redirect - if (location.hash) { - url += '&hash=' + location.hash.slice(1) - } - - location.href = url - }, - - addFilterButton(event) { - this.addFilterShow = true - this.$nextTick(() => { - this.$refs.addFilterAutocomplete.focus() - }) - }, - - addFilterKeydown(event) { - - // ESC will clear searchbox - if (event.which == 27) { - this.addFilterTerm = '' - this.addFilterShow = false - } - }, - - addFilterSelect(filtr) { - this.addFilter(filtr.key) - this.addFilterTerm = '' - this.addFilterShow = false - }, - - addFilter(filter_key) { - - // show corresponding grid filter - this.filters[filter_key].visible = true - this.filters[filter_key].active = true - - // track down the component - var gridFilter = null - for (var gf of this.$refs.gridFilters) { - if (gf.filter.key == filter_key) { - gridFilter = gf - break - } - } - - // tell component to focus the value field, ASAP - this.$nextTick(function() { - gridFilter.focusValue() - }) - - }, - - applyFilters(params) { - if (params === undefined) { - params = {} - } - - // merge in actual filter params - // cf. https://stackoverflow.com/a/171256 - params = {...params, ...this.getFilterParams()} - - // hide inactive filters - for (var key in this.filters) { - var filter = this.filters[key] - if (!filter.active) { - filter.visible = false - } - } - - // set some explicit params - params.partial = true - params.filter = true - - params = new URLSearchParams(params) - this.loadAsyncData(params) - this.appliedFiltersHook() - }, - - appliedFiltersHook() {}, - - clearFilters() { - - // explicitly deactivate all filters - for (var key in this.filters) { - this.filters[key].active = false - } - - // then just "apply" as normal - this.applyFilters() - }, - - // explicitly set filters for the grid, to the given set. - // this totally overrides whatever might be current. the - // new filter set should look like: - // - // [ - // {key: 'status_code', - // verb: 'equal', - // value: 1}, - // {key: 'description', - // verb: 'contains', - // value: 'whatever'}, - // ] - // - setFilters(newFilters) { - for (let key in this.filters) { - let filter = this.filters[key] - let active = false - for (let newFilter of newFilters) { - if (newFilter.key == key) { - active = true - filter.active = true - filter.visible = true - filter.verb = newFilter.verb - filter.value = newFilter.value - break - } - } - if (!active) { - filter.active = false - filter.visible = false - } - } - this.applyFilters() - }, - - saveDefaults() { - - // apply current filters as normal, but add special directive - this.applyFilters({'save-current-filters-as-defaults': true}) - }, - - deleteObject(event) { - // we let parent component/app deal with this, in whatever way makes sense... - // TODO: should we ever provide anything besides the URL for this? - this.$emit('deleteActionClicked', event.target.href) - }, - - checkedRowUUIDs() { - let uuids = [] - for (let row of this.$data.checkedRows) { - uuids.push(row.uuid) - } - return uuids - }, - - allRowUUIDs() { - let uuids = [] - for (let row of this.data) { - uuids.push(row.uuid) - } - return uuids - }, - - // when a user clicks a row, handle as if they clicked checkbox. - // note that this method is only used if table is "checkable" - rowClick(row) { - let i = this.checkedRows.indexOf(row) - if (i >= 0) { - this.checkedRows.splice(i, 1) - } else { - this.checkedRows.push(row) - } - % if grid.check_handler: - this.${grid.check_handler}(this.checkedRows, row) - % endif - }, - } - } - -</script> diff --git a/tailbone/templates/grids/complete.mako b/tailbone/templates/grids/complete.mako new file mode 100644 index 00000000..60f9a3b8 --- /dev/null +++ b/tailbone/templates/grids/complete.mako @@ -0,0 +1,930 @@ +## -*- coding: utf-8; -*- + +<% request.register_component(grid.vue_tagname, grid.vue_component) %> + +<script type="text/x-template" id="${grid.vue_tagname}-template"> + <div> + + <div style="display: flex; justify-content: space-between; margin-bottom: 0.5em;"> + + <div style="display: flex; flex-direction: column; justify-content: end;"> + <div class="filters"> + % if getattr(grid, 'filterable', False): + <form method="GET" @submit.prevent="applyFilters()"> + + <div style="display: flex; flex-direction: column; gap: 0.5rem;"> + <grid-filter v-for="key in filtersSequence" + :key="key" + :filter="filters[key]" + ref="gridFilters"> + </grid-filter> + </div> + + <div style="display: flex; gap: 0.5rem; margin-top: 0.5rem;"> + + <b-button type="is-primary" + native-type="submit" + icon-pack="fas" + icon-left="check"> + Apply Filters + </b-button> + + <b-button v-if="!addFilterShow" + icon-pack="fas" + icon-left="plus" + @click="addFilterInit()"> + Add Filter + </b-button> + + <b-autocomplete v-if="addFilterShow" + ref="addFilterAutocomplete" + :data="addFilterChoices" + v-model="addFilterTerm" + placeholder="Add Filter" + field="key" + :custom-formatter="formatAddFilterItem" + open-on-focus + keep-first + icon-pack="fas" + clearable + clear-on-select + @select="addFilterSelect"> + </b-autocomplete> + + <b-button @click="resetView()" + icon-pack="fas" + icon-left="home"> + Default View + </b-button> + + <b-button @click="clearFilters()" + icon-pack="fas" + icon-left="trash"> + No Filters + </b-button> + + % if allow_save_defaults and request.user: + <b-button @click="saveDefaults()" + icon-pack="fas" + icon-left="save" + :disabled="savingDefaults"> + {{ savingDefaults ? "Working, please wait..." : "Save Defaults" }} + </b-button> + % endif + + </div> + </form> + % endif + </div> + </div> + + <div style="display: flex; flex-direction: column; justify-content: space-between;"> + + <div class="context-menu"> + % if context_menu: + <ul id="context-menu"> + ## TODO: stop using |n filter + ${context_menu|n} + </ul> + % endif + </div> + + <div class="grid-tools-wrapper"> + % if tools: + <div class="grid-tools"> + ## TODO: stop using |n filter + ${tools|n} + </div> + % endif + </div> + + </div> + + </div> + + <${b}-table + :data="visibleData" + :loading="loading" + :row-class="getRowClass" + % if request.use_oruga: + tr-checked-class="is-checked" + % endif + + % if request.rattail_config.getbool('tailbone', 'sticky_headers'): + sticky-header + height="600px" + % endif + + :checkable="checkable" + + % if getattr(grid, 'checkboxes', False): + % if request.use_oruga: + v-model:checked-rows="checkedRows" + % else: + :checked-rows.sync="checkedRows" + % endif + % if grid.clicking_row_checks_box: + @click="rowClick" + % endif + % endif + + % if getattr(grid, 'check_handler', None): + @check="${grid.check_handler}" + % endif + % if getattr(grid, 'check_all_handler', None): + @check-all="${grid.check_all_handler}" + % endif + + % if hasattr(grid, 'checkable'): + % if isinstance(grid.checkable, str): + :is-row-checkable="${grid.row_checkable}" + % elif grid.checkable: + :is-row-checkable="row => row._checkable" + % endif + % endif + + ## sorting + % if grid.sortable: + ## nb. buefy/oruga only support *one* default sorter + :default-sort="sorters.length ? [sorters[0].field, sorters[0].order] : null" + % if grid.sort_on_backend: + backend-sorting + @sort="onSort" + % endif + % if grid.sort_multiple: + % if grid.sort_on_backend: + ## TODO: there is a bug (?) which prevents the arrow + ## from displaying for simple default single-column sort, + ## when multi-column sort is allowed for the table. for + ## now we work around that by waiting until mount to + ## enable the multi-column support. see also + ## https://github.com/buefy/buefy/issues/2584 + :sort-multiple="allowMultiSort" + :sort-multiple-data="sortingPriority" + @sorting-priority-removed="sortingPriorityRemoved" + % else: + sort-multiple + % endif + ## nb. user must ctrl-click column header for multi-sort + sort-multiple-key="ctrlKey" + % endif + % endif + + % if getattr(grid, 'click_handlers', None): + @cellclick="cellClick" + % endif + + ## paging + % if grid.paginated: + paginated + pagination-size="${'small' if request.use_oruga else 'is-small'}" + :per-page="perPage" + :current-page="currentPage" + @page-change="onPageChange" + % if grid.paginate_on_backend: + backend-pagination + :total="pagerStats.item_count" + % endif + % endif + + ## TODO: should let grid (or master view) decide how to set these? + icon-pack="fas" + ## note that :striped="true" was interfering with row status (e.g. warning) styles + :striped="false" + :hoverable="true" + :narrowed="true"> + + % for column in grid.get_vue_columns(): + <${b}-table-column field="${column['field']}" + label="${column['label']}" + v-slot="props" + :sortable="${json.dumps(column.get('sortable', False))|n}" + :searchable="${json.dumps(column.get('searchable', False))|n}" + cell-class="c_${column['field']}" + :visible="${json.dumps(column.get('visible', True))}"> + % if hasattr(grid, 'raw_renderers') and column['field'] in grid.raw_renderers: + ${grid.raw_renderers[column['field']]()} + % elif grid.is_linked(column['field']): + <a :href="props.row._action_url_view" + % if view_click_handler: + @click.prevent="${view_click_handler}" + % endif + v-html="props.row.${column['field']}"> + </a> + % else: + <span v-html="props.row.${column['field']}"></span> + % endif + </${b}-table-column> + % endfor + + % if grid.actions: + <${b}-table-column field="actions" + label="Actions" + v-slot="props"> + ## TODO: we do not currently differentiate for "main vs. more" + ## here, but ideally we would tuck "more" away in a drawer etc. + % for action in grid.actions: + <a v-if="props.row._action_url_${action.key}" + :href="props.row._action_url_${action.key}" + class="grid-action${' has-text-danger' if action.key == 'delete' else ''} ${action.link_class or ''}" + % if getattr(action, 'click_handler', None): + @click.prevent="${action.click_handler}" + % endif + % if getattr(action, 'target', None): + target="${action.target}" + % endif + > + ${action.render_icon_and_label()} + </a> + + % endfor + </${b}-table-column> + % endif + + <template #empty> + <section class="section"> + <div class="content has-text-grey has-text-centered"> + <p> + <b-icon + pack="fas" + icon="sad-tear" + size="is-large"> + </b-icon> + </p> + <p>Nothing here.</p> + </div> + </section> + </template> + + <template #footer> + <div style="display: flex; justify-content: space-between;"> + + % if getattr(grid, 'expose_direct_link', False): + <b-button type="is-primary" + size="is-small" + @click="copyDirectLink()" + title="Copy link to clipboard"> + % if request.use_oruga: + <o-icon icon="share-alt" /> + % else: + <span><i class="fa fa-share-alt"></i></span> + % endif + </b-button> + % else: + <div></div> + % endif + + % if grid.paginated: + <div v-if="pagerStats.first_item" + style="display: flex; gap: 0.5rem; align-items: center;"> + <span> + showing + {{ renderNumber(pagerStats.first_item) }} + - {{ renderNumber(pagerStats.last_item) }} + of {{ renderNumber(pagerStats.item_count) }} results; + </span> + <b-select v-model="perPage" + size="is-small" + @input="perPageUpdated"> + % for value in grid.get_pagesize_options(): + <option value="${value}">${value}</option> + % endfor + </b-select> + <span> + per page + </span> + </div> + % endif + + </div> + </template> + + </${b}-table> + + ## dummy input field needed for sharing links on *insecure* sites + % if getattr(request, 'scheme', None) == 'http': + <b-input v-model="shareLink" ref="shareLink" v-show="shareLink"></b-input> + % endif + + </div> +</script> + +<script type="text/javascript"> + + const ${grid.vue_component}Context = ${json.dumps(grid.get_vue_context())|n} + let ${grid.vue_component}CurrentData = ${grid.vue_component}Context.data + + let ${grid.vue_component}Data = { + loading: false, + ajaxDataUrl: ${json.dumps(getattr(grid, 'ajax_data_url', request.path_url))|n}, + + ## nb. this tracks whether grid.fetchFirstData() happened + fetchedFirstData: false, + + savingDefaults: false, + + data: ${grid.vue_component}CurrentData, + rowStatusMap: ${json.dumps(grid_data['row_status_map'] if grid_data is not Undefined else {})|n}, + + checkable: ${json.dumps(getattr(grid, 'checkboxes', False))|n}, + % if getattr(grid, 'checkboxes', False): + checkedRows: ${grid_data['checked_rows_code']|n}, + % endif + + ## paging + % if grid.paginated: + pageSizeOptions: ${json.dumps(grid.pagesize_options)|n}, + perPage: ${json.dumps(grid.pagesize)|n}, + currentPage: ${json.dumps(grid.page)|n}, + % if grid.paginate_on_backend: + pagerStats: ${json.dumps(grid.get_vue_pager_stats())|n}, + % endif + % endif + + ## sorting + % if grid.sortable: + sorters: ${json.dumps(grid.get_vue_active_sorters())|n}, + % if grid.sort_multiple: + % if grid.sort_on_backend: + ## TODO: there is a bug (?) which prevents the arrow + ## from displaying for simple default single-column sort, + ## when multi-column sort is allowed for the table. for + ## now we work around that by waiting until mount to + ## enable the multi-column support. see also + ## https://github.com/buefy/buefy/issues/2584 + allowMultiSort: false, + ## nb. this should be empty when current sort is single-column + % if len(grid.active_sorters) > 1: + sortingPriority: ${json.dumps(grid.get_vue_active_sorters())|n}, + % else: + sortingPriority: [], + % endif + % endif + % endif + % endif + + ## filterable: ${json.dumps(grid.filterable)|n}, + filters: ${json.dumps(filters_data if getattr(grid, 'filterable', False) else None)|n}, + filtersSequence: ${json.dumps(filters_sequence if getattr(grid, 'filterable', False) else None)|n}, + addFilterTerm: '', + addFilterShow: false, + + ## dummy input value needed for sharing links on *insecure* sites + % if getattr(request, 'scheme', None) == 'http': + shareLink: null, + % endif + } + + let ${grid.vue_component} = { + template: '#${grid.vue_tagname}-template', + + mixins: [FormPosterMixin], + + props: { + csrftoken: String, + }, + + computed: { + + ## TODO: this should be temporary? but anyway 'total' is + ## still referenced in other places, e.g. "delete results" + % if grid.paginated: + total() { return this.pagerStats.item_count }, + % endif + + % if not grid.paginate_on_backend: + + pagerStats() { + const data = this.visibleData + let last = this.currentPage * this.perPage + let first = last - this.perPage + 1 + if (last > data.length) { + last = data.length + } + return { + 'item_count': data.length, + 'items_per_page': this.perPage, + 'page': this.currentPage, + 'first_item': first, + 'last_item': last, + } + }, + + % endif + + addFilterChoices() { + // nb. this returns all choices available for "Add Filter" operation + + // collect all filters, which are *not* already shown + let choices = [] + for (let field of this.filtersSequence) { + let filtr = this.filters[field] + if (!filtr.visible) { + choices.push(filtr) + } + } + + // parse list of search terms + let terms = [] + for (let term of this.addFilterTerm.toLowerCase().split(' ')) { + term = term.trim() + if (term) { + terms.push(term) + } + } + + // only filters matching all search terms are presented + // as choices to the user + return choices.filter(option => { + let label = option.label.toLowerCase() + for (let term of terms) { + if (label.indexOf(term) < 0) { + return false + } + } + return true + }) + }, + + // note, can use this with v-model for hidden 'uuids' fields + selected_uuids: function() { + return this.checkedRowUUIDs().join(',') + }, + + // nb. this can be overridden if needed, e.g. to dynamically + // show/hide certain records in a static data set + visibleData() { + return this.data + }, + + directLink() { + let params = new URLSearchParams(this.getAllParams()) + return `${request.path_url}?${'$'}{params}` + }, + }, + + % if grid.sortable and grid.sort_multiple and grid.sort_on_backend: + + ## TODO: there is a bug (?) which prevents the arrow + ## from displaying for simple default single-column sort, + ## when multi-column sort is allowed for the table. for + ## now we work around that by waiting until mount to + ## enable the multi-column support. see also + ## https://github.com/buefy/buefy/issues/2584 + mounted() { + this.allowMultiSort = true + }, + + % endif + + methods: { + + renderNumber(value) { + if (value != undefined) { + return value.toLocaleString('en') + } + }, + + formatAddFilterItem(filtr) { + if (!filtr.key) { + filtr = this.filters[filtr] + } + return filtr.label || filtr.key + }, + + % if getattr(grid, 'click_handlers', None): + cellClick(row, column, rowIndex, columnIndex) { + % for key in grid.click_handlers: + if (column._props.field == '${key}') { + ${grid.click_handlers[key]}(row) + } + % endfor + }, + % endif + + copyDirectLink() { + + if (navigator.clipboard) { + // this is the way forward, but requires HTTPS + navigator.clipboard.writeText(this.directLink) + + } else { + // use deprecated 'copy' command, but this just + // tells the browser to copy currently-selected + // text..which means we first must "add" some text + // to screen, and auto-select that, before copying + // to clipboard + this.shareLink = this.directLink + this.$nextTick(() => { + let input = this.$refs.shareLink.$el.firstChild + input.select() + document.execCommand('copy') + // re-hide the dummy input + this.shareLink = null + }) + } + + this.$buefy.toast.open({ + message: "Link was copied to clipboard", + type: 'is-info', + duration: 2000, // 2 seconds + }) + }, + + addRowClass(index, className) { + + // TODO: this may add duplicated name to class string + // (not a serious problem i think, but could be improved) + this.rowStatusMap[index] = (this.rowStatusMap[index] || '') + + ' ' + className + + // nb. for some reason b-table does not always "notice" + // when we update status; so we force it to refresh + this.$forceUpdate() + }, + + getRowClass(row, index) { + return this.rowStatusMap[index] + }, + + getBasicParams() { + const params = { + % if grid.paginated and grid.paginate_on_backend: + pagesize: this.perPage, + page: this.currentPage, + % endif + } + % if grid.sortable and grid.sort_on_backend: + for (let i = 1; i <= this.sorters.length; i++) { + params['sort'+i+'key'] = this.sorters[i-1].field + params['sort'+i+'dir'] = this.sorters[i-1].order + } + % endif + return params + }, + + getFilterParams() { + let params = {} + for (var key in this.filters) { + var filter = this.filters[key] + if (filter.active) { + params[key] = filter.value + params[key+'.verb'] = filter.verb + } + } + if (Object.keys(params).length) { + params.filter = true + } + return params + }, + + getAllParams() { + return {...this.getBasicParams(), + ...this.getFilterParams()} + }, + + ## nb. this is meant to call for a grid which is hidden at + ## first, when it is first being shown to the user. and if + ## it was initialized with empty data set. + async fetchFirstData() { + if (this.fetchedFirstData) { + return + } + await this.loadAsyncData() + this.fetchedFirstData = true + }, + + ## TODO: i noticed buefy docs show using `async` keyword here, + ## so now i am too. knowing nothing at all of if/how this is + ## supposed to improve anything. we shall see i guess + async loadAsyncData(params, success, failure) { + + if (params === undefined || params === null) { + params = new URLSearchParams(this.getBasicParams()) + } else { + params = new URLSearchParams(params) + } + if (!params.has('partial')) { + params.append('partial', true) + } + params = params.toString() + + this.loading = true + this.$http.get(`${'$'}{this.ajaxDataUrl}?${'$'}{params}`).then(response => { + if (!response.data.error) { + ${grid.vue_component}CurrentData = response.data.data + this.data = ${grid.vue_component}CurrentData + % if grid.paginated and grid.paginate_on_backend: + this.pagerStats = response.data.pager_stats + % endif + this.rowStatusMap = response.data.row_status_map || {} + this.loading = false + this.savingDefaults = false + this.checkedRows = this.locateCheckedRows(response.data.checked_rows || []) + if (success) { + success() + } + } else { + this.$buefy.toast.open({ + message: response.data.error, + type: 'is-danger', + duration: 2000, // 4 seconds + }) + this.loading = false + this.savingDefaults = false + if (failure) { + failure() + } + } + }) + .catch((error) => { + ${grid.vue_component}CurrentData = [] + this.data = [] + % if grid.paginated and grid.paginate_on_backend: + this.pagerStats = {} + % endif + this.loading = false + this.savingDefaults = false + if (failure) { + failure() + } + throw error + }) + }, + + locateCheckedRows(checked) { + let rows = [] + if (checked) { + for (let i = 0; i < this.data.length; i++) { + if (checked.includes(i)) { + rows.push(this.data[i]) + } + } + } + return rows + }, + + onPageChange(page) { + this.currentPage = page + this.loadAsyncData() + }, + + perPageUpdated(value) { + + // nb. buefy passes value, oruga passes event + if (value.target) { + value = event.target.value + } + + this.loadAsyncData({ + pagesize: value, + }) + }, + + % if grid.sortable and grid.sort_on_backend: + + onSort(field, order, event) { + + ## nb. buefy passes field name; oruga passes field object + % if request.use_oruga: + field = field.field + % endif + + % if grid.sort_multiple: + + // did user ctrl-click the column header? + if (event.ctrlKey) { + + // toggle direction for existing, or add new sorter + const sorter = this.sorters.filter(s => s.field === field)[0] + if (sorter) { + sorter.order = sorter.order === 'desc' ? 'asc' : 'desc' + } else { + this.sorters.push({field, order}) + } + + // apply multi-column sorting + this.sortingPriority = this.sorters + + } else { + + % endif + + // sort by single column only + this.sorters = [{field, order}] + + % if grid.sort_multiple: + // multi-column sort not engaged + this.sortingPriority = [] + } + % endif + + // nb. always reset to first page when sorting changes + this.currentPage = 1 + this.loadAsyncData() + }, + + % if grid.sort_multiple: + + sortingPriorityRemoved(field) { + + // prune from active sorters + this.sorters = this.sorters.filter(s => s.field !== field) + + // nb. even though we might have just one sorter + // now, we are still technically in multi-sort mode + this.sortingPriority = this.sorters + + this.loadAsyncData() + }, + + % endif + + % endif + + resetView() { + this.loading = true + + // use current url proper, plus reset param + let url = '?reset-view=true' + + // add current hash, to preserve that in redirect + if (location.hash) { + url += '&hash=' + location.hash.slice(1) + } + + location.href = url + }, + + addFilterInit() { + this.addFilterShow = true + + this.$nextTick(() => { + const input = this.$refs.addFilterAutocomplete.$el.querySelector('input') + input.addEventListener('keydown', this.addFilterKeydown) + this.$refs.addFilterAutocomplete.focus() + }) + }, + + addFilterHide() { + const input = this.$refs.addFilterAutocomplete.$el.querySelector('input') + input.removeEventListener('keydown', this.addFilterKeydown) + this.addFilterTerm = '' + this.addFilterShow = false + }, + + addFilterKeydown(event) { + + // ESC will clear searchbox + if (event.which == 27) { + this.addFilterHide() + } + }, + + addFilterSelect(filtr) { + this.addFilter(filtr.key) + this.addFilterHide() + }, + + addFilter(filter_key) { + + // show corresponding grid filter + this.filters[filter_key].visible = true + this.filters[filter_key].active = true + + // track down the component + var gridFilter = null + for (var gf of this.$refs.gridFilters) { + if (gf.filter.key == filter_key) { + gridFilter = gf + break + } + } + + // tell component to focus the value field, ASAP + this.$nextTick(function() { + gridFilter.focusValue() + }) + + }, + + applyFilters(params) { + if (params === undefined) { + params = {} + } + + // merge in actual filter params + // cf. https://stackoverflow.com/a/171256 + params = {...params, ...this.getFilterParams()} + + // hide inactive filters + for (var key in this.filters) { + var filter = this.filters[key] + if (!filter.active) { + filter.visible = false + } + } + + // set some explicit params + params.partial = true + params.filter = true + + params = new URLSearchParams(params) + this.loadAsyncData(params) + this.appliedFiltersHook() + }, + + appliedFiltersHook() {}, + + clearFilters() { + + // explicitly deactivate all filters + for (var key in this.filters) { + this.filters[key].active = false + } + + // then just "apply" as normal + this.applyFilters() + }, + + // explicitly set filters for the grid, to the given set. + // this totally overrides whatever might be current. the + // new filter set should look like: + // + // [ + // {key: 'status_code', + // verb: 'equal', + // value: 1}, + // {key: 'description', + // verb: 'contains', + // value: 'whatever'}, + // ] + // + setFilters(newFilters) { + for (let key in this.filters) { + let filter = this.filters[key] + let active = false + for (let newFilter of newFilters) { + if (newFilter.key == key) { + active = true + filter.active = true + filter.visible = true + filter.verb = newFilter.verb + filter.value = newFilter.value + break + } + } + if (!active) { + filter.active = false + filter.visible = false + } + } + this.applyFilters() + }, + + saveDefaults() { + this.savingDefaults = true + + // apply current filters as normal, but add special directive + this.applyFilters({'save-current-filters-as-defaults': true}) + }, + + deleteObject(event) { + // we let parent component/app deal with this, in whatever way makes sense... + // TODO: should we ever provide anything besides the URL for this? + this.$emit('deleteActionClicked', event.target.href) + }, + + checkedRowUUIDs() { + let uuids = [] + for (let row of this.$data.checkedRows) { + uuids.push(row.uuid) + } + return uuids + }, + + allRowUUIDs() { + let uuids = [] + for (let row of this.data) { + uuids.push(row.uuid) + } + return uuids + }, + + // when a user clicks a row, handle as if they clicked checkbox. + // note that this method is only used if table is "checkable" + rowClick(row) { + let i = this.checkedRows.indexOf(row) + if (i >= 0) { + this.checkedRows.splice(i, 1) + } else { + this.checkedRows.push(row) + } + % if getattr(grid, 'check_handler', None): + this.${grid.check_handler}(this.checkedRows, row) + % endif + }, + } + } + +</script> diff --git a/tailbone/templates/grids/filter-components.mako b/tailbone/templates/grids/filter-components.mako new file mode 100644 index 00000000..e4915065 --- /dev/null +++ b/tailbone/templates/grids/filter-components.mako @@ -0,0 +1,350 @@ +## -*- coding: utf-8; -*- + +<%def name="make_grid_filter_components()"> + ${self.make_grid_filter_numeric_value_component()} + ${self.make_grid_filter_date_value_component()} + ${self.make_grid_filter_component()} +</%def> + +<%def name="make_grid_filter_numeric_value_component()"> + <% request.register_component('grid-filter-numeric-value', 'GridFilterNumericValue') %> + <script type="text/x-template" id="grid-filter-numeric-value-template"> + <div class="level"> + <div class="level-left"> + <div class="level-item"> + <b-input v-model="startValue" + ref="startValue" + @input="startValueChanged"> + </b-input> + </div> + <div v-show="wantsRange" + class="level-item"> + and + </div> + <div v-show="wantsRange" + class="level-item"> + <b-input v-model="endValue" + ref="endValue" + @input="endValueChanged"> + </b-input> + </div> + </div> + </div> + </script> + <script> + + const GridFilterNumericValue = { + template: '#grid-filter-numeric-value-template', + props: { + ${'modelValue' if request.use_oruga else 'value'}: String, + wantsRange: Boolean, + }, + data() { + const value = this.${'modelValue' if request.use_oruga else 'value'} + const {startValue, endValue} = this.parseValue(value) + return { + startValue, + endValue, + } + }, + watch: { + // when changing from e.g. 'equal' to 'between' filter verbs, + // must proclaim new filter value, to reflect (lack of) range + wantsRange(val) { + if (val) { + this.$emit('input', this.startValue + '|' + this.endValue) + } else { + this.$emit('input', this.startValue) + } + }, + + ${'modelValue' if request.use_oruga else 'value'}(to, from) { + const parsed = this.parseValue(to) + this.startValue = parsed.startValue + this.endValue = parsed.endValue + }, + }, + methods: { + focus() { + this.$refs.startValue.focus() + }, + startValueChanged(value) { + if (this.wantsRange) { + value += '|' + this.endValue + } + this.$emit("${'update:modelValue' if request.use_oruga else 'input'}", value) + }, + endValueChanged(value) { + value = this.startValue + '|' + value + this.$emit("${'update:modelValue' if request.use_oruga else 'input'}", value) + }, + + parseValue(value) { + let startValue = null + let endValue = null + if (this.wantsRange) { + if (value.includes('|')) { + let values = value.split('|') + if (values.length == 2) { + startValue = values[0] + endValue = values[1] + } else { + startValue = value + } + } else { + startValue = value + } + } else { + startValue = value + } + + return { + startValue, + endValue, + } + }, + }, + } + + Vue.component('grid-filter-numeric-value', GridFilterNumericValue) + + </script> +</%def> + +<%def name="make_grid_filter_date_value_component()"> + <% request.register_component('grid-filter-date-value', 'GridFilterDateValue') %> + <script type="text/x-template" id="grid-filter-date-value-template"> + <div class="level"> + <div class="level-left"> + <div class="level-item"> + <tailbone-datepicker v-model="startDate" + ref="startDate" + @${'update:model-value' if request.use_oruga else 'input'}="startDateChanged"> + </tailbone-datepicker> + </div> + <div v-show="dateRange" + class="level-item"> + and + </div> + <div v-show="dateRange" + class="level-item"> + <tailbone-datepicker v-model="endDate" + ref="endDate" + @${'update:model-value' if request.use_oruga else 'input'}="endDateChanged"> + </tailbone-datepicker> + </div> + </div> + </div> + </script> + <script> + + const GridFilterDateValue = { + template: '#grid-filter-date-value-template', + props: { + ${'modelValue' if request.use_oruga else 'value'}: String, + dateRange: Boolean, + }, + data() { + let startDate = null + let endDate = null + let value = this.${'modelValue' if request.use_oruga else 'value'} + if (value) { + + if (this.dateRange) { + let values = value.split('|') + if (values.length == 2) { + startDate = this.parseDate(values[0]) + endDate = this.parseDate(values[1]) + } else { // no end date specified? + startDate = this.parseDate(value) + } + + } else { // not a range, so start date only + startDate = this.parseDate(value) + } + } + + return { + startDate, + endDate, + } + }, + methods: { + focus() { + this.$refs.startDate.focus() + }, + formatDate(date) { + if (date === null) { + return null + } + if (typeof(date) == 'string') { + return date + } + // just need to convert to simple ISO date format here, seems + // like there should be a more obvious way to do that? + var year = date.getFullYear() + var month = date.getMonth() + 1 + var day = date.getDate() + month = month < 10 ? '0' + month : month + day = day < 10 ? '0' + day : day + return year + '-' + month + '-' + day + }, + parseDate(value) { + if (value) { + // note, this assumes classic YYYY-MM-DD (i.e. ISO?) format + const parts = value.split('-') + return new Date(parts[0], parseInt(parts[1]) - 1, parts[2]) + } + }, + startDateChanged(value) { + value = this.formatDate(value) + if (this.dateRange) { + value += '|' + this.formatDate(this.endDate) + } + this.$emit("${'update:modelValue' if request.use_oruga else 'input'}", value) + }, + endDateChanged(value) { + value = this.formatDate(this.startDate) + '|' + this.formatDate(value) + this.$emit("${'update:modelValue' if request.use_oruga else 'input'}", value) + }, + }, + } + + Vue.component('grid-filter-date-value', GridFilterDateValue) + + </script> +</%def> + +<%def name="make_grid_filter_component()"> + <% request.register_component('grid-filter', 'GridFilter') %> + <script type="text/x-template" id="grid-filter-template"> + <div class="filter" + v-show="filter.visible" + style="display: flex; gap: 0.5rem;"> + + <div class="filter-fieldname"> + <b-button @click="filter.active = !filter.active" + icon-pack="fas" + :icon-left="filter.active ? 'check' : null"> + {{ filter.label }} + </b-button> + </div> + + <div v-show="filter.active" + style="display: flex; gap: 0.5rem;"> + + <b-select v-model="filter.verb" + @input="focusValue()" + class="filter-verb"> + <option v-for="verb in filter.verbs" + :key="verb" + :value="verb"> + {{ filter.verb_labels[verb] }} + </option> + </b-select> + + ## only one of the following "value input" elements will be rendered + + <grid-filter-date-value v-if="filter.data_type == 'date'" + v-model="filter.value" + v-show="valuedVerb()" + :date-range="filter.verb == 'between'" + ref="valueInput"> + </grid-filter-date-value> + + <b-select v-if="filter.data_type == 'choice'" + v-model="filter.value" + v-show="valuedVerb()" + ref="valueInput"> + <option v-for="choice in filter.choices" + :key="choice" + :value="choice"> + {{ filter.choice_labels[choice] || choice }} + </option> + </b-select> + + <grid-filter-numeric-value v-if="filter.data_type == 'number'" + v-model="filter.value" + v-show="valuedVerb()" + :wants-range="filter.verb == 'between'" + ref="valueInput"> + </grid-filter-numeric-value> + + <b-input v-if="filter.data_type == 'string' && !multiValuedVerb()" + v-model="filter.value" + v-show="valuedVerb()" + ref="valueInput"> + </b-input> + + <b-input v-if="filter.data_type == 'string' && multiValuedVerb()" + type="textarea" + v-model="filter.value" + v-show="valuedVerb()" + ref="valueInput"> + </b-input> + + </div> + </div> + </script> + <script> + + const GridFilter = { + template: '#grid-filter-template', + props: { + filter: Object + }, + + methods: { + + changeVerb() { + // set focus to value input, "as quickly as we can" + this.$nextTick(function() { + this.focusValue() + }) + }, + + valuedVerb() { + /* this returns true if the filter's current verb should expose value input(s) */ + + // if filter has no "valueless" verbs, then all verbs should expose value inputs + if (!this.filter.valueless_verbs) { + return true + } + + // if filter *does* have valueless verbs, check if "current" verb is valueless + if (this.filter.valueless_verbs.includes(this.filter.verb)) { + return false + } + + // current verb is *not* valueless + return true + }, + + multiValuedVerb() { + /* this returns true if the filter's current verb should expose a multi-value input */ + + // if filter has no "multi-value" verbs then we safely assume false + if (!this.filter.multiple_value_verbs) { + return false + } + + // if filter *does* have multi-value verbs, see if "current" is one + if (this.filter.multiple_value_verbs.includes(this.filter.verb)) { + return true + } + + // current verb is not multi-value + return false + }, + + focusValue: function() { + this.$refs.valueInput.focus() + // this.$refs.valueInput.select() + } + } + } + + Vue.component('grid-filter', GridFilter) + + </script> +</%def> diff --git a/tailbone/templates/grids/filters.mako b/tailbone/templates/grids/filters.mako deleted file mode 100644 index 857f53b1..00000000 --- a/tailbone/templates/grids/filters.mako +++ /dev/null @@ -1,38 +0,0 @@ -## -*- coding: utf-8; -*- -<div class="newfilters"> - - ${h.form(form.action_url, method='get')} - ${h.hidden('reset-to-default-filters', value='false')} - ${h.hidden('save-current-filters-as-defaults', value='false')} - - <fieldset> - <legend>Filters</legend> - % for filtr in form.iter_filters(): - <div class="filter" id="filter-${filtr.key}" data-key="${filtr.key}"${' style="display: none;"' if not filtr.active else ''|n}> - ${h.checkbox('{}-active'.format(filtr.key), class_='active', id='filter-active-{}'.format(filtr.key), checked=filtr.active)} - <label for="filter-active-${filtr.key}">${filtr.label}</label> - <div class="inputs" style="display: inline-block;"> - ${form.filter_verb(filtr)} - ${form.filter_value(filtr)} - </div> - </div> - % endfor - </fieldset> - - <div class="buttons"> - <button type="submit" id="apply-filters">Apply Filters</button> - <select id="add-filter"> - <option value="">Add a Filter</option> - % for filtr in form.iter_filters(): - <option value="${filtr.key}"${' disabled="disabled"' if filtr.active else ''|n}>${filtr.label}</option> - % endfor - </select> - <button type="button" id="default-filters">Default View</button> - <button type="button" id="clear-filters">No Filters</button> - % if allow_save_defaults and request.user: - <button type="button" id="save-defaults">Save Defaults</button> - % endif - </div> - - ${h.end_form()} -</div><!-- newfilters --> diff --git a/tailbone/templates/grids/filters_buefy.mako b/tailbone/templates/grids/filters_buefy.mako deleted file mode 100644 index 5e1fef9b..00000000 --- a/tailbone/templates/grids/filters_buefy.mako +++ /dev/null @@ -1,70 +0,0 @@ -## -*- coding: utf-8; -*- - -<form action="${form.action_url}" method="GET" @submit.prevent="applyFilters()"> - - <grid-filter v-for="key in filtersSequence" - :key="key" - :filter="filters[key]" - ref="gridFilters"> - </grid-filter> - - <b-field grouped> - - <b-button type="is-primary" - native-type="submit" - icon-pack="fas" - icon-left="check" - class="control"> - Apply Filters - </b-button> - - <b-button v-if="!addFilterShow" - icon-pack="fas" - icon-left="plus" - class="control" - @click="addFilterButton"> - Add Filter - </b-button> - - <b-autocomplete v-if="addFilterShow" - ref="addFilterAutocomplete" - :data="addFilterChoices" - v-model="addFilterTerm" - placeholder="Add Filter" - field="key" - :custom-formatter="filtr => filtr.label" - open-on-focus - keep-first - icon-pack="fas" - clearable - clear-on-select - @select="addFilterSelect" - @keydown.native="addFilterKeydown"> - </b-autocomplete> - - <b-button @click="resetView()" - icon-pack="fas" - icon-left="home" - class="control"> - Default View - </b-button> - - <b-button @click="clearFilters()" - icon-pack="fas" - icon-left="trash" - class="control"> - No Filters - </b-button> - - % if allow_save_defaults and request.user: - <b-button @click="saveDefaults()" - icon-pack="fas" - icon-left="save" - class="control"> - Save Defaults - </b-button> - % endif - - </b-field> - -</form> diff --git a/tailbone/templates/grids/vue_template.mako b/tailbone/templates/grids/vue_template.mako new file mode 100644 index 00000000..625f046b --- /dev/null +++ b/tailbone/templates/grids/vue_template.mako @@ -0,0 +1,3 @@ +## -*- coding: utf-8; -*- +<%inherit file="/grids/complete.mako" /> +${parent.body()} diff --git a/tailbone/templates/home.mako b/tailbone/templates/home.mako index e4f7d072..54e44d57 100644 --- a/tailbone/templates/home.mako +++ b/tailbone/templates/home.mako @@ -1,33 +1,7 @@ ## -*- coding: utf-8; -*- -<%inherit file="/page.mako" /> -<%namespace name="base_meta" file="/base_meta.mako" /> - -<%def name="title()">Home</%def> - -<%def name="extra_styles()"> - ${parent.extra_styles()} - <style type="text/css"> - .logo { - text-align: center; - } - .logo img { - margin: 3em auto; - max-height: 350px; - max-width: 800px; - } - </style> -</%def> +<%inherit file="wuttaweb:templates/home.mako" /> +## DEPRECATED; remains for back-compat <%def name="render_this_page()"> ${self.page_content()} </%def> - -<%def name="page_content()"> - <div class="logo"> - ${h.image(image_url, "{} logo".format(capture(base_meta.app_title)))} - <h1>Welcome to ${base_meta.app_title()}</h1> - </div> -</%def> - - -${parent.body()} diff --git a/tailbone/templates/importing/configure.mako b/tailbone/templates/importing/configure.mako index 90f7cabd..2445341d 100644 --- a/tailbone/templates/importing/configure.mako +++ b/tailbone/templates/importing/configure.mako @@ -6,61 +6,65 @@ <h3 class="is-size-3">Designated Handlers</h3> - <b-table :data="handlersData" + <${b}-table :data="handlersData" narrowed icon-pack="fas" :default-sort="['host_title', 'asc']"> - <b-table-column field="host_title" + <${b}-table-column field="host_title" label="Data Source" v-slot="props" sortable> {{ props.row.host_title }} - </b-table-column> - <b-table-column field="local_title" + </${b}-table-column> + <${b}-table-column field="local_title" label="Data Target" v-slot="props" sortable> {{ props.row.local_title }} - </b-table-column> - <b-table-column field="direction" + </${b}-table-column> + <${b}-table-column field="direction" label="Direction" v-slot="props" sortable> {{ props.row.direction_display }} - </b-table-column> - <b-table-column field="handler_spec" + </${b}-table-column> + <${b}-table-column field="handler_spec" label="Handler Spec" v-slot="props" sortable> {{ props.row.handler_spec }} - </b-table-column> - <b-table-column field="cmd" + </${b}-table-column> + <${b}-table-column field="cmd" label="Command" v-slot="props" sortable> {{ props.row.command }} {{ props.row.subcommand }} - </b-table-column> - <b-table-column field="runas" + </${b}-table-column> + <${b}-table-column field="runas" label="Default Runas" v-slot="props" sortable> {{ props.row.default_runas }} - </b-table-column> - <b-table-column label="Actions" + </${b}-table-column> + <${b}-table-column label="Actions" v-slot="props"> <a href="#" class="grid-action" @click.prevent="editHandler(props.row)"> + % if request.use_oruga: + <o-icon icon="edit" /> + % else: <i class="fas fa-edit"></i> + % endif Edit </a> - </b-table-column> - <template slot="empty"> + </${b}-table-column> + <template #empty> <section class="section"> <div class="content has-text-grey has-text-centered"> <p> <b-icon pack="fas" - icon="fas fa-sad-tear" + icon="sad-tear" size="is-large"> </b-icon> </p> @@ -68,7 +72,7 @@ </div> </section> </template> - </b-table> + </${b}-table> <b-modal :active.sync="editHandlerShowDialog"> <div class="card"> @@ -140,9 +144,9 @@ </b-modal> </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> ThisPageData.handlersData = ${json.dumps(handlers_data)|n} @@ -199,6 +203,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/importing/runjob.mako b/tailbone/templates/importing/runjob.mako index 2bc2a4e9..a9625bc3 100644 --- a/tailbone/templates/importing/runjob.mako +++ b/tailbone/templates/importing/runjob.mako @@ -63,28 +63,26 @@ </div> </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> - ${form.component_studly}Data.submittingRun = false - ${form.component_studly}Data.submittingExplain = false - ${form.component_studly}Data.runJob = false + ${form.vue_component}Data.submittingRun = false + ${form.vue_component}Data.submittingExplain = false + ${form.vue_component}Data.runJob = false - ${form.component_studly}.methods.submitRun = function() { + ${form.vue_component}.methods.submitRun = function() { this.submittingRun = true this.runJob = true this.$nextTick(() => { - this.$refs.${form.component_studly}.submit() + this.$refs.${form.vue_component}.submit() }) } - ${form.component_studly}.methods.submitExplain = function() { + ${form.vue_component}.methods.submitExplain = function() { this.submittingExplain = true - this.$refs.${form.component_studly}.submit() + this.$refs.${form.vue_component}.submit() } </script> </%def> - -${parent.body()} diff --git a/tailbone/templates/login.mako b/tailbone/templates/login.mako index 6e6e347f..d2ea7828 100644 --- a/tailbone/templates/login.mako +++ b/tailbone/templates/login.mako @@ -1,78 +1,17 @@ ## -*- coding: utf-8; -*- -<%inherit file="/form.mako" /> -<%namespace name="base_meta" file="/base_meta.mako" /> - -<%def name="title()">Login</%def> +<%inherit file="wuttaweb:templates/auth/login.mako" /> +## TODO: this will not be needed with wuttaform <%def name="extra_styles()"> ${parent.extra_styles()} - <style type="text/css"> - .logo img { - display: block; - margin: 3rem auto; - max-height: 350px; - max-width: 800px; - } - - /* must force a particular label with, in order to make sure */ - /* the username and password inputs are the same size */ - .field.is-horizontal .field-label .label { - text-align: left; - width: 6rem; - } - - .buttons { + <style> + .card-content .buttons { justify-content: right; } </style> </%def> -<%def name="logo()"> - ${h.image(image_url, "{} logo".format(capture(base_meta.app_title)))} -</%def> - -<%def name="login_form()"> - <div class="form"> - ${form.render_deform(form_kwargs={'data-ajax': 'false'})|n} - </div> -</%def> - +## DEPRECATED; remains for back-compat <%def name="render_this_page()"> ${self.page_content()} </%def> - -<%def name="page_content()"> - <div class="logo"> - ${self.logo()} - </div> - - <div class="columns is-centered"> - <div class="column is-narrow"> - <div class="card"> - <div class="card-content"> - <tailbone-form></tailbone-form> - </div> - </div> - </div> - </div> -</%def> - -<%def name="modify_this_page_vars()"> - <script type="text/javascript"> - - TailboneForm.mounted = function() { - this.$refs.username.focus() - } - - TailboneForm.methods.usernameKeydown = function(event) { - if (event.which == 13) { - event.preventDefault() - this.$refs.password.focus() - } - } - - </script> -</%def> - - -${parent.body()} diff --git a/tailbone/templates/luigi/configure.mako b/tailbone/templates/luigi/configure.mako index c35e3216..de364828 100644 --- a/tailbone/templates/luigi/configure.mako +++ b/tailbone/templates/luigi/configure.mako @@ -22,48 +22,56 @@ </div> <div class="block" style="padding-left: 2rem; display: flex;"> - <b-table :data="overnightTasks"> - <!-- <b-table-column field="key" --> + <${b}-table :data="overnightTasks"> + <!-- <${b}-table-column field="key" --> <!-- label="Key" --> <!-- sortable> --> <!-- {{ props.row.key }} --> - <!-- </b-table-column> --> - <b-table-column field="key" + <!-- </${b}-table-column> --> + <${b}-table-column field="key" label="Key" v-slot="props"> {{ props.row.key }} - </b-table-column> - <b-table-column field="description" + </${b}-table-column> + <${b}-table-column field="description" label="Description" v-slot="props"> {{ props.row.description }} - </b-table-column> - <b-table-column field="class_name" + </${b}-table-column> + <${b}-table-column field="class_name" label="Class Name" v-slot="props"> {{ props.row.class_name }} - </b-table-column> - <b-table-column field="script" + </${b}-table-column> + <${b}-table-column field="script" label="Script" v-slot="props"> {{ props.row.script }} - </b-table-column> - <b-table-column label="Actions" + </${b}-table-column> + <${b}-table-column label="Actions" v-slot="props"> <a href="#" @click.prevent="overnightTaskEdit(props.row)"> - <i class="fas fa-edit"></i> + % if request.use_oruga: + <o-icon icon="edit" /> + % else: + <i class="fas fa-edit"></i> + % endif Edit </a> <a href="#" class="has-text-danger" @click.prevent="overnightTaskDelete(props.row)"> - <i class="fas fa-trash"></i> + % if request.use_oruga: + <o-icon icon="trash" /> + % else: + <i class="fas fa-trash"></i> + % endif Delete </a> - </b-table-column> - </b-table> + </${b}-table-column> + </${b}-table> <b-modal has-modal-card :active.sync="overnightTaskShowDialog"> @@ -77,31 +85,31 @@ <b-field label="Key" :type="overnightTaskKey ? null : 'is-danger'"> <b-input v-model.trim="overnightTaskKey" - ref="overnightTaskKey"> - </b-input> + ref="overnightTaskKey" + expanded /> </b-field> <b-field label="Description" :type="overnightTaskDescription ? null : 'is-danger'"> <b-input v-model.trim="overnightTaskDescription" - ref="overnightTaskDescription"> - </b-input> + ref="overnightTaskDescription" + expanded /> </b-field> <b-field label="Module"> - <b-input v-model.trim="overnightTaskModule"> - </b-input> + <b-input v-model.trim="overnightTaskModule" + expanded /> </b-field> <b-field label="Class Name"> - <b-input v-model.trim="overnightTaskClass"> - </b-input> + <b-input v-model.trim="overnightTaskClass" + expanded /> </b-field> <b-field label="Script"> - <b-input v-model.trim="overnightTaskScript"> - </b-input> + <b-input v-model.trim="overnightTaskScript" + expanded /> </b-field> <b-field label="Notes"> <b-input v-model.trim="overnightTaskNotes" - type="textarea"> - </b-input> + type="textarea" + expanded /> </b-field> </section> @@ -139,48 +147,56 @@ </div> <div class="block" style="padding-left: 2rem; display: flex;"> - <b-table :data="backfillTasks"> - <b-table-column field="key" + <${b}-table :data="backfillTasks"> + <${b}-table-column field="key" label="Key" v-slot="props"> {{ props.row.key }} - </b-table-column> - <b-table-column field="description" + </${b}-table-column> + <${b}-table-column field="description" label="Description" v-slot="props"> {{ props.row.description }} - </b-table-column> - <b-table-column field="script" + </${b}-table-column> + <${b}-table-column field="script" label="Script" v-slot="props"> {{ props.row.script }} - </b-table-column> - <b-table-column field="forward" + </${b}-table-column> + <${b}-table-column field="forward" label="Orientation" v-slot="props"> {{ props.row.forward ? "Forward" : "Backward" }} - </b-table-column> - <b-table-column field="target_date" + </${b}-table-column> + <${b}-table-column field="target_date" label="Target Date" v-slot="props"> {{ props.row.target_date }} - </b-table-column> - <b-table-column label="Actions" + </${b}-table-column> + <${b}-table-column label="Actions" v-slot="props"> <a href="#" @click.prevent="backfillTaskEdit(props.row)"> - <i class="fas fa-edit"></i> + % if request.use_oruga: + <o-icon icon="edit" /> + % else: + <i class="fas fa-edit"></i> + % endif Edit </a> <a href="#" class="has-text-danger" @click.prevent="backfillTaskDelete(props.row)"> - <i class="fas fa-trash"></i> + % if request.use_oruga: + <o-icon icon="trash" /> + % else: + <i class="fas fa-trash"></i> + % endif Delete </a> - </b-table-column> - </b-table> + </${b}-table-column> + </${b}-table> <b-modal has-modal-card :active.sync="backfillTaskShowDialog"> @@ -194,19 +210,19 @@ <b-field label="Key" :type="backfillTaskKey ? null : 'is-danger'"> <b-input v-model.trim="backfillTaskKey" - ref="backfillTaskKey"> - </b-input> + ref="backfillTaskKey" + expanded /> </b-field> <b-field label="Description" :type="backfillTaskDescription ? null : 'is-danger'"> <b-input v-model.trim="backfillTaskDescription" - ref="backfillTaskDescription"> - </b-input> + ref="backfillTaskDescription" + expanded /> </b-field> <b-field label="Script" :type="backfillTaskScript ? null : 'is-danger'"> - <b-input v-model.trim="backfillTaskScript"> - </b-input> + <b-input v-model.trim="backfillTaskScript" + expanded /> </b-field> <b-field grouped> <b-field label="Orientation"> @@ -222,8 +238,8 @@ </b-field> <b-field label="Notes"> <b-input v-model.trim="backfillTaskNotes" - type="textarea"> - </b-input> + type="textarea" + expanded /> </b-field> </section> @@ -252,7 +268,8 @@ expanded> <b-input name="rattail.luigi.url" v-model="simpleSettings['rattail.luigi.url']" - @input="settingsNeedSaved = true"> + @input="settingsNeedSaved = true" + expanded> </b-input> </b-field> @@ -261,7 +278,8 @@ expanded> <b-input name="rattail.luigi.scheduler.supervisor_process_name" v-model="simpleSettings['rattail.luigi.scheduler.supervisor_process_name']" - @input="settingsNeedSaved = true"> + @input="settingsNeedSaved = true" + expanded> </b-input> </b-field> @@ -270,7 +288,8 @@ expanded> <b-input name="rattail.luigi.scheduler.restart_command" v-model="simpleSettings['rattail.luigi.scheduler.restart_command']" - @input="settingsNeedSaved = true"> + @input="settingsNeedSaved = true" + expanded> </b-input> </b-field> @@ -278,9 +297,9 @@ </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> ThisPageData.overnightTasks = ${json.dumps(overnight_tasks)|n} ThisPageData.overnightTaskShowDialog = false @@ -406,6 +425,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/luigi/index.mako b/tailbone/templates/luigi/index.mako index a64866df..0dd72d01 100644 --- a/tailbone/templates/luigi/index.mako +++ b/tailbone/templates/luigi/index.mako @@ -53,25 +53,25 @@ <h3 class="block is-size-3">Overnight Tasks</h3> - <b-table :data="overnightTasks" hoverable> - <b-table-column field="description" + <${b}-table :data="overnightTasks" hoverable> + <${b}-table-column field="description" label="Description" v-slot="props"> {{ props.row.description }} - </b-table-column> - <b-table-column field="script" + </${b}-table-column> + <${b}-table-column field="script" label="Command" v-slot="props"> {{ props.row.script || props.row.class_name }} - </b-table-column> - <b-table-column field="last_date" + </${b}-table-column> + <${b}-table-column field="last_date" label="Last Date" v-slot="props"> <span :class="overnightTextClass(props.row)"> {{ props.row.last_date || "never!" }} </span> - </b-table-column> - <b-table-column label="Actions" + </${b}-table-column> + <${b}-table-column label="Actions" v-slot="props"> <b-button type="is-primary" icon-pack="fas" @@ -79,8 +79,13 @@ @click="overnightTaskLaunchInit(props.row)"> Launch </b-button> - <b-modal has-modal-card - :active.sync="overnightTaskShowLaunchDialog"> + <${b}-modal has-modal-card + % if request.use_oruga: + v-model:active="overnightTaskShowLaunchDialog" + % else: + :active.sync="overnightTaskShowLaunchDialog" + % endif + > <div class="modal-card"> <header class="modal-card-head"> @@ -127,12 +132,12 @@ </b-button> </footer> </div> - </b-modal> - </b-table-column> + </${b}-modal> + </${b}-table-column> <template #empty> <p class="block">No tasks defined.</p> </template> - </b-table> + </${b}-table> % endif @@ -140,35 +145,35 @@ <h3 class="block is-size-3">Backfill Tasks</h3> - <b-table :data="backfillTasks" hoverable> - <b-table-column field="description" + <${b}-table :data="backfillTasks" hoverable> + <${b}-table-column field="description" label="Description" v-slot="props"> {{ props.row.description }} - </b-table-column> - <b-table-column field="script" + </${b}-table-column> + <${b}-table-column field="script" label="Script" v-slot="props"> {{ props.row.script }} - </b-table-column> - <b-table-column field="forward" + </${b}-table-column> + <${b}-table-column field="forward" label="Orientation" v-slot="props"> {{ props.row.forward ? "Forward" : "Backward" }} - </b-table-column> - <b-table-column field="last_date" + </${b}-table-column> + <${b}-table-column field="last_date" label="Last Date" v-slot="props"> <span :class="backfillTextClass(props.row)"> {{ props.row.last_date }} </span> - </b-table-column> - <b-table-column field="target_date" + </${b}-table-column> + <${b}-table-column field="target_date" label="Target Date" v-slot="props"> {{ props.row.target_date }} - </b-table-column> - <b-table-column label="Actions" + </${b}-table-column> + <${b}-table-column label="Actions" v-slot="props"> <b-button type="is-primary" icon-pack="fas" @@ -176,14 +181,19 @@ @click="backfillTaskLaunch(props.row)"> Launch </b-button> - </b-table-column> + </${b}-table-column> <template #empty> <p class="block">No tasks defined.</p> </template> - </b-table> + </${b}-table> - <b-modal has-modal-card - :active.sync="backfillTaskShowLaunchDialog"> + <${b}-modal has-modal-card + % if request.use_oruga: + v-model:active="backfillTaskShowLaunchDialog" + % else: + :active.sync="backfillTaskShowLaunchDialog" + % endif + > <div class="modal-card"> <header class="modal-card-head"> @@ -238,16 +248,16 @@ </b-button> </footer> </div> - </b-modal> + </${b}-modal> % endif </div> </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> % if master.has_perm('restart_scheduler'): @@ -364,6 +374,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/master/clone.mako b/tailbone/templates/master/clone.mako index 07784f74..4c7e4662 100644 --- a/tailbone/templates/master/clone.mako +++ b/tailbone/templates/master/clone.mako @@ -3,12 +3,12 @@ <%def name="title()">Clone ${model_title}: ${instance_title}</%def> -<%def name="render_buefy_form()"> +<%def name="render_form()"> <br /> <b-notification :closable="false"> You are about to clone the following ${model_title} as a new record: </b-notification> - ${parent.render_buefy_form()} + ${parent.render_form()} </%def> <%def name="render_form_buttons()"> @@ -34,9 +34,9 @@ ${h.end_form()} </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> TailboneFormData.formSubmitting = false TailboneFormData.submitButtonText = "Yes, please clone away" @@ -48,6 +48,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/master/create.mako b/tailbone/templates/master/create.mako index 27cd404c..d7dcbbd8 100644 --- a/tailbone/templates/master/create.mako +++ b/tailbone/templates/master/create.mako @@ -1,6 +1,6 @@ ## -*- coding: utf-8; -*- <%inherit file="/form.mako" /> -<%def name="title()">New ${model_title_plural if master.creates_multiple else model_title}</%def> +<%def name="title()">New ${model_title_plural if getattr(master, 'creates_multiple', False) else model_title}</%def> ${parent.body()} diff --git a/tailbone/templates/master/delete.mako b/tailbone/templates/master/delete.mako index 0cb5b6c2..d2f517d9 100644 --- a/tailbone/templates/master/delete.mako +++ b/tailbone/templates/master/delete.mako @@ -3,12 +3,12 @@ <%def name="title()">Delete ${model_title}: ${instance_title}</%def> -<%def name="render_buefy_form()"> +<%def name="render_form()"> <br /> <b-notification type="is-danger" :closable="false"> You are about to delete the following ${model_title} and all associated data: </b-notification> - ${parent.render_buefy_form()} + ${parent.render_form()} </%def> <%def name="render_form_buttons()"> @@ -27,26 +27,21 @@ <b-button type="is-primary is-danger" native-type="submit" :disabled="formSubmitting"> - {{ formButtonText }} + {{ formSubmitting ? "Working, please wait..." : "${form.button_label_submit}" }} </b-button> </div> ${h.end_form()} </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> - TailboneFormData.formSubmitting = false - TailboneFormData.formButtonText = "Yes, please DELETE this data forever!" + ${form.vue_component}Data.formSubmitting = false - TailboneForm.methods.submitForm = function() { + ${form.vue_component}.methods.submitForm = function() { this.formSubmitting = true - this.formButtonText = "Working, please wait..." } </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/master/form.mako b/tailbone/templates/master/form.mako index c142d8ef..17063c21 100644 --- a/tailbone/templates/master/form.mako +++ b/tailbone/templates/master/form.mako @@ -1,10 +1,18 @@ ## -*- coding: utf-8; -*- <%inherit file="/form.mako" /> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - % if master.deletable and instance_deletable and request.has_perm('{}.delete'.format(permission_prefix)) and master.delete_confirm == 'simple': - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> + + ## declare extra data needed by form + % if form is not Undefined and getattr(form, 'json_data', None): + % for key, value in form.json_data.items(): + ${form.vue_component}Data.${key} = ${json.dumps(value)|n} + % endfor + % endif + + % if master.deletable and instance_deletable and master.has_perm('delete') and getattr(master, 'delete_confirm', 'full') == 'simple': ThisPage.methods.deleteObject = function() { if (confirm("Are you sure you wish to delete this ${model_title}?")) { @@ -12,9 +20,11 @@ } } - </script> + % endif + </script> + + % if form is not Undefined and hasattr(form, 'render_included_templates'): + ${form.render_included_templates()} % endif + </%def> - - -${parent.body()} diff --git a/tailbone/templates/master/index.mako b/tailbone/templates/master/index.mako index b0ee17d6..a2d26c60 100644 --- a/tailbone/templates/master/index.mako +++ b/tailbone/templates/master/index.mako @@ -12,187 +12,178 @@ <%def name="content_title()"></%def> -<%def name="context_menu_items()"> - % if master.results_downloadable_csv and request.has_perm('{}.results_csv'.format(permission_prefix)): - <li>${h.link_to("Download results as CSV", url('{}.results_csv'.format(route_prefix)))}</li> - % endif - % if master.results_downloadable_xlsx and request.has_perm('{}.results_xlsx'.format(permission_prefix)): - <li>${h.link_to("Download results as XLSX", url('{}.results_xlsx'.format(route_prefix)))}</li> - % endif - % if master.has_input_file_templates and master.has_perm('create'): - % for template in six.itervalues(input_file_templates): - <li>${h.link_to("Download {} Template".format(template['label']), template['effective_url'])}</li> - % endfor - % endif -</%def> - <%def name="grid_tools()"> ## grid totals - % if master.supports_grid_totals: - <b-button v-if="gridTotalsDisplay == null" - :disabled="gridTotalsFetching" - @click="gridTotalsFetch()"> - {{ gridTotalsFetching ? "Working, please wait..." : "Show Totals" }} - </b-button> - <div v-if="gridTotalsDisplay != null" - class="control"> - Totals: {{ gridTotalsDisplay }} + % if getattr(master, 'supports_grid_totals', False): + <div style="display: flex; align-items: center;"> + <b-button v-if="gridTotalsDisplay == null" + :disabled="gridTotalsFetching" + @click="gridTotalsFetch()"> + {{ gridTotalsFetching ? "Working, please wait..." : "Show Totals" }} + </b-button> + <div v-if="gridTotalsDisplay != null" + class="control"> + Totals: {{ gridTotalsDisplay }} + </div> </div> % endif ## download search results - % if master.results_downloadable and master.has_perm('download_results'): - <b-button type="is-primary" - icon-pack="fas" - icon-left="fas fa-download" - @click="showDownloadResultsDialog = true" - :disabled="!total"> - Download Results - </b-button> + % if getattr(master, 'results_downloadable', False) and master.has_perm('download_results'): + <div> + <b-button type="is-primary" + icon-pack="fas" + icon-left="download" + @click="showDownloadResultsDialog = true" + :disabled="!total"> + Download Results + </b-button> - ${h.form(url('{}.download_results'.format(route_prefix)), ref='download_results_form')} - ${h.csrf_token(request)} - <input type="hidden" name="fmt" :value="downloadResultsFormat" /> - <input type="hidden" name="fields" :value="downloadResultsFieldsIncluded" /> - ${h.end_form()} + ${h.form(url('{}.download_results'.format(route_prefix)), ref='download_results_form')} + ${h.csrf_token(request)} + <input type="hidden" name="fmt" :value="downloadResultsFormat" /> + <input type="hidden" name="fields" :value="downloadResultsFieldsIncluded" /> + ${h.end_form()} - <b-modal :active.sync="showDownloadResultsDialog"> - <div class="card"> + <b-modal :active.sync="showDownloadResultsDialog"> + <div class="card"> - <div class="card-content"> - <p> - There are - <span class="is-size-4 has-text-weight-bold"> - {{ total.toLocaleString('en') }} ${model_title_plural} - </span> - matching your current filters. - </p> - <p> - You may download this set as a single data file if you like. - </p> - <br /> + <div class="card-content"> + <p> + There are + <span class="is-size-4 has-text-weight-bold"> + {{ total.toLocaleString('en') }} ${model_title_plural} + </span> + matching your current filters. + </p> + <p> + You may download this set as a single data file if you like. + </p> + <br /> - <b-notification type="is-warning" :closable="false" - v-if="downloadResultsFormat == 'xlsx' && total >= 1000"> - Excel downloads for large data sets can take a long time to - generate, and bog down the server in the meantime. You are - encouraged to choose CSV for a large data set, even though - the end result (file size) may be larger with CSV. - </b-notification> + <b-notification type="is-warning" :closable="false" + v-if="downloadResultsFormat == 'xlsx' && total >= 1000"> + Excel downloads for large data sets can take a long time to + generate, and bog down the server in the meantime. You are + encouraged to choose CSV for a large data set, even though + the end result (file size) may be larger with CSV. + </b-notification> - <div style="display: flex; justify-content: space-between"> + <div style="display: flex; justify-content: space-between"> - <div> - <b-field horizontal label="Format"> - <b-select v-model="downloadResultsFormat"> - % for key, label in six.iteritems(master.download_results_supported_formats()): - <option value="${key}">${label}</option> - % endfor - </b-select> - </b-field> - </div> - - <div> - - <div v-show="downloadResultsFieldsMode != 'choose'" - class="has-text-right"> - <p v-if="downloadResultsFieldsMode == 'default'"> - Will use DEFAULT fields. - </p> - <p v-if="downloadResultsFieldsMode == 'all'"> - Will use ALL fields. - </p> - <br /> + <div> + <b-field label="Format"> + <b-select v-model="downloadResultsFormat"> + % for key, label in master.download_results_supported_formats().items(): + <option value="${key}">${label}</option> + % endfor + </b-select> + </b-field> </div> - <div class="buttons is-right"> - <b-button type="is-primary" - v-show="downloadResultsFieldsMode != 'default'" - @click="downloadResultsUseDefaultFields()"> - Use Default Fields - </b-button> - <b-button type="is-primary" - v-show="downloadResultsFieldsMode != 'all'" - @click="downloadResultsUseAllFields()"> - Use All Fields - </b-button> - <b-button type="is-primary" - v-show="downloadResultsFieldsMode != 'choose'" - @click="downloadResultsFieldsMode = 'choose'"> - Choose Fields - </b-button> - </div> + <div> - <div v-show="downloadResultsFieldsMode == 'choose'"> - <div style="display: flex;"> - <div> - <b-field label="Excluded Fields"> - <b-select multiple native-size="8" - expanded - ref="downloadResultsExcludedFields"> - <option v-for="field in downloadResultsFieldsAvailable" - v-if="!downloadResultsFieldsIncluded.includes(field)" - :key="field" - :value="field"> - {{ field }} - </option> - </b-select> - </b-field> - </div> - <div> - <br /><br /> - <b-button style="margin: 0.5rem;" - @click="downloadResultsExcludeFields()"> - < - </b-button> - <br /> - <b-button style="margin: 0.5rem;" - @click="downloadResultsIncludeFields()"> - > - </b-button> - </div> - <div> - <b-field label="Included Fields"> - <b-select multiple native-size="8" - expanded - ref="downloadResultsIncludedFields"> - <option v-for="field in downloadResultsFieldsIncluded" - :key="field" - :value="field"> - {{ field }} - </option> - </b-select> - </b-field> + <div v-show="downloadResultsFieldsMode != 'choose'" + class="has-text-right"> + <p v-if="downloadResultsFieldsMode == 'default'"> + Will use DEFAULT fields. + </p> + <p v-if="downloadResultsFieldsMode == 'all'"> + Will use ALL fields. + </p> + <br /> + </div> + + <div class="buttons is-right"> + <b-button type="is-primary" + v-show="downloadResultsFieldsMode != 'default'" + @click="downloadResultsUseDefaultFields()"> + Use Default Fields + </b-button> + <b-button type="is-primary" + v-show="downloadResultsFieldsMode != 'all'" + @click="downloadResultsUseAllFields()"> + Use All Fields + </b-button> + <b-button type="is-primary" + v-show="downloadResultsFieldsMode != 'choose'" + @click="downloadResultsFieldsMode = 'choose'"> + Choose Fields + </b-button> + </div> + + <div v-show="downloadResultsFieldsMode == 'choose'"> + <div style="display: flex;"> + <div> + <b-field label="Excluded Fields"> + <b-select multiple native-size="8" + expanded + v-model="downloadResultsExcludedFieldsSelected" + ref="downloadResultsExcludedFields"> + <option v-for="field in downloadResultsFieldsExcluded" + :key="field" + :value="field"> + {{ field }} + </option> + </b-select> + </b-field> + </div> + <div> + <br /><br /> + <b-button style="margin: 0.5rem;" + @click="downloadResultsExcludeFields()"> + < + </b-button> + <br /> + <b-button style="margin: 0.5rem;" + @click="downloadResultsIncludeFields()"> + > + </b-button> + </div> + <div> + <b-field label="Included Fields"> + <b-select multiple native-size="8" + expanded + v-model="downloadResultsIncludedFieldsSelected" + ref="downloadResultsIncludedFields"> + <option v-for="field in downloadResultsFieldsIncluded" + :key="field" + :value="field"> + {{ field }} + </option> + </b-select> + </b-field> + </div> </div> </div> + </div> - </div> - </div> - </div> <!-- card-content --> + </div> <!-- card-content --> - <footer class="modal-card-foot"> - <b-button @click="showDownloadResultsDialog = false"> - Cancel - </b-button> - <once-button type="is-primary" - @click="downloadResultsSubmit()" - icon-pack="fas" - icon-left="fas fa-download" - :disabled="!downloadResultsFieldsIncluded.length" - text="Download Results"> - </once-button> - </footer> - </div> - </b-modal> + <footer class="modal-card-foot"> + <b-button @click="showDownloadResultsDialog = false"> + Cancel + </b-button> + <once-button type="is-primary" + @click="downloadResultsSubmit()" + icon-pack="fas" + icon-left="download" + :disabled="!downloadResultsFieldsIncluded.length" + text="Download Results"> + </once-button> + </footer> + </div> + </b-modal> + </div> % endif ## download rows for search results - % if master.has_rows and master.results_rows_downloadable and master.has_perm('download_results_rows'): + % if getattr(master, 'has_rows', False) and master.results_rows_downloadable and master.has_perm('download_results_rows'): <b-button type="is-primary" icon-pack="fas" - icon-left="fas fa-download" + icon-left="download" @click="downloadResultsRows()" :disabled="downloadResultsRowsButtonDisabled"> {{ downloadResultsRowsButtonText }} @@ -203,7 +194,7 @@ % endif ## merge 2 objects - % if master.mergeable and request.has_perm('{}.merge'.format(permission_prefix)): + % if getattr(master, 'mergeable', False) and request.has_perm('{}.merge'.format(permission_prefix)): ${h.form(url('{}.merge'.format(route_prefix)), class_='control', **{'@submit': 'submitMergeForm'})} ${h.csrf_token(request)} @@ -221,7 +212,7 @@ % endif ## enable / disable selected objects - % if master.supports_set_enabled_toggle and master.has_perm('enable_disable_set'): + % if getattr(master, 'supports_set_enabled_toggle', False) and master.has_perm('enable_disable_set'): ${h.form(url('{}.enable_set'.format(route_prefix)), class_='control', ref='enable_selected_form')} ${h.csrf_token(request)} @@ -243,7 +234,7 @@ % endif ## delete selected objects - % if master.set_deletable and master.has_perm('delete_set'): + % if getattr(master, 'set_deletable', False) and master.has_perm('delete_set'): ${h.form(url('{}.delete_set'.format(route_prefix)), ref='delete_selected_form', class_='control')} ${h.csrf_token(request)} ${h.hidden('uuids', v_model='selected_uuids')} @@ -258,7 +249,7 @@ % endif ## delete search results - % if master.bulk_deletable and request.has_perm('{}.bulk_delete'.format(permission_prefix)): + % if getattr(master, 'bulk_deletable', False) and request.has_perm('{}.bulk_delete'.format(permission_prefix)): ${h.form(url('{}.bulk_delete'.format(route_prefix)), ref='delete_results_form', class_='control')} ${h.csrf_token(request)} <b-button type="is-danger" @@ -274,6 +265,11 @@ </%def> +## DEPRECATED; remains for back-compat +<%def name="render_this_page()"> + ${self.page_content()} +</%def> + <%def name="page_content()"> % if download_results_path: @@ -292,7 +288,7 @@ ${self.render_grid_component()} - % if master.deletable and request.has_perm('{}.delete'.format(permission_prefix)) and master.delete_confirm == 'simple': + % if master.deletable and master.has_perm('delete') and getattr(master, 'delete_confirm', 'full') == 'simple': ${h.form('#', ref='deleteObjectForm')} ${h.csrf_token(request)} ${h.end_form()} @@ -300,45 +296,34 @@ </%def> <%def name="render_grid_component()"> - <${grid.component} ref="grid" :csrftoken="csrftoken" - % if master.deletable and request.has_perm('{}.delete'.format(permission_prefix)) and master.delete_confirm == 'simple': - @deleteActionClicked="deleteObject" - % endif - > - </${grid.component}> + ${grid.render_vue_tag()} </%def> -<%def name="make_this_page_component()"> - ${parent.make_this_page_component()} +############################## +## vue components +############################## + +<%def name="render_vue_templates()"> + ${parent.render_vue_templates()} + + ## DEPRECATED; called for back-compat + ${self.make_grid_component()} +</%def> + +## DEPRECATED; remains for back-compat +<%def name="make_grid_component()"> + ${grid.render_vue_template(tools=capture(self.grid_tools).strip(), context_menu=capture(self.context_menu_items).strip())} +</%def> + +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} <script type="text/javascript"> - ${grid.component_studly}.data = function() { return ${grid.component_studly}Data } + % if getattr(master, 'supports_grid_totals', False): + ${grid.vue_component}Data.gridTotalsDisplay = null + ${grid.vue_component}Data.gridTotalsFetching = false - Vue.component('${grid.component}', ${grid.component_studly}) - - </script> -</%def> - -<%def name="render_this_page()"> - ${self.page_content()} -</%def> - -<%def name="render_this_page_template()"> - ${parent.render_this_page_template()} - - ## TODO: stop using |n filter - ${grid.render_buefy(tools=capture(self.grid_tools).strip(), context_menu=capture(self.context_menu_items).strip())|n} -</%def> - -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> - - % if master.supports_grid_totals: - ${grid.component_studly}Data.gridTotalsDisplay = null - ${grid.component_studly}Data.gridTotalsFetching = false - - ${grid.component_studly}.methods.gridTotalsFetch = function() { + ${grid.vue_component}.methods.gridTotalsFetch = function() { this.gridTotalsFetching = true let url = '${url(f'{route_prefix}.fetch_grid_totals')}' @@ -350,7 +335,7 @@ }) } - ${grid.component_studly}.methods.appliedFiltersHook = function() { + ${grid.vue_component}.methods.appliedFiltersHook = function() { this.gridTotalsDisplay = null this.gridTotalsFetching = false } @@ -394,7 +379,7 @@ % endif ## delete single object - % if master.deletable and master.has_perm('delete') and master.delete_confirm == 'simple': + % if master.deletable and master.has_perm('delete') and getattr(master, 'delete_confirm', 'full') == 'simple': ThisPage.methods.deleteObject = function(url) { if (confirm("Are you sure you wish to delete this ${model_title}?")) { let form = this.$refs.deleteObjectForm @@ -405,16 +390,19 @@ % endif ## download results - % if master.results_downloadable and master.has_perm('download_results'): + % if getattr(master, 'results_downloadable', False) and master.has_perm('download_results'): - ${grid.component_studly}Data.downloadResultsFormat = '${master.download_results_default_format()}' - ${grid.component_studly}Data.showDownloadResultsDialog = false - ${grid.component_studly}Data.downloadResultsFieldsMode = 'default' - ${grid.component_studly}Data.downloadResultsFieldsAvailable = ${json.dumps(download_results_fields_available)|n} - ${grid.component_studly}Data.downloadResultsFieldsDefault = ${json.dumps(download_results_fields_default)|n} - ${grid.component_studly}Data.downloadResultsFieldsIncluded = ${json.dumps(download_results_fields_default)|n} + ${grid.vue_component}Data.downloadResultsFormat = '${master.download_results_default_format()}' + ${grid.vue_component}Data.showDownloadResultsDialog = false + ${grid.vue_component}Data.downloadResultsFieldsMode = 'default' + ${grid.vue_component}Data.downloadResultsFieldsAvailable = ${json.dumps(download_results_fields_available)|n} + ${grid.vue_component}Data.downloadResultsFieldsDefault = ${json.dumps(download_results_fields_default)|n} + ${grid.vue_component}Data.downloadResultsFieldsIncluded = ${json.dumps(download_results_fields_default)|n} - ${grid.component_studly}.computed.downloadResultsFieldsExcluded = function() { + ${grid.vue_component}Data.downloadResultsExcludedFieldsSelected = [] + ${grid.vue_component}Data.downloadResultsIncludedFieldsSelected = [] + + ${grid.vue_component}.computed.downloadResultsFieldsExcluded = function() { let excluded = [] this.downloadResultsFieldsAvailable.forEach(field => { if (!this.downloadResultsFieldsIncluded.includes(field)) { @@ -424,70 +412,73 @@ return excluded } - ${grid.component_studly}.methods.downloadResultsExcludeFields = function() { - let selected = this.$refs.downloadResultsIncludedFields.selected + ${grid.vue_component}.methods.downloadResultsExcludeFields = function() { + const selected = Array.from(this.downloadResultsIncludedFieldsSelected) if (!selected) { return } - selected = Array.from(selected) - selected.forEach(field => { - // de-select the entry within "included" field input - let index = this.$refs.downloadResultsIncludedFields.selected.indexOf(field) - if (index > -1) { - this.$refs.downloadResultsIncludedFields.selected.splice(index, 1) + selected.forEach(field => { + let index + + // remove field from selected + index = this.downloadResultsIncludedFieldsSelected.indexOf(field) + if (index >= 0) { + this.downloadResultsIncludedFieldsSelected.splice(index, 1) } - // remove field from official "included" list + // remove field from included + // nb. excluded list will reflect this change too index = this.downloadResultsFieldsIncluded.indexOf(field) - if (index > -1) { + if (index >= 0) { this.downloadResultsFieldsIncluded.splice(index, 1) } - }, this) + }) } - ${grid.component_studly}.methods.downloadResultsIncludeFields = function() { - let selected = this.$refs.downloadResultsExcludedFields.selected + ${grid.vue_component}.methods.downloadResultsIncludeFields = function() { + const selected = Array.from(this.downloadResultsExcludedFieldsSelected) if (!selected) { return } - selected = Array.from(selected) - selected.forEach(field => { - // de-select the entry within "excluded" field input - let index = this.$refs.downloadResultsExcludedFields.selected.indexOf(field) - if (index > -1) { - this.$refs.downloadResultsExcludedFields.selected.splice(index, 1) + selected.forEach(field => { + let index + + // remove field from selected + index = this.downloadResultsExcludedFieldsSelected.indexOf(field) + if (index >= 0) { + this.downloadResultsExcludedFieldsSelected.splice(index, 1) } - // add field to official "included" list + // add field to included + // nb. excluded list will reflect this change too this.downloadResultsFieldsIncluded.push(field) - - }, this) + }) } - ${grid.component_studly}.methods.downloadResultsUseDefaultFields = function() { + ${grid.vue_component}.methods.downloadResultsUseDefaultFields = function() { this.downloadResultsFieldsIncluded = Array.from(this.downloadResultsFieldsDefault) this.downloadResultsFieldsMode = 'default' } - ${grid.component_studly}.methods.downloadResultsUseAllFields = function() { + ${grid.vue_component}.methods.downloadResultsUseAllFields = function() { this.downloadResultsFieldsIncluded = Array.from(this.downloadResultsFieldsAvailable) this.downloadResultsFieldsMode = 'all' } - ${grid.component_studly}.methods.downloadResultsSubmit = function() { + ${grid.vue_component}.methods.downloadResultsSubmit = function() { this.$refs.download_results_form.submit() } % endif ## download rows for results - % if master.has_rows and master.results_rows_downloadable and master.has_perm('download_results_rows'): + % if getattr(master, 'has_rows', False) and master.results_rows_downloadable and master.has_perm('download_results_rows'): - ${grid.component_studly}Data.downloadResultsRowsButtonDisabled = false - ${grid.component_studly}Data.downloadResultsRowsButtonText = "Download Rows for Results" + ${grid.vue_component}Data.downloadResultsRowsButtonDisabled = false + ${grid.vue_component}Data.downloadResultsRowsButtonText = "Download Rows for Results" - ${grid.component_studly}.methods.downloadResultsRows = function() { + ${grid.vue_component}.methods.downloadResultsRows = function() { if (confirm("This will generate an Excel file which contains " + "not the results themselves, but the *rows* for " + "each.\n\nAre you sure you want this?")) { @@ -499,12 +490,12 @@ % endif ## enable / disable selected objects - % if master.supports_set_enabled_toggle and master.has_perm('enable_disable_set'): + % if getattr(master, 'supports_set_enabled_toggle', False) and master.has_perm('enable_disable_set'): - ${grid.component_studly}Data.enableSelectedSubmitting = false - ${grid.component_studly}Data.enableSelectedText = "Enable Selected" + ${grid.vue_component}Data.enableSelectedSubmitting = false + ${grid.vue_component}Data.enableSelectedText = "Enable Selected" - ${grid.component_studly}.computed.enableSelectedDisabled = function() { + ${grid.vue_component}.computed.enableSelectedDisabled = function() { if (this.enableSelectedSubmitting) { return true } @@ -514,7 +505,7 @@ return false } - ${grid.component_studly}.methods.enableSelectedSubmit = function() { + ${grid.vue_component}.methods.enableSelectedSubmit = function() { let uuids = this.checkedRowUUIDs() if (!uuids.length) { alert("You must first select one or more objects to disable.") @@ -529,10 +520,10 @@ this.$refs.enable_selected_form.submit() } - ${grid.component_studly}Data.disableSelectedSubmitting = false - ${grid.component_studly}Data.disableSelectedText = "Disable Selected" + ${grid.vue_component}Data.disableSelectedSubmitting = false + ${grid.vue_component}Data.disableSelectedText = "Disable Selected" - ${grid.component_studly}.computed.disableSelectedDisabled = function() { + ${grid.vue_component}.computed.disableSelectedDisabled = function() { if (this.disableSelectedSubmitting) { return true } @@ -542,7 +533,7 @@ return false } - ${grid.component_studly}.methods.disableSelectedSubmit = function() { + ${grid.vue_component}.methods.disableSelectedSubmit = function() { let uuids = this.checkedRowUUIDs() if (!uuids.length) { alert("You must first select one or more objects to disable.") @@ -560,12 +551,12 @@ % endif ## delete selected objects - % if master.set_deletable and master.has_perm('delete_set'): + % if getattr(master, 'set_deletable', False) and master.has_perm('delete_set'): - ${grid.component_studly}Data.deleteSelectedSubmitting = false - ${grid.component_studly}Data.deleteSelectedText = "Delete Selected" + ${grid.vue_component}Data.deleteSelectedSubmitting = false + ${grid.vue_component}Data.deleteSelectedText = "Delete Selected" - ${grid.component_studly}.computed.deleteSelectedDisabled = function() { + ${grid.vue_component}.computed.deleteSelectedDisabled = function() { if (this.deleteSelectedSubmitting) { return true } @@ -575,7 +566,7 @@ return false } - ${grid.component_studly}.methods.deleteSelectedSubmit = function() { + ${grid.vue_component}.methods.deleteSelectedSubmit = function() { let uuids = this.checkedRowUUIDs() if (!uuids.length) { alert("You must first select one or more objects to disable.") @@ -591,12 +582,12 @@ } % endif - % if master.bulk_deletable and master.has_perm('bulk_delete'): + % if getattr(master, 'bulk_deletable', False) and master.has_perm('bulk_delete'): - ${grid.component_studly}Data.deleteResultsSubmitting = false - ${grid.component_studly}Data.deleteResultsText = "Delete Results" + ${grid.vue_component}Data.deleteResultsSubmitting = false + ${grid.vue_component}Data.deleteResultsText = "Delete Results" - ${grid.component_studly}.computed.deleteResultsDisabled = function() { + ${grid.vue_component}.computed.deleteResultsDisabled = function() { if (this.deleteResultsSubmitting) { return true } @@ -606,7 +597,7 @@ return false } - ${grid.component_studly}.methods.deleteResultsSubmit = function() { + ${grid.vue_component}.methods.deleteResultsSubmit = function() { // TODO: show "plural model title" here? if (!confirm("You are about to delete " + this.total.toLocaleString('en') + " objects.\n\nAre you sure?")) { return @@ -619,12 +610,12 @@ % endif - % if master.mergeable and master.has_perm('merge'): + % if getattr(master, 'mergeable', False) and master.has_perm('merge'): - ${grid.component_studly}Data.mergeFormButtonText = "Merge 2 ${model_title_plural}" - ${grid.component_studly}Data.mergeFormSubmitting = false + ${grid.vue_component}Data.mergeFormButtonText = "Merge 2 ${model_title_plural}" + ${grid.vue_component}Data.mergeFormSubmitting = false - ${grid.component_studly}.methods.submitMergeForm = function() { + ${grid.vue_component}.methods.submitMergeForm = function() { this.mergeFormSubmitting = true this.mergeFormButtonText = "Working, please wait..." } @@ -632,5 +623,10 @@ </script> </%def> - -${parent.body()} +<%def name="make_vue_components()"> + ${parent.make_vue_components()} + <script> + ${grid.vue_component}.data = function() { return ${grid.vue_component}Data } + Vue.component('${grid.vue_tagname}', ${grid.vue_component}) + </script> +</%def> diff --git a/tailbone/templates/master/merge.mako b/tailbone/templates/master/merge.mako index 6727dc5c..487d258d 100644 --- a/tailbone/templates/master/merge.mako +++ b/tailbone/templates/master/merge.mako @@ -109,8 +109,8 @@ <merge-buttons></merge-buttons> </%def> -<%def name="render_this_page_template()"> - ${parent.render_this_page_template()} +<%def name="render_vue_templates()"> + ${parent.render_vue_templates()} <script type="text/x-template" id="merge-buttons-template"> <div class="level" style="margin-top: 2em;"> @@ -147,11 +147,7 @@ </div> </div> </script> -</%def> - -<%def name="make_this_page_component()"> - ${parent.make_this_page_component()} - <script type="text/javascript"> + <script> const MergeButtons = { template: '#merge-buttons-template', @@ -175,10 +171,13 @@ } } - Vue.component('merge-buttons', MergeButtons) - </script> </%def> - -${parent.body()} +<%def name="make_vue_components()"> + ${parent.make_vue_components()} + <script> + Vue.component('merge-buttons', MergeButtons) + <% request.register_component('merge-buttons', 'MergeButtons') %> + </script> +</%def> diff --git a/tailbone/templates/master/versions.mako b/tailbone/templates/master/versions.mako index bfec39b7..a6bb14f0 100644 --- a/tailbone/templates/master/versions.mako +++ b/tailbone/templates/master/versions.mako @@ -16,27 +16,16 @@ ${self.page_content()} </%def> -<%def name="make_this_page_component()"> - ${parent.make_this_page_component()} - <script type="text/javascript"> - - TailboneGrid.data = function() { return TailboneGridData } - - Vue.component('tailbone-grid', TailboneGrid) - - </script> -</%def> - -<%def name="render_this_page_template()"> - ${parent.render_this_page_template()} - - ## TODO: stop using |n filter - ${grid.render_buefy()|n} -</%def> - <%def name="page_content()"> - <tailbone-grid :csrftoken="csrftoken"> - </tailbone-grid> + ${grid.render_vue_tag(**{':csrftoken': 'csrftoken'})} </%def> -${parent.body()} +<%def name="render_vue_templates()"> + ${parent.render_vue_templates()} + ${grid.render_vue_template()} +</%def> + +<%def name="make_vue_components()"> + ${parent.make_vue_components()} + ${grid.render_vue_finalize()} +</%def> diff --git a/tailbone/templates/master/view.mako b/tailbone/templates/master/view.mako index 9a37b2bb..118c028c 100644 --- a/tailbone/templates/master/view.mako +++ b/tailbone/templates/master/view.mako @@ -8,12 +8,15 @@ </%def> <%def name="render_instance_header_title_extras()"> - % if master.touchable and master.has_perm('touch'): + % if getattr(master, 'touchable', False) and master.has_perm('touch'): <b-button title=""Touch" this record to trigger sync" - icon-pack="fas" - icon-left="hand-pointer" @click="touchRecord()" :disabled="touchSubmitting"> + % if request.use_oruga: + <o-icon icon="hand-pointer" /> + % else: + <span><i class="fa fa-hand-pointer"></i></span> + % endif </b-button> % endif % if expose_versions: @@ -34,7 +37,7 @@ % if xref_buttons or xref_links: <nav class="panel"> <p class="panel-heading">Cross-Reference</p> - <div class="panel-block buttons"> + <div class="panel-block"> <div style="display: flex; flex-direction: column; gap: 0.5rem;"> % for button in xref_buttons: ${button} @@ -48,12 +51,6 @@ % endif </%def> -<%def name="context_menu_items()"> - ## TODO: either make this configurable, or just lose it. - ## nobody seems to ever find it useful in practice. - ## <li>${h.link_to("Permalink for this {}".format(model_title), action_url('view', instance))}</li> -</%def> - <%def name="render_row_grid_tools()"> ${rows_grid_tools} % if master.rows_downloadable_xlsx and master.has_perm('row_results_xlsx'): @@ -96,7 +93,7 @@ ${parent.render_this_page()} ## render row grid - % if master.has_rows: + % if getattr(master, 'has_rows', False): <br /> % if rows_title: <h4 class="block is-size-4">${rows_title}</h4> @@ -113,17 +110,25 @@ <p class="block"> <a href="${master.get_action_url('versions', instance)}" target="_blank"> - <i class="fas fa-external-link-alt"></i> + % if request.use_oruga: + <o-icon icon="external-link-alt" /> + % else: + <i class="fas fa-external-link-alt"></i> + % endif View as separate page </a> </p> </div> - <versions-grid ref="versionsGrid" - @view-revision="viewRevision"> - </versions-grid> + ${versions_grid.render_vue_tag(ref='versionsGrid', **{'@view-revision': 'viewRevision'})} - <b-modal :active.sync="viewVersionShowDialog" :width="1200"> + <${b}-modal :width="1200" + % if request.use_oruga: + v-model:active="viewVersionShowDialog" + % else: + :active.sync="viewVersionShowDialog" + % endif + > <div class="card"> <div class="card-content"> <div style="display: flex; flex-direction: column; gap: 1.5rem;"> @@ -170,7 +175,11 @@ <div> <a :href="viewVersionData.url" target="_blank"> - <i class="fas fa-external-link-alt"></i> + % if request.use_oruga: + <o-icon icon="external-link-alt" /> + % else: + <i class="fas fa-external-link-alt"></i> + % endif View as separate page </a> </div> @@ -187,6 +196,7 @@ <p class="block has-text-weight-bold"> {{ version.model_title }} + ({{ version.operation }}) </p> <table class="diff monospace is-size-7" @@ -213,34 +223,50 @@ </div> </div> - <b-loading :active.sync="viewVersionLoading" :is-full-page="false"></b-loading> + % if request.use_oruga: + <o-loading v-model:active="viewVersionLoading" :is-full-page="false" /> + % else: + <b-loading :active.sync="viewVersionLoading" :is-full-page="false"></b-loading> + % endif </div> </div> - </b-modal> + </${b}-modal> </div> % endif </%def> <%def name="render_row_grid_component()"> - <tailbone-grid ref="rowGrid" id="rowGrid"></tailbone-grid> + ${rows_grid.render_vue_tag(id='rowGrid', ref='rowGrid')} </%def> -<%def name="render_this_page_template()"> - % if master.has_rows: - ## TODO: stop using |n filter - ${rows_grid.render_buefy(allow_save_defaults=False, tools=capture(self.render_row_grid_tools))|n} +<%def name="render_vue_templates()"> + ${parent.render_vue_templates()} + % if getattr(master, 'has_rows', False): + ${rows_grid.render_vue_template(allow_save_defaults=False, tools=capture(self.render_row_grid_tools))} % endif - ${parent.render_this_page_template()} % if expose_versions: - ${versions_grid.render_buefy()|n} + ${versions_grid.render_vue_template()} % endif </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - % if expose_versions: - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> + % if getattr(master, 'touchable', False) and master.has_perm('touch'): + + WholePageData.touchSubmitting = false + + WholePage.methods.touchRecord = function() { + this.touchSubmitting = true + location.href = '${master.get_action_url('touch', instance)}' + } + + % endif + + % if expose_versions: + + WholePageData.viewingHistory = false ThisPage.props.viewingHistory = Boolean ThisPageData.gettingRevisions = false @@ -295,48 +321,16 @@ this.viewVersionShowAllFields = !this.viewVersionShowAllFields } - </script> + % endif + </script> +</%def> + +<%def name="make_vue_components()"> + ${parent.make_vue_components()} + % if getattr(master, 'has_rows', False): + ${rows_grid.render_vue_finalize()} + % endif + % if expose_versions: + ${versions_grid.render_vue_finalize()} % endif </%def> - -<%def name="modify_whole_page_vars()"> - ${parent.modify_whole_page_vars()} - <script type="text/javascript"> - - % if master.touchable and master.has_perm('touch'): - - WholePageData.touchSubmitting = false - - WholePage.methods.touchRecord = function() { - this.touchSubmitting = true - location.href = '${master.get_action_url('touch', instance)}' - } - - % endif - - % if expose_versions: - WholePageData.viewingHistory = false - % endif - - </script> -</%def> - -<%def name="finalize_this_page_vars()"> - ${parent.finalize_this_page_vars()} - <script type="text/javascript"> - - % if master.has_rows: - TailboneGrid.data = function() { return TailboneGridData } - Vue.component('tailbone-grid', TailboneGrid) - % endif - - % if expose_versions: - VersionsGrid.data = function() { return VersionsGridData } - Vue.component('versions-grid', VersionsGrid) - % endif - - </script> -</%def> - - -${parent.body()} diff --git a/tailbone/templates/master/view_version.mako b/tailbone/templates/master/view_version.mako index 6417dfb7..dfe03a64 100644 --- a/tailbone/templates/master/view_version.mako +++ b/tailbone/templates/master/view_version.mako @@ -19,48 +19,39 @@ </%def> <%def name="page_content()"> -## TODO: this was basically copied from Revel diff template..need to abstract -<div class="form-wrapper"> + <div class="form-wrapper" style="margin: 1rem; 0;"> + <div class="form"> - <div class="form"> + <b-field label="Changed" horizontal> + <span>${h.pretty_datetime(request.rattail_config, changed)}</span> + </b-field> + + <b-field label="Changed by" horizontal> + <span>${transaction.user or ''}</span> + </b-field> + + <b-field label="IP Address" horizontal> + <span>${transaction.remote_addr}</span> + </b-field> + + <b-field label="Comment" horizontal> + <span>${transaction.meta.get('comment') or ''}</span> + </b-field> + + <b-field label="TXN ID" horizontal> + <span>${transaction.id}</span> + </b-field> - <div class="field-wrapper"> - <label>Changed</label> - <div class="field">${h.pretty_datetime(request.rattail_config, changed)}</div> </div> - - <div class="field-wrapper"> - <label>Changed by</label> - <div class="field">${transaction.user or ''}</div> - </div> - - <div class="field-wrapper"> - <label>IP Address</label> - <div class="field">${transaction.remote_addr}</div> - </div> - - <div class="field-wrapper"> - <label>Comment</label> - <div class="field">${transaction.meta.get('comment') or ''}</div> - </div> - - <div class="field-wrapper"> - <label>TXN ID</label> - <div class="field">${transaction.id}</div> - </div> - </div> -</div><!-- form-wrapper --> - -<div class="versions-wrapper"> - % for diff in version_diffs: - <h4 class="is-size-4 block">${diff.title}</h4> - ${diff.render_html()} - % endfor -</div> - + <div class="versions-wrapper"> + % for diff in version_diffs: + <h4 class="is-size-4 block">${diff.title}</h4> + ${diff.render_html()} + % endfor + </div> </%def> diff --git a/tailbone/templates/members/configure.mako b/tailbone/templates/members/configure.mako index 465bf611..f1f0e39f 100644 --- a/tailbone/templates/members/configure.mako +++ b/tailbone/templates/members/configure.mako @@ -52,9 +52,9 @@ </div> </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> ThisPage.methods.getLabelForKey = function(key) { switch (key) { @@ -75,6 +75,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/messages/create.mako b/tailbone/templates/messages/create.mako index 4a15573b..39236f75 100644 --- a/tailbone/templates/messages/create.mako +++ b/tailbone/templates/messages/create.mako @@ -32,14 +32,14 @@ % endif </%def> -<%def name="render_this_page_template()"> - ${parent.render_this_page_template()} +<%def name="render_vue_templates()"> + ${parent.render_vue_templates()} ${message_recipients_template()} </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> TailboneFormData.possibleRecipients = new Map(${json.dumps(available_recipients)|n}) TailboneFormData.recipientDisplayMap = ${json.dumps(recipient_display_map)|n} @@ -59,6 +59,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/messages/index.mako b/tailbone/templates/messages/index.mako index 3fc82fd3..eaa4b6c9 100644 --- a/tailbone/templates/messages/index.mako +++ b/tailbone/templates/messages/index.mako @@ -22,15 +22,15 @@ % endif </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} % if request.matched_route.name in ('messages.inbox', 'messages.archive'): - <script type="text/javascript"> + <script> - TailboneGridData.moveMessagesSubmitting = false - TailboneGridData.moveMessagesText = null + ${grid.vue_component}Data.moveMessagesSubmitting = false + ${grid.vue_component}Data.moveMessagesText = null - TailboneGrid.computed.moveMessagesTextCurrent = function() { + ${grid.vue_component}.computed.moveMessagesTextCurrent = function() { if (this.moveMessagesText) { return this.moveMessagesText } @@ -38,7 +38,7 @@ return "Move " + count.toString() + " selected to ${'Archive' if request.matched_route.name == 'messages.inbox' else 'Inbox'}" } - TailboneGrid.methods.moveMessagesSubmit = function() { + ${grid.vue_component}.methods.moveMessagesSubmit = function() { this.moveMessagesSubmitting = true this.moveMessagesText = "Working, please wait..." } @@ -46,6 +46,3 @@ </script> % endif </%def> - - -${parent.body()} diff --git a/tailbone/templates/messages/view.mako b/tailbone/templates/messages/view.mako index 2e2baa60..36418698 100644 --- a/tailbone/templates/messages/view.mako +++ b/tailbone/templates/messages/view.mako @@ -82,22 +82,19 @@ </div> </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> - TailboneFormData.showingAllRecipients = false + ${form.vue_component}Data.showingAllRecipients = false - TailboneForm.methods.showMoreRecipients = function() { + ${form.vue_component}.methods.showMoreRecipients = function() { this.showingAllRecipients = true } - TailboneForm.methods.hideMoreRecipients = function() { + ${form.vue_component}.methods.hideMoreRecipients = function() { this.showingAllRecipients = false } </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/ordering/configure.mako b/tailbone/templates/ordering/configure.mako new file mode 100644 index 00000000..dc505c42 --- /dev/null +++ b/tailbone/templates/ordering/configure.mako @@ -0,0 +1,74 @@ +## -*- coding: utf-8; -*- +<%inherit file="/configure.mako" /> + +<%def name="form_content()"> + + <h3 class="block is-size-3">Workflows</h3> + <div class="block" style="padding-left: 2rem;"> + + <p class="block"> + Users can only choose from the workflows enabled below. + </p> + + <b-field> + <b-checkbox name="rattail.batch.purchase.allow_ordering_from_scratch" + v-model="simpleSettings['rattail.batch.purchase.allow_ordering_from_scratch']" + native-value="true" + @input="settingsNeedSaved = true"> + From Scratch + </b-checkbox> + </b-field> + + <b-field> + <b-checkbox name="rattail.batch.purchase.allow_ordering_from_file" + v-model="simpleSettings['rattail.batch.purchase.allow_ordering_from_file']" + native-value="true" + @input="settingsNeedSaved = true"> + From Order File + </b-checkbox> + </b-field> + + </div> + + <h3 class="block is-size-3">Vendors</h3> + <div class="block" style="padding-left: 2rem;"> + + <b-field message="If not set, user must choose a "supported" vendor."> + <b-checkbox name="rattail.batch.purchase.allow_ordering_any_vendor" + v-model="simpleSettings['rattail.batch.purchase.allow_ordering_any_vendor']" + native-value="true" + @input="settingsNeedSaved = true"> + Allow ordering for <span class="has-text-weight-bold">any</span> vendor + </b-checkbox> + </b-field> + + </div> + + <h3 class="block is-size-3">Order Parsers</h3> + <div class="block" style="padding-left: 2rem;"> + + <p class="block"> + Only the selected file parsers will be exposed to users. + </p> + + % for Parser in order_parsers: + <b-field message="${Parser.key}"> + <b-checkbox name="order_parser_${Parser.key}" + v-model="orderParsers['${Parser.key}']" + native-value="true" + @input="settingsNeedSaved = true"> + ${Parser.title} + </b-checkbox> + </b-field> + % endfor + + </div> + +</%def> + +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> + ThisPageData.orderParsers = ${json.dumps(order_parsers_data)|n} + </script> +</%def> diff --git a/tailbone/templates/ordering/view.mako b/tailbone/templates/ordering/view.mako index f0e6380a..34a6085f 100644 --- a/tailbone/templates/ordering/view.mako +++ b/tailbone/templates/ordering/view.mako @@ -21,14 +21,14 @@ % endif </%def> -<%def name="render_this_page_template()"> - ${parent.render_this_page_template()} +<%def name="render_vue_templates()"> + ${parent.render_vue_templates()} % if not batch.executed and not batch.complete and master.has_perm('edit_row'): <script type="text/x-template" id="ordering-scanner-template"> <div> <b-button type="is-primary" icon-pack="fas" - icon-left="fas fa-play" + icon-left="play" @click="startScanning()"> Start Scanning </b-button> @@ -111,7 +111,7 @@ <div class="buttons"> <b-button type="is-primary" icon-pack="fas" - icon-left="fas fa-save" + icon-left="save" @click="saveCurrentRow()"> Save </b-button> @@ -185,10 +185,10 @@ % endif </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} % if not batch.executed and not batch.complete and master.has_perm('edit_row'): - <script type="text/javascript"> + <script> let OrderingScanner = { template: '#ordering-scanner-template', @@ -204,7 +204,7 @@ saving: false, ## TODO: should find a better way to handle CSRF token - csrftoken: ${json.dumps(request.session.get_csrf_token() or request.session.new_csrf_token())|n}, + csrftoken: ${json.dumps(h.get_csrf_token(request))|n}, } }, computed: { @@ -408,16 +408,11 @@ % endif </%def> -<%def name="make_this_page_component()"> - ${parent.make_this_page_component()} +<%def name="make_vue_components()"> + ${parent.make_vue_components()} % if not batch.executed and not batch.complete and master.has_perm('edit_row'): - <script type="text/javascript"> - + <script> Vue.component('ordering-scanner', OrderingScanner) - </script> % endif </%def> - - -${parent.body()} diff --git a/tailbone/templates/ordering/worksheet.mako b/tailbone/templates/ordering/worksheet.mako index e41fe15f..eb2077e7 100644 --- a/tailbone/templates/ordering/worksheet.mako +++ b/tailbone/templates/ordering/worksheet.mako @@ -73,7 +73,7 @@ <div class="grid"> <table class="order-form"> <% column_count = 8 + len(header_columns) + (0 if ignore_cases else 1) + int(capture(self.extra_count)) %> - % for department in sorted(six.itervalues(departments), key=lambda d: d.name if d else ''): + % for department in sorted(departments.values(), key=lambda d: d.name if d else ''): <thead> <tr> <th class="department" colspan="${column_count}">Department @@ -84,7 +84,7 @@ % endif </th> </tr> - % for subdepartment in sorted(six.itervalues(department._order_subdepartments), key=lambda s: s.name if s else ''): + % for subdepartment in sorted(department._order_subdepartments.values(), key=lambda s: s.name if s else ''): <tr> <th class="subdepartment" colspan="${column_count}">Subdepartment % if subdepartment.number or subdepartment.name: @@ -199,9 +199,8 @@ <ordering-worksheet></ordering-worksheet> </%def> -<%def name="render_this_page_template()"> - ${parent.render_this_page_template()} - +<%def name="render_vue_templates()"> + ${parent.render_vue_templates()} <script type="text/x-template" id="ordering-worksheet-template"> <div> <div class="form-wrapper"> @@ -239,11 +238,7 @@ ${self.order_form_grid()} </div> </script> -</%def> - -<%def name="make_this_page_component()"> - ${parent.make_this_page_component()} - <script type="text/javascript"> + <script> const OrderingWorksheet = { template: '#ordering-worksheet-template', @@ -255,7 +250,7 @@ submitting: false, ## TODO: should find a better way to handle CSRF token - csrftoken: ${json.dumps(request.session.get_csrf_token() or request.session.new_csrf_token())|n}, + csrftoken: ${json.dumps(h.get_csrf_token(request))|n}, } }, methods: { @@ -298,14 +293,12 @@ }, } - Vue.component('ordering-worksheet', OrderingWorksheet) - </script> </%def> - -############################## -## page body -############################## - -${parent.body()} +<%def name="make_vue_components()"> + ${parent.make_vue_components()} + <script> + Vue.component('ordering-worksheet', OrderingWorksheet) + </script> +</%def> diff --git a/tailbone/templates/page.mako b/tailbone/templates/page.mako index bf799440..43b0a266 100644 --- a/tailbone/templates/page.mako +++ b/tailbone/templates/page.mako @@ -1,36 +1,26 @@ ## -*- coding: utf-8; -*- <%inherit file="/base.mako" /> -<%def name="context_menu_items()"></%def> +<%def name="render_vue_templates()"> + ${parent.render_vue_templates()} + ${self.render_vue_template_this_page()} +</%def> -<%def name="page_content()"></%def> - -<%def name="render_this_page()"> - <div style="display: flex;"> - - <div class="this-page-content" style="flex-grow: 1;"> - ${self.page_content()} - </div> - - <ul id="context-menu"> - ${self.context_menu_items()} - </ul> - - </div> +<%def name="render_vue_template_this_page()"> + ## DEPRECATED; called for back-compat + ${self.render_this_page_template()} </%def> <%def name="render_this_page_template()"> <script type="text/x-template" id="this-page-template"> <div> + ## DEPRECATED; called for back-compat ${self.render_this_page()} </div> </script> -</%def> + <script> -<%def name="declare_this_page_vars()"> - <script type="text/javascript"> - - let ThisPage = { + const ThisPage = { template: '#this-page-template', mixins: [SimpleRequestMixin], props: { @@ -46,36 +36,71 @@ }, } - let ThisPageData = { + const ThisPageData = { ## TODO: should find a better way to handle CSRF token - csrftoken: ${json.dumps(request.session.get_csrf_token() or request.session.new_csrf_token())|n}, + csrftoken: ${json.dumps(h.get_csrf_token(request))|n}, } </script> </%def> -<%def name="modify_this_page_vars()"> - ## NOTE: if you override this, must use <script> tags +## DEPRECATED; remains for back-compat +<%def name="render_this_page()"> + <div style="display: flex;"> + + <div class="this-page-content" style="flex-grow: 1;"> + ${self.page_content()} + </div> + + ## DEPRECATED; remains for back-compat + <ul id="context-menu"> + ${self.context_menu_items()} + </ul> + </div> </%def> -<%def name="finalize_this_page_vars()"> - ## NOTE: if you override this, must use <script> tags +## nb. this is the canonical block for page content! +<%def name="page_content()"></%def> + +## DEPRECATED; remains for back-compat +<%def name="context_menu_items()"> + % if context_menu_list_items is not Undefined: + % for item in context_menu_list_items: + <li>${item}</li> + % endfor + % endif +</%def> + +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + + ## DEPRECATED; called for back-compat + ${self.declare_this_page_vars()} + ${self.modify_this_page_vars()} +</%def> + +<%def name="make_vue_components()"> + ${parent.make_vue_components()} + + ## DEPRECATED; called for back-compat + ${self.make_this_page_component()} </%def> <%def name="make_this_page_component()"> - ${self.declare_this_page_vars()} - ${self.modify_this_page_vars()} ${self.finalize_this_page_vars()} - - <script type="text/javascript"> - + <script> ThisPage.data = function() { return ThisPageData } - Vue.component('this-page', ThisPage) - + <% request.register_component('this-page', 'ThisPage') %> </script> </%def> +############################## +## DEPRECATED +############################## -${self.render_this_page_template()} -${self.make_this_page_component()} +<%def name="declare_this_page_vars()"></%def> + +<%def name="modify_this_page_vars()"></%def> + +<%def name="finalize_this_page_vars()"></%def> diff --git a/tailbone/templates/page_help.mako b/tailbone/templates/page_help.mako index 4da6ac37..ea86c6da 100644 --- a/tailbone/templates/page_help.mako +++ b/tailbone/templates/page_help.mako @@ -6,10 +6,8 @@ % if help_url or help_markdown: - <b-field> - <p class="control"> - <b-button icon-pack="fas" - icon-left="question-circle" + % if request.use_oruga: + <o-button icon-left="question-circle" % if help_markdown: @click="displayInit()" % elif help_url: @@ -18,57 +16,117 @@ % endif > Help - </b-button> - </p> - % if can_edit_help: - ## TODO: this dropdown is duplicated, below - <b-dropdown aria-role="list" position="is-bottom-left"> - <template #trigger="{ active }"> - <b-button> - <span><i class="fa fa-cog"></i></span> - </b-button> - </template> - <b-dropdown-item aria-role="listitem" - @click="configureInit()"> - Edit Page Help - </b-dropdown-item> - <b-dropdown-item aria-role="listitem" - @click="configureFieldsInit()"> - Edit Fields Help - </b-dropdown-item> - </b-dropdown> - % endif - </b-field> + </o-button> + + % if can_edit_help: + ## TODO: this dropdown is duplicated, below + <o-dropdown position="bottom-left" + ## TODO: why does click not work here?! + :triggers="['click', 'hover']"> + <template #trigger> + <o-button> + <o-icon icon="cog" /> + </o-button> + </template> + <o-dropdown-item label="Edit Page Help" + @click="configureInit()" /> + <o-dropdown-item label="Edit Fields Help" + @click="configureFieldsInit()" /> + </o-dropdown> + % endif + + % else: + ## buefy + <b-field> + <p class="control"> + <b-button icon-pack="fas" + icon-left="question-circle" + % if help_markdown: + @click="displayInit()" + % elif help_url: + tag="a" href="${help_url}" + target="_blank" + % endif + > + Help + </b-button> + </p> + % if can_edit_help: + ## TODO: this dropdown is duplicated, below + <b-dropdown aria-role="list" position="is-bottom-left"> + <template #trigger="{ active }"> + <b-button> + <span><i class="fa fa-cog"></i></span> + </b-button> + </template> + <b-dropdown-item aria-role="listitem" + @click="configureInit()"> + Edit Page Help + </b-dropdown-item> + <b-dropdown-item aria-role="listitem" + @click="configureFieldsInit()"> + Edit Fields Help + </b-dropdown-item> + </b-dropdown> + % endif + </b-field> + % endif: % elif can_edit_help: - <b-field> - <p class="control"> - ## TODO: this dropdown is duplicated, above - <b-dropdown aria-role="list" position="is-bottom-left"> - <template #trigger="{ active }"> - <b-button> - <span><i class="fa fa-question-circle"></i></span> - <span><i class="fa fa-cog"></i></span> - </b-button> + ## TODO: this dropdown is duplicated, above + % if request.use_oruga: + <o-dropdown position="bottom-left" + ## TODO: why does click not work here?! + :triggers="['click', 'hover']"> + <template #trigger> + <o-button> + <o-icon icon="question-circle" /> + <o-icon icon="cog" /> + </o-button> </template> - <b-dropdown-item aria-role="listitem" - @click="configureInit()"> - Edit Page Help - </b-dropdown-item> - <b-dropdown-item aria-role="listitem" - @click="configureFieldsInit()"> - Edit Fields Help - </b-dropdown-item> - </b-dropdown> - </p> - </b-field> - + <o-dropdown-item label="Edit Page Help" + @click="configureInit()" /> + <o-dropdown-item label="Edit Fields Help" + @click="configureFieldsInit()" /> + </o-dropdown> + % else: + <b-field> + <p class="control"> + <b-dropdown aria-role="list" position="is-bottom-left"> + <template #trigger> + <b-button> + % if request.use_oruga: + <o-icon icon="question-circle" /> + <o-icon icon="cog" /> + % else: + <span><i class="fa fa-question-circle"></i></span> + <span><i class="fa fa-cog"></i></span> + % endif + </b-button> + </template> + <b-dropdown-item aria-role="listitem" + @click="configureInit()"> + Edit Page Help + </b-dropdown-item> + <b-dropdown-item aria-role="listitem" + @click="configureFieldsInit()"> + Edit Fields Help + </b-dropdown-item> + </b-dropdown> + </p> + </b-field> + % endif % endif % if help_markdown: - <b-modal has-modal-card - :active.sync="displayShowDialog"> + <${b}-modal has-modal-card + % if request.use_oruga: + v-model:active="displayShowDialog" + % else: + :active.sync="displayShowDialog" + % endif + > <div class="modal-card"> <header class="modal-card-head"> @@ -94,14 +152,23 @@ </b-button> </footer> </div> - </b-modal> + </${b}-modal> % endif % if can_edit_help: - <b-modal has-modal-card - :active.sync="configureShowDialog"> - <div class="modal-card"> + <${b}-modal has-modal-card + % if request.use_oruga: + v-model:active="configureShowDialog" + % else: + :active.sync="configureShowDialog" + % endif + > + <div class="modal-card" + % if request.use_oruga: + style="margin: auto;" + % endif + > <header class="modal-card-head"> <p class="modal-card-title">Configure Help</p> @@ -128,13 +195,15 @@ <b-field label="Help Link (URL)"> <b-input v-model="helpURL" - ref="helpURL"> + ref="helpURL" + expanded> </b-input> </b-field> <b-field label="Help Text (Markdown)"> <b-input v-model="markdownText" - type="textarea" rows="8"> + type="textarea" rows="8" + expanded> </b-input> </b-field> @@ -153,7 +222,7 @@ </b-button> </footer> </div> - </b-modal> + </${b}-modal> % endif @@ -235,6 +304,7 @@ PageHelp.data = function() { return PageHelpData } Vue.component('page-help', PageHelp) + <% request.register_component('page-help', 'PageHelp') %> </script> </%def> diff --git a/tailbone/templates/people/configure.mako b/tailbone/templates/people/configure.mako index 9e6ce5fb..257432dc 100644 --- a/tailbone/templates/people/configure.mako +++ b/tailbone/templates/people/configure.mako @@ -4,7 +4,7 @@ <%def name="form_content()"> <h3 class="block is-size-3">General</h3> - <div class="block" style="padding-left: 2rem;"> + <div class="block" style="padding-left: 2rem; width: 50%;"> <b-field message="If set, grid links are to Personal tab of Profile view."> <b-checkbox name="rattail.people.straight_to_profile" @@ -28,8 +28,30 @@ message="Leave blank for default handler."> <b-input name="rattail.people.handler" v-model="simpleSettings['rattail.people.handler']" - @input="settingsNeedSaved = true"> - </b-input> + @input="settingsNeedSaved = true" + expanded /> + </b-field> + + </div> + + <h3 class="block is-size-3">Profile View</h3> + <div class="block" style="padding-left: 2rem; width: 50%;"> + + <b-field> + <b-checkbox name="tailbone.people.profile.expose_members" + v-model="simpleSettings['tailbone.people.profile.expose_members']" + native-value="true" + @input="settingsNeedSaved = true"> + Show tab for Member Accounts + </b-checkbox> + </b-field> + <b-field> + <b-checkbox name="tailbone.people.profile.expose_transactions" + v-model="simpleSettings['tailbone.people.profile.expose_transactions']" + native-value="true" + @input="settingsNeedSaved = true"> + Show tab for Customer POS Transactions + </b-checkbox> </b-field> </div> diff --git a/tailbone/templates/people/index.mako b/tailbone/templates/people/index.mako index c819050a..cd6fddf1 100644 --- a/tailbone/templates/people/index.mako +++ b/tailbone/templates/people/index.mako @@ -3,7 +3,7 @@ <%def name="grid_tools()"> - % if master.mergeable and master.has_perm('request_merge'): + % if getattr(master, 'mergeable', False) and master.has_perm('request_merge'): <b-button @click="showMergeRequest()" icon-pack="fas" icon-left="object-ungroup" @@ -61,37 +61,37 @@ ${parent.grid_tools()} </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> - % if master.mergeable and master.has_perm('request_merge'): + % if getattr(master, 'mergeable', False) and master.has_perm('request_merge'): - ${grid.component_studly}Data.mergeRequestShowDialog = false - ${grid.component_studly}Data.mergeRequestRows = [] - ${grid.component_studly}Data.mergeRequestSubmitText = "Submit Merge Request" - ${grid.component_studly}Data.mergeRequestSubmitting = false + ${grid.vue_component}Data.mergeRequestShowDialog = false + ${grid.vue_component}Data.mergeRequestRows = [] + ${grid.vue_component}Data.mergeRequestSubmitText = "Submit Merge Request" + ${grid.vue_component}Data.mergeRequestSubmitting = false - ${grid.component_studly}.computed.mergeRequestRemovingUUID = function() { + ${grid.vue_component}.computed.mergeRequestRemovingUUID = function() { if (this.mergeRequestRows.length) { return this.mergeRequestRows[0].uuid } return null } - ${grid.component_studly}.computed.mergeRequestKeepingUUID = function() { + ${grid.vue_component}.computed.mergeRequestKeepingUUID = function() { if (this.mergeRequestRows.length) { return this.mergeRequestRows[1].uuid } return null } - ${grid.component_studly}.methods.showMergeRequest = function() { + ${grid.vue_component}.methods.showMergeRequest = function() { this.mergeRequestRows = this.checkedRows this.mergeRequestShowDialog = true } - ${grid.component_studly}.methods.submitMergeRequest = function() { + ${grid.vue_component}.methods.submitMergeRequest = function() { this.mergeRequestSubmitting = true this.mergeRequestSubmitText = "Working, please wait..." } @@ -100,5 +100,3 @@ </script> </%def> - -${parent.body()} diff --git a/tailbone/templates/people/merge-requests/view.mako b/tailbone/templates/people/merge-requests/view.mako index 9e8905cf..e2db1476 100644 --- a/tailbone/templates/people/merge-requests/view.mako +++ b/tailbone/templates/people/merge-requests/view.mako @@ -18,10 +18,10 @@ % endif </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} % if not instance.merged and request.has_perm('people.merge'): - <script type="text/javascript"> + <script> ThisPageData.mergeFormButtonText = "Perform Merge" ThisPageData.mergeFormSubmitting = false @@ -34,5 +34,3 @@ </script> % endif </%def> - -${parent.body()} diff --git a/tailbone/templates/people/view.mako b/tailbone/templates/people/view.mako index 973a1da8..15c669fa 100644 --- a/tailbone/templates/people/view.mako +++ b/tailbone/templates/people/view.mako @@ -2,34 +2,6 @@ <%inherit file="/master/view.mako" /> <%namespace file="/util.mako" import="view_profiles_helper" /> -<%def name="object_helpers()"> - ${parent.object_helpers()} - ${view_profiles_helper([instance])} -</%def> - -<%def name="render_buefy_form()"> - <div class="form"> - <tailbone-form v-on:make-user="makeUser"></tailbone-form> - </div> -</%def> - -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> - - TailboneForm.methods.clickMakeUser = function(event) { - this.$emit('make-user') - } - - ThisPage.methods.makeUser = function(event) { - if (confirm("Really make a user account for this person?")) { - this.$refs.makeUserForm.submit() - } - } - - </script> -</%def> - <%def name="page_content()"> ${parent.page_content()} % if not instance.users and request.has_perm('users.create'): @@ -40,6 +12,30 @@ % endif </%def> +<%def name="object_helpers()"> + ${parent.object_helpers()} + ${view_profiles_helper([instance])} +</%def> -${parent.body()} +<%def name="render_form()"> + <div class="form"> + <${form.vue_tagname} v-on:make-user="makeUser"></${form.vue_tagname}> + </div> +</%def> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> + + ${form.vue_component}.methods.clickMakeUser = function(event) { + this.$emit('make-user') + } + + ThisPage.methods.makeUser = function(event) { + if (confirm("Really make a user account for this person?")) { + this.$refs.makeUserForm.submit() + } + } + + </script> +</%def> diff --git a/tailbone/templates/people/view_profile_buefy.mako b/tailbone/templates/people/view_profile.mako similarity index 58% rename from tailbone/templates/people/view_profile_buefy.mako rename to tailbone/templates/people/view_profile.mako index 4b1e089c..6ca5a84c 100644 --- a/tailbone/templates/people/view_profile_buefy.mako +++ b/tailbone/templates/people/view_profile.mako @@ -15,7 +15,7 @@ </%def> <%def name="content_title()"> - ${dynamic_content_title} + ${dynamic_content_title or str(instance)} </%def> <%def name="render_instance_header_title_extras()"> @@ -78,7 +78,9 @@ </%def> <%def name="render_personal_name_card()"> - <div class="card personal"> + <div class="card personal" + ## nb. hack to force refresh for vue3 + :key="refreshPersonalCard"> <header class="card-header"> <p class="card-header-title">Name</p> </header> @@ -91,6 +93,12 @@ <span>{{ person.first_name }}</span> </b-field> + % if use_preferred_first_name: + <b-field horizontal label="Preferred First Name"> + <span>{{ person.preferred_first_name }}</span> + </b-field> + % endif + <b-field horizontal label="Middle Name"> <span>{{ person.middle_name }}</span> </b-field> @@ -109,8 +117,13 @@ Edit Name </b-button> </div> - <b-modal has-modal-card - :active.sync="editNameShowDialog"> + <${b}-modal has-modal-card + % if request.use_oruga: + v-model:active="editNameShowDialog" + % else: + :active.sync="editNameShowDialog" + % endif + > <div class="modal-card"> <header class="modal-card-head"> @@ -118,36 +131,53 @@ </header> <section class="modal-card-body"> - <b-field label="First Name"> - <b-input v-model.trim="editNameFirst" - :maxlength="maxLengths.person_first_name || null"> - </b-input> - </b-field> + + % if use_preferred_first_name: + <b-field grouped> + <b-field label="First Name"> + <b-input v-model.trim="editNameFirst" + :maxlength="maxLengths.person_first_name || null" /> + </b-field> + <b-field label="Preferred First Name" expanded> + <b-input v-model.trim="editNameFirstPreferred" + :maxlength="maxLengths.person_preferred_first_name || null"> + </b-input> + </b-field> + </b-field> + % else: + <b-field label="First Name"> + <b-input v-model.trim="editNameFirst" + :maxlength="maxLengths.person_first_name || null" + expanded /> + </b-field> + % endif + <b-field label="Middle Name"> <b-input v-model.trim="editNameMiddle" - :maxlength="maxLengths.person_middle_name || null"> - </b-input> + :maxlength="maxLengths.person_middle_name || null" + expanded /> </b-field> <b-field label="Last Name"> <b-input v-model.trim="editNameLast" - :maxlength="maxLengths.person_last_name || null"> - </b-input> + :maxlength="maxLengths.person_last_name || null" + expanded /> </b-field> </section> <footer class="modal-card-foot"> - <once-button type="is-primary" - @click="editNameSave()" - :disabled="editNameSaveDisabled" - icon-left="save" - text="Save"> - </once-button> + <b-button type="is-primary" + @click="editNameSave()" + :disabled="editNameSaveDisabled" + icon-pack="fas" + icon-left="save"> + {{ editNameSaving ? "Working, please wait..." : "Save" }} + </b-button> <b-button @click="editNameShowDialog = false"> Cancel </b-button> </footer> </div> - </b-modal> + </${b}-modal> % endif </div> </div> @@ -156,7 +186,9 @@ </%def> <%def name="render_personal_address_card()"> - <div class="card personal"> + <div class="card personal" + ## nb. hack to force refresh for vue3 + :key="refreshAddressCard"> <header class="card-header"> <p class="card-header-title">Address</p> </header> @@ -199,8 +231,13 @@ icon-left="edit"> Edit Address </b-button> - <b-modal has-modal-card - :active.sync="editAddressShowDialog"> + <${b}-modal has-modal-card + % if request.use_oruga: + v-model:active="editAddressShowDialog" + % else: + :active.sync="editAddressShowDialog" + % endif + > <div class="modal-card"> <header class="modal-card-head"> @@ -211,20 +248,20 @@ <b-field label="Street 1" expanded> <b-input v-model.trim="editAddressStreet1" - :maxlength="maxLengths.address_street || null"> - </b-input> + :maxlength="maxLengths.address_street || null" + expanded /> </b-field> <b-field label="Street 2" expanded> <b-input v-model.trim="editAddressStreet2" - :maxlength="maxLengths.address_street2 || null"> - </b-input> + :maxlength="maxLengths.address_street2 || null" + expanded /> </b-field> <b-field label="Zipcode"> <b-input v-model.trim="editAddressZipcode" - :maxlength="maxLengths.address_zipcode || null"> - </b-input> + :maxlength="maxLengths.address_zipcode || null" + expanded /> </b-field> <b-field grouped> @@ -260,7 +297,7 @@ </b-button> </footer> </div> - </b-modal> + </${b}-modal> % endif </div> </div> @@ -292,8 +329,13 @@ Add Phone </b-button> </div> - <b-modal has-modal-card - :active.sync="editPhoneShowDialog"> + <${b}-modal has-modal-card + % if request.use_oruga: + v-model:active="editPhoneShowDialog" + % else: + :active.sync="editPhoneShowDialog" + % endif + > <div class="modal-card"> <header class="modal-card-head"> @@ -303,23 +345,21 @@ </header> <section class="modal-card-body"> - <b-field grouped> - <b-field label="Type" expanded> - <b-select v-model="editPhoneType" expanded> - <option v-for="option in phoneTypeOptions" - :key="option.value" - :value="option.value"> - {{ option.label }} - </option> - </b-select> - </b-field> + <b-field label="Type"> + <b-select v-model="editPhoneType"> + <option v-for="option in phoneTypeOptions" + :key="option.value" + :value="option.value"> + {{ option.label }} + </option> + </b-select> + </b-field> - <b-field label="Number" expanded> - <b-input v-model.trim="editPhoneNumber" - ref="editPhoneInput"> - </b-input> - </b-field> + <b-field label="Number"> + <b-input v-model.trim="editPhoneNumber" + ref="editPhoneInput" + expanded /> </b-field> <b-field label="Preferred?"> @@ -342,51 +382,154 @@ </b-button> </footer> </div> - </b-modal> + </${b}-modal> % endif - <b-table :data="person.phones"> + <${b}-table :data="person.phones"> - <b-table-column field="preference" + <${b}-table-column field="preference" label="Preferred" v-slot="props"> {{ props.row.preferred ? "Yes" : "" }} - </b-table-column> + </${b}-table-column> - <b-table-column field="type" + <${b}-table-column field="type" label="Type" v-slot="props"> {{ props.row.type }} - </b-table-column> + </${b}-table-column> - <b-table-column field="number" + <${b}-table-column field="number" label="Number" v-slot="props"> {{ props.row.number }} - </b-table-column> + </${b}-table-column> % if request.has_perm('people_profile.edit_person'): - <b-table-column label="Actions" + <${b}-table-column label="Actions" v-slot="props"> - <a href="#" @click.prevent="editPhoneInit(props.row)"> - <i class="fas fa-edit"></i> - Edit + <a href="#" @click.prevent="editPhoneInit(props.row)" + % if not request.use_oruga: + class="grid-action" + % endif + > + % if request.use_oruga: + <span class="icon-text"> + <o-icon icon="edit" /> + <span>Edit</span> + </span> + % else: + <i class="fas fa-edit"></i> + Edit + % endif </a> <a href="#" @click.prevent="deletePhoneInit(props.row)" - class="has-text-danger"> - <i class="fas fa-trash"></i> - Delete + % if request.use_oruga: + class="has-text-danger" + % else: + class="grid-action has-text-danger" + % endif + > + % if request.use_oruga: + <span class="icon-text"> + <o-icon icon="trash" /> + <span>Delete</span> + </span> + % else: + <i class="fas fa-trash"></i> + Delete + % endif </a> - <a href="#" @click.prevent="preferPhoneInit(props.row)" - v-if="!props.row.preferred"> - <i class="fas fa-star"></i> - Set Preferred + <a v-if="!props.row.preferred" + href="#" @click.prevent="preferPhoneInit(props.row)" + % if not request.use_oruga: + class="grid-action" + % endif + > + % if request.use_oruga: + <span class="icon-text"> + <o-icon icon="star" /> + <span>Set Preferred</span> + </span> + % else: + <i class="fas fa-star"></i> + Set Preferred + % endif </a> - </b-table-column> + </${b}-table-column> % endif - </b-table> + </${b}-table> + % if request.has_perm('people_profile.edit_person'): + + <${b}-modal has-modal-card + % if request.use_oruga: + v-model:active="deletePhoneShowDialog" + % else: + :active.sync="deletePhoneShowDialog" + % endif + > + <div class="modal-card"> + + <header class="modal-card-head"> + <p class="modal-card-title">Delete Phone</p> + </header> + + <section class="modal-card-body"> + <p class="block">Really delete this phone number?</p> + <p class="block has-text-weight-bold">{{ deletePhoneNumber }}</p> + </section> + + <footer class="modal-card-foot"> + <b-button type="is-danger" + @click="deletePhoneSave()" + :disabled="deletePhoneSaving" + icon-pack="fas" + icon-left="trash"> + {{ deletePhoneSaving ? "Working, please wait..." : "Delete" }} + </b-button> + <b-button @click="deletePhoneShowDialog = false"> + Cancel + </b-button> + </footer> + </div> + </${b}-modal> + + <${b}-modal has-modal-card + % if request.use_oruga: + v-model:active="preferPhoneShowDialog" + % else: + :active.sync="preferPhoneShowDialog" + % endif + > + <div class="modal-card"> + + <header class="modal-card-head"> + <p class="modal-card-title">Set Preferred Phone</p> + </header> + + <section class="modal-card-body"> + <p class="block">Really make this the preferred phone number?</p> + <p class="block has-text-weight-bold">{{ preferPhoneNumber }}</p> + </section> + + <footer class="modal-card-foot"> + <b-button type="is-primary" + @click="preferPhoneSave()" + :disabled="preferPhoneSaving" + icon-pack="fas" + icon-left="save"> + {{ preferPhoneSaving ? "Working, please wait..." : "Set Preferred" }} + </b-button> + <b-button @click="preferPhoneShowDialog = false"> + Cancel + </b-button> + </footer> + </div> + </${b}-modal> + + % endif </div> </div> </div> @@ -409,8 +552,13 @@ Add Email </b-button> </div> - <b-modal has-modal-card - :active.sync="editEmailShowDialog"> + <${b}-modal has-modal-card + % if request.use_oruga: + v-model:active="editEmailShowDialog" + % else: + :active.sync="editEmailShowDialog" + % endif + > <div class="modal-card"> <header class="modal-card-head"> @@ -420,24 +568,21 @@ </header> <section class="modal-card-body"> - <b-field grouped> - <b-field label="Type" expanded> - <b-select v-model="editEmailType" expanded> - <option v-for="option in emailTypeOptions" - :key="option.value" - :value="option.value"> - {{ option.label }} - </option> - </b-select> - </b-field> - - <b-field label="Address" expanded> - <b-input v-model.trim="editEmailAddress" - ref="editEmailInput"> - </b-input> - </b-field> + <b-field label="Type"> + <b-select v-model="editEmailType"> + <option v-for="option in emailTypeOptions" + :key="option.value" + :value="option.value"> + {{ option.label }} + </option> + </b-select> + </b-field> + <b-field label="Address"> + <b-input v-model.trim="editEmailAddress" + ref="editEmailInput" + expanded /> </b-field> <b-field v-if="!editEmailUUID" @@ -468,57 +613,159 @@ </b-button> </footer> </div> - </b-modal> + </${b}-modal> % endif - <b-table :data="person.emails"> + <${b}-table :data="person.emails"> - <b-table-column field="preference" + <${b}-table-column field="preference" label="Preferred" v-slot="props"> {{ props.row.preferred ? "Yes" : "" }} - </b-table-column> + </${b}-table-column> - <b-table-column field="type" + <${b}-table-column field="type" label="Type" v-slot="props"> {{ props.row.type }} - </b-table-column> + </${b}-table-column> - <b-table-column field="address" + <${b}-table-column field="address" label="Address" v-slot="props"> {{ props.row.address }} - </b-table-column> + </${b}-table-column> - <b-table-column field="invalid" + <${b}-table-column field="invalid" label="Invalid?" v-slot="props"> <span v-if="props.row.invalid" class="has-text-danger has-text-weight-bold">Invalid</span> - </b-table-column> + </${b}-table-column> % if request.has_perm('people_profile.edit_person'): - <b-table-column label="Actions" + <${b}-table-column label="Actions" v-slot="props"> - <a href="#" @click.prevent="editEmailInit(props.row)"> - <i class="fas fa-edit"></i> - Edit + <a href="#" @click.prevent="editEmailInit(props.row)" + % if not request.use_oruga: + class="grid-action" + % endif + > + % if request.use_oruga: + <span class="icon-text"> + <o-icon icon="edit" /> + <span>Edit</span> + </span> + % else: + <i class="fas fa-edit"></i> + Edit + % endif </a> <a href="#" @click.prevent="deleteEmailInit(props.row)" - class="has-text-danger"> - <i class="fas fa-trash"></i> - Delete + % if request.use_oruga: + class="has-text-danger" + % else: + class="grid-action has-text-danger" + % endif + > + % if request.use_oruga: + <span class="icon-text"> + <o-icon icon="trash" /> + <span>Delete</span> + </span> + % else: + <i class="fas fa-trash"></i> + Delete + % endif </a> - <a href="#" @click.prevent="preferEmailInit(props.row)" - v-if="!props.row.preferred"> - <i class="fas fa-star"></i> - Set Preferred + <a v-if="!props.row.preferred" + % if not request.use_oruga: + class="grid-action" + % endif + href="#" @click.prevent="preferEmailInit(props.row)"> + % if request.use_oruga: + <span class="icon-text"> + <o-icon icon="star" /> + <span>Set Preferred</span> + </span> + % else: + <i class="fas fa-star"></i> + Set Preferred + % endif </a> - </b-table-column> + </${b}-table-column> % endif - </b-table> + </${b}-table> + % if request.has_perm('people_profile.edit_person'): + + <${b}-modal has-modal-card + % if request.use_oruga: + v-model:active="deleteEmailShowDialog" + % else: + :active.sync="deleteEmailShowDialog" + % endif + > + <div class="modal-card"> + + <header class="modal-card-head"> + <p class="modal-card-title">Delete Email</p> + </header> + + <section class="modal-card-body"> + <p class="block">Really delete this email address?</p> + <p class="block has-text-weight-bold">{{ deleteEmailAddress }}</p> + </section> + + <footer class="modal-card-foot"> + <b-button type="is-danger" + @click="deleteEmailSave()" + :disabled="deleteEmailSaving" + icon-pack="fas" + icon-left="trash"> + {{ deleteEmailSaving ? "Working, please wait..." : "Delete" }} + </b-button> + <b-button @click="deleteEmailShowDialog = false"> + Cancel + </b-button> + </footer> + </div> + </${b}-modal> + + <${b}-modal has-modal-card + % if request.use_oruga: + v-model:active="preferEmailShowDialog" + % else: + :active.sync="preferEmailShowDialog" + % endif + > + <div class="modal-card"> + + <header class="modal-card-head"> + <p class="modal-card-title">Set Preferred Email</p> + </header> + + <section class="modal-card-body"> + <p class="block">Really make this the preferred email address?</p> + <p class="block has-text-weight-bold">{{ preferEmailAddress }}</p> + </section> + + <footer class="modal-card-foot"> + <b-button type="is-primary" + @click="preferEmailSave()" + :disabled="preferEmailSaving" + icon-pack="fas" + icon-left="save"> + {{ preferEmailSaving ? "Working, please wait..." : "Set Preferred" }} + </b-button> + <b-button @click="preferEmailShowDialog = false"> + Cancel + </b-button> + </footer> + </div> + </${b}-modal> + + % endif </div> </div> </div> @@ -546,16 +793,22 @@ </b-button> % endif </div> - <b-loading :active.sync="refreshingTab" :is-full-page="false"></b-loading> + % if request.use_oruga: + <o-loading v-model:active="refreshingTab" :full-page="false"></o-loading> + % else: + <b-loading :active.sync="refreshingTab" :is-full-page="false"></b-loading> + % endif </div> </script> </%def> <%def name="render_personal_tab()"> - <b-tab-item label="Personal" - value="personal" - icon-pack="fas" - :icon="tabchecks.personal ? 'check' : null"> + <${b}-tab-item label="Personal" + value="personal" + % if not request.use_oruga: + icon-pack="fas" + % endif + :icon="tabchecks.personal ? 'check' : null"> <personal-tab ref="tab_personal" :person="person" @profile-changed="profileChanged" @@ -563,9 +816,10 @@ :email-type-options="emailTypeOptions" :max-lengths="maxLengths"> </personal-tab> - </b-tab-item> + </${b}-tab-item> </%def> +% if expose_members: <%def name="render_member_tab_template()"> <script type="text/x-template" id="member-tab-template"> <div> @@ -583,20 +837,35 @@ </div> <br /> - <b-collapse v-for="member in members" - :key="member.uuid" - class="panel" - :open="members.length == 1"> + <${b}-collapse v-for="member in members" + :key="member.uuid" + class="panel" + :open="members.length == 1"> - <div slot="trigger" - slot-scope="props" - class="panel-heading" - role="button"> - <b-icon pack="fas" - icon="caret-right"> - </b-icon> - <strong>{{ member._key }} - {{ member.display }}</strong> - </div> + <template #trigger="props"> + <div class="panel-heading" + role="button" + style="cursor: pointer;"> + + ## TODO: for some reason buefy will "reuse" the icon + ## element in such a way that its display does not + ## refresh. so to work around that, we use different + ## structure for the two icons, so buefy is forced to + ## re-draw + + <b-icon v-if="props.open" + pack="fas" + icon="caret-down" /> + + <span v-if="!props.open"> + <b-icon pack="fas" + icon="caret-right" /> + </span> + + + <strong>{{ member._key }} - {{ member.display }}</strong> + </div> + </template> <div class="panel-block"> <div style="display: flex; justify-content: space-between; width: 100%;"> @@ -664,7 +933,7 @@ </div> </div> </div> - </b-collapse> + </${b}-collapse> </div> <div v-if="!members.length"> @@ -672,13 +941,17 @@ </div> % endif - <b-loading :active.sync="refreshingTab" :is-full-page="false"></b-loading> + % if request.use_oruga: + <o-loading v-model:active="refreshingTab" :full-page="false"></o-loading> + % else: + <b-loading :active.sync="refreshingTab" :is-full-page="false"></b-loading> + % endif </div> </script> </%def> <%def name="render_member_tab()"> - <b-tab-item label="Member" + <${b}-tab-item label="Member" value="member" icon-pack="fas" :icon="tabchecks.member ? 'check' : null"> @@ -687,8 +960,9 @@ @profile-changed="profileChanged" :phone-type-options="phoneTypeOptions"> </member-tab> - </b-tab-item> + </${b}-tab-item> </%def> +% endif <%def name="render_customer_tab_template()"> <script type="text/x-template" id="customer-tab-template"> @@ -700,26 +974,41 @@ </div> <br /> - <b-collapse v-for="customer in customers" - :key="customer.uuid" - class="panel" - :open="customers.length == 1"> + <${b}-collapse v-for="customer in customers" + :key="customer.uuid" + class="panel" + :open="customers.length == 1"> - <div slot="trigger" - slot-scope="props" - class="panel-heading" - role="button"> - <b-icon pack="fas" - icon="caret-right"> - </b-icon> - <strong>{{ customer._key }} - {{ customer.name }}</strong> - </div> + <template #trigger="props"> + <div class="panel-heading" + role="button" + style="cursor: pointer;"> + + ## TODO: for some reason buefy will "reuse" the icon + ## element in such a way that its display does not + ## refresh. so to work around that, we use different + ## structure for the two icons, so buefy is forced to + ## re-draw + + <b-icon v-if="props.open" + pack="fas" + icon="caret-down" /> + + <span v-if="!props.open"> + <b-icon pack="fas" + icon="caret-right" /> + </span> + + + <strong>{{ customer._key }} - {{ customer.name }}</strong> + </div> + </template> <div class="panel-block"> <div style="display: flex; justify-content: space-between; width: 100%;"> <div style="flex-grow: 1;"> - <b-field horizontal label="${customer_key_label}"> + <b-field horizontal label="${customer_key_label or 'TODO: Customer Key'}"> {{ customer._key }} </b-field> @@ -788,19 +1077,23 @@ </div> </div> </div> - </b-collapse> + </${b}-collapse> </div> <div v-if="!customers.length"> <p>{{ person.display_name }} does not have a customer account.</p> </div> - <b-loading :active.sync="refreshingTab" :is-full-page="false"></b-loading> + % if request.use_oruga: + <o-loading v-model:active="refreshingTab" :full-page="false"></o-loading> + % else: + <b-loading :active.sync="refreshingTab" :is-full-page="false"></b-loading> + % endif </div> </script> </%def> <%def name="render_customer_tab()"> - <b-tab-item label="Customer" + <${b}-tab-item label="Customer" value="customer" icon-pack="fas" :icon="tabchecks.customer ? 'check' : null"> @@ -808,7 +1101,7 @@ :person="person" @profile-changed="profileChanged"> </customer-tab> - </b-tab-item> + </${b}-tab-item> </%def> <%def name="render_shopper_tab_template()"> @@ -870,13 +1163,17 @@ <div v-if="!shoppers.length"> <p>{{ person.display_name }} is not a shopper.</p> </div> - <b-loading :active.sync="refreshingTab" :is-full-page="false"></b-loading> + % if request.use_oruga: + <o-loading v-model:active="refreshingTab" :full-page="false"></o-loading> + % else: + <b-loading :active.sync="refreshingTab" :is-full-page="false"></b-loading> + % endif </div> </script> </%def> <%def name="render_shopper_tab()"> - <b-tab-item label="Shopper" + <${b}-tab-item label="Shopper" value="shopper" icon-pack="fas" :icon="tabchecks.shopper ? 'check' : null"> @@ -884,7 +1181,7 @@ :person="person" @profile-changed="profileChanged"> </shopper-tab> - </b-tab-item> + </${b}-tab-item> </%def> <%def name="render_employee_tab_template()"> @@ -896,96 +1193,110 @@ <div v-if="employee.uuid"> - <b-field horizontal label="Employee ID"> - <div class="level"> - <div class="level-left"> - <div class="level-item"> - <span>{{ employee.id }}</span> + <div :key="refreshEmployeeCard"> + <b-field horizontal label="Employee ID"> + <div class="level"> + <div class="level-left"> + <div class="level-item"> + <span>{{ employee.id }}</span> + </div> + % if request.has_perm('employees.edit'): + <div class="level-item"> + <b-button type="is-primary" + icon-pack="fas" + icon-left="edit" + @click="editEmployeeIdInit()"> + Edit ID + </b-button> + <${b}-modal has-modal-card + % if request.use_oruga: + v-model:active="editEmployeeIdShowDialog" + % else: + :active.sync="editEmployeeIdShowDialog" + % endif + > + <div class="modal-card"> + + <header class="modal-card-head"> + <p class="modal-card-title">Employee ID</p> + </header> + + <section class="modal-card-body"> + <b-field label="Employee ID"> + <b-input v-model="editEmployeeIdValue"></b-input> + </b-field> + </section> + + <footer class="modal-card-foot"> + <b-button @click="editEmployeeIdShowDialog = false"> + Cancel + </b-button> + <b-button type="is-primary" + icon-pack="fas" + icon-left="save" + :disabled="editEmployeeIdSaving" + @click="editEmployeeIdSave()"> + {{ editEmployeeIdSaving ? "Working, please wait..." : "Save" }} + </b-button> + </footer> + </div> + </${b}-modal> + </div> + % endif </div> - % if request.has_perm('employees.edit'): - <div class="level-item"> - <b-button type="is-primary" - icon-pack="fas" - icon-left="edit" - @click="editEmployeeIdInit()"> - Edit ID - </b-button> - <b-modal has-modal-card - :active.sync="editEmployeeIdShowDialog"> - <div class="modal-card"> - - <header class="modal-card-head"> - <p class="modal-card-title">Employee ID</p> - </header> - - <section class="modal-card-body"> - <b-field label="Employee ID"> - <b-input v-model="editEmployeeIdValue"></b-input> - </b-field> - </section> - - <footer class="modal-card-foot"> - <b-button @click="editEmployeeIdShowDialog = false"> - Cancel - </b-button> - <b-button type="is-primary" - icon-pack="fas" - icon-left="save" - :disabled="editEmployeeIdSaving" - @click="editEmployeeIdSave()"> - {{ editEmployeeIdSaving ? "Working, please wait..." : "Save" }} - </b-button> - </footer> - </div> - </b-modal> - </div> - % endif </div> - </div> - </b-field> + </b-field> - <b-field horizontal label="Employee Status"> - <span>{{ employee.status_display }}</span> - </b-field> + <b-field horizontal label="Employee Status"> + <span>{{ employee.status_display }}</span> + </b-field> - <b-field horizontal label="Start Date"> - <span>{{ employee.start_date }}</span> - </b-field> + <b-field horizontal label="Start Date"> + <span>{{ employee.start_date }}</span> + </b-field> - <b-field horizontal label="End Date"> - <span>{{ employee.end_date }}</span> - </b-field> + <b-field horizontal label="End Date"> + <span>{{ employee.end_date }}</span> + </b-field> + </div> <br /> <p><strong>Employee History</strong></p> <br /> - <b-table :data="employeeHistory"> + <${b}-table :data="employeeHistory"> - <b-table-column field="start_date" + <${b}-table-column field="start_date" label="Start Date" v-slot="props"> {{ props.row.start_date }} - </b-table-column> + </${b}-table-column> - <b-table-column field="end_date" + <${b}-table-column field="end_date" label="End Date" v-slot="props"> {{ props.row.end_date }} - </b-table-column> + </${b}-table-column> % if request.has_perm('people_profile.edit_employee_history'): - <b-table-column field="actions" + <${b}-table-column field="actions" label="Actions" v-slot="props"> <a href="#" @click.prevent="editEmployeeHistoryInit(props.row)"> - <i class="fas fa-edit"></i> - Edit + % if request.use_oruga: + <span class="icon-text"> + <o-icon icon="edit" /> + <span>Edit</span> + </span> + % else: + <i class="fas fa-edit"></i> + Edit + % endif </a> - </b-table-column> + </${b}-table-column> % endif - </b-table> + </${b}-table> </div> @@ -995,119 +1306,151 @@ </div> - <div> - <div class="buttons"> + <div style="display: flex; gap: 0.75rem;"> - % if request.has_perm('people_profile.toggle_employee'): + % if request.has_perm('people_profile.toggle_employee'): - <b-button v-if="!employee.current" - type="is-primary" - @click="startEmployeeInit()"> - ${person} is now an Employee - </b-button> + <b-button v-if="!employee.current" + type="is-primary" + @click="startEmployeeInit()"> + ${person} is now an Employee + </b-button> - <b-button v-if="employee.current" - type="is-primary" - @click="stopEmployeeInit()"> - ${person} is no longer an Employee - </b-button> + <b-button v-if="employee.current" + type="is-primary" + @click="stopEmployeeInit()"> + ${person} is no longer an Employee + </b-button> - <b-modal has-modal-card - :active.sync="startEmployeeShowDialog"> - <div class="modal-card"> + <${b}-modal has-modal-card + % if request.use_oruga: + v-model:active="startEmployeeShowDialog" + % else: + :active.sync="startEmployeeShowDialog" + % endif + > + <div class="modal-card"> - <header class="modal-card-head"> - <p class="modal-card-title">Employee Start</p> - </header> + <header class="modal-card-head"> + <p class="modal-card-title">Employee Start</p> + </header> - <section class="modal-card-body"> - <b-field label="Employee Number"> - <b-input v-model="startEmployeeID"></b-input> - </b-field> - <b-field label="Start Date"> - <tailbone-datepicker v-model="startEmployeeStartDate"></tailbone-datepicker> - </b-field> - </section> + <section class="modal-card-body"> + <b-field label="Employee Number"> + <b-input v-model="startEmployeeID"></b-input> + </b-field> + <b-field label="Start Date"> + <tailbone-datepicker v-model="startEmployeeStartDate" + ref="startEmployeeStartDate" /> + </b-field> + </section> - <footer class="modal-card-foot"> - <b-button @click="startEmployeeShowDialog = false"> - Cancel - </b-button> - <once-button type="is-primary" - @click="startEmployeeSave()" - :disabled="!startEmployeeStartDate" - text="Save"> - </once-button> - </footer> - </div> - </b-modal> + <footer class="modal-card-foot"> + <b-button @click="startEmployeeShowDialog = false"> + Cancel + </b-button> + <b-button type="is-primary" + @click="startEmployeeSave()" + :disabled="startEmployeeSaveDisabled" + icon-pack="fas" + icon-left="save"> + {{ startEmployeeSaving ? "Working, please wait..." : "Save" }} + </b-button> + </footer> + </div> + </${b}-modal> - <b-modal has-modal-card - :active.sync="stopEmployeeShowDialog"> - <div class="modal-card"> + <${b}-modal has-modal-card + % if request.use_oruga: + v-model:active="stopEmployeeShowDialog" + % else: + :active.sync="stopEmployeeShowDialog" + % endif + > + <div class="modal-card"> - <header class="modal-card-head"> - <p class="modal-card-title">Employee End</p> - </header> + <header class="modal-card-head"> + <p class="modal-card-title">Employee End</p> + </header> - <section class="modal-card-body"> - <b-field label="End Date" - :type="stopEmployeeEndDate ? null : 'is-danger'"> - <tailbone-datepicker v-model="stopEmployeeEndDate"></tailbone-datepicker> - </b-field> - <b-field label="Revoke Internal App Access"> - <b-checkbox v-model="stopEmployeeRevokeAccess"> - </b-checkbox> - </b-field> - </section> + <section class="modal-card-body"> + <b-field label="End Date" + :type="stopEmployeeEndDate ? null : 'is-danger'"> + <tailbone-datepicker v-model="stopEmployeeEndDate"></tailbone-datepicker> + </b-field> + <b-field label="Revoke Internal App Access"> + <b-checkbox v-model="stopEmployeeRevokeAccess"> + </b-checkbox> + </b-field> + </section> - <footer class="modal-card-foot"> - <b-button @click="stopEmployeeShowDialog = false"> - Cancel - </b-button> - <once-button type="is-primary" - @click="stopEmployeeSave()" - :disabled="!stopEmployeeEndDate" - text="Save"> - </once-button> - </footer> - </div> - </b-modal> - % endif + <footer class="modal-card-foot"> + <b-button @click="stopEmployeeShowDialog = false"> + Cancel + </b-button> + <b-button type="is-primary" + @click="stopEmployeeSave()" + :disabled="stopEmployeeSaveDisabled" + icon-pack="fas" + icon-left="save"> + {{ stopEmployeeSaving ? "Working, please wait..." : "Save" }} + </b-button> + </footer> + </div> + </${b}-modal> + % endif - % if request.has_perm('people_profile.edit_employee_history'): - <b-modal has-modal-card - :active.sync="editEmployeeHistoryShowDialog"> - <div class="modal-card"> + % if request.has_perm('people_profile.edit_employee_history'): + <${b}-modal has-modal-card + % if request.use_oruga: + v-model:active="editEmployeeHistoryShowDialog" + % else: + :active.sync="editEmployeeHistoryShowDialog" + % endif + > + <div class="modal-card"> - <header class="modal-card-head"> - <p class="modal-card-title">Edit Employee History</p> - </header> + <header class="modal-card-head"> + <p class="modal-card-title">Edit Employee History</p> + </header> - <section class="modal-card-body"> - <b-field label="Start Date"> - <tailbone-datepicker v-model="editEmployeeHistoryStartDate"></tailbone-datepicker> - </b-field> - <b-field label="End Date"> - <tailbone-datepicker v-model="editEmployeeHistoryEndDate" - :disabled="!editEmployeeHistoryEndDateRequired"> - </tailbone-datepicker> - </b-field> - </section> + <section class="modal-card-body"> + <b-field label="Start Date"> + <tailbone-datepicker v-model="editEmployeeHistoryStartDate"></tailbone-datepicker> + </b-field> + <b-field label="End Date"> + <tailbone-datepicker v-model="editEmployeeHistoryEndDate" + :disabled="!editEmployeeHistoryEndDateRequired"> + </tailbone-datepicker> + </b-field> + </section> - <footer class="modal-card-foot"> - <b-button @click="editEmployeeHistoryShowDialog = false"> - Cancel - </b-button> - <once-button type="is-primary" - @click="editEmployeeHistorySave()" - :disabled="!editEmployeeHistoryStartDate || (editEmployeeHistoryEndDateRequired && !editEmployeeHistoryEndDate)" - text="Save"> - </once-button> - </footer> - </div> - </b-modal> - % endif + <footer class="modal-card-foot"> + <b-button @click="editEmployeeHistoryShowDialog = false"> + Cancel + </b-button> + <b-button type="is-primary" + @click="editEmployeeHistorySave()" + :disabled="editEmployeeHistorySaveDisabled" + icon-pack="fas" + icon-left="save"> + {{ editEmployeeHistorySaving ? "Working, please wait..." : "Save" }} + </b-button> + </footer> + </div> + </${b}-modal> + % endif + + <div style="display: flex; flex-direction: column; align-items: right; gap: 0.75rem;"> + + <b-button v-for="link in employee.external_links" + :key="link.url" + type="is-primary" + tag="a" :href="link.url" target="_blank" + icon-pack="fas" + icon-left="external-link-alt"> + {{ link.label }} + </b-button> % if request.has_perm('employees.view'): <b-button v-if="employee.view_url" @@ -1120,13 +1463,17 @@ </div> </div> - <b-loading :active.sync="refreshingTab" :is-full-page="false"></b-loading> + % if request.use_oruga: + <o-loading v-model:active="refreshingTab" :full-page="false"></o-loading> + % else: + <b-loading :active.sync="refreshingTab" :is-full-page="false"></b-loading> + % endif </div> </script> </%def> <%def name="render_employee_tab()"> - <b-tab-item label="Employee" + <${b}-tab-item label="Employee" value="employee" icon-pack="fas" :icon="tabchecks.employee ? 'check' : null"> @@ -1134,7 +1481,7 @@ :person="person" @profile-changed="profileChanged"> </employee-tab> - </b-tab-item> + </${b}-tab-item> </%def> <%def name="render_notes_tab_template()"> @@ -1151,62 +1498,82 @@ </b-button> % endif - <b-table :data="notes"> + <${b}-table :data="notes"> - <b-table-column field="note_type" + <${b}-table-column field="note_type" label="Type" v-slot="props"> {{ props.row.note_type_display }} - </b-table-column> + </${b}-table-column> - <b-table-column field="subject" + <${b}-table-column field="subject" label="Subject" v-slot="props"> {{ props.row.subject }} - </b-table-column> + </${b}-table-column> - <b-table-column field="text" + <${b}-table-column field="text" label="Text" v-slot="props"> {{ props.row.text }} - </b-table-column> + </${b}-table-column> - <b-table-column field="created" + <${b}-table-column field="created" label="Created" v-slot="props"> <span v-html="props.row.created_display"></span> - </b-table-column> + </${b}-table-column> - <b-table-column field="created_by" + <${b}-table-column field="created_by" label="Created By" v-slot="props"> {{ props.row.created_by_display }} - </b-table-column> + </${b}-table-column> % if request.has_any_perm('people_profile.edit_note', 'people_profile.delete_note'): - <b-table-column label="Actions" + <${b}-table-column label="Actions" v-slot="props"> % if request.has_perm('people_profile.edit_note'): <a href="#" @click.prevent="editNoteInit(props.row)"> - <i class="fas fa-edit"></i> - Edit + % if request.use_oruga: + <span class="icon-text"> + <o-icon icon="edit" /> + <span>Edit</span> + </span> + % else: + <i class="fas fa-edit"></i> + Edit + % endif </a> % endif % if request.has_perm('people_profile.delete_note'): <a href="#" @click.prevent="deleteNoteInit(props.row)" class="has-text-danger"> - <i class="fas fa-trash"></i> - Delete + % if request.use_oruga: + <span class="icon-text"> + <o-icon icon="trash" /> + <span>Delete</span> + </span> + % else: + <i class="fas fa-trash"></i> + Delete + % endif + </a> % endif - </b-table-column> + </${b}-table-column> % endif - </b-table> + </${b}-table> % if request.has_any_perm('people_profile.add_note', 'people_profile.edit_note', 'people_profile.delete_note'): - <b-modal :active.sync="editNoteShowDialog" - has-modal-card> + <${b}-modal has-modal-card + % if request.use_oruga: + v-model:active="editNoteShowDialog" + % else: + :active.sync="editNoteShowDialog" + % endif + > <div class="modal-card"> @@ -1232,14 +1599,16 @@ <b-field label="Subject"> <b-input v-model.trim="editNoteSubject" - :disabled="editNoteDelete"> + :disabled="editNoteDelete" + expanded> </b-input> </b-field> <b-field label="Text"> <b-input v-model.trim="editNoteText" type="textarea" - :disabled="editNoteDelete"> + :disabled="editNoteDelete" + expanded> </b-input> </b-field> @@ -1265,16 +1634,20 @@ </footer> </div> - </b-modal> + </${b}-modal> % endif - <b-loading :active.sync="refreshingTab" :is-full-page="false"></b-loading> + % if request.use_oruga: + <o-loading v-model:active="refreshingTab" :full-page="false"></o-loading> + % else: + <b-loading :active.sync="refreshingTab" :is-full-page="false"></b-loading> + % endif </div> </script> </%def> <%def name="render_notes_tab()"> - <b-tab-item label="Notes" + <${b}-tab-item label="Notes" value="notes" icon-pack="fas" :icon="tabchecks.notes ? 'check' : null"> @@ -1282,9 +1655,37 @@ :person="person" @profile-changed="profileChanged"> </notes-tab> - </b-tab-item> + </${b}-tab-item> </%def> +% if expose_transactions: + + <%def name="render_transactions_tab_template()"> + <script type="text/x-template" id="transactions-tab-template"> + <div> + <transactions-grid + ref="transactionsGrid" + /> + </div> + </script> + </%def> + + <%def name="render_transactions_tab()"> + <${b}-tab-item label="Transactions" + value="transactions" + % if not request.use_oruga: + icon-pack="fas" + % endif + icon="bars"> + <transactions-tab ref="tab_transactions" + :person="person" + @profile-changed="profileChanged" /> + </${b}-tab-item> + </%def> + +% endif + + <%def name="render_user_tab_template()"> <script type="text/x-template" id="user-tab-template"> <div> @@ -1294,28 +1695,45 @@ <br /> <div id="users-accordion"> - <b-collapse class="panel" - v-for="user in users" - :key="user.uuid"> + <${b}-collapse v-for="user in users" + :key="user.uuid" + class="panel"> - <div slot="trigger" - class="panel-heading" - role="button"> - <strong>{{ user.username }}</strong> - </div> + <template #trigger="props"> + <div class="panel-heading" + role="button" + style="cursor: pointer;"> + + ## TODO: for some reason buefy will "reuse" the icon + ## element in such a way that its display does not + ## refresh. so to work around that, we use different + ## structure for the two icons, so buefy is forced to + ## re-draw + + <b-icon v-if="props.open" + pack="fas" + icon="caret-down" /> + + <span v-if="!props.open"> + <b-icon pack="fas" + icon="caret-right" /> + </span> + + + <strong>{{ user.username }}</strong> + </div> + </template> <div class="panel-block"> <div style="display: flex; justify-content: space-between; width: 100%;"> - <div> - <div class="field-wrapper id"> - <div class="field-row"> - <label>Username</label> - <div class="field"> - {{ user.username }} - </div> - </div> - </div> + <div style="flex-grow: 1;"> + <b-field horizontal label="Username"> + {{ user.username }} + </b-field> + <b-field horizontal label="Active"> + {{ user.active ? "Yes" : "No" }} + </b-field> </div> <div> @@ -1328,20 +1746,77 @@ </div> </div> - </b-collapse> + </${b}-collapse> </div> </div> - <div v-if="!users.length"> + <div v-if="!users.length" + style="display: flex; justify-content: space-between;"> + <p>{{ person.display_name }} does not have a user account.</p> + + % if request.has_perm('users.create'): + <b-button type="primary" + icon-pack="fas" + icon-left="plus" + @click="createUserInit()"> + Create User + </b-button> + + <${b}-modal has-modal-card + % if request.use_oruga: + v-model:active="createUserShowDialog" + % else: + :active.sync="createUserShowDialog" + % endif + > + <div class="modal-card"> + + <header class="modal-card-head"> + <p class="modal-card-title">Create User</p> + </header> + + <section class="modal-card-body"> + <b-field label="Person"> + <span>{{ person.display_name }}</span> + </b-field> + <b-field label="Username"> + <b-input v-model="createUserUsername" + ref="username" /> + </b-field> + <b-field label="Active"> + <b-checkbox v-model="createUserActive" /> + </b-field> + </section> + + <footer class="modal-card-foot"> + <b-button @click="createUserShowDialog = false"> + Cancel + </b-button> + <b-button type="is-primary" + @click="createUserSave()" + :disabled="createUserSaveDisabled" + icon-pack="fas" + icon-left="save"> + {{ createUserSaving ? "Working, please wait..." : "Save" }} + </b-button> + </footer> + </div> + </${b}-modal> + % endif </div> - <b-loading :active.sync="refreshingTab" :is-full-page="false"></b-loading> + + % if request.use_oruga: + <o-loading v-model:active="refreshingTab" :full-page="false"></o-loading> + % else: + <b-loading :active.sync="refreshingTab" :is-full-page="false"></b-loading> + % endif </div> </script> </%def> <%def name="render_user_tab()"> - <b-tab-item label="User" + <${b}-tab-item label="User" value="user" icon-pack="fas" :icon="tabchecks.user ? 'check' : null"> @@ -1349,18 +1824,25 @@ :person="person" @profile-changed="profileChanged"> </user-tab> - </b-tab-item> + </${b}-tab-item> </%def> <%def name="render_profile_tabs()"> ${self.render_personal_tab()} - ${self.render_member_tab()} + + % if expose_members: + ${self.render_member_tab()} + % endif + ${self.render_customer_tab()} % if expose_customer_shoppers: ${self.render_shopper_tab()} % endif ${self.render_employee_tab()} ${self.render_notes_tab()} + % if expose_transactions: + ${self.render_transactions_tab()} + % endif ${self.render_user_tab()} </%def> @@ -1372,23 +1854,35 @@ ${self.render_profile_info_extra_buttons()} - <b-tabs v-model="activeTab" - % if request.has_perm('people_profile.view_versions'): - v-show="!viewingHistory" - % endif - type="is-boxed" - @input="activeTabChanged"> + <${b}-tabs v-model="activeTab" + % if request.has_perm('people_profile.view_versions'): + v-show="!viewingHistory" + % endif + % if request.use_oruga: + type="boxed" + @change="activeTabChanged" + % else: + type="is-boxed" + @input="activeTabChanged" + % endif + > ${self.render_profile_tabs()} - </b-tabs> + </${b}-tabs> % if request.has_perm('people_profile.view_versions'): - ${revisions_grid.render_buefy_table_element(data_prop='revisions', - show_footer=True, - vshow='viewingHistory', - loading='gettingRevisions')|n} + ${revisions_grid.render_table_element(data_prop='revisions', + show_footer=True, + vshow='viewingHistory', + loading='gettingRevisions')|n} - <b-modal :active.sync="showingRevisionDialog"> + <${b}-modal + % if request.use_oruga: + v-model:active="showingRevisionDialog" + % else: + :active.sync="showingRevisionDialog" + % endif + > <div class="card"> <div class="card-content"> @@ -1467,38 +1961,125 @@ </div> </div> - </b-modal> + </${b}-modal> % endif </div> </script> -</%def> + <script> -<%def name="render_this_page_template()"> - ${parent.render_this_page_template()} - ${self.render_personal_tab_template()} - ${self.render_member_tab_template()} - ${self.render_customer_tab_template()} - % if expose_customer_shoppers: - ${self.render_shopper_tab_template()} - % endif - ${self.render_employee_tab_template()} - ${self.render_notes_tab_template()} - ${self.render_user_tab_template()} - ${self.render_profile_info_template()} + let ProfileInfoData = { + activeTab: location.hash ? location.hash.substring(1) : 'personal', + tabchecks: ${json.dumps(tabchecks or {})|n}, + today: '${rattail_app.today()}', + profileLastChanged: Date.now(), + person: ${json.dumps(person_data or {})|n}, + phoneTypeOptions: ${json.dumps(phone_type_options or [])|n}, + emailTypeOptions: ${json.dumps(email_type_options or [])|n}, + maxLengths: ${json.dumps(max_lengths or {})|n}, + + % if request.has_perm('people_profile.view_versions'): + loadingRevisions: false, + showingRevisionDialog: false, + revision: {}, + revisionShowAllFields: false, + % endif + } + + let ProfileInfo = { + template: '#profile-info-template', + props: { + % if request.has_perm('people_profile.view_versions'): + viewingHistory: Boolean, + gettingRevisions: Boolean, + revisions: Array, + revisionVersionMap: null, + % endif + }, + computed: {}, + mounted() { + + // auto-refresh whichever tab is shown first + ## TODO: how to not assume 'personal' is the default tab? + let tab = this.$refs['tab_' + (this.activeTab || 'personal')] + if (tab && tab.refreshTab) { + tab.refreshTab() + } + }, + methods: { + + profileChanged(data) { + this.$emit('change-content-title', data.person.dynamic_content_title) + this.person = data.person + this.tabchecks = data.tabchecks + this.profileLastChanged = Date.now() + }, + + activeTabChanged(value) { + location.hash = value + this.refreshTabIfNeeded(value) + this.activeTabChangedExtra(value) + }, + + refreshTabIfNeeded(key) { + // TODO: this is *always* refreshing, should be more selective (?) + let tab = this.$refs['tab_' + key] + if (tab && tab.refreshIfNeeded) { + tab.refreshIfNeeded(this.profileLastChanged) + } + }, + + activeTabChangedExtra(value) {}, + + % if request.has_perm('people_profile.view_versions'): + + viewRevision(row) { + this.revision = this.revisionVersionMap[row.txnid] + this.showingRevisionDialog = true + }, + + viewPrevRevision() { + let txnid = this.revision.prev_txnid + this.revision = this.revisionVersionMap[txnid] + }, + + viewNextRevision() { + let txnid = this.revision.next_txnid + this.revision = this.revisionVersionMap[txnid] + }, + + toggleVersionFields() { + this.revisionShowAllFields = !this.revisionShowAllFields + }, + + % endif + }, + } + + </script> </%def> <%def name="declare_personal_tab_vars()"> <script type="text/javascript"> let PersonalTabData = { + % if hasattr(master, 'profile_tab_personal'): refreshTabURL: '${url('people.profile_tab_personal', uuid=person.uuid)}', + % endif + + // nb. hack to force refresh for vue3 + refreshPersonalCard: 1, + refreshAddressCard: 1, % if request.has_perm('people_profile.edit_person'): editNameShowDialog: false, editNameFirst: null, + % if use_preferred_first_name: + editNameFirstPreferred: null, + % endif editNameMiddle: null, editNameLast: null, + editNameSaving: false, editAddressShowDialog: false, editAddressStreet1: null, @@ -1515,6 +2096,16 @@ editPhonePreferred: false, editPhoneSaving: false, + deletePhoneShowDialog: false, + deletePhoneUUID: null, + deletePhoneNumber: null, + deletePhoneSaving: false, + + preferPhoneShowDialog: false, + preferPhoneUUID: null, + preferPhoneNumber: null, + preferPhoneSaving: false, + editEmailShowDialog: false, editEmailUUID: null, editEmailType: null, @@ -1522,6 +2113,17 @@ editEmailPreferred: null, editEmailInvalid: false, editEmailSaving: false, + + deleteEmailShowDialog: false, + deleteEmailUUID: null, + deleteEmailAddress: null, + deleteEmailSaving: false, + + preferEmailShowDialog: false, + preferEmailUUID: null, + preferEmailAddress: null, + preferEmailSaving: false, + % endif } @@ -1539,6 +2141,9 @@ % if request.has_perm('people_profile.edit_person'): editNameSaveDisabled: function() { + if (this.editNameSaving) { + return true + } if (!this.editNameFirst || !this.editNameLast) { return true } @@ -1590,15 +2195,22 @@ editNameInit() { this.editNameFirst = this.person.first_name + % if use_preferred_first_name: + this.editNameFirstPreferred = this.person.preferred_first_name + % endif this.editNameMiddle = this.person.middle_name this.editNameLast = this.person.last_name this.editNameShowDialog = true }, editNameSave() { + this.editNameSaving = true let url = '${url('people.profile_edit_name', uuid=person.uuid)}' let params = { first_name: this.editNameFirst, + % if use_preferred_first_name: + preferred_first_name: this.editNameFirstPreferred, + % endif middle_name: this.editNameMiddle, last_name: this.editNameLast, } @@ -1607,6 +2219,11 @@ this.$emit('profile-changed', response.data) this.editNameShowDialog = false this.refreshTab() + this.editNameSaving = false + // nb. hack to force refresh for vue3 + this.refreshPersonalCard += 1 + }, response => { + this.editNameSaving = false }) }, @@ -1636,6 +2253,8 @@ this.$emit('profile-changed', response.data) this.editAddressShowDialog = false this.refreshTab() + // nb. hack to force refresh for vue3 + this.refreshAddressCard += 1 }) }, @@ -1688,26 +2307,47 @@ }, deletePhoneInit(phone) { + this.deletePhoneUUID = phone.uuid + this.deletePhoneNumber = phone.number + this.deletePhoneShowDialog = true + }, + + deletePhoneSave() { + this.deletePhoneSaving = true let url = '${url('people.profile_delete_phone', uuid=person.uuid)}' let params = { - phone_uuid: phone.uuid, + phone_uuid: this.deletePhoneUUID, } - this.simplePOST(url, params, response => { this.$emit('profile-changed', response.data) this.refreshTab() + this.deletePhoneShowDialog = false + this.deletePhoneSaving = false + }, response => { + this.deletePhoneSaving = false }) }, preferPhoneInit(phone) { + this.preferPhoneUUID = phone.uuid + this.preferPhoneNumber = phone.number + this.preferPhoneShowDialog = true + }, + + preferPhoneSave() { + this.preferPhoneSaving = true let url = '${url('people.profile_set_preferred_phone', uuid=person.uuid)}' let params = { - phone_uuid: phone.uuid, + phone_uuid: this.preferPhoneUUID, } this.simplePOST(url, params, response => { this.$emit('profile-changed', response.data) this.refreshTab() + this.preferPhoneShowDialog = false + this.preferPhoneSaving = false + }, response => { + this.preferPhoneSaving = false }) }, @@ -1762,26 +2402,47 @@ }, deleteEmailInit(email) { + this.deleteEmailUUID = email.uuid + this.deleteEmailAddress = email.address + this.deleteEmailShowDialog = true + }, + + deleteEmailSave() { + this.deleteEmailSaving = true let url = '${url('people.profile_delete_email', uuid=person.uuid)}' let params = { - email_uuid: email.uuid, + email_uuid: this.deleteEmailUUID, } - this.simplePOST(url, params, response => { this.$emit('profile-changed', response.data) this.refreshTab() + this.deleteEmailShowDialog = false + this.deleteEmailSaving = false + }, response => { + this.deleteEmailSaving = false }) }, preferEmailInit(email) { + this.preferEmailUUID = email.uuid + this.preferEmailAddress = email.address + this.preferEmailShowDialog = true + }, + + preferEmailSave() { + this.preferEmailSaving = true let url = '${url('people.profile_set_preferred_email', uuid=person.uuid)}' let params = { - email_uuid: email.uuid, + email_uuid: this.preferEmailUUID, } this.simplePOST(url, params, response => { this.$emit('profile-changed', response.data) this.refreshTab() + this.preferEmailShowDialog = false + this.preferEmailSaving = false + }, response => { + this.preferEmailSaving = false }) }, @@ -1798,10 +2459,12 @@ PersonalTab.data = function() { return PersonalTabData } Vue.component('personal-tab', PersonalTab) + <% request.register_component('personal-tab', 'PersonalTab') %> </script> </%def> +% if expose_members: <%def name="declare_member_tab_vars()"> <script type="text/javascript"> @@ -1843,15 +2506,19 @@ MemberTab.data = function() { return MemberTabData } Vue.component('member-tab', MemberTab) + <% request.register_component('member-tab', 'MemberTab') %> </script> </%def> +% endif <%def name="declare_customer_tab_vars()"> <script type="text/javascript"> let CustomerTabData = { + % if hasattr(master, 'profile_tab_customer'): refreshTabURL: '${url('people.profile_tab_customer', uuid=person.uuid)}', + % endif customers: [], } @@ -1879,6 +2546,7 @@ CustomerTab.data = function() { return CustomerTabData } Vue.component('customer-tab', CustomerTab) + <% request.register_component('customer-tab', 'CustomerTab') %> </script> </%def> @@ -1915,6 +2583,7 @@ ShopperTab.data = function() { return ShopperTabData } Vue.component('shopper-tab', ShopperTab) + <% request.register_component('shopper-tab', 'ShopperTab') %> </script> </%def> @@ -1923,10 +2592,15 @@ <script type="text/javascript"> let EmployeeTabData = { + % if hasattr(master, 'profile_tab_employee'): refreshTabURL: '${url('people.profile_tab_employee', uuid=person.uuid)}', + % endif employee: {}, employeeHistory: [], + // nb. hack to force refresh for vue3 + refreshEmployeeCard: 1, + % if request.has_perm('employees.edit'): editEmployeeIdShowDialog: false, editEmployeeIdValue: null, @@ -1937,10 +2611,12 @@ startEmployeeShowDialog: false, startEmployeeID: null, startEmployeeStartDate: null, + startEmployeeSaving: false, stopEmployeeShowDialog: false, stopEmployeeEndDate: null, stopEmployeeRevokeAccess: false, + stopEmployeeSaving: false, % endif % if request.has_perm('people_profile.edit_employee_history'): @@ -1949,6 +2625,7 @@ editEmployeeHistoryStartDate: null, editEmployeeHistoryEndDate: null, editEmployeeHistoryEndDateRequired: false, + editEmployeeHistorySaving: false, % endif } @@ -1958,11 +2635,56 @@ props: { person: Object, }, - computed: {}, + computed: { + + % if request.has_perm('people_profile.toggle_employee'): + + startEmployeeSaveDisabled() { + if (this.startEmployeeSaving) { + return true + } + if (!this.startEmployeeStartDate) { + return true + } + return false + }, + + stopEmployeeSaveDisabled() { + if (this.stopEmployeeSaving) { + return true + } + if (!this.stopEmployeeEndDate) { + return true + } + return false + }, + + % endif + + % if request.has_perm('people_profile.edit_employee_history'): + + editEmployeeHistorySaveDisabled() { + if (this.editEmployeeHistorySaving) { + return true + } + if (!this.editEmployeeHistoryStartDate) { + return true + } + if (this.editEmployeeHistoryEndDateRequired && !this.editEmployeeHistoryEndDate) { + return true + } + return false + }, + + % endif + + }, methods: { refreshTabSuccess(response) { this.employee = response.data.employee + // nb. hack to force refresh for vue3 + this.refreshEmployeeCard += 1 this.employeeHistory = response.data.employee_history }, @@ -1977,7 +2699,7 @@ this.editEmployeeIdSaving = true let url = '${url('people.profile_update_employee_id', uuid=instance.uuid)}' let params = { - 'employee_id': this.editEmployeeIdValue, + 'employee_id': this.editEmployeeIdValue || null, } this.simplePOST(url, params, response => { this.$emit('profile-changed', response.data) @@ -2000,34 +2722,52 @@ }, startEmployeeSave() { - let url = '${url('people.profile_start_employee', uuid=person.uuid)}' - let params = { + this.startEmployeeSaving = true + const url = '${url('people.profile_start_employee', uuid=person.uuid)}' + const params = { id: this.startEmployeeID, - start_date: this.startEmployeeStartDate, + % if request.use_oruga: + start_date: this.$refs.startEmployeeStartDate.formatDate(this.startEmployeeStartDate), + % else: + start_date: this.startEmployeeStartDate, + % endif } this.simplePOST(url, params, response => { this.$emit('profile-changed', response.data) this.startEmployeeShowDialog = false this.refreshTab() + this.startEmployeeSaving = false + }, response => { + this.startEmployeeSaving = false }) }, stopEmployeeInit() { + this.stopEmployeeEndDate = null + this.stopEmployeeRevokeAccess = false this.stopEmployeeShowDialog = true }, stopEmployeeSave() { - let url = '${url('people.profile_end_employee', uuid=person.uuid)}' - let params = { - end_date: this.stopEmployeeEndDate, + this.stopEmployeeSaving = true + const url = '${url('people.profile_end_employee', uuid=person.uuid)}' + const params = { + % if request.use_oruga: + end_date: this.$refs.startEmployeeStartDate.formatDate(this.stopEmployeeEndDate), + % else: + end_date: this.stopEmployeeEndDate, + % endif revoke_access: this.stopEmployeeRevokeAccess, } this.simplePOST(url, params, response => { this.$emit('profile-changed', response.data) this.stopEmployeeShowDialog = false + this.stopEmployeeSaving = false this.refreshTab() + }, response => { + this.stopEmployeeSaving = false }) }, @@ -2044,17 +2784,26 @@ }, editEmployeeHistorySave() { + this.editEmployeeHistorySaving = true let url = '${url('people.profile_edit_employee_history', uuid=person.uuid)}' let params = { uuid: this.editEmployeeHistoryUUID, - start_date: this.editEmployeeHistoryStartDate, - end_date: this.editEmployeeHistoryEndDate, + % if request.use_oruga: + start_date: this.$refs.startEmployeeStartDate.formatDate(this.editEmployeeHistoryStartDate), + end_date: this.$refs.startEmployeeStartDate.formatDate(this.editEmployeeHistoryEndDate), + % else: + start_date: this.editEmployeeHistoryStartDate, + end_date: this.editEmployeeHistoryEndDate, + % endif } this.simplePOST(url, params, response => { this.$emit('profile-changed', response.data) this.editEmployeeHistoryShowDialog = false this.refreshTab() + this.editEmployeeHistorySaving = false + }, response => { + this.editEmployeeHistorySaving = false }) }, @@ -2071,6 +2820,7 @@ EmployeeTab.data = function() { return EmployeeTabData } Vue.component('employee-tab', EmployeeTab) + <% request.register_component('employee-tab', 'EmployeeTab') %> </script> </%def> @@ -2079,7 +2829,9 @@ <script type="text/javascript"> let NotesTabData = { + % if hasattr(master, 'profile_tab_notes'): refreshTabURL: '${url('people.profile_tab_notes', uuid=person.uuid)}', + % endif notes: [], noteTypeOptions: [], @@ -2191,16 +2943,69 @@ NotesTab.data = function() { return NotesTabData } Vue.component('notes-tab', NotesTab) + <% request.register_component('notes-tab', 'NotesTab') %> </script> </%def> +% if expose_transactions: + + <%def name="declare_transactions_tab_vars()"> + <script type="text/javascript"> + + let TransactionsTabData = {} + + let TransactionsTab = { + template: '#transactions-tab-template', + mixins: [TabMixin, SimpleRequestMixin], + props: { + person: Object, + }, + computed: {}, + methods: { + + // nb. we override this completely, just tell the grid to refresh + refreshTab() { + this.refreshingTab = true + this.$refs.transactionsGrid.loadAsyncData(null, () => { + this.refreshed = Date.now() + this.refreshingTab = false + }) + } + }, + } + + </script> + </%def> + + <%def name="make_transactions_tab_component()"> + ${self.declare_transactions_tab_vars()} + <script type="text/javascript"> + + TransactionsTab.data = function() { return TransactionsTabData } + Vue.component('transactions-tab', TransactionsTab) + <% request.register_component('transactions-tab', 'TransactionsTab') %> + + </script> + </%def> + +% endif + <%def name="declare_user_tab_vars()"> <script type="text/javascript"> let UserTabData = { + % if hasattr(master, 'profile_tab_user'): refreshTabURL: '${url('people.profile_tab_user', uuid=person.uuid)}', + % endif users: [], + + % if request.has_perm('users.create'): + createUserShowDialog: false, + createUserUsername: null, + createUserActive: false, + createUserSaving: false, + % endif } let UserTab = { @@ -2209,12 +3014,64 @@ props: { person: Object, }, - computed: {}, + + computed: { + + % if request.has_perm('users.create'): + + createUserSaveDisabled() { + if (this.createUserSaving) { + return true + } + if (!this.createUserUsername) { + return true + } + return false + }, + + % endif + }, + methods: { refreshTabSuccess(response) { this.users = response.data.users + this.createUserSuggestedUsername = response.data.suggested_username }, + + % if request.has_perm('users.create'): + + createUserInit() { + this.createUserUsername = this.createUserSuggestedUsername + this.createUserActive = true + this.createUserShowDialog = true + this.$nextTick(() => { + this.$refs.username.focus() + }) + }, + + createUserSave() { + this.createUserSaving = true + + % if hasattr(master, 'profile_make_user'): + let url = '${master.get_action_url('profile_make_user', instance)}' + % endif + let params = { + username: this.createUserUsername, + active: this.createUserActive, + } + + this.simplePOST(url, params, response => { + this.$emit('profile-changed', response.data) + this.createUserSaving = false + this.createUserShowDialog = false + this.refreshTab() + }, response => { + this.createUserSaving = false + }) + }, + + % endif }, } @@ -2227,117 +3084,51 @@ UserTab.data = function() { return UserTabData } Vue.component('user-tab', UserTab) - - </script> -</%def> - -<%def name="declare_profile_info_vars()"> - <script type="text/javascript"> - - let ProfileInfoData = { - activeTab: location.hash ? location.hash.substring(1) : undefined, - tabchecks: ${json.dumps(tabchecks)|n}, - today: '${rattail_app.today()}', - profileLastChanged: Date.now(), - person: ${json.dumps(person_data)|n}, - phoneTypeOptions: ${json.dumps(phone_type_options)|n}, - emailTypeOptions: ${json.dumps(email_type_options)|n}, - maxLengths: ${json.dumps(max_lengths)|n}, - - % if request.has_perm('people_profile.view_versions'): - loadingRevisions: false, - showingRevisionDialog: false, - revision: {}, - revisionShowAllFields: false, - % endif - } - - let ProfileInfo = { - template: '#profile-info-template', - props: { - % if request.has_perm('people_profile.view_versions'): - viewingHistory: Boolean, - gettingRevisions: Boolean, - revisions: Array, - revisionVersionMap: null, - % endif - }, - computed: {}, - mounted() { - - // auto-refresh whichever tab is shown first - ## TODO: how to not assume 'personal' is the default tab? - let tab = this.$refs['tab_' + (this.activeTab || 'personal')] - if (tab && tab.refreshTab) { - tab.refreshTab() - } - }, - methods: { - - profileChanged(data) { - this.$emit('change-content-title', data.person.dynamic_content_title) - this.person = data.person - this.tabchecks = data.tabchecks - this.profileLastChanged = Date.now() - }, - - activeTabChanged(value) { - location.hash = value - this.refreshTabIfNeeded(value) - this.activeTabChangedExtra(value) - }, - - refreshTabIfNeeded(key) { - // TODO: this is *always* refreshing, should be more selective (?) - let tab = this.$refs['tab_' + key] - if (tab && tab.refreshIfNeeded) { - tab.refreshIfNeeded(this.profileLastChanged) - } - }, - - activeTabChangedExtra(value) {}, - - % if request.has_perm('people_profile.view_versions'): - - viewRevision(row) { - this.revision = this.revisionVersionMap[row.txnid] - this.showingRevisionDialog = true - }, - - viewPrevRevision() { - let txnid = this.revision.prev_txnid - this.revision = this.revisionVersionMap[txnid] - }, - - viewNextRevision() { - let txnid = this.revision.next_txnid - this.revision = this.revisionVersionMap[txnid] - }, - - toggleVersionFields() { - this.revisionShowAllFields = !this.revisionShowAllFields - }, - - % endif - }, - } + <% request.register_component('user-tab', 'UserTab') %> </script> </%def> <%def name="make_profile_info_component()"> - ${self.declare_profile_info_vars()} - <script type="text/javascript"> + ## DEPRECATED; called for back-compat + ${self.declare_profile_info_vars()} + + <script> ProfileInfo.data = function() { return ProfileInfoData } Vue.component('profile-info', ProfileInfo) - + <% request.register_component('profile-info', 'ProfileInfo') %> </script> </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="render_vue_templates()"> + ${parent.render_vue_templates()} + + ${self.render_personal_tab_template()} + + % if expose_members: + ${self.render_member_tab_template()} + % endif + + ${self.render_customer_tab_template()} + % if expose_customer_shoppers: + ${self.render_shopper_tab_template()} + % endif + ${self.render_employee_tab_template()} + ${self.render_notes_tab_template()} + + % if expose_transactions: + ${transactions_grid.render_complete(allow_save_defaults=False)|n} + ${self.render_transactions_tab_template()} + % endif + + ${self.render_user_tab_template()} + ${self.render_profile_info_template()} +</%def> + +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> % if request.has_perm('people_profile.view_versions'): ThisPage.props.viewingHistory = Boolean @@ -2385,28 +3176,8 @@ }, } - </script> -</%def> -<%def name="make_this_page_component()"> - ${parent.make_this_page_component()} - ${self.make_personal_tab_component()} - ${self.make_member_tab_component()} - ${self.make_customer_tab_component()} - % if expose_customer_shoppers: - ${self.make_shopper_tab_component()} - % endif - ${self.make_employee_tab_component()} - ${self.make_notes_tab_component()} - ${self.make_user_tab_component()} - ${self.make_profile_info_component()} -</%def> - -<%def name="modify_whole_page_vars()"> - ${parent.modify_whole_page_vars()} - - % if request.has_perm('people_profile.view_versions'): - <script type="text/javascript"> + % if request.has_perm('people_profile.view_versions'): WholePageData.viewingHistory = false WholePageData.gettingRevisions = false @@ -2442,9 +3213,44 @@ }) } - </script> - % endif + % endif + </script> </%def> +<%def name="make_vue_components()"> + ${parent.make_vue_components()} -${parent.body()} + ${self.make_personal_tab_component()} + + % if expose_members: + ${self.make_member_tab_component()} + % endif + + ${self.make_customer_tab_component()} + % if expose_customer_shoppers: + ${self.make_shopper_tab_component()} + % endif + ${self.make_employee_tab_component()} + ${self.make_notes_tab_component()} + + % if expose_transactions: + <script type="text/javascript"> + + TransactionsGrid.data = function() { return TransactionsGridData } + Vue.component('transactions-grid', TransactionsGrid) + ## TODO: why is this line not needed? + ## <% request.register_component('transactions-grid', 'TransactionsGrid') %> + + </script> + ${self.make_transactions_tab_component()} + % endif + + ${self.make_user_tab_component()} + ${self.make_profile_info_component()} +</%def> + +############################## +## DEPRECATED +############################## + +<%def name="declare_profile_info_vars()"></%def> diff --git a/tailbone/templates/poser/reports/view.mako b/tailbone/templates/poser/reports/view.mako index aac0c7ae..cb8b51aa 100644 --- a/tailbone/templates/poser/reports/view.mako +++ b/tailbone/templates/poser/reports/view.mako @@ -62,19 +62,13 @@ <br /> </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} % if master.has_perm('replace'): - <script type="text/javascript"> - - ${form.component_studly}Data.showUploadForm = false - - ${form.component_studly}Data.uploadFile = null - - ${form.component_studly}Data.uploadSubmitting = false - - </script> + <script> + ${form.vue_component}Data.showUploadForm = false + ${form.vue_component}Data.uploadFile = null + ${form.vue_component}Data.uploadSubmitting = false + </script> % endif </%def> - -${parent.body()} diff --git a/tailbone/templates/poser/setup.mako b/tailbone/templates/poser/setup.mako index 8d01bb33..239e7db2 100644 --- a/tailbone/templates/poser/setup.mako +++ b/tailbone/templates/poser/setup.mako @@ -118,14 +118,9 @@ % endif </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> - +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> ThisPageData.setupSubmitting = false - </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/poser/views/configure.mako b/tailbone/templates/poser/views/configure.mako index f4d75779..cdde15c5 100644 --- a/tailbone/templates/poser/views/configure.mako +++ b/tailbone/templates/poser/views/configure.mako @@ -9,7 +9,7 @@ % for topkey, topgroup in sorted(view_settings.items(), key=lambda itm: 'aaaa' if itm[0] == 'rattail' else itm[0]): <h3 class="block is-size-3">Views for: ${topkey}</h3> - % for group_key, group in six.iteritems(topgroup): + % for group_key, group in topgroup.items(): <h4 class="block is-size-4">${group_key.capitalize()}</h4> % for key, label in group: ${self.simple_flag(key, label)} diff --git a/tailbone/templates/principal/find_by_perm.mako b/tailbone/templates/principal/find_by_perm.mako index e0536324..ddc44e3d 100644 --- a/tailbone/templates/principal/find_by_perm.mako +++ b/tailbone/templates/principal/find_by_perm.mako @@ -10,12 +10,20 @@ </find-principals> </%def> -<%def name="render_this_page_template()"> - ${parent.render_this_page_template()} +<%def name="principal_table()"> + <div + style="width: 50%;" + > + ${grid.render_table_element(data_prop='principalsData')|n} + </div> +</%def> + +<%def name="render_vue_templates()"> + ${parent.render_vue_templates()} <script type="text/x-template" id="find-principals-template"> <div> - ${h.form(request.current_route_url(), method='GET', **{'@submit': 'formSubmitting = true'})} + ${h.form(request.url, method='GET', **{'@submit': 'formSubmitting = true'})} <div style="margin-left: 10rem; max-width: 50%;"> ${h.hidden('permission_group', **{':value': 'selectedGroup'})} @@ -24,13 +32,13 @@ ref="permissionGroupAutocomplete" v-model="permissionGroupTerm" :data="permissionGroupChoices" - field="groupkey" :custom-formatter="filtr => filtr.label" open-on-focus keep-first icon-pack="fas" clearable clear-on-select + expanded @select="permissionGroupSelect"> </b-autocomplete> <b-button v-if="selectedGroup" @@ -45,13 +53,13 @@ ref="permissionAutocomplete" v-model="permissionTerm" :data="permissionChoices" - field="permkey" :custom-formatter="filtr => filtr.label" open-on-focus keep-first icon-pack="fas" clearable clear-on-select + expanded @select="permissionSelect"> </b-autocomplete> <b-button v-if="selectedPermission" @@ -63,7 +71,7 @@ <b-field horizontal> <div class="buttons" style="margin-top: 1rem;"> <once-button tag="a" - href="${request.current_route_url(_query=None)}" + href="${request.path_url}" text="Reset Form"> </once-button> <b-button type="is-primary" @@ -80,32 +88,19 @@ ${h.end_form()} % if principals is not None: - <div class="grid half"> - <br /> - <h2>Found ${len(principals)} ${model_title_plural} with permission: ${selected_permission}</h2> - ${self.principal_table()} - </div> + <br /> + <p class="block"> + Found ${len(principals)} ${model_title_plural} with permission: + <span class="has-text-weight-bold">${selected_permission}</span> + </p> + ${self.principal_table()} % endif </div> </script> -</%def> - -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} <script type="text/javascript"> - ThisPageData.permissionGroups = ${json.dumps(buefy_perms)|n} - ThisPageData.sortedGroups = ${json.dumps(buefy_sorted_groups)|n} - - </script> -</%def> - -<%def name="make_this_page_component()"> - ${parent.make_this_page_component()} - <script type="text/javascript"> - - Vue.component('find-principals', { + const FindPrincipals = { template: '#find-principals-template', props: { permissionGroups: Object, @@ -113,13 +108,14 @@ }, data() { return { - groupPermissions: ${json.dumps(buefy_perms.get(selected_group, {}).get('permissions', []))|n}, + groupPermissions: ${json.dumps(perms_data.get(selected_group, {}).get('permissions', []))|n}, permissionGroupTerm: '', permissionTerm: '', selectedGroup: ${json.dumps(selected_group)|n}, selectedPermission: ${json.dumps(selected_permission)|n}, selectedPermissionLabel: ${json.dumps(selected_permission_label or '')|n}, formSubmitting: false, + principalsData: ${json.dumps(principals_data)|n}, } }, @@ -187,6 +183,10 @@ methods: { + navigateTo(url) { + location.href = url + }, + permissionGroupSelect(option) { this.selectedPermission = null this.selectedPermissionLabel = null @@ -224,10 +224,23 @@ }) }, } - }) + } </script> </%def> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> + ThisPageData.permissionGroups = ${json.dumps(perms_data)|n} + ThisPageData.sortedGroups = ${json.dumps(sorted_groups_data)|n} + </script> +</%def> -${parent.body()} +<%def name="make_vue_components()"> + ${parent.make_vue_components()} + <script> + Vue.component('find-principals', FindPrincipals) + <% request.register_component('find-principals', 'FindPrincipals') %> + </script> +</%def> diff --git a/tailbone/templates/products/batch.mako b/tailbone/templates/products/batch.mako index 81af729b..db029e5a 100644 --- a/tailbone/templates/products/batch.mako +++ b/tailbone/templates/products/batch.mako @@ -22,7 +22,7 @@ </%def> <%def name="render_form_innards()"> - ${h.form(request.current_route_url(), **{'@submit': 'submit{}'.format(form.component_studly)})} + ${h.form(request.current_route_url(), **{'@submit': 'submit{}'.format(form.vue_component)})} ${h.csrf_token(request)} <section> @@ -30,7 +30,7 @@ ${render_deform_field(form, dform['description'])} ${render_deform_field(form, dform['notes'])} - % for key, pform in six.iteritems(params_forms): + % for key, pform in params_forms.items(): <div v-show="field_model_batch_type == '${key}'"> % for field in pform.make_deform_form(): ${render_deform_field(pform, field)} @@ -43,8 +43,8 @@ <div class="buttons"> <b-button type="is-primary" native-type="submit" - :disabled="${form.component_studly}Submitting"> - {{ ${form.component_studly}ButtonText }} + :disabled="${form.vue_component}Submitting"> + {{ ${form.vue_component}ButtonText }} </b-button> <b-button tag="a" href="${url('products')}"> Cancel @@ -54,33 +54,34 @@ ${h.end_form()} </%def> -<%def name="render_form()"> - <script type="text/x-template" id="${form.component}-template"> +<%def name="render_form_template()"> + <script type="text/x-template" id="${form.vue_tagname}-template"> ${self.render_form_innards()} </script> </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <% request.register_component(form.vue_tagname, form.vue_component) %> + <script> - ## TODO: ugh, an awful lot of duplicated code here (from /forms/deform_buefy.mako) + ## TODO: ugh, an awful lot of duplicated code here (from /forms/deform.mako) - let ${form.component_studly} = { - template: '#${form.component}-template', + let ${form.vue_component} = { + template: '#${form.vue_tagname}-template', methods: { ## TODO: deprecate / remove the latter option here % if form.auto_disable_save or form.auto_disable: - submit${form.component_studly}() { - this.${form.component_studly}Submitting = true - this.${form.component_studly}ButtonText = "Working, please wait..." + submit${form.vue_component}() { + this.${form.vue_component}Submitting = true + this.${form.vue_component}ButtonText = "Working, please wait..." } % endif } } - let ${form.component_studly}Data = { + let ${form.vue_component}Data = { ## TODO: ugh, this seems pretty hacky. need to declare some data models ## for various field components to bind to... @@ -95,8 +96,8 @@ ## TODO: deprecate / remove the latter option here % if form.auto_disable_save or form.auto_disable: - ${form.component_studly}Submitting: false, - ${form.component_studly}ButtonText: ${json.dumps(getattr(form, 'submit_label', getattr(form, 'save_label', "Submit")))|n}, + ${form.vue_component}Submitting: false, + ${form.vue_component}ButtonText: ${json.dumps(getattr(form, 'submit_label', getattr(form, 'save_label', "Submit")))|n}, % endif ## TODO: more hackiness, this is for the sake of batch params @@ -114,6 +115,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/products/configure.mako b/tailbone/templates/products/configure.mako index 10f3c0e5..a43a85d4 100644 --- a/tailbone/templates/products/configure.mako +++ b/tailbone/templates/products/configure.mako @@ -41,8 +41,8 @@ <b-input name="rattail.pod.pictures.gtin.root_url" v-model="simpleSettings['rattail.pod.pictures.gtin.root_url']" :disabled="!simpleSettings['tailbone.products.show_pod_image']" - @input="settingsNeedSaved = true"> - </b-input> + @input="settingsNeedSaved = true" + expanded /> </b-field> </div> @@ -95,9 +95,9 @@ </div> </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> ThisPage.methods.getTitleForKey = function(key) { switch (key) { @@ -118,6 +118,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/products/index.mako b/tailbone/templates/products/index.mako index 0d4bc410..5ffa9512 100644 --- a/tailbone/templates/products/index.mako +++ b/tailbone/templates/products/index.mako @@ -36,16 +36,16 @@ </${grid.component}> </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} % if label_profiles and master.has_perm('print_labels'): - <script type="text/javascript"> + <script> - ${grid.component_studly}Data.quickLabelProfile = ${json.dumps(label_profiles[0].uuid)|n} - ${grid.component_studly}Data.quickLabelQuantity = 1 - ${grid.component_studly}Data.quickLabelSpeedbumpThreshold = ${json.dumps(quick_label_speedbump_threshold)|n} + ${grid.vue_component}Data.quickLabelProfile = ${json.dumps(label_profiles[0].uuid)|n} + ${grid.vue_component}Data.quickLabelQuantity = 1 + ${grid.vue_component}Data.quickLabelSpeedbumpThreshold = ${json.dumps(quick_label_speedbump_threshold)|n} - ${grid.component_studly}.methods.quickLabelPrint = function(row) { + ${grid.vue_component}.methods.quickLabelPrint = function(row) { let quantity = parseInt(this.quickLabelQuantity) if (isNaN(quantity)) { @@ -83,6 +83,3 @@ </script> % endif </%def> - - -${parent.body()} diff --git a/tailbone/templates/products/lookup.mako b/tailbone/templates/products/lookup.mako index 4e8c3a8b..bb9590b2 100644 --- a/tailbone/templates/products/lookup.mako +++ b/tailbone/templates/products/lookup.mako @@ -3,21 +3,26 @@ <%def name="tailbone_product_lookup_template()"> <script type="text/x-template" id="tailbone-product-lookup-template"> <div style="width: 100%;"> + <div style="display: flex; gap: 0.5rem;"> - <b-field grouped> - - <b-field :expanded="!product"> - <b-autocomplete ref="productAutocomplete" - v-if="!product" - v-model="autocompleteValue" - placeholder="Enter UPC or brand, description etc." - :data="autocompleteOptions" - field="value" - :custom-formatter="option => option.label" - @typing="getAutocompleteOptions" - @select="autocompleteSelected" - style="width: 100%;"> - </b-autocomplete> + <b-field :style="{'flex-grow': product ? '0' : '1'}"> + <${b}-autocomplete v-if="!product" + ref="productAutocomplete" + v-model="autocompleteValue" + expanded + placeholder="Enter UPC or brand, description etc." + :data="autocompleteOptions" + % if request.use_oruga: + @input="getAutocompleteOptions" + :formatter="option => option.label" + % else: + @typing="getAutocompleteOptions" + :custom-formatter="option => option.label" + field="value" + % endif + @select="autocompleteSelected" + style="width: 100%;"> + </${b}-autocomplete> <b-button v-if="product" @click="clearSelection(true)"> {{ product.full_description }} @@ -42,7 +47,7 @@ View Product </b-button> - </b-field> + </div> <b-modal :active.sync="lookupShowDialog"> <div class="card"> @@ -52,8 +57,10 @@ <b-input v-model="searchTerm" ref="searchTermInput" - @keydown.native="searchTermInputKeydown"> - </b-input> + % if not request.use_oruga: + @keydown.native="searchTermInputKeydown" + % endif + /> <b-button class="control" type="is-primary" @@ -88,88 +95,103 @@ </b-field> - <b-table :data="searchResults" - narrowed - icon-pack="fas" - :loading="searchResultsLoading" - :selected.sync="searchResultSelected"> + <${b}-table :data="searchResults" + narrowed + % if request.use_oruga: + v-model:selected="searchResultSelected" + % else: + :selected.sync="searchResultSelected" + icon-pack="fas" + % endif + :loading="searchResultsLoading"> - <b-table-column label="${request.rattail_config.product_key_title()}" + <${b}-table-column label="${request.rattail_config.product_key_title()}" field="product_key" v-slot="props"> {{ props.row.product_key }} - </b-table-column> + </${b}-table-column> - <b-table-column label="Brand" + <${b}-table-column label="Brand" field="brand_name" v-slot="props"> {{ props.row.brand_name }} - </b-table-column> + </${b}-table-column> - <b-table-column label="Description" + <${b}-table-column label="Description" field="description" v-slot="props"> - {{ props.row.description }} - {{ props.row.size }} - </b-table-column> + <span :class="{organic: props.row.organic}"> + {{ props.row.description }} + {{ props.row.size }} + </span> + </${b}-table-column> - <b-table-column label="Unit Price" + <${b}-table-column label="Unit Price" field="unit_price" v-slot="props"> {{ props.row.unit_price_display }} - </b-table-column> + </${b}-table-column> - <b-table-column label="Sale Price" + <${b}-table-column label="Sale Price" field="sale_price" v-slot="props"> <span class="has-background-warning"> {{ props.row.sale_price_display }} </span> - </b-table-column> + </${b}-table-column> - <b-table-column label="Sale Ends" + <${b}-table-column label="Sale Ends" field="sale_ends" v-slot="props"> <span class="has-background-warning"> {{ props.row.sale_ends_display }} </span> - </b-table-column> + </${b}-table-column> - <b-table-column label="Department" + <${b}-table-column label="Department" field="department_name" v-slot="props"> {{ props.row.department_name }} - </b-table-column> + </${b}-table-column> - <b-table-column label="Vendor" + <${b}-table-column label="Vendor" field="vendor_name" v-slot="props"> {{ props.row.vendor_name }} - </b-table-column> + </${b}-table-column> - <b-table-column label="Actions" + <${b}-table-column label="Actions" v-slot="props"> <a :href="props.row.url" - target="_blank" - class="grid-action"> - <i class="fas fa-external-link-alt"></i> - View + % if not request.use_oruga: + class="grid-action" + % endif + target="_blank"> + % if request.use_oruga: + <span class="icon-text"> + <o-icon icon="external-link-alt" /> + <span>View</span> + </span> + % else: + <i class="fas fa-external-link-alt"></i> + View + % endif </a> - </b-table-column> + </${b}-table-column> - <template slot="empty"> + <template #empty> <div class="content has-text-grey has-text-centered"> <p> <b-icon pack="fas" - icon="fas fa-sad-tear" + icon="sad-tear" size="is-large"> </b-icon> </p> <p>Nothing here.</p> </div> </template> - </b-table> + </${b}-table> <br /> <div class="level"> @@ -226,6 +248,9 @@ searchTerm: null, searchTermLastUsed: null, + % if request.use_oruga: + searchTermInputElement: null, + % endif searchProductKey: true, searchVendorItemCode: true, @@ -239,6 +264,20 @@ searchResultSelected: null, } }, + + % if request.use_oruga: + + mounted() { + this.searchTermInputElement = this.$refs.searchTermInput.$el.querySelector('input') + this.searchTermInputElement.addEventListener('keydown', this.searchTermInputKeydown) + }, + + beforeDestroy() { + this.searchTermInputElement.removeEventListener('keydown', this.searchTermInputKeydown) + }, + + % endif + methods: { focus() { @@ -261,7 +300,12 @@ } }, + ## TODO: add debounce for oruga? + % if request.use_oruga: + getAutocompleteOptions(entry) { + % else: getAutocompleteOptions: debounce(function (entry) { + % endif // since the `@typing` event from buefy component does not // "self-regulate" in any way, we a) use `debounce` above, @@ -280,7 +324,11 @@ this.autocompleteOptions = [] throw error }) + % if request.use_oruga: + }, + % else: }), + % endif autocompleteSelected(option) { this.$emit('selected', { @@ -357,6 +405,7 @@ } Vue.component('tailbone-product-lookup', TailboneProductLookup) + <% request.register_component('tailbone-product-lookup', 'TailboneProductLookup') %> </script> </%def> diff --git a/tailbone/templates/products/pending/view.mako b/tailbone/templates/products/pending/view.mako index 765c8838..72c9c76d 100644 --- a/tailbone/templates/products/pending/view.mako +++ b/tailbone/templates/products/pending/view.mako @@ -2,11 +2,6 @@ <%inherit file="/master/view.mako" /> <%namespace name="product_lookup" file="/products/lookup.mako" /> -<%def name="render_this_page_template()"> - ${parent.render_this_page_template()} - ${product_lookup.tailbone_product_lookup_template()} -</%def> - <%def name="page_content()"> ${parent.page_content()} @@ -67,9 +62,14 @@ % endif </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="render_vue_templates()"> + ${parent.render_vue_templates()} + ${product_lookup.tailbone_product_lookup_template()} +</%def> + +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> % if master.has_perm('ignore_product') and instance.status_code in (enum.PENDING_PRODUCT_STATUS_PENDING, enum.PENDING_PRODUCT_STATUS_READY): @@ -124,10 +124,7 @@ </script> </%def> -<%def name="make_this_page_component()"> - ${parent.make_this_page_component()} +<%def name="make_vue_components()"> + ${parent.make_vue_components()} ${product_lookup.tailbone_product_lookup_component()} </%def> - - -${parent.body()} diff --git a/tailbone/templates/products/view.mako b/tailbone/templates/products/view.mako index 5de6d099..66ca3128 100644 --- a/tailbone/templates/products/view.mako +++ b/tailbone/templates/products/view.mako @@ -4,6 +4,9 @@ <%def name="extra_styles()"> ${parent.extra_styles()} <style type="text/css"> + nav.item-panel { + min-width: 600px; + } #main-product-panel { margin-right: 2em; margin-top: 1em; @@ -22,18 +25,18 @@ </%def> <%def name="left_column()"> - <nav class="panel" id="pricing-panel"> + <nav class="panel item-panel" id="pricing-panel"> <p class="panel-heading">Pricing</p> <div class="panel-block"> - <div> + <div style="width: 100%;"> ${self.render_price_fields(form)} </div> </div> </nav> - <nav class="panel"> + <nav class="panel item-panel"> <p class="panel-heading">Flags</p> <div class="panel-block"> - <div> + <div style="width: 100%;"> ${self.render_flag_fields(form)} </div> </div> @@ -54,10 +57,10 @@ <%def name="extra_main_fields(form)"></%def> <%def name="organization_panel()"> - <nav class="panel"> + <nav class="panel item-panel"> <p class="panel-heading">Organization</p> <div class="panel-block"> - <div> + <div style="width: 100%;"> ${self.render_organization_fields(form)} </div> </div> @@ -93,10 +96,10 @@ </%def> <%def name="movement_panel()"> - <nav class="panel"> + <nav class="panel item-panel"> <p class="panel-heading">Movement</p> <div class="panel-block"> - <div> + <div style="width: 100%;"> ${self.render_movement_fields(form)} </div> </div> @@ -108,11 +111,11 @@ </%def> <%def name="lookup_codes_grid()"> - ${lookup_codes['grid'].render_buefy_table_element(data_prop='lookupCodesData')|n} + ${lookup_codes['grid'].render_table_element(data_prop='lookupCodesData')|n} </%def> <%def name="lookup_codes_panel()"> - <nav class="panel"> + <nav class="panel item-panel"> <p class="panel-heading">Additional Lookup Codes</p> <div class="panel-block"> ${self.lookup_codes_grid()} @@ -121,11 +124,11 @@ </%def> <%def name="sources_grid()"> - ${vendor_sources['grid'].render_buefy_table_element(data_prop='vendorSourcesData')|n} + ${vendor_sources['grid'].render_table_element(data_prop='vendorSourcesData')|n} </%def> <%def name="sources_panel()"> - <nav class="panel"> + <nav class="panel item-panel"> <p class="panel-heading"> Vendor Sources % if request.rattail_config.versioning_enabled() and master.has_perm('versions'): @@ -141,7 +144,7 @@ </%def> <%def name="notes_panel()"> - <nav class="panel"> + <nav class="panel item-panel"> <p class="panel-heading">Notes</p> <div class="panel-block"> <div class="field">${form.render_field_readonly('notes')}</div> @@ -150,7 +153,7 @@ </%def> <%def name="ingredients_panel()"> - <nav class="panel"> + <nav class="panel item-panel"> <p class="panel-heading">Ingredients</p> <div class="panel-block"> ${form.render_field_readonly('ingredients')} @@ -175,7 +178,7 @@ </p> </header> <section class="modal-card-body"> - ${regular_price_history_grid.render_buefy_table_element(data_prop='regularPriceHistoryData', loading='regularPriceHistoryLoading', paginated=True, per_page=10)|n} + ${regular_price_history_grid.render_table_element(data_prop='regularPriceHistoryData', loading='regularPriceHistoryLoading', paginated=True, per_page=10)|n} </section> <footer class="modal-card-foot"> <b-button @click="showingPriceHistory_regular = false"> @@ -194,7 +197,7 @@ </p> </header> <section class="modal-card-body"> - ${current_price_history_grid.render_buefy_table_element(data_prop='currentPriceHistoryData', loading='currentPriceHistoryLoading', paginated=True, per_page=10)|n} + ${current_price_history_grid.render_table_element(data_prop='currentPriceHistoryData', loading='currentPriceHistoryLoading', paginated=True, per_page=10)|n} </section> <footer class="modal-card-foot"> <b-button @click="showingPriceHistory_current = false"> @@ -213,7 +216,7 @@ </p> </header> <section class="modal-card-body"> - ${suggested_price_history_grid.render_buefy_table_element(data_prop='suggestedPriceHistoryData', loading='suggestedPriceHistoryLoading', paginated=True, per_page=10)|n} + ${suggested_price_history_grid.render_table_element(data_prop='suggestedPriceHistoryData', loading='suggestedPriceHistoryLoading', paginated=True, per_page=10)|n} </section> <footer class="modal-card-foot"> <b-button @click="showingPriceHistory_suggested = false"> @@ -232,7 +235,7 @@ </p> </header> <section class="modal-card-body"> - ${cost_history_grid.render_buefy_table_element(data_prop='costHistoryData', loading='costHistoryLoading', paginated=True, per_page=10)|n} + ${cost_history_grid.render_table_element(data_prop='costHistoryData', loading='costHistoryLoading', paginated=True, per_page=10)|n} </section> <footer class="modal-card-foot"> <b-button @click="showingCostHistory = false"> @@ -245,13 +248,13 @@ </%def> <%def name="page_content()"> - <div style="display: flex; flex-direction: column;"> + <div style="display: flex; flex-direction: column;"> - <nav class="panel" id="main-product-panel"> + <nav class="panel item-panel" id="main-product-panel"> <p class="panel-heading">Product</p> <div class="panel-block"> - <div style="display: flex; justify-content: space-between; width: 100%;"> - <div> + <div style="display: flex; gap: 2rem; width: 100%;"> + <div style="flex-grow: 1;"> ${self.render_main_fields(form)} </div> <div> @@ -279,9 +282,9 @@ % endif </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> ThisPageData.vendorSourcesData = ${json.dumps(vendor_sources['data'])|n} ThisPageData.lookupCodesData = ${json.dumps(lookup_codes['data'])|n} @@ -289,7 +292,7 @@ % if request.rattail_config.versioning_enabled() and master.has_perm('versions'): ThisPageData.showingPriceHistory_regular = false - ThisPageData.regularPriceHistoryDataRaw = ${json.dumps(regular_price_history_grid.get_buefy_data()['data'])|n} + ThisPageData.regularPriceHistoryDataRaw = ${json.dumps(regular_price_history_grid.get_table_data()['data'])|n} ThisPageData.regularPriceHistoryLoading = false ThisPage.computed.regularPriceHistoryData = function() { @@ -318,7 +321,7 @@ } ThisPageData.showingPriceHistory_current = false - ThisPageData.currentPriceHistoryDataRaw = ${json.dumps(current_price_history_grid.get_buefy_data()['data'])|n} + ThisPageData.currentPriceHistoryDataRaw = ${json.dumps(current_price_history_grid.get_table_data()['data'])|n} ThisPageData.currentPriceHistoryLoading = false ThisPage.computed.currentPriceHistoryData = function() { @@ -348,7 +351,7 @@ } ThisPageData.showingPriceHistory_suggested = false - ThisPageData.suggestedPriceHistoryDataRaw = ${json.dumps(suggested_price_history_grid.get_buefy_data()['data'])|n} + ThisPageData.suggestedPriceHistoryDataRaw = ${json.dumps(suggested_price_history_grid.get_table_data()['data'])|n} ThisPageData.suggestedPriceHistoryLoading = false ThisPage.computed.suggestedPriceHistoryData = function() { @@ -377,7 +380,7 @@ } ThisPageData.showingCostHistory = false - ThisPageData.costHistoryDataRaw = ${json.dumps(cost_history_grid.get_buefy_data()['data'])|n} + ThisPageData.costHistoryDataRaw = ${json.dumps(cost_history_grid.get_table_data()['data'])|n} ThisPageData.costHistoryLoading = false ThisPage.computed.costHistoryData = function() { @@ -408,6 +411,3 @@ % endif </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/purchases/credits/index.mako b/tailbone/templates/purchases/credits/index.mako index 4248d4ad..94028bdb 100644 --- a/tailbone/templates/purchases/credits/index.mako +++ b/tailbone/templates/purchases/credits/index.mako @@ -59,27 +59,24 @@ </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> - ${grid.component_studly}Data.changeStatusShowDialog = false - ${grid.component_studly}Data.changeStatusOptions = ${json.dumps(status_options)|n} - ${grid.component_studly}Data.changeStatusValue = null - ${grid.component_studly}Data.changeStatusSubmitting = false + ${grid.vue_component}Data.changeStatusShowDialog = false + ${grid.vue_component}Data.changeStatusOptions = ${json.dumps(status_options)|n} + ${grid.vue_component}Data.changeStatusValue = null + ${grid.vue_component}Data.changeStatusSubmitting = false - ${grid.component_studly}.methods.changeStatusInit = function() { + ${grid.vue_component}.methods.changeStatusInit = function() { this.changeStatusValue = null this.changeStatusShowDialog = true } - ${grid.component_studly}.methods.changeStatusSubmit = function() { + ${grid.vue_component}.methods.changeStatusSubmit = function() { this.changeStatusSubmitting = true this.$refs.changeStatusForm.submit() } </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/receiving/configure.mako b/tailbone/templates/receiving/configure.mako index 92003fee..a36dde43 100644 --- a/tailbone/templates/receiving/configure.mako +++ b/tailbone/templates/receiving/configure.mako @@ -69,12 +69,12 @@ <h3 class="block is-size-3">Vendors</h3> <div class="block" style="padding-left: 2rem;"> - <b-field message="If set, user must choose a "supported" vendor; otherwise they may choose "any" vendor."> - <b-checkbox name="rattail.batch.purchase.supported_vendors_only" - v-model="simpleSettings['rattail.batch.purchase.supported_vendors_only']" + <b-field message="If not set, user must choose a "supported" vendor."> + <b-checkbox name="rattail.batch.purchase.allow_receiving_any_vendor" + v-model="simpleSettings['rattail.batch.purchase.allow_receiving_any_vendor']" native-value="true" @input="settingsNeedSaved = true"> - Only allow batch for "supported" vendors + Allow receiving for <span class="has-text-weight-bold">any</span> vendor </b-checkbox> </b-field> @@ -115,6 +115,15 @@ </b-checkbox> </b-field> + <b-field message="NB. Allow Decimal Quantities setting also affects Ordering behavior."> + <b-checkbox name="rattail.batch.purchase.allow_decimal_quantities" + v-model="simpleSettings['rattail.batch.purchase.allow_decimal_quantities']" + native-value="true" + @input="settingsNeedSaved = true"> + Allow Decimal Quantities + </b-checkbox> + </b-field> + <b-field> <b-checkbox name="rattail.batch.purchase.allow_expired_credits" v-model="simpleSettings['rattail.batch.purchase.allow_expired_credits']" diff --git a/tailbone/templates/receiving/declare_credit.mako b/tailbone/templates/receiving/declare_credit.mako index 6224a539..a377e270 100644 --- a/tailbone/templates/receiving/declare_credit.mako +++ b/tailbone/templates/receiving/declare_credit.mako @@ -1,6 +1,5 @@ ## -*- coding: utf-8; -*- <%inherit file="/form.mako" /> -<%namespace file="/forms/util.mako" import="render_buefy_field" /> <%def name="title()">Declare Credit for Row #${row.sequence}</%def> @@ -11,7 +10,7 @@ % endif </%def> -<%def name="render_buefy_form()"> +<%def name="render_form()"> <p class="block"> Please select the "state" of the product, and enter the @@ -31,22 +30,22 @@ if you need to "receive" instead of "convert" the product. </p> - ${parent.render_buefy_form()} + ${parent.render_form()} </%def> -<%def name="buefy_form_body()"> +<%def name="form_body()"> - ${render_buefy_field(dform['credit_type'])} + ${form.render_field_complete('credit_type')} - ${render_buefy_field(dform['quantity'])} + ${form.render_field_complete('quantity')} - ${render_buefy_field(dform['expiration_date'], bfield_kwargs={'v-show': "field_model_credit_type == 'expired'"})} + ${form.render_field_complete('expiration_date', bfield_attrs={'v-show': "field_model_credit_type == 'expired'"})} </%def> -<%def name="render_form()"> - ${form.render_deform(buttons=capture(self.render_form_buttons), form_body=capture(self.buefy_form_body))|n} +<%def name="render_form_template()"> + ${form.render_deform(buttons=capture(self.render_form_buttons), form_body=capture(self.form_body))|n} </%def> diff --git a/tailbone/templates/receiving/receive_row.mako b/tailbone/templates/receiving/receive_row.mako index 7ef95ac4..48dc6755 100644 --- a/tailbone/templates/receiving/receive_row.mako +++ b/tailbone/templates/receiving/receive_row.mako @@ -1,6 +1,5 @@ ## -*- coding: utf-8; -*- <%inherit file="/form.mako" /> -<%namespace file="/forms/util.mako" import="render_buefy_field" /> <%def name="title()">Receive for Row #${row.sequence}</%def> @@ -11,7 +10,7 @@ % endif </%def> -<%def name="render_buefy_form()"> +<%def name="render_form()"> <p class="block"> Please select the "state" of the product, and enter the appropriate @@ -28,22 +27,22 @@ if you need to "convert" some already-received amount, into a credit. </p> - ${parent.render_buefy_form()} + ${parent.render_form()} </%def> -<%def name="buefy_form_body()"> +<%def name="form_body()"> - ${render_buefy_field(dform['mode'])} + ${form.render_field_complete('mode')} - ${render_buefy_field(dform['quantity'])} + ${form.render_field_complete('quantity')} - ${render_buefy_field(dform['expiration_date'], bfield_kwargs={'v-show': "field_model_mode == 'expired'"})} + ${form.render_field_complete('expiration_date', bfield_attrs={'v-show': "field_model_mode == 'expired'"})} </%def> -<%def name="render_form()"> - ${form.render_deform(buttons=capture(self.render_form_buttons), form_body=capture(self.buefy_form_body))|n} +<%def name="render_form_template()"> + ${form.render_deform(buttons=capture(self.render_form_buttons), form_body=capture(self.form_body))|n} </%def> diff --git a/tailbone/templates/receiving/view.mako b/tailbone/templates/receiving/view.mako index 30bfd3a9..710dec4a 100644 --- a/tailbone/templates/receiving/view.mako +++ b/tailbone/templates/receiving/view.mako @@ -27,72 +27,127 @@ <%def name="render_po_vs_invoice_helper()"> % if master.handler.has_purchase_order(batch) and master.handler.has_invoice_file(batch): - <div class="object-helper"> - <h3>PO vs. Invoice</h3> - <div class="object-helper-content"> - ${po_vs_invoice_breakdown_grid} + <nav class="panel"> + <p class="panel-heading">PO vs. Invoice</p> + <div class="panel-block"> + <div style="width: 100%;"> + ${po_vs_invoice_breakdown_grid} + </div> </div> - </div> + </nav> % endif </%def> -<%def name="render_auto_receive_helper()"> - % if master.has_perm('auto_receive') and master.can_auto_receive(batch): +<%def name="render_tools_helper()"> + % if allow_confirm_all_costs or (master.has_perm('auto_receive') and master.can_auto_receive(batch)): + <nav class="panel"> + <p class="panel-heading">Tools</p> + <div class="panel-block"> + <div style="display: flex; flex-direction: column; gap: 0.5rem; width: 100%;"> - <div class="object-helper"> - <h3>Tools</h3> - <div class="object-helper-content"> - <b-button type="is-primary" - @click="autoReceiveShowDialog = true" - icon-pack="fas" - icon-left="check"> - Auto-Receive All Items - </b-button> + % if allow_confirm_all_costs: + <b-button type="is-primary" + icon-pack="fas" + icon-left="check" + @click="confirmAllCostsShowDialog = true"> + Confirm All Costs + </b-button> + <b-modal has-modal-card + :active.sync="confirmAllCostsShowDialog"> + <div class="modal-card"> + + <header class="modal-card-head"> + <p class="modal-card-title">Confirm All Costs</p> + </header> + + <section class="modal-card-body"> + <p class="block"> + You can automatically mark all catalog and invoice + cost amounts as "confirmed" if you wish. + </p> + <p class="block"> + Would you like to do this? + </p> + </section> + + <footer class="modal-card-foot"> + <b-button @click="confirmAllCostsShowDialog = false"> + Cancel + </b-button> + ${h.form(url(f'{route_prefix}.confirm_all_costs', uuid=batch.uuid), **{'@submit': 'confirmAllCostsSubmitting = true'})} + ${h.csrf_token(request)} + <b-button type="is-primary" + native-type="submit" + :disabled="confirmAllCostsSubmitting" + icon-pack="fas" + icon-left="check"> + {{ confirmAllCostsSubmitting ? "Working, please wait..." : "Confirm All" }} + </b-button> + ${h.end_form()} + </footer> + </div> + </b-modal> + % endif + + % if master.has_perm('auto_receive') and master.can_auto_receive(batch): + <b-button type="is-primary" + @click="autoReceiveShowDialog = true" + icon-pack="fas" + icon-left="check"> + Auto-Receive All Items + </b-button> + <b-modal has-modal-card + :active.sync="autoReceiveShowDialog"> + <div class="modal-card"> + + <header class="modal-card-head"> + <p class="modal-card-title">Auto-Receive All Items</p> + </header> + + <section class="modal-card-body"> + <p class="block"> + You can automatically set the "received" quantity to + match the "shipped" quantity for all items, based on + the invoice. + </p> + <p class="block"> + Would you like to do so? + </p> + </section> + + <footer class="modal-card-foot"> + <b-button @click="autoReceiveShowDialog = false"> + Cancel + </b-button> + ${h.form(url('{}.auto_receive'.format(route_prefix), uuid=batch.uuid), **{'@submit': 'autoReceiveSubmitting = true'})} + ${h.csrf_token(request)} + <b-button type="is-primary" + native-type="submit" + :disabled="autoReceiveSubmitting" + icon-pack="fas" + icon-left="check"> + {{ autoReceiveSubmitting ? "Working, please wait..." : "Auto-Receive All Items" }} + </b-button> + ${h.end_form()} + </footer> + </div> + </b-modal> + % endif + </div> </div> - </div> - - <b-modal has-modal-card - :active.sync="autoReceiveShowDialog"> - <div class="modal-card"> - - <header class="modal-card-head"> - <p class="modal-card-title">Auto-Receive All Items</p> - </header> - - <section class="modal-card-body"> - <p class="block"> - You can automatically set the "received" quantity to - match the "shipped" quantity for all items, based on - the invoice. - </p> - <p class="block"> - Would you like to do so? - </p> - </section> - - <footer class="modal-card-foot"> - <b-button @click="autoReceiveShowDialog = false"> - Cancel - </b-button> - ${h.form(url('{}.auto_receive'.format(route_prefix), uuid=batch.uuid), **{'@submit': 'autoReceiveSubmitting = true'})} - ${h.csrf_token(request)} - <b-button type="is-primary" - native-type="submit" - :disabled="autoReceiveSubmitting" - icon-pack="fas" - icon-left="check"> - {{ autoReceiveSubmitting ? "Working, please wait..." : "Auto-Receive All Items" }} - </b-button> - ${h.end_form()} - </footer> - </div> - </b-modal> + </nav> % endif </%def> -<%def name="render_this_page_template()"> - ${parent.render_this_page_template()} +<%def name="object_helpers()"> + ${self.render_status_breakdown()} + ${self.render_po_vs_invoice_helper()} + ${self.render_execute_helper()} + ${self.render_tools_helper()} +</%def> +<%def name="render_vue_templates()"> + ${parent.render_vue_templates()} % if allow_edit_catalog_unit_cost or allow_edit_invoice_unit_cost: <script type="text/x-template" id="receiving-cost-editor-template"> <div> @@ -113,16 +168,16 @@ % endif </%def> -<%def name="object_helpers()"> - ${self.render_status_breakdown()} - ${self.render_po_vs_invoice_helper()} - ${self.render_execute_helper()} - ${self.render_auto_receive_helper()} -</%def> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> + % if allow_confirm_all_costs: + + ThisPageData.confirmAllCostsShowDialog = false + ThisPageData.confirmAllCostsSubmitting = false + + % endif ThisPageData.autoReceiveShowDialog = false ThisPageData.autoReceiveSubmitting = false @@ -262,13 +317,13 @@ % if allow_edit_catalog_unit_cost: - ${rows_grid.component_studly}.methods.catalogUnitCostClicked = function(row) { + ${rows_grid.vue_component}.methods.catalogUnitCostClicked = function(row) { // start edit for clicked cell this.$refs['catalogUnitCost_' + row.uuid].startEdit() } - ${rows_grid.component_studly}.methods.catalogCostConfirmed = function(amount, index) { + ${rows_grid.vue_component}.methods.catalogCostConfirmed = function(amount, index) { // update display to indicate cost was confirmed this.addRowClass(index, 'catalog_cost_confirmed') @@ -297,13 +352,13 @@ % if allow_edit_invoice_unit_cost: - ${rows_grid.component_studly}.methods.invoiceUnitCostClicked = function(row) { + ${rows_grid.vue_component}.methods.invoiceUnitCostClicked = function(row) { // start edit for clicked cell this.$refs['invoiceUnitCost_' + row.uuid].startEdit() } - ${rows_grid.component_studly}.methods.invoiceCostConfirmed = function(amount, index) { + ${rows_grid.vue_component}.methods.invoiceCostConfirmed = function(amount, index) { // update display to indicate cost was confirmed this.addRowClass(index, 'invoice_cost_confirmed') @@ -333,6 +388,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/receiving/view_row.mako b/tailbone/templates/receiving/view_row.mako index 2341cd3e..086754c6 100644 --- a/tailbone/templates/receiving/view_row.mako +++ b/tailbone/templates/receiving/view_row.mako @@ -60,8 +60,12 @@ <nav class="panel"> <p class="panel-heading">Product</p> <div class="panel-block"> - <div style="display: flex;"> - <div> + <div style="display: flex; gap: 1rem;"> + <div style="flex-grow: 1;" + % if request.use_oruga: + class="form-wrapper" + % endif + > ${form.render_field_readonly('item_entry')} % if row.product: ${form.render_field_readonly(product_key_field)} @@ -80,7 +84,7 @@ ${form.render_field_readonly('catalog_unit_cost')} </div> % if image_url: - <div class="is-pulled-right"> + <div> ${h.image(image_url, "Product Image", width=150, height=150)} </div> % endif @@ -157,8 +161,13 @@ </div> - <b-modal has-modal-card - :active.sync="accountForProductShowDialog"> + <${b}-modal has-modal-card + % if request.use_oruga: + v-model:active="accountForProductShowDialog" + % else: + :active.sync="accountForProductShowDialog" + % endif + > <div class="modal-card"> <header class="modal-card-head"> @@ -208,18 +217,26 @@ </b-field> - <div class="level"> - <div class="level-left"> + <div style="display: flex; gap: 0.5rem; align-items: center;"> - <div class="level-item"> - <numeric-input v-model="accountForProductQuantity" - ref="accountForProductQuantityInput"> - </numeric-input> - </div> + <numeric-input v-model="accountForProductQuantity" + ref="accountForProductQuantityInput"> + </numeric-input> - <div class="level-item"> - % if allow_cases: - <b-field> + % if allow_cases: + % if request.use_oruga: + <div> + <o-button label="Units" + :variant="accountForProductUOM == 'units' ? 'primary' : null" + @click="accountForProductUOMClicked('units')" /> + <o-button label="Cases" + :variant="accountForProductUOM == 'cases' ? 'primary' : null" + @click="accountForProductUOMClicked('cases')" /> + </div> + % else: + <b-field + ## TODO: a bit hacky, but otherwise buefy styles throw us off here + style="margin-bottom: 0;"> <b-radio-button v-model="accountForProductUOM" @click.native="accountForProductUOMClicked('units')" native-value="units"> @@ -231,24 +248,17 @@ Cases </b-radio-button> </b-field> - % else: - <b-field> - <input type="hidden" v-model="accountForProductUOM" /> - Units - </b-field> % endif - </div> + <span v-if="accountForProductUOM == 'cases' && accountForProductQuantity"> + = {{ accountForProductTotalUnits }} + </span> - % if allow_cases: - <div class="level-item" - v-if="accountForProductUOM == 'cases' && accountForProductQuantity"> - = {{ accountForProductTotalUnits }} - </div> - % endif + % else: + <input type="hidden" v-model="accountForProductUOM" /> + <span>Units</span> + % endif - </div> </div> - </section> <footer class="modal-card-foot"> @@ -264,10 +274,15 @@ </b-button> </footer> </div> - </b-modal> + </${b}-modal> - <b-modal has-modal-card - :active.sync="declareCreditShowDialog"> + <${b}-modal has-modal-card + % if request.use_oruga: + v-model:active="declareCreditShowDialog" + % else: + :active.sync="declareCreditShowDialog" + % endif + > <div class="modal-card"> <header class="modal-card-head"> @@ -315,47 +330,51 @@ </b-field> - <div class="level"> - <div class="level-left"> + <div style="display: flex; gap: 0.5rem; align-items: center;"> - <div class="level-item"> - <numeric-input v-model="declareCreditQuantity" - ref="declareCreditQuantityInput"> - </numeric-input> - </div> - - <div class="level-item"> - % if allow_cases: - <b-field> - <b-radio-button v-model="declareCreditUOM" - @click.native="declareCreditUOMClicked('units')" - native-value="units"> - Units - </b-radio-button> - <b-radio-button v-model="declareCreditUOM" - @click.native="declareCreditUOMClicked('cases')" - native-value="cases"> - Cases - </b-radio-button> - </b-field> - % else: - <b-field> - <input type="hidden" v-model="declareCreditUOM" /> - Units - </b-field> - % endif - </div> + <numeric-input v-model="declareCreditQuantity" + ref="declareCreditQuantityInput"> + </numeric-input> % if allow_cases: - <div class="level-item" - v-if="declareCreditUOM == 'cases' && declareCreditQuantity"> + + % if request.use_oruga: + <div> + <o-button label="Units" + :variant="declareCreditUOM == 'units' ? 'primary' : null" + @click="declareCreditUOM = 'units'" /> + <o-button label="Cases" + :variant="declareCreditUOM == 'cases' ? 'primary' : null" + @click="declareCreditUOM = 'cases'" /> + </div> + % else: + <b-field + ## TODO: a bit hacky, but otherwise buefy styles throw us off here + style="margin-bottom: 0;"> + <b-radio-button v-model="declareCreditUOM" + @click.native="declareCreditUOMClicked('units')" + native-value="units"> + Units + </b-radio-button> + <b-radio-button v-model="declareCreditUOM" + @click.native="declareCreditUOMClicked('cases')" + native-value="cases"> + Cases + </b-radio-button> + </b-field> + % endif + <span v-if="declareCreditUOM == 'cases' && declareCreditQuantity"> = {{ declareCreditTotalUnits }} - </div> + </span> + + % else: + <b-field> + <input type="hidden" v-model="declareCreditUOM" /> + Units + </b-field> % endif - </div> </div> - </section> <footer class="modal-card-foot"> @@ -371,7 +390,7 @@ </b-button> </footer> </div> - </b-modal> + </${b}-modal> <nav class="panel" > <p class="panel-heading">Credits</p> @@ -429,7 +448,11 @@ <nav class="panel" > <p class="panel-heading">Purchase Order</p> <div class="panel-block"> - <div> + <div + % if request.use_oruga: + class="form-wrapper" + % endif + > ${form.render_field_readonly('po_line_number')} ${form.render_field_readonly('po_unit_cost')} ${form.render_field_readonly('po_case_size')} @@ -443,7 +466,11 @@ <nav class="panel" > <p class="panel-heading">Invoice</p> <div class="panel-block"> - <div> + <div + % if request.use_oruga: + class="form-wrapper" + % endif + > ${form.render_field_readonly('invoice_number')} ${form.render_field_readonly('invoice_line_number')} ${form.render_field_readonly('invoice_unit_cost')} @@ -457,9 +484,9 @@ </div> </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> ## ThisPage.methods.editUnitCost = function() { ## alert("TODO: not yet implemented") @@ -515,6 +542,10 @@ ThisPage.methods.accountForProductUOMClicked = function(uom) { + % if request.use_oruga: + this.accountForProductUOM = uom + % endif + // TODO: this does not seem to work as expected..even though // the code appears to be correct this.$nextTick(() => { @@ -689,6 +720,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/reports/generated/choose.mako b/tailbone/templates/reports/generated/choose.mako index 55cf71dd..0921530c 100644 --- a/tailbone/templates/reports/generated/choose.mako +++ b/tailbone/templates/reports/generated/choose.mako @@ -23,7 +23,7 @@ </style> </%def> -<%def name="render_buefy_form()"> +<%def name="render_form()"> <div class="form"> <p>Please select the type of report you wish to generate.</p> <br /> @@ -53,13 +53,13 @@ % endif </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> - TailboneFormData.reportDescriptions = ${json.dumps(report_descriptions)|n} + ${form.vue_component}Data.reportDescriptions = ${json.dumps(report_descriptions)|n} - TailboneForm.methods.reportTypeChanged = function(reportType) { + ${form.vue_component}.methods.reportTypeChanged = function(reportType) { this.$emit('report-change', this.reportDescriptions[reportType]) } @@ -71,6 +71,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/reports/generated/delete.mako b/tailbone/templates/reports/generated/delete.mako index 0c994ad0..f60a9819 100644 --- a/tailbone/templates/reports/generated/delete.mako +++ b/tailbone/templates/reports/generated/delete.mako @@ -1,16 +1,11 @@ ## -*- coding: utf-8; -*- <%inherit file="/master/delete.mako" /> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> - +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> % if params_data is not Undefined: - ${form.component_studly}Data.paramsData = ${json.dumps(params_data)|n} + ${form.vue_component}Data.paramsData = ${json.dumps(params_data)|n} % endif - </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/reports/generated/generate.mako b/tailbone/templates/reports/generated/generate.mako index 9feb9f83..2b8fa66c 100644 --- a/tailbone/templates/reports/generated/generate.mako +++ b/tailbone/templates/reports/generated/generate.mako @@ -5,7 +5,7 @@ <%def name="content_title()">New Report: ${report.name}</%def> -<%def name="render_buefy_form()"> +<%def name="render_form()"> <div class="form"> <p class="block"> ${report.__doc__} diff --git a/tailbone/templates/reports/generated/view.mako b/tailbone/templates/reports/generated/view.mako index 6260efba..cce6f346 100644 --- a/tailbone/templates/reports/generated/view.mako +++ b/tailbone/templates/reports/generated/view.mako @@ -23,16 +23,11 @@ % endif </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> - +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> % if params_data is not Undefined: - ${form.component_studly}Data.paramsData = ${json.dumps(params_data)|n} + ${form.vue_component}Data.paramsData = ${json.dumps(params_data)|n} % endif - </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/reports/inventory.mako b/tailbone/templates/reports/inventory.mako index 6c6e739f..cc5adc10 100644 --- a/tailbone/templates/reports/inventory.mako +++ b/tailbone/templates/reports/inventory.mako @@ -1,4 +1,4 @@ -## -*- coding: utf-8 -*- +## -*- coding: utf-8; -*- <%inherit file="/page.mako" /> <%def name="title()">Inventory Worksheet</%def> @@ -29,7 +29,8 @@ </b-field> <b-field> - <b-checkbox name="exclude-not-for-sale" :value="true" + <b-checkbox name="exclude-not-for-sale" + v-model="excludeNotForSale" native-value="1"> Exclude items marked "not for sale". </b-checkbox> @@ -47,14 +48,10 @@ ${h.end_form()} </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> - +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> ThisPageData.departments = ${json.dumps([{'uuid': d.uuid, 'name': d.name} for d in departments])|n} - + ThisPageData.excludeNotForSale = true </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/reports/ordering.mako b/tailbone/templates/reports/ordering.mako index 84e9b819..61ccdb16 100644 --- a/tailbone/templates/reports/ordering.mako +++ b/tailbone/templates/reports/ordering.mako @@ -18,35 +18,46 @@ <tailbone-autocomplete v-model="vendorUUID" service-url="${url('vendors.autocomplete')}" name="vendor" - @input="vendorChanged"> + expanded + % if request.use_oruga: + @update:model-value="vendorChanged" + % else: + @input="vendorChanged" + % endif + > </tailbone-autocomplete> </b-field> <b-field label="Departments"> - <b-table v-if="fetchedDepartments" - :data="departments" - narrowed - checkable - :checked-rows.sync="checkedDepartments" - :loading="fetchingDepartments"> + <${b}-table v-if="fetchedDepartments" + :data="departments" + narrowed + checkable + % if request.use_oruga: + v-model:checked-rows="checkedDepartments" + % else: + :checked-rows.sync="checkedDepartments" + % endif + :loading="fetchingDepartments"> - <b-table-column field="number" + <${b}-table-column field="number" label="Number" v-slot="props"> {{ props.row.number }} - </b-table-column> + </${b}-table-column> - <b-table-column field="name" + <${b}-table-column field="name" label="Name" v-slot="props"> {{ props.row.name }} - </b-table-column> + </${b}-table-column> - </b-table> + </${b}-table> </b-field> <b-field> - <b-checkbox name="preferred_only" :value="true" + <b-checkbox name="preferred_only" + v-model="preferredVendorOnly" native-value="1"> Only include products for which this vendor is preferred. </b-checkbox> @@ -70,13 +81,14 @@ <%def name="extra_fields()"></%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> ThisPageData.vendorUUID = null ThisPageData.departments = [] ThisPageData.checkedDepartments = [] + ThisPageData.preferredVendorOnly = true ThisPageData.fetchingDepartments = false ThisPageData.fetchedDepartments = false @@ -115,6 +127,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/reports/problems/view.mako b/tailbone/templates/reports/problems/view.mako index 026c73dc..5cdf2be5 100644 --- a/tailbone/templates/reports/problems/view.mako +++ b/tailbone/templates/reports/problems/view.mako @@ -45,11 +45,10 @@ <b-button @click="runReportShowDialog = false"> Cancel </b-button> - ${h.form(master.get_action_url('execute', instance))} + ${h.form(master.get_action_url('execute', instance), **{'@submit': 'runReportSubmitting = true'})} ${h.csrf_token(request)} <b-button type="is-primary" native-type="submit" - @click="runReportSubmitting = true" :disabled="runReportSubmitting" icon-pack="fas" icon-left="arrow-circle-right"> @@ -62,12 +61,12 @@ % endif </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> % if weekdays_data is not Undefined: - ${form.component_studly}Data.weekdaysData = ${json.dumps(weekdays_data)|n} + ${form.vue_component}Data.weekdaysData = ${json.dumps(weekdays_data)|n} % endif ThisPageData.runReportShowDialog = false @@ -75,6 +74,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/roles/create.mako b/tailbone/templates/roles/create.mako index 625b2675..89dd56c3 100644 --- a/tailbone/templates/roles/create.mako +++ b/tailbone/templates/roles/create.mako @@ -6,15 +6,11 @@ ${h.stylesheet_link(request.static_url('tailbone:static/css/perms.css'))} </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> - +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> // TODO: this variable name should be more dynamic (?) since this is // connected to (and only here b/c of) the permissions field - TailboneFormData.showingPermissionGroup = '' - + ${form.vue_component}Data.showingPermissionGroup = '' </script> </%def> - -${parent.body()} diff --git a/tailbone/templates/roles/edit.mako b/tailbone/templates/roles/edit.mako index 67f63013..e77cca33 100644 --- a/tailbone/templates/roles/edit.mako +++ b/tailbone/templates/roles/edit.mako @@ -6,15 +6,11 @@ ${h.stylesheet_link(request.static_url('tailbone:static/css/perms.css'))} </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> - +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> // TODO: this variable name should be more dynamic (?) since this is // connected to (and only here b/c of) the permissions field - TailboneFormData.showingPermissionGroup = '' - + ${form.vue_component}Data.showingPermissionGroup = '' </script> </%def> - -${parent.body()} diff --git a/tailbone/templates/roles/find_by_perm.mako b/tailbone/templates/roles/find_by_perm.mako deleted file mode 100644 index 8908d12e..00000000 --- a/tailbone/templates/roles/find_by_perm.mako +++ /dev/null @@ -1,21 +0,0 @@ -## -*- coding: utf-8 -*- -<%inherit file="/principal/find_by_perm.mako" /> - -<%def name="principal_table()"> - <table> - <thead> - <tr> - <th>Name</th> - </tr> - </thead> - <tbody> - % for role in principals: - <tr> - <td>${h.link_to(role.name, url('roles.view', uuid=role.uuid))}</td> - </tr> - % endfor - </tbody> - </table> -</%def> - -${parent.body()} diff --git a/tailbone/templates/roles/view.mako b/tailbone/templates/roles/view.mako index 5dcd9408..f5588695 100644 --- a/tailbone/templates/roles/view.mako +++ b/tailbone/templates/roles/view.mako @@ -6,18 +6,16 @@ ${h.stylesheet_link(request.static_url('tailbone:static/css/perms.css'))} </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> % if users_data is not Undefined: - ${form.component_studly}Data.usersData = ${json.dumps(users_data)|n} + ${form.vue_component}Data.usersData = ${json.dumps(users_data)|n} % endif ThisPage.methods.detachPerson = function(url) { - ## TODO: this should require POST, but we will add that once - ## we can assume a Buefy theme is present, to avoid having to - ## implement the logic in old jquery... + ## TODO: this should require POST! but for now we just redirect.. if (confirm("Are you sure you want to detach this person from this customer account?")) { location.href = url } @@ -25,5 +23,3 @@ </script> </%def> - -${parent.body()} diff --git a/tailbone/templates/settings/email/configure.mako b/tailbone/templates/settings/email/configure.mako index ef487809..f9c815c2 100644 --- a/tailbone/templates/settings/email/configure.mako +++ b/tailbone/templates/settings/email/configure.mako @@ -86,9 +86,9 @@ </div> </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> ThisPageData.testRecipient = ${json.dumps(user_email_address)|n} ThisPageData.sendingTest = false @@ -137,6 +137,3 @@ % endif </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/settings/email/index.mako b/tailbone/templates/settings/email/index.mako index 11881285..ab8d6fa4 100644 --- a/tailbone/templates/settings/email/index.mako +++ b/tailbone/templates/settings/email/index.mako @@ -15,10 +15,10 @@ ${parent.render_grid_component()} </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} % if master.has_perm('configure'): - <script type="text/javascript"> + <script> ThisPageData.showEmails = 'available' @@ -26,9 +26,9 @@ this.$refs.grid.showEmails = this.showEmails } - ${grid.component_studly}Data.showEmails = 'available' + ${grid.vue_component}Data.showEmails = 'available' - ${grid.component_studly}.computed.visibleData = function() { + ${grid.vue_component}.computed.visibleData = function() { if (this.showEmails == 'available') { return this.data.filter(email => email.hidden == 'No') @@ -41,23 +41,27 @@ return this.data } - ${grid.component_studly}.methods.renderLabelToggleHidden = function(row) { + ${grid.vue_component}.methods.renderLabelToggleHidden = function(row) { return row.hidden == 'Yes' ? "Un-hide" : "Hide" } - ${grid.component_studly}.methods.toggleHidden = function(row) { + ${grid.vue_component}.methods.toggleHidden = function(row) { let url = '${url('{}.toggle_hidden'.format(route_prefix))}' let params = { key: row.key, - hidden: row.hidden == 'No'? true : false, + hidden: row.hidden == 'No' ? true : false, } this.submitForm(url, params, response => { - row.hidden = params.hidden ? 'Yes' : 'No' + // must update "original" data row, since our row arg + // may just be a proxy and not trigger view refresh + for (let email of this.data) { + if (email.key == row.key) { + email.hidden = params.hidden ? 'Yes' : 'No' + } + } }) } </script> % endif </%def> - -${parent.body()} diff --git a/tailbone/templates/settings/email/view.mako b/tailbone/templates/settings/email/view.mako index 1d292c69..73ad7066 100644 --- a/tailbone/templates/settings/email/view.mako +++ b/tailbone/templates/settings/email/view.mako @@ -1,13 +1,13 @@ ## -*- coding: utf-8; -*- <%inherit file="/master/view.mako" /> -<%def name="render_buefy_form()"> - ${parent.render_buefy_form()} +<%def name="render_form()"> + ${parent.render_form()} <email-preview-tools></email-preview-tools> </%def> -<%def name="render_this_page_template()"> - ${parent.render_this_page_template()} +<%def name="render_vue_templates()"> + ${parent.render_vue_templates()} <script type="text/x-template" id="email-preview-tools-template"> ${h.form(url('email.preview'), **{'@submit': 'submitPreviewForm'})} @@ -72,10 +72,6 @@ ${h.end_form()} </script> -</%def> - -<%def name="make_this_page_component()"> - ${parent.make_this_page_component()} <script type="text/javascript"> const EmailPreviewTools = { @@ -100,10 +96,13 @@ } } - Vue.component('email-preview-tools', EmailPreviewTools) - </script> </%def> - -${parent.body()} +<%def name="make_vue_components()"> + ${parent.make_vue_components()} + <script> + Vue.component('email-preview-tools', EmailPreviewTools) + <% request.register_component('email-preview-tools', 'EmailPreviewTools') %> + </script> +</%def> diff --git a/tailbone/templates/shifts/base.mako b/tailbone/templates/shifts/base.mako index 4bae5ebf..52b48832 100644 --- a/tailbone/templates/shifts/base.mako +++ b/tailbone/templates/shifts/base.mako @@ -57,7 +57,7 @@ <div class="field-wrapper employee"> <label>Employee</label> <div class="field"> - ${dform['employee'].serialize(text=six.text_type(employee), selected_callback='employee_selected')|n} + ${dform['employee'].serialize(text=str(employee), selected_callback='employee_selected')|n} </div> </div> % endif @@ -152,7 +152,7 @@ </tr> </thead> <tbody> - % for emp in sorted(employees, key=six.text_type): + % for emp in sorted(employees, key=str): <tr data-employee-uuid="${emp.uuid}"> <td class="employee"> ## TODO: add link to single employee schedule / timesheet here... diff --git a/tailbone/templates/tables/create.mako b/tailbone/templates/tables/create.mako index 4fc2eb96..34844c5c 100644 --- a/tailbone/templates/tables/create.mako +++ b/tailbone/templates/tables/create.mako @@ -695,9 +695,9 @@ </b-steps> </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> // nb. for warning user they may lose changes if leaving page ThisPageData.dirty = false @@ -983,6 +983,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/tempmon/appliances/view.mako b/tailbone/templates/tempmon/appliances/view.mako index 07a524b8..a55af922 100644 --- a/tailbone/templates/tempmon/appliances/view.mako +++ b/tailbone/templates/tempmon/appliances/view.mako @@ -8,14 +8,9 @@ % endif </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> - - ${form.component_studly}Data.probesData = ${json.dumps(probes_data)|n} - +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> + ${form.vue_component}Data.probesData = ${json.dumps(probes_data)|n} </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/tempmon/clients/view.mako b/tailbone/templates/tempmon/clients/view.mako index cff22fed..434da4c8 100644 --- a/tailbone/templates/tempmon/clients/view.mako +++ b/tailbone/templates/tempmon/clients/view.mako @@ -22,14 +22,9 @@ % endif </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> - - ${form.component_studly}Data.probesData = ${json.dumps(probes_data)|n} - +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> + ${form.vue_component}Data.probesData = ${json.dumps(probes_data)|n} </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/tempmon/dashboard.mako b/tailbone/templates/tempmon/dashboard.mako index 396b0e68..befaf8b4 100644 --- a/tailbone/templates/tempmon/dashboard.mako +++ b/tailbone/templates/tempmon/dashboard.mako @@ -59,9 +59,9 @@ % endif </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> ThisPageData.appliances = ${json.dumps(appliances_data)|n} ThisPageData.applianceUUID = ${json.dumps(appliance.uuid if appliance else None)|n} @@ -118,6 +118,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/tempmon/probes/graph.mako b/tailbone/templates/tempmon/probes/graph.mako index 412f25dd..94a440e0 100644 --- a/tailbone/templates/tempmon/probes/graph.mako +++ b/tailbone/templates/tempmon/probes/graph.mako @@ -66,9 +66,9 @@ <canvas ref="tempchart" width="400" height="150"></canvas> </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> ThisPageData.currentTimeRange = ${json.dumps(current_time_range)|n} ThisPageData.chart = null @@ -128,6 +128,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/themes/butterball/base.mako b/tailbone/templates/themes/butterball/base.mako new file mode 100644 index 00000000..b69eacfb --- /dev/null +++ b/tailbone/templates/themes/butterball/base.mako @@ -0,0 +1,1245 @@ +## -*- coding: utf-8; -*- +<%namespace name="base_meta" file="/base_meta.mako" /> +<%namespace name="page_help" file="/page_help.mako" /> +<%namespace file="/field-components.mako" import="make_field_components" /> +<%namespace file="/formposter.mako" import="declare_formposter_mixin" /> +<%namespace file="/grids/filter-components.mako" import="make_grid_filter_components" /> +<%namespace file="/buefy-components.mako" import="make_buefy_components" /> +<%namespace file="/buefy-plugin.mako" import="make_buefy_plugin" /> +<%namespace file="/http-plugin.mako" import="make_http_plugin" /> +## <%namespace file="/grids/nav.mako" import="grid_index_nav" /> +## <%namespace name="multi_file_upload" file="/multi_file_upload.mako" /> +<!DOCTYPE html> +<html lang="en"> + <head> + <meta http-equiv="content-type" content="text/html; charset=UTF-8" /> + <title>${base_meta.global_title()} » ${capture(self.title)|n}</title> + ${base_meta.favicon()} + ${self.header_core()} + ${self.head_tags()} + </head> + + <body> + <div id="app" style="height: 100%;"> + <whole-page></whole-page> + </div> + + ## TODO: this must come before the self.body() call..but why? + ${declare_formposter_mixin()} + + ## content body from derived/child template + ${self.body()} + + ## Vue app + ${self.render_vue_templates()} + ${self.modify_vue_vars()} + ${self.make_vue_components()} + ${self.make_vue_app()} + </body> +</html> + +<%def name="title()"></%def> + +<%def name="content_title()"> + ${self.title()} +</%def> + +<%def name="header_core()"> + ${self.core_javascript()} + ${self.core_styles()} +</%def> + +<%def name="core_javascript()"> + <script type="importmap"> + { + ## TODO: eventually version / url should be configurable + "imports": { + "vue": "${h.get_liburl(request, 'bb_vue', prefix='tailbone')}", + "@oruga-ui/oruga-next": "${h.get_liburl(request, 'bb_oruga', prefix='tailbone')}", + "@oruga-ui/theme-bulma": "${h.get_liburl(request, 'bb_oruga_bulma', prefix='tailbone')}", + "@fortawesome/fontawesome-svg-core": "${h.get_liburl(request, 'bb_fontawesome_svg_core', prefix='tailbone')}", + "@fortawesome/free-solid-svg-icons": "${h.get_liburl(request, 'bb_free_solid_svg_icons', prefix='tailbone')}", + "@fortawesome/vue-fontawesome": "${h.get_liburl(request, 'bb_vue_fontawesome', prefix='tailbone')}" + } + } + </script> + <script> + // empty stub to avoid errors for older buefy templates + const Vue = { + component(tagname, classname) {}, + } + </script> +</%def> + +<%def name="core_styles()"> + % if user_css: + ${h.stylesheet_link(user_css)} + % else: + ${h.stylesheet_link(h.get_liburl(request, 'bb_oruga_bulma_css', prefix='tailbone'))} + % endif +</%def> + +<%def name="head_tags()"> + ${self.extra_javascript()} + ${self.extra_styles()} +</%def> + +<%def name="extra_javascript()"> +## ## some commonly-useful logic for detecting (non-)numeric input +## ${h.javascript_link(request.static_url('tailbone:static/js/numeric.js') + '?ver={}'.format(tailbone.__version__))} +## +## ## debounce, for better autocomplete performance +## ${h.javascript_link(request.static_url('tailbone:static/js/debounce.js') + '?ver={}'.format(tailbone.__version__))} + +## ## Tailbone / Buefy stuff +## ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.numericinput.js') + '?ver={}'.format(tailbone.__version__))} +## ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.timepicker.js') + '?ver={}'.format(tailbone.__version__))} + +## <script type="text/javascript"> +## +## ## NOTE: this code was copied from +## ## https://bulma.io/documentation/components/navbar/#navbar-menu +## +## document.addEventListener('DOMContentLoaded', () => { +## +## // Get all "navbar-burger" elements +## const $navbarBurgers = Array.prototype.slice.call(document.querySelectorAll('.navbar-burger'), 0) +## +## // Add a click event on each of them +## $navbarBurgers.forEach( el => { +## el.addEventListener('click', () => { +## +## // Get the target from the "data-target" attribute +## const target = el.dataset.target +## const $target = document.getElementById(target) +## +## // Toggle the "is-active" class on both the "navbar-burger" and the "navbar-menu" +## el.classList.toggle('is-active') +## $target.classList.toggle('is-active') +## +## }) +## }) +## }) +## +## </script> +</%def> + +<%def name="extra_styles()"> + +## ${h.stylesheet_link(request.static_url('tailbone:static/css/base.css') + '?ver={}'.format(tailbone.__version__))} +## ${h.stylesheet_link(request.static_url('tailbone:static/css/layout.css') + '?ver={}'.format(tailbone.__version__))} +## ${h.stylesheet_link(request.static_url('tailbone:static/css/grids.css') + '?ver={}'.format(tailbone.__version__))} +## ${h.stylesheet_link(request.static_url('tailbone:static/css/filters.css') + '?ver={}'.format(tailbone.__version__))} +## ${h.stylesheet_link(request.static_url('tailbone:static/css/forms.css') + '?ver={}'.format(tailbone.__version__))} + + ${h.stylesheet_link(request.static_url('tailbone:static/css/grids.rowstatus.css') + '?ver={}'.format(tailbone.__version__))} + ${h.stylesheet_link(request.static_url('tailbone:static/css/diffs.css') + '?ver={}'.format(tailbone.__version__))} + + ## nb. this is used (only?) in /generate-feature page + ${h.stylesheet_link(request.static_url('tailbone:static/css/codehilite.css') + '?ver={}'.format(tailbone.__version__))} + + <style> + + /* ****************************** */ + /* page */ + /* ****************************** */ + + /* nb. helps force footer to bottom of screen */ + html, body { + height: 100%; + } + + ## maybe add testing watermark + % if not request.rattail_config.production(): + html, .navbar, .footer { + background-image: url(${request.static_url('tailbone:static/img/testing.png')}); + } + % endif + + ## maybe force global background color + % if background_color: + body, .navbar, .footer { + background-color: ${background_color}; + } + % endif + + #content-title h1 { + max-width: 50%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + ## TODO: is this a good idea? + h1.title { + font-size: 2rem; + font-weight: bold; + margin-bottom: 0 !important; + } + + #context-menu { + margin-bottom: 1em; + /* margin-left: 1em; */ + text-align: right; + /* white-space: nowrap; */ + } + + ## TODO: ugh why is this needed to center modal on screen? + .modal .modal-content .modal-card { + margin: auto; + } + + .object-helpers .panel { + margin: 1rem; + margin-bottom: 1.5rem; + } + + /* ****************************** */ + /* grids */ + /* ****************************** */ + + .filters .filter-fieldname .button { + min-width: ${filter_fieldname_width}; + justify-content: left; + } + .filters .filter-verb { + min-width: ${filter_verb_width}; + } + + .grid-tools { + display: flex; + gap: 0.5rem; + justify-content: end; + } + + a.grid-action { + align-items: center; + display: inline-flex; + gap: 0.1rem; + white-space: nowrap; + } + + /************************************************** + * grid rows which are "checked" (selected) + **************************************************/ + + /* TODO: this references some color values, whereas it would be preferable + * to refer to some sort of "state" instead, color of which was + * configurable. b/c these are just the default Buefy theme colors. */ + + tr.is-checked { + background-color: #7957d5; + color: white; + } + + tr.is-checked:hover { + color: #363636; + } + + tr.is-checked a { + color: white; + } + + tr.is-checked:hover a { + color: #7957d5; + } + + /* ****************************** */ + /* forms */ + /* ****************************** */ + + /* note that these should only apply to "normal" primary forms */ + + .form { + padding-left: 5em; + } + + /* .form-wrapper .form .field.is-horizontal .field-label .label, */ + .form-wrapper .field.is-horizontal .field-label { + text-align: left; + white-space: nowrap; + min-width: 18em; + } + + .form-wrapper .form .field.is-horizontal .field-body { + min-width: 30em; + } + + .form-wrapper .form .field.is-horizontal .field-body .autocomplete, + .form-wrapper .form .field.is-horizontal .field-body .autocomplete .dropdown-trigger, + .form-wrapper .form .field.is-horizontal .field-body .select, + .form-wrapper .form .field.is-horizontal .field-body .select select { + width: 100%; + } + + .form-wrapper .form .buttons { + padding-left: 10rem; + } + + /****************************** + * fix datepicker within modals + * TODO: someday this may not be necessary? cf. + * https://github.com/buefy/buefy/issues/292#issuecomment-347365637 + ******************************/ + + /* TODO: this does change some things, but does not actually work 100% */ + /* right for oruga 0.8.7 or 0.8.9 */ + + .modal .animation-content .modal-card { + overflow: visible !important; + } + + .modal-card-body { + overflow: visible !important; + } + + /* TODO: a simpler option we might try sometime instead? */ + /* cf. https://github.com/buefy/buefy/issues/292#issuecomment-1073851313 */ + + /* .dropdown-content{ */ + /* position: fixed; */ + /* } */ + + </style> + ${base_meta.extra_styles()} +</%def> + +<%def name="make_feedback_component()"> + <% request.register_component('feedback-form', 'FeedbackForm') %> + <script type="text/x-template" id="feedback-form-template"> + <div> + + <o-button variant="primary" + @click="showFeedback()" + icon-left="comment"> + Feedback + </o-button> + + <o-modal v-model:active="showDialog"> + <div class="modal-card"> + + <header class="modal-card-head"> + <p class="modal-card-title"> + User Feedback + </p> + </header> + + <section class="modal-card-body"> + <p class="block"> + Questions, suggestions, comments, complaints, etc. + <span class="red">regarding this website</span> are + welcome and may be submitted below. + </p> + + <b-field label="User Name"> + <b-input v-model="userName" + % if request.user: + disabled + % endif + expanded> + </b-input> + </b-field> + + <b-field label="Referring URL"> + <b-input + v-model="referrer" + disabled expanded> + </b-input> + </b-field> + + <o-field label="Message"> + <o-input type="textarea" + v-model="message" + ref="message" + expanded> + </o-input> + </o-field> + + % if request.rattail_config.getbool('tailbone', 'feedback_allows_reply'): + <div class="level"> + <div class="level-left"> + <div class="level-item"> + <b-checkbox v-model="pleaseReply" + @input="pleaseReplyChanged"> + Please email me back{{ pleaseReply ? " at: " : "" }} + </b-checkbox> + </div> + <div class="level-item" v-show="pleaseReply"> + <b-input v-model="userEmail" + ref="userEmail"> + </b-input> + </div> + </div> + </div> + % endif + + </section> + + <footer class="modal-card-foot"> + <o-button @click="showDialog = false"> + Cancel + </o-button> + <o-button variant="primary" + @click="sendFeedback()" + :disabled="sending || !message?.trim()"> + {{ sending ? "Working, please wait..." : "Send Message" }} + </o-button> + </footer> + </div> + </o-modal> + </div> + </script> + <script> + + const FeedbackForm = { + template: '#feedback-form-template', + mixins: [SimpleRequestMixin], + + props: { + action: String, + }, + + data() { + return { + referrer: null, + % if request.user: + userUUID: ${json.dumps(request.user.uuid)|n}, + userName: ${json.dumps(str(request.user))|n}, + % else: + userUUID: null, + userName: null, + % endif + message: null, + pleaseReply: false, + userEmail: null, + showDialog: false, + sending: false, + } + }, + + methods: { + + pleaseReplyChanged(value) { + this.$nextTick(() => { + this.$refs.userEmail.focus() + }) + }, + + showFeedback() { + this.referrer = location.href + this.message = null + this.showDialog = true + this.$nextTick(function() { + this.$refs.message.focus() + }) + }, + + sendFeedback() { + this.sending = true + + const params = { + referrer: this.referrer, + user: this.userUUID, + user_name: this.userName, + please_reply_to: this.pleaseReply ? this.userEmail : '', + message: this.message?.trim(), + } + + this.simplePOST(this.action, params, response => { + + this.$buefy.toast.open({ + message: "Message sent! Thank you for your feedback.", + type: 'is-info', + duration: 4000, // 4 seconds + }) + + this.sending = false + this.showDialog = false + + }, response => { + this.sending = false + }) + }, + } + } + + </script> +</%def> + +<%def name="make_menu_search_component()"> + <% request.register_component('menu-search', 'MenuSearch') %> + <script type="text/x-template" id="menu-search-template"> + <div style="display: flex;"> + + <a v-show="!searchActive" + href="${url('home')}" + class="navbar-item" + style="display: flex; gap: 0.5rem;"> + ${base_meta.header_logo()} + <div id="global-header-title"> + ${base_meta.global_title()} + </div> + </a> + + <div v-show="searchActive" + class="navbar-item"> + <o-autocomplete ref="searchAutocomplete" + v-model="searchTerm" + :data="searchFilteredData" + field="label" + open-on-focus + keep-first + icon-pack="fas" + clearable + @select="searchSelect"> + </o-autocomplete> + </div> + </div> + </script> + <script> + + const MenuSearch = { + template: '#menu-search-template', + + props: { + searchData: Array, + }, + + data() { + return { + searchActive: false, + searchTerm: null, + searchInput: null, + } + }, + + computed: { + + searchFilteredData() { + if (!this.searchTerm || !this.searchTerm.length) { + return this.searchData + } + + let terms = [] + for (let term of this.searchTerm.toLowerCase().split(' ')) { + term = term.trim() + if (term) { + terms.push(term) + } + } + if (!terms.length) { + return this.searchData + } + + // all terms must match + return this.searchData.filter((option) => { + let label = option.label.toLowerCase() + for (let term of terms) { + if (label.indexOf(term) < 0) { + return false + } + } + return true + }) + }, + }, + + mounted() { + this.searchInput = this.$refs.searchAutocomplete.$el.querySelector('input') + this.searchInput.addEventListener('keydown', this.searchKeydown) + }, + + beforeDestroy() { + this.searchInput.removeEventListener('keydown', this.searchKeydown) + }, + + methods: { + + searchInit() { + this.searchTerm = '' + this.searchActive = true + this.$nextTick(() => { + this.$refs.searchAutocomplete.focus() + }) + }, + + searchKeydown(event) { + // ESC will dismiss searchbox + if (event.which == 27) { + this.searchActive = false + } + }, + + searchSelect(option) { + location.href = option.url + }, + }, + } + + </script> +</%def> + +<%def name="render_vue_template_whole_page()"> + <script type="text/x-template" id="whole-page-template"> + <div id="whole-page" style="height: 100%; display: flex; flex-direction: column; justify-content: space-between;"> + + <div class="header-wrapper"> + + <header> + + <!-- this main menu, with search --> + <nav class="navbar" role="navigation" aria-label="main navigation" + style="display: flex; align-items: center;"> + + <div class="navbar-brand"> + <menu-search :search-data="globalSearchData" + ref="menuSearch" /> + <a role="button" class="navbar-burger" data-target="navbarMenu" aria-label="menu" aria-expanded="false"> + <span aria-hidden="true"></span> + <span aria-hidden="true"></span> + <span aria-hidden="true"></span> + <span aria-hidden="true"></span> + </a> + </div> + + <div class="navbar-menu" id="navbarMenu" + style="display: flex; align-items: center;" + > + <div class="navbar-start"> + + ## global search button + <div v-if="globalSearchData.length" + class="navbar-item"> + <o-button variant="primary" + size="small" + @click="globalSearchInit()"> + <o-icon icon="search" size="small" /> + </o-button> + </div> + + ## main menu + % for topitem in menus: + % if topitem['is_link']: + ${h.link_to(topitem['title'], topitem['url'], target=topitem['target'], class_='navbar-item')} + % else: + <div class="navbar-item has-dropdown is-hoverable"> + <a class="navbar-link">${topitem['title']}</a> + <div class="navbar-dropdown"> + % for item in topitem['items']: + % if item['is_menu']: + <% item_hash = id(item) %> + <% toggle = f'menu_{item_hash}_shown' %> + <div> + <a class="navbar-link" @click.prevent="toggleNestedMenu('${item_hash}')"> + ${item['title']} + </a> + </div> + % for subitem in item['items']: + % if subitem['is_sep']: + <hr class="navbar-divider" v-show="${toggle}"> + % else: + ${h.link_to("{}".format(subitem['title']), subitem['url'], class_='navbar-item nested', target=subitem['target'], **{'v-show': toggle})} + % endif + % endfor + % else: + % if item['is_sep']: + <hr class="navbar-divider"> + % else: + ${h.link_to(item['title'], item['url'], class_='navbar-item', target=item['target'])} + % endif + % endif + % endfor + </div> + </div> + % endif + % endfor + + </div><!-- navbar-start --> + ${self.render_navbar_end()} + </div> + </nav> + + <!-- nb. this has index title, help button etc. --> + <nav style="display: flex; justify-content: space-between; align-items: center; padding: 0.5rem;"> + + ## Current Context + <div style="display: flex; gap: 0.5rem; align-items: center;"> + % if master: + % if master.listing: + <h1 class="title"> + ${index_title} + </h1> + % if master.creatable and getattr(master, 'show_create_link', True) and master.has_perm('create'): + <once-button type="is-primary" + tag="a" href="${url('{}.create'.format(route_prefix))}" + icon-left="plus" + style="margin-left: 1rem;" + text="Create New"> + </once-button> + % endif + % elif index_url: + <h1 class="title"> + ${h.link_to(index_title, index_url)} + </h1> + % if parent_url is not Undefined: + <h1 class="title"> + » + </h1> + <h1 class="title"> + ${h.link_to(parent_title, parent_url)} + </h1> + % elif instance_url is not Undefined: + <h1 class="title"> + » + </h1> + <h1 class="title"> + ${h.link_to(instance_title, instance_url)} + </h1> + % elif master.creatable and getattr(master, 'show_create_link', True) and master.has_perm('create'): + % if not request.matched_route.name.endswith('.create'): + <once-button type="is-primary" + tag="a" href="${url('{}.create'.format(route_prefix))}" + icon-left="plus" + style="margin-left: 1rem;" + text="Create New"> + </once-button> + % endif + % endif +## % if master.viewing and grid_index: +## ${grid_index_nav()} +## % endif + % else: + <h1 class="title"> + ${index_title} + </h1> + % endif + % elif index_title: + % if index_url: + <h1 class="title"> + ${h.link_to(index_title, index_url)} + </h1> + % else: + <h1 class="title"> + ${index_title} + </h1> + % endif + % endif + + % if expose_db_picker is not Undefined and expose_db_picker: + <span>DB:</span> + ${h.form(url('change_db_engine'), ref='dbPickerForm')} + ${h.csrf_token(request)} + ${h.hidden('engine_type', value=master.engine_type_key)} + <input type="hidden" name="referrer" :value="referrer" /> + <b-select name="dbkey" + v-model="dbSelected" + @input="changeDB()"> + % for option in db_picker_options: + <option value="${option.value}"> + ${option.label} + </option> + % endfor + </b-select> + ${h.end_form()} + % endif + + </div> + + <div style="display: flex; gap: 0.5rem;"> + + ## Quickie Lookup + % if quickie is not Undefined and quickie and request.has_perm(quickie.perm): + ${h.form(quickie.url, method='get', style='display: flex; gap: 0.5rem; margin-right: 1rem;')} + <b-input name="entry" + placeholder="${quickie.placeholder}" + autocomplete="off"> + </b-input> + <o-button variant="primary" + native-type="submit" + icon-left="search"> + Lookup + </o-button> + ${h.end_form()} + % endif + + % if master and master.configurable and master.has_perm('configure'): + % if not request.matched_route.name.endswith('.configure'): + <once-button type="is-primary" + tag="a" + href="${url('{}.configure'.format(route_prefix))}" + icon-left="cog" + text="${(configure_button_title or "Configure") if configure_button_title is not Undefined else "Configure"}"> + </once-button> + % endif + % endif + + ## Theme Picker + % if expose_theme_picker and request.has_perm('common.change_app_theme'): + ${h.form(url('change_theme'), method="post", ref='themePickerForm')} + ${h.csrf_token(request)} + <input type="hidden" name="referrer" :value="referrer" /> + <div style="display: flex; align-items: center; gap: 0.5rem;"> + <span>Theme:</span> + <b-select name="theme" + v-model="globalTheme" + @input="changeTheme()"> + % for option in theme_picker_options: + <option value="${option.value}"> + ${option.label} + </option> + % endfor + </b-select> + </div> + ${h.end_form()} + % endif + + % if help_url or help_markdown or can_edit_help: + <page-help + % if can_edit_help: + @configure-fields-help="configureFieldsHelp = true" + % endif + > + </page-help> + % endif + + ## Feedback Button / Dialog + % if request.has_perm('common.feedback'): + <feedback-form action="${url('feedback')}" /> + % endif + </div> + </nav> + </header> + + ## Page Title + % if capture(self.content_title): + <section class="has-background-primary" + ## TODO: id is only for css, do we need it? + id="content-title" + style="padding: 0.5rem; padding-left: 1rem;"> + <div style="display: flex; align-items: center; gap: 1rem;"> + + <h1 class="title has-text-white" v-html="contentTitleHTML" /> + + <div style="flex-grow: 1; display: flex; gap: 0.5rem;"> + ${self.render_instance_header_title_extras()} + </div> + + <div style="display: flex; gap: 0.5rem;"> + ${self.render_instance_header_buttons()} + </div> + + </div> + </section> + % endif + + </div> <!-- header-wrapper --> + + <div class="content-wrapper" + style="flex-grow: 1; padding: 0.5rem;"> + + ## Page Body + <section id="page-body"> + + % if request.session.peek_flash('error'): + % for error in request.session.pop_flash('error'): + <b-notification type="is-warning"> + ${error} + </b-notification> + % endfor + % endif + + % if request.session.peek_flash('warning'): + % for msg in request.session.pop_flash('warning'): + <b-notification type="is-warning"> + ${msg} + </b-notification> + % endfor + % endif + + % if request.session.peek_flash(): + % for msg in request.session.pop_flash(): + <b-notification type="is-info"> + ${msg} + </b-notification> + % endfor + % endif + + ## true page content + <div> + ${self.render_this_page_component()} + </div> + </section> + </div><!-- content-wrapper --> + + ## Footer + <footer class="footer"> + <div class="content"> + ${base_meta.footer()} + </div> + </footer> + </div> + </script> +</%def> + +<%def name="render_this_page_component()"> + <this-page @change-content-title="changeContentTitle" + % if can_edit_help: + :configure-fields-help="configureFieldsHelp" + % endif + > + </this-page> +</%def> + +<%def name="render_navbar_end()"> + <div class="navbar-end"> + ${self.render_user_menu()} + </div> +</%def> + +<%def name="render_user_menu()"> + % if request.user: + <div class="navbar-item has-dropdown is-hoverable"> + % if messaging_enabled: + <a class="navbar-link ${'has-background-danger has-text-white' if request.is_root else ''}">${request.user}${" ({})".format(inbox_count) if inbox_count else ''}</a> + % else: + <a class="navbar-link ${'has-background-danger has-text-white' if request.is_root else ''}">${request.user}</a> + % endif + <div class="navbar-dropdown"> + % if request.is_root: + ${h.form(url('stop_root'), ref='stopBeingRootForm')} + ${h.csrf_token(request)} + <input type="hidden" name="referrer" value="${request.current_route_url()}" /> + <a @click="$refs.stopBeingRootForm.submit()" + class="navbar-item has-background-danger has-text-white"> + Stop being root + </a> + ${h.end_form()} + % elif request.is_admin: + ${h.form(url('become_root'), ref='startBeingRootForm')} + ${h.csrf_token(request)} + <input type="hidden" name="referrer" value="${request.current_route_url()}" /> + <a @click="$refs.startBeingRootForm.submit()" + class="navbar-item has-background-danger has-text-white"> + Become root + </a> + ${h.end_form()} + % endif + % if messaging_enabled: + ${h.link_to("Messages{}".format(" ({})".format(inbox_count) if inbox_count else ''), url('messages.inbox'), class_='navbar-item')} + % endif + % if request.is_root or not request.user.prevent_password_change: + ${h.link_to("Change Password", url('change_password'), class_='navbar-item')} + % endif + ${h.link_to("Edit Preferences", url('my.preferences'), class_='navbar-item')} + ${h.link_to("Logout", url('logout'), class_='navbar-item')} + </div> + </div> + % else: + ${h.link_to("Login", url('login'), class_='navbar-item')} + % endif +</%def> + +<%def name="render_instance_header_title_extras()"></%def> + +<%def name="render_instance_header_buttons()"> + ${self.render_crud_header_buttons()} + ${self.render_prevnext_header_buttons()} +</%def> + +<%def name="render_crud_header_buttons()"> +% if master and master.viewing and not getattr(master, 'cloning', False): + ## TODO: is there a better way to check if viewing parent? + % if parent_instance is Undefined: + % if master.editable and instance_editable and master.has_perm('edit'): + <once-button tag="a" href="${master.get_action_url('edit', instance)}" + icon-left="edit" + text="Edit This"> + </once-button> + % endif + % if not getattr(master, 'cloning', False) and getattr(master, 'cloneable', False) and master.has_perm('clone'): + <once-button tag="a" href="${master.get_action_url('clone', instance)}" + icon-left="object-ungroup" + text="Clone This"> + </once-button> + % endif + % if master.deletable and instance_deletable and master.has_perm('delete'): + <once-button tag="a" href="${master.get_action_url('delete', instance)}" + type="is-danger" + icon-left="trash" + text="Delete This"> + </once-button> + % endif + % else: + ## viewing row + % if instance_deletable and master.has_perm('delete_row'): + <once-button tag="a" href="${master.get_action_url('delete', instance)}" + type="is-danger" + icon-left="trash" + text="Delete This"> + </once-button> + % endif + % endif + % elif master and master.editing: + % if master.viewable and master.has_perm('view'): + <once-button tag="a" href="${master.get_action_url('view', instance)}" + icon-left="eye" + text="View This"> + </once-button> + % endif + % if master.deletable and instance_deletable and master.has_perm('delete'): + <once-button tag="a" href="${master.get_action_url('delete', instance)}" + type="is-danger" + icon-left="trash" + text="Delete This"> + </once-button> + % endif + % elif master and master.deleting: + % if master.viewable and master.has_perm('view'): + <once-button tag="a" href="${master.get_action_url('view', instance)}" + icon-left="eye" + text="View This"> + </once-button> + % endif + % if master.editable and instance_editable and master.has_perm('edit'): + <once-button tag="a" href="${master.get_action_url('edit', instance)}" + icon-left="edit" + text="Edit This"> + </once-button> + % endif + % elif master and getattr(master, 'cloning', False): + % if master.viewable and master.has_perm('view'): + <once-button tag="a" href="${master.get_action_url('view', instance)}" + icon-left="eye" + text="View This"> + </once-button> + % endif + % endif +</%def> + +<%def name="render_prevnext_header_buttons()"> + % if show_prev_next is not Undefined and show_prev_next: + % if prev_url: + <b-button tag="a" href="${prev_url}" + icon-pack="fas" + icon-left="arrow-left"> + Older + </b-button> + % else: + <b-button tag="a" href="#" + disabled + icon-pack="fas" + icon-left="arrow-left"> + Older + </b-button> + % endif + % if next_url: + <b-button tag="a" href="${next_url}" + icon-pack="fas" + icon-left="arrow-right"> + Newer + </b-button> + % else: + <b-button tag="a" href="#" + disabled + icon-pack="fas" + icon-left="arrow-right"> + Newer + </b-button> + % endif + % endif +</%def> + +<%def name="render_vue_script_whole_page()"> + <script> + + const WholePage = { + template: '#whole-page-template', + mixins: [SimpleRequestMixin], + computed: {}, + + mounted() { + window.addEventListener('keydown', this.globalKey) + for (let hook of this.mountedHooks) { + hook(this) + } + }, + beforeDestroy() { + window.removeEventListener('keydown', this.globalKey) + }, + + methods: { + + changeContentTitle(newTitle) { + this.contentTitleHTML = newTitle + }, + + % if expose_db_picker is not Undefined and expose_db_picker: + changeDB() { + this.$refs.dbPickerForm.submit() + }, + % endif + + % if expose_theme_picker and request.has_perm('common.change_app_theme'): + changeTheme() { + this.$refs.themePickerForm.submit() + }, + % endif + + globalKey(event) { + + // Ctrl+8 opens global search + if (event.target.tagName == 'BODY') { + if (event.ctrlKey && event.key == '8') { + this.globalSearchInit() + } + } + }, + + globalSearchInit() { + this.$refs.menuSearch.searchInit() + }, + + toggleNestedMenu(hash) { + const key = 'menu_' + hash + '_shown' + this[key] = !this[key] + }, + }, + } + + const WholePageData = { + contentTitleHTML: ${json.dumps(capture(self.content_title))|n}, + globalSearchData: ${json.dumps(global_search_data)|n}, + mountedHooks: [], + + % if expose_db_picker is not Undefined and expose_db_picker: + dbSelected: ${json.dumps(db_picker_selected)|n}, + % endif + + % if expose_theme_picker and request.has_perm('common.change_app_theme'): + globalTheme: ${json.dumps(theme)|n}, + referrer: location.href, + % endif + + % if can_edit_help: + configureFieldsHelp: false, + % endif + } + + ## declare nested menu visibility toggle flags + % for topitem in menus: + % if topitem['is_menu']: + % for item in topitem['items']: + % if item['is_menu']: + WholePageData.menu_${id(item)}_shown = false + % endif + % endfor + % endif + % endfor + + </script> +</%def> + +############################## +## vue components + app +############################## + +<%def name="render_vue_templates()"> +## ${multi_file_upload.render_template()} +## ${multi_file_upload.declare_vars()} + + ## global components used by various (but not all) pages + ${make_field_components()} + ${make_grid_filter_components()} + + ## global components for buefy-based template compatibility + ${make_http_plugin()} + ${make_buefy_plugin()} + ${make_buefy_components()} + + ## special global components, used by WholePage + ${self.make_menu_search_component()} + ${page_help.render_template()} + ${page_help.declare_vars()} + % if request.has_perm('common.feedback'): + ${self.make_feedback_component()} + % endif + + ## DEPRECATED; called for back-compat + ${self.render_whole_page_template()} + + ## DEPRECATED; called for back-compat + ${self.declare_whole_page_vars()} +</%def> + +## DEPRECATED; remains for back-compat +<%def name="render_whole_page_template()"> + ${self.render_vue_template_whole_page()} + ${self.render_vue_script_whole_page()} +</%def> + +<%def name="modify_vue_vars()"> + ## DEPRECATED; called for back-compat + ${self.modify_whole_page_vars()} +</%def> + +<%def name="make_vue_components()"> + ${page_help.make_component()} + ## ${multi_file_upload.make_component()} + + ## DEPRECATED; called for back-compat (?) + ${self.make_whole_page_component()} +</%def> + +## DEPRECATED; remains for back-compat +<%def name="make_whole_page_component()"> + <script> + WholePage.data = () => { return WholePageData } + </script> + <% request.register_component('whole-page', 'WholePage') %> +</%def> + +<%def name="make_vue_app()"> + ## DEPRECATED; called for back-compat + ${self.make_whole_page_app()} +</%def> + +## DEPRECATED; remains for back-compat +<%def name="make_whole_page_app()"> + <script type="module"> + import {createApp} from 'vue' + import {Oruga} from '@oruga-ui/oruga-next' + import {bulmaConfig} from '@oruga-ui/theme-bulma' + import { library } from "@fortawesome/fontawesome-svg-core" + import { fas } from "@fortawesome/free-solid-svg-icons" + import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome" + library.add(fas) + + const app = createApp() + app.component('vue-fontawesome', FontAwesomeIcon) + + % if hasattr(request, '_tailbone_registered_components'): + % for tagname, classname in request._tailbone_registered_components.items(): + app.component('${tagname}', ${classname}) + % endfor + % endif + + app.use(Oruga, { + ...bulmaConfig, + iconComponent: 'vue-fontawesome', + iconPack: 'fas', + }) + + app.use(HttpPlugin) + app.use(BuefyPlugin) + + app.mount('#app') + </script> +</%def> + +############################## +## DEPRECATED +############################## + +<%def name="declare_whole_page_vars()"></%def> + +<%def name="modify_whole_page_vars()"></%def> diff --git a/tailbone/templates/themes/butterball/buefy-components.mako b/tailbone/templates/themes/butterball/buefy-components.mako new file mode 100644 index 00000000..3a2cd798 --- /dev/null +++ b/tailbone/templates/themes/butterball/buefy-components.mako @@ -0,0 +1,759 @@ + +<%def name="make_buefy_components()"> + ${self.make_b_autocomplete_component()} + ${self.make_b_button_component()} + ${self.make_b_checkbox_component()} + ${self.make_b_collapse_component()} + ${self.make_b_datepicker_component()} + ${self.make_b_dropdown_component()} + ${self.make_b_dropdown_item_component()} + ${self.make_b_field_component()} + ${self.make_b_icon_component()} + ${self.make_b_input_component()} + ${self.make_b_loading_component()} + ${self.make_b_modal_component()} + ${self.make_b_notification_component()} + ${self.make_b_radio_component()} + ${self.make_b_select_component()} + ${self.make_b_steps_component()} + ${self.make_b_step_item_component()} + ${self.make_b_table_component()} + ${self.make_b_table_column_component()} + ${self.make_b_tooltip_component()} + ${self.make_once_button_component()} +</%def> + +<%def name="make_b_autocomplete_component()"> + <script type="text/x-template" id="b-autocomplete-template"> + <o-autocomplete v-model="orugaValue" + :data="data" + :field="field" + :open-on-focus="openOnFocus" + :keep-first="keepFirst" + :clearable="clearable" + :clear-on-select="clearOnSelect" + :formatter="customFormatter" + :placeholder="placeholder" + @update:model-value="orugaValueUpdated" + ref="autocomplete"> + </o-autocomplete> + </script> + <script> + const BAutocomplete = { + template: '#b-autocomplete-template', + props: { + modelValue: String, + data: Array, + field: String, + openOnFocus: Boolean, + keepFirst: Boolean, + clearable: Boolean, + clearOnSelect: Boolean, + customFormatter: null, + placeholder: String, + }, + data() { + return { + orugaValue: this.modelValue, + } + }, + watch: { + modelValue(to, from) { + if (this.orugaValue != to) { + this.orugaValue = to + } + }, + }, + methods: { + focus() { + const input = this.$refs.autocomplete.$el.querySelector('input') + input.focus() + }, + orugaValueUpdated(value) { + this.$emit('update:modelValue', value) + }, + }, + } + </script> + <% request.register_component('b-autocomplete', 'BAutocomplete') %> +</%def> + +<%def name="make_b_button_component()"> + <script type="text/x-template" id="b-button-template"> + <o-button :variant="variant" + :size="orugaSize" + :native-type="nativeType" + :tag="tag" + :href="href" + :icon-left="iconLeft"> + <slot /> + </o-button> + </script> + <script> + const BButton = { + template: '#b-button-template', + props: { + type: String, + nativeType: String, + tag: String, + href: String, + size: String, + iconPack: String, // ignored + iconLeft: String, + }, + computed: { + orugaSize() { + if (this.size) { + return this.size.replace(/^is-/, '') + } + }, + variant() { + if (this.type) { + return this.type.replace(/^is-/, '') + } + }, + }, + } + </script> + <% request.register_component('b-button', 'BButton') %> +</%def> + +<%def name="make_b_checkbox_component()"> + <script type="text/x-template" id="b-checkbox-template"> + <o-checkbox v-model="orugaValue" + @update:model-value="orugaValueUpdated" + :name="name" + :native-value="nativeValue"> + <slot /> + </o-checkbox> + </script> + <script> + const BCheckbox = { + template: '#b-checkbox-template', + props: { + modelValue: null, + name: String, + nativeValue: null, + }, + data() { + return { + orugaValue: this.modelValue, + } + }, + watch: { + modelValue(to, from) { + this.orugaValue = to + }, + }, + methods: { + orugaValueUpdated(value) { + this.$emit('update:modelValue', value) + }, + }, + } + </script> + <% request.register_component('b-checkbox', 'BCheckbox') %> +</%def> + +<%def name="make_b_collapse_component()"> + <script type="text/x-template" id="b-collapse-template"> + <o-collapse :open="open"> + <slot name="trigger" /> + <slot /> + </o-collapse> + </script> + <script> + const BCollapse = { + template: '#b-collapse-template', + props: { + open: Boolean, + }, + } + </script> + <% request.register_component('b-collapse', 'BCollapse') %> +</%def> + +<%def name="make_b_datepicker_component()"> + <script type="text/x-template" id="b-datepicker-template"> + <o-datepicker :name="name" + v-model="orugaValue" + @update:model-value="orugaValueUpdated" + :value="value" + :placeholder="placeholder" + :date-formatter="dateFormatter" + :date-parser="dateParser" + :disabled="disabled" + :editable="editable" + :icon="icon" + :close-on-click="false"> + </o-datepicker> + </script> + <script> + const BDatepicker = { + template: '#b-datepicker-template', + props: { + dateFormatter: null, + dateParser: null, + disabled: Boolean, + editable: Boolean, + icon: String, + // iconPack: String, // ignored + modelValue: Date, + name: String, + placeholder: String, + value: null, + }, + data() { + return { + orugaValue: this.modelValue, + } + }, + watch: { + modelValue(to, from) { + if (this.orugaValue != to) { + this.orugaValue = to + } + }, + }, + methods: { + orugaValueUpdated(value) { + if (this.modelValue != value) { + this.$emit('update:modelValue', value) + } + }, + }, + } + </script> + <% request.register_component('b-datepicker', 'BDatepicker') %> +</%def> + +<%def name="make_b_dropdown_component()"> + <script type="text/x-template" id="b-dropdown-template"> + <o-dropdown :position="buefyPosition" + :triggers="triggers"> + <slot name="trigger" /> + <slot /> + </o-dropdown> + </script> + <script> + const BDropdown = { + template: '#b-dropdown-template', + props: { + position: String, + triggers: Array, + }, + computed: { + buefyPosition() { + if (this.position) { + return this.position.replace(/^is-/, '') + } + }, + }, + } + </script> + <% request.register_component('b-dropdown', 'BDropdown') %> +</%def> + +<%def name="make_b_dropdown_item_component()"> + <script type="text/x-template" id="b-dropdown-item-template"> + <o-dropdown-item :label="label"> + <slot /> + </o-dropdown-item> + </script> + <script> + const BDropdownItem = { + template: '#b-dropdown-item-template', + props: { + label: String, + }, + } + </script> + <% request.register_component('b-dropdown-item', 'BDropdownItem') %> +</%def> + +<%def name="make_b_field_component()"> + <script type="text/x-template" id="b-field-template"> + <o-field :grouped="grouped" + :label="label" + :horizontal="horizontal" + :expanded="expanded" + :variant="variant"> + <slot /> + </o-field> + </script> + <script> + const BField = { + template: '#b-field-template', + props: { + expanded: Boolean, + grouped: Boolean, + horizontal: Boolean, + label: String, + type: String, + }, + computed: { + variant() { + if (this.type) { + return this.type.replace(/^is-/, '') + } + }, + }, + } + </script> + <% request.register_component('b-field', 'BField') %> +</%def> + +<%def name="make_b_icon_component()"> + <script type="text/x-template" id="b-icon-template"> + <o-icon :icon="icon" + :size="orugaSize" /> + </script> + <script> + const BIcon = { + template: '#b-icon-template', + props: { + icon: String, + size: String, + }, + computed: { + orugaSize() { + if (this.size) { + return this.size.replace(/^is-/, '') + } + }, + }, + } + </script> + <% request.register_component('b-icon', 'BIcon') %> +</%def> + +<%def name="make_b_input_component()"> + <script type="text/x-template" id="b-input-template"> + <o-input :type="type" + :disabled="disabled" + v-model="orugaValue" + @update:modelValue="val => $emit('update:modelValue', val)" + :autocomplete="autocomplete" + ref="input" + :expanded="expanded"> + <slot /> + </o-input> + </script> + <script> + const BInput = { + template: '#b-input-template', + props: { + modelValue: null, + type: String, + autocomplete: String, + disabled: Boolean, + expanded: Boolean, + }, + data() { + return { + orugaValue: this.modelValue + } + }, + watch: { + modelValue(to, from) { + if (this.orugaValue != to) { + this.orugaValue = to + } + }, + }, + methods: { + focus() { + if (this.type == 'textarea') { + // TODO: this does not always work right? + this.$refs.input.$el.querySelector('textarea').focus() + } else { + // TODO: pretty sure we can rely on the <o-input> focus() + // here, but not sure why we weren't already doing that? + //this.$refs.input.$el.querySelector('input').focus() + this.$refs.input.focus() + } + }, + }, + } + </script> + <% request.register_component('b-input', 'BInput') %> +</%def> + +<%def name="make_b_loading_component()"> + <script type="text/x-template" id="b-loading-template"> + <o-loading :full-page="isFullPage"> + <slot /> + </o-loading> + </script> + <script> + const BLoading = { + template: '#b-loading-template', + props: { + isFullPage: Boolean, + }, + } + </script> + <% request.register_component('b-loading', 'BLoading') %> +</%def> + +<%def name="make_b_modal_component()"> + <script type="text/x-template" id="b-modal-template"> + <o-modal v-model:active="trueActive" + @update:active="activeChanged"> + <slot /> + </o-modal> + </script> + <script> + const BModal = { + template: '#b-modal-template', + props: { + active: Boolean, + hasModalCard: Boolean, // nb. this is ignored + }, + data() { + return { + trueActive: this.active, + } + }, + watch: { + active(to, from) { + this.trueActive = to + }, + trueActive(to, from) { + if (this.active != to) { + this.tellParent(to) + } + }, + }, + methods: { + + tellParent(active) { + // TODO: this does not work properly + this.$emit('update:active', active) + }, + + activeChanged(active) { + this.tellParent(active) + }, + }, + } + </script> + <% request.register_component('b-modal', 'BModal') %> +</%def> + +<%def name="make_b_notification_component()"> + <script type="text/x-template" id="b-notification-template"> + <o-notification :variant="variant" + :closable="closable"> + <slot /> + </o-notification> + </script> + <script> + const BNotification = { + template: '#b-notification-template', + props: { + type: String, + closable: { + type: Boolean, + default: true, + }, + }, + computed: { + variant() { + if (this.type) { + return this.type.replace(/^is-/, '') + } + }, + }, + } + </script> + <% request.register_component('b-notification', 'BNotification') %> +</%def> + +<%def name="make_b_radio_component()"> + <script type="text/x-template" id="b-radio-template"> + <o-radio v-model="orugaValue" + @update:model-value="orugaValueUpdated" + :native-value="nativeValue"> + <slot /> + </o-radio> + </script> + <script> + const BRadio = { + template: '#b-radio-template', + props: { + modelValue: null, + nativeValue: null, + }, + data() { + return { + orugaValue: this.modelValue, + } + }, + watch: { + modelValue(to, from) { + this.orugaValue = to + }, + }, + methods: { + orugaValueUpdated(value) { + this.$emit('update:modelValue', value) + }, + }, + } + </script> + <% request.register_component('b-radio', 'BRadio') %> +</%def> + +<%def name="make_b_select_component()"> + <script type="text/x-template" id="b-select-template"> + <o-select :name="name" + ref="select" + v-model="orugaValue" + @update:model-value="orugaValueUpdated" + :expanded="expanded" + :multiple="multiple" + :size="orugaSize" + :native-size="nativeSize"> + <slot /> + </o-select> + </script> + <script> + const BSelect = { + template: '#b-select-template', + props: { + expanded: Boolean, + modelValue: null, + multiple: Boolean, + name: String, + nativeSize: null, + size: null, + }, + data() { + return { + orugaValue: this.modelValue, + } + }, + watch: { + modelValue(to, from) { + this.orugaValue = to + }, + }, + computed: { + orugaSize() { + if (this.size) { + return this.size.replace(/^is-/, '') + } + }, + }, + methods: { + focus() { + this.$refs.select.focus() + }, + orugaValueUpdated(value) { + this.$emit('update:modelValue', value) + this.$emit('input', value) + }, + }, + } + </script> + <% request.register_component('b-select', 'BSelect') %> +</%def> + +<%def name="make_b_steps_component()"> + <script type="text/x-template" id="b-steps-template"> + <o-steps v-model="orugaValue" + @update:model-value="orugaValueUpdated" + :animated="animated" + :rounded="rounded" + :has-navigation="hasNavigation" + :vertical="vertical"> + <slot /> + </o-steps> + </script> + <script> + const BSteps = { + template: '#b-steps-template', + props: { + modelValue: null, + animated: Boolean, + rounded: Boolean, + hasNavigation: Boolean, + vertical: Boolean, + }, + data() { + return { + orugaValue: this.modelValue, + } + }, + watch: { + modelValue(to, from) { + this.orugaValue = to + }, + }, + methods: { + orugaValueUpdated(value) { + this.$emit('update:modelValue', value) + this.$emit('input', value) + }, + }, + } + </script> + <% request.register_component('b-steps', 'BSteps') %> +</%def> + +<%def name="make_b_step_item_component()"> + <script type="text/x-template" id="b-step-item-template"> + <o-step-item :step="step" + :value="value" + :label="label" + :clickable="clickable"> + <slot /> + </o-step-item> + </script> + <script> + const BStepItem = { + template: '#b-step-item-template', + props: { + step: null, + value: null, + label: String, + clickable: Boolean, + }, + } + </script> + <% request.register_component('b-step-item', 'BStepItem') %> +</%def> + +<%def name="make_b_table_component()"> + <script type="text/x-template" id="b-table-template"> + <o-table :data="data"> + <slot /> + </o-table> + </script> + <script> + const BTable = { + template: '#b-table-template', + props: { + data: Array, + }, + } + </script> + <% request.register_component('b-table', 'BTable') %> +</%def> + +<%def name="make_b_table_column_component()"> + <script type="text/x-template" id="b-table-column-template"> + <o-table-column :field="field" + :label="label" + v-slot="props"> + ## TODO: this does not seem to really work for us... + <slot :props="props" /> + </o-table-column> + </script> + <script> + const BTableColumn = { + template: '#b-table-column-template', + props: { + field: String, + label: String, + }, + } + </script> + <% request.register_component('b-table-column', 'BTableColumn') %> +</%def> + +<%def name="make_b_tooltip_component()"> + <script type="text/x-template" id="b-tooltip-template"> + <o-tooltip :label="label" + :position="orugaPosition" + :multiline="multilined"> + <slot /> + </o-tooltip> + </script> + <script> + const BTooltip = { + template: '#b-tooltip-template', + props: { + label: String, + multilined: Boolean, + position: String, + }, + computed: { + orugaPosition() { + if (this.position) { + return this.position.replace(/^is-/, '') + } + }, + }, + } + </script> + <% request.register_component('b-tooltip', 'BTooltip') %> +</%def> + +<%def name="make_once_button_component()"> + <script type="text/x-template" id="once-button-template"> + <b-button :type="type" + :native-type="nativeType" + :tag="tag" + :href="href" + :title="title" + :disabled="buttonDisabled" + @click="clicked" + icon-pack="fas" + :icon-left="iconLeft"> + {{ buttonText }} + </b-button> + </script> + <script> + const OnceButton = { + template: '#once-button-template', + props: { + type: String, + nativeType: String, + tag: String, + href: String, + text: String, + title: String, + iconLeft: String, + working: String, + workingText: String, + disabled: Boolean, + }, + data() { + return { + currentText: null, + currentDisabled: null, + } + }, + computed: { + buttonText: function() { + return this.currentText || this.text + }, + buttonDisabled: function() { + if (this.currentDisabled !== null) { + return this.currentDisabled + } + return this.disabled + }, + }, + methods: { + + clicked(event) { + this.currentDisabled = true + if (this.workingText) { + this.currentText = this.workingText + } else if (this.working) { + this.currentText = this.working + ", please wait..." + } else { + this.currentText = "Working, please wait..." + } + // this.$nextTick(function() { + // this.$emit('click', event) + // }) + } + }, + } + </script> + <% request.register_component('once-button', 'OnceButton') %> +</%def> diff --git a/tailbone/templates/themes/butterball/buefy-plugin.mako b/tailbone/templates/themes/butterball/buefy-plugin.mako new file mode 100644 index 00000000..4cbedfea --- /dev/null +++ b/tailbone/templates/themes/butterball/buefy-plugin.mako @@ -0,0 +1,32 @@ + +<%def name="make_buefy_plugin()"> + <script> + + const BuefyPlugin = { + install(app, options) { + app.config.globalProperties.$buefy = { + + toast: { + open(options) { + + let variant = null + if (options.type) { + variant = options.type.replace(/^is-/, '') + } + + const opts = { + duration: options.duration, + message: options.message, + position: 'top', + variant, + } + + const oruga = app.config.globalProperties.$oruga + oruga.notification.open(opts) + }, + }, + } + }, + } + </script> +</%def> diff --git a/tailbone/templates/themes/butterball/field-components.mako b/tailbone/templates/themes/butterball/field-components.mako new file mode 100644 index 00000000..917083c4 --- /dev/null +++ b/tailbone/templates/themes/butterball/field-components.mako @@ -0,0 +1,542 @@ +## -*- coding: utf-8; -*- + +<%def name="make_field_components()"> + ${self.make_numeric_input_component()} + ${self.make_tailbone_autocomplete_component()} + ${self.make_tailbone_datepicker_component()} + ${self.make_tailbone_timepicker_component()} +</%def> + +<%def name="make_numeric_input_component()"> + <% request.register_component('numeric-input', 'NumericInput') %> + ${h.javascript_link(request.static_url('tailbone:static/js/numeric.js') + f'?ver={tailbone.__version__}')} + <script type="text/x-template" id="numeric-input-template"> + <o-input v-model="orugaValue" + @update:model-value="orugaValueUpdated" + ref="input" + :disabled="disabled" + :icon="icon" + :name="name" + :placeholder="placeholder" + :size="size" + /> + </script> + <script> + + const NumericInput = { + template: '#numeric-input-template', + + props: { + modelValue: [Number, String], + allowEnter: Boolean, + disabled: Boolean, + icon: String, + iconPack: String, // ignored + name: String, + placeholder: String, + size: String, + }, + + data() { + return { + orugaValue: this.modelValue, + inputElement: null, + } + }, + + watch: { + modelValue(to, from) { + this.orugaValue = to + }, + }, + + mounted() { + this.inputElement = this.$refs.input.$el.querySelector('input') + this.inputElement.addEventListener('keydown', this.keyDown) + }, + + beforeDestroy() { + this.inputElement.removeEventListener('keydown', this.keyDown) + }, + + methods: { + + focus() { + this.$refs.input.focus() + }, + + keyDown(event) { + // by default we only allow numeric keys, and general navigation + // keys, but we might also allow Enter key + if (!key_modifies(event) && !key_allowed(event)) { + if (!this.allowEnter || event.which != 13) { + event.preventDefault() + } + } + }, + + orugaValueUpdated(value) { + this.$emit('update:modelValue', value) + this.$emit('input', value) + }, + + select() { + this.$el.children[0].select() + }, + }, + } + + </script> +</%def> + +<%def name="make_tailbone_autocomplete_component()"> + <% request.register_component('tailbone-autocomplete', 'TailboneAutocomplete') %> + <script type="text/x-template" id="tailbone-autocomplete-template"> + <div> + + <o-button v-if="modelValue" + style="width: 100%; justify-content: left;" + @click="clearSelection(true)" + expanded> + {{ internalLabel }} (click to change) + </o-button> + + <o-autocomplete ref="autocompletex" + v-show="!modelValue" + v-model="orugaValue" + :placeholder="placeholder" + :data="filteredData" + :field="field" + :formatter="customFormatter" + @input="inputChanged" + @select="optionSelected" + keep-first + open-on-focus + :expanded="expanded" + :clearable="clearable" + :clear-on-select="clearOnSelect"> + <template #default="{ option }"> + {{ option.label }} + </template> + </o-autocomplete> + + <input type="hidden" :name="name" :value="modelValue" /> + </div> + </script> + <script> + + const TailboneAutocomplete = { + template: '#tailbone-autocomplete-template', + + props: { + + // this is the "input" field name essentially. primarily + // is useful for "traditional" tailbone forms; it normally + // is not used otherwise. it is passed as-is to the oruga + // autocomplete component `name` prop + name: String, + + // static data set; used when serviceUrl is not provided + data: Array, + + // the url from which search results are to be obtained. the + // url should expect a GET request with a query string with a + // single `term` parameter, and return results as a JSON array + // containing objects with `value` and `label` properties. + serviceUrl: String, + + // callers do not specify this directly but rather by way of + // the `v-model` directive. this component will emit `input` + // events when the value changes + modelValue: String, + + // callers may set an initial label if needed. this is useful + // in cases where the autocomplete needs to "already have a + // value" on page load. for instance when a user fills out + // the autocomplete field, but leaves other required fields + // blank and submits the form; page will re-load showing + // errors but the autocomplete field should remain "set" - + // normally it is only given a "value" (e.g. uuid) but this + // allows for the "label" to display correctly as well + initialLabel: String, + + // while the `initialLabel` above is useful for setting the + // *initial* label (of course), it cannot be used to + // arbitrarily update the label during the component's life. + // if you do need to *update* the label after initial page + // load, then you should set `assignedLabel` instead. one + // place this happens is in /custorders/create page, where + // product autocomplete shows some results, and user clicks + // one, but then handler logic can forcibly "swap" the + // selection, causing *different* product data to come back + // from the server, and autocomplete label should be updated + // to match. this feels a bit awkward still but does work.. + assignedLabel: String, + + // simple placeholder text for the input box + placeholder: String, + + // these are passed as-is to <o-autocomplete> + clearable: Boolean, + clearOnSelect: Boolean, + customFormatter: null, + expanded: Boolean, + field: String, + }, + + data() { + + const internalLabel = this.assignedLabel || this.initialLabel + + // we want to track the "currently selected option" - which + // should normally be `null` to begin with, unless we were + // given a value, in which case we use `initialLabel` to + // complete the option + let selected = null + if (this.modelValue) { + selected = { + value: this.modelValue, + label: internalLabel, + } + } + + return { + + // this contains the search results; its contents may + // change over time as new searches happen. the + // "currently selected option" should be one of these, + // unless it is null + fetchedData: [], + + // this tracks our "currently selected option" - per above + selected, + + // since we are wrapping a component which also makes + // use of the "value" paradigm, we must separate the + // concerns. so we use our own `modelValue` prop to + // interact with the caller, but then we use this + // `orugaValue` data point to communicate with the + // oruga autocomplete component. note that + // `this.modelValue` will always be either a uuid or + // null, whereas `this.orugaValue` may be raw text as + // entered by the user. + // orugaValue: this.modelValue, + orugaValue: null, + + // this stores the "internal" label for the button + internalLabel, + } + }, + + computed: { + + filteredData() { + + // do not filter if data comes from backend + if (this.serviceUrl) { + return this.fetchedData + } + + if (!this.orugaValue || !this.orugaValue.length) { + return this.data + } + + const terms = [] + for (let term of this.orugaValue.toLowerCase().split(' ')) { + term = term.trim() + if (term) { + terms.push(term) + } + } + if (!terms.length) { + return this.data + } + + // all terms must match + return this.data.filter((option) => { + const label = option.label.toLowerCase() + for (const term of terms) { + if (label.indexOf(term) < 0) { + return false + } + } + return true + }) + }, + }, + + watch: { + + assignedLabel(to, from) { + // update button label when caller changes it + this.internalLabel = to + }, + }, + + methods: { + + inputChanged(entry) { + if (this.serviceUrl) { + this.getAsyncData(entry) + } + }, + + // fetch new search results from the server. this is + // invoked via the `@input` event from oruga autocomplete + // component. + getAsyncData(entry) { + + // since the `@input` event from oruga component does + // not "self-regulate" in any way (?), we skip the + // search unless we have at least 3 characters of + // input from user + if (entry.length < 3) { + this.fetchedData = [] + return + } + + // and perform the search + this.$http.get(this.serviceUrl + '?term=' + encodeURIComponent(entry)) + .then(({ data }) => { + this.fetchedData = data + }) + .catch((error) => { + this.fetchedData = [] + throw error + }) + }, + + // this method is invoked via the `@select` event of the + // oruga autocomplete component. the `option` received + // will be one of: + // - object with (at least) `value` and `label` keys + // - simple string (e.g. when data set is static) + // - null + optionSelected(option) { + + this.selected = option + this.internalLabel = option?.label || option + + // reset the internal value for oruga autocomplete + // component. note that this value will normally hold + // either the raw text entered by the user, or a uuid. + // we will not be needing either of those b/c they are + // not visible to user once selection is made, and if + // the selection is cleared we want user to start over + // anyway + this.orugaValue = null + this.fetchedData = [] + + // here is where we alert callers to the new value + if (option) { + this.$emit('newLabel', option.label) + } + const value = option?.[this.field || 'value'] || option + this.$emit('update:modelValue', value) + // this.$emit('select', option) + // this.$emit('input', value) + }, + +## // set selection to the given option, which should a simple +## // object with (at least) `value` and `label` properties +## setSelection(option) { +## this.$refs.autocomplete.setSelected(option) +## }, + + // clear the field of any value, i.e. set the "currently + // selected option" to null. this is invoked when you click + // the button, which is visible while the field has a value. + // but callers can invoke it directly as well. + clearSelection(focus) { + + this.$emit('update:modelValue', null) + this.$emit('input', null) + this.$emit('newLabel', null) + this.internalLabel = null + this.selected = null + this.orugaValue = null + +## // clear selection for the oruga autocomplete component +## this.$refs.autocomplete.setSelected(null) + + // maybe set focus to our (autocomplete) component + if (focus) { + this.$nextTick(function() { + this.focus() + }) + } + }, + + // nb. this used to be relevant but now is here only for sake + // of backward-compatibility (for callers) + getDisplayText() { + return this.internalLabel + }, + + // set focus to this component, which will just set focus + // to the oruga autocomplete component + focus() { + // TODO: why is this ref null?! + if (this.$refs.autocompletex) { + this.$refs.autocompletex.focus() + } + }, + + // returns the "raw" user input from the underlying oruga + // autocomplete component + getUserInput() { + return this.orugaValue + }, + }, + } + + </script> +</%def> + +<%def name="make_tailbone_datepicker_component()"> + <% request.register_component('tailbone-datepicker', 'TailboneDatepicker') %> + <script type="text/x-template" id="tailbone-datepicker-template"> + <o-datepicker placeholder="Click to select ..." + icon="calendar-alt" + :date-formatter="formatDate" + :date-parser="parseDate" + v-model="orugaValue" + @update:model-value="orugaValueUpdated" + :disabled="disabled" + ref="trueDatePicker"> + </o-datepicker> + </script> + <script> + + const TailboneDatepicker = { + template: '#tailbone-datepicker-template', + + props: { + modelValue: [Date, String], + disabled: Boolean, + }, + + data() { + return { + orugaValue: this.parseDate(this.modelValue), + } + }, + + watch: { + modelValue(to, from) { + this.orugaValue = this.parseDate(to) + }, + }, + + methods: { + + formatDate(date) { + if (date === null) { + return null + } + if (typeof(date) == 'string') { + return date + } + // just need to convert to simple ISO date format here, seems + // like there should be a more obvious way to do that? + var year = date.getFullYear() + var month = date.getMonth() + 1 + var day = date.getDate() + month = month < 10 ? '0' + month : month + day = day < 10 ? '0' + day : day + return year + '-' + month + '-' + day + }, + + parseDate(value) { + if (typeof(value) == 'object') { + // nb. we are assuming it is a Date here + return value + } + if (value) { + // note, this assumes classic YYYY-MM-DD (i.e. ISO?) format + const parts = value.split('-') + return new Date(parts[0], parseInt(parts[1]) - 1, parts[2]) + } + return null + }, + + orugaValueUpdated(date) { + this.$emit('update:modelValue', date) + }, + + focus() { + this.$refs.trueDatePicker.focus() + }, + }, + } + + </script> +</%def> + +<%def name="make_tailbone_timepicker_component()"> + <% request.register_component('tailbone-timepicker', 'TailboneTimepicker') %> + <script type="text/x-template" id="tailbone-timepicker-template"> + <o-timepicker :name="name" + v-model="orugaValue" + @update:model-value="orugaValueUpdated" + placeholder="Click to select ..." + icon="clock" + hour-format="12" + :time-formatter="formatTime" /> + </script> + <script> + + const TailboneTimepicker = { + template: '#tailbone-timepicker-template', + + props: { + modelValue: [Date, String], + name: String, + }, + + data() { + return { + orugaValue: this.parseTime(this.modelValue), + } + }, + + watch: { + modelValue(to, from) { + this.orugaValue = this.parseTime(to) + }, + }, + + methods: { + + formatTime(value) { + if (!value) { + return null + } + + return value.toLocaleTimeString('en-US') + }, + + parseTime(value) { + if (!value) { + return value + } + + if (value.getHours) { + return value + } + + let found = value.match(/^(\d\d):(\d\d):\d\d$/) + if (found) { + return new Date(null, null, null, + parseInt(found[1]), parseInt(found[2])) + } + }, + + orugaValueUpdated(value) { + this.$emit('update:modelValue', value) + }, + }, + } + + </script> +</%def> diff --git a/tailbone/templates/themes/butterball/http-plugin.mako b/tailbone/templates/themes/butterball/http-plugin.mako new file mode 100644 index 00000000..06afc2bb --- /dev/null +++ b/tailbone/templates/themes/butterball/http-plugin.mako @@ -0,0 +1,100 @@ + +<%def name="make_http_plugin()"> + <script> + + const HttpPlugin = { + + install(app, options) { + app.config.globalProperties.$http = { + + get(url, options) { + if (options === undefined) { + options = {} + } + + if (options.params) { + // convert params to query string + const data = new URLSearchParams() + for (let [key, value] of Object.entries(options.params)) { + // nb. all values get converted to string here, so + // fallback to empty string to avoid null value + // from being interpreted as "null" string + if (value === null) { + value = '' + } + data.append(key, value) + } + // TODO: this should be smarter in case query string already exists + url += '?' + data.toString() + // params is not a valid arg for options to fetch() + delete options.params + } + + return new Promise((resolve, reject) => { + fetch(url, options).then(response => { + // original response does not contain 'data' + // attribute, so must use a "mock" response + // which does contain everything + response.json().then(json => { + resolve({ + data: json, + headers: response.headers, + ok: response.ok, + redirected: response.redirected, + status: response.status, + statusText: response.statusText, + type: response.type, + url: response.url, + }) + }, json => { + reject(response) + }) + }, response => { + reject(response) + }) + }) + }, + + post(url, params, options) { + + if (params) { + + // attach params as json + options.body = JSON.stringify(params) + + // and declare content-type + options.headers = new Headers(options.headers) + options.headers.append('Content-Type', 'application/json') + } + + options.method = 'POST' + + return new Promise((resolve, reject) => { + fetch(url, options).then(response => { + // original response does not contain 'data' + // attribute, so must use a "mock" response + // which does contain everything + response.json().then(json => { + resolve({ + data: json, + headers: response.headers, + ok: response.ok, + redirected: response.redirected, + status: response.status, + statusText: response.statusText, + type: response.type, + url: response.url, + }) + }, json => { + reject(response) + }) + }, response => { + reject(response) + }) + }) + }, + } + }, + } + </script> +</%def> diff --git a/tailbone/templates/themes/butterball/progress.mako b/tailbone/templates/themes/butterball/progress.mako new file mode 100644 index 00000000..1c389fb8 --- /dev/null +++ b/tailbone/templates/themes/butterball/progress.mako @@ -0,0 +1,244 @@ +## -*- coding: utf-8; -*- +<%namespace name="base_meta" file="/base_meta.mako" /> +<%namespace file="/base.mako" import="core_javascript" /> +<%namespace file="/base.mako" import="core_styles" /> +<%namespace file="/http-plugin.mako" import="make_http_plugin" /> +<!DOCTYPE html> +<html lang="en"> + <head> + <meta http-equiv="content-type" content="text/html; charset=UTF-8" /> + ${base_meta.favicon()} + <title>${initial_msg or "Working"}...</title> + ${core_javascript()} + ${core_styles()} + ${self.extra_styles()} + </head> + + <body> + <div id="app" style="height: 100%; display: flex; flex-direction: column; justify-content: space-between;"> + <whole-page></whole-page> + </div> + + ${make_http_plugin()} + ${self.make_whole_page_component()} + ${self.modify_whole_page_vars()} + ${self.make_whole_page_app()} + </body> +</html> + +<%def name="extra_styles()"></%def> + +<%def name="make_whole_page_component()"> + <script type="text/x-template" id="whole-page-template"> + <section class="hero is-fullheight"> + <div class="hero-body"> + <div class="container"> + + <div style="display: flex; flex-direction: column; justify-content: center;"> + <div style="margin: auto; display: flex; gap: 1rem; align-items: end;"> + + <div style="display: flex; flex-direction: column; gap: 1rem;"> + + <div style="display: flex; gap: 3rem;"> + <span>{{ progressMessage }} ... {{ totalDisplay }}</span> + <span>{{ percentageDisplay }}</span> + </div> + + <div style="display: flex; gap: 1rem; align-items: center;"> + + <div> + <progress class="progress is-large" + style="width: 400px;" + :max="progressMax" + :value="progressValue" /> + </div> + + % if can_cancel: + <o-button v-show="canCancel" + @click="cancelProgress()" + :disabled="cancelingProgress" + icon-left="ban"> + {{ cancelingProgress ? "Canceling, please wait..." : "Cancel" }} + </o-button> + % endif + + </div> + </div> + + </div> + </div> + + ${self.after_progress()} + + </div> + </div> + </section> + </script> + <script> + + const WholePage = { + template: '#whole-page-template', + + computed: { + + percentageDisplay() { + if (!this.progressMax) { + return + } + + const percent = this.progressValue / this.progressMax + return percent.toLocaleString(undefined, { + style: 'percent', + minimumFractionDigits: 0}) + }, + + totalDisplay() { + + % if can_cancel: + if (!this.stillInProgress && !this.cancelingProgress) { + return "done!" + } + % else: + if (!this.stillInProgress) { + return "done!" + } + % endif + + if (this.progressMaxDisplay) { + return `(${'$'}{this.progressMaxDisplay} total)` + } + }, + }, + + mounted() { + + // fetch first progress data, one second from now + setTimeout(() => { + this.updateProgress() + }, 1000) + + // custom logic if applicable + this.mountedCustom() + }, + + methods: { + + mountedCustom() {}, + + updateProgress() { + + this.$http.get(this.progressURL).then(response => { + + if (response.data.error) { + // errors stop the show, we redirect to "cancel" page + location.href = '${cancel_url}' + + } else { + + if (response.data.complete || response.data.maximum) { + this.progressMessage = response.data.message + this.progressMaxDisplay = response.data.maximum_display + + if (response.data.complete) { + this.progressValue = this.progressMax + this.stillInProgress = false + % if can_cancel: + this.canCancel = false + % endif + + location.href = response.data.success_url + + } else { + this.progressValue = response.data.value + this.progressMax = response.data.maximum + } + } + + // custom logic if applicable + this.updateProgressCustom(response) + + if (this.stillInProgress) { + + // fetch progress data again, in one second from now + setTimeout(() => { + this.updateProgress() + }, 1000) + } + } + }) + }, + + updateProgressCustom(response) {}, + + % if can_cancel: + + cancelProgress() { + + if (confirm("Do you really wish to cancel this operation?")) { + + this.cancelingProgress = true + this.stillInProgress = false + + let params = {cancel_msg: ${json.dumps(cancel_msg)|n}} + this.$http.get(this.cancelURL, {params: params}).then(response => { + location.href = ${json.dumps(cancel_url)|n} + }) + } + + }, + + % endif + } + } + + const WholePageData = { + + progressURL: '${url('progress', key=progress.key, _query={'sessiontype': progress.session.type})}', + progressMessage: "${(initial_msg or "Working").replace('"', '\\"')} (please wait)", + progressMax: null, + progressMaxDisplay: null, + progressValue: null, + stillInProgress: true, + + % if can_cancel: + canCancel: true, + cancelURL: '${url('progress.cancel', key=progress.key, _query={'sessiontype': progress.session.type})}', + cancelingProgress: false, + % endif + } + + </script> +</%def> + +<%def name="after_progress()"></%def> + +<%def name="modify_whole_page_vars()"></%def> + +<%def name="make_whole_page_app()"> + <script type="module"> + import {createApp} from 'vue' + import {Oruga} from '@oruga-ui/oruga-next' + import {bulmaConfig} from '@oruga-ui/theme-bulma' + import { library } from "@fortawesome/fontawesome-svg-core" + import { fas } from "@fortawesome/free-solid-svg-icons" + import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome" + library.add(fas) + + const app = createApp() + + app.component('vue-fontawesome', FontAwesomeIcon) + + WholePage.data = () => { return WholePageData } + app.component('whole-page', WholePage) + + app.use(Oruga, { + ...bulmaConfig, + iconComponent: 'vue-fontawesome', + iconPack: 'fas', + }) + + app.use(HttpPlugin) + + app.mount('#app') + </script> +</%def> diff --git a/tailbone/templates/themes/waterpark/base.mako b/tailbone/templates/themes/waterpark/base.mako new file mode 100644 index 00000000..774479ba --- /dev/null +++ b/tailbone/templates/themes/waterpark/base.mako @@ -0,0 +1,504 @@ +## -*- coding: utf-8; -*- +<%inherit file="wuttaweb:templates/base.mako" /> +<%namespace name="base_meta" file="/base_meta.mako" /> +<%namespace file="/formposter.mako" import="declare_formposter_mixin" /> +<%namespace file="/grids/filter-components.mako" import="make_grid_filter_components" /> +<%namespace name="page_help" file="/page_help.mako" /> + +<%def name="base_styles()"> + ${parent.base_styles()} + ${h.stylesheet_link(request.static_url('tailbone:static/css/diffs.css') + '?ver={}'.format(tailbone.__version__))} + <style> + + .filters .filter-fieldname .field, + .filters .filter-fieldname .field label { + width: 100%; + } + + .filters .filter-fieldname, + .filters .filter-fieldname .field label, + .filters .filter-fieldname .button { + justify-content: left; + } + + .filters .filter-verb .select, + .filters .filter-verb .select select { + width: 100%; + } + + % if filter_fieldname_width is not Undefined: + + .filters .filter-fieldname, + .filters .filter-fieldname .button { + min-width: ${filter_fieldname_width}; + } + + .filters .filter-verb { + min-width: ${filter_verb_width}; + } + + % endif + + </style> +</%def> + +<%def name="before_content()"> + ## TODO: this must come before the self.body() call..but why? + ${declare_formposter_mixin()} +</%def> + +<%def name="render_navbar_brand()"> + <div class="navbar-brand"> + <a class="navbar-item" href="${url('home')}" + v-show="!menuSearchActive"> + <div style="display: flex; align-items: center;"> + ${base_meta.header_logo()} + <div id="navbar-brand-title"> + ${base_meta.global_title()} + </div> + </div> + </a> + <div v-show="menuSearchActive" + class="navbar-item"> + <b-autocomplete ref="menuSearchAutocomplete" + v-model="menuSearchTerm" + :data="menuSearchFilteredData" + field="label" + open-on-focus + keep-first + icon-pack="fas" + clearable + @keydown.native="menuSearchKeydown" + @select="menuSearchSelect"> + </b-autocomplete> + </div> + <a role="button" class="navbar-burger" data-target="navbar-menu" aria-label="menu" aria-expanded="false"> + <span aria-hidden="true"></span> + <span aria-hidden="true"></span> + <span aria-hidden="true"></span> + </a> + </div> +</%def> + +<%def name="render_navbar_start()"> + <div class="navbar-start"> + + <div v-if="menuSearchData.length" + class="navbar-item"> + <b-button type="is-primary" + size="is-small" + @click="menuSearchInit()"> + <span><i class="fa fa-search"></i></span> + </b-button> + </div> + + % for topitem in menus: + % if topitem['is_link']: + ${h.link_to(topitem['title'], topitem['url'], target=topitem['target'], class_='navbar-item')} + % else: + <div class="navbar-item has-dropdown is-hoverable"> + <a class="navbar-link">${topitem['title']}</a> + <div class="navbar-dropdown"> + % for item in topitem['items']: + % if item['is_menu']: + <% item_hash = id(item) %> + <% toggle = 'menu_{}_shown'.format(item_hash) %> + <div> + <a class="navbar-link" @click.prevent="toggleNestedMenu('${item_hash}')"> + ${item['title']} + </a> + </div> + % for subitem in item['items']: + % if subitem['is_sep']: + <hr class="navbar-divider" v-show="${toggle}"> + % else: + ${h.link_to("{}".format(subitem['title']), subitem['url'], class_='navbar-item nested', target=subitem['target'], **{'v-show': toggle})} + % endif + % endfor + % else: + % if item['is_sep']: + <hr class="navbar-divider"> + % else: + ${h.link_to(item['title'], item['url'], class_='navbar-item', target=item['target'])} + % endif + % endif + % endfor + </div> + </div> + % endif + % endfor + + </div> +</%def> + +<%def name="render_theme_picker()"> + % if expose_theme_picker and request.has_perm('common.change_app_theme'): + <div class="level-item"> + ${h.form(url('change_theme'), method="post", ref='themePickerForm')} + ${h.csrf_token(request)} + <input type="hidden" name="referrer" :value="referrer" /> + <div style="display: flex; align-items: center; gap: 0.5rem;"> + <span>Theme:</span> + <b-select name="theme" + v-model="globalTheme" + @input="changeTheme()"> + % for option in theme_picker_options: + <option value="${option.value}"> + ${option.label} + </option> + % endfor + </b-select> + </div> + ${h.end_form()} + </div> + % endif +</%def> + +<%def name="render_feedback_button()"> + + <div class="level-item"> + <page-help + % if can_edit_help: + @configure-fields-help="configureFieldsHelp = true" + % endif + /> + </div> + + ${parent.render_feedback_button()} +</%def> + +<%def name="render_crud_header_buttons()"> + % if master: + % if master.viewing: + % if instance_editable and master.has_perm('edit'): + <wutta-button once + tag="a" href="${master.get_action_url('edit', instance)}" + icon-left="edit" + label="Edit This" /> + % endif + % if getattr(master, 'cloneable', False) and not master.cloning and master.has_perm('clone'): + <wutta-button once + tag="a" href="${master.get_action_url('clone', instance)}" + icon-left="object-ungroup" + label="Clone This" /> + % endif + % if instance_deletable and master.has_perm('delete'): + <wutta-button once type="is-danger" + tag="a" href="${master.get_action_url('delete', instance)}" + icon-left="trash" + label="Delete This" /> + % endif + % elif master.editing: + % if master.has_perm('view'): + <wutta-button once + tag="a" href="${master.get_action_url('view', instance)}" + icon-left="eye" + label="View This" /> + % endif + % if instance_deletable and master.has_perm('delete'): + <wutta-button once type="is-danger" + tag="a" href="${master.get_action_url('delete', instance)}" + icon-left="trash" + label="Delete This" /> + % endif + % elif master.deleting: + % if master.has_perm('view'): + <wutta-button once + tag="a" href="${master.get_action_url('view', instance)}" + icon-left="eye" + label="View This" /> + % endif + % if instance_editable and master.has_perm('edit'): + <wutta-button once + tag="a" href="${master.get_action_url('edit', instance)}" + icon-left="edit" + label="Edit This" /> + % endif + % endif + % endif +</%def> + +<%def name="render_prevnext_header_buttons()"> + % if show_prev_next is not Undefined and show_prev_next: + % if prev_url: + <wutta-button once + tag="a" href="${prev_url}" + icon-left="arrow-left" + label="Older" /> + % else: + <b-button tag="a" href="#" + disabled + icon-pack="fas" + icon-left="arrow-left"> + Older + </b-button> + % endif + % if next_url: + <wutta-button once + tag="a" href="${next_url}" + icon-left="arrow-right" + label="Newer" /> + % else: + <b-button tag="a" href="#" + disabled + icon-pack="fas" + icon-left="arrow-right"> + Newer + </b-button> + % endif + % endif +</%def> + +<%def name="render_this_page_component()"> + <this-page @change-content-title="changeContentTitle" + % if can_edit_help: + :configure-fields-help="configureFieldsHelp" + % endif + /> +</%def> + +<%def name="render_vue_template_feedback()"> + <script type="text/x-template" id="feedback-template"> + <div> + + <div class="level-item"> + <b-button type="is-primary" + @click="showFeedback()" + icon-pack="fas" + icon-left="comment"> + Feedback + </b-button> + </div> + + <b-modal has-modal-card + :active.sync="showDialog"> + <div class="modal-card"> + + <header class="modal-card-head"> + <p class="modal-card-title">User Feedback</p> + </header> + + <section class="modal-card-body"> + <p class="block"> + Questions, suggestions, comments, complaints, etc. + <span class="red">regarding this website</span> are + welcome and may be submitted below. + </p> + + <b-field label="User Name"> + <b-input v-model="userName" + % if request.user: + disabled + % endif + > + </b-input> + </b-field> + + <b-field label="Referring URL"> + <b-input + v-model="referrer" + disabled="true"> + </b-input> + </b-field> + + <b-field label="Message"> + <b-input type="textarea" + v-model="message" + ref="textarea"> + </b-input> + </b-field> + + % if config.get_bool('tailbone.feedback_allows_reply'): + <div class="level"> + <div class="level-left"> + <div class="level-item"> + <b-checkbox v-model="pleaseReply" + @input="pleaseReplyChanged"> + Please email me back{{ pleaseReply ? " at: " : "" }} + </b-checkbox> + </div> + <div class="level-item" v-show="pleaseReply"> + <b-input v-model="userEmail" + ref="userEmail"> + </b-input> + </div> + </div> + </div> + % endif + + </section> + + <footer class="modal-card-foot"> + <b-button @click="showDialog = false"> + Cancel + </b-button> + <b-button type="is-primary" + icon-pack="fas" + icon-left="paper-plane" + @click="sendFeedback()" + :disabled="sendingFeedback || !message || !message.trim()"> + {{ sendingFeedback ? "Working, please wait..." : "Send Message" }} + </b-button> + </footer> + </div> + </b-modal> + + </div> + </script> +</%def> + +<%def name="render_vue_script_feedback()"> + ${parent.render_vue_script_feedback()} + <script> + + WuttaFeedbackForm.template = '#feedback-template' + WuttaFeedbackForm.props.message = String + + % if config.get_bool('tailbone.feedback_allows_reply'): + + WuttaFeedbackFormData.pleaseReply = false + WuttaFeedbackFormData.userEmail = null + + WuttaFeedbackForm.methods.pleaseReplyChanged = function(value) { + this.$nextTick(() => { + this.$refs.userEmail.focus() + }) + } + + WuttaFeedbackForm.methods.getExtraParams = function() { + return { + please_reply_to: this.pleaseReply ? this.userEmail : null, + } + } + + % endif + + // TODO: deprecate / remove these + const FeedbackForm = WuttaFeedbackForm + const FeedbackFormData = WuttaFeedbackFormData + + </script> +</%def> + +<%def name="render_vue_templates()"> + ${parent.render_vue_templates()} + ${page_help.render_template()} + ${page_help.declare_vars()} +</%def> + +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> + + ############################## + ## menu search + ############################## + + WholePageData.menuSearchActive = false + WholePageData.menuSearchTerm = '' + WholePageData.menuSearchData = ${json.dumps(global_search_data or [])|n} + + WholePage.computed.menuSearchFilteredData = function() { + if (!this.menuSearchTerm.length) { + return this.menuSearchData + } + + const terms = [] + for (let term of this.menuSearchTerm.toLowerCase().split(' ')) { + term = term.trim() + if (term) { + terms.push(term) + } + } + if (!terms.length) { + return this.menuSearchData + } + + // all terms must match + return this.menuSearchData.filter((option) => { + const label = option.label.toLowerCase() + for (const term of terms) { + if (label.indexOf(term) < 0) { + return false + } + } + return true + }) + } + + WholePage.methods.globalKey = function(event) { + + // Ctrl+8 opens menu search + if (event.target.tagName == 'BODY') { + if (event.ctrlKey && event.key == '8') { + this.menuSearchInit() + } + } + } + + WholePage.mounted = function() { + window.addEventListener('keydown', this.globalKey) + for (let hook of this.mountedHooks) { + hook(this) + } + } + + WholePage.beforeDestroy = function() { + window.removeEventListener('keydown', this.globalKey) + } + + WholePage.methods.menuSearchInit = function() { + this.menuSearchTerm = '' + this.menuSearchActive = true + this.$nextTick(() => { + this.$refs.menuSearchAutocomplete.focus() + }) + } + + WholePage.methods.menuSearchKeydown = function(event) { + + // ESC will dismiss searchbox + if (event.which == 27) { + this.menuSearchActive = false + } + } + + WholePage.methods.menuSearchSelect = function(option) { + location.href = option.url + } + + ############################## + ## theme picker + ############################## + + % if expose_theme_picker and request.has_perm('common.change_app_theme'): + + WholePageData.globalTheme = ${json.dumps(theme or None)|n} + ## WholePageData.referrer = location.href + + WholePage.methods.changeTheme = function() { + this.$refs.themePickerForm.submit() + } + + % endif + + ############################## + ## edit fields help + ############################## + + % if can_edit_help: + WholePageData.configureFieldsHelp = false + % endif + + </script> +</%def> + +<%def name="make_vue_components()"> + ${parent.make_vue_components()} + ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.datepicker.js') + f'?ver={tailbone.__version__}')} + ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.numericinput.js') + f'?ver={tailbone.__version__}')} + ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.oncebutton.js') + f'?ver={tailbone.__version__}')} + ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.timepicker.js') + f'?ver={tailbone.__version__}')} + ${make_grid_filter_components()} + ${page_help.make_component()} +</%def> diff --git a/tailbone/templates/themes/waterpark/configure.mako b/tailbone/templates/themes/waterpark/configure.mako new file mode 100644 index 00000000..7a3e5261 --- /dev/null +++ b/tailbone/templates/themes/waterpark/configure.mako @@ -0,0 +1,78 @@ +## -*- coding: utf-8; -*- +<%inherit file="wuttaweb:templates/configure.mako" /> +<%namespace name="tailbone_base" file="tailbone:templates/configure.mako" /> + +<%def name="input_file_templates_section()"> + ${tailbone_base.input_file_templates_section()} +</%def> + +<%def name="output_file_templates_section()"> + ${tailbone_base.output_file_templates_section()} +</%def> + +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> + + ############################## + ## input file templates + ############################## + + % if input_file_template_settings is not Undefined: + + ThisPageData.inputFileTemplateSettings = ${json.dumps(input_file_template_settings)|n} + ThisPageData.inputFileTemplateFileOptions = ${json.dumps(input_file_options)|n} + ThisPageData.inputFileTemplateUploads = { + % for key in input_file_templates: + '${key}': null, + % endfor + } + + ThisPage.methods.validateInputFileTemplateSettings = function() { + % for tmpl in input_file_templates.values(): + if (this.inputFileTemplateSettings['${tmpl['setting_mode']}'] == 'hosted') { + if (!this.inputFileTemplateSettings['${tmpl['setting_file']}']) { + if (!this.inputFileTemplateUploads['${tmpl['key']}']) { + return "You must provide a file to upload for the ${tmpl['label']} template." + } + } + } + % endfor + } + + ThisPageData.validators.push(ThisPage.methods.validateInputFileTemplateSettings) + + % endif + + ############################## + ## output file templates + ############################## + + % if output_file_template_settings is not Undefined: + + ThisPageData.outputFileTemplateSettings = ${json.dumps(output_file_template_settings)|n} + ThisPageData.outputFileTemplateFileOptions = ${json.dumps(output_file_options)|n} + ThisPageData.outputFileTemplateUploads = { + % for key in output_file_templates: + '${key}': null, + % endfor + } + + ThisPage.methods.validateOutputFileTemplateSettings = function() { + % for tmpl in output_file_templates.values(): + if (this.outputFileTemplateSettings['${tmpl['setting_mode']}'] == 'hosted') { + if (!this.outputFileTemplateSettings['${tmpl['setting_file']}']) { + if (!this.outputFileTemplateUploads['${tmpl['key']}']) { + return "You must provide a file to upload for the ${tmpl['label']} template." + } + } + } + % endfor + } + + ThisPageData.validators.push(ThisPage.methods.validateOutputFileTemplateSettings) + + % endif + + </script> +</%def> diff --git a/tailbone/templates/themes/waterpark/form.mako b/tailbone/templates/themes/waterpark/form.mako new file mode 100644 index 00000000..f88d6821 --- /dev/null +++ b/tailbone/templates/themes/waterpark/form.mako @@ -0,0 +1,10 @@ +## -*- coding: utf-8; -*- +<%inherit file="wuttaweb:templates/form.mako" /> + +<%def name="render_vue_template_form()"> + % if form is not Undefined: + ${form.render_vue_template(buttons=capture(self.render_form_buttons))} + % endif +</%def> + +<%def name="render_form_buttons()"></%def> diff --git a/tailbone/templates/themes/waterpark/master/configure.mako b/tailbone/templates/themes/waterpark/master/configure.mako new file mode 100644 index 00000000..51da5b0a --- /dev/null +++ b/tailbone/templates/themes/waterpark/master/configure.mako @@ -0,0 +1,2 @@ +## -*- coding: utf-8; -*- +<%inherit file="wuttaweb:templates/master/configure.mako" /> diff --git a/tailbone/templates/themes/waterpark/master/create.mako b/tailbone/templates/themes/waterpark/master/create.mako new file mode 100644 index 00000000..23399b9e --- /dev/null +++ b/tailbone/templates/themes/waterpark/master/create.mako @@ -0,0 +1,2 @@ +## -*- coding: utf-8; -*- +<%inherit file="wuttaweb:templates/master/create.mako" /> diff --git a/tailbone/templates/themes/waterpark/master/delete.mako b/tailbone/templates/themes/waterpark/master/delete.mako new file mode 100644 index 00000000..a15dfaf8 --- /dev/null +++ b/tailbone/templates/themes/waterpark/master/delete.mako @@ -0,0 +1,46 @@ +## -*- coding: utf-8; -*- +<%inherit file="tailbone:templates/form.mako" /> + +<%def name="title()">Delete ${model_title}: ${instance_title}</%def> + +<%def name="render_form()"> + <br /> + <b-notification type="is-danger" :closable="false"> + You are about to delete the following ${model_title} and all associated data: + </b-notification> + ${parent.render_form()} +</%def> + +<%def name="render_form_buttons()"> + <br /> + <b-notification type="is-danger" :closable="false"> + Are you sure about this? + </b-notification> + <br /> + + ${h.form(request.current_route_url(), **{'@submit': 'submitForm'})} + ${h.csrf_token(request)} + <div class="buttons"> + <wutta-button once tag="a" href="${form.cancel_url}" + label="Whoops, nevermind..." /> + <b-button type="is-primary is-danger" + native-type="submit" + :disabled="formSubmitting"> + {{ formSubmitting ? "Working, please wait..." : "${form.button_label_submit}" }} + </b-button> + </div> + ${h.end_form()} +</%def> + +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> + + ${form.vue_component}Data.formSubmitting = false + + ${form.vue_component}.methods.submitForm = function() { + this.formSubmitting = true + } + + </script> +</%def> diff --git a/tailbone/templates/themes/waterpark/master/edit.mako b/tailbone/templates/themes/waterpark/master/edit.mako new file mode 100644 index 00000000..18a2fa2f --- /dev/null +++ b/tailbone/templates/themes/waterpark/master/edit.mako @@ -0,0 +1,2 @@ +## -*- coding: utf-8; -*- +<%inherit file="wuttaweb:templates/master/edit.mako" /> diff --git a/tailbone/templates/themes/waterpark/master/form.mako b/tailbone/templates/themes/waterpark/master/form.mako new file mode 100644 index 00000000..db56843b --- /dev/null +++ b/tailbone/templates/themes/waterpark/master/form.mako @@ -0,0 +1,2 @@ +## -*- coding: utf-8; -*- +<%inherit file="wuttaweb:templates/master/form.mako" /> diff --git a/tailbone/templates/themes/waterpark/master/index.mako b/tailbone/templates/themes/waterpark/master/index.mako new file mode 100644 index 00000000..e6702599 --- /dev/null +++ b/tailbone/templates/themes/waterpark/master/index.mako @@ -0,0 +1,299 @@ +## -*- coding: utf-8; -*- +<%inherit file="wuttaweb:templates/master/index.mako" /> + +<%def name="grid_tools()"> + + ## grid totals + % if getattr(master, 'supports_grid_totals', False): + <div style="display: flex; align-items: center;"> + <b-button v-if="gridTotalsDisplay == null" + :disabled="gridTotalsFetching" + @click="gridTotalsFetch()"> + {{ gridTotalsFetching ? "Working, please wait..." : "Show Totals" }} + </b-button> + <div v-if="gridTotalsDisplay != null" + class="control"> + Totals: {{ gridTotalsDisplay }} + </div> + </div> + % endif + + ## download search results + % if getattr(master, 'results_downloadable', False) and master.has_perm('download_results'): + <div> + <b-button type="is-primary" + icon-pack="fas" + icon-left="download" + @click="showDownloadResultsDialog = true" + :disabled="!total"> + Download Results + </b-button> + + ${h.form(url('{}.download_results'.format(route_prefix)), ref='download_results_form')} + ${h.csrf_token(request)} + <input type="hidden" name="fmt" :value="downloadResultsFormat" /> + <input type="hidden" name="fields" :value="downloadResultsFieldsIncluded" /> + ${h.end_form()} + + <b-modal :active.sync="showDownloadResultsDialog"> + <div class="card"> + + <div class="card-content"> + <p> + There are + <span class="is-size-4 has-text-weight-bold"> + {{ total.toLocaleString('en') }} ${model_title_plural} + </span> + matching your current filters. + </p> + <p> + You may download this set as a single data file if you like. + </p> + <br /> + + <b-notification type="is-warning" :closable="false" + v-if="downloadResultsFormat == 'xlsx' && total >= 1000"> + Excel downloads for large data sets can take a long time to + generate, and bog down the server in the meantime. You are + encouraged to choose CSV for a large data set, even though + the end result (file size) may be larger with CSV. + </b-notification> + + <div style="display: flex; justify-content: space-between"> + + <div> + <b-field label="Format"> + <b-select v-model="downloadResultsFormat"> + % for key, label in master.download_results_supported_formats().items(): + <option value="${key}">${label}</option> + % endfor + </b-select> + </b-field> + </div> + + <div> + + <div v-show="downloadResultsFieldsMode != 'choose'" + class="has-text-right"> + <p v-if="downloadResultsFieldsMode == 'default'"> + Will use DEFAULT fields. + </p> + <p v-if="downloadResultsFieldsMode == 'all'"> + Will use ALL fields. + </p> + <br /> + </div> + + <div class="buttons is-right"> + <b-button type="is-primary" + v-show="downloadResultsFieldsMode != 'default'" + @click="downloadResultsUseDefaultFields()"> + Use Default Fields + </b-button> + <b-button type="is-primary" + v-show="downloadResultsFieldsMode != 'all'" + @click="downloadResultsUseAllFields()"> + Use All Fields + </b-button> + <b-button type="is-primary" + v-show="downloadResultsFieldsMode != 'choose'" + @click="downloadResultsFieldsMode = 'choose'"> + Choose Fields + </b-button> + </div> + + <div v-show="downloadResultsFieldsMode == 'choose'"> + <div style="display: flex;"> + <div> + <b-field label="Excluded Fields"> + <b-select multiple native-size="8" + expanded + v-model="downloadResultsExcludedFieldsSelected" + ref="downloadResultsExcludedFields"> + <option v-for="field in downloadResultsFieldsExcluded" + :key="field" + :value="field"> + {{ field }} + </option> + </b-select> + </b-field> + </div> + <div> + <br /><br /> + <b-button style="margin: 0.5rem;" + @click="downloadResultsExcludeFields()"> + < + </b-button> + <br /> + <b-button style="margin: 0.5rem;" + @click="downloadResultsIncludeFields()"> + > + </b-button> + </div> + <div> + <b-field label="Included Fields"> + <b-select multiple native-size="8" + expanded + v-model="downloadResultsIncludedFieldsSelected" + ref="downloadResultsIncludedFields"> + <option v-for="field in downloadResultsFieldsIncluded" + :key="field" + :value="field"> + {{ field }} + </option> + </b-select> + </b-field> + </div> + </div> + </div> + + </div> + </div> + </div> <!-- card-content --> + + <footer class="modal-card-foot"> + <b-button @click="showDownloadResultsDialog = false"> + Cancel + </b-button> + <once-button type="is-primary" + @click="downloadResultsSubmit()" + icon-pack="fas" + icon-left="download" + :disabled="!downloadResultsFieldsIncluded.length" + text="Download Results"> + </once-button> + </footer> + </div> + </b-modal> + </div> + % endif + + ## download rows for search results + % if getattr(master, 'has_rows', False) and master.results_rows_downloadable and master.has_perm('download_results_rows'): + <b-button type="is-primary" + icon-pack="fas" + icon-left="download" + @click="downloadResultsRows()" + :disabled="downloadResultsRowsButtonDisabled"> + {{ downloadResultsRowsButtonText }} + </b-button> + ${h.form(url('{}.download_results_rows'.format(route_prefix)), ref='downloadResultsRowsForm')} + ${h.csrf_token(request)} + ${h.end_form()} + % endif + + ## merge 2 objects + % if getattr(master, 'mergeable', False) and request.has_perm('{}.merge'.format(permission_prefix)): + + ${h.form(url('{}.merge'.format(route_prefix)), class_='control', **{'@submit': 'submitMergeForm'})} + ${h.csrf_token(request)} + <input type="hidden" + name="uuids" + :value="checkedRowUUIDs()" /> + <b-button type="is-primary" + native-type="submit" + icon-pack="fas" + icon-left="object-ungroup" + :disabled="mergeFormSubmitting || checkedRows.length != 2"> + {{ mergeFormButtonText }} + </b-button> + ${h.end_form()} + % endif + + ## enable / disable selected objects + % if getattr(master, 'supports_set_enabled_toggle', False) and master.has_perm('enable_disable_set'): + + ${h.form(url('{}.enable_set'.format(route_prefix)), class_='control', ref='enable_selected_form')} + ${h.csrf_token(request)} + ${h.hidden('uuids', v_model='selected_uuids')} + <b-button :disabled="enableSelectedDisabled" + @click="enableSelectedSubmit()"> + {{ enableSelectedText }} + </b-button> + ${h.end_form()} + + ${h.form(url('{}.disable_set'.format(route_prefix)), ref='disable_selected_form', class_='control')} + ${h.csrf_token(request)} + ${h.hidden('uuids', v_model='selected_uuids')} + <b-button :disabled="disableSelectedDisabled" + @click="disableSelectedSubmit()"> + {{ disableSelectedText }} + </b-button> + ${h.end_form()} + % endif + + ## delete selected objects + % if getattr(master, 'set_deletable', False) and master.has_perm('delete_set'): + ${h.form(url('{}.delete_set'.format(route_prefix)), ref='delete_selected_form', class_='control')} + ${h.csrf_token(request)} + ${h.hidden('uuids', v_model='selected_uuids')} + <b-button type="is-danger" + :disabled="deleteSelectedDisabled" + @click="deleteSelectedSubmit()" + icon-pack="fas" + icon-left="trash"> + {{ deleteSelectedText }} + </b-button> + ${h.end_form()} + % endif + + ## delete search results + % if getattr(master, 'bulk_deletable', False) and request.has_perm('{}.bulk_delete'.format(permission_prefix)): + ${h.form(url('{}.bulk_delete'.format(route_prefix)), ref='delete_results_form', class_='control')} + ${h.csrf_token(request)} + <b-button type="is-danger" + :disabled="deleteResultsDisabled" + :title="total ? null : 'There are no results to delete'" + @click="deleteResultsSubmit()" + icon-pack="fas" + icon-left="trash"> + {{ deleteResultsText }} + </b-button> + ${h.end_form()} + % endif + +</%def> + +## DEPRECATED; remains for back-compat +<%def name="render_this_page()"> + ${self.page_content()} +</%def> + +<%def name="render_vue_template_grid()"> + ${grid.render_vue_template(tools=capture(self.grid_tools).strip(), context_menu=capture(self.context_menu_items).strip())} +</%def> + +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> + + % if getattr(master, 'bulk_deletable', False) and master.has_perm('bulk_delete'): + + ${grid.vue_component}Data.deleteResultsSubmitting = false + ${grid.vue_component}Data.deleteResultsText = "Delete Results" + + ${grid.vue_component}.computed.deleteResultsDisabled = function() { + if (this.deleteResultsSubmitting) { + return true + } + if (!this.total) { + return true + } + return false + } + + ${grid.vue_component}.methods.deleteResultsSubmit = function() { + // TODO: show "plural model title" here? + if (!confirm("You are about to delete " + this.total.toLocaleString('en') + " objects.\n\nAre you sure?")) { + return + } + + this.deleteResultsSubmitting = true + this.deleteResultsText = "Working, please wait..." + this.$refs.delete_results_form.submit() + } + + % endif + + </script> +</%def> diff --git a/tailbone/templates/themes/waterpark/master/view.mako b/tailbone/templates/themes/waterpark/master/view.mako new file mode 100644 index 00000000..99194469 --- /dev/null +++ b/tailbone/templates/themes/waterpark/master/view.mako @@ -0,0 +1,2 @@ +## -*- coding: utf-8; -*- +<%inherit file="wuttaweb:templates/master/view.mako" /> diff --git a/tailbone/templates/themes/waterpark/page.mako b/tailbone/templates/themes/waterpark/page.mako new file mode 100644 index 00000000..66ce47dc --- /dev/null +++ b/tailbone/templates/themes/waterpark/page.mako @@ -0,0 +1,48 @@ +## -*- coding: utf-8; -*- +<%inherit file="wuttaweb:templates/page.mako" /> + +<%def name="render_vue_template_this_page()"> + <script type="text/x-template" id="this-page-template"> + <div style="height: 100%;"> + ## DEPRECATED; called for back-compat + ${self.render_this_page()} + </div> + </script> +</%def> + +## DEPRECATED; remains for back-compat +<%def name="render_this_page()"> + <div style="display: flex;"> + + <div class="this-page-content" style="flex-grow: 1;"> + ${self.page_content()} + </div> + + ## DEPRECATED; remains for back-compat + <ul id="context-menu"> + ${self.context_menu_items()} + </ul> + </div> +</%def> + +## DEPRECATED; remains for back-compat +<%def name="context_menu_items()"> + % if context_menu_list_items is not Undefined: + % for item in context_menu_list_items: + <li>${item}</li> + % endfor + % endif +</%def> + +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> + + ThisPageData.csrftoken = ${json.dumps(h.get_csrf_token(request))|n} + + % if can_edit_help: + ThisPage.props.configureFieldsHelp = Boolean + % endif + + </script> +</%def> diff --git a/tailbone/templates/trainwreck/transactions/configure.mako b/tailbone/templates/trainwreck/transactions/configure.mako index fd6c53a7..10c57e18 100644 --- a/tailbone/templates/trainwreck/transactions/configure.mako +++ b/tailbone/templates/trainwreck/transactions/configure.mako @@ -3,6 +3,19 @@ <%def name="form_content()"> + <h3 class="block is-size-3">Display</h3> + <div class="block" style="padding-left: 2rem;"> + + <b-field> + <b-checkbox name="tailbone.trainwreck.view_txn.autocollapse_header" + v-model="simpleSettings['tailbone.trainwreck.view_txn.autocollapse_header']" + native-value="true" + @input="settingsNeedSaved = true"> + Auto-collapse header when viewing transaction + </b-checkbox> + </b-field> + </div> + <h3 class="block is-size-3">Rotation</h3> <div class="block" style="padding-left: 2rem;"> @@ -33,7 +46,7 @@ The selected DBs will be hidden from the DB picker when viewing Trainwreck data. </p> - % for key, engine in six.iteritems(trainwreck_engines): + % for key, engine in trainwreck_engines.items(): <b-field> <b-checkbox name="hidedb_${key}" v-model="hiddenDatabases['${key}']" @@ -49,14 +62,9 @@ </div> </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> - +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> ThisPageData.hiddenDatabases = ${json.dumps(hidden_databases)|n} - </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/trainwreck/transactions/index.mako b/tailbone/templates/trainwreck/transactions/index.mako deleted file mode 100644 index 31d956fc..00000000 --- a/tailbone/templates/trainwreck/transactions/index.mako +++ /dev/null @@ -1,12 +0,0 @@ -## -*- coding: utf-8; -*- -<%inherit file="/master/index.mako" /> - -<%def name="context_menu_items()"> - ${parent.context_menu_items()} - % if master.has_perm('rollover'): - <li>${h.link_to("Yearly Rollover", url('{}.rollover'.format(route_prefix)))}</li> - % endif -</%def> - - -${parent.body()} diff --git a/tailbone/templates/trainwreck/transactions/rollover.mako b/tailbone/templates/trainwreck/transactions/rollover.mako index 8e27d087..f26515b5 100644 --- a/tailbone/templates/trainwreck/transactions/rollover.mako +++ b/tailbone/templates/trainwreck/transactions/rollover.mako @@ -8,7 +8,7 @@ <%def name="page_content()"> <br /> - % if six.text_type(next_year) not in trainwreck_engines: + % if str(next_year) not in trainwreck_engines: <b-notification type="is-warning"> You do not have a database configured for next year (${next_year}). You should be sure to configure it before next year rolls around. @@ -48,14 +48,9 @@ </b-table> </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> - +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> ThisPageData.engines = ${json.dumps(engines_data)|n} - </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/trainwreck/transactions/view.mako b/tailbone/templates/trainwreck/transactions/view.mako index 2be51c7d..630950cf 100644 --- a/tailbone/templates/trainwreck/transactions/view.mako +++ b/tailbone/templates/trainwreck/transactions/view.mako @@ -1,15 +1,11 @@ ## -*- coding: utf-8; -*- <%inherit file="/master/view.mako" /> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> - +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> % if custorder_xref_markers_data is not Undefined: - ${form.component_studly}Data.custorderXrefMarkersData = ${json.dumps(custorder_xref_markers_data)|n} + ${form.vue_component}Data.custorderXrefMarkersData = ${json.dumps(custorder_xref_markers_data)|n} % endif - </script> </%def> - -${parent.body()} diff --git a/tailbone/templates/trainwreck/transactions/view_row.mako b/tailbone/templates/trainwreck/transactions/view_row.mako index 9abcb8ba..2507492e 100644 --- a/tailbone/templates/trainwreck/transactions/view_row.mako +++ b/tailbone/templates/trainwreck/transactions/view_row.mako @@ -1,16 +1,11 @@ ## -*- coding: utf-8; -*- <%inherit file="/master/view_row.mako" /> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> - +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> % if discounts_data is not Undefined: - ${form.component_studly}Data.discountsData = ${json.dumps(discounts_data)|n} + ${form.vue_component}Data.discountsData = ${json.dumps(discounts_data)|n} % endif - </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/units-of-measure/index.mako b/tailbone/templates/units-of-measure/index.mako index fb3a3219..4815fc79 100644 --- a/tailbone/templates/units-of-measure/index.mako +++ b/tailbone/templates/units-of-measure/index.mako @@ -7,7 +7,7 @@ % if master.has_perm('collect_wild_uoms'): <b-button type="is-primary" icon-pack="fas" - icon-left="fas fa-shopping-basket" + icon-left="shopping-basket" @click="showingCollectWildDialog = true"> Collect from the Wild </b-button> @@ -51,20 +51,17 @@ % endif </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} % if master.has_perm('collect_wild_uoms'): - <script type="text/javascript"> + <script> - TailboneGridData.showingCollectWildDialog = false + ${grid.vue_component}Data.showingCollectWildDialog = false - TailboneGrid.methods.collectFromWild = function() { - this.$refs['collect-wild-uoms-form'].submit() - } + ${grid.vue_component}.methods.collectFromWild = function() { + this.$refs['collect-wild-uoms-form'].submit() + } - </script> + </script> % endif </%def> - - -${parent.body()} diff --git a/tailbone/templates/upgrades/configure.mako b/tailbone/templates/upgrades/configure.mako index 4172c2b1..9439f830 100644 --- a/tailbone/templates/upgrades/configure.mako +++ b/tailbone/templates/upgrades/configure.mako @@ -7,31 +7,35 @@ <h3 class="is-size-3">Upgradable Systems</h3> <div class="block" style="padding-left: 2rem; display: flex;"> - <b-table :data="upgradeSystems" + <${b}-table :data="upgradeSystems" sortable> - <b-table-column field="key" + <${b}-table-column field="key" label="Key" v-slot="props" sortable> {{ props.row.key }} - </b-table-column> - <b-table-column field="label" + </${b}-table-column> + <${b}-table-column field="label" label="Label" v-slot="props" sortable> {{ props.row.label }} - </b-table-column> - <b-table-column field="command" + </${b}-table-column> + <${b}-table-column field="command" label="Command" v-slot="props" sortable> {{ props.row.command }} - </b-table-column> - <b-table-column label="Actions" + </${b}-table-column> + <${b}-table-column label="Actions" v-slot="props"> <a href="#" @click.prevent="upgradeSystemEdit(props.row)"> - <i class="fas fa-edit"></i> + % if request.use_oruga: + <o-icon icon="edit" /> + % else: + <i class="fas fa-edit"></i> + % endif Edit </a> @@ -39,11 +43,15 @@ v-if="props.row.key != 'rattail'" class="has-text-danger" @click.prevent="updateSystemDelete(props.row)"> - <i class="fas fa-trash"></i> + % if request.use_oruga: + <o-icon icon="trash" /> + % else: + <i class="fas fa-trash"></i> + % endif Delete </a> - </b-table-column> - </b-table> + </${b}-table-column> + </${b}-table> <div style="margin-left: 1rem;"> <b-button type="is-primary" @@ -66,21 +74,21 @@ :type="upgradeSystemKey ? null : 'is-danger'"> <b-input v-model.trim="upgradeSystemKey" ref="upgradeSystemKey" - :disabled="upgradeSystemKey == 'rattail'"> - </b-input> + :disabled="upgradeSystemKey == 'rattail'" + expanded /> </b-field> <b-field label="Label" :type="upgradeSystemLabel ? null : 'is-danger'"> <b-input v-model.trim="upgradeSystemLabel" ref="upgradeSystemLabel" - :disabled="upgradeSystemKey == 'rattail'"> - </b-input> + :disabled="upgradeSystemKey == 'rattail'" + expanded /> </b-field> <b-field label="Command" :type="upgradeSystemCommand ? null : 'is-danger'"> <b-input v-model.trim="upgradeSystemCommand" - ref="upgradeSystemCommand"> - </b-input> + ref="upgradeSystemCommand" + expanded /> </b-field> </section> @@ -103,9 +111,9 @@ </div> </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> ThisPageData.upgradeSystems = ${json.dumps(upgrade_systems)|n} ThisPageData.upgradeSystemShowDialog = false @@ -153,6 +161,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/upgrades/view.mako b/tailbone/templates/upgrades/view.mako index c5419574..c3fca81d 100644 --- a/tailbone/templates/upgrades/view.mako +++ b/tailbone/templates/upgrades/view.mako @@ -19,9 +19,15 @@ ${parent.render_this_page()} % if expose_websockets and master.has_perm('execute'): - <b-modal :active.sync="upgradeExecuting" - full-screen - :can-cancel="false"> + <${b}-modal full-screen + % if request.use_oruga: + v-model:active="upgradeExecuting" + :cancelable="false" + % else: + :active.sync="upgradeExecuting" + :can-cancel="false" + % endif + > <div class="card"> <div class="card-content"> @@ -32,6 +38,10 @@ Upgrading ${system_title} (please wait) ... {{ executeUpgradeComplete ? "DONE!" : "" }} </p> + % if request.use_oruga: + <progress class="progress is-large" + style="width: 400px;" /> + % else: <b-progress size="is-large" style="width: 400px;" ## :value="80" @@ -39,6 +49,7 @@ ## format="percent" > </b-progress> + % endif </div> <div class="level-right"> <div class="level-item"> @@ -65,7 +76,7 @@ </div> </div> - </b-modal> + </${b}-modal> % endif % if master.has_perm('execute'): @@ -75,7 +86,7 @@ % endif </%def> -<%def name="render_buefy_form()"> +<%def name="render_form()"> <div class="form"> <${form.component} % if master.has_perm('execute'): @@ -126,11 +137,11 @@ % endif </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> - TailboneFormData.showingPackages = 'diffs' + ${form.vue_component}Data.showingPackages = 'diffs' % if master.has_perm('execute'): @@ -142,7 +153,7 @@ // execute upgrade ////////////////////////////// - TailboneForm.props.upgradeExecuting = { + ${form.vue_component}.props.upgradeExecuting = { type: Boolean, default: false, } @@ -242,9 +253,9 @@ // execute upgrade ////////////////////////////// - TailboneFormData.formSubmitting = false + ${form.vue_component}Data.formSubmitting = false - TailboneForm.methods.submitForm = function() { + ${form.vue_component}.methods.submitForm = function() { this.formSubmitting = true } @@ -254,12 +265,12 @@ // declare failure ////////////////////////////// - TailboneForm.props.declareFailureSubmitting = { + ${form.vue_component}.props.declareFailureSubmitting = { type: Boolean, default: false, } - TailboneForm.methods.declareFailureClick = function() { + ${form.vue_component}.methods.declareFailureClick = function() { this.$emit('declare-failure-click') } @@ -276,6 +287,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/users/find_by_perm.mako b/tailbone/templates/users/find_by_perm.mako deleted file mode 100644 index 59fcf643..00000000 --- a/tailbone/templates/users/find_by_perm.mako +++ /dev/null @@ -1,23 +0,0 @@ -## -*- coding: utf-8 -*- -<%inherit file="/principal/find_by_perm.mako" /> - -<%def name="principal_table()"> - <table> - <thead> - <tr> - <th>Username</th> - <th>Person</th> - </tr> - </thead> - <tbody> - % for user in principals: - <tr> - <td>${h.link_to(user.username, url('users.view', uuid=user.uuid))}</td> - <td>${user.person or ''}</td> - </tr> - % endfor - </tbody> - </table> -</%def> - -${parent.body()} diff --git a/tailbone/templates/users/preferences.mako b/tailbone/templates/users/preferences.mako index a44534dc..ecfdd1c7 100644 --- a/tailbone/templates/users/preferences.mako +++ b/tailbone/templates/users/preferences.mako @@ -27,10 +27,10 @@ <div class="block" style="padding-left: 2rem;"> <b-field label="Theme Style"> - <b-select name="tailbone.${user.uuid}.buefy_css" - v-model="simpleSettings['tailbone.${user.uuid}.buefy_css']" + <b-select name="tailbone.${user.uuid}.user_css" + v-model="simpleSettings['tailbone.${user.uuid}.user_css']" @input="settingsNeedSaved = true"> - <option v-for="option in buefyCSSOptions" + <option v-for="option in themeStyleOptions" :key="option.value" :value="option.value"> {{ option.label }} @@ -42,14 +42,9 @@ </div> </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> - - ThisPageData.buefyCSSOptions = ${json.dumps(buefy_css_options)|n} - +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> + ThisPageData.themeStyleOptions = ${json.dumps(theme_style_options)|n} </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/users/view.mako b/tailbone/templates/users/view.mako index f65b6d1c..d1afd218 100644 --- a/tailbone/templates/users/view.mako +++ b/tailbone/templates/users/view.mako @@ -14,13 +14,6 @@ % endif </%def> -<%def name="context_menu_items()"> - ${parent.context_menu_items()} - % if master.has_perm('preferences'): - <li>${h.link_to("Edit User Preferences", action_url('preferences', instance))}</li> - % endif -</%def> - <%def name="render_this_page()"> ${parent.render_this_page()} @@ -40,6 +33,7 @@ <b-field label="Description" :type="{'is-danger': !apiNewTokenDescription}"> <b-input v-model.trim="apiNewTokenDescription" + expanded ref="apiNewTokenDescription"> </b-input> </b-field> @@ -82,12 +76,12 @@ % endif </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} % if master.has_perm('manage_api_tokens'): - <script type="text/javascript"> + <script> - ${form.component_studly}.props.apiTokens = null + ${form.vue_component}.props.apiTokens = null ThisPageData.apiTokens = ${json.dumps(api_tokens_data)|n} @@ -140,6 +134,3 @@ </script> % endif </%def> - - -${parent.body()} diff --git a/tailbone/templates/vendors/configure.mako b/tailbone/templates/vendors/configure.mako index 79dad455..6b135346 100644 --- a/tailbone/templates/vendors/configure.mako +++ b/tailbone/templates/vendors/configure.mako @@ -44,14 +44,9 @@ </div> </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> - +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> ThisPageData.supportedVendorSettings = ${json.dumps(supported_vendor_settings)|n} - </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/views/model/create.mako b/tailbone/templates/views/model/create.mako index 6a542c52..e902fd48 100644 --- a/tailbone/templates/views/model/create.mako +++ b/tailbone/templates/views/model/create.mako @@ -259,11 +259,11 @@ def includeme(config): </b-steps> </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> - ThisPageData.activeStep = null + ThisPageData.activeStep = 'enter-details' ThisPageData.modelNames = ${json.dumps(model_names)|n} ThisPageData.modelName = null @@ -334,6 +334,3 @@ def includeme(config): </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/workorders/view.mako b/tailbone/templates/workorders/view.mako index e631c141..432e011d 100644 --- a/tailbone/templates/workorders/view.mako +++ b/tailbone/templates/workorders/view.mako @@ -24,7 +24,7 @@ ${h.csrf_token(request)} <b-button type="is-primary" icon-pack="fas" - icon-left="fas fa-arrow-right" + icon-left="arrow-right" @click="receive()" :disabled="receiveButtonDisabled"> {{ receiveButtonText }} @@ -41,7 +41,7 @@ ${h.csrf_token(request)} <b-button type="is-primary" icon-pack="fas" - icon-left="fas fa-arrow-right" + icon-left="arrow-right" @click="awaitEstimate()" :disabled="awaitEstimateButtonDisabled"> {{ awaitEstimateButtonText }} @@ -58,7 +58,7 @@ ${h.csrf_token(request)} <b-button type="is-primary" icon-pack="fas" - icon-left="fas fa-arrow-right" + icon-left="arrow-right" @click="awaitParts()" :disabled="awaitPartsButtonDisabled"> {{ awaitPartsButtonText }} @@ -75,7 +75,7 @@ ${h.csrf_token(request)} <b-button type="is-primary" icon-pack="fas" - icon-left="fas fa-arrow-right" + icon-left="arrow-right" @click="workOnIt()" :disabled="workOnItButtonDisabled"> {{ workOnItButtonText }} @@ -92,7 +92,7 @@ ${h.csrf_token(request)} <b-button type="is-primary" icon-pack="fas" - icon-left="fas fa-arrow-right" + icon-left="arrow-right" @click="release()" :disabled="releaseButtonDisabled"> {{ releaseButtonText }} @@ -109,7 +109,7 @@ ${h.csrf_token(request)} <b-button type="is-primary" icon-pack="fas" - icon-left="fas fa-arrow-right" + icon-left="arrow-right" @click="deliver()" :disabled="deliverButtonDisabled"> {{ deliverButtonText }} @@ -132,7 +132,7 @@ ${h.csrf_token(request)} <b-button type="is-warning" icon-pack="fas" - icon-left="fas fa-ban" + icon-left="ban" @click="confirmCancel()" :disabled="cancelButtonDisabled"> {{ cancelButtonText }} @@ -145,9 +145,9 @@ </nav> </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> ThisPageData.receiveButtonDisabled = false ThisPageData.receiveButtonText = "I've received the order from customer" @@ -216,6 +216,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/tweens.py b/tailbone/tweens.py index f944a66f..9c06c1be 100644 --- a/tailbone/tweens.py +++ b/tailbone/tweens.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2018 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,9 +24,6 @@ Tween Factories """ -from __future__ import unicode_literals, absolute_import - -import six from sqlalchemy.exc import OperationalError @@ -64,7 +61,7 @@ def sqlerror_tween_factory(handler, registry): mark_error_retryable(error) raise error else: - raise TransientError(six.text_type(error)) + raise TransientError(str(error)) # if connection was *not* invalid, raise original error raise diff --git a/tailbone/util.py b/tailbone/util.py index 4c9c680e..71aa35e3 100644 --- a/tailbone/util.py +++ b/tailbone/util.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,14 +25,13 @@ Utilities """ import datetime - -import pytz -import humanize +import importlib import logging +import warnings +import humanize import markdown -from rattail.time import timezone, make_utc from rattail.files import resource_path import colander @@ -40,6 +39,12 @@ from pyramid.renderers import get_renderer from pyramid.interfaces import IRoutesMapper from webhelpers2.html import HTML, tags +from wuttaweb.util import (get_form_data as wutta_get_form_data, + get_libver as wutta_get_libver, + get_liburl as wutta_get_liburl, + get_csrf_token as wutta_get_csrf_token, + render_csrf_token) + log = logging.getLogger(__name__) @@ -56,37 +61,30 @@ class SortColumn(object): def get_csrf_token(request): - """ - Convenience function to retrieve the effective CSRF token for the given - request. - """ - token = request.session.get_csrf_token() - if token is None: - token = request.session.new_csrf_token() - return token + """ """ + warnings.warn("tailbone.util.get_csrf_token() is deprecated; " + "please use wuttaweb.util.get_csrf_token() instead", + DeprecationWarning, stacklevel=2) + return wutta_get_csrf_token(request) def csrf_token(request, name='_csrf'): - """ - Convenience function. Returns CSRF hidden tag inside hidden DIV. - """ - token = get_csrf_token(request) - return HTML.tag("div", tags.hidden(name, value=token), style="display:none;") + """ """ + warnings.warn("tailbone.util.csrf_token() is deprecated; " + "please use wuttaweb.util.render_csrf_token() instead", + DeprecationWarning, stacklevel=2) + return render_csrf_token(request, name=name) def get_form_data(request): """ - Returns the effective form data for the given request. Mostly - this is a convenience, to return either POST or JSON depending on - the type of request. + DEPECATED - use :func:`wuttaweb:wuttaweb.util.get_form_data()` + instead. """ - # nb. we prefer JSON only if no POST is present - # TODO: this seems to work for our use case at least, but perhaps - # there is a better way? see also - # https://docs.pylonsproject.org/projects/pyramid/en/latest/api/request.html#pyramid.request.Request.is_xhr - if request.is_xhr and not request.POST: - return request.json_body - return request.POST + warnings.warn("tailbone.util.get_form_data() is deprecated; " + "please use wuttaweb.util.get_form_data() instead", + DeprecationWarning, stacklevel=2) + return wutta_get_form_data(request) def get_global_search_options(request): @@ -106,92 +104,32 @@ def get_global_search_options(request): return options -def get_libver(request, key, fallback=True, default_only=False): +def get_libver(request, key, fallback=True, default_only=False): # pragma: no cover """ - Return the appropriate URL for the library identified by ``key``. + DEPRECATED - use :func:`wuttaweb:wuttaweb.util.get_libver()` + instead. """ - config = request.rattail_config + warnings.warn("tailbone.util.get_libver() is deprecated; " + "please use wuttaweb.util.get_libver() instead", + DeprecationWarning, stacklevel=2) - if not default_only: - version = config.get('tailbone', 'libver.{}'.format(key)) - if version: - return version - - if not fallback and not default_only: - - if key == 'buefy': - version = config.get('tailbone', 'buefy_version') - if version: - return version - - elif key == 'buefy.css': - version = get_libver(request, 'buefy', fallback=False) - if version: - return version - - elif key == 'vue': - version = config.get('tailbone', 'vue_version') - if version: - return version - - return - - if key == 'buefy': - if not default_only: - version = config.get('tailbone', 'buefy_version') - if version: - return version - return 'latest' - - elif key == 'buefy.css': - version = get_libver(request, 'buefy', default_only=default_only) - if version: - return version - return 'latest' - - elif key == 'vue': - if not default_only: - version = config.get('tailbone', 'vue_version') - if version: - return version - return '2.6.14' - - elif key == 'vue_resource': - return 'latest' - - elif key == 'fontawesome': - return '5.3.1' + return wutta_get_libver(request, key, prefix='tailbone', + configured_only=not fallback, + default_only=default_only) -def get_liburl(request, key, fallback=True): +def get_liburl(request, key, fallback=True): # pragma: no cover """ - Return the appropriate URL for the library identified by ``key``. + DEPRECATED - use :func:`wuttaweb:wuttaweb.util.get_liburl()` + instead. """ - config = request.rattail_config + warnings.warn("tailbone.util.get_liburl() is deprecated; " + "please use wuttaweb.util.get_liburl() instead", + DeprecationWarning, stacklevel=2) - url = config.get('tailbone', 'liburl.{}'.format(key)) - if url: - return url - - if not fallback: - return - - version = get_libver(request, key) - - if key == 'buefy': - return 'https://unpkg.com/buefy@{}/dist/buefy.min.js'.format(version) - - elif key == 'buefy.css': - return 'https://unpkg.com/buefy@{}/dist/buefy.min.css'.format(version) - - elif key == 'vue': - return 'https://unpkg.com/vue@{}/dist/vue.min.js'.format(version) - - elif key == 'vue_resource': - return 'https://cdn.jsdelivr.net/npm/vue-resource@{}'.format(version) - - elif key == 'fontawesome': - return 'https://use.fontawesome.com/releases/v{}/js/all.js'.format(version) + return wutta_get_liburl(request, key, prefix='tailbone', + configured_only=not fallback, + default_only=False) def pretty_datetime(config, value): @@ -207,16 +145,18 @@ def pretty_datetime(config, value): if not value: return '' + app = config.get_app() + # Make sure we're dealing with a tz-aware value. If we're given a naive # value, we assume it to be local to the UTC timezone. if not value.tzinfo: - value = pytz.utc.localize(value) + value = app.make_utc(value, tzinfo=True) # Calculate time diff using UTC. - time_ago = datetime.datetime.utcnow() - make_utc(value) + time_ago = datetime.datetime.utcnow() - app.make_utc(value) # Convert value to local timezone. - local = timezone(config) + local = app.get_timezone() value = local.normalize(value.astimezone(local)) return HTML.tag('span', @@ -242,13 +182,13 @@ def raw_datetime(config, value, verbose=False, as_date=False): # Make sure we're dealing with a tz-aware value. If we're given a naive # value, we assume it to be local to the UTC timezone. if not value.tzinfo: - value = pytz.utc.localize(value) + value = app.make_utc(value, tzinfo=True) # Calculate time diff using UTC. - time_ago = datetime.datetime.utcnow() - make_utc(value) + time_ago = datetime.datetime.utcnow() - app.make_utc(value) # Convert value to local timezone. - local = timezone(config) + local = app.get_timezone() value = local.normalize(value.astimezone(local)) kwargs = {} @@ -333,6 +273,41 @@ def get_theme_template_path(rattail_config, theme=None, session=None): return resource_path(theme_path) +def get_available_themes(rattail_config, include=None): + """ + Returns a list of theme names which are available. If config does + not specify, some defaults will be assumed. + """ + # get available list from config, if it has one + available = rattail_config.getlist('tailbone', 'themes.keys') + if not available: + available = rattail_config.getlist('tailbone', 'themes', + ignore_ambiguous=True) + if available: + warnings.warn("URGENT: instead of 'tailbone.themes', " + "you should set 'tailbone.themes.keys'", + DeprecationWarning, stacklevel=2) + else: + available = [] + + # include any themes specified by caller + if include is not None: + for theme in include: + if theme not in available: + available.append(theme) + + # sort the list by name + available.sort() + + # make default theme the first option + i = available.index('default') + if i >= 0: + available.pop(i) + available.insert(0, 'default') + + return available + + def get_effective_theme(rattail_config, theme=None, session=None): """ Validates and returns the "effective" theme. If you provide a theme, that @@ -350,15 +325,25 @@ def get_effective_theme(rattail_config, theme=None, session=None): session.close() # confirm requested theme is available - available = rattail_config.getlist('tailbone', 'themes', - default=['bobcat']) - available.append('default') + available = get_available_themes(rattail_config) if theme not in available: raise ValueError("theme not available: {}".format(theme)) return theme +def should_use_oruga(request): + """ + Returns a flag indicating whether or not the current theme + supports (and therefore should use) Oruga + Vue 3 as opposed to + the default of Buefy + Vue 2. + """ + theme = request.registry.settings.get('tailbone.theme') + if theme and 'butterball' in theme: + return True + return False + + def validate_email_address(address): """ Perform basic validation on the given email address. This leverages the @@ -398,7 +383,7 @@ def include_configured_views(pyramid_config): """ rattail_config = pyramid_config.registry.settings.get('rattail_config') app = rattail_config.get_app() - model = rattail_config.get_model() + model = app.model session = app.make_session() # fetch all include-related settings at once diff --git a/tailbone/views/asgi/__init__.py b/tailbone/views/asgi/__init__.py index bebe16f3..33888654 100644 --- a/tailbone/views/asgi/__init__.py +++ b/tailbone/views/asgi/__init__.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. # @@ -41,12 +41,13 @@ class MockRequest(dict): pass -class WebsocketView(object): +class WebsocketView: def __init__(self, pyramid_config): self.pyramid_config = pyramid_config self.registry = self.pyramid_config.registry - self.model = self.rattail_config.get_model() + app = self.get_rattail_app() + self.model = app.model @property def rattail_config(self): diff --git a/tailbone/views/auth.py b/tailbone/views/auth.py index f8d71d34..eceab803 100644 --- a/tailbone/views/auth.py +++ b/tailbone/views/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 @@ Auth Views """ -from rattail.db.auth import authenticate_user, set_user_password - import colander from deform import widget as dfwidget from pyramid.httpexceptions import HTTPForbidden @@ -46,28 +44,6 @@ class UserLogin(colander.MappingSchema): widget=dfwidget.PasswordWidget()) -@colander.deferred -def current_password_correct(node, kw): - request = kw['request'] - app = request.rattail_config.get_app() - auth = app.get_auth_handler() - user = kw['user'] - def validate(node, value): - if not auth.authenticate_user(Session(), user.username, value): - raise colander.Invalid(node, "The password is incorrect") - return validate - - -class ChangePassword(colander.MappingSchema): - - current_password = colander.SchemaNode(colander.String(), - widget=dfwidget.PasswordWidget(), - validator=current_password_correct) - - new_password = colander.SchemaNode(colander.String(), - widget=dfwidget.CheckedPasswordWidget()) - - class AuthenticationView(View): def forbidden(self): @@ -92,6 +68,7 @@ class AuthenticationView(View): """ The login view, responsible for displaying and handling the login form. """ + app = self.get_rattail_app() referrer = self.request.get_referrer(default=self.request.route_url('home')) # redirect if already logged in @@ -101,10 +78,9 @@ class AuthenticationView(View): form = forms.Form(schema=UserLogin(), request=self.request) form.save_label = "Login" - form.auto_disable_save = False - form.auto_disable = False # TODO: deprecate / remove this form.show_reset = True form.show_cancel = False + form.button_icon_submit = 'user' if form.validate(): user = self.authenticate_user(form.validated['username'], form.validated['password']) @@ -118,24 +94,19 @@ class AuthenticationView(View): else: self.request.session.flash("Invalid username or password", 'error') - image_url = self.rattail_config.get( - 'tailbone', 'main_image_url', - default=self.request.static_url('tailbone:static/img/home_logo.png')) - # nb. hacky..but necessary, to add the refs, for autofocus # (also add key handler, so ENTER acts like TAB) dform = form.make_deform_form() dform['username'].widget.attributes = { 'ref': 'username', - '@keydown.native': 'usernameKeydown', + 'autocomplete': 'off', } dform['password'].widget.attributes = {'ref': 'password'} return { 'form': form, 'referrer': referrer, - 'image_url': image_url, - 'index_title': self.rattail_config.node_title(), + 'index_title': app.get_node_title(), 'help_url': global_help_url(self.rattail_config), } @@ -183,14 +154,32 @@ class AuthenticationView(View): self.request.user)) return self.redirect(self.request.get_referrer()) - schema = ChangePassword().bind(user=self.request.user, request=self.request) + def check_user_password(node, value): + auth = self.app.get_auth_handler() + user = self.request.user + if not auth.check_user_password(user, value): + node.raise_invalid("The password is incorrect") + + schema = colander.Schema() + + schema.add(colander.SchemaNode(colander.String(), + name='current_password', + widget=dfwidget.PasswordWidget(), + validator=check_user_password)) + + schema.add(colander.SchemaNode(colander.String(), + name='new_password', + widget=dfwidget.CheckedPasswordWidget())) + form = forms.Form(schema=schema, request=self.request) if form.validate(): - set_user_password(self.request.user, form.validated['new_password']) + auth = self.app.get_auth_handler() + auth.set_user_password(self.request.user, form.validated['new_password']) self.request.session.flash("Your password has been changed.") return self.redirect(self.request.get_referrer()) - return {'form': form} + return {'index_title': str(self.request.user), + 'form': form} def become_root(self): """ @@ -238,6 +227,9 @@ class AuthenticationView(View): config.add_view(cls, attr='change_password', route_name='change_password', renderer='/change_password.mako') # become/stop root + # TODO: these should require POST but i won't bother until + # after butterball becomes default theme..or probably should + # just refactor the falafel theme accordingly..? config.add_route('become_root', '/root/yes') config.add_view(cls, attr='become_root', route_name='become_root') config.add_route('stop_root', '/root/no') diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index b9c28be7..c162b579 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/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. # @@ -32,29 +32,25 @@ import logging import socket import subprocess import tempfile +import warnings import json import markdown import sqlalchemy as sa from sqlalchemy import orm -from rattail.db import model, Session as RattailSession -from rattail.db.util import short_session from rattail.threads import Thread -from rattail.util import prettify, simple_error -from rattail.progress import SocketProgress +from rattail.util import simple_error import colander -import deform from deform import widget as dfwidget -from pyramid.renderers import render_to_response -from pyramid.response import FileResponse from webhelpers2.html import HTML, tags +from wuttaweb.util import render_csrf_token + from tailbone import forms, grids from tailbone.db import Session from tailbone.views import MasterView -from tailbone.util import csrf_token log = logging.getLogger(__name__) @@ -68,6 +64,7 @@ class BatchMasterView(MasterView): batch_handler_class = None has_rows = True rows_deletable = True + rows_deletable_if_executed = False rows_bulk_deletable = True rows_downloadable_csv = True rows_downloadable_xlsx = True @@ -115,7 +112,7 @@ class BatchMasterView(MasterView): } def __init__(self, request): - super(BatchMasterView, self).__init__(request) + super().__init__(request) self.batch_handler = self.get_handler() # TODO: deprecate / remove this (?) self.handler = self.batch_handler @@ -167,7 +164,7 @@ class BatchMasterView(MasterView): return self.rattail_config.batch_filepath(batch.batch_key, batch.uuid, filename) def template_kwargs_view(self, **kwargs): - kwargs = super(BatchMasterView, self).template_kwargs_view(**kwargs) + kwargs = super().template_kwargs_view(**kwargs) batch = kwargs['instance'] kwargs['batch'] = batch kwargs['handler'] = self.handler @@ -190,13 +187,15 @@ class BatchMasterView(MasterView): breakdown = self.make_status_breakdown(batch) factory = self.get_grid_factory() - g = factory('batch_row_status_breakdown', [], + g = factory(self.request, + key='batch_row_status_breakdown', + data=[], columns=['title', 'count']) g.set_click_handler('title', "autoFilterStatus(props.row)") kwargs['status_breakdown_data'] = breakdown kwargs['status_breakdown_grid'] = HTML.literal( - g.render_buefy_table_element(data_prop='statusBreakdownData', - empty_labels=True)) + g.render_table_element(data_prop='statusBreakdownData', + empty_labels=True)) return kwargs @@ -207,7 +206,7 @@ class BatchMasterView(MasterView): action_url=action_url, component='upload-worksheet-form') form.set_type('worksheet_file', 'file') - # TODO: must set these to avoid some default Buefy code + # TODO: must set these to avoid some default code form.auto_disable = False form.auto_disable_save = False return form @@ -288,7 +287,8 @@ class BatchMasterView(MasterView): return not batch.executed and not batch.complete def configure_grid(self, g): - super(BatchMasterView, self).configure_grid(g) + super().configure_grid(g) + model = self.model # created_by CreatedBy = orm.aliased(model.User) @@ -337,7 +337,7 @@ class BatchMasterView(MasterView): return batch.id_str def configure_form(self, f): - super(BatchMasterView, self).configure_form(f) + super().configure_form(f) # id f.set_readonly('id') @@ -384,7 +384,7 @@ class BatchMasterView(MasterView): f.set_label('executed_by', "Executed by") # notes - f.set_type('notes', 'text') + f.set_type('notes', 'text_wrapped') # if self.creating and self.request.user: # batch = fs.model @@ -436,13 +436,13 @@ class BatchMasterView(MasterView): label = HTML.literal( '{{{{ togglingBatchComplete ? "Working, please wait..." : "{}" }}}}'.format(label)) - submit = self.make_buefy_button(label, is_primary=True, - native_type='submit', - **{':disabled': 'togglingBatchComplete'}) + submit = self.make_button(label, is_primary=True, + native_type='submit', + **{':disabled': 'togglingBatchComplete'}) form = [ begin_form, - csrf_token(self.request), + render_csrf_token(self.request), tags.hidden('complete', value=value), submit, tags.end_form(), @@ -603,7 +603,7 @@ class BatchMasterView(MasterView): return True def configure_row_grid(self, g): - super(BatchMasterView, self).configure_row_grid(g) + super().configure_row_grid(g) g.set_sort_defaults('sequence') g.set_link('sequence') @@ -644,7 +644,7 @@ class BatchMasterView(MasterView): if batch.executed: self.request.session.flash("You cannot add new rows to a batch which has been executed") return self.redirect(self.get_action_url('view', batch)) - return super(BatchMasterView, self).create_row() + return super().create_row() def save_create_row_form(self, form): batch = self.get_instance() @@ -657,7 +657,7 @@ class BatchMasterView(MasterView): self.handler.refresh_row(row) def configure_row_form(self, f): - super(BatchMasterView, self).configure_row_form(f) + super().configure_row_form(f) # sequence f.set_readonly('sequence') @@ -681,9 +681,9 @@ class BatchMasterView(MasterView): permission_prefix = self.get_permission_prefix() if self.request.has_perm('{}.create_row'.format(permission_prefix)): url = self.get_action_url('create_row', batch) - return self.make_buefy_button("New Row", url=url, - is_primary=True, - icon_left='plus') + return self.make_button("New Row", url=url, + is_primary=True, + icon_left='plus') def make_batch_row_grid_tools(self, batch): pass @@ -696,7 +696,7 @@ class BatchMasterView(MasterView): batch = self.get_instance() # TODO: most of this logic is copied from MasterView, should refactor/merge somehow... - if 'main_actions' not in kwargs: + if 'actions' not in kwargs: actions = [] # view action @@ -704,11 +704,11 @@ class BatchMasterView(MasterView): view = lambda r, i: self.get_row_action_url('view', r) actions.append(self.make_action('view', icon='eye', url=view)) - # edit and delete are NOT allowed after execution, or if batch is "complete" - if not batch.executed and not batch.complete: + # edit and delete are NOT allowed if batch is "complete" + if not batch.complete: # edit action - if self.rows_editable and self.has_perm('edit_row'): + if self.rows_editable and not batch.executed and self.has_perm('edit_row'): actions.append(self.make_action('edit', icon='edit', url=self.row_edit_action_url)) @@ -717,9 +717,9 @@ class BatchMasterView(MasterView): actions.append(self.make_action('delete', icon='trash', url=self.row_delete_action_url)) kwargs.setdefault('delete_speedbump', self.rows_deletable_speedbump) - kwargs['main_actions'] = actions + kwargs['actions'] = actions - return super(BatchMasterView, self).make_row_grid_kwargs(**kwargs) + return super().make_row_grid_kwargs(**kwargs) def make_row_grid_tools(self, batch): return (self.make_default_row_grid_tools(batch) or '') + (self.make_batch_row_grid_tools(batch) or '') @@ -852,14 +852,17 @@ class BatchMasterView(MasterView): labels = kwargs.setdefault('labels', {}) labels[field.name] = field.title - # auto-convert select widgets for buefy theme + # auto-convert select widgets for theme if isinstance(field.widget, forms.widgets.PlainSelectWidget): + warnings.warn("PlainSelectWidget is deprecated; " + "please use deform.widget.SelectWidget instead", + DeprecationWarning, stacklevel=2) field.widget = dfwidget.SelectWidget(values=field.widget.values) if not schema: schema = colander.Schema() - kwargs['component'] = 'execute-form' + kwargs['vue_tagname'] = 'execute-form' form = forms.Form(schema=schema, request=self.request, defaults=defaults, **kwargs) self.configure_execute_form(form) return form @@ -1022,7 +1025,8 @@ class BatchMasterView(MasterView): cxn.close() def catchup_versions(self, port, batch_uuid, username, *models): - with short_session() as s: + app = self.get_rattail_app() + with app.short_session() as s: batch = s.get(self.model_class, batch_uuid) batch_id = batch.id_str description = str(batch) @@ -1048,8 +1052,10 @@ class BatchMasterView(MasterView): """ Thread target for populating batch data with progress indicator. """ + app = self.get_rattail_app() + model = self.model # mustn't use tailbone web session here - session = RattailSession() + session = app.make_session() batch = session.get(self.model_class, batch_uuid) user = session.get(model.User, user_uuid) try: @@ -1057,7 +1063,8 @@ class BatchMasterView(MasterView): session.flush() except Exception as error: session.rollback() - log.exception("population failed for batch %s: %s", batch.uuid, batch) + log.warning("population failed for batch %s: %s", batch.uuid, batch, + exc_info=True) session.close() if progress: progress.session.load() @@ -1106,7 +1113,9 @@ class BatchMasterView(MasterView): # Refresh data for the batch, with progress. Note that we must use the # rattail session here; can't use tailbone because it has web request # transaction binding etc. - session = RattailSession() + app = self.get_rattail_app() + model = self.model + session = app.make_session() batch = session.get(self.model_class, batch_uuid) cognizer = session.get(model.User, user_uuid) if user_uuid else None try: @@ -1159,7 +1168,9 @@ class BatchMasterView(MasterView): """ Thread target for refreshing multiple batches with progress indicator. """ - session = RattailSession() + app = self.get_rattail_app() + model = self.model + session = app.make_session() batches = batches.with_session(session).all() user = session.get(model.User, user_uuid) try: @@ -1234,9 +1245,16 @@ class BatchMasterView(MasterView): return False batch = self.get_parent(row) - if batch.complete or batch.executed: + + if batch.complete: return False + if batch.executed: + if not self.rows_deletable_if_executed: + return False + if not self.has_perm('delete_row_if_executed'): + return False + return True def template_kwargs_view_row(self, **kwargs): @@ -1256,7 +1274,7 @@ class BatchMasterView(MasterView): self.handler.do_remove_row(row) def delete_row_objects(self, rows): - deleted = super(BatchMasterView, self).delete_row_objects(rows) + deleted = super().delete_row_objects(rows) batch = self.get_instance() # decrement rowcount for batch @@ -1299,7 +1317,9 @@ class BatchMasterView(MasterView): # Execute the batch, with progress. Note that we must use the rattail # session here; can't use tailbone because it has web request # transaction binding etc. - session = RattailSession() + app = self.get_rattail_app() + model = self.model + session = app.make_session() batch = self.get_instance_for_key(key, session) user = session.get(model.User, user_uuid) try: @@ -1374,7 +1394,9 @@ class BatchMasterView(MasterView): """ Thread target for executing multiple batches with progress indicator. """ - session = RattailSession() + app = self.get_rattail_app() + model = self.model + session = app.make_session() batches = batches.with_session(session).all() user = session.get(model.User, user_uuid) try: @@ -1414,7 +1436,7 @@ class BatchMasterView(MasterView): return self.get_index_url() def get_row_csv_fields(self): - fields = super(BatchMasterView, self).get_row_csv_fields() + fields = super().get_row_csv_fields() fields = [field for field in fields if field not in ('uuid', 'batch_uuid', 'removed')] return fields @@ -1493,6 +1515,12 @@ class BatchMasterView(MasterView): config.add_tailbone_permission(permission_prefix, '{}.refresh'.format(permission_prefix), "Refresh data for {}".format(model_title)) + # delete row if executed + if cls.rows_deletable_if_executed: + config.add_tailbone_permission(permission_prefix, + f'{permission_prefix}.delete_row_if_executed', + "Delete rows after batch is executed") + # toggle complete config.add_route('{}.toggle_complete'.format(route_prefix), '{}/{{{}}}/toggle-complete'.format(url_prefix, model_key)) config.add_view(cls, attr='toggle_complete', route_name='{}.toggle_complete'.format(route_prefix), @@ -1537,7 +1565,7 @@ class FileBatchMasterView(BatchMasterView): return uploads def configure_form(self, f): - super(FileBatchMasterView, self).configure_form(f) + super().configure_form(f) batch = f.model_instance # filename diff --git a/tailbone/views/batch/handheld.py b/tailbone/views/batch/handheld.py index 03b9a441..486d8774 100644 --- a/tailbone/views/batch/handheld.py +++ b/tailbone/views/batch/handheld.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,12 @@ Views for handheld batches from collections import OrderedDict -from rattail.db import model +from rattail.db.model import HandheldBatch, HandheldBatchRow import colander +from deform import widget as dfwidget from webhelpers2.html import tags -from tailbone import forms from tailbone.views.batch import FileBatchMasterView @@ -46,14 +46,14 @@ class ExecutionOptions(colander.Schema): action = colander.SchemaNode( colander.String(), validator=colander.OneOf(ACTION_OPTIONS), - widget=forms.widgets.PlainSelectWidget(values=ACTION_OPTIONS.items())) + widget=dfwidget.SelectWidget(values=list(ACTION_OPTIONS.items()))) class HandheldBatchView(FileBatchMasterView): """ Master view for handheld batches. """ - model_class = model.HandheldBatch + model_class = HandheldBatch default_handler_spec = 'rattail.batch.handheld:HandheldBatchHandler' model_title_plural = "Handheld Batches" route_prefix = 'batch.handheld' @@ -61,7 +61,7 @@ class HandheldBatchView(FileBatchMasterView): execution_options_schema = ExecutionOptions editable = False - model_row_class = model.HandheldBatchRow + model_row_class = HandheldBatchRow rows_creatable = False rows_editable = True @@ -116,7 +116,7 @@ class HandheldBatchView(FileBatchMasterView): ] def configure_grid(self, g): - super(HandheldBatchView, self).configure_grid(g) + super().configure_grid(g) device_types = OrderedDict(sorted(self.enum.HANDHELD_DEVICE_TYPE.items(), key=lambda item: item[1])) g.set_enum('device_type', device_types) @@ -126,7 +126,7 @@ class HandheldBatchView(FileBatchMasterView): return 'notice' def configure_form(self, f): - super(HandheldBatchView, self).configure_form(f) + super().configure_form(f) batch = f.model_instance # device_type @@ -156,13 +156,13 @@ class HandheldBatchView(FileBatchMasterView): return tags.link_to(text, url) def get_batch_kwargs(self, batch): - kwargs = super(HandheldBatchView, self).get_batch_kwargs(batch) + kwargs = super().get_batch_kwargs(batch) kwargs['device_type'] = batch.device_type kwargs['device_name'] = batch.device_name return kwargs def configure_row_grid(self, g): - super(HandheldBatchView, self).configure_row_grid(g) + super().configure_row_grid(g) g.set_type('cases', 'quantity') g.set_type('units', 'quantity') g.set_label('brand_name', "Brand") @@ -172,7 +172,7 @@ class HandheldBatchView(FileBatchMasterView): return 'warning' def configure_row_form(self, f): - super(HandheldBatchView, self).configure_row_form(f) + super().configure_row_form(f) # readonly fields f.set_readonly('upc') @@ -188,7 +188,7 @@ class HandheldBatchView(FileBatchMasterView): return self.request.route_url('batch.inventory.view', uuid=result.uuid) elif kwargs['action'] == 'make_label_batch': return self.request.route_url('labels.batch.view', uuid=result.uuid) - return super(HandheldBatchView, self).get_execute_success_url(batch) + return super().get_execute_success_url(batch) def get_execute_results_success_url(self, result, **kwargs): if result is True: diff --git a/tailbone/views/batch/importer.py b/tailbone/views/batch/importer.py index f0b76bf6..ea4e1c74 100644 --- a/tailbone/views/batch/importer.py +++ b/tailbone/views/batch/importer.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,7 +26,7 @@ Views for importer batches import sqlalchemy as sa -from rattail.db import model +from rattail.db.model import ImporterBatch import colander @@ -37,7 +37,7 @@ class ImporterBatchView(BatchMasterView): """ Master view for importer batches. """ - model_class = model.ImporterBatch + model_class = ImporterBatch default_handler_spec = 'rattail.batch.importer:ImporterBatchHandler' route_prefix = 'batch.importer' url_prefix = '/batches/importer' @@ -91,7 +91,7 @@ class ImporterBatchView(BatchMasterView): ] def configure_form(self, f): - super(ImporterBatchView, self).configure_form(f) + super().configure_form(f) # readonly fields f.set_readonly('import_handler_spec') @@ -110,21 +110,21 @@ class ImporterBatchView(BatchMasterView): self.make_row_table(batch.row_table) kwargs['rows'] = self.Session.query(self.current_row_table).all() kwargs.setdefault('status_enum', self.enum.IMPORTER_BATCH_ROW_STATUS) - breakdown = super(ImporterBatchView, self).make_status_breakdown( - batch, **kwargs) + breakdown = super().make_status_breakdown(batch, **kwargs) return breakdown def delete_instance(self, batch): self.make_row_table(batch.row_table) if self.current_row_table is not None: self.current_row_table.drop() - super(ImporterBatchView, self).delete_instance(batch) + super().delete_instance(batch) def make_row_table(self, name): if not hasattr(self, 'current_row_table'): - metadata = sa.MetaData(schema='batch', bind=self.Session.bind) + metadata = sa.MetaData(schema='batch') try: - self.current_row_table = sa.Table(name, metadata, autoload=True) + self.current_row_table = sa.Table(name, metadata, + autoload_with=self.Session.bind) except sa.exc.NoSuchTableError: self.current_row_table = None @@ -136,7 +136,7 @@ class ImporterBatchView(BatchMasterView): return self.enum.IMPORTER_BATCH_ROW_STATUS def configure_row_grid(self, g): - super(ImporterBatchView, self).configure_row_grid(g) + super().configure_row_grid(g) def make_filter(field, **kwargs): column = getattr(self.current_row_table.c, field) @@ -145,9 +145,7 @@ class ImporterBatchView(BatchMasterView): make_filter('object_key') make_filter('object_str') - # for some reason we have to do this differently for Buefy? - kwargs = {} - make_filter('status_code', label="Status", **kwargs) + make_filter('status_code', label="Status") g.filters['status_code'].set_choices(self.enum.IMPORTER_BATCH_ROW_STATUS) def make_sorter(field): @@ -190,7 +188,7 @@ class ImporterBatchView(BatchMasterView): def get_parent(self, row): uuid = self.current_row_table.name - return self.Session.get(model.ImporterBatch, uuid) + return self.Session.get(ImporterBatch, uuid) def get_row_instance_title(self, row): if row.object_str: @@ -242,7 +240,7 @@ class ImporterBatchView(BatchMasterView): kwargs.setdefault('schema', colander.Schema()) kwargs.setdefault('cancel_url', None) - return super(ImporterBatchView, self).make_row_form(instance=row, **kwargs) + return super().make_row_form(instance=row, **kwargs) def configure_row_form(self, f): """ @@ -277,7 +275,7 @@ class ImporterBatchView(BatchMasterView): query = self.get_effective_row_data(sort=False) batch.rowcount -= query.count() delete_query = self.current_row_table.delete().where(self.current_row_table.c.uuid.in_([row.uuid for row in query])) - delete_query.execute() + self.Session.bind.execute(delete_query) return self.redirect(self.get_action_url('view', batch)) def get_row_xlsx_fields(self): @@ -291,7 +289,7 @@ class ImporterBatchView(BatchMasterView): ] def get_row_xlsx_row(self, row, fields): - xlrow = super(ImporterBatchView, self).get_row_xlsx_row(row, fields) + xlrow = super().get_row_xlsx_row(row, fields) xlrow['status'] = self.enum.IMPORTER_BATCH_ROW_STATUS[row.status_code] diff --git a/tailbone/views/batch/labels.py b/tailbone/views/batch/labels.py index 79b14a76..7291b05e 100644 --- a/tailbone/views/batch/labels.py +++ b/tailbone/views/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 @@ Views for label batches """ -from __future__ import unicode_literals, absolute_import - -import six - from rattail.db import model from deform import widget as dfwidget @@ -123,7 +119,7 @@ class LabelBatchView(BatchMasterView): ] def configure_form(self, f): - super(LabelBatchView, self).configure_form(f) + super().configure_form(f) # handheld_batches if self.creating: @@ -142,7 +138,7 @@ class LabelBatchView(BatchMasterView): f.replace('label_profile', 'label_profile_uuid') # TODO: should restrict somehow? just allow override? profiles = self.Session.query(model.LabelProfile) - values = [(p.uuid, six.text_type(p)) + values = [(p.uuid, str(p)) for p in profiles] require_profile = False if not require_profile: @@ -159,7 +155,7 @@ class LabelBatchView(BatchMasterView): return HTML.tag('ul', c=items) def configure_row_grid(self, g): - super(LabelBatchView, self).configure_row_grid(g) + super().configure_row_grid(g) # short labels g.set_label('brand_name', "Brand") @@ -171,7 +167,7 @@ class LabelBatchView(BatchMasterView): return 'warning' def configure_row_form(self, f): - super(LabelBatchView, self).configure_row_form(f) + super().configure_row_form(f) # readonly fields f.set_readonly('sequence') @@ -219,7 +215,7 @@ class LabelBatchView(BatchMasterView): profiles = self.Session.query(model.LabelProfile)\ .filter(model.LabelProfile.visible == True)\ .order_by(model.LabelProfile.ordinal) - profile_values = [(p.uuid, six.text_type(p)) + profile_values = [(p.uuid, str(p)) for p in profiles] f.set_widget('label_profile_uuid', forms.widgets.JQuerySelectWidget(values=profile_values)) diff --git a/tailbone/views/batch/pos.py b/tailbone/views/batch/pos.py index afda919e..b6fef6c8 100644 --- a/tailbone/views/batch/pos.py +++ b/tailbone/views/batch/pos.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. # @@ -195,6 +195,7 @@ class POSBatchView(BatchMasterView): factory = self.get_grid_factory() g = factory( + self.request, key=f'{route_prefix}.taxes', data=[], columns=[ @@ -206,7 +207,7 @@ class POSBatchView(BatchMasterView): ) return HTML.literal( - g.render_buefy_table_element(data_prop='taxesData')) + g.render_table_element(data_prop='taxesData')) def template_kwargs_view(self, **kwargs): kwargs = super().template_kwargs_view(**kwargs) diff --git a/tailbone/views/batch/pricing.py b/tailbone/views/batch/pricing.py index 6ba28889..5b5d013b 100644 --- a/tailbone/views/batch/pricing.py +++ b/tailbone/views/batch/pricing.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 @@ Views for pricing batches """ -from __future__ import unicode_literals, absolute_import - -import six - from rattail.db import model from rattail.time import localtime @@ -155,7 +151,7 @@ class PricingBatchView(BatchMasterView): return self.batch_handler.allow_future() def configure_form(self, f): - super(PricingBatchView, self).configure_form(f) + super().configure_form(f) app = self.get_rattail_app() batch = f.model_instance @@ -192,7 +188,7 @@ class PricingBatchView(BatchMasterView): f.set_required('input_filename', False) def get_batch_kwargs(self, batch, **kwargs): - kwargs = super(PricingBatchView, self).get_batch_kwargs(batch, **kwargs) + kwargs = super().get_batch_kwargs(batch, **kwargs) kwargs['start_date'] = batch.start_date kwargs['min_diff_threshold'] = batch.min_diff_threshold kwargs['min_diff_percent'] = batch.min_diff_percent @@ -213,7 +209,7 @@ class PricingBatchView(BatchMasterView): return kwargs def configure_row_grid(self, g): - super(PricingBatchView, self).configure_row_grid(g) + super().configure_row_grid(g) g.set_joiner('vendor_id', lambda q: q.outerjoin(model.Vendor)) g.set_sorter('vendor_id', model.Vendor.id) @@ -241,13 +237,13 @@ class PricingBatchView(BatchMasterView): if row.subdepartment_number: if row.subdepartment_name: return HTML.tag('span', title=row.subdepartment_name, - c=six.text_type(row.subdepartment_number)) + c=str(row.subdepartment_number)) return row.subdepartment_number def render_true_margin(self, row, field): margin = row.true_margin if margin: - margin = six.text_type(margin) + margin = str(margin) else: margin = HTML.literal(' ') if row.old_true_margin is not None: @@ -295,7 +291,7 @@ class PricingBatchView(BatchMasterView): return HTML.tag('span', title=title, c=text) def configure_row_form(self, f): - super(PricingBatchView, self).configure_row_form(f) + super().configure_row_form(f) # readonly fields f.set_readonly('product') @@ -328,7 +324,7 @@ class PricingBatchView(BatchMasterView): return tags.link_to(text, url) def get_row_csv_fields(self): - fields = super(PricingBatchView, self).get_row_csv_fields() + fields = super().get_row_csv_fields() if 'vendor_uuid' in fields: i = fields.index('vendor_uuid') @@ -344,7 +340,7 @@ class PricingBatchView(BatchMasterView): # TODO: this is the same as xlsx row! should merge/share somehow? def get_row_csv_row(self, row, fields): - csvrow = super(PricingBatchView, self).get_row_csv_row(row, fields) + csvrow = super().get_row_csv_row(row, fields) vendor = row.vendor if 'vendor_id' in fields: @@ -358,7 +354,7 @@ class PricingBatchView(BatchMasterView): # TODO: this is the same as csv row! should merge/share somehow? def get_row_xlsx_row(self, row, fields): - xlrow = super(PricingBatchView, self).get_row_xlsx_row(row, fields) + xlrow = super().get_row_xlsx_row(row, fields) vendor = row.vendor if 'vendor_id' in fields: diff --git a/tailbone/views/batch/product.py b/tailbone/views/batch/product.py index dfe8d890..590c3ff0 100644 --- a/tailbone/views/batch/product.py +++ b/tailbone/views/batch/product.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,12 @@ Views for generic product batches from collections import OrderedDict -from rattail.db import model +from rattail.db.model import ProductBatch, ProductBatchRow import colander +from deform import widget as dfwidget from webhelpers2.html import HTML -from tailbone import forms from tailbone.views.batch import BatchMasterView @@ -46,15 +46,15 @@ class ExecutionOptions(colander.Schema): action = colander.SchemaNode( colander.String(), validator=colander.OneOf(ACTION_OPTIONS), - widget=forms.widgets.PlainSelectWidget(values=ACTION_OPTIONS.items())) + widget=dfwidget.SelectWidget(values=list(ACTION_OPTIONS.items()))) class ProductBatchView(BatchMasterView): """ Master view for product batches. """ - model_class = model.ProductBatch - model_row_class = model.ProductBatchRow + model_class = ProductBatch + model_row_class = ProductBatchRow default_handler_spec = 'rattail.batch.product:ProductBatchHandler' route_prefix = 'batch.product' url_prefix = '/batches/product' @@ -129,7 +129,7 @@ class ProductBatchView(BatchMasterView): ] def configure_form(self, f): - super(ProductBatchView, self).configure_form(f) + super().configure_form(f) # input_filename if self.creating: @@ -139,7 +139,8 @@ class ProductBatchView(BatchMasterView): f.set_renderer('input_filename', self.render_downloadable_file) def configure_row_grid(self, g): - super(ProductBatchView, self).configure_row_grid(g) + super().configure_row_grid(g) + model = self.model g.set_joiner('vendor', lambda q: q.outerjoin(model.Vendor)) g.set_sorter('vendor', model.Vendor.name) @@ -165,7 +166,7 @@ class ProductBatchView(BatchMasterView): return 'warning' def configure_row_form(self, f): - super(ProductBatchView, self).configure_row_form(f) + super().configure_row_form(f) f.set_type('upc', 'gpc') @@ -204,10 +205,10 @@ class ProductBatchView(BatchMasterView): return self.request.route_url('labels.batch.view', uuid=result.uuid) elif kwargs['action'] == 'make_pricing_batch': return self.request.route_url('batch.pricing.view', uuid=result.uuid) - return super(ProductBatchView, self).get_execute_success_url(batch) + return super().get_execute_success_url(batch) def get_row_csv_fields(self): - fields = super(ProductBatchView, self).get_row_csv_fields() + fields = super().get_row_csv_fields() if 'vendor_uuid' in fields: i = fields.index('vendor_uuid') @@ -273,12 +274,12 @@ class ProductBatchView(BatchMasterView): data['report_name'] = (report.name or '') if report else '' def get_row_csv_row(self, row, fields): - csvrow = super(ProductBatchView, self).get_row_csv_row(row, fields) + csvrow = super().get_row_csv_row(row, fields) self.supplement_row_data(row, fields, csvrow) return csvrow def get_row_xlsx_row(self, row, fields): - xlrow = super(ProductBatchView, self).get_row_xlsx_row(row, fields) + xlrow = super().get_row_xlsx_row(row, fields) self.supplement_row_data(row, fields, xlrow) return xlrow diff --git a/tailbone/views/batch/vendorinvoice.py b/tailbone/views/batch/vendorinvoice.py index 6b8bdef7..4815d1f4 100644 --- a/tailbone/views/batch/vendorinvoice.py +++ b/tailbone/views/batch/vendorinvoice.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 @@ Views for maintaining vendor invoices """ -from __future__ import unicode_literals, absolute_import - -import six - from rattail.db import model from rattail.vendors.invoices import iter_invoice_parsers, require_invoice_parser @@ -89,10 +85,10 @@ class VendorInvoiceView(FileBatchMasterView): ] def get_instance_title(self, batch): - return six.text_type(batch.vendor) + return str(batch.vendor) def configure_grid(self, g): - super(VendorInvoiceView, self).configure_grid(g) + super().configure_grid(g) # vendor g.set_joiner('vendor', lambda q: q.join(model.Vendor)) @@ -118,7 +114,7 @@ class VendorInvoiceView(FileBatchMasterView): g.set_link('executed', False) def configure_form(self, f): - super(VendorInvoiceView, self).configure_form(f) + super().configure_form(f) # vendor if self.creating: @@ -167,7 +163,7 @@ class VendorInvoiceView(FileBatchMasterView): # raise formalchemy.ValidationError(unicode(error)) def get_batch_kwargs(self, batch): - kwargs = super(VendorInvoiceView, self).get_batch_kwargs(batch) + kwargs = super().get_batch_kwargs(batch) kwargs['parser_key'] = batch.parser_key return kwargs @@ -183,7 +179,7 @@ class VendorInvoiceView(FileBatchMasterView): return True def configure_row_grid(self, g): - super(VendorInvoiceView, self).configure_row_grid(g) + super().configure_row_grid(g) g.set_label('upc', "UPC") g.set_label('brand_name', "Brand") g.set_label('shipped_cases', "Cases") diff --git a/tailbone/views/common.py b/tailbone/views/common.py index 4632a285..f4d98c05 100644 --- a/tailbone/views/common.py +++ b/tailbone/views/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. # @@ -25,15 +25,13 @@ Various common views """ import os +import warnings from collections import OrderedDict from rattail.batch import consume_batch_id -from rattail.util import simple_error, import_module_path +from rattail.util import get_pkg_version, simple_error from rattail.files import resource_path -from pyramid import httpexceptions -from pyramid.response import Response - from tailbone import forms from tailbone.forms.common import Feedback from tailbone.db import Session @@ -52,17 +50,36 @@ class CommonView(View): """ Home page view. """ - if not self.request.user: - if self.rattail_config.getbool('tailbone', 'login_is_home', default=True): - raise self.redirect(self.request.route_url('login')) + app = self.get_rattail_app() - image_url = self.rattail_config.get( - 'tailbone', 'main_image_url', - default=self.request.static_url('tailbone:static/img/home_logo.png')) + # maybe auto-redirect anons to login + if not self.request.user: + redirect = self.config.get_bool('wuttaweb.home_redirect_to_login') + if redirect is None: + redirect = self.config.get_bool('tailbone.login_is_home') + if redirect is not None: + warnings.warn("tailbone.login_is_home setting is deprecated; " + "please set wuttaweb.home_redirect_to_login instead", + DeprecationWarning) + else: + # TODO: this is opposite of upstream default, should change + redirect = True + if redirect: + return self.redirect(self.request.route_url('login')) + + image_url = self.config.get('wuttaweb.logo_url') + if not image_url: + image_url = self.config.get('tailbone.main_image_url') + if image_url: + warnings.warn("tailbone.main_image_url setting is deprecated; " + "please set wuttaweb.logo_url instead", + DeprecationWarning) + else: + image_url = self.request.static_url('tailbone:static/img/home_logo.png') context = { 'image_url': image_url, - 'index_title': self.rattail_config.node_title(), + 'index_title': app.get_node_title(), 'help_url': global_help_url(self.rattail_config), } @@ -101,7 +118,8 @@ class CommonView(View): return response def get_project_title(self): - return self.rattail_config.app_title() + app = self.get_rattail_app() + return app.get_title() def get_project_version(self): @@ -109,9 +127,8 @@ class CommonView(View): if hasattr(self, 'project_version'): return self.project_version - pkg = self.rattail_config.app_package() - mod = import_module_path(pkg) - return mod.__version__ + app = self.get_rattail_app() + return app.get_version() def exception(self): """ @@ -123,11 +140,12 @@ class CommonView(View): """ Generic view to show "about project" info page. """ + app = self.get_rattail_app() return { 'project_title': self.get_project_title(), 'project_version': self.get_project_version(), 'packages': self.get_packages(), - 'index_title': self.rattail_config.node_title(), + 'index_title': app.get_node_title(), } def get_packages(self): @@ -135,10 +153,9 @@ class CommonView(View): Should return the full set of packages which should be displayed on the 'about' page. """ - import rattail, tailbone return OrderedDict([ - ('rattail', rattail.__version__), - ('Tailbone', tailbone.__version__), + ('rattail', get_pkg_version('rattail')), + ('Tailbone', get_pkg_version('Tailbone')), ]) def change_theme(self): @@ -153,9 +170,8 @@ class CommonView(View): except Exception as error: msg = "Failed to set theme: {}: {}".format(error.__class__.__name__, error) self.request.session.flash(msg, 'error') - else: - self.request.session.flash("App theme has been changed to: {}".format(theme)) - return self.redirect(self.request.get_referrer()) + referrer = self.request.params.get('referrer') or self.request.get_referrer() + return self.redirect(referrer) def change_db_engine(self): """ @@ -187,7 +203,8 @@ class CommonView(View): data['client_ip'] = self.request.client_addr app.send_email('user_feedback', data=data) return {'ok': True} - return {'error': "Form did not validate!"} + dform = form.make_deform_form() + return {'error': str(dform.error)} def consume_batch_id(self): """ @@ -209,7 +226,7 @@ class CommonView(View): raise self.forbidden() app = self.get_rattail_app() - app_title = self.rattail_config.app_title() + app_title = app.get_title() poser_handler = app.get_poser_handler() poser_dir = poser_handler.get_default_poser_dir() poser_dir_exists = os.path.isdir(poser_dir) diff --git a/tailbone/views/core.py b/tailbone/views/core.py index 97b59c10..88b2519f 100644 --- a/tailbone/views/core.py +++ b/tailbone/views/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. # @@ -26,10 +26,6 @@ Base View Class import os -from rattail.db import model -from rattail.core import Object -from rattail.util import progress_loop - from pyramid import httpexceptions from pyramid.renderers import render_to_response from pyramid.response import FileResponse @@ -40,7 +36,7 @@ from tailbone.progress import SessionProgress from tailbone.config import protected_usernames -class View(object): +class View: """ Base class for all class-based views. """ @@ -62,8 +58,10 @@ class View(object): config = self.rattail_config if config: - self.enum = config.get_enum() - self.model = config.get_model() + self.config = config + self.app = self.config.get_app() + self.model = self.app.model + self.enum = self.app.enum @property def rattail_config(self): @@ -94,6 +92,7 @@ class View(object): Returns the :class:`rattail:rattail.db.model.User` instance corresponding to the "late login" form data (if any), or ``None``. """ + model = self.model if self.request.method == 'POST': uuid = self.request.POST.get('late-login-user') if uuid: @@ -120,7 +119,8 @@ class View(object): return httpexceptions.HTTPFound(location=url, **kwargs) def progress_loop(self, func, items, factory, *args, **kwargs): - return progress_loop(func, items, factory, *args, **kwargs) + app = self.get_rattail_app() + return app.progress_loop(func, items, factory, *args, **kwargs) def make_progress(self, key, **kwargs): """ @@ -165,7 +165,8 @@ class View(object): return self.expose_quickie_search def get_quickie_context(self): - return Object( + app = self.get_rattail_app() + return app.make_object( url=self.get_quickie_url(), perm=self.get_quickie_perm(), placeholder=self.get_quickie_placeholder()) diff --git a/tailbone/views/customers.py b/tailbone/views/customers.py index 0d4e3d7c..7e49ccef 100644 --- a/tailbone/views/customers.py +++ b/tailbone/views/customers.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. # @@ -37,14 +37,14 @@ from tailbone import grids from tailbone.db import Session from tailbone.views import MasterView -from rattail.db import model +from rattail.db.model import Customer, CustomerShopper, PendingCustomer class CustomerView(MasterView): """ Master view for the Customer class. """ - model_class = model.Customer + model_class = Customer is_contact = True has_versions = True results_downloadable = True @@ -208,8 +208,7 @@ class CustomerView(MasterView): url = lambda r, i: self.request.route_url( f'{route_prefix}.view', **self.get_action_route_kwargs(r)) # nb. insert to slot 1, just after normal View action - g.main_actions.insert(1, self.make_action( - 'view_raw', url=url, icon='eye')) + g.actions.insert(1, self.make_action('view_raw', url=url, icon='eye')) g.set_link('name') g.set_link('person') @@ -251,6 +250,7 @@ class CustomerView(MasterView): if instance: return instance + model = self.model key = self.request.matchdict['uuid'] # search by Customer.id @@ -270,7 +270,7 @@ class CustomerView(MasterView): if instance: return instance.customer - raise HTTPNotFound + raise self.notfound() def configure_form(self, f): super().configure_form(f) @@ -341,7 +341,7 @@ class CustomerView(MasterView): # people if self.should_expose_people(): if self.viewing: - f.set_renderer('people', self.render_people_buefy) + f.set_renderer('people', self.render_people) else: f.remove('people') else: @@ -436,6 +436,7 @@ class CustomerView(MasterView): return kwargs def unique_id(self, node, value): + model = self.model query = self.Session.query(model.Customer)\ .filter(model.Customer.id == value) if self.editing: @@ -463,27 +464,14 @@ class CustomerView(MasterView): url = self.request.route_url('people.view', uuid=person.uuid) return tags.link_to(text, url) - # TODO: remove if no longer used - def render_people(self, customer, field): - people = customer.people - if not people: - return "" - - items = [] - for person in people: - text = str(person) - url = self.request.route_url('people.view', uuid=person.uuid) - link = tags.link_to(text, url) - items.append(HTML.tag('li', c=[link])) - return HTML.tag('ul', c=items) - def render_shoppers(self, customer, field): route_prefix = self.get_route_prefix() permission_prefix = self.get_permission_prefix() factory = self.get_grid_factory() g = factory( - key='{}.people'.format(route_prefix), + self.request, + key=f'{route_prefix}.people', data=[], columns=[ 'shopper_number', @@ -504,15 +492,16 @@ class CustomerView(MasterView): ) return HTML.literal( - g.render_buefy_table_element(data_prop='shoppers')) + g.render_table_element(data_prop='shoppers')) - def render_people_buefy(self, customer, field): + def render_people(self, customer, field): route_prefix = self.get_route_prefix() permission_prefix = self.get_permission_prefix() factory = self.get_grid_factory() g = factory( - key='{}.people'.format(route_prefix), + self.request, + key=f'{route_prefix}.people', data=[], columns=[ 'full_name', @@ -524,16 +513,16 @@ class CustomerView(MasterView): ) if self.request.has_perm('people.view'): - g.main_actions.append(self.make_action('view', icon='eye')) + g.actions.append(self.make_action('view', icon='eye')) if self.request.has_perm('people.edit'): - g.main_actions.append(self.make_action('edit', icon='edit')) + g.actions.append(self.make_action('edit', icon='edit')) if self.people_detachable and self.has_perm('detach_person'): - g.main_actions.append(self.make_action('detach', icon='minus-circle', - link_class='has-text-warning', - click_handler="$emit('detach-person', props.row._action_url_detach)")) + g.actions.append(self.make_action('detach', icon='minus-circle', + link_class='has-text-warning', + click_handler="$emit('detach-person', props.row._action_url_detach)")) return HTML.literal( - g.render_buefy_table_element(data_prop='peopleData')) + g.render_table_element(data_prop='peopleData')) def render_groups(self, customer, field): groups = customer.groups @@ -559,6 +548,7 @@ class CustomerView(MasterView): def get_version_child_classes(self): classes = super().get_version_child_classes() + model = self.model classes.extend([ (model.CustomerGroupAssignment, 'customer_uuid'), (model.CustomerPhoneNumber, 'parent_uuid'), @@ -570,6 +560,7 @@ class CustomerView(MasterView): return classes def detach_person(self): + model = self.model customer = self.get_instance() person = self.Session.get(model.Person, self.request.matchdict['person_uuid']) if not person: @@ -665,9 +656,7 @@ class CustomerView(MasterView): config.add_tailbone_permission(permission_prefix, '{}.detach_person'.format(permission_prefix), "Detach a Person from a {}".format(model_title)) - # TODO: this should require POST, but we'll add that once - # we can assume a Buefy theme is present, to avoid having - # to implement the logic in old jquery... + # TODO: this should require POST! config.add_route('{}.detach_person'.format(route_prefix), '{}/detach-person/{{person_uuid}}'.format(instance_url_prefix), # request_method='POST', @@ -681,7 +670,7 @@ class CustomerShopperView(MasterView): """ Master view for the CustomerShopper class. """ - model_class = model.CustomerShopper + model_class = CustomerShopper route_prefix = 'customer_shoppers' url_prefix = '/customer-shoppers' @@ -762,7 +751,7 @@ class PendingCustomerView(MasterView): """ Master view for the Pending Customer class. """ - model_class = model.PendingCustomer + model_class = PendingCustomer route_prefix = 'pending_customers' url_prefix = '/customers/pending' @@ -891,7 +880,7 @@ class PendingCustomerView(MasterView): # TODO: this only works when creating, need to add edit support? # TODO: can this just go away? since we have unique_id() view method above def unique_id(node, value): - customers = Session.query(model.Customer).filter(model.Customer.id == value) + customers = Session.query(Customer).filter(Customer.id == value) if customers.count(): raise colander.Invalid(node, "Customer ID must be unique") @@ -900,6 +889,8 @@ def customer_info(request): """ View which returns simple dictionary of info for a particular customer. """ + app = request.rattail_config.get_app() + model = app.model uuid = request.params.get('uuid') customer = Session.get(model.Customer, uuid) if uuid else None if not customer: diff --git a/tailbone/views/custorders/batch.py b/tailbone/views/custorders/batch.py index 38d2eda7..fa0df901 100644 --- a/tailbone/views/custorders/batch.py +++ b/tailbone/views/custorders/batch.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,7 +24,7 @@ Base class for customer order batch views """ -from rattail.db import model +from rattail.db.model import CustomerOrderBatch, CustomerOrderBatchRow import colander from webhelpers2.html import tags @@ -38,8 +38,8 @@ class CustomerOrderBatchView(BatchMasterView): Master view base class, for customer order batches. The views for the various mode/workflow batches will derive from this. """ - model_class = model.CustomerOrderBatch - model_row_class = model.CustomerOrderBatchRow + model_class = CustomerOrderBatch + model_row_class = CustomerOrderBatchRow default_handler_spec = 'rattail.batch.custorder:CustomerOrderBatchHandler' grid_columns = [ @@ -122,7 +122,7 @@ class CustomerOrderBatchView(BatchMasterView): ] def configure_grid(self, g): - super(CustomerOrderBatchView, self).configure_grid(g) + super().configure_grid(g) g.set_type('total_price', 'currency') @@ -131,9 +131,9 @@ class CustomerOrderBatchView(BatchMasterView): g.set_link('created_by') def configure_form(self, f): - super(CustomerOrderBatchView, self).configure_form(f) + super().configure_form(f) order = f.model_instance - model = self.rattail_config.get_model() + model = self.model # readonly fields f.set_readonly('rows') @@ -201,7 +201,7 @@ class CustomerOrderBatchView(BatchMasterView): return 'notice' def configure_row_grid(self, g): - super(CustomerOrderBatchView, self).configure_row_grid(g) + super().configure_row_grid(g) g.set_type('case_quantity', 'quantity') g.set_type('cases_ordered', 'quantity') @@ -215,7 +215,7 @@ class CustomerOrderBatchView(BatchMasterView): g.set_link('product_description') def configure_row_form(self, f): - super(CustomerOrderBatchView, self).configure_row_form(f) + super().configure_row_form(f) f.set_renderer('product', self.render_product) f.set_renderer('pending_product', self.render_pending_product) diff --git a/tailbone/views/custorders/items.py b/tailbone/views/custorders/items.py index 91976196..e7edf3aa 100644 --- a/tailbone/views/custorders/items.py +++ b/tailbone/views/custorders/items.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. # @@ -385,6 +385,7 @@ class CustomerOrderItemView(MasterView): factory = self.get_grid_factory() g = factory( + self.request, key=f'{route_prefix}.events', data=[], columns=[ @@ -401,7 +402,7 @@ class CustomerOrderItemView(MasterView): ) table = HTML.literal( - g.render_buefy_table_element(data_prop='eventsData')) + g.render_table_element(data_prop='eventsData')) elements = [table] if self.has_perm('add_note'): diff --git a/tailbone/views/custorders/orders.py b/tailbone/views/custorders/orders.py index f76d4d93..b1a9831a 100644 --- a/tailbone/views/custorders/orders.py +++ b/tailbone/views/custorders/orders.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. # @@ -29,13 +29,12 @@ import logging from sqlalchemy import orm -from rattail.db import model -from rattail.util import pretty_quantity, simple_error +from rattail.db.model import CustomerOrder, CustomerOrderItem +from rattail.util import simple_error from rattail.batch import get_batch_handler from webhelpers2.html import tags, HTML -from tailbone.db import Session from tailbone.views import MasterView @@ -46,7 +45,7 @@ class CustomerOrderView(MasterView): """ Master view for customer orders """ - model_class = model.CustomerOrder + model_class = CustomerOrder route_prefix = 'custorders' editable = False configurable = True @@ -80,7 +79,7 @@ class CustomerOrderView(MasterView): ] has_rows = True - model_row_class = model.CustomerOrderItem + model_row_class = CustomerOrderItem rows_viewable = False row_labels = { @@ -116,15 +115,17 @@ class CustomerOrderView(MasterView): ] def __init__(self, request): - super(CustomerOrderView, self).__init__(request) + super().__init__(request) self.batch_handler = self.get_batch_handler() def query(self, session): + model = self.app.model return session.query(model.CustomerOrder)\ .options(orm.joinedload(model.CustomerOrder.customer)) def configure_grid(self, g): super().configure_grid(g) + model = self.app.model # id g.set_link('id') @@ -163,7 +164,7 @@ class CustomerOrderView(MasterView): return f"#{order.id} for {order.customer or order.person}" def configure_form(self, f): - super(CustomerOrderView, self).configure_form(f) + super().configure_form(f) order = f.model_instance f.set_readonly('id') @@ -233,6 +234,7 @@ class CustomerOrderView(MasterView): class_='has-background-warning') def get_row_data(self, order): + model = self.app.model return self.Session.query(model.CustomerOrderItem)\ .filter(model.CustomerOrderItem.order == order) @@ -240,11 +242,13 @@ class CustomerOrderView(MasterView): return item.order def make_row_grid_kwargs(self, **kwargs): - kwargs = super(CustomerOrderView, self).make_row_grid_kwargs(**kwargs) + kwargs = super().make_row_grid_kwargs(**kwargs) - assert not kwargs['main_actions'] - kwargs['main_actions'].append( - self.make_action('view', icon='eye', url=self.row_view_action_url)) + actions = kwargs.get('actions', []) + if not actions: + actions.append(self.make_action('view', icon='eye', + url=self.row_view_action_url)) + kwargs['actions'] = actions return kwargs @@ -253,7 +257,7 @@ class CustomerOrderView(MasterView): return self.request.route_url('custorders.items.view', uuid=item.uuid) def configure_row_grid(self, g): - super(CustomerOrderView, self).configure_row_grid(g) + super().configure_row_grid(g) app = self.get_rattail_app() handler = app.get_batch_handler( 'custorder', @@ -423,6 +427,7 @@ class CustomerOrderView(MasterView): if not user: raise RuntimeError("this feature requires a user to be logged in") + model = self.app.model try: # there should be at most *one* new batch per user batch = self.Session.query(model.CustomerOrderBatch)\ @@ -488,6 +493,7 @@ class CustomerOrderView(MasterView): if not uuid: return {'error': "Must specify a customer UUID"} + model = self.app.model customer = self.Session.get(model.Customer, uuid) if not customer: return {'error': "Customer not found"} @@ -508,6 +514,7 @@ class CustomerOrderView(MasterView): return info def assign_contact(self, batch, data): + model = self.app.model kwargs = {} # this will either be a Person or Customer UUID @@ -662,6 +669,7 @@ class CustomerOrderView(MasterView): if not uuid: return {'error': "Must specify a product UUID"} + model = self.app.model product = self.Session.get(model.Product, uuid) if not product: return {'error': "Product not found"} @@ -725,8 +733,7 @@ class CustomerOrderView(MasterView): return app.render_currency(obj.unit_price) def normalize_row(self, row): - app = self.get_rattail_app() - products_handler = app.get_products_handler() + products_handler = self.app.get_products_handler() data = { 'uuid': row.uuid, @@ -742,20 +749,20 @@ class CustomerOrderView(MasterView): 'product_size': row.product_size, 'product_weighed': row.product_weighed, - 'case_quantity': pretty_quantity(row.case_quantity), - 'cases_ordered': pretty_quantity(row.cases_ordered), - 'units_ordered': pretty_quantity(row.units_ordered), - 'order_quantity': pretty_quantity(row.order_quantity), + 'case_quantity': self.app.render_quantity(row.case_quantity), + 'cases_ordered': self.app.render_quantity(row.cases_ordered), + 'units_ordered': self.app.render_quantity(row.units_ordered), + 'order_quantity': self.app.render_quantity(row.order_quantity), 'order_uom': row.order_uom, 'order_uom_choices': self.uom_choices_for_row(row), - 'discount_percent': pretty_quantity(row.discount_percent), + 'discount_percent': self.app.render_quantity(row.discount_percent), 'department_display': row.department_name, 'unit_price': float(row.unit_price) if row.unit_price is not None else None, 'unit_price_display': self.get_unit_price_display(row), 'total_price': float(row.total_price) if row.total_price is not None else None, - 'total_price_display': app.render_currency(row.total_price), + 'total_price_display': self.app.render_currency(row.total_price), 'status_code': row.status_code, 'status_text': row.status_text, @@ -763,15 +770,15 @@ class CustomerOrderView(MasterView): if row.unit_regular_price: data['unit_regular_price'] = float(row.unit_regular_price) - data['unit_regular_price_display'] = app.render_currency(row.unit_regular_price) + data['unit_regular_price_display'] = self.app.render_currency(row.unit_regular_price) if row.unit_sale_price: data['unit_sale_price'] = float(row.unit_sale_price) - data['unit_sale_price_display'] = app.render_currency(row.unit_sale_price) + data['unit_sale_price_display'] = self.app.render_currency(row.unit_sale_price) if row.sale_ends: - sale_ends = app.localtime(row.sale_ends, from_utc=True).date() + sale_ends = self.app.localtime(row.sale_ends, from_utc=True).date() data['sale_ends'] = str(sale_ends) - data['sale_ends_display'] = app.render_date(sale_ends) + data['sale_ends_display'] = self.app.render_date(sale_ends) if row.unit_sale_price and row.unit_price == row.unit_sale_price: data['pricing_reflects_sale'] = True @@ -808,12 +815,12 @@ class CustomerOrderView(MasterView): case_price = self.batch_handler.get_case_price_for_row(row) data['case_price'] = float(case_price) if case_price is not None else None - data['case_price_display'] = app.render_currency(case_price) + data['case_price_display'] = self.app.render_currency(case_price) if self.batch_handler.product_price_may_be_questionable(): data['price_needs_confirmation'] = row.price_needs_confirmation - key = app.get_product_key_field() + key = self.app.get_product_key_field() if key == 'upc': data['product_key'] = data['product_upc_pretty'] elif key == 'item_id': @@ -837,7 +844,7 @@ class CustomerOrderView(MasterView): case_qty = unit_qty = '??' else: case_qty = data['case_quantity'] - unit_qty = pretty_quantity(row.order_quantity * row.case_quantity) + unit_qty = self.app.render_quantity(row.order_quantity * row.case_quantity) data.update({ 'order_quantity_display': "{} {} (× {} {} = {} {})".format( data['order_quantity'], @@ -850,14 +857,14 @@ class CustomerOrderView(MasterView): else: data.update({ 'order_quantity_display': "{} {}".format( - pretty_quantity(row.order_quantity), + self.app.render_quantity(row.order_quantity), self.enum.UNIT_OF_MEASURE[unit_uom]), }) return data def add_item(self, batch, data): - app = self.get_rattail_app() + model = self.app.model order_quantity = decimal.Decimal(data.get('order_quantity') or '0') order_uom = data.get('order_uom') @@ -888,7 +895,7 @@ class CustomerOrderView(MasterView): pending_info = dict(data['pending_product']) if 'upc' in pending_info: - pending_info['upc'] = app.make_gpc(pending_info['upc']) + pending_info['upc'] = self.app.make_gpc(pending_info['upc']) for field in ('unit_cost', 'regular_price_amount', 'case_size'): if field in pending_info: @@ -917,6 +924,7 @@ class CustomerOrderView(MasterView): if not uuid: return {'error': "Must specify a row UUID"} + model = self.app.model row = self.Session.get(model.CustomerOrderBatchRow, uuid) if not row: return {'error': "Row not found"} @@ -975,6 +983,7 @@ class CustomerOrderView(MasterView): if not uuid: return {'error': "Must specify a row UUID"} + model = self.app.model row = self.Session.get(model.CustomerOrderBatchRow, uuid) if not row: return {'error': "Row not found"} diff --git a/tailbone/views/datasync.py b/tailbone/views/datasync.py index ac0fec52..2b955b5f 100644 --- a/tailbone/views/datasync.py +++ b/tailbone/views/datasync.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. # @@ -30,10 +30,12 @@ import logging import sqlalchemy as sa -from rattail.db import model +from rattail.db.model import DataSyncChange from rattail.datasync.util import purge_datasync_settings from rattail.util import simple_error +from webhelpers2.html import tags + from tailbone.views import MasterView from tailbone.util import raw_datetime from tailbone.config import should_expose_websockets @@ -71,10 +73,22 @@ class DataSyncThreadView(MasterView): ] def __init__(self, request, context=None): - super(DataSyncThreadView, self).__init__(request, context=context) + super().__init__(request, context=context) app = self.get_rattail_app() self.datasync_handler = app.get_datasync_handler() + def get_context_menu_items(self, thread=None): + items = super().get_context_menu_items(thread) + route_prefix = self.get_route_prefix() + + # nb. do not show this for /configure page + if self.request.matched_route.name != f'{route_prefix}.configure': + if self.request.has_perm('datasync_changes.list'): + url = self.request.route_url('datasyncchanges') + items.append(tags.link_to("View DataSync Changes", url)) + + return items + def status(self): """ View to list/filter/sort the model data. @@ -106,7 +120,7 @@ class DataSyncThreadView(MasterView): from datasync_change group by source, consumer """ - result = self.Session.execute(sql) + result = self.Session.execute(sa.text(sql)) all_changes = {} for row in result: all_changes[(row.source, row.consumer)] = row.changes @@ -188,10 +202,36 @@ class DataSyncThreadView(MasterView): return self.redirect(self.request.get_referrer( default=self.request.route_url('datasyncchanges'))) - def configure_get_context(self): + def configure_get_simple_settings(self): + """ """ + return [ + + # basic + {'section': 'rattail.datasync', + 'option': 'use_profile_settings', + 'type': bool}, + + # misc. + {'section': 'rattail.datasync', + 'option': 'supervisor_process_name'}, + {'section': 'rattail.datasync', + 'option': 'batch_size_limit', + 'type': int}, + + # legacy + {'section': 'tailbone', + 'option': 'datasync.restart'}, + + ] + + def configure_get_context(self, **kwargs): + """ """ + context = super().configure_get_context(**kwargs) + profiles = self.datasync_handler.get_configured_profiles( include_disabled=True, ignore_problems=True) + context['profiles'] = profiles profiles_data = [] for profile in sorted(profiles.values(), key=lambda p: p.key): @@ -229,25 +269,15 @@ class DataSyncThreadView(MasterView): data['consumers_data'] = consumers profiles_data.append(data) - return { - 'profiles': profiles, - 'profiles_data': profiles_data, - 'use_profile_settings': self.datasync_handler.should_use_profile_settings(), - 'supervisor_process_name': self.rattail_config.get( - 'rattail.datasync', 'supervisor_process_name'), - 'restart_command': self.rattail_config.get( - 'tailbone', 'datasync.restart'), - } + context['profiles_data'] = profiles_data + return context - def configure_gather_settings(self, data): - settings = [] - watch = [] + def configure_gather_settings(self, data, **kwargs): + """ """ + settings = super().configure_gather_settings(data, **kwargs) - use_profile_settings = data.get('use_profile_settings') == 'true' - settings.append({'name': 'rattail.datasync.use_profile_settings', - 'value': 'true' if use_profile_settings else 'false'}) - - if use_profile_settings: + if data.get('rattail.datasync.use_profile_settings') == 'true': + watch = [] for profile in json.loads(data['profiles']): pkey = profile['key'] @@ -309,17 +339,12 @@ class DataSyncThreadView(MasterView): settings.append({'name': 'rattail.datasync.watch', 'value': ', '.join(watch)}) - if data['supervisor_process_name']: - settings.append({'name': 'rattail.datasync.supervisor_process_name', - 'value': data['supervisor_process_name']}) - - if data['restart_command']: - settings.append({'name': 'tailbone.datasync.restart', - 'value': data['restart_command']}) - return settings - def configure_remove_settings(self): + def configure_remove_settings(self, **kwargs): + """ """ + super().configure_remove_settings(**kwargs) + purge_datasync_settings(self.rattail_config, self.Session()) @classmethod @@ -368,7 +393,7 @@ class DataSyncChangeView(MasterView): """ Master view for the DataSyncChange model. """ - model_class = model.DataSyncChange + model_class = DataSyncChange url_prefix = '/datasync/changes' permission_prefix = 'datasync_changes' creatable = False @@ -389,8 +414,19 @@ class DataSyncChangeView(MasterView): 'consumer', ] + def get_context_menu_items(self, change=None): + items = super().get_context_menu_items(change) + + if self.listing: + + if self.request.has_perm('datasync.status'): + url = self.request.route_url('datasync.status') + items.append(tags.link_to("View DataSync Status", url)) + + return items + def configure_grid(self, g): - super(DataSyncChangeView, self).configure_grid(g) + super().configure_grid(g) # batch_sequence g.set_label('batch_sequence', "Batch Seq.") @@ -404,7 +440,7 @@ class DataSyncChangeView(MasterView): return kwargs def configure_form(self, f): - super(DataSyncChangeView, self).configure_form(f) + super().configure_form(f) f.set_readonly('obtained') diff --git a/tailbone/views/departments.py b/tailbone/views/departments.py index 8115c5c3..47de8dca 100644 --- a/tailbone/views/departments.py +++ b/tailbone/views/departments.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,7 +24,7 @@ Department Views """ -from rattail.db import model +from rattail.db.model import Department, Product from webhelpers2.html import HTML @@ -35,7 +35,7 @@ class DepartmentView(MasterView): """ Master view for the Department class. """ - model_class = model.Department + model_class = Department touchable = True has_versions = True results_downloadable = True @@ -59,12 +59,13 @@ class DepartmentView(MasterView): 'tax', 'food_stampable', 'exempt_from_gross_sales', + 'default_custorder_discount', 'allow_product_deletions', 'employees', ] has_rows = True - model_row_class = model.Product + model_row_class = Product rows_title = "Products" row_labels = { @@ -110,7 +111,16 @@ class DepartmentView(MasterView): f.set_type('personnel', 'boolean') # tax - f.set_renderer('tax', self.render_tax) + if self.creating: + # TODO: make this editable instead + f.remove('tax') + else: + f.set_renderer('tax', self.render_tax) + # TODO: make this editable + f.set_readonly('tax') + + # default_custorder_discount + f.set_type('default_custorder_discount', 'percent') def render_employees(self, department, field): route_prefix = self.get_route_prefix() @@ -118,7 +128,8 @@ class DepartmentView(MasterView): factory = self.get_grid_factory() g = factory( - key='{}.employees'.format(route_prefix), + self.request, + key=f'{route_prefix}.employees', data=[], columns=[ 'first_name', @@ -129,12 +140,12 @@ class DepartmentView(MasterView): ) if self.request.has_perm('employees.view'): - g.main_actions.append(self.make_action('view', icon='eye')) + g.actions.append(self.make_action('view', icon='eye')) if self.request.has_perm('employees.edit'): - g.main_actions.append(self.make_action('edit', icon='edit')) + g.actions.append(self.make_action('edit', icon='edit')) return HTML.literal( - g.render_buefy_table_element(data_prop='employeesData')) + g.render_table_element(data_prop='employeesData')) def template_kwargs_view(self, **kwargs): kwargs = super().template_kwargs_view(**kwargs) @@ -160,6 +171,7 @@ class DepartmentView(MasterView): Check to see if there are any products which belong to the department; if there are then we do not allow delete and redirect the user. """ + model = self.model count = self.Session.query(model.Product)\ .filter(model.Product.department == department)\ .count() @@ -169,6 +181,7 @@ class DepartmentView(MasterView): raise self.redirect(self.get_action_url('view', department)) def get_row_data(self, department): + model = self.model return self.Session.query(model.Product)\ .filter(model.Product.department == department) @@ -198,6 +211,7 @@ class DepartmentView(MasterView): """ View list of departments by vendor """ + model = self.model data = self.Session.query(model.Department)\ .outerjoin(model.Product)\ .join(model.ProductCost)\ diff --git a/tailbone/views/email.py b/tailbone/views/email.py index 22954782..98bd4295 100644 --- a/tailbone/views/email.py +++ b/tailbone/views/email.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,14 +28,13 @@ import logging import re import warnings -from rattail import mail -from rattail.db import model -from rattail.config import parse_list +from wuttjamaican.util import parse_list + +from rattail.db.model import EmailAttempt from rattail.util import simple_error import colander from deform import widget as dfwidget -from webhelpers2.html import HTML from tailbone import grids from tailbone.db import Session @@ -85,7 +84,7 @@ class EmailSettingView(MasterView): ] def __init__(self, request): - super(EmailSettingView, self).__init__(request) + super().__init__(request) self.email_handler = self.get_handler() @property @@ -117,11 +116,12 @@ class EmailSettingView(MasterView): return data def configure_grid(self, g): - g.sorters['key'] = g.make_simple_sorter('key', foldcase=True) - g.sorters['prefix'] = g.make_simple_sorter('prefix', foldcase=True) - g.sorters['subject'] = g.make_simple_sorter('subject', foldcase=True) - g.sorters['enabled'] = g.make_simple_sorter('enabled') + super().configure_grid(g) + + g.sort_on_backend = False + g.sort_multiple = False g.set_sort_defaults('key') + g.set_type('enabled', 'boolean') g.set_link('key') g.set_link('subject') @@ -131,18 +131,16 @@ class EmailSettingView(MasterView): # to g.set_renderer('to', self.render_to_short) - g.sorters['to'] = g.make_simple_sorter('to', foldcase=True) # hidden if self.has_perm('configure'): - g.sorters['hidden'] = g.make_simple_sorter('hidden') g.set_type('hidden', 'boolean') else: g.remove('hidden') # toggle hidden if self.has_perm('configure'): - g.main_actions.append( + g.actions.append( self.make_action('toggle_hidden', url='#', icon='ban', click_handler='toggleHidden(props.row)', factory=ToggleHidden)) @@ -204,7 +202,7 @@ class EmailSettingView(MasterView): return True def configure_form(self, f): - super(EmailSettingView, self).configure_form(f) + super().configure_form(f) profile = f.model_instance['_email'] # key @@ -437,7 +435,7 @@ class EmailPreview(View): """ def __init__(self, request): - super(EmailPreview, self).__init__(request) + super().__init__(request) if hasattr(self, 'get_handler'): warnings.warn("defining a get_handler() method is deprecated; " @@ -520,7 +518,7 @@ class EmailAttemptView(MasterView): """ Master view for email attempts. """ - model_class = model.EmailAttempt + model_class = EmailAttempt route_prefix = 'email_attempts' url_prefix = '/email/attempts' creatable = False @@ -553,7 +551,7 @@ class EmailAttemptView(MasterView): ] def configure_grid(self, g): - super(EmailAttemptView, self).configure_grid(g) + super().configure_grid(g) # sent g.set_sort_defaults('sent', 'desc') @@ -583,13 +581,12 @@ class EmailAttemptView(MasterView): if len(recips) > 2: recips = recips[:2] recips.append('...') - recips = [HTML.escape(r) for r in recips] return ', '.join(recips) return value def configure_form(self, f): - super(EmailAttemptView, self).configure_form(f) + super().configure_form(f) # key f.set_renderer('key', self.render_email_key) diff --git a/tailbone/views/employees.py b/tailbone/views/employees.py index f4f99058..debd8fcb 100644 --- a/tailbone/views/employees.py +++ b/tailbone/views/employees.py @@ -167,8 +167,7 @@ class EmployeeView(MasterView): url = lambda r, i: self.request.route_url( f'{route_prefix}.view', **self.get_action_route_kwargs(r)) # nb. insert to slot 1, just after normal View action - g.main_actions.insert(1, self.make_action( - 'view_raw', url=url, icon='eye')) + g.actions.insert(1, self.make_action('view_raw', url=url, icon='eye')) def default_view_url(self): if (self.request.has_perm('people.view_profile') diff --git a/tailbone/views/exports.py b/tailbone/views/exports.py index 82591099..44df359f 100644 --- a/tailbone/views/exports.py +++ b/tailbone/views/exports.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,13 +24,9 @@ Master class for generic export history views """ -from __future__ import unicode_literals, absolute_import - import os import shutil -import six - from pyramid.response import FileResponse from webhelpers2.html import tags @@ -83,7 +79,7 @@ class ExportMasterView(MasterView): return self.get_file_path(export) def configure_grid(self, g): - super(ExportMasterView, self).configure_grid(g) + super().configure_grid(g) model = self.model # id @@ -106,7 +102,7 @@ class ExportMasterView(MasterView): return export.id_str def configure_form(self, f): - super(ExportMasterView, self).configure_form(f) + super().configure_form(f) export = f.model_instance # NOTE: we try to handle the 'creating' scenario even though this class @@ -149,7 +145,7 @@ class ExportMasterView(MasterView): f.set_renderer('filename', self.render_downloadable_file) def objectify(self, form, data=None): - obj = super(ExportMasterView, self).objectify(form, data=data) + obj = super().objectify(form, data=data) if self.creating: obj.created_by = self.request.user return obj @@ -158,7 +154,7 @@ class ExportMasterView(MasterView): user = export.created_by if not user: return "" - text = six.text_type(user) + text = str(user) if self.request.has_perm('users.view'): url = self.request.route_url('users.view', uuid=user.uuid) return tags.link_to(text, url) @@ -175,12 +171,8 @@ class ExportMasterView(MasterView): export = self.get_instance() path = self.get_file_path(export) response = FileResponse(path, request=self.request) - if six.PY3: - response.headers['Content-Length'] = str(os.path.getsize(path)) - response.headers['Content-Disposition'] = 'attachment; filename="{}"'.format(export.filename) - else: - response.headers[b'Content-Length'] = six.binary_type(os.path.getsize(path)) - response.headers[b'Content-Disposition'] = b'attachment; filename="{}"'.format(export.filename) + response.headers['Content-Length'] = str(os.path.getsize(path)) + response.headers['Content-Disposition'] = 'attachment; filename="{}"'.format(export.filename) return response def delete_instance(self, export): @@ -195,4 +187,4 @@ class ExportMasterView(MasterView): shutil.rmtree(dirname) # continue w/ normal deletion - super(ExportMasterView, self).delete_instance(export) + super().delete_instance(export) diff --git a/tailbone/views/importing.py b/tailbone/views/importing.py index acfddbf8..48b32cc2 100644 --- a/tailbone/views/importing.py +++ b/tailbone/views/importing.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,16 +25,15 @@ View for running arbitrary import/export jobs """ import getpass -import socket -import sys +import json import logging +import socket import subprocess +import sys import time -import json import sqlalchemy as sa -from rattail.exceptions import ConfigurationError from rattail.threads import Thread import colander @@ -152,10 +151,15 @@ class ImportingView(MasterView): return data def configure_grid(self, g): - super(ImportingView, self).configure_grid(g) + super().configure_grid(g) g.set_link('host_title') + g.set_searchable('host_title') + g.set_link('local_title') + g.set_searchable('local_title') + + g.set_searchable('handler_spec') def get_instance(self): """ @@ -177,7 +181,7 @@ class ImportingView(MasterView): return ImportHandlerSchema() def make_form_kwargs(self, **kwargs): - kwargs = super(ImportingView, self).make_form_kwargs(**kwargs) + kwargs = super().make_form_kwargs(**kwargs) # nb. this is set as sort of a hack, to prevent SA model # inspection logic @@ -186,7 +190,7 @@ class ImportingView(MasterView): return kwargs def configure_form(self, f): - super(ImportingView, self).configure_form(f) + super().configure_form(f) f.set_renderer('models', self.render_models) @@ -198,7 +202,7 @@ class ImportingView(MasterView): return HTML.tag('ul', c=items) def template_kwargs_view(self, **kwargs): - kwargs = super(ImportingView, self).template_kwargs_view(**kwargs) + kwargs = super().template_kwargs_view(**kwargs) handler_info = kwargs['instance'] kwargs['handler'] = handler_info['_handler'] return kwargs @@ -453,22 +457,7 @@ And here is the output: return HTML.tag('div', class_='tailbone-markdown', c=[notes]) def get_cmd_for_handler(self, handler, ignore_errors=False): - handler_key = handler.get_key() - - cmd = self.rattail_config.getlist('rattail.importing', - '{}.cmd'.format(handler_key)) - if not cmd or len(cmd) != 2: - cmd = self.rattail_config.getlist('rattail.importing', - '{}.default_cmd'.format(handler_key)) - - if not cmd or len(cmd) != 2: - msg = ("Missing or invalid config; please set '{}.default_cmd' in the " - "[rattail.importing] section of your config file".format(handler_key)) - if ignore_errors: - return - raise ConfigurationError(msg) - - return cmd + return handler.get_cmd(ignore_errors=ignore_errors) def get_runas_for_handler(self, handler): handler_key = handler.get_key() diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 7a1eff98..21a5e58f 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/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. # @@ -30,7 +30,6 @@ import csv import datetime import getpass import shutil -import tempfile import logging from collections import OrderedDict @@ -40,13 +39,11 @@ from sqlalchemy import orm import sqlalchemy_continuum as continuum from sqlalchemy_utils.functions import get_primary_keys, get_columns -from rattail.db import model, Session as RattailSession +from wuttjamaican.util import get_class_hierarchy from rattail.db.continuum import model_transaction_query -from rattail.util import prettify, simple_error, get_class_hierarchy -from rattail.time import localtime +from rattail.util import simple_error from rattail.threads import Thread from rattail.csvutil import UnicodeDictWriter -from rattail.files import temp_path from rattail.excel import ExcelWriter from rattail.gpc import GPC @@ -55,7 +52,6 @@ import deform from deform import widget as dfwidget from pyramid import httpexceptions from pyramid.renderers import get_renderer, render_to_response, render -from pyramid.response import FileResponse from webhelpers2.html import HTML, tags from webob.compat import cgi_FieldStorage @@ -121,6 +117,7 @@ class MasterView(View): supports_prev_next = False supports_import_batch_from_file = False has_input_file_templates = False + has_output_file_templates = False configurable = False # set to True to add "View *global* Objects" permission, and @@ -141,6 +138,7 @@ class MasterView(View): deleting = False executing = False cloning = False + configuring = False has_pk_fields = False has_image = False has_thumbnail = False @@ -221,7 +219,8 @@ class MasterView(View): to the current thread (one per request), this method should instead return e.g. a new independent ``rattail.db.Session`` instance. """ - return RattailSession() + app = self.get_rattail_app() + return app.make_session() @classmethod def get_grid_factory(cls): @@ -324,12 +323,19 @@ class MasterView(View): string, then the view will return the rendered grid only. Otherwise returns the full page. """ + # nb. normally this "save defaults" flag is checked within make_grid() + # but it returns JSON data so we can't just do a redirect when there + # is no user; must return JSON error message instead + if (self.request.GET.get('save-current-filters-as-defaults') == 'true' + and not self.request.user): + return self.json_response({'error': "User is not currently logged in"}) + self.listing = True grid = self.make_grid() # If user just refreshed the page with a reset instruction, issue a # redirect in order to clear out the query string. - if self.request.GET.get('reset-to-default-filters') == 'true': + if self.request.GET.get('reset-view'): kw = {'_query': None} hash_ = self.request.GET.get('hash') if hash_: @@ -337,14 +343,16 @@ class MasterView(View): return self.redirect(self.request.current_route_url(**kw)) # Stash some grid stats, for possible use when generating URLs. - if grid.pageable and hasattr(grid, 'pager'): + if grid.paginated and hasattr(grid, 'pager'): self.first_visible_grid_index = grid.pager.first_item # return grid data only, if partial page was requested - if self.request.params.get('partial'): - return self.json_response(grid.get_buefy_data()) + if self.request.GET.get('partial'): + context = grid.get_table_data() + return self.json_response(context) context = { + 'index_url': None, # nb. avoid title link since this *is* the index 'grid': grid, } @@ -375,7 +383,7 @@ class MasterView(View): grid contents etc. """ - def make_grid(self, factory=None, key=None, data=None, columns=None, **kwargs): + def make_grid(self, factory=None, key=None, data=None, columns=None, session=None, **kwargs): """ Creates a new grid instance """ @@ -384,13 +392,12 @@ class MasterView(View): if key is None: key = self.get_grid_key() if data is None: - data = self.get_data(session=kwargs.get('session')) + data = self.get_data(session=session) if columns is None: columns = self.get_grid_columns() - kwargs.setdefault('request', self.request) kwargs = self.make_grid_kwargs(**kwargs) - grid = factory(key, data, columns, **kwargs) + grid = factory(self.request, key=key, data=data, columns=columns, **kwargs) self.configure_grid(grid) grid.load_settings() return grid @@ -403,9 +410,9 @@ class MasterView(View): """ if session is None: session = self.Session() - kwargs.setdefault('pageable', False) + kwargs.setdefault('paginated', False) grid = self.make_grid(session=session, **kwargs) - return grid.make_visible_data() + return grid.get_visible_data() def get_grid_columns(self): """ @@ -436,7 +443,8 @@ class MasterView(View): 'filterable': self.filterable, 'use_byte_string_filters': self.use_byte_string_filters, 'sortable': self.sortable, - 'pageable': self.pageable, + 'sort_multiple': not self.request.use_oruga, + 'paginated': self.pageable, 'extra_row_class': self.grid_extra_class, 'url': lambda obj: self.get_action_url('view', obj), 'checkboxes': checkboxes, @@ -450,10 +458,26 @@ class MasterView(View): if self.sortable or self.pageable or self.filterable: defaults['expose_direct_link'] = True - if 'main_actions' not in kwargs and 'more_actions' not in kwargs: - main, more = self.get_grid_actions() - defaults['main_actions'] = main - defaults['more_actions'] = more + if 'actions' not in kwargs: + + if 'main_actions' in kwargs: + warnings.warn("main_actions param is deprecated for make_grid_kwargs(); " + "please use actions param instead", + DeprecationWarning, stacklevel=2) + main = kwargs.pop('main_actions') + else: + main = self.get_main_actions() + + if 'more_actions' in kwargs: + warnings.warn("more_actions param is deprecated for make_grid_kwargs(); " + "please use actions param instead", + DeprecationWarning, stacklevel=2) + more = kwargs.pop('more_actions') + else: + more = self.get_more_actions() + + defaults['actions'] = main + more + defaults.update(kwargs) return defaults @@ -527,7 +551,8 @@ class MasterView(View): def get_quickie_result_url(self, obj): return self.get_action_url('view', obj) - def make_row_grid(self, factory=None, key=None, data=None, columns=None, **kwargs): + def make_row_grid(self, factory=None, key=None, data=None, columns=None, + session=None, **kwargs): """ Make and return a new (configured) rows grid instance. """ @@ -544,9 +569,8 @@ class MasterView(View): if columns is None: columns = self.get_row_grid_columns() - kwargs.setdefault('request', self.request) kwargs = self.make_row_grid_kwargs(**kwargs) - grid = factory(key, data, columns, **kwargs) + grid = factory(self.request, key=key, data=data, columns=columns, **kwargs) self.configure_row_grid(grid) grid.load_settings() return grid @@ -565,15 +589,16 @@ class MasterView(View): 'filterable': self.rows_filterable, 'use_byte_string_filters': self.use_byte_string_filters, 'sortable': self.rows_sortable, - 'pageable': self.rows_pageable, + 'sort_multiple': not self.request.use_oruga, + 'paginated': self.rows_pageable, 'extra_row_class': self.row_grid_extra_class, 'url': lambda obj: self.get_row_action_url('view', obj), } if self.rows_default_pagesize: - defaults['default_pagesize'] = self.rows_default_pagesize + defaults['pagesize'] = self.rows_default_pagesize - if self.has_rows and 'main_actions' not in defaults: + if self.has_rows and 'actions' not in defaults: actions = [] # view action @@ -588,16 +613,17 @@ class MasterView(View): # delete action if self.rows_deletable and self.has_perm('delete_row'): - actions.append(self.make_action('delete', icon='trash', url=self.row_delete_action_url)) + actions.append(self.make_action('delete', icon='trash', + url=self.row_delete_action_url, + link_class='has-text-danger')) defaults['delete_speedbump'] = self.rows_deletable_speedbump - defaults['main_actions'] = actions + defaults['actions'] = actions defaults.update(kwargs) return defaults def configure_row_grid(self, grid): - # super(MasterView, self).configure_row_grid(grid) self.set_row_labels(grid) self.configure_column_customer_key(grid) @@ -627,9 +653,8 @@ class MasterView(View): if columns is None: columns = self.get_version_grid_columns() - kwargs.setdefault('request', self.request) kwargs = self.make_version_grid_kwargs(**kwargs) - grid = factory(key, data, columns, **kwargs) + grid = factory(self.request, key=key, data=data, columns=columns, **kwargs) self.configure_version_grid(grid) grid.load_settings() return grid @@ -655,12 +680,12 @@ class MasterView(View): defaults = { 'model_class': continuum.transaction_class(self.get_model_class()), 'width': 'full', - 'pageable': True, + 'paginated': True, 'url': lambda txn: self.request.route_url(route, uuid=instance.uuid, txnid=txn.id), } - if 'main_actions' not in kwargs: + if 'actions' not in kwargs: url = lambda txn, i: self.request.route_url(route, uuid=instance.uuid, txnid=txn.id) - defaults['main_actions'] = [ + defaults['actions'] = [ self.make_action('view', icon='eye', url=url), ] defaults.update(kwargs) @@ -714,10 +739,11 @@ class MasterView(View): return obj def normalize_uploads(self, form, skip=None): + app = self.get_rattail_app() uploads = {} def normalize(filedict): - tempdir = tempfile.mkdtemp() + tempdir = app.make_temp_dir() filepath = os.path.join(tempdir, filedict['filename']) tmpinfo = form.deform_form[node.name].widget.tmpstore.get(filedict['uid']) tmpdata = tmpinfo['fp'].read() @@ -877,7 +903,7 @@ class MasterView(View): def valid_employee_uuid(self, node, value): if value: - model = self.model + model = self.app.model employee = self.Session.get(model.Employee, value) if not employee: node.raise_invalid("Employee not found") @@ -913,7 +939,7 @@ class MasterView(View): def valid_vendor_uuid(self, node, value): if value: - model = self.model + model = self.app.model vendor = self.Session.get(model.Vendor, value) if not vendor: node.raise_invalid("Vendor not found") @@ -1109,7 +1135,8 @@ class MasterView(View): Thread target for populating new object with progress indicator. """ # mustn't use tailbone web session here - session = RattailSession() + app = self.get_rattail_app() + session = app.make_session() obj = session.get(self.model_class, uuid) try: self.populate_object(session, obj, progress=progress) @@ -1160,7 +1187,7 @@ class MasterView(View): # If user just refreshed the page with a reset instruction, issue a # redirect in order to clear out the query string. - if self.request.GET.get('reset-to-default-filters') == 'true': + if self.request.GET.get('reset-view'): kw = {'_query': None} hash_ = self.request.GET.get('hash') if hash_: @@ -1170,7 +1197,7 @@ class MasterView(View): # return grid only, if partial page was requested if self.request.params.get('partial'): # render grid data only, as JSON - return self.json_response(grid.get_buefy_data()) + return self.json_response(grid.get_table_data()) context = { 'instance': instance, @@ -1303,7 +1330,7 @@ class MasterView(View): # return grid only, if partial page was requested if self.request.params.get('partial'): # render grid data only, as JSON - return self.json_response(grid.get_buefy_data()) + return self.json_response(grid.get_table_data()) return self.render_to_response('versions', { 'instance': instance, @@ -1355,18 +1382,19 @@ class MasterView(View): return classes def make_revisions_grid(self, obj, empty_data=False): + model = self.app.model route_prefix = self.get_route_prefix() row_url = lambda txn, i: self.request.route_url(f'{route_prefix}.version', uuid=obj.uuid, txnid=txn.id) kwargs = { - 'component': 'versions-grid', + 'vue_tagname': 'versions-grid', 'ajax_data_url': self.get_action_url('revisions_data', obj), 'sortable': True, - 'default_sortkey': 'changed', - 'default_sortdir': 'desc', - 'main_actions': [ + 'sort_multiple': not self.request.use_oruga, + 'sort_defaults': ('changed', 'desc'), + 'actions': [ self.make_action('view', icon='eye', url='#', click_handler='viewRevision(props.row)'), self.make_action('view_separate', url=row_url, target='_blank', @@ -1391,8 +1419,8 @@ class MasterView(View): grid = self.make_version_grid(**kwargs) - grid.set_joiner('user', lambda q: q.outerjoin(self.model.User)) - grid.set_sorter('user', self.model.User.username) + grid.set_joiner('user', lambda q: q.outerjoin(model.User)) + grid.set_sorter('user', model.User.username) grid.set_link('remote_addr') @@ -1460,12 +1488,13 @@ class MasterView(View): else: # no txnid, return grid data obj = self.get_instance() grid = self.make_revisions_grid(obj) - return grid.get_buefy_data() + return grid.get_table_data() def view_version(self): """ View showing diff details of a particular object version. """ + app = self.get_rattail_app() instance = self.get_instance() model_class = self.get_model_class() route_prefix = self.get_route_prefix() @@ -1513,7 +1542,7 @@ class MasterView(View): 'instance_title_normal': instance_title, 'instance_url': self.get_action_url('versions', instance), 'transaction': transaction, - 'changed': localtime(self.rattail_config, transaction.issued_at, from_utc=True), + 'changed': app.localtime(transaction.issued_at, from_utc=True), 'version_diffs': version_diffs, 'show_prev_next': True, 'prev_url': prev_url, @@ -1528,6 +1557,15 @@ class MasterView(View): }) def title_for_version(self, version): + """ + Must return the title text for the given version. By default + this will be the :term:`rattail:model title` for the version's + data class. + + :param version: Reference to a Continuum version object. + + :returns: Title text for the version, as string. + """ cls = continuum.parent_class(version.__class__) return cls.get_model_title() @@ -1669,10 +1707,10 @@ class MasterView(View): """ if session is None: session = self.Session() - kwargs.setdefault('pageable', False) + kwargs.setdefault('paginated', False) kwargs.setdefault('sortable', sort) grid = self.make_row_grid(session=session, **kwargs) - return grid.make_visible_data() + return grid.get_visible_data() @classmethod def get_row_url_prefix(cls): @@ -1755,16 +1793,10 @@ class MasterView(View): path = self.download_path(obj, filename) if not path or not os.path.exists(path): raise self.notfound() - response = FileResponse(path, request=self.request) - response.content_length = os.path.getsize(path) + response = self.file_response(path) content_type = self.download_content_type(path, filename) if content_type: response.content_type = content_type - - # content-disposition - filename = os.path.basename(path) - response.content_disposition = str('attachment; filename="{}"'.format(filename)) - return response def download_content_type(self, path, filename): @@ -1792,6 +1824,26 @@ class MasterView(View): path = os.path.join(basedir, filespec) return self.file_response(path) + def download_output_file_template(self): + """ + View for downloading an output file template. + """ + key = self.request.GET['key'] + filespec = self.request.GET['file'] + + matches = [tmpl for tmpl in self.get_output_file_templates() + if tmpl['key'] == key] + if not matches: + raise self.notfound() + + template = matches[0] + templatesdir = os.path.join(self.rattail_config.datadir(), + 'templates', 'output_files', + self.get_route_prefix()) + basedir = os.path.join(templatesdir, template['key']) + path = os.path.join(basedir, filespec) + return self.file_response(path) + def edit(self): """ View for editing an existing model record. @@ -1841,7 +1893,7 @@ class MasterView(View): View for deleting an existing model record. """ if not self.deletable: - raise httpexceptions.HTTPForbidden() + raise self.forbidden() self.deleting = True instance = self.get_instance() @@ -1853,6 +1905,7 @@ class MasterView(View): return self.redirect(self.get_action_url('view', instance)) form = self.make_form(instance) + form.save_label = "DELETE Forever" # TODO: Add better validation, ideally CSRF etc. if self.request.method == 'POST': @@ -2051,7 +2104,10 @@ class MasterView(View): # caller must explicitly request websocket behavior; otherwise # we will assume traditional behavior for progress - ws = self.request.is_xhr and self.request.json_body.get('ws') + ws = False + if ((self.request.is_xhr or self.request.content_type == 'application/json') + and self.request.json_body.get('ws')): + ws = True # make our progress tracker progress = self.make_execute_progress(obj, ws=ws) @@ -2096,7 +2152,9 @@ class MasterView(View): """ Thread target for executing an object. """ - session = RattailSession() + app = self.get_rattail_app() + model = self.app.model + session = app.make_session() obj = self.get_instance_for_key(key, session) user = session.get(model.User, user_uuid) try: @@ -2278,9 +2336,13 @@ class MasterView(View): except Exception as error: self.request.session.flash("Requested merge cannot proceed (maybe swap kept/removed and try again?): {}".format(error), 'error') else: - self.merge_objects(object_to_remove, object_to_keep) - self.request.session.flash("{} has been merged into {}".format(msg, object_to_keep)) - return self.redirect(self.get_action_url('view', object_to_keep)) + try: + self.merge_objects(object_to_remove, object_to_keep) + self.request.session.flash("{} has been merged into {}".format(msg, object_to_keep)) + return self.redirect(self.get_action_url('view', object_to_keep)) + except Exception as error: + error = simple_error(error) + self.request.session.flash(f"merge failed: {error}", 'error') if not object_to_remove or not object_to_keep or object_to_remove is object_to_keep: return self.redirect(self.get_index_url()) @@ -2530,11 +2592,12 @@ class MasterView(View): so if you like you can return a different help URL depending on which type of CRUD view is in effect, etc. """ - model = self.model + # nb. self.Session may differ, so use tailbone.db.Session + session = Session() + model = self.app.model route_prefix = self.get_route_prefix() - # nb. self.Session may differ, so use tailbone.db.Session - info = Session.query(model.TailbonePageHelp)\ + info = session.query(model.TailbonePageHelp)\ .filter(model.TailbonePageHelp.route_prefix == route_prefix)\ .first() if info and info.help_url: @@ -2552,11 +2615,12 @@ class MasterView(View): """ Return the markdown help text for current page, if defined. """ - model = self.model + # nb. self.Session may differ, so use tailbone.db.Session + session = Session() + model = self.app.model route_prefix = self.get_route_prefix() - # nb. self.Session may differ, so use tailbone.db.Session - info = Session.query(model.TailbonePageHelp)\ + info = session.query(model.TailbonePageHelp)\ .filter(model.TailbonePageHelp.route_prefix == route_prefix)\ .first() if info and info.markdown_text: @@ -2573,7 +2637,9 @@ class MasterView(View): if not self.can_edit_help(): raise self.forbidden() - model = self.model + # nb. self.Session may differ, so use tailbone.db.Session + session = Session() + model = self.app.model route_prefix = self.get_route_prefix() schema = colander.Schema() @@ -2590,13 +2656,12 @@ class MasterView(View): if not form.validate(): return {'error': "Form did not validate"} - # nb. self.Session may differ, so use tailbone.db.Session - info = Session.query(model.TailbonePageHelp)\ + info = session.query(model.TailbonePageHelp)\ .filter(model.TailbonePageHelp.route_prefix == route_prefix)\ .first() if not info: info = model.TailbonePageHelp(route_prefix=route_prefix) - Session.add(info) + session.add(info) info.help_url = form.validated['help_url'] info.markdown_text = form.validated['markdown_text'] @@ -2606,7 +2671,9 @@ class MasterView(View): if not self.can_edit_help(): raise self.forbidden() - model = self.model + # nb. self.Session may differ, so use tailbone.db.Session + session = Session() + model = self.app.model route_prefix = self.get_route_prefix() schema = colander.Schema() @@ -2622,15 +2689,14 @@ class MasterView(View): if not form.validate(): return {'error': "Form did not validate"} - # nb. self.Session may differ, so use tailbone.db.Session - info = Session.query(model.TailboneFieldInfo)\ + info = session.query(model.TailboneFieldInfo)\ .filter(model.TailboneFieldInfo.route_prefix == route_prefix)\ .filter(model.TailboneFieldInfo.field_name == form.validated['field_name'])\ .first() if not info: info = model.TailboneFieldInfo(route_prefix=route_prefix, field_name=form.validated['field_name']) - Session.add(info) + session.add(info) info.markdown_text = form.validated['markdown_text'] return {'ok': True} @@ -2684,8 +2750,15 @@ class MasterView(View): context.update(data) context.update(self.template_kwargs(**context)) - if hasattr(self, 'template_kwargs_{}'.format(template)): - context.update(getattr(self, 'template_kwargs_{}'.format(template))(**context)) + + method_name = f'template_kwargs_{template}' + if hasattr(self, method_name): + context.update(getattr(self, method_name)(**context)) + for supp in self.iter_view_supplements(): + if hasattr(supp, 'template_kwargs'): + context.update(getattr(supp, 'template_kwargs')(**context)) + if hasattr(supp, method_name): + context.update(getattr(supp, method_name)(**context)) # First try the template path most specific to the view. mako_path = '{}/{}.mako'.format(self.get_template_prefix(), template) @@ -2783,15 +2856,28 @@ class MasterView(View): # would therefore share the "current" engine) selected = self.get_current_engine_dbkey() kwargs['expose_db_picker'] = True - kwargs['db_picker_options'] = [tags.Option(k) for k in engines] + kwargs['db_picker_options'] = [tags.Option(k, value=k) for k in engines] kwargs['db_picker_selected'] = selected + # context menu + obj = kwargs.get('instance') + items = self.get_context_menu_items(obj) + for supp in self.iter_view_supplements(): + items.extend(supp.get_context_menu_items(obj) or []) + kwargs['context_menu_list_items'] = items + # add info for downloadable input file templates, if any if self.has_input_file_templates: templates = self.normalize_input_file_templates() kwargs['input_file_templates'] = OrderedDict([(tmpl['key'], tmpl) for tmpl in templates]) + # add info for downloadable output file templates, if any + if self.has_output_file_templates: + templates = self.normalize_output_file_templates() + kwargs['output_file_templates'] = OrderedDict([(tmpl['key'], tmpl) + for tmpl in templates]) + return kwargs def get_input_file_templates(self): @@ -2866,6 +2952,81 @@ class MasterView(View): return templates + def get_output_file_templates(self): + return [] + + def normalize_output_file_templates(self, templates=None, + include_file_options=False): + if templates is None: + templates = self.get_output_file_templates() + + route_prefix = self.get_route_prefix() + + if include_file_options: + templatesdir = os.path.join(self.rattail_config.datadir(), + 'templates', 'output_files', + route_prefix) + + for template in templates: + + if 'config_section' not in template: + if hasattr(self, 'output_file_template_config_section'): + template['config_section'] = self.output_file_template_config_section + else: + template['config_section'] = route_prefix + section = template['config_section'] + + if 'config_prefix' not in template: + template['config_prefix'] = '{}.{}'.format( + self.output_file_template_config_prefix, + template['key']) + prefix = template['config_prefix'] + + for key in ('mode', 'file', 'url'): + + if 'option_{}'.format(key) not in template: + template['option_{}'.format(key)] = '{}.{}'.format(prefix, key) + + if 'setting_{}'.format(key) not in template: + template['setting_{}'.format(key)] = '{}.{}'.format( + section, + template['option_{}'.format(key)]) + + if key not in template: + value = self.rattail_config.get( + section, + template['option_{}'.format(key)]) + if value is not None: + template[key] = value + + template.setdefault('mode', 'default') + template.setdefault('file', None) + template.setdefault('url', template['default_url']) + + if include_file_options: + options = [] + basedir = os.path.join(templatesdir, template['key']) + if os.path.exists(basedir): + for name in sorted(os.listdir(basedir)): + if len(name) == 4 and name.isdigit(): + files = os.listdir(os.path.join(basedir, name)) + if len(files) == 1: + options.append(os.path.join(name, files[0])) + template['file_options'] = options + template['file_options_dir'] = basedir + + if template['mode'] == 'external': + template['effective_url'] = template['url'] + elif template['mode'] == 'hosted': + template['effective_url'] = self.request.route_url( + '{}.download_output_file_template'.format(route_prefix), + _query={'key': template['key'], + 'file': template['file']}) + else: + template['effective_url'] = template['default_url'] + + return templates + def template_kwargs_index(self, **kwargs): """ Method stub, so subclass can always invoke super() for it. @@ -2893,6 +3054,41 @@ class MasterView(View): kwargs['xref_links'] = self.get_xref_links(obj) return kwargs + def get_context_menu_items(self, obj=None): + items = [] + route_prefix = self.get_route_prefix() + + if self.listing: + + if self.results_downloadable_csv and self.has_perm('results_csv'): + url = self.request.route_url(f'{route_prefix}.results_csv') + items.append(tags.link_to("Download results as CSV", url)) + + if self.results_downloadable_xlsx and self.has_perm('results_xlsx'): + url = self.request.route_url(f'{route_prefix}.results_xlsx') + items.append(tags.link_to("Download results as XLSX", url)) + + if self.has_input_file_templates and self.has_perm('create'): + templates = self.normalize_input_file_templates() + for template in templates: + items.append(tags.link_to(f"Download {template['label']} Template", + template['effective_url'])) + + if self.has_output_file_templates and self.has_perm('configure'): + templates = self.normalize_output_file_templates() + for template in templates: + items.append(tags.link_to(f"Download {template['label']} Template", + template['effective_url'])) + + # if self.viewing: + + # # # TODO: either make this configurable, or just lose it. + # # # nobody seems to ever find it useful in practice. + # # url = self.get_action_url('view', instance) + # # items.append(tags.link_to(f"Permalink for this {model_title}", url)) + + return items + def get_xref_buttons(self, obj): buttons = [] for supp in self.iter_view_supplements(): @@ -2911,11 +3107,11 @@ class MasterView(View): normal.append(button) return normal - def make_buefy_button(self, label, - type=None, is_primary=False, - url=None, target=None, is_external=False, - icon_left=None, - **kwargs): + def make_button(self, label, + type=None, is_primary=False, + url=None, target=None, is_external=False, + icon_left=None, + **kwargs): """ Make and return a HTML ``<b-button>`` literal. """ @@ -2968,7 +3164,7 @@ class MasterView(View): assumed to be external, which affects the icon and causes button click to open link in a new tab. """ - # TODO: this should call make_buefy_button() + # TODO: this should call make_button() # nb. unfortunately HTML.tag() calls its first arg 'tag' and # so we can't pass a kwarg with that name...so instead we @@ -2985,7 +3181,7 @@ class MasterView(View): button = HTML.tag('b-button', **btn_kw) button = str(button) button = button.replace('<b-button ', - '<b-button tag="a"') + '<b-button tag="a" ') button = HTML.literal(button) return button @@ -3049,6 +3245,11 @@ class MasterView(View): return key def get_grid_actions(self): + """ """ + warnings.warn("get_grid_actions() method is deprecated; " + "please use get_main_actions() or get_more_actions() instead", + DeprecationWarning, stacklevel=2) + main, more = self.get_main_actions(), self.get_more_actions() if len(more) == 1: main, more = main + more, [] @@ -3124,7 +3325,7 @@ class MasterView(View): url=self.default_clone_url) def make_grid_action_delete(self): - kwargs = {} + kwargs = {'link_class': 'has-text-danger'} if self.delete_confirm == 'simple': kwargs['click_handler'] = 'deleteObject' return self.make_action('delete', icon='trash', url=self.default_delete_url, **kwargs) @@ -3158,14 +3359,18 @@ class MasterView(View): def make_action(self, key, url=None, factory=None, **kwargs): """ - Make a new :class:`GridAction` instance for the current grid. + Make and return a new :class:`~tailbone.grids.core.GridAction` + instance. + + This can be called to make actions for any grid, not just the + one from :meth:`index()`. """ if url is None: route = '{}.{}'.format(self.get_route_prefix(), key) url = lambda r, i: self.request.route_url(route, **self.get_action_route_kwargs(r)) if not factory: factory = grids.GridAction - return factory(key, url=url, **kwargs) + return factory(self.request, key, url=url, **kwargs) def get_action_route_kwargs(self, obj): """ @@ -3494,14 +3699,14 @@ class MasterView(View): Normalize the given object into a data dict, for use when writing to the results file for download. """ + app = self.get_rattail_app() data = {} for field in fields: value = getattr(obj, field, None) # make timestamps zone-aware if isinstance(value, datetime.datetime): - value = localtime(self.rattail_config, value, - from_utc=not self.has_local_times) + value = app.localtime(value, from_utc=not self.has_local_times) data[field] = value @@ -3531,13 +3736,14 @@ class MasterView(View): Coerce the given data dict record, to a "row" dict suitable for use when writing directly to XLSX file. """ + app = self.get_rattail_app() data = dict(data) for key in data: value = data[key] # make timestamps local, "zone-naive" if isinstance(value, datetime.datetime): - value = localtime(self.rattail_config, value, tzinfo=False) + value = app.localtime(value, tzinfo=False) data[key] = value @@ -3993,14 +4199,14 @@ class MasterView(View): Normalize the given row object into a data dict, for use when writing to the results file for download. """ + app = self.get_rattail_app() data = {} for field in fields: value = getattr(row, field, None) # make timestamps zone-aware if isinstance(value, datetime.datetime): - value = localtime(self.rattail_config, value, - from_utc=not self.has_local_times) + value = app.localtime(value, from_utc=not self.has_local_times) data[field] = value @@ -4030,6 +4236,7 @@ class MasterView(View): Coerce the given data dict record, to a "row" dict suitable for use when writing directly to XLSX file. """ + app = self.get_rattail_app() data = dict(data) for key in data: value = data[key] @@ -4040,7 +4247,7 @@ class MasterView(View): # make timestamps local, "zone-naive" elif isinstance(value, datetime.datetime): - value = localtime(self.rattail_config, value, tzinfo=False) + value = app.localtime(value, tzinfo=False) data[key] = value @@ -4050,10 +4257,11 @@ class MasterView(View): """ Download current *row* results as XLSX. """ + app = self.get_rattail_app() obj = self.get_instance() results = self.get_effective_row_data(sort=True) fields = self.get_row_xlsx_fields() - path = temp_path(suffix='.xlsx') + path = app.make_temp_file(suffix='.xlsx') writer = ExcelWriter(path, fields, sheet_title=self.get_row_model_title_plural()) writer.write_header() @@ -4091,6 +4299,7 @@ class MasterView(View): """ Return a dict for use when writing the row's data to XLSX download. """ + app = self.get_rattail_app() xlrow = {} for field in fields: value = getattr(row, field, None) @@ -4103,9 +4312,9 @@ class MasterView(View): # but we should make sure they're in "local" time zone effectively. # note however, this assumes a "naive" time value is in UTC zone! if value.tzinfo: - value = localtime(self.rattail_config, value, tzinfo=False) + value = app.localtime(value, tzinfo=False) else: - value = localtime(self.rattail_config, value, from_utc=True, tzinfo=False) + value = app.localtime(value, from_utc=True, tzinfo=False) xlrow[field] = value return xlrow @@ -4169,12 +4378,13 @@ class MasterView(View): """ Return a dict for use when writing the row's data to CSV download. """ + app = self.get_rattail_app() csvrow = {} for field in fields: value = getattr(obj, field, None) if isinstance(value, datetime.datetime): # TODO: this assumes value is *always* naive UTC - value = localtime(self.rattail_config, value, from_utc=True) + value = app.localtime(value, from_utc=True) csvrow[field] = '' if value is None else str(value) return csvrow @@ -4182,12 +4392,13 @@ class MasterView(View): """ Return a dict for use when writing the row's data to CSV download. """ + app = self.get_rattail_app() csvrow = {} for field in fields: value = getattr(row, field, None) if isinstance(value, datetime.datetime): # TODO: this assumes value is *always* naive UTC - value = localtime(self.rattail_config, value, from_utc=True) + value = app.localtime(value, from_utc=True) csvrow[field] = '' if value is None else str(value) return csvrow @@ -4354,7 +4565,7 @@ class MasterView(View): 'request': self.request, 'readonly': self.viewing, 'model_class': getattr(self, 'model_class', None), - 'action_url': self.request.current_route_url(_query=None), + 'action_url': self.request.path_url, 'assume_local_times': self.has_local_times, 'route_prefix': route_prefix, 'can_edit_help': self.can_edit_help(), @@ -4424,6 +4635,9 @@ class MasterView(View): if not self.has_perm('view_global'): obj.local_only = True + for supp in self.iter_view_supplements(): + obj = supp.objectify(obj, form, data) + return obj def objectify_contact(self, contact, data): @@ -4962,13 +5176,52 @@ class MasterView(View): return diffs.Diff(old_data, new_data, **kwargs) def get_version_diff_factory(self, **kwargs): + """ + Must return the factory to be used when creating version diff + objects. + + By default this returns the + :class:`tailbone.diffs.VersionDiff` class, unless + :attr:`version_diff_factory` is set, in which case that is + returned as-is. + + :returns: A factory which can produce + :class:`~tailbone.diffs.VersionDiff` objects. + """ if hasattr(self, 'version_diff_factory'): return self.version_diff_factory return diffs.VersionDiff + def get_version_diff_enums(self, version): + """ + This can optionally return a dict of field enums, to be passed + to the version diff factory. This method is called as part of + :meth:`make_version_diff()`. + """ + def make_version_diff(self, version, *args, **kwargs): + """ + Make a version diff object, using the factory returned by + :meth:`get_version_diff_factory()`. + + :param version: Reference to a Continuum version object. + + :param title: If specified, must be as a kwarg. Optional + override for the version title text. If not specified, + :meth:`title_for_version()` is called for the title. + + :param \*args: Additional args to pass to the factory. + + :param \*\*kwargs: Additional kwargs to pass to the factory. + + :returns: A :class:`~tailbone.diffs.VersionDiff` object. + """ if 'title' not in kwargs: kwargs['title'] = self.title_for_version(version) + + if 'enums' not in kwargs: + kwargs['enums'] = self.get_version_diff_enums(version) + factory = self.get_version_diff_factory() return factory(version, *args, **kwargs) @@ -4980,6 +5233,8 @@ class MasterView(View): """ Generic view for configuring some aspect of the software. """ + self.configuring = True + app = self.get_rattail_app() if self.request.method == 'POST': if self.request.POST.get('remove_settings'): self.configure_remove_settings() @@ -4994,7 +5249,7 @@ class MasterView(View): uploads = {} for key, value in data.items(): if isinstance(value, cgi_FieldStorage): - tempdir = tempfile.mkdtemp() + tempdir = app.make_temp_dir() filename = os.path.basename(value.filename) filepath = os.path.join(tempdir, filename) with open(filepath, 'wb') as f: @@ -5060,6 +5315,39 @@ class MasterView(View): data[template['setting_file']] = os.path.join(numdir, info['filename']) + if self.has_output_file_templates: + templatesdir = os.path.join(self.rattail_config.datadir(), + 'templates', 'output_files', + self.get_route_prefix()) + + def get_next_filedir(basedir): + nextid = 1 + while True: + path = os.path.join(basedir, '{:04d}'.format(nextid)) + if not os.path.exists(path): + # this should fail if there happens to be a race + # condition and someone else got to this id first + os.mkdir(path) + return path + nextid += 1 + + for template in self.normalize_output_file_templates(): + key = '{}.upload'.format(template['setting_file']) + if key in uploads: + assert self.request.POST[template['setting_mode']] == 'hosted' + assert not self.request.POST[template['setting_file']] + info = uploads[key] + basedir = os.path.join(templatesdir, template['key']) + if not os.path.exists(basedir): + os.makedirs(basedir) + filedir = get_next_filedir(basedir) + filepath = os.path.join(filedir, info['filename']) + shutil.copyfile(info['filepath'], filepath) + shutil.rmtree(info['filedir']) + numdir = os.path.basename(filedir) + data[template['setting_file']] = os.path.join(numdir, + info['filename']) + def configure_get_simple_settings(self): """ If you have some "simple" settings, each of which basically @@ -5104,7 +5392,8 @@ class MasterView(View): simple['option']) def configure_get_context(self, simple_settings=None, - input_file_templates=True): + input_file_templates=True, + output_file_templates=True): """ Returns the full context dict, for rendering the configure page template. @@ -5153,7 +5442,7 @@ class MasterView(View): for template in self.normalize_input_file_templates( include_file_options=True): settings[template['setting_mode']] = template['mode'] - settings[template['setting_file']] = template['file'] + settings[template['setting_file']] = template['file'] or '' settings[template['setting_url']] = template['url'] file_options[template['key']] = template['file_options'] file_option_dirs[template['key']] = template['file_options_dir'] @@ -5161,10 +5450,27 @@ class MasterView(View): context['input_file_options'] = file_options context['input_file_option_dirs'] = file_option_dirs + # add settings for output file templates, if any + if output_file_templates and self.has_output_file_templates: + settings = {} + file_options = {} + file_option_dirs = {} + for template in self.normalize_output_file_templates( + include_file_options=True): + settings[template['setting_mode']] = template['mode'] + settings[template['setting_file']] = template['file'] or '' + settings[template['setting_url']] = template['url'] + file_options[template['key']] = template['file_options'] + file_option_dirs[template['key']] = template['file_options_dir'] + context['output_file_template_settings'] = settings + context['output_file_options'] = file_options + context['output_file_option_dirs'] = file_option_dirs + return context def configure_gather_settings(self, data, simple_settings=None, - input_file_templates=True): + input_file_templates=True, + output_file_templates=True): settings = [] # maybe collect "simple" settings @@ -5210,12 +5516,32 @@ class MasterView(View): settings.append({'name': template['setting_url'], 'value': data.get(template['setting_url'])}) + # maybe also collect output file template settings + if output_file_templates and self.has_output_file_templates: + for template in self.normalize_output_file_templates(): + + # mode + settings.append({'name': template['setting_mode'], + 'value': data.get(template['setting_mode'])}) + + # file + value = data.get(template['setting_file']) + if value: + # nb. avoid saving if empty, so can remain "null" + settings.append({'name': template['setting_file'], + 'value': value}) + + # url + settings.append({'name': template['setting_url'], + 'value': data.get(template['setting_url'])}) + return settings def configure_remove_settings(self, simple_settings=None, - input_file_templates=True): + input_file_templates=True, + output_file_templates=True): app = self.get_rattail_app() - model = self.model + model = self.app.model names = [] if simple_settings is None: @@ -5232,6 +5558,14 @@ class MasterView(View): template['setting_url'], ]) + if output_file_templates and self.has_output_file_templates: + for template in self.normalize_output_file_templates(): + names.extend([ + template['setting_mode'], + template['setting_file'], + template['setting_url'], + ]) + if names: # nb. using thread-local session here; we do not use # self.Session b/c it may not point to Rattail @@ -5494,6 +5828,15 @@ class MasterView(View): route_name='{}.download_input_file_template'.format(route_prefix), permission='{}.create'.format(permission_prefix)) + # download output file template + if cls.has_output_file_templates and cls.configurable: + config.add_route(f'{route_prefix}.download_output_file_template', + f'{url_prefix}/download-output-file-template') + config.add_view(cls, attr='download_output_file_template', + route_name=f'{route_prefix}.download_output_file_template', + # TODO: this is different from input file, should change? + permission=f'{permission_prefix}.configure') + # view if cls.viewable: cls._defaults_view(config) @@ -5757,7 +6100,7 @@ class MasterView(View): renderer='json') -class ViewSupplement(object): +class ViewSupplement: """ Base class for view "supplements" - which are sort of like plugins which can "supplement" certain aspects of the view. @@ -5784,6 +6127,7 @@ class ViewSupplement(object): def __init__(self, master): self.master = master self.request = master.request + self.app = master.app self.model = master.model self.rattail_config = master.rattail_config self.Session = master.Session @@ -5817,7 +6161,7 @@ class ViewSupplement(object): This is accomplished by subjecting the current base query to a join, e.g. something like:: - model = self.model + model = self.app.model query = query.outerjoin(model.MyExtension) return query """ @@ -5835,12 +6179,18 @@ class ViewSupplement(object): renderers, default values etc. for them. """ + def objectify(self, obj, form, data): + return obj + def get_xref_buttons(self, obj): return [] def get_xref_links(self, obj): return [] + def get_context_menu_items(self, obj): + return [] + def get_version_child_classes(self): """ Return a list of additional "version child classes" which are diff --git a/tailbone/views/members.py b/tailbone/views/members.py index 3a4ff0a1..46ed7e4b 100644 --- a/tailbone/views/members.py +++ b/tailbone/views/members.py @@ -27,6 +27,7 @@ Member Views from collections import OrderedDict import sqlalchemy as sa +import sqlalchemy_continuum as continuum from rattail.db import model from rattail.db.model import MembershipType, Member, MemberEquityPayment @@ -71,6 +72,7 @@ class MembershipTypeView(MasterView): ] def configure_grid(self, g): + """ """ super().configure_grid(g) g.set_sort_defaults('number') @@ -79,6 +81,7 @@ class MembershipTypeView(MasterView): g.set_link('name') def get_row_data(self, memtype): + """ """ model = self.model return self.Session.query(model.Member)\ .filter(model.Member.membership_type == memtype) @@ -102,7 +105,7 @@ class MemberView(MasterView): """ Master view for the Member class. """ - model_class = model.Member + model_class = Member is_contact = True touchable = True has_versions = True @@ -169,6 +172,7 @@ class MemberView(MasterView): return app.get_people_handler().get_quickie_search_placeholder() def configure_grid(self, g): + """ """ super().configure_grid(g) route_prefix = self.get_route_prefix() model = self.model @@ -225,8 +229,7 @@ class MemberView(MasterView): url = lambda r, i: self.request.route_url( f'{route_prefix}.view', **self.get_action_route_kwargs(r)) # nb. insert to slot 1, just after normal View action - g.main_actions.insert(1, self.make_action( - 'view_raw', url=url, icon='eye')) + g.actions.insert(1, self.make_action('view_raw', url=url, icon='eye')) # equity_total # TODO: should make this configurable @@ -263,13 +266,16 @@ class MemberView(MasterView): default=False) def grid_extra_class(self, member, i): + """ """ if not member.active: return 'warning' if member.equity_current is False: return 'notice' def configure_form(self, f): + """ """ super().configure_form(f) + model = self.model member = f.model_instance # date fields @@ -342,6 +348,7 @@ class MemberView(MasterView): return app.render_currency(total) def template_kwargs_view(self, **kwargs): + """ """ kwargs = super().template_kwargs_view(**kwargs) app = self.get_rattail_app() member = kwargs['instance'] @@ -360,10 +367,12 @@ class MemberView(MasterView): return kwargs def render_default_email(self, member, field): + """ """ if member.emails: return member.emails[0].address def render_default_phone(self, member, field): + """ """ if member.phones: return member.phones[0].number @@ -376,6 +385,7 @@ class MemberView(MasterView): return tags.link_to(text, url) def get_row_data(self, member): + """ """ model = self.model return self.Session.query(model.MemberEquityPayment)\ .filter(model.MemberEquityPayment.member == member) @@ -395,6 +405,7 @@ class MemberView(MasterView): uuid=payment.uuid) def configure_get_simple_settings(self): + """ """ return [ # General @@ -417,12 +428,16 @@ class MemberEquityPaymentView(MasterView): """ Master view for the MemberEquityPayment class. """ - model_class = model.MemberEquityPayment + model_class = MemberEquityPayment route_prefix = 'member_equity_payments' url_prefix = '/member-equity-payments' supports_grid_totals = True has_versions = True + labels = { + 'status_code': "Status", + } + grid_columns = [ 'received', '_member_key_', @@ -431,6 +446,7 @@ class MemberEquityPaymentView(MasterView): 'description', 'source', 'transaction_identifier', + 'status_code', ] form_fields = [ @@ -441,9 +457,11 @@ class MemberEquityPaymentView(MasterView): 'description', 'source', 'transaction_identifier', + 'status_code', ] def query(self, session): + """ """ query = super().query(session) model = self.model @@ -452,6 +470,7 @@ class MemberEquityPaymentView(MasterView): return query def configure_grid(self, g): + """ """ super().configure_grid(g) model = self.model @@ -482,6 +501,9 @@ class MemberEquityPaymentView(MasterView): g.set_link('transaction_identifier') + # status_code + g.set_enum('status_code', model.MemberEquityPayment.STATUS) + def render_member_key(self, payment, field): key = getattr(payment.member, field) return key @@ -493,6 +515,7 @@ class MemberEquityPaymentView(MasterView): return {'totals_display': app.render_currency(total)} def configure_form(self, f): + """ """ super().configure_form(f) model = self.model payment = f.model_instance @@ -531,6 +554,17 @@ class MemberEquityPaymentView(MasterView): else: f.set_readonly('received') + # status_code + f.set_enum('status_code', model.MemberEquityPayment.STATUS) + + def get_version_diff_enums(self, version): + """ """ + model = self.model + cls = continuum.parent_class(version.__class__) + + if cls is model.MemberEquityPayment: + return {'status_code': model.MemberEquityPayment.STATUS} + def defaults(config, **kwargs): base = globals() diff --git a/tailbone/views/menus.py b/tailbone/views/menus.py index f60ad274..b606e4e7 100644 --- a/tailbone/views/menus.py +++ b/tailbone/views/menus.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. # @@ -30,7 +30,6 @@ import sqlalchemy as sa from tailbone.views import View from tailbone.db import Session -from tailbone.menus import make_menu_key class MenuConfigView(View): @@ -79,12 +78,16 @@ class MenuConfigView(View): return context def configure_gather_settings(self, data): + app = self.get_rattail_app() + web = app.get_web_handler() + menus = web.get_menu_handler() + settings = [{'name': 'tailbone.menu.from_settings', 'value': 'true'}] main_keys = [] for topitem in json.loads(data['menus']): - key = make_menu_key(self.rattail_config, topitem['title']) + key = menus._make_menu_key(self.rattail_config, topitem['title']) main_keys.append(key) settings.extend([ @@ -99,7 +102,7 @@ class MenuConfigView(View): if item.get('route'): item_key = item['route'] else: - item_key = make_menu_key(self.rattail_config, item['title']) + item_key = menus._make_menu_key(self.rattail_config, item['title']) item_keys.append(item_key) settings.extend([ diff --git a/tailbone/views/messages.py b/tailbone/views/messages.py index d1509163..9199c025 100644 --- a/tailbone/views/messages.py +++ b/tailbone/views/messages.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. # @@ -29,10 +29,8 @@ from rattail.time import localtime import colander from deform import widget as dfwidget -from pyramid import httpexceptions from webhelpers2.html import tags, HTML -# from tailbone import forms from tailbone.db import Session from tailbone.views import MasterView from tailbone.util import raw_datetime @@ -83,15 +81,15 @@ class MessageView(MasterView): def index(self): if not self.request.user: - raise httpexceptions.HTTPForbidden + raise self.forbidden() return super().index() def get_instance(self): if not self.request.user: - raise httpexceptions.HTTPForbidden + raise self.forbidden() message = super().get_instance() if not self.associated_with(message): - raise httpexceptions.HTTPForbidden + raise self.forbidden() return message def associated_with(self, message): @@ -243,7 +241,7 @@ class MessageView(MasterView): f.insert_after('recipients', 'set_recipients') f.remove('recipients') f.set_node('set_recipients', colander.SchemaNode(colander.Set())) - f.set_widget('set_recipients', RecipientsWidgetBuefy()) + f.set_widget('set_recipients', RecipientsWidget()) f.set_label('set_recipients', "To") if self.replying: @@ -395,7 +393,7 @@ class MessageView(MasterView): message = self.get_instance() recipient = self.get_recipient(message) if not recipient: - raise httpexceptions.HTTPForbidden + raise self.forbidden() dest = self.request.GET.get('dest') if dest not in ('inbox', 'archive'): @@ -516,11 +514,11 @@ class SentView(MessageView): default_active=True, default_verb='contains') -class RecipientsWidgetBuefy(dfwidget.Widget): +class RecipientsWidget(dfwidget.Widget): """ - Custom "message recipients" widget, for use with Buefy / Vue.js themes. + Custom "message recipients" widget, for use with Vue.js themes. """ - template = 'message_recipients_buefy' + template = 'message_recipients' def deserialize(self, field, pstruct): if pstruct is colander.null: diff --git a/tailbone/views/people.py b/tailbone/views/people.py index 7f786ace..405b1ca3 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.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. # @@ -32,16 +32,15 @@ import sqlalchemy as sa from sqlalchemy import orm import sqlalchemy_continuum as continuum -from rattail.db import model, api -from rattail.db.util import maxlen -from rattail.time import localtime +from rattail.db import api +from rattail.db.model import Person, PersonNote, MergePeopleRequest from rattail.util import simple_error import colander -from pyramid.httpexceptions import HTTPFound, HTTPNotFound from webhelpers2.html import HTML, tags from tailbone import forms, grids +from tailbone.db import TrainwreckSession from tailbone.views import MasterView from tailbone.util import raw_datetime @@ -53,7 +52,7 @@ class PersonView(MasterView): """ Master view for the Person class. """ - model_class = model.Person + model_class = Person model_title_plural = "People" route_prefix = 'people' touchable = True @@ -176,8 +175,7 @@ class PersonView(MasterView): url = lambda r, i: self.request.route_url( f'{route_prefix}.view', **self.get_action_route_kwargs(r)) # nb. insert to slot 1, just after normal View action - g.main_actions.insert(1, self.make_action( - 'view_raw', url=url, icon='eye')) + g.actions.insert(1, self.make_action('view_raw', url=url, icon='eye')) g.set_link('display_name') g.set_link('first_name') @@ -210,6 +208,7 @@ class PersonView(MasterView): c="MR") def get_instance(self): + model = self.model # TODO: I don't recall why this fallback check for a vendor contact # exists here, but leaving it intact for now. key = self.request.matchdict['uuid'] @@ -219,7 +218,7 @@ class PersonView(MasterView): instance = self.Session.get(model.VendorContact, key) if instance: return instance.person - raise HTTPNotFound + raise self.notfound() def is_person_protected(self, person): for user in person.users: @@ -237,6 +236,13 @@ class PersonView(MasterView): return True return not self.is_person_protected(person) + def configure_form(self, f): + super().configure_form(f) + + # preferred_first_name + if self.people_handler.should_use_preferred_first_name(): + f.insert_after('first_name', 'preferred_first_name') + def objectify(self, form, data=None): if data is None: data = form.validated @@ -248,6 +254,9 @@ class PersonView(MasterView): names = {} if 'first_name' in form: names['first'] = data['first_name'] + if self.people_handler.should_use_preferred_first_name(): + if 'preferred_first_name' in form: + names['preferred_first'] = data['preferred_first_name'] if 'middle_name' in form: names['middle'] = data['middle_name'] if 'last_name' in form: @@ -292,6 +301,8 @@ class PersonView(MasterView): In addition to "touching" the person proper, we also "touch" each contact info record associated with them. """ + model = self.model + # touch person, as per usual super().touch_instance(person) @@ -426,6 +437,7 @@ class PersonView(MasterView): return "" def get_version_child_classes(self): + model = self.model return [ (model.PersonPhoneNumber, 'parent_uuid'), (model.PersonEmailAddress, 'parent_uuid'), @@ -474,16 +486,113 @@ class PersonView(MasterView): 'expose_customer_people': self.customers_should_expose_people(), 'expose_customer_shoppers': self.customers_should_expose_shoppers(), 'max_one_member': app.get_membership_handler().max_one_per_person(), + 'use_preferred_first_name': self.people_handler.should_use_preferred_first_name(), + 'expose_members': self.should_expose_profile_members(), + 'expose_transactions': self.should_expose_profile_transactions(), } + if context['expose_transactions']: + context['transactions_grid'] = self.profile_transactions_grid(person, empty=True) + if self.request.has_perm('people_profile.view_versions'): context['revisions_grid'] = self.profile_revisions_grid(person) - return self.render_to_response('view_profile_buefy', context) + return self.render_to_response('view_profile', context) + + def should_expose_profile_members(self): + return self.rattail_config.get_bool('tailbone.people.profile.expose_members', + default=False) + + def should_expose_profile_transactions(self): + return self.rattail_config.get_bool('tailbone.people.profile.expose_transactions', + default=False) + + def profile_transactions_grid(self, person, empty=False): + app = self.get_rattail_app() + trainwreck = app.get_trainwreck_handler() + model = trainwreck.get_model() + route_prefix = self.get_route_prefix() + if empty: + # TODO: surely there is a better way to have empty data..? but so + # much logic depends on a query, can't just pass empty list here + data = TrainwreckSession.query(model.Transaction)\ + .filter(model.Transaction.uuid == 'bogus') + else: + data = self.profile_transactions_query(person) + factory = self.get_grid_factory() + g = factory( + self.request, + key=f'{route_prefix}.profile.transactions.{person.uuid}', + data=data, + model_class=model.Transaction, + ajax_data_url=self.get_action_url('view_profile_transactions', person), + columns=[ + 'start_time', + 'end_time', + 'system', + 'terminal_id', + 'receipt_number', + 'cashier_name', + 'customer_id', + 'customer_name', + 'total', + ], + labels={ + 'terminal_id': "Terminal", + 'customer_id': "Customer " + app.get_customer_key_label(), + }, + filterable=True, + sortable=True, + paginated=True, + default_sortkey='end_time', + default_sortdir='desc', + component='transactions-grid', + ) + if self.request.has_perm('trainwreck.transactions.view'): + url = lambda row, i: self.request.route_url('trainwreck.transactions.view', + uuid=row.uuid) + g.actions.append(self.make_action('view', icon='eye', url=url)) + g.load_settings() + + g.set_enum('system', self.enum.TRAINWRECK_SYSTEM) + g.set_type('total', 'currency') + + return g + + def profile_transactions_query(self, person): + """ + Method which must return the base query for the profile's POS + Transactions grid data. + """ + customer = self.app.get_customer(person) + + if customer: + key_field = self.app.get_customer_key_field() + customer_key = getattr(customer, key_field) + if customer_key is not None: + customer_key = str(customer_key) + else: + # nb. this should *not* match anything, so query returns + # no results.. + customer_key = person.uuid + + trainwreck = self.app.get_trainwreck_handler() + model = trainwreck.get_model() + query = TrainwreckSession.query(model.Transaction)\ + .filter(model.Transaction.customer_id == customer_key) + return query + + def profile_transactions_data(self): + """ + AJAX view to return new sorted, filtered data for transactions + grid within profile view. + """ + person = self.get_instance() + grid = self.profile_transactions_grid(person) + return grid.get_table_data() def get_context_tabchecks(self, person): app = self.get_rattail_app() - membership = app.get_membership_handler() clientele = app.get_clientele_handler() tabchecks = {} @@ -494,12 +603,14 @@ class PersonView(MasterView): tabchecks['personal'] = True # member - if membership.max_one_per_person(): - member = app.get_member(person) - tabchecks['member'] = bool(member and member.active) - else: - members = membership.get_members_for_account_holder(person) - tabchecks['member'] = any([m.active for m in members]) + if self.should_expose_profile_members(): + membership = app.get_membership_handler() + if membership.max_one_per_person(): + member = app.get_member(person) + tabchecks['member'] = bool(member and member.active) + else: + members = membership.get_members_for_account_holder(person) + tabchecks['member'] = any([m.active for m in members]) # customer customers = clientele.get_customers_for_account_holder(person) @@ -542,26 +653,22 @@ class PersonView(MasterView): """ return kwargs - def template_kwargs_view_profile_buefy(self, **kwargs): - """ - Note that any subclass should not need to define this method. - It by default invokes :meth:`template_kwargs_view_profile()` - and returns that result. - """ - return self.template_kwargs_view_profile(**kwargs) - def get_max_lengths(self): + app = self.get_rattail_app() model = self.model - return { - 'person_first_name': maxlen(model.Person.first_name), - 'person_middle_name': maxlen(model.Person.middle_name), - 'person_last_name': maxlen(model.Person.last_name), - 'address_street': maxlen(model.PersonMailingAddress.street), - 'address_street2': maxlen(model.PersonMailingAddress.street2), - 'address_city': maxlen(model.PersonMailingAddress.city), - 'address_state': maxlen(model.PersonMailingAddress.state), - 'address_zipcode': maxlen(model.PersonMailingAddress.zipcode), + lengths = { + 'person_first_name': app.maxlen(model.Person.first_name), + 'person_middle_name': app.maxlen(model.Person.middle_name), + 'person_last_name': app.maxlen(model.Person.last_name), + 'address_street': app.maxlen(model.PersonMailingAddress.street), + 'address_street2': app.maxlen(model.PersonMailingAddress.street2), + 'address_city': app.maxlen(model.PersonMailingAddress.city), + 'address_state': app.maxlen(model.PersonMailingAddress.state), + 'address_zipcode': app.maxlen(model.PersonMailingAddress.zipcode), } + if self.people_handler.should_use_preferred_first_name(): + lengths['person_preferred_first_name'] = app.maxlen(model.Person.preferred_first_name) + return lengths def get_phone_type_options(self): """ @@ -606,6 +713,9 @@ class PersonView(MasterView): 'dynamic_content_title': self.get_context_content_title(person), } + if self.people_handler.should_use_preferred_first_name(): + context['preferred_first_name'] = person.preferred_first_name + if person.address: context['address'] = self.get_context_address(person.address) @@ -730,10 +840,15 @@ class PersonView(MasterView): membership = app.get_membership_handler() data = OrderedDict() - members = membership.get_members_for_account_holder(person) for member in members: - data[member.uuid] = self.get_context_member(member) + context = self.get_context_member(member) + + for supp in self.iter_view_supplements(): + if hasattr(supp, 'get_context_for_member'): + context = supp.get_context_for_member(member, context) + + data[member.uuid] = context return list(data.values()) @@ -786,6 +901,12 @@ class PersonView(MasterView): app = self.get_rattail_app() handler = app.get_employment_handler() context = handler.get_context_employee(employee) + context.setdefault('external_links', []) + + for supp in self.iter_view_supplements(): + if hasattr(supp, 'get_context_for_employee'): + context = supp.get_context_for_employee(employee, context) + context['view_url'] = self.request.route_url('employees.view', uuid=employee.uuid) return context @@ -871,10 +992,16 @@ class PersonView(MasterView): person = self.get_instance() data = dict(self.request.json_body) - self.handler.update_names(person, - first=data['first_name'], - middle=data['middle_name'], - last=data['last_name']) + kw = { + 'first': data['first_name'], + 'middle': data['middle_name'], + 'last': data['last_name'], + } + + if self.people_handler.should_use_preferred_first_name(): + kw['preferred_first'] = data['preferred_first_name'] + + self.handler.update_names(person, **kw) self.Session.flush() return self.profile_changed_response(person) @@ -913,6 +1040,7 @@ class PersonView(MasterView): """ View which updates a phone number for the person. """ + model = self.model person = self.get_instance() data = dict(self.request.json_body) @@ -940,6 +1068,7 @@ class PersonView(MasterView): """ View which allows a person's phone number to be deleted. """ + model = self.model person = self.get_instance() data = dict(self.request.json_body) @@ -960,6 +1089,7 @@ class PersonView(MasterView): """ View which allows a person's "preferred" phone to be set. """ + model = self.model person = self.get_instance() data = dict(self.request.json_body) @@ -1016,6 +1146,7 @@ class PersonView(MasterView): """ View which updates an email address for the person. """ + model = self.model person = self.get_instance() data = dict(self.request.json_body) @@ -1039,6 +1170,7 @@ class PersonView(MasterView): """ View which allows a person's email address to be deleted. """ + model = self.model person = self.get_instance() data = dict(self.request.json_body) @@ -1059,6 +1191,7 @@ class PersonView(MasterView): """ View which allows a person's "preferred" email to be set. """ + model = self.model person = self.get_instance() data = dict(self.request.json_body) @@ -1192,6 +1325,7 @@ class PersonView(MasterView): """ AJAX view for updating an employee history record. """ + model = self.model person = self.get_instance() employee = person.employee @@ -1244,18 +1378,47 @@ class PersonView(MasterView): """ Fetch user tab data for profile view. """ + app = self.get_rattail_app() + auth = app.get_auth_handler() person = self.get_instance() - return { + context = { 'users': self.get_context_users(person), } + if not context['users']: + context['suggested_username'] = auth.make_unique_username(self.Session(), + person=person) + + return context + + def profile_make_user(self): + """ + Create a new user account, presumably from the profile view. + """ + app = self.get_rattail_app() + model = self.model + auth = app.get_auth_handler() + + person = self.get_instance() + if person.users: + return {'error': f"This person already has {len(person.users)} user accounts."} + + data = self.request.json_body + user = auth.make_user(session=self.Session(), + person=person, + username=data['username'], + active=data['active']) + + self.Session.flush() + return self.profile_changed_response(person) + def profile_revisions_grid(self, person): route_prefix = self.get_route_prefix() factory = self.get_grid_factory() g = factory( - '{}.profile.revisions'.format(route_prefix), - [], # start with empty data! - request=self.request, + self.request, + key=f'{route_prefix}.profile.revisions', + data=[], # start with empty data! columns=[ 'changed', 'changed_by', @@ -1270,7 +1433,7 @@ class PersonView(MasterView): 'changed_by', 'comment', ], - main_actions=[ + actions=[ self.make_action('view', icon='eye', url='#', click_handler='viewRevision(props.row)'), ], @@ -1379,7 +1542,7 @@ class PersonView(MasterView): """ View which locates and organizes all relevant "transaction" (version) history data for a given Person. Returns JSON, for - use with the Buefy table element on the full profile view. + use with the table element on the full profile view. """ person = self.get_instance() versions = self.profile_revisions_collect(person) @@ -1459,6 +1622,7 @@ class PersonView(MasterView): return self.profile_changed_response(person) def create_note(self, person, form): + model = self.model note = model.PersonNote() note.type = form.validated['note_type'] note.subject = form.validated['note_subject'] @@ -1478,6 +1642,7 @@ class PersonView(MasterView): return self.profile_changed_response(person) def update_note(self, person, form): + model = self.model note = self.Session.get(model.PersonNote, form.validated['uuid']) note.subject = form.validated['note_subject'] note.text = form.validated['note_text'] @@ -1494,10 +1659,12 @@ class PersonView(MasterView): return self.profile_changed_response(person) def delete_note(self, person, form): + model = self.model note = self.Session.get(model.PersonNote, form.validated['uuid']) self.Session.delete(note) def make_user(self): + model = self.model uuid = self.request.POST['person_uuid'] person = self.Session.get(model.Person, uuid) if not person: @@ -1536,6 +1703,14 @@ class PersonView(MasterView): {'section': 'rattail', 'option': 'people.handler'}, + + # Profile View + {'section': 'tailbone', + 'option': 'people.profile.expose_members', + 'type': bool}, + {'section': 'tailbone', + 'option': 'people.profile.expose_transactions', + 'type': bool}, ] @classmethod @@ -1747,6 +1922,15 @@ class PersonView(MasterView): route_name=f'{route_prefix}.profile_tab_user', renderer='json') + # profile - make user + config.add_route(f'{route_prefix}.profile_make_user', + f'{instance_url_prefix}/make-user', + request_method='POST') + config.add_view(cls, attr='profile_make_user', + route_name=f'{route_prefix}.profile_make_user', + permission='users.create', + renderer='json') + # profile - revisions data config.add_tailbone_permission('people_profile', 'people_profile.view_versions', @@ -1795,6 +1979,15 @@ class PersonView(MasterView): permission='people_profile.delete_note', renderer='json') + # profile - transactions data + config.add_route(f'{route_prefix}.view_profile_transactions', + f'{instance_url_prefix}/profile/transactions', + request_method='GET') + config.add_view(cls, attr='profile_transactions_data', + route_name=f'{route_prefix}.view_profile_transactions', + permission=f'{permission_prefix}.view_profile', + renderer='json') + # make user for person config.add_route('{}.make_user'.format(route_prefix), '{}/make-user'.format(url_prefix), request_method='POST') @@ -1815,7 +2008,7 @@ class PersonNoteView(MasterView): """ Master view for the PersonNote class. """ - model_class = model.PersonNote + model_class = PersonNote route_prefix = 'person_notes' url_prefix = '/people/notes' has_versions = True @@ -1842,6 +2035,7 @@ class PersonNoteView(MasterView): def configure_grid(self, g): super().configure_grid(g) + model = self.model # person g.set_joiner('person', lambda q: q.join(model.Person, @@ -1881,7 +2075,7 @@ def valid_note_uuid(node, kw): session = kw['session'] person_uuid = kw['person_uuid'] def validate(node, value): - note = session.get(model.PersonNote, value) + note = session.get(PersonNote, value) if not note: raise colander.Invalid(node, "Note not found") if note.person.uuid != person_uuid: @@ -1906,7 +2100,7 @@ class MergePeopleRequestView(MasterView): """ Master view for the MergePeopleRequest class. """ - model_class = model.MergePeopleRequest + model_class = MergePeopleRequest route_prefix = 'people_merge_requests' url_prefix = '/people/merge-requests' creatable = False @@ -1950,8 +2144,9 @@ class MergePeopleRequestView(MasterView): g.set_link('keeping_uuid') def render_referenced_person_name(self, merge_request, field): + model = self.model uuid = getattr(merge_request, field) - person = self.Session.get(self.model.Person, uuid) + person = self.Session.get(model.Person, uuid) if person: return str(person) return "(person not found)" @@ -1971,8 +2166,9 @@ class MergePeopleRequestView(MasterView): f.set_renderer('keeping_uuid', self.render_referenced_person) def render_referenced_person(self, merge_request, field): + model = self.model uuid = getattr(merge_request, field) - person = self.Session.get(self.model.Person, uuid) + person = self.Session.get(model.Person, uuid) if person: text = str(person) url = self.request.route_url('people.view', uuid=person.uuid) @@ -1994,4 +2190,8 @@ def defaults(config, **kwargs): def includeme(config): - defaults(config) + wutta_config = config.registry.settings['wutta_config'] + if wutta_config.get_bool('tailbone.use_wutta_views', default=False, usedb=False): + config.include('tailbone.views.wutta.people') + else: + defaults(config) diff --git a/tailbone/views/poser/reports.py b/tailbone/views/poser/reports.py index 43ba211d..ded80b18 100644 --- a/tailbone/views/poser/reports.py +++ b/tailbone/views/poser/reports.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 @@ Poser Report Views """ -from __future__ import unicode_literals, absolute_import - import os -import six - from rattail.util import simple_error import colander @@ -95,7 +91,7 @@ class PoserReportView(PoserMasterView): return self.poser_handler.get_all_reports(ignore_errors=False) def configure_grid(self, g): - super(PoserReportView, self).configure_grid(g) + super().configure_grid(g) g.sorters['report_key'] = g.make_simple_sorter('report_key', foldcase=True) g.sorters['report_name'] = g.make_simple_sorter('report_name', foldcase=True) @@ -114,7 +110,7 @@ class PoserReportView(PoserMasterView): g.set_searchable('description') if self.request.has_perm('report_output.create'): - g.more_actions.append(self.make_action( + g.actions.append(self.make_action( 'generate', icon='arrow-circle-right', url=self.get_generate_url)) @@ -157,7 +153,7 @@ class PoserReportView(PoserMasterView): return report def configure_form(self, f): - super(PoserReportView, self).configure_form(f) + super().configure_form(f) report = f.model_instance # report_key @@ -179,7 +175,7 @@ class PoserReportView(PoserMasterView): f.set_helptext('flavor', "Determines the type of sample code to generate.") flavors = self.poser_handler.get_supported_report_flavors() values = [(key, flavor['description']) - for key, flavor in six.iteritems(flavors)] + for key, flavor in flavors.items()] f.set_widget('flavor', dfwidget.SelectWidget(values=values)) f.set_validator('flavor', colander.OneOf(flavors)) if flavors: @@ -231,7 +227,7 @@ class PoserReportView(PoserMasterView): return report def configure_row_grid(self, g): - super(PoserReportView, self).configure_row_grid(g) + super().configure_row_grid(g) g.set_renderer('id', self.render_id_str) diff --git a/tailbone/views/poser/views.py b/tailbone/views/poser/views.py index 14c97a61..27efd549 100644 --- a/tailbone/views/poser/views.py +++ b/tailbone/views/poser/views.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 @@ Poser Views for Views... """ -from __future__ import unicode_literals, absolute_import - -import six - import colander from .master import PoserMasterView @@ -68,7 +64,7 @@ class PoserViewView(PoserMasterView): return self.make_form({}) def configure_form(self, f): - super(PoserViewView, self).configure_form(f) + super().configure_form(f) view = f.model_instance # key @@ -224,28 +220,28 @@ class PoserViewView(PoserMasterView): }, }} - for key, views in six.iteritems(everything['rattail']): - for vkey, view in six.iteritems(views): + for key, views in everything['rattail'].items(): + for vkey, view in views.items(): view['options'] = [vkey] providers = get_all_providers(self.rattail_config) - for provider in six.itervalues(providers): + for provider in providers.values(): # loop thru provider top-level groups - for topkey, groups in six.iteritems(provider.get_provided_views()): + for topkey, groups in provider.get_provided_views().items(): # get or create top group topgroup = everything.setdefault(topkey, {}) # loop thru provider view groups - for key, views in six.iteritems(groups): + for key, views in groups.items(): # add group to top group, if it's new if key not in topgroup: topgroup[key] = views # also must init the options for group - for vkey, view in six.iteritems(views): + for vkey, view in views.items(): view['options'] = [vkey] else: # otherwise must "update" existing group @@ -254,7 +250,7 @@ class PoserViewView(PoserMasterView): stdgroup = topgroup[key] # loop thru views within provider group - for vkey, view in six.iteritems(views): + for vkey, view in views.items(): # add view to group if it's new if vkey not in stdgroup: @@ -270,8 +266,8 @@ class PoserViewView(PoserMasterView): settings = [] view_settings = self.collect_available_view_settings() - for topgroup in six.itervalues(view_settings): - for view_section, section_settings in six.iteritems(topgroup): + for topgroup in view_settings.values(): + for view_section, section_settings in topgroup.items(): for key in section_settings: settings.append({'section': 'tailbone.includes', 'option': key}) @@ -282,25 +278,25 @@ class PoserViewView(PoserMasterView): input_file_templates=True): # first get normal context - context = super(PoserViewView, self).configure_get_context( + context = super().configure_get_context( simple_settings=simple_settings, input_file_templates=input_file_templates) # first add available options view_settings = self.collect_available_view_settings() view_options = {} - for topgroup in six.itervalues(view_settings): - for key, views in six.iteritems(topgroup): - for vkey, view in six.iteritems(views): + for topgroup in view_settings.values(): + for key, views in topgroup.items(): + for vkey, view in views.items(): view_options[vkey] = view['options'] context['view_options'] = view_options # then add all available settings as sorted (key, label) options - for topkey, topgroup in six.iteritems(view_settings): + for topkey, topgroup in view_settings.items(): for key in list(topgroup): settings = topgroup[key] settings = [(key, setting.get('label', key)) - for key, setting in six.iteritems(settings)] + for key, setting in settings.items()] settings.sort(key=lambda itm: itm[1]) topgroup[key] = settings context['view_settings'] = view_settings @@ -308,7 +304,7 @@ class PoserViewView(PoserMasterView): return context def configure_flash_settings_saved(self): - super(PoserViewView, self).configure_flash_settings_saved() + super().configure_flash_settings_saved() self.request.session.flash("Please restart the web app!", 'warning') diff --git a/tailbone/views/principal.py b/tailbone/views/principal.py index 20f6b866..3986f8b0 100644 --- a/tailbone/views/principal.py +++ b/tailbone/views/principal.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. # @@ -43,7 +43,7 @@ class PrincipalMasterView(MasterView): def get_fallback_templates(self, template, **kwargs): return [ '/principal/{}.mako'.format(template), - ] + super(PrincipalMasterView, self).get_fallback_templates(template, **kwargs) + ] + super().get_fallback_templates(template, **kwargs) def perm_sortkey(self, item): key, value = item @@ -54,7 +54,7 @@ class PrincipalMasterView(MasterView): View for finding all users who have been granted a given permission """ permissions = copy.deepcopy( - self.request.registry.settings.get('tailbone_permissions', {})) + self.request.registry.settings.get('wutta_permissions', {})) # sort groups, and permissions for each group, for UI's sake sorted_perms = sorted(permissions.items(), key=self.perm_sortkey) @@ -65,18 +65,25 @@ class PrincipalMasterView(MasterView): principals = None permission_group = self.request.GET.get('permission_group') permission = self.request.GET.get('permission') + grid = None if permission_group and permission: principals = self.find_principals_with_permission(self.Session(), permission) + grid = self.find_by_perm_make_results_grid(principals) else: # otherwise clear both values permission_group = None permission = None - context = {'permissions': sorted_perms, 'principals': principals} + context = { + 'permissions': sorted_perms, + 'principals': principals, + 'principals_data': self.find_by_perm_results_data(principals), + 'grid': grid, + } - perms = self.get_buefy_perms_data(sorted_perms) - context['buefy_perms'] = perms - context['buefy_sorted_groups'] = list(perms) + perms = self.get_perms_data(sorted_perms) + context['perms_data'] = perms + context['sorted_groups_data'] = list(perms) if permission_group and permission_group not in perms: permission_group = None @@ -95,7 +102,7 @@ class PrincipalMasterView(MasterView): return self.render_to_response('find_by_perm', context) - def get_buefy_perms_data(self, sorted_perms): + def get_perms_data(self, sorted_perms): data = OrderedDict() for gkey, group in sorted_perms: @@ -114,6 +121,35 @@ class PrincipalMasterView(MasterView): return data + def find_by_perm_make_results_grid(self, principals): + route_prefix = self.get_route_prefix() + factory = self.get_grid_factory() + g = factory(self.request, + key=f'{route_prefix}.results', + data=[], + columns=[], + actions=[ + self.make_action('view', icon='eye', + click_handler='navigateTo(props.row._url)'), + ]) + self.find_by_perm_configure_results_grid(g) + return g + + def find_by_perm_configure_results_grid(self, g): + pass + + def find_by_perm_results_data(self, principals): + data = [] + for principal in principals or []: + data.append(self.find_by_perm_normalize(principal)) + return data + + def find_by_perm_normalize(self, principal): + return { + 'uuid': principal.uuid, + '_url': self.get_action_url('view', principal), + } + @classmethod def defaults(cls, config): cls._principal_defaults(config) @@ -158,7 +194,7 @@ class PermissionsRenderer(Object): rendered = False for key in sorted(perms, key=lambda p: perms[p]['label'].lower()): checked = auth.has_permission(Session(), principal, key, - include_guest=self.include_guest, + include_anonymous=self.include_guest, include_authenticated=self.include_authenticated) if checked: label = perms[key]['label'] diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 16c65fdb..8461ae03 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.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,12 +33,12 @@ from sqlalchemy import orm import sqlalchemy_continuum as continuum from rattail import enum, pod, sil -from rattail.db import model, api, auth, Session as RattailSession +from rattail.db import api, auth, Session as RattailSession +from rattail.db.model import Product, PendingProduct, ProductCost, CustomerOrderItem from rattail.gpc import GPC from rattail.threads import Thread from rattail.exceptions import LabelPrintingError -from rattail.util import load_object, pretty_quantity, simple_error -from rattail.time import localtime, make_utc +from rattail.util import simple_error import colander from deform import widget as dfwidget @@ -75,12 +75,13 @@ class ProductView(MasterView): """ Master view for the Product class. """ - model_class = model.Product + model_class = Product has_versions = True results_downloadable_xlsx = True supports_autocomplete = True bulk_deletable = True mergeable = True + touchable = True configurable = True labels = { @@ -174,6 +175,7 @@ class ProductView(MasterView): def query(self, session): query = super().query(session) + model = self.model if not self.has_perm('view_deleted'): query = query.filter(model.Product.deleted == False) @@ -382,7 +384,7 @@ class ProductView(MasterView): g.set_filter('report_code_name', model.ReportCode.name) if self.expose_label_printing and self.has_perm('print_labels'): - g.more_actions.append(self.make_action( + g.actions.append(self.make_action( 'print_label', icon='print', url='#', click_handler='quickLabelPrint(props.row)')) @@ -417,13 +419,13 @@ class ProductView(MasterView): app = self.get_rattail_app() if price.starts: - starts = localtime(self.rattail_config, price.starts, from_utc=True) + starts = app.localtime(price.starts, from_utc=True) starts = app.render_date(starts.date()) else: starts = "??" if price.ends: - ends = localtime(self.rattail_config, price.ends, from_utc=True) + ends = app.localtime(price.ends, from_utc=True) ends = app.render_date(ends.date()) else: ends = "??" @@ -444,9 +446,12 @@ class ProductView(MasterView): if not text: return history - text = HTML.tag('span', c=[text]) - br = HTML.tag('br') - return HTML.tag('div', c=[text, br, history]) + text = HTML.tag('p', c=[text]) + history = HTML.tag('p', c=[history]) + div = HTML.tag('div', c=[text, history]) + # nb. for some reason we must wrap once more for oruga, + # otherwise it splits up the field?! + return HTML.tag('div', c=[div]) def show_price_effective_dates(self): if not self.rattail_config.versioning_enabled(): @@ -456,23 +461,25 @@ class ProductView(MasterView): default=True) def render_regular_price(self, product, field): + app = self.get_rattail_app() text = self.render_price(product, field) if text and self.show_price_effective_dates(): history = self.get_regular_price_history(product) if history: - date = localtime(self.rattail_config, history[0]['changed'], from_utc=True).date() + date = app.localtime(history[0]['changed'], from_utc=True).date() text = "{} (as of {})".format(text, date) return self.add_price_history_link(text, 'regular') def render_current_price(self, product, field): + app = self.get_rattail_app() text = self.render_price(product, field) if text and self.show_price_effective_dates(): history = self.get_current_price_history(product) if history: - date = localtime(self.rattail_config, history[0]['changed'], from_utc=True).date() + date = app.localtime(history[0]['changed'], from_utc=True).date() text = "{} (as of {})".format(text, date) return self.add_price_history_link(text, 'current') @@ -489,10 +496,11 @@ class ProductView(MasterView): if not text: return + app = self.get_rattail_app() if self.show_price_effective_dates(): history = self.get_suggested_price_history(product) if history: - date = localtime(self.rattail_config, history[0]['changed'], from_utc=True).date() + date = app.localtime(history[0]['changed'], from_utc=True).date() text = "{} (as of {})".format(text, date) text = self.warn_if_regprice_more_than_srp(product, text) @@ -526,13 +534,15 @@ class ProductView(MasterView): inventory = product.inventory if not inventory: return "" - return pretty_quantity(inventory.on_hand) + app = self.get_rattail_app() + return app.render_quantity(inventory.on_hand) def render_on_order(self, product, column): inventory = product.inventory if not inventory: return "" - return pretty_quantity(inventory.on_order) + app = self.get_rattail_app() + return app.render_quantity(inventory.on_order) def template_kwargs_index(self, **kwargs): kwargs = super().template_kwargs_index(**kwargs) @@ -681,6 +691,7 @@ class ProductView(MasterView): return data def get_instance(self): + model = self.model key = self.request.matchdict['uuid'] product = self.Session.get(model.Product, key) if product: @@ -692,6 +703,7 @@ class ProductView(MasterView): def configure_form(self, f): super().configure_form(f) + model = self.model product = f.model_instance # unit_size @@ -1105,7 +1117,8 @@ class ProductView(MasterView): value = product.inventory.on_hand if not value: return "" - return pretty_quantity(value) + app = self.get_rattail_app() + return app.render_quantity(value) def render_inventory_on_order(self, product, field): if not product.inventory: @@ -1113,7 +1126,8 @@ class ProductView(MasterView): value = product.inventory.on_order if not value: return "" - return pretty_quantity(value) + app = self.get_rattail_app() + return app.render_quantity(value) def price_history(self): """ @@ -1136,7 +1150,7 @@ class ProductView(MasterView): if price is not None: history['price'] = float(price) history['price_display'] = app.render_currency(price) - changed = localtime(self.rattail_config, history['changed'], from_utc=True) + changed = app.localtime(history['changed'], from_utc=True) history['changed'] = str(changed) history['changed_display_html'] = raw_datetime(self.rattail_config, changed) user = history.pop('changed_by') @@ -1149,6 +1163,7 @@ class ProductView(MasterView): """ AJAX view for fetching cost history for a product. """ + app = self.get_rattail_app() product = self.get_instance() data = self.get_cost_history(product) @@ -1162,7 +1177,7 @@ class ProductView(MasterView): history['cost_display'] = "${:0.2f}".format(cost) else: history['cost_display'] = None - changed = localtime(self.rattail_config, history['changed'], from_utc=True) + changed = app.localtime(history['changed'], from_utc=True) history['changed'] = str(changed) history['changed_display_html'] = raw_datetime(self.rattail_config, changed) user = history.pop('changed_by') @@ -1182,8 +1197,9 @@ class ProductView(MasterView): # regular price data = [] # defer fetching until user asks for it - grid = grids.Grid('products.regular_price_history', data, - request=self.request, + grid = grids.Grid(self.request, + key='products.regular_price_history', + data=data, columns=[ 'price', 'since', @@ -1196,8 +1212,9 @@ class ProductView(MasterView): # current price data = [] # defer fetching until user asks for it - grid = grids.Grid('products.current_price_history', data, - request=self.request, + grid = grids.Grid(self.request, + key='products.current_price_history', + data=data, columns=[ 'price', 'price_type', @@ -1214,8 +1231,9 @@ class ProductView(MasterView): # suggested price data = [] # defer fetching until user asks for it - grid = grids.Grid('products.suggested_price_history', data, - request=self.request, + grid = grids.Grid(self.request, + key='products.suggested_price_history', + data=data, columns=[ 'price', 'since', @@ -1228,8 +1246,9 @@ class ProductView(MasterView): # cost history data = [] # defer fetching until user asks for it - grid = grids.Grid('products.cost_history', data, - request=self.request, + grid = grids.Grid(self.request, + key='products.cost_history', + data=data, columns=[ 'cost', 'vendor', @@ -1320,7 +1339,8 @@ class ProductView(MasterView): factory = self.get_grid_factory() g = factory( - key='{}.vendor_sources'.format(route_prefix), + self.request, + key=f'{route_prefix}.vendor_sources', data=[], columns=columns, labels={ @@ -1361,7 +1381,8 @@ class ProductView(MasterView): factory = self.get_grid_factory() g = factory( - key='{}.lookup_codes'.format(route_prefix), + self.request, + key=f'{route_prefix}.lookup_codes', data=[], columns=[ 'sequence', @@ -1388,10 +1409,12 @@ class ProductView(MasterView): Returns a sequence of "records" which corresponds to the given product's regular price history. """ + app = self.get_rattail_app() + model = self.model Transaction = continuum.transaction_class(model.Product) ProductVersion = continuum.version_class(model.Product) ProductPriceVersion = continuum.version_class(model.ProductPrice) - now = make_utc() + now = app.make_utc() history = [] # first we find all relevant ProductVersion records @@ -1457,10 +1480,12 @@ class ProductView(MasterView): Returns a sequence of "records" which corresponds to the given product's current price history. """ + app = self.get_rattail_app() + model = self.model Transaction = continuum.transaction_class(model.Product) ProductVersion = continuum.version_class(model.Product) ProductPriceVersion = continuum.version_class(model.ProductPrice) - now = make_utc() + now = app.make_utc() history = [] # first we find all relevant ProductVersion records @@ -1599,10 +1624,12 @@ class ProductView(MasterView): Returns a sequence of "records" which corresponds to the given product's SRP history. """ + app = self.get_rattail_app() + model = self.model Transaction = continuum.transaction_class(model.Product) ProductVersion = continuum.version_class(model.Product) ProductPriceVersion = continuum.version_class(model.ProductPrice) - now = make_utc() + now = app.make_utc() history = [] # first we find all relevant ProductVersion records @@ -1668,10 +1695,12 @@ class ProductView(MasterView): Returns a sequence of "records" which corresponds to the given product's cost history. """ + app = self.get_rattail_app() + model = self.model Transaction = continuum.transaction_class(model.Product) ProductVersion = continuum.version_class(model.Product) ProductCostVersion = continuum.version_class(model.ProductCost) - now = make_utc() + now = app.make_utc() history = [] # we just find all relevant (preferred!) ProductCostVersion records @@ -1735,6 +1764,7 @@ class ProductView(MasterView): 'form': form}) def get_version_child_classes(self): + model = self.model return [ (model.ProductCode, 'product_uuid'), (model.ProductCost, 'product_uuid'), @@ -1789,8 +1819,8 @@ class ProductView(MasterView): def search(self): """ Perform a product search across multiple fields, and return - the results as JSON suitable for row data for a Buefy - ``<b-table>`` component. + the results as JSON suitable for row data for a table + component. """ if 'term' not in self.request.GET: # TODO: deprecate / remove this? not sure if/where it is used @@ -1827,7 +1857,8 @@ class ProductView(MasterView): lookup_fields.append('alt_code') if lookup_fields: product = self.products_handler.locate_product_for_entry( - session, term, lookup_fields=lookup_fields) + session, term, lookup_fields=lookup_fields, + first_if_multiple=True) if product: final_results.append(self.search_normalize_result(product)) @@ -1882,6 +1913,7 @@ class ProductView(MasterView): 'case_price', 'case_price_display', 'uom_choices', + 'organic', ]) # TODO: deprecate / remove this? not sure if/where it is used @@ -1893,6 +1925,7 @@ class ProductView(MasterView): Eventually this should be more generic, or at least offer more fields for search. For now it operates only on the ``Product.upc`` field. """ + model = self.model data = None upc = self.request.GET.get('upc', '').strip() upc = re.sub(r'\D', '', upc) @@ -1948,10 +1981,11 @@ class ProductView(MasterView): """ View for making a new batch from current product grid query. """ + app = self.get_rattail_app() supported = self.get_supported_batches() batch_options = [] for key, info in list(supported.items()): - handler = load_object(info['spec'])(self.rattail_config) + handler = app.load_object(info['spec'])(self.rattail_config) handler.spec = info['spec'] handler.option_key = key handler.option_title = info.get('title', handler.get_model_title()) @@ -2079,6 +2113,7 @@ class ProductView(MasterView): """ Threat target for making a batch from current products query. """ + model = self.model session = RattailSession() user = session.get(model.User, user_uuid) assert user @@ -2219,7 +2254,7 @@ class PendingProductView(MasterView): """ Master view for the Pending Product class. """ - model_class = model.PendingProduct + model_class = PendingProduct route_prefix = 'pending_products' url_prefix = '/products/pending' bulk_deletable = True @@ -2266,7 +2301,7 @@ class PendingProductView(MasterView): ] has_rows = True - model_row_class = model.CustomerOrderItem + model_row_class = CustomerOrderItem rows_title = "Customer Orders" # TODO: add support for this someday rows_viewable = False @@ -2429,9 +2464,10 @@ class PendingProductView(MasterView): # resolved* if self.creating: f.remove('resolved', 'resolved_by') + elif pending.resolved: + f.set_renderer('resolved_by', self.render_user) else: - if not pending.resolved: - f.remove('resolved', 'resolved_by') + f.remove('resolved', 'resolved_by') def render_status_code(self, pending, field): status = pending.status_code @@ -2448,19 +2484,19 @@ class PendingProductView(MasterView): if (self.has_perm('ignore_product') and status in (self.enum.PENDING_PRODUCT_STATUS_PENDING, self.enum.PENDING_PRODUCT_STATUS_READY)): - buttons.append(self.make_buefy_button("Ignore Product", - type='is-warning', - icon_left='ban', - **{'@click': "$emit('ignore-product')"})) + buttons.append(self.make_button("Ignore Product", + type='is-warning', + icon_left='ban', + **{'@click': "$emit('ignore-product')"})) if (self.has_perm('resolve_product') and status in (self.enum.PENDING_PRODUCT_STATUS_PENDING, self.enum.PENDING_PRODUCT_STATUS_READY, self.enum.PENDING_PRODUCT_STATUS_IGNORED)): - buttons.append(self.make_buefy_button("Resolve Product", - is_primary=True, - icon_left='object-ungroup', - **{'@click': "$emit('resolve-product')"})) + buttons.append(self.make_button("Resolve Product", + is_primary=True, + icon_left='object-ungroup', + **{'@click': "$emit('resolve-product')"})) if buttons: text = HTML.tag('span', class_='control', c=[text]) @@ -2535,7 +2571,14 @@ class PendingProductView(MasterView): app = self.get_rattail_app() products_handler = app.get_products_handler() kwargs = self.get_resolve_product_kwargs() - products_handler.resolve_product(pending, product, self.request.user, **kwargs) + + try: + products_handler.resolve_product(pending, product, self.request.user, **kwargs) + except Exception as error: + log.warning("failed to resolve product", exc_info=True) + self.request.session.flash(f"Resolve failed: {simple_error(error)}", 'error') + return redirect + return redirect def get_resolve_product_kwargs(self, **kwargs): @@ -2626,6 +2669,78 @@ class PendingProductView(MasterView): permission=f'{permission_prefix}.ignore_product') +class ProductCostView(MasterView): + """ + Master view for Product Costs + """ + model_class = ProductCost + route_prefix = 'product_costs' + url_prefix = '/products/costs' + has_versions = True + + grid_columns = [ + '_product_key_', + 'vendor', + 'preference', + 'code', + 'case_size', + 'case_cost', + 'pack_size', + 'pack_cost', + 'unit_cost', + ] + + def query(self, session): + """ """ + query = super().query(session) + model = self.app.model + + # always join on Product + return query.join(model.Product) + + def configure_grid(self, g): + """ """ + super().configure_grid(g) + model = self.app.model + + # product key + field = self.get_product_key_field() + g.set_renderer(field, self.render_product_key) + g.set_sorter(field, getattr(model.Product, field)) + g.set_sort_defaults(field) + g.set_filter(field, getattr(model.Product, field)) + + # vendor + g.set_joiner('vendor', lambda q: q.join(model.Vendor)) + g.set_sorter('vendor', model.Vendor.name) + g.set_filter('vendor', model.Vendor.name, label="Vendor Name") + + def render_product_key(self, cost, field): + """ """ + handler = self.app.get_products_handler() + return handler.render_product_key(cost.product) + + def configure_form(self, f): + """ """ + super().configure_form(f) + + # product + f.set_renderer('product', self.render_product) + if 'product_uuid' in f and 'product' in f: + f.remove('product') + f.replace('product_uuid', 'product') + + # vendor + f.set_renderer('vendor', self.render_vendor) + if 'vendor_uuid' in f and 'vendor' in f: + f.remove('vendor') + f.replace('vendor_uuid', 'vendor') + + # futures + # TODO: should eventually show a subgrid here? + f.remove('futures') + + def defaults(config, **kwargs): base = globals() @@ -2635,6 +2750,9 @@ def defaults(config, **kwargs): PendingProductView = kwargs.get('PendingProductView', base['PendingProductView']) PendingProductView.defaults(config) + ProductCostView = kwargs.get('ProductCostView', base['ProductCostView']) + ProductCostView.defaults(config) + def includeme(config): defaults(config) diff --git a/tailbone/views/progress.py b/tailbone/views/progress.py index 169f324e..3f47ba3e 100644 --- a/tailbone/views/progress.py +++ b/tailbone/views/progress.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 @@ Progress Views """ -from __future__ import unicode_literals, absolute_import - -import six - from tailbone.progress import get_progress_session @@ -44,7 +40,7 @@ def progress(request): bits = session.get('extra_session_bits') if bits: - for key, value in six.iteritems(bits): + for key, value in bits.items(): request.session[key] = value elif session.get('error'): diff --git a/tailbone/views/purchasing/batch.py b/tailbone/views/purchasing/batch.py index e49a5dea..5e00704e 100644 --- a/tailbone/views/purchasing/batch.py +++ b/tailbone/views/purchasing/batch.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,14 +24,15 @@ Base class for purchasing batch views """ +import warnings + from rattail.db.model import PurchaseBatch, PurchaseBatchRow import colander from deform import widget as dfwidget -from pyramid import httpexceptions from webhelpers2.html import tags, HTML -from tailbone import forms, grids +from tailbone import forms from tailbone.views.batch import BatchMasterView @@ -68,6 +69,8 @@ class PurchasingBatchView(BatchMasterView): 'store', 'buyer', 'vendor', + 'description', + 'workflow', 'department', 'purchase', 'vendor_email', @@ -159,6 +162,174 @@ class PurchasingBatchView(BatchMasterView): def batch_mode(self): raise NotImplementedError("Please define `batch_mode` for your purchasing batch view") + def get_supported_workflows(self): + """ + Return the supported "create batch" workflows. + """ + enum = self.app.enum + if self.batch_mode == enum.PURCHASE_BATCH_MODE_ORDERING: + return self.batch_handler.supported_ordering_workflows() + elif self.batch_mode == enum.PURCHASE_BATCH_MODE_RECEIVING: + return self.batch_handler.supported_receiving_workflows() + elif self.batch_mode == enum.PURCHASE_BATCH_MODE_COSTING: + return self.batch_handler.supported_costing_workflows() + raise ValueError("unknown batch mode") + + def allow_any_vendor(self): + """ + Return boolean indicating whether creating a batch for "any" + vendor is allowed, vs. only supported vendors. + """ + enum = self.app.enum + + if self.batch_mode == enum.PURCHASE_BATCH_MODE_ORDERING: + return self.batch_handler.allow_ordering_any_vendor() + + elif self.batch_mode == enum.PURCHASE_BATCH_MODE_RECEIVING: + value = self.config.get_bool('rattail.batch.purchase.allow_receiving_any_vendor') + if value is not None: + return value + value = self.config.get_bool('rattail.batch.purchase.supported_vendors_only') + if value is not None: + warnings.warn("setting rattail.batch.purchase.supported_vendors_only is deprecated; " + "please use rattail.batch.purchase.allow_receiving_any_vendor instead", + DeprecationWarning) + # nb. must negate this setting + return not value + return False + + raise ValueError("unknown batch mode") + + def get_supported_vendors(self): + """ + Return the supported vendors for creating a batch. + """ + return [] + + def create(self, form=None, **kwargs): + """ + Custom view for creating a new batch. We split the process + into two steps, 1) choose workflow and 2) create batch. This + is because the specific form details for creating a batch will + depend on which "type" of batch creation is to be done, and + it's much easier to keep conditional logic for that in the + server instead of client-side etc. + """ + model = self.app.model + enum = self.app.enum + route_prefix = self.get_route_prefix() + + workflows = self.get_supported_workflows() + valid_workflows = [workflow['workflow_key'] + for workflow in workflows] + + # if user has already identified their desired workflow, then + # we can just farm out to the default logic. we will of + # course configure our form differently, based on workflow, + # but this create() method at least will not need + # customization for that. + if self.request.matched_route.name.endswith('create_workflow'): + + redirect = self.redirect(self.request.route_url(f'{route_prefix}.create')) + + # however we do have one more thing to check - the workflow + # requested must of course be valid! + workflow_key = self.request.matchdict['workflow_key'] + if workflow_key not in valid_workflows: + self.request.session.flash(f"Not a supported workflow: {workflow_key}", 'error') + raise redirect + + # also, we require vendor to be correctly identified. if + # someone e.g. navigates to a URL by accident etc. we want + # to gracefully handle and redirect + uuid = self.request.matchdict['vendor_uuid'] + vendor = self.Session.get(model.Vendor, uuid) + if not vendor: + self.request.session.flash("Invalid vendor selection. " + "Please choose an existing vendor.", + 'warning') + raise redirect + + # okay now do the normal thing, per workflow + return super().create(**kwargs) + + # on the other hand, if caller provided a form, that means we are in + # the middle of some other custom workflow, e.g. "add child to truck + # dump parent" or some such. in which case we also defer to the normal + # logic, so as to not interfere with that. + if form: + return super().create(form=form, **kwargs) + + # okay, at this point we need the user to select a vendor and workflow + self.creating = True + context = {} + + # form to accept user choice of vendor/workflow + schema = colander.Schema() + schema.add(colander.SchemaNode(colander.String(), name='vendor')) + schema.add(colander.SchemaNode(colander.String(), name='workflow', + validator=colander.OneOf(valid_workflows))) + factory = self.get_form_factory() + form = factory(schema=schema, request=self.request) + + # configure vendor field + vendor_handler = self.app.get_vendor_handler() + if self.allow_any_vendor(): + # user may choose *any* available vendor + use_dropdown = vendor_handler.choice_uses_dropdown() + if use_dropdown: + vendors = self.Session.query(model.Vendor)\ + .order_by(model.Vendor.id)\ + .all() + vendor_values = [(vendor.uuid, f"({vendor.id}) {vendor.name}") + for vendor in vendors] + form.set_widget('vendor', dfwidget.SelectWidget(values=vendor_values)) + if len(vendors) == 1: + form.set_default('vendor', vendors[0].uuid) + else: + vendor_display = "" + if self.request.method == 'POST': + if self.request.POST.get('vendor'): + vendor = self.Session.get(model.Vendor, self.request.POST['vendor']) + if vendor: + vendor_display = str(vendor) + vendors_url = self.request.route_url('vendors.autocomplete') + form.set_widget('vendor', forms.widgets.JQueryAutocompleteWidget( + field_display=vendor_display, service_url=vendors_url)) + else: # only "supported" vendors allowed + vendors = self.get_supported_vendors() + vendor_values = [(vendor.uuid, vendor_handler.render_vendor(vendor)) + for vendor in vendors] + form.set_widget('vendor', dfwidget.SelectWidget(values=vendor_values)) + form.set_validator('vendor', self.valid_vendor_uuid) + + # configure workflow field + values = [(workflow['workflow_key'], workflow['display']) + for workflow in workflows] + form.set_widget('workflow', + dfwidget.SelectWidget(values=values)) + if len(workflows) == 1: + form.set_default('workflow', workflows[0]['workflow_key']) + + form.submit_label = "Continue" + form.cancel_url = self.get_index_url() + + # if form validates, that means user has chosen a creation + # type, so we just redirect to the appropriate "new batch of + # type X" page + if form.validate(): + workflow_key = form.validated['workflow'] + vendor_uuid = form.validated['vendor'] + url = self.request.route_url(f'{route_prefix}.create_workflow', + workflow_key=workflow_key, + vendor_uuid=vendor_uuid) + raise self.redirect(url) + + context['form'] = form + if hasattr(form, 'make_deform_form'): + context['dform'] = form.make_deform_form() + return self.render_to_response('create', context) + def query(self, session): model = self.model return session.query(model.PurchaseBatch)\ @@ -227,20 +398,40 @@ class PurchasingBatchView(BatchMasterView): def configure_form(self, f): super().configure_form(f) - model = self.model + model = self.app.model + enum = self.app.enum + route_prefix = self.get_route_prefix() + + today = self.app.today() batch = f.model_instance - app = self.get_rattail_app() - today = app.localtime().date() + workflow = self.request.matchdict.get('workflow_key') + vendor_handler = self.app.get_vendor_handler() # mode - f.set_enum('mode', self.enum.PURCHASE_BATCH_MODE) + f.set_enum('mode', enum.PURCHASE_BATCH_MODE) + + # workflow + if self.creating: + if workflow: + f.set_widget('workflow', dfwidget.HiddenWidget()) + f.set_default('workflow', workflow) + f.set_hidden('workflow') + # nb. show readonly '_workflow' + f.insert_after('workflow', '_workflow') + f.set_readonly('_workflow') + f.set_renderer('_workflow', self.render_workflow) + else: + f.set_readonly('workflow') + f.set_renderer('workflow', self.render_workflow) + else: + f.remove('workflow') # store - single_store = self.rattail_config.single_store() + single_store = self.config.single_store() if self.creating: f.replace('store', 'store_uuid') if single_store: - store = self.rattail_config.get_store(self.Session()) + store = self.config.get_store(self.Session()) f.set_widget('store_uuid', dfwidget.HiddenWidget()) f.set_default('store_uuid', store.uuid) f.set_hidden('store_uuid') @@ -264,7 +455,6 @@ class PurchasingBatchView(BatchMasterView): if self.creating: f.replace('vendor', 'vendor_uuid') f.set_label('vendor_uuid', "Vendor") - vendor_handler = app.get_vendor_handler() use_dropdown = vendor_handler.choice_uses_dropdown() if use_dropdown: vendors = self.Session.query(model.Vendor)\ @@ -314,7 +504,7 @@ class PurchasingBatchView(BatchMasterView): if buyer: buyer_display = str(buyer) elif self.creating: - buyer = app.get_employee(self.request.user) + buyer = self.app.get_employee(self.request.user) if buyer: buyer_display = str(buyer) f.set_default('buyer_uuid', buyer.uuid) @@ -325,6 +515,30 @@ class PurchasingBatchView(BatchMasterView): field_display=buyer_display, service_url=buyers_url)) f.set_label('buyer_uuid', "Buyer") + # order_file + if self.creating: + f.set_type('order_file', 'file', required=False) + else: + f.set_readonly('order_file') + f.set_renderer('order_file', self.render_downloadable_file) + + # order_parser_key + if self.creating: + kwargs = {} + if 'vendor_uuid' in self.request.matchdict: + vendor = self.Session.get(model.Vendor, + self.request.matchdict['vendor_uuid']) + if vendor: + kwargs['vendor'] = vendor + parsers = vendor_handler.get_supported_order_parsers(**kwargs) + parser_values = [(p.key, p.title) for p in parsers] + if len(parsers) == 1: + f.set_default('order_parser_key', parsers[0].key) + f.set_widget('order_parser_key', dfwidget.SelectWidget(values=parser_values)) + f.set_label('order_parser_key', "Order Parser") + else: + f.remove_field('order_parser_key') + # invoice_file if self.creating: f.set_type('invoice_file', 'file', required=False) @@ -342,7 +556,7 @@ class PurchasingBatchView(BatchMasterView): if vendor: kwargs['vendor'] = vendor - parsers = self.handler.get_supported_invoice_parsers(**kwargs) + parsers = self.batch_handler.get_supported_invoice_parsers(**kwargs) parser_values = [(p.key, p.display) for p in parsers] if len(parsers) == 1: f.set_default('invoice_parser_key', parsers[0].key) @@ -401,6 +615,35 @@ class PurchasingBatchView(BatchMasterView): 'vendor_contact', 'status_code') + # tweak some things if we are in "step 2" of creating new batch + if self.creating and workflow: + + # display vendor but do not allow changing + vendor = self.Session.get(model.Vendor, self.request.matchdict['vendor_uuid']) + if not vendor: + raise ValueError(f"vendor not found: {self.request.matchdict['vendor_uuid']}") + f.set_readonly('vendor_uuid') + f.set_default('vendor_uuid', str(vendor)) + + # cancel should take us back to choosing a workflow + f.cancel_url = self.request.route_url(f'{route_prefix}.create') + + def render_workflow(self, batch, field): + key = self.request.matchdict['workflow_key'] + info = self.get_workflow_info(key) + if info: + return info['display'] + + def get_workflow_info(self, key): + enum = self.app.enum + if self.batch_mode == enum.PURCHASE_BATCH_MODE_ORDERING: + return self.batch_handler.ordering_workflow_info(key) + elif self.batch_mode == enum.PURCHASE_BATCH_MODE_RECEIVING: + return self.batch_handler.receiving_workflow_info(key) + elif self.batch_mode == enum.PURCHASE_BATCH_MODE_COSTING: + return self.batch_handler.costing_workflow_info(key) + raise ValueError("unknown batch mode") + def render_store(self, batch, field): store = batch.store if not store: @@ -516,10 +759,12 @@ class PurchasingBatchView(BatchMasterView): def get_batch_kwargs(self, batch, **kwargs): kwargs = super().get_batch_kwargs(batch, **kwargs) - model = self.model + model = self.app.model kwargs['mode'] = self.batch_mode + kwargs['workflow'] = self.request.POST['workflow'] kwargs['truck_dump'] = batch.truck_dump + kwargs['order_parser_key'] = batch.order_parser_key kwargs['invoice_parser_key'] = batch.invoice_parser_key if batch.store: @@ -537,6 +782,11 @@ class PurchasingBatchView(BatchMasterView): elif batch.vendor_uuid: kwargs['vendor_uuid'] = batch.vendor_uuid + # must pull vendor from URL if it was not in form data + if 'vendor_uuid' not in kwargs and 'vendor' not in kwargs: + if 'vendor_uuid' in self.request.matchdict: + kwargs['vendor_uuid'] = self.request.matchdict['vendor_uuid'] + if batch.department: kwargs['department'] = batch.department elif batch.department_uuid: @@ -794,7 +1044,8 @@ class PurchasingBatchView(BatchMasterView): factory = self.get_grid_factory() g = factory( - key='{}.row_credits'.format(route_prefix), + self.request, + key=f'{route_prefix}.row_credits', data=[], columns=[ 'credit_type', @@ -826,7 +1077,7 @@ class PurchasingBatchView(BatchMasterView): def render_row_credits(self, row, field): g = self.make_row_credits_grid(row) return HTML.literal( - g.render_buefy_table_element(data_prop='rowData.credits')) + g.render_table_element(data_prop='rowData.credits')) # def before_create_row(self, form): # row = form.fieldset.model @@ -919,6 +1170,25 @@ class PurchasingBatchView(BatchMasterView): # # otherwise just view batch again # return self.get_action_url('view', batch) + @classmethod + def defaults(cls, config): + cls._purchase_batch_defaults(config) + cls._batch_defaults(config) + cls._defaults(config) + + @classmethod + def _purchase_batch_defaults(cls, config): + route_prefix = cls.get_route_prefix() + url_prefix = cls.get_url_prefix() + permission_prefix = cls.get_permission_prefix() + + # new batch using workflow X + config.add_route(f'{route_prefix}.create_workflow', + f'{url_prefix}/new/{{workflow_key}}/{{vendor_uuid}}') + config.add_view(cls, attr='create', + route_name=f'{route_prefix}.create_workflow', + permission=f'{permission_prefix}.create') + class NewProduct(colander.Schema): diff --git a/tailbone/views/purchasing/ordering.py b/tailbone/views/purchasing/ordering.py index 63c13517..c7cc7bfc 100644 --- a/tailbone/views/purchasing/ordering.py +++ b/tailbone/views/purchasing/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,14 +28,10 @@ import os import json import openpyxl -from sqlalchemy import orm -from rattail.db import model, api from rattail.core import Object -from rattail.time import localtime - -from webhelpers2.html import tags +from tailbone.db import Session from tailbone.views.purchasing import PurchasingBatchView @@ -51,6 +47,8 @@ class OrderingBatchView(PurchasingBatchView): rows_editable = True has_worksheet = True default_help_url = 'https://rattailproject.org/docs/rattail-manual/features/purchasing/ordering/index.html' + downloadable = True + configurable = True labels = { 'po_total_calculated': "PO Total", @@ -59,9 +57,14 @@ class OrderingBatchView(PurchasingBatchView): form_fields = [ 'id', 'store', - 'buyer', 'vendor', + 'description', + 'workflow', + 'order_file', + 'order_parser_key', + 'buyer', 'department', + 'params', 'purchase', 'vendor_email', 'vendor_fax', @@ -132,15 +135,26 @@ class OrderingBatchView(PurchasingBatchView): return self.enum.PURCHASE_BATCH_MODE_ORDERING def configure_form(self, f): - super(OrderingBatchView, self).configure_form(f) + super().configure_form(f) batch = f.model_instance + workflow = self.request.matchdict.get('workflow_key') # purchase if self.creating or not batch.executed or not batch.purchase: f.remove_field('purchase') + # now that all fields are setup, some final tweaks based on workflow + if self.creating and workflow: + + if workflow == 'from_scratch': + f.remove('order_file', + 'order_parser_key') + + elif workflow == 'from_file': + f.set_required('order_file') + def get_batch_kwargs(self, batch, **kwargs): - kwargs = super(OrderingBatchView, self).get_batch_kwargs(batch, **kwargs) + kwargs = super().get_batch_kwargs(batch, **kwargs) kwargs['ship_method'] = batch.ship_method kwargs['notes_to_vendor'] = batch.notes_to_vendor return kwargs @@ -155,7 +169,7 @@ class OrderingBatchView(PurchasingBatchView): * ``cases_ordered`` * ``units_ordered`` """ - super(OrderingBatchView, self).configure_row_form(f) + super().configure_row_form(f) # when editing, only certain fields should allow changes if self.editing: @@ -308,9 +322,7 @@ class OrderingBatchView(PurchasingBatchView): title = self.get_instance_title(batch) order_date = batch.date_ordered if not order_date: - order_date = localtime(self.rattail_config).date() - - buefy_data = self.get_worksheet_buefy_data(departments) + order_date = self.app.today() return self.render_to_response('worksheet', { 'batch': batch, @@ -324,10 +336,10 @@ class OrderingBatchView(PurchasingBatchView): 'get_upc': lambda p: p.upc.pretty() if p.upc else '', 'header_columns': self.order_form_header_columns, 'ignore_cases': not self.handler.allow_cases(), - 'worksheet_data': buefy_data, + 'worksheet_data': self.get_worksheet_data(departments), }) - def get_worksheet_buefy_data(self, departments): + def get_worksheet_data(self, departments): data = {} for department in departments.values(): for subdepartment in department._order_subdepartments.values(): @@ -371,6 +383,7 @@ class OrderingBatchView(PurchasingBatchView): of being updated. If a matching row is not found, it will not be created. """ + model = self.app.model batch = self.get_instance() try: @@ -480,13 +493,75 @@ class OrderingBatchView(PurchasingBatchView): return self.file_response(path) def get_execute_success_url(self, batch, result, **kwargs): + model = self.app.model if isinstance(result, model.Purchase): return self.request.route_url('purchases.view', uuid=result.uuid) - return super(OrderingBatchView, self).get_execute_success_url(batch, result, **kwargs) + return super().get_execute_success_url(batch, result, **kwargs) + + def configure_get_simple_settings(self): + return [ + + # workflows + {'section': 'rattail.batch', + 'option': 'purchase.allow_ordering_from_scratch', + 'type': bool, + 'default': True}, + {'section': 'rattail.batch', + 'option': 'purchase.allow_ordering_from_file', + 'type': bool, + 'default': True}, + + # vendors + {'section': 'rattail.batch', + 'option': 'purchase.allow_ordering_any_vendor', + 'type': bool, + 'default': True, + }, + ] + + def configure_get_context(self): + context = super().configure_get_context() + vendor_handler = self.app.get_vendor_handler() + + Parsers = vendor_handler.get_all_order_parsers() + Supported = vendor_handler.get_supported_order_parsers() + context['order_parsers'] = Parsers + context['order_parsers_data'] = dict([(Parser.key, Parser in Supported) + for Parser in Parsers]) + + return context + + def configure_gather_settings(self, data): + settings = super().configure_gather_settings(data) + vendor_handler = self.app.get_vendor_handler() + + supported = [] + for Parser in vendor_handler.get_all_order_parsers(): + name = f'order_parser_{Parser.key}' + if data.get(name) == 'true': + supported.append(Parser.key) + settings.append({'name': 'rattail.vendors.supported_order_parsers', + 'value': ', '.join(supported)}) + + return settings + + def configure_remove_settings(self): + super().configure_remove_settings() + + names = [ + 'rattail.vendors.supported_order_parsers', + ] + + # nb. using thread-local session here; we do not use + # self.Session b/c it may not point to Rattail + session = Session() + for name in names: + self.app.delete_setting(session, name) @classmethod def defaults(cls, config): cls._ordering_defaults(config) + cls._purchase_batch_defaults(config) cls._batch_defaults(config) cls._defaults(config) diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index 9de4baa3..01858c98 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/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. # @@ -25,24 +25,22 @@ Views for 'receiving' (purchasing) batches """ import os -import re import decimal import logging from collections import OrderedDict -import humanize +# import humanize from rattail import pod -from rattail.time import localtime, make_utc -from rattail.util import pretty_quantity, prettify, simple_error +from rattail.util import simple_error import colander from deform import widget as dfwidget -from pyramid import httpexceptions from webhelpers2.html import tags, HTML -from tailbone import forms, grids -from tailbone.util import get_form_data +from wuttaweb.util import get_form_data + +from tailbone import forms from tailbone.views.purchasing import PurchasingBatchView @@ -110,7 +108,7 @@ class ReceivingBatchView(PurchasingBatchView): 'store', 'vendor', 'description', - 'receiving_workflow', + 'workflow', 'truck_dump', 'truck_dump_children_first', 'truck_dump_children', @@ -237,135 +235,18 @@ class ReceivingBatchView(PurchasingBatchView): if not self.handler.allow_truck_dump_receiving(): g.remove('truck_dump') - def create(self, form=None, **kwargs): - """ - Custom view for creating a new receiving batch. We split the process - into two steps, 1) choose and 2) create. This is because the specific - form details for creating a batch will depend on which "type" of batch - creation is to be done, and it's much easier to keep conditional logic - for that in the server instead of client-side etc. - - See also - :meth:`tailbone.views.purchasing.costing:CostingBatchView.create()` - which uses similar logic. - """ - model = self.model - route_prefix = self.get_route_prefix() - workflows = self.handler.supported_receiving_workflows() - valid_workflows = [workflow['workflow_key'] - for workflow in workflows] - - # if user has already identified their desired workflow, then we can - # just farm out to the default logic. we will of course configure our - # form differently, based on workflow, but this create() method at - # least will not need customization for that. - if self.request.matched_route.name.endswith('create_workflow'): - - redirect = self.redirect(self.request.route_url('{}.create'.format(route_prefix))) - - # however we do have one more thing to check - the workflow - # requested must of course be valid! - workflow_key = self.request.matchdict['workflow_key'] - if workflow_key not in valid_workflows: - self.request.session.flash( - "Not a supported workflow: {}".format(workflow_key), - 'error') - raise redirect - - # also, we require vendor to be correctly identified. if - # someone e.g. navigates to a URL by accident etc. we want - # to gracefully handle and redirect - uuid = self.request.matchdict['vendor_uuid'] - vendor = self.Session.get(model.Vendor, uuid) - if not vendor: - self.request.session.flash("Invalid vendor selection. " - "Please choose an existing vendor.", - 'warning') - raise redirect - - # okay now do the normal thing, per workflow - return super().create(**kwargs) - - # on the other hand, if caller provided a form, that means we are in - # the middle of some other custom workflow, e.g. "add child to truck - # dump parent" or some such. in which case we also defer to the normal - # logic, so as to not interfere with that. - if form: - return super().create(form=form, **kwargs) - - # okay, at this point we need the user to select a vendor and workflow - self.creating = True - context = {} - - # form to accept user choice of vendor/workflow - schema = NewReceivingBatch().bind(valid_workflows=valid_workflows) - form = forms.Form(schema=schema, request=self.request) - - # configure vendor field - app = self.get_rattail_app() - vendor_handler = app.get_vendor_handler() - if self.rattail_config.getbool('rattail.batch', 'purchase.supported_vendors_only'): - # only show vendors for which we have dedicated invoice parsers - vendors = {} - for parser in self.batch_handler.get_supported_invoice_parsers(): - if parser.vendor_key: - vendor = vendor_handler.get_vendor(self.Session(), - parser.vendor_key) - if vendor: - vendors[vendor.uuid] = vendor - vendors = sorted(vendors.values(), key=lambda v: v.name) - vendor_values = [(vendor.uuid, vendor_handler.render_vendor(vendor)) - for vendor in vendors] - form.set_widget('vendor', dfwidget.SelectWidget(values=vendor_values)) - else: - # user may choose *any* available vendor - use_dropdown = vendor_handler.choice_uses_dropdown() - if use_dropdown: - vendors = self.Session.query(model.Vendor)\ - .order_by(model.Vendor.id)\ - .all() - vendor_values = [(vendor.uuid, "({}) {}".format(vendor.id, vendor.name)) - for vendor in vendors] - form.set_widget('vendor', dfwidget.SelectWidget(values=vendor_values)) - if len(vendors) == 1: - form.set_default('vendor', vendors[0].uuid) - else: - vendor_display = "" - if self.request.method == 'POST': - if self.request.POST.get('vendor'): - vendor = self.Session.get(model.Vendor, self.request.POST['vendor']) - if vendor: - vendor_display = str(vendor) - vendors_url = self.request.route_url('vendors.autocomplete') - form.set_widget('vendor', forms.widgets.JQueryAutocompleteWidget( - field_display=vendor_display, service_url=vendors_url)) - form.set_validator('vendor', self.valid_vendor_uuid) - - # configure workflow field - values = [(workflow['workflow_key'], workflow['display']) - for workflow in workflows] - form.set_widget('workflow', - dfwidget.SelectWidget(values=values)) - if len(workflows) == 1: - form.set_default('workflow', workflows[0]['workflow_key']) - - form.submit_label = "Continue" - form.cancel_url = self.get_index_url() - - # if form validates, that means user has chosen a creation type, so we - # just redirect to the appropriate "new batch of type X" page - if form.validate(): - workflow_key = form.validated['workflow'] - vendor_uuid = form.validated['vendor'] - url = self.request.route_url('{}.create_workflow'.format(route_prefix), - workflow_key=workflow_key, - vendor_uuid=vendor_uuid) - raise self.redirect(url) - - context['form'] = form - if hasattr(form, 'make_deform_form'): - context['dform'] = form.make_deform_form() - return self.render_to_response('create', context) + def get_supported_vendors(self): + """ """ + vendor_handler = self.app.get_vendor_handler() + vendors = {} + for parser in self.batch_handler.get_supported_invoice_parsers(): + if parser.vendor_key: + vendor = vendor_handler.get_vendor(self.Session(), + parser.vendor_key) + if vendor: + vendors[vendor.uuid] = vendor + vendors = sorted(vendors.values(), key=lambda v: v.name) + return vendors def row_deletable(self, row): @@ -406,13 +287,7 @@ class ReceivingBatchView(PurchasingBatchView): # cancel should take us back to choosing a workflow f.cancel_url = self.request.route_url('{}.create'.format(route_prefix)) - # receiving_workflow - if self.creating and workflow: - f.set_readonly('receiving_workflow') - f.set_renderer('receiving_workflow', self.render_receiving_workflow) - else: - f.remove('receiving_workflow') - + # TODO: remove this # batch_type if self.creating: f.set_widget('batch_type', dfwidget.HiddenWidget()) @@ -527,12 +402,13 @@ class ReceivingBatchView(PurchasingBatchView): # multiple invoice files (if applicable) if (not self.creating - and batch.get_param('receiving_workflow') == 'from_multi_invoice'): + and batch.get_param('workflow') == 'from_multi_invoice'): if 'invoice_files' not in f: f.insert_before('invoice_file', 'invoice_files') f.set_renderer('invoice_files', self.render_invoice_files) f.set_readonly('invoice_files', True) + f.remove('invoice_file') # invoice totals f.set_label('invoice_total', "Invoice Total (Orig.)") @@ -625,12 +501,6 @@ class ReceivingBatchView(PurchasingBatchView): items.append(HTML.tag('li', c=[link])) return HTML.tag('ul', c=items) - def render_receiving_workflow(self, batch, field): - key = self.request.matchdict['workflow_key'] - info = self.handler.receiving_workflow_info(key) - if info: - return info['display'] - def get_visible_params(self, batch): params = super().get_visible_params(batch) @@ -655,42 +525,40 @@ class ReceivingBatchView(PurchasingBatchView): def get_batch_kwargs(self, batch, **kwargs): kwargs = super().get_batch_kwargs(batch, **kwargs) - batch_type = self.request.POST['batch_type'] # must pull vendor from URL if it was not in form data if 'vendor_uuid' not in kwargs and 'vendor' not in kwargs: if 'vendor_uuid' in self.request.matchdict: kwargs['vendor_uuid'] = self.request.matchdict['vendor_uuid'] - # TODO: ugh should just have workflow and no batch_type - kwargs['receiving_workflow'] = batch_type - if batch_type == 'from_scratch': + workflow = kwargs['workflow'] + if workflow == 'from_scratch': kwargs.pop('truck_dump_batch', None) kwargs.pop('truck_dump_batch_uuid', None) - elif batch_type == 'from_invoice': + elif workflow == 'from_invoice': pass - elif batch_type == 'from_multi_invoice': + elif workflow == 'from_multi_invoice': pass - elif batch_type == 'from_po': + elif workflow == 'from_po': # TODO: how to best handle this field? this doesn't seem flexible kwargs['purchase_key'] = batch.purchase_uuid - elif batch_type == 'from_po_with_invoice': + elif workflow == 'from_po_with_invoice': # TODO: how to best handle this field? this doesn't seem flexible kwargs['purchase_key'] = batch.purchase_uuid - elif batch_type == 'truck_dump_children_first': + elif workflow == 'truck_dump_children_first': kwargs['truck_dump'] = True kwargs['truck_dump_children_first'] = True kwargs['order_quantities_known'] = True # TODO: this makes sense in some cases, but all? # (should just omit that field when not relevant) kwargs['date_ordered'] = None - elif batch_type == 'truck_dump_children_last': + elif workflow == 'truck_dump_children_last': kwargs['truck_dump'] = True kwargs['truck_dump_ready'] = True # TODO: this makes sense in some cases, but all? # (should just omit that field when not relevant) kwargs['date_ordered'] = None - elif batch_type.startswith('truck_dump_child'): + elif workflow.startswith('truck_dump_child'): truck_dump = self.get_instance() kwargs['store'] = truck_dump.store kwargs['vendor'] = truck_dump.vendor @@ -775,17 +643,26 @@ class ReceivingBatchView(PurchasingBatchView): breakdown = self.make_po_vs_invoice_breakdown(batch) factory = self.get_grid_factory() - g = factory('batch_po_vs_invoice_breakdown', [], - columns=['title', 'count']) + g = factory(self.request, + key='batch_po_vs_invoice_breakdown', + data=[], + columns=['title', 'count']) g.set_click_handler('title', "autoFilterPoVsInvoice(props.row)") kwargs['po_vs_invoice_breakdown_data'] = breakdown kwargs['po_vs_invoice_breakdown_grid'] = HTML.literal( - g.render_buefy_table_element(data_prop='poVsInvoiceBreakdownData', - empty_labels=True)) + g.render_table_element(data_prop='poVsInvoiceBreakdownData', + empty_labels=True)) kwargs['allow_edit_catalog_unit_cost'] = self.allow_edit_catalog_unit_cost(batch) kwargs['allow_edit_invoice_unit_cost'] = self.allow_edit_invoice_unit_cost(batch) + if (kwargs['allow_edit_catalog_unit_cost'] + and kwargs['allow_edit_invoice_unit_cost'] + and not batch.get_param('confirmed_all_costs')): + kwargs['allow_confirm_all_costs'] = True + else: + kwargs['allow_confirm_all_costs'] = False + return kwargs def get_context_credits(self, row): @@ -1025,14 +902,16 @@ class ReceivingBatchView(PurchasingBatchView): if batch.is_truck_dump_parent(): permission_prefix = self.get_permission_prefix() if self.request.has_perm('{}.edit_row'.format(permission_prefix)): - transform = grids.GridAction('transform', + transform = self.make_action('transform', icon='shuffle', label="Transform to Unit", url=self.transform_unit_url) - g.more_actions.append(transform) - if g.main_actions and g.main_actions[-1].key == 'delete': - delete = g.main_actions.pop() - g.more_actions.append(delete) + if g.actions and g.actions[-1].key == 'delete': + delete = g.actions.pop() + g.actions.append(transform) + g.actions.append(delete) + else: + g.actions.append(transform) # truck_dump_status if not batch.is_truck_dump_parent(): @@ -1105,7 +984,7 @@ class ReceivingBatchView(PurchasingBatchView): and self.row_editable(row)): # add the Un-Declare action - g.main_actions.append(self.make_action( + g.actions.append(self.make_action( 'remove', label="Un-Declare", url='#', icon='trash', link_class='has-text-danger', @@ -1129,6 +1008,7 @@ class ReceivingBatchView(PurchasingBatchView): """ Primary desktop view for row-level receiving. """ + app = self.get_rattail_app() # TODO: this code was largely copied from mobile_receive_row() but it # tries to pave the way for shared logic, i.e. where the latter would # simply invoke this method and return the result. however we're not @@ -1262,7 +1142,7 @@ class ReceivingBatchView(PurchasingBatchView): if accounted_for: # some product accounted for; button should receive "remainder" only if remainder: - remainder = pretty_quantity(remainder) + remainder = app.render_quantity(remainder) context['quick_receive_quantity'] = remainder context['quick_receive_text'] = "Receive Remainder ({} {})".format(remainder, context['unit_uom']) else: @@ -1272,7 +1152,7 @@ class ReceivingBatchView(PurchasingBatchView): else: # nothing yet accounted for, button should receive "all" if not remainder: raise ValueError("why is remainder empty?") - remainder = pretty_quantity(remainder) + remainder = app.render_quantity(remainder) context['quick_receive_quantity'] = remainder context['quick_receive_text'] = "Receive ALL ({} {})".format(remainder, context['unit_uom']) @@ -1849,6 +1729,7 @@ class ReceivingBatchView(PurchasingBatchView): """ AJAX view for updating various cost fields in a data row. """ + app = self.get_rattail_app() model = self.model batch = self.get_instance() data = dict(get_form_data(self.request)) @@ -1883,10 +1764,10 @@ class ReceivingBatchView(PurchasingBatchView): 'catalog_cost_confirmed': row.catalog_cost_confirmed, 'invoice_unit_cost': self.render_simple_unit_cost(row, 'invoice_unit_cost'), 'invoice_cost_confirmed': row.invoice_cost_confirmed, - 'invoice_total_calculated': '{:0.2f}'.format(row.invoice_total_calculated), + 'invoice_total_calculated': app.render_currency(row.invoice_total_calculated), }, 'batch': { - 'invoice_total_calculated': '{:0.2f}'.format(batch.invoice_total_calculated), + 'invoice_total_calculated': app.render_currency(batch.invoice_total_calculated), }, } @@ -1910,6 +1791,45 @@ class ReceivingBatchView(PurchasingBatchView): batch = self.get_instance() return self.handler_action(batch, 'auto_receive') + def confirm_all_costs(self): + """ + View which can "confirm all costs" for the batch. + """ + batch = self.get_instance() + return self.handler_action(batch, 'confirm_all_receiving_costs') + + def confirm_all_receiving_costs_thread(self, uuid, user_uuid, progress=None): + app = self.get_rattail_app() + model = self.model + session = app.make_session() + + batch = session.get(model.PurchaseBatch, uuid) + # user = session.query(model.User).get(user_uuid) + try: + self.handler.confirm_all_receiving_costs(batch, progress=progress) + + # if anything goes wrong, rollback and log the error etc. + except Exception as error: + session.rollback() + log.exception("failed to confirm all costs for batch: %s", batch) + session.close() + if progress: + progress.session.load() + progress.session['error'] = True + progress.session['error_msg'] = f"Failed to confirm costs: {simple_error(error)}" + progress.session.save() + + else: + session.commit() + session.refresh(batch) + success_url = self.get_action_url('view', batch) + session.close() + if progress: + progress.session.load() + progress.session['complete'] = True + progress.session['success_url'] = success_url + progress.session.save() + def configure_get_simple_settings(self): config = self.rattail_config return [ @@ -1935,6 +1855,12 @@ class ReceivingBatchView(PurchasingBatchView): 'type': bool}, # vendors + {'section': 'rattail.batch', + 'option': 'purchase.allow_receiving_any_vendor', + 'type': bool}, + # TODO: deprecated; can remove this once all live config + # is updated. but for now it remains so this setting is + # auto-deleted {'section': 'rattail.batch', 'option': 'purchase.supported_vendors_only', 'type': bool}, @@ -1951,6 +1877,9 @@ class ReceivingBatchView(PurchasingBatchView): {'section': 'rattail.batch', 'option': 'purchase.allow_cases', 'type': bool}, + {'section': 'rattail.batch', + 'option': 'purchase.allow_decimal_quantities', + 'type': bool}, {'section': 'rattail.batch', 'option': 'purchase.allow_expired_credits', 'type': bool}, @@ -1982,6 +1911,7 @@ class ReceivingBatchView(PurchasingBatchView): @classmethod def defaults(cls, config): cls._receiving_defaults(config) + cls._purchase_batch_defaults(config) cls._batch_defaults(config) cls._defaults(config) @@ -1989,17 +1919,11 @@ class ReceivingBatchView(PurchasingBatchView): def _receiving_defaults(cls, config): rattail_config = config.registry.settings.get('rattail_config') route_prefix = cls.get_route_prefix() - url_prefix = cls.get_url_prefix() instance_url_prefix = cls.get_instance_url_prefix() model_key = cls.get_model_key() model_title = cls.get_model_title() permission_prefix = cls.get_permission_prefix() - # new receiving batch using workflow X - config.add_route('{}.create_workflow'.format(route_prefix), '{}/new/{{workflow_key}}/{{vendor_uuid}}'.format(url_prefix)) - config.add_view(cls, attr='create', route_name='{}.create_workflow'.format(route_prefix), - permission='{}.create'.format(permission_prefix)) - # row-level receiving config.add_route('{}.receive_row'.format(route_prefix), '{}/rows/{{row_uuid}}/receive'.format(instance_url_prefix)) config.add_view(cls, attr='receive_row', route_name='{}.receive_row'.format(route_prefix), @@ -2034,6 +1958,14 @@ class ReceivingBatchView(PurchasingBatchView): config.add_view(cls, attr='transform_unit_row', route_name='{}.transform_unit_row'.format(route_prefix), permission='{}.edit_row'.format(permission_prefix), renderer='json') + # confirm all costs + config.add_route(f'{route_prefix}.confirm_all_costs', + f'{instance_url_prefix}/confirm-all-costs', + request_method='POST') + config.add_view(cls, attr='confirm_all_costs', + route_name=f'{route_prefix}.confirm_all_costs', + permission=f'{permission_prefix}.edit_row') + # auto-receive all items config.add_tailbone_permission(permission_prefix, '{}.auto_receive'.format(permission_prefix), @@ -2044,33 +1976,6 @@ class ReceivingBatchView(PurchasingBatchView): permission='{}.auto_receive'.format(permission_prefix)) -@colander.deferred -def valid_workflow(node, kw): - """ - Deferred validator for ``workflow`` field, for new batches. - """ - valid_workflows = kw['valid_workflows'] - - def validate(node, value): - # we just need to provide possible values, and let stock validator - # handle the rest - oneof = colander.OneOf(valid_workflows) - return oneof(node, value) - - return validate - - -class NewReceivingBatch(colander.Schema): - """ - Schema for choosing which "type" of new receiving batch should be created. - """ - vendor = colander.SchemaNode(colander.String(), - label="Vendor") - - workflow = colander.SchemaNode(colander.String(), - validator=valid_workflow) - - class ReceiveRowForm(colander.MappingSchema): mode = colander.SchemaNode(colander.String(), diff --git a/tailbone/views/reports.py b/tailbone/views/reports.py index 9bf30a88..099224be 100644 --- a/tailbone/views/reports.py +++ b/tailbone/views/reports.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. # @@ -32,9 +32,8 @@ import logging from collections import OrderedDict import rattail -from rattail.db import model, Session as RattailSession +from rattail.db.model import ReportOutput from rattail.files import resource_path -from rattail.time import localtime from rattail.threads import Thread from rattail.util import simple_error @@ -81,6 +80,7 @@ class OrderingWorksheet(View): upc_getter = staticmethod(get_upc) def __call__(self): + model = self.model if self.request.params.get('vendor'): vendor = Session.get(model.Vendor, self.request.params['vendor']) if vendor: @@ -104,7 +104,8 @@ class OrderingWorksheet(View): """ Rendering engine for the ordering worksheet report. """ - + app = self.get_rattail_app() + model = self.model q = Session.query(model.ProductCost) q = q.join(model.Product) q = q.filter(model.Product.deleted == False) @@ -127,7 +128,7 @@ class OrderingWorksheet(View): key = '{0} {1}'.format(brand, product.description) return key - now = localtime(self.request.rattail_config) + now = app.localtime() data = dict( vendor=vendor, costs=costs, @@ -157,7 +158,7 @@ class InventoryWorksheet(View): """ This is the "Inventory Worksheet" report. """ - + model = self.model departments = Session.query(model.Department) if self.request.params.get('department'): @@ -178,6 +179,8 @@ class InventoryWorksheet(View): """ Generates the Inventory Worksheet report. """ + app = self.get_rattail_app() + model = self.model def get_products(subdepartment): q = Session.query(model.Product) @@ -191,7 +194,7 @@ class InventoryWorksheet(View): q = q.order_by(model.Brand.name, model.Product.description) return q.all() - now = localtime(self.request.rattail_config) + now = app.localtime() data = dict( date=now.strftime('%a %d %b %Y'), time=now.strftime('%I:%M %p'), @@ -209,7 +212,7 @@ class ReportOutputView(ExportMasterView): """ Master view for report output """ - model_class = model.ReportOutput + model_class = ReportOutput route_prefix = 'report_output' url_prefix = '/reports/generated' creatable = True @@ -238,7 +241,7 @@ class ReportOutputView(ExportMasterView): ] def __init__(self, request): - super(ReportOutputView, self).__init__(request) + super().__init__(request) self.report_handler = self.get_report_handler() def get_report_handler(self): @@ -246,7 +249,7 @@ class ReportOutputView(ExportMasterView): return app.get_report_handler() def configure_grid(self, g): - super(ReportOutputView, self).configure_grid(g) + super().configure_grid(g) g.filters['report_name'].default_active = True g.filters['report_name'].default_verb = 'contains' @@ -254,7 +257,7 @@ class ReportOutputView(ExportMasterView): g.set_link('filename') def configure_form(self, f): - super(ReportOutputView, self).configure_form(f) + super().configure_form(f) # report_type f.set_renderer('report_type', self.render_report_type) @@ -282,10 +285,10 @@ class ReportOutputView(ExportMasterView): # add help button if report has a link report = self.report_handler.get_report(type_key) if report and report.help_url: - button = self.make_buefy_button("Help for this report", - url=report.help_url, - is_external=True, - icon_left='question-circle') + button = self.make_button("Help for this report", + url=report.help_url, + is_external=True, + icon_left='question-circle') button = HTML.tag('div', class_='level-item', c=[button]) rendered = HTML.tag('div', class_='level-item', c=[rendered]) rendered = HTML.tag('div', class_='level-left', c=[rendered, button]) @@ -305,13 +308,14 @@ class ReportOutputView(ExportMasterView): route_prefix = self.get_route_prefix() factory = self.get_grid_factory() g = factory( - key='{}.params'.format(route_prefix), + self.request, + key=f'{route_prefix}.params', data=params, columns=['key', 'value'], labels={'key': "Name"}, ) return HTML.literal( - g.render_buefy_table_element(data_prop='paramsData')) + g.render_table_element(data_prop='paramsData')) def get_params_context(self, report): params_data = [] @@ -323,7 +327,7 @@ class ReportOutputView(ExportMasterView): return params_data def template_kwargs_view(self, **kwargs): - kwargs = super(ReportOutputView, self).template_kwargs_view(**kwargs) + kwargs = super().template_kwargs_view(**kwargs) output = kwargs['instance'] kwargs['params_data'] = self.get_params_context(output) @@ -339,7 +343,7 @@ class ReportOutputView(ExportMasterView): return kwargs def template_kwargs_delete(self, **kwargs): - kwargs = super(ReportOutputView, self).template_kwargs_delete(**kwargs) + kwargs = super().template_kwargs_delete(**kwargs) report = kwargs['instance'] kwargs['params_data'] = self.get_params_context(report) @@ -496,7 +500,9 @@ class ReportOutputView(ExportMasterView): resulting :class:`rattail:~rattail.db.model.reports.ReportOutput` object. """ - session = RattailSession() + app = self.get_rattail_app() + model = self.model + session = app.make_session() user = session.get(model.User, user_uuid) try: output = self.report_handler.generate_output(session, report, params, user, progress=progress) @@ -603,7 +609,7 @@ class ProblemReportView(MasterView): ] def __init__(self, request): - super(ProblemReportView, self).__init__(request) + super().__init__(request) app = self.get_rattail_app() self.problem_handler = app.get_problem_report_handler() @@ -660,7 +666,7 @@ class ProblemReportView(MasterView): return ProblemReportSchema() def configure_form(self, f): - super(ProblemReportView, self).configure_form(f) + super().configure_form(f) # email_* if self.editing: @@ -700,13 +706,16 @@ class ProblemReportView(MasterView): return ', '.join(recips) def render_days(self, report_info, field): - g = self.get_grid_factory()('days', [], - columns=['weekday_name', 'enabled'], - labels={'weekday_name': "Weekday"}) - return HTML.literal(g.render_buefy_table_element(data_prop='weekdaysData')) + factory = self.get_grid_factory() + g = factory(self.request, + key='days', + data=[], + columns=['weekday_name', 'enabled'], + labels={'weekday_name': "Weekday"}) + return HTML.literal(g.render_table_element(data_prop='weekdaysData')) def template_kwargs_view(self, **kwargs): - kwargs = super(ProblemReportView, self).template_kwargs_view(**kwargs) + kwargs = super().template_kwargs_view(**kwargs) report_info = kwargs['instance'] data = [] diff --git a/tailbone/views/roles.py b/tailbone/views/roles.py index 2be47415..e8a6d8a2 100644 --- a/tailbone/views/roles.py +++ b/tailbone/views/roles.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. # @@ -29,8 +29,7 @@ import os from sqlalchemy import orm from openpyxl.styles import Font, PatternFill -from rattail.db import model -from rattail.db.auth import administrator_role, guest_role, authenticated_role +from rattail.db.model import Role from rattail.excel import ExcelWriter import colander @@ -46,7 +45,7 @@ class RoleView(PrincipalMasterView): """ Master view for the Role model. """ - model_class = model.Role + model_class = Role has_versions = True touchable = True @@ -77,7 +76,7 @@ class RoleView(PrincipalMasterView): ] def configure_grid(self, g): - super(RoleView, self).configure_grid(g) + super().configure_grid(g) # name g.filters['name'].default_active = True @@ -107,8 +106,11 @@ class RoleView(PrincipalMasterView): if role.node_type and role.node_type != self.rattail_config.node_type(): return False + app = self.get_rattail_app() + auth = app.get_auth_handler() + # only "root" can edit Administrator - if role is administrator_role(self.Session()): + if role is auth.get_role_administrator(self.Session()): return self.request.is_root # only "admin" can edit "admin-ish" roles @@ -116,11 +118,11 @@ class RoleView(PrincipalMasterView): return self.request.is_admin # can edit Authenticated only if user has permission - if role is authenticated_role(self.Session()): + if role is auth.get_role_authenticated(self.Session()): return self.has_perm('edit_authenticated') # can edit Guest only if user has permission - if role is guest_role(self.Session()): + if role is auth.get_role_anonymous(self.Session()): return self.has_perm('edit_guest') # current user can edit their own roles, only if they have permission @@ -139,11 +141,14 @@ class RoleView(PrincipalMasterView): if role.node_type and role.node_type != self.rattail_config.node_type(): return False - if role is administrator_role(self.Session()): + app = self.get_rattail_app() + auth = app.get_auth_handler() + + if role is auth.get_role_administrator(self.Session()): return False - if role is authenticated_role(self.Session()): + if role is auth.get_role_authenticated(self.Session()): return False - if role is guest_role(self.Session()): + if role is auth.get_role_anonymous(self.Session()): return False # only "admin" can delete "admin-ish" roles @@ -158,6 +163,7 @@ class RoleView(PrincipalMasterView): return True def unique_name(self, node, value): + model = self.model query = self.Session.query(model.Role)\ .filter(model.Role.name == value) if self.editing: @@ -167,7 +173,7 @@ class RoleView(PrincipalMasterView): raise colander.Invalid(node, "Name must be unique") def configure_form(self, f): - super(RoleView, self).configure_form(f) + super().configure_form(f) role = f.model_instance app = self.get_rattail_app() auth = app.get_auth_handler() @@ -185,17 +191,17 @@ class RoleView(PrincipalMasterView): # session_timeout f.set_renderer('session_timeout', self.render_session_timeout) - if self.editing and role is guest_role(self.Session()): + if self.editing and role is auth.get_role_anonymous(self.Session()): f.set_readonly('session_timeout') # sync_me, node_type if not self.creating: include = True - if role is administrator_role(self.Session()): + if role is auth.get_role_administrator(self.Session()): include = False - elif role is authenticated_role(self.Session()): + elif role is auth.get_role_authenticated(self.Session()): include = False - elif role is guest_role(self.Session()): + elif role is auth.get_role_anonymous(self.Session()): include = False if not include: f.remove('sync_me', 'sync_users', 'node_type') @@ -226,7 +232,7 @@ class RoleView(PrincipalMasterView): for groupkey in self.tailbone_permissions: for key in self.tailbone_permissions[groupkey]['perms']: if auth.has_permission(self.Session(), role, key, - include_guest=False, + include_anonymous=False, include_authenticated=False): granted.append(key) f.set_default('permissions', granted) @@ -234,12 +240,14 @@ class RoleView(PrincipalMasterView): f.remove_field('permissions') def render_users(self, role, field): + app = self.get_rattail_app() + auth = app.get_auth_handler() - if role is guest_role(self.Session()): + if role is auth.get_role_anonymous(self.Session()): return ("The guest role is implied for all anonymous users, " "i.e. when not logged in.") - if role is authenticated_role(self.Session()): + if role is auth.get_role_authenticated(self.Session()): return ("The authenticated role is implied for all users, " "but only when logged in.") @@ -247,7 +255,8 @@ class RoleView(PrincipalMasterView): permission_prefix = self.get_permission_prefix() factory = self.get_grid_factory() g = factory( - key='{}.users'.format(route_prefix), + self.request, + key=f'{route_prefix}.users', data=[], columns=[ 'full_name', @@ -260,12 +269,12 @@ class RoleView(PrincipalMasterView): ) if self.request.has_perm('users.view'): - g.main_actions.append(self.make_action('view', icon='eye')) + g.actions.append(self.make_action('view', icon='eye')) if self.request.has_perm('users.edit'): - g.main_actions.append(self.make_action('edit', icon='edit')) + g.actions.append(self.make_action('edit', icon='edit')) return HTML.literal( - g.render_buefy_table_element(data_prop='usersData')) + g.render_table_element(data_prop='usersData')) def get_available_permissions(self): """ @@ -278,8 +287,8 @@ class RoleView(PrincipalMasterView): if the current user is an admin; otherwise it will be the "subset" of permissions which the current user has been granted. """ - # fetch full set of permissions registered in the app - permissions = self.request.registry.settings.get('tailbone_permissions', {}) + # get all known permissions from settings cache + permissions = self.request.registry.settings.get('wutta_permissions', {}) # admin user gets to manage all permissions if self.request.is_admin: @@ -306,7 +315,9 @@ class RoleView(PrincipalMasterView): return available def render_session_timeout(self, role, field): - if role is guest_role(self.Session()): + app = self.get_rattail_app() + auth = app.get_auth_handler() + if role is auth.get_role_anonymous(self.Session()): return "(not applicable)" if role.session_timeout is None: return "" @@ -322,7 +333,7 @@ class RoleView(PrincipalMasterView): """ if data is None: data = form.validated - role = super(RoleView, self).objectify(form, data) + role = super().objectify(form, data) self.update_permissions(role, data['permissions']) return role @@ -345,22 +356,26 @@ class RoleView(PrincipalMasterView): auth.revoke_permission(role, pkey) def template_kwargs_view(self, **kwargs): + app = self.get_rattail_app() + auth = app.get_auth_handler() + model = self.model role = kwargs['instance'] if role.users: users = sorted(role.users, key=lambda u: u.username) actions = [ - grids.GridAction('view', icon='zoomin', + self.make_action('view', icon='zoomin', url=lambda r, i: self.request.route_url('users.view', uuid=r.uuid)) ] - kwargs['users'] = grids.Grid(None, users, ['username', 'active'], - request=self.request, + kwargs['users'] = grids.Grid(self.request, + data=users, + columns=['username', 'active'], model_class=model.User, - main_actions=actions) + actions=actions) else: kwargs['users'] = None - kwargs['guest_role'] = guest_role(self.Session()) - kwargs['authenticated_role'] = authenticated_role(self.Session()) + kwargs['guest_role'] = auth.get_role_anonymous(self.Session()) + kwargs['authenticated_role'] = auth.get_role_authenticated(self.Session()) role = kwargs['instance'] if role not in (kwargs['guest_role'], kwargs['authenticated_role']): @@ -381,15 +396,18 @@ class RoleView(PrincipalMasterView): return kwargs def before_delete(self, role): - admin = administrator_role(self.Session()) - guest = guest_role(self.Session()) - authenticated = authenticated_role(self.Session()) + app = self.get_rattail_app() + auth = app.get_auth_handler() + admin = auth.get_role_administrator(self.Session()) + guest = auth.get_role_anonymous(self.Session()) + authenticated = auth.get_role_authenticated(self.Session()) if role in (admin, guest, authenticated): self.request.session.flash("You may not delete the {} role.".format(role.name), 'error') return self.redirect(self.request.get_referrer(default=self.request.route_url('roles'))) def find_principals_with_permission(self, session, permission): app = self.get_rattail_app() + model = self.model auth = app.get_auth_handler() # TODO: this should search Permission table instead, and work backward to Role? @@ -398,16 +416,28 @@ class RoleView(PrincipalMasterView): .options(orm.joinedload(model.Role._permissions)) roles = [] for role in all_roles: - if auth.has_permission(session, role, permission, include_guest=False): + if auth.has_permission(session, role, permission, include_anonymous=False): roles.append(role) return roles + def find_by_perm_configure_results_grid(self, g): + g.append('name') + g.set_link('name') + + def find_by_perm_normalize(self, role): + data = super().find_by_perm_normalize(role) + + data['name'] = role.name + + return data + def download_permissions_matrix(self): """ View which renders the complete role / permissions matrix data into an Excel spreadsheet, and returns that file. """ app = self.get_rattail_app() + model = self.model auth = app.get_auth_handler() roles = self.Session.query(model.Role)\ @@ -459,7 +489,7 @@ class RoleView(PrincipalMasterView): # and show an 'X' for any role which has this perm for col, role in enumerate(roles, 2): if auth.has_permission(self.Session(), role, key, - include_guest=False): + include_anonymous=False): sheet.cell(row=writing_row, column=col, value="X") writing_row += 1 diff --git a/tailbone/views/settings.py b/tailbone/views/settings.py index 47cca0c5..10a0c2eb 100644 --- a/tailbone/views/settings.py +++ b/tailbone/views/settings.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,178 +24,174 @@ Settings Views """ -import os -import re -import subprocess -import sys -from collections import OrderedDict - import json - -from rattail.db import model -from rattail.settings import Setting -from rattail.util import import_module_path +import re import colander -from tailbone import forms +from rattail.db.model import Setting +from rattail.settings import Setting as AppSetting +from rattail.util import import_module_path + +from tailbone import forms, grids from tailbone.db import Session from tailbone.views import MasterView, View -from tailbone.util import get_libver, get_liburl +from wuttaweb.util import get_libver, get_liburl +from wuttaweb.views.settings import AppInfoView as WuttaAppInfoView -class AppInfoView(MasterView): - """ - Master view for the overall app, to show/edit config etc. - """ - route_prefix = 'appinfo' - model_key = 'UNUSED' - model_title = "UNUSED" - model_title_plural = "App Details" - creatable = False - viewable = False - editable = False - deletable = False - filterable = False - pageable = False - configurable = True +class AppInfoView(WuttaAppInfoView): + """ """ + Session = Session + weblib_config_prefix = 'tailbone' - grid_columns = [ - 'name', - 'version', - 'editable_project_location', - ] - - def get_index_title(self): - return "{} for {}".format(self.get_model_title_plural(), - self.rattail_config.app_title()) - - def get_data(self, session=None): - pip = os.path.join(sys.prefix, 'bin', 'pip') - output = subprocess.check_output([pip, 'list', '--format=json']) - data = json.loads(output.decode('utf_8').strip()) - - for pkg in data: - pkg.setdefault('editable_project_location', '') - - return data + # TODO: for now we override to get tailbone searchable grid + def make_grid(self, **kwargs): + """ """ + return grids.Grid(self.request, **kwargs) def configure_grid(self, g): - super(AppInfoView, self).configure_grid(g) + """ """ + super().configure_grid(g) - g.sorters['name'] = g.make_simple_sorter('name', foldcase=True) - g.set_sort_defaults('name') + # name g.set_searchable('name') - g.sorters['version'] = g.make_simple_sorter('version', foldcase=True) - - g.sorters['editable_project_location'] = g.make_simple_sorter( - 'editable_project_location', foldcase=True) + # editable_project_location g.set_searchable('editable_project_location') - def template_kwargs_index(self, **kwargs): - kwargs = super(AppInfoView, self).template_kwargs_index(**kwargs) - kwargs['configure_button_title'] = "Configure App" - return kwargs - def configure_get_context(self, **kwargs): - context = super(AppInfoView, self).configure_get_context(**kwargs) + """ """ + context = super().configure_get_context(**kwargs) + simple_settings = context['simple_settings'] + weblibs = context['weblibs'] - weblibs = OrderedDict([ - ('vue', "Vue"), - ('vue_resource', "vue-resource"), - ('buefy', "Buefy"), - ('buefy.css', "Buefy CSS"), - ('fontawesome', "FontAwesome"), - ]) + for weblib in weblibs: + key = weblib['key'] - for key in weblibs: - title = weblibs[key] - weblibs[key] = { - 'key': key, - 'title': title, + # TODO: this is only needed to migrate legacy settings to + # use the newer wuttaweb setting names + url = simple_settings[f'wuttaweb.liburl.{key}'] + if not url and weblib['configured_url']: + simple_settings[f'wuttaweb.liburl.{key}'] = weblib['configured_url'] - # nb. these values are exactly as configured, and are - # used for editing the settings - 'configured_version': get_libver(self.request, key, fallback=False), - 'configured_url': get_liburl(self.request, key, fallback=False), - - # these are for informational purposes only - 'default_version': get_libver(self.request, key, default_only=True), - 'live_url': get_liburl(self.request, key), - } - - context['weblibs'] = list(weblibs.values()) return context + # nb. these email settings require special handling below + configure_profile_key_mismatches = [ + 'default.subject', + 'default.to', + 'default.cc', + 'default.bcc', + 'feedback.subject', + 'feedback.to', + ] + def configure_get_simple_settings(self): - return [ + """ """ + simple_settings = super().configure_get_simple_settings() - # basics - {'section': 'rattail', - 'option': 'app_title'}, - {'section': 'rattail', - 'option': 'node_type'}, - {'section': 'rattail', - 'option': 'node_title'}, - {'section': 'rattail', - 'option': 'production', - 'type': bool}, - {'section': 'rattail', - 'option': 'running_from_source', - 'type': bool}, - {'section': 'rattail', - 'option': 'running_from_source.rootpkg'}, + # TODO: + # there are several email config keys which differ between + # wuttjamaican and rattail. basically all of the "profile" keys + # have a different prefix. - # display - {'section': 'tailbone', - 'option': 'background_color'}, + # after wuttaweb has declared its settings, we examine each and + # overwrite the value if one is defined with rattail config key. + # (nb. this happens even if wuttjamaican key has a value!) - # grids - {'section': 'tailbone', - 'option': 'grid.default_pagesize', - # TODO: seems like should enforce this, but validation is - # not setup yet - # 'type': int - }, + # note that we *do* declare the profile mismatch keys for + # rattail, as part of simple settings. this ensures the + # parent logic will always remove them when saving. however + # we must also include them in gather_settings() to ensure + # they are saved to match wuttjamaican values. - # web libs - {'section': 'tailbone', - 'option': 'libver.vue'}, - {'section': 'tailbone', - 'option': 'liburl.vue'}, - {'section': 'tailbone', - 'option': 'libver.vue_resource'}, - {'section': 'tailbone', - 'option': 'liburl.vue_resource'}, - {'section': 'tailbone', - 'option': 'libver.buefy'}, - {'section': 'tailbone', - 'option': 'liburl.buefy'}, - {'section': 'tailbone', - 'option': 'libver.buefy.css'}, - {'section': 'tailbone', - 'option': 'liburl.buefy.css'}, - {'section': 'tailbone', - 'option': 'libver.fontawesome'}, - {'section': 'tailbone', - 'option': 'liburl.fontawesome'}, + # there are also a couple of flags where rattail's default is the + # opposite of wuttjamaican. so we overwrite those too as needed. - # nb. these are no longer used (deprecated), but we keep - # them defined here so the tool auto-deletes them - {'section': 'tailbone', - 'option': 'buefy_version'}, - {'section': 'tailbone', - 'option': 'vue_version'}, + for setting in simple_settings: - ] + # nb. the update home page redirect setting is off by + # default for wuttaweb, but on for tailbone + if setting['name'] == 'wuttaweb.home_redirect_to_login': + value = self.config.get_bool('wuttaweb.home_redirect_to_login') + if value is None: + value = self.config.get_bool('tailbone.login_is_home', default=True) + setting['value'] = value + + # nb. sending email is off by default for wuttjamaican, + # but on for rattail + elif setting['name'] == 'rattail.mail.send_emails': + value = self.config.get_bool('rattail.mail.send_emails', default=True) + setting['value'] = value + + # nb. this one is even more special, key is entirely different + elif setting['name'] == 'rattail.email.default.sender': + value = self.config.get('rattail.email.default.sender') + if value is None: + value = self.config.get('rattail.mail.default.from') + setting['value'] = value + + else: + + # nb. fetch alternate value for profile key mismatch + for key in self.configure_profile_key_mismatches: + if setting['name'] == f'rattail.email.{key}': + value = self.config.get(f'rattail.email.{key}') + if value is None: + value = self.config.get(f'rattail.mail.{key}') + setting['value'] = value + break + + # nb. these are no longer used (deprecated), but we keep + # them defined here so the tool auto-deletes them + + simple_settings.extend([ + {'name': 'tailbone.login_is_home'}, + {'name': 'tailbone.buefy_version'}, + {'name': 'tailbone.vue_version'}, + ]) + + simple_settings.append({'name': 'rattail.mail.default.from'}) + for key in self.configure_profile_key_mismatches: + simple_settings.append({'name': f'rattail.mail.{key}'}) + + for key in self.get_weblibs(): + simple_settings.extend([ + {'name': f'tailbone.libver.{key}'}, + {'name': f'tailbone.liburl.{key}'}, + ]) + + return simple_settings + + def configure_gather_settings(self, data, simple_settings=None): + """ """ + settings = super().configure_gather_settings(data, simple_settings=simple_settings) + + # nb. must add legacy rattail profile settings to match new ones + for setting in list(settings): + + if setting['name'] == 'rattail.email.default.sender': + value = setting['value'] + settings.append({'name': 'rattail.mail.default.from', + 'value': value}) + + else: + for key in self.configure_profile_key_mismatches: + if setting['name'] == f'rattail.email.{key}': + value = setting['value'] + settings.append({'name': f'rattail.mail.{key}', + 'value': value}) + break + + return settings class SettingView(MasterView): """ Master view for the settings model. """ - model_class = model.Setting + model_class = Setting model_title = "Raw Setting" model_title_plural = "Raw Settings" bulk_deletable = True @@ -207,18 +203,19 @@ class SettingView(MasterView): ] def configure_grid(self, g): - super(SettingView, self).configure_grid(g) + super().configure_grid(g) g.filters['name'].default_active = True g.filters['name'].default_verb = 'contains' g.set_sort_defaults('name') g.set_link('name') def configure_form(self, f): - super(SettingView, self).configure_form(f) + super().configure_form(f) if self.creating: f.set_validator('name', self.unique_name) def unique_name(self, node, value): + model = self.model setting = self.Session.get(model.Setting, value) if setting: raise colander.Invalid(node, "Setting name must be unique") @@ -245,7 +242,7 @@ class SettingView(MasterView): self.rattail_config.beaker_invalidate_setting(setting.name) # otherwise delete like normal - super(SettingView, self).delete_instance(setting) + super().delete_instance(setting) # TODO: deprecate / remove this @@ -307,14 +304,14 @@ class AppSettingsView(View): 'settings': settings, 'config_options': config_options, } - context['buefy_data'] = self.get_buefy_data(form, groups, settings) + context['settings_data'] = self.get_settings_data(form, groups, settings) # TODO: this seems hacky, and probably only needed if theme changes? if current_group == '(All)': current_group = '' context['current_group'] = current_group return context - def get_buefy_data(self, form, groups, settings): + def get_settings_data(self, form, groups, settings): dform = form.make_deform_form() grouped = dict([(label, []) for label in groups]) @@ -340,8 +337,7 @@ class AppSettingsView(View): # specify error / message if applicable # TODO: not entirely clear to me why some field errors are - # represented differently? presumably it depends on - # whether Buefy is used by the theme. + # represented differently? if field.error: s['error'] = True if isinstance(field.error, colander.Invalid): @@ -407,7 +403,7 @@ class AppSettingsView(View): module = import_module_path(module) for name in dir(module): obj = getattr(module, name) - if isinstance(obj, type) and issubclass(obj, Setting) and obj is not Setting: + if isinstance(obj, type) and issubclass(obj, AppSetting) and obj is not AppSetting: if core_only and not obj.core: continue # NOTE: we set this here, and reference it elsewhere diff --git a/tailbone/views/shifts/lib.py b/tailbone/views/shifts/lib.py index 8fc58264..1827bee0 100644 --- a/tailbone/views/shifts/lib.py +++ b/tailbone/views/shifts/lib.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,9 +28,8 @@ import datetime import sqlalchemy as sa -from rattail import enum -from rattail.db import model, api -from rattail.time import localtime, make_utc, get_sunday +from rattail.db import api +from rattail.time import get_sunday from rattail.util import hours_as_decimal import colander @@ -83,6 +82,8 @@ class TimeSheetView(View): """ Determine date/store/dept context from user's session and/or defaults. """ + app = self.get_rattail_app() + model = self.model date = None date_key = 'timesheet.{}.date'.format(self.key) if date_key in self.request.session: @@ -93,7 +94,7 @@ class TimeSheetView(View): except ValueError: pass if not date: - date = localtime(self.rattail_config).date() + date = app.today() store = None department = None @@ -113,7 +114,7 @@ class TimeSheetView(View): store = api.get_store(Session(), store) employees = Session.query(model.Employee)\ - .filter(model.Employee.status == enum.EMPLOYEE_STATUS_CURRENT) + .filter(model.Employee.status == self.enum.EMPLOYEE_STATUS_CURRENT) if store: employees = employees.join(model.EmployeeStore)\ .filter(model.EmployeeStore.store == store) @@ -132,6 +133,8 @@ class TimeSheetView(View): """ Determine employee/date context from user's session and/or defaults """ + app = self.get_rattail_app() + model = self.model date = None date_key = 'timesheet.{}.employee.date'.format(self.key) if date_key in self.request.session: @@ -142,7 +145,7 @@ class TimeSheetView(View): except ValueError: pass if not date: - date = localtime(self.rattail_config).date() + date = app.today() employee = None employee_key = 'timesheet.{}.employee'.format(self.key) @@ -191,7 +194,7 @@ class TimeSheetView(View): stores = self.get_stores() store_values = [(s.uuid, "{} - {}".format(s.id, s.name)) for s in stores] store_values.insert(0, ('', "(all)")) - form.set_widget('store', forms.widgets.PlainSelectWidget(values=store_values)) + form.set_widget('store', dfwidget.SelectWidget(values=store_values)) if context['store']: form.set_default('store', context['store'].uuid) else: @@ -203,7 +206,7 @@ class TimeSheetView(View): departments = self.get_departments() department_values = [(d.uuid, d.name) for d in departments] department_values.insert(0, ('', "(all)")) - form.set_widget('department', forms.widgets.PlainSelectWidget(values=department_values)) + form.set_widget('department', dfwidget.SelectWidget(values=department_values)) if context['department']: form.set_default('department', context['department'].uuid) else: @@ -292,6 +295,7 @@ class TimeSheetView(View): self.request.session['timesheet.{}.{}'.format(mainkey, key)] = value def get_stores(self): + model = self.model return Session.query(model.Store).order_by(model.Store.id).all() def get_store_options(self, stores): @@ -299,6 +303,7 @@ class TimeSheetView(View): return tags.Options(options, prompt="(all)") def get_departments(self): + model = self.model return Session.query(model.Department).order_by(model.Department.name).all() def get_department_options(self, departments): @@ -402,6 +407,7 @@ class TimeSheetView(View): the given params. The cached shift data is attached to each employee. """ app = self.get_rattail_app() + model = self.model # TODO: a bit hacky, this? display hours as HH:MM by default, but # check config in order to display as HH.HH for certain users @@ -413,19 +419,19 @@ class TimeSheetView(View): hours_style = 'pretty' shift_type = 'scheduled' if cls is model.ScheduledShift else 'worked' - min_time = localtime(self.rattail_config, datetime.datetime.combine(weekdays[0], datetime.time(0))) - max_time = localtime(self.rattail_config, datetime.datetime.combine(weekdays[-1] + datetime.timedelta(days=1), datetime.time(0))) + min_time = app.localtime(datetime.datetime.combine(weekdays[0], datetime.time(0))) + max_time = app.localtime(datetime.datetime.combine(weekdays[-1] + datetime.timedelta(days=1), datetime.time(0))) shifts = Session.query(cls)\ .filter(cls.employee_uuid.in_([e.uuid for e in employees]))\ .filter(sa.or_( sa.and_( - cls.start_time >= make_utc(min_time), - cls.start_time < make_utc(max_time), + cls.start_time >= app.make_utc(min_time), + cls.start_time < app.make_utc(max_time), ), sa.and_( cls.start_time == None, - cls.end_time >= make_utc(min_time), - cls.end_time < make_utc(max_time), + cls.end_time >= app.make_utc(min_time), + cls.end_time < app.make_utc(max_time), )))\ .all() diff --git a/tailbone/views/tables.py b/tailbone/views/tables.py index 962dbf50..bfd52f2b 100644 --- a/tailbone/views/tables.py +++ b/tailbone/views/tables.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. # @@ -80,7 +80,7 @@ class TableView(MasterView): ] def __init__(self, request): - super(TableView, self).__init__(request) + super().__init__(request) app = self.get_rattail_app() self.db_handler = app.get_db_handler() @@ -102,7 +102,7 @@ class TableView(MasterView): for row in result] def configure_grid(self, g): - super(TableView, self).configure_grid(g) + super().configure_grid(g) # table_name g.sorters['table_name'] = g.make_simple_sorter('table_name', foldcase=True) @@ -114,7 +114,7 @@ class TableView(MasterView): g.sorters['row_count'] = g.make_simple_sorter('row_count') def configure_form(self, f): - super(TableView, self).configure_form(f) + super().configure_form(f) # TODO: should render this instead, by inspecting table if not self.creating: @@ -169,7 +169,7 @@ class TableView(MasterView): return TableSchema() def get_xref_buttons(self, table): - buttons = super(TableView, self).get_xref_buttons(table) + buttons = super().get_xref_buttons(table) if table.get('model_name'): all_views = self.request.registry.settings['tailbone_model_views'] @@ -182,15 +182,15 @@ class TableView(MasterView): if self.request.has_perm('model_views.create'): url = self.request.route_url('model_views.create', _query={'model_name': table['model_name']}) - buttons.append(self.make_buefy_button("New View", - is_primary=True, - url=url, - icon_left='plus')) + buttons.append(self.make_button("New View", + is_primary=True, + url=url, + icon_left='plus')) return buttons def template_kwargs_create(self, **kwargs): - kwargs = super(TableView, self).template_kwargs_create(**kwargs) + kwargs = super().template_kwargs_create(**kwargs) app = self.get_rattail_app() model = self.model @@ -301,7 +301,7 @@ class TableView(MasterView): return data def configure_row_grid(self, g): - super(TableView, self).configure_row_grid(g) + super().configure_row_grid(g) g.sorters['sequence'] = g.make_simple_sorter('sequence') g.set_sort_defaults('sequence') @@ -419,7 +419,7 @@ class TablesView(TableView): def __init__(self, request): warnings.warn("TablesView is deprecated; please use TableView instead", DeprecationWarning, stacklevel=2) - super(TablesView, self).__init__(request) + super().__init__(request) class TableSchema(colander.Schema): diff --git a/tailbone/views/tempmon/appliances.py b/tailbone/views/tempmon/appliances.py index c523ae78..4ce52009 100644 --- a/tailbone/views/tempmon/appliances.py +++ b/tailbone/views/tempmon/appliances.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,11 +24,9 @@ Views for tempmon appliances """ -from __future__ import unicode_literals, absolute_import - +import io import os -import six from PIL import Image from rattail_tempmon.db import model as tempmon @@ -68,7 +66,7 @@ class TempmonApplianceView(MasterView): ] def configure_grid(self, g): - super(TempmonApplianceView, self).configure_grid(g) + super().configure_grid(g) # name g.set_sort_defaults('name') @@ -94,7 +92,7 @@ class TempmonApplianceView(MasterView): return HTML.tag('div', class_='image-frame', c=[helper, image]) def configure_form(self, f): - super(TempmonApplianceView, self).configure_form(f) + super().configure_form(f) # name f.set_validator('name', self.unique_name) @@ -122,7 +120,7 @@ class TempmonApplianceView(MasterView): f.remove_field('probes') def template_kwargs_view(self, **kwargs): - kwargs = super(TempmonApplianceView, self).template_kwargs_view(**kwargs) + kwargs = super().template_kwargs_view(**kwargs) appliance = kwargs['instance'] kwargs['probes_data'] = self.normalize_probes(appliance.probes) @@ -176,13 +174,13 @@ class TempmonApplianceView(MasterView): im = Image.open(f) im.thumbnail((600, 600), Image.ANTIALIAS) - data = six.BytesIO() + data = io.BytesIO() im.save(data, 'JPEG') appliance.image_normal = data.getvalue() data.close() im.thumbnail((150, 150), Image.ANTIALIAS) - data = six.BytesIO() + data = io.BytesIO() im.save(data, 'JPEG') appliance.image_thumbnail = data.getvalue() data.close() diff --git a/tailbone/views/tempmon/core.py b/tailbone/views/tempmon/core.py index 62ace028..7540abbe 100644 --- a/tailbone/views/tempmon/core.py +++ b/tailbone/views/tempmon/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. # @@ -77,7 +77,8 @@ class MasterView(views.MasterView): factory = self.get_grid_factory() g = factory( - key='{}.probes'.format(route_prefix), + self.request, + key=f'{route_prefix}.probes', data=[], columns=[ 'description', @@ -95,7 +96,7 @@ class MasterView(views.MasterView): 'critical_temp_max': "Crit. Max", }, linked_columns=['description'], - main_actions=actions, + actions=actions, ) return HTML.literal( - g.render_buefy_table_element(data_prop='probesData')) + g.render_table_element(data_prop='probesData')) diff --git a/tailbone/views/trainwreck/base.py b/tailbone/views/trainwreck/base.py index 82c5c163..d5f077aa 100644 --- a/tailbone/views/trainwreck/base.py +++ b/tailbone/views/trainwreck/base.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. # @@ -164,6 +164,18 @@ class TransactionView(MasterView): return TrainwreckSession() + def get_context_menu_items(self, txn=None): + items = super().get_context_menu_items(txn) + route_prefix = self.get_route_prefix() + + if self.listing: + + if self.has_perm('rollover'): + url = self.request.route_url(f'{route_prefix}.rollover') + items.append(tags.link_to("Yearly Rollover", url)) + + return items + def configure_grid(self, g): super().configure_grid(g) app = self.get_rattail_app() @@ -202,7 +214,7 @@ class TransactionView(MasterView): return 'warning' def configure_form(self, f): - super(TransactionView, self).configure_form(f) + super().configure_form(f) # system f.set_enum('system', self.enum.TRAINWRECK_SYSTEM) @@ -234,16 +246,17 @@ class TransactionView(MasterView): factory = self.get_grid_factory() g = factory( - key='{}.custorder_xref_markers'.format(route_prefix), + self.request, + key=f'{route_prefix}.custorder_xref_markers', data=[], - columns=['custorder_xref', 'custorder_item_xref'], - request=self.request) + columns=['custorder_xref', 'custorder_item_xref']) return HTML.literal( - g.render_buefy_table_element(data_prop='custorderXrefMarkersData')) + g.render_table_element(data_prop='custorderXrefMarkersData')) def template_kwargs_view(self, **kwargs): - kwargs = super(TransactionView, self).template_kwargs_view(**kwargs) + kwargs = super().template_kwargs_view(**kwargs) + config = self.rattail_config form = kwargs['form'] if 'custorder_xref_markers' in form: @@ -256,8 +269,32 @@ class TransactionView(MasterView): }) kwargs['custorder_xref_markers_data'] = markers + # collapse header + kwargs['main_form_title'] = "Transaction Header" + kwargs['main_form_collapsible'] = True + kwargs['main_form_autocollapse'] = config.get_bool( + 'tailbone.trainwreck.view_txn.autocollapse_header', + default=False) + return kwargs + def get_xref_buttons(self, txn): + app = self.get_rattail_app() + clientele = app.get_clientele_handler() + buttons = super().get_xref_buttons(txn) + + if txn.customer_id: + customer = clientele.locate_customer_for_key(Session(), txn.customer_id) + if customer: + person = app.get_person(customer) + if person: + url = self.request.route_url('people.view_profile', uuid=person.uuid) + buttons.append(self.make_xref_button(text=str(person), + url=url, + internal=True)) + + return buttons + def get_row_data(self, transaction): return self.Session.query(self.model_row_class)\ .filter(self.model_row_class.transaction == transaction) @@ -266,7 +303,7 @@ class TransactionView(MasterView): return item.transaction def configure_row_grid(self, g): - super(TransactionView, self).configure_row_grid(g) + super().configure_row_grid(g) g.set_sort_defaults('sequence') g.set_type('unit_quantity', 'quantity') @@ -286,7 +323,7 @@ class TransactionView(MasterView): return "Trainwreck Line Item" def configure_row_form(self, f): - super(TransactionView, self).configure_row_form(f) + super().configure_row_form(f) # transaction f.set_renderer('transaction', self.render_transaction) @@ -318,14 +355,14 @@ class TransactionView(MasterView): factory = self.get_grid_factory() g = factory( - key='{}.discounts'.format(route_prefix), + self.request, + key=f'{route_prefix}.discounts', data=[], columns=['discount_type', 'description', 'amount'], - labels={'discount_type': "Type"}, - request=self.request) + labels={'discount_type': "Type"}) return HTML.literal( - g.render_buefy_table_element(data_prop='discountsData')) + g.render_table_element(data_prop='discountsData')) def template_kwargs_view_row(self, **kwargs): form = kwargs['form'] @@ -390,6 +427,11 @@ class TransactionView(MasterView): def configure_get_simple_settings(self): return [ + # display + {'section': 'tailbone', + 'option': 'trainwreck.view_txn.autocollapse_header', + 'type': bool}, + # rotation {'section': 'trainwreck', 'option': 'use_rotation', @@ -401,7 +443,7 @@ class TransactionView(MasterView): ] def configure_get_context(self): - context = super(TransactionView, self).configure_get_context() + context = super().configure_get_context() app = self.get_rattail_app() trainwreck_handler = app.get_trainwreck_handler() @@ -415,7 +457,7 @@ class TransactionView(MasterView): return context def configure_gather_settings(self, data): - settings = super(TransactionView, self).configure_gather_settings(data) + settings = super().configure_gather_settings(data) app = self.get_rattail_app() trainwreck_handler = app.get_trainwreck_handler() @@ -432,7 +474,7 @@ class TransactionView(MasterView): return settings def configure_remove_settings(self): - super(TransactionView, self).configure_remove_settings() + super().configure_remove_settings() app = self.get_rattail_app() names = [ diff --git a/tailbone/views/upgrades.py b/tailbone/views/upgrades.py index f7c83eec..ffa88032 100644 --- a/tailbone/views/upgrades.py +++ b/tailbone/views/upgrades.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,9 +33,7 @@ from collections import OrderedDict import sqlalchemy as sa -from rattail.core import Object -from rattail.db import model, Session as RattailSession -from rattail.time import make_utc +from rattail.db.model import Upgrade from rattail.threads import Thread from deform import widget as dfwidget @@ -53,7 +51,7 @@ class UpgradeView(MasterView): """ Master view for all user events """ - model_class = model.Upgrade + model_class = Upgrade downloadable = True cloneable = True configurable = True @@ -100,7 +98,7 @@ class UpgradeView(MasterView): ] def __init__(self, request): - super(UpgradeView, self).__init__(request) + super().__init__(request) if hasattr(self, 'get_handler'): warnings.warn("defining get_handler() is deprecated. please " @@ -120,7 +118,8 @@ class UpgradeView(MasterView): return self.upgrade_handler def configure_grid(self, g): - super(UpgradeView, self).configure_grid(g) + super().configure_grid(g) + model = self.model # system systems = self.upgrade_handler.get_all_systems() @@ -147,10 +146,12 @@ class UpgradeView(MasterView): return 'notice' def template_kwargs_view(self, **kwargs): - kwargs = super(UpgradeView, self).template_kwargs_view(**kwargs) + kwargs = super().template_kwargs_view(**kwargs) + app = self.get_rattail_app() + model = self.model upgrade = kwargs['instance'] - kwargs['system_title'] = self.rattail_config.app_title() + kwargs['system_title'] = app.get_title() if upgrade.system: system = self.upgrade_handler.get_system(upgrade.system) if system: @@ -177,7 +178,7 @@ class UpgradeView(MasterView): return kwargs def configure_form(self, f): - super(UpgradeView, self).configure_form(f) + super().configure_form(f) upgrade = f.model_instance # system @@ -275,9 +276,10 @@ class UpgradeView(MasterView): f.fields = ['system', 'description', 'notes', 'enabled'] def clone_instance(self, original): + app = self.get_rattail_app() cloned = self.model_class() cloned.system = original.system - cloned.created = make_utc() + cloned.created = app.make_utc() cloned.created_by = self.request.user cloned.description = original.description cloned.notes = original.notes @@ -335,7 +337,6 @@ class UpgradeView(MasterView): return HTML.tag('div', c="(not available for this upgrade)") def get_extra_diff_row_attrs(self, field, attrs): - # note, this is only needed/used with Buefy extra = {} if attrs.get('class') != 'diff': extra['v-show'] = "showingPackages == 'all'" @@ -347,56 +348,27 @@ class UpgradeView(MasterView): commit_hash_pattern = re.compile(r'^.{40}$') def get_changelog_projects(self): - projects = { - 'rattail': { - 'commit_url': 'https://kallithea.rattailproject.org/rattail-project/rattail/changelog/{new_version}/?size=10', - 'release_url': 'https://kallithea.rattailproject.org/rattail-project/rattail/files/v{new_version}/CHANGES.rst', - }, - 'Tailbone': { - 'commit_url': 'https://kallithea.rattailproject.org/rattail-project/tailbone/changelog/{new_version}/?size=10', - 'release_url': 'https://kallithea.rattailproject.org/rattail-project/tailbone/files/v{new_version}/CHANGES.rst', - }, - 'pyCOREPOS': { - 'commit_url': 'https://kallithea.rattailproject.org/rattail-project/pycorepos/changelog/{new_version}/?size=10', - 'release_url': 'https://kallithea.rattailproject.org/rattail-project/pycorepos/files/v{new_version}/CHANGES.rst', - }, - 'rattail_corepos': { - 'commit_url': 'https://kallithea.rattailproject.org/rattail-project/rattail-corepos/changelog/{new_version}/?size=10', - 'release_url': 'https://kallithea.rattailproject.org/rattail-project/rattail-corepos/files/v{new_version}/CHANGES.rst', - }, - 'tailbone_corepos': { - 'commit_url': 'https://kallithea.rattailproject.org/rattail-project/tailbone-corepos/changelog/{new_version}/?size=10', - 'release_url': 'https://kallithea.rattailproject.org/rattail-project/tailbone-corepos/files/v{new_version}/CHANGES.rst', - }, - 'onager': { - 'commit_url': 'https://kallithea.rattailproject.org/rattail-restricted/onager/changelog/{new_version}/?size=10', - 'release_url': 'https://kallithea.rattailproject.org/rattail-restricted/onager/files/v{new_version}/CHANGES.rst', - }, - 'rattail-onager': { - 'commit_url': 'https://kallithea.rattailproject.org/rattail-restricted/rattail-onager/changelog/{new_version}/?size=10', - 'release_url': 'https://kallithea.rattailproject.org/rattail-restricted/rattail-onager/files/v{new_version}/CHANGELOG.md', - }, - 'rattail_tempmon': { - 'commit_url': 'https://kallithea.rattailproject.org/rattail-project/rattail-tempmon/changelog/{new_version}/?size=10', - 'release_url': 'https://kallithea.rattailproject.org/rattail-project/rattail-tempmon/files/v{new_version}/CHANGES.rst', - }, - 'tailbone-onager': { - 'commit_url': 'https://kallithea.rattailproject.org/rattail-restricted/tailbone-onager/changelog/{new_version}/?size=10', - 'release_url': 'https://kallithea.rattailproject.org/rattail-restricted/tailbone-onager/files/v{new_version}/CHANGELOG.md', - }, - 'rattail_woocommerce': { - 'commit_url': 'https://kallithea.rattailproject.org/rattail-project/rattail-woocommerce/changelog/{new_version}/?size=10', - 'release_url': 'https://kallithea.rattailproject.org/rattail-project/rattail-woocommerce/files/v{new_version}/CHANGES.rst', - }, - 'tailbone_woocommerce': { - 'commit_url': 'https://kallithea.rattailproject.org/rattail-project/tailbone-woocommerce/changelog/{new_version}/?size=10', - 'release_url': 'https://kallithea.rattailproject.org/rattail-project/tailbone-woocommerce/files/v{new_version}/CHANGES.rst', - }, - 'tailbone_theo': { - 'commit_url': 'https://kallithea.rattailproject.org/rattail-project/theo/changelog/{new_version}/?size=10', - 'release_url': 'https://kallithea.rattailproject.org/rattail-project/theo/files/v{new_version}/CHANGES.rst', - }, + project_map = { + 'onager': 'onager', + 'pyCOREPOS': 'pycorepos', + 'rattail': 'rattail', + 'rattail_corepos': 'rattail-corepos', + 'rattail-onager': 'rattail-onager', + 'rattail_tempmon': 'rattail-tempmon', + 'rattail_woocommerce': 'rattail-woocommerce', + 'Tailbone': 'tailbone', + 'tailbone_corepos': 'tailbone-corepos', + 'tailbone-onager': 'tailbone-onager', + 'tailbone_theo': 'theo', + 'tailbone_woocommerce': 'tailbone-woocommerce', } + + projects = {} + for name, repo in project_map.items(): + projects[name] = { + 'commit_url': f'https://forgejo.wuttaproject.org/rattail/{repo}/compare/{{old_version}}...{{new_version}}', + 'release_url': f'https://forgejo.wuttaproject.org/rattail/{repo}/src/tag/v{{new_version}}/CHANGELOG.md', + } return projects def get_changelog_url(self, project, old_version, new_version): @@ -449,13 +421,14 @@ class UpgradeView(MasterView): return packages def parse_requirement(self, line): + app = self.get_rattail_app() match = re.match(r'^.*@(.*)#egg=(.*)$', line) if match: - return Object(name=match.group(2), version=match.group(1)) + return app.make_object(name=match.group(2), version=match.group(1)) match = re.match(r'^(.*)==(.*)$', line) if match: - return Object(name=match.group(1), version=match.group(2)) + return app.make_object(name=match.group(1), version=match.group(2)) def download_path(self, upgrade, filename): return self.rattail_config.upgrade_filepath(upgrade.uuid, filename=filename) @@ -537,17 +510,17 @@ class UpgradeView(MasterView): def delete_instance(self, upgrade): self.handler.delete_files(upgrade) - super(UpgradeView, self).delete_instance(upgrade) + super().delete_instance(upgrade) def configure_get_context(self, **kwargs): - context = super(UpgradeView, self).configure_get_context(**kwargs) + context = super().configure_get_context(**kwargs) context['upgrade_systems'] = self.upgrade_handler.get_all_systems() return context def configure_gather_settings(self, data): - settings = super(UpgradeView, self).configure_gather_settings(data) + settings = super().configure_gather_settings(data) keys = [] for system in json.loads(data['upgrade_systems']): @@ -568,7 +541,7 @@ class UpgradeView(MasterView): return settings def configure_remove_settings(self): - super(UpgradeView, self).configure_remove_settings() + super().configure_remove_settings() app = self.get_rattail_app() model = self.model diff --git a/tailbone/views/users.py b/tailbone/views/users.py index 833c6cf5..dfed0a11 100644 --- a/tailbone/views/users.py +++ b/tailbone/views/users.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,8 +28,6 @@ import sqlalchemy as sa from sqlalchemy import orm from rattail.db.model import User, UserEvent -from rattail.db.auth import (administrator_role, guest_role, - authenticated_role, set_user_password) import colander from deform import widget as dfwidget @@ -46,8 +44,6 @@ class UserView(PrincipalMasterView): Master view for the User model. """ model_class = User - has_rows = True - model_row_class = UserEvent has_versions = True touchable = True mergeable = True @@ -78,21 +74,37 @@ class UserView(PrincipalMasterView): 'permissions', ] + has_rows = True + model_row_class = UserEvent + rows_title = "User Events" + rows_viewable = False + row_grid_columns = [ 'type_code', 'occurred', ] def __init__(self, request): - super(UserView, self).__init__(request) + super().__init__(request) app = self.get_rattail_app() # always get a reference to the auth/merge handler self.auth_handler = app.get_auth_handler() self.merge_handler = self.auth_handler + def get_context_menu_items(self, user=None): + items = super().get_context_menu_items(user) + + if self.viewing: + + if self.has_perm('preferences'): + url = self.get_action_url('preferences', user) + items.append(tags.link_to("Edit User Preferences", url)) + + return items + def query(self, session): - query = super(UserView, self).query(session) + query = super().query(session) model = self.model # bring in the related Person(s) @@ -102,7 +114,7 @@ class UserView(PrincipalMasterView): return query def configure_grid(self, g): - super(UserView, self).configure_grid(g) + super().configure_grid(g) model = self.model del g.filters['salt'] @@ -177,7 +189,7 @@ class UserView(PrincipalMasterView): raise colander.Invalid(node, "Person not found (you must *select* a record)") def configure_form(self, f): - super(UserView, self).configure_form(f) + super().configure_form(f) model = self.model user = f.model_instance @@ -198,9 +210,13 @@ class UserView(PrincipalMasterView): person_display = str(person) elif self.editing: person_display = str(user.person or '') - people_url = self.request.route_url('people.autocomplete') - f.set_widget('person_uuid', forms.widgets.JQueryAutocompleteWidget( - field_display=person_display, service_url=people_url)) + try: + people_url = self.request.route_url('people.autocomplete') + except KeyError: + pass # TODO: wutta compat + else: + f.set_widget('person_uuid', forms.widgets.JQueryAutocompleteWidget( + field_display=person_display, service_url=people_url)) f.set_validator('person_uuid', self.valid_person) f.set_label('person_uuid', "Person") @@ -225,7 +241,7 @@ class UserView(PrincipalMasterView): # f.set_required('password') # api_tokens - if self.creating or self.editing: + if self.creating or self.editing or self.deleting: f.remove('api_tokens') elif self.has_perm('manage_api_tokens'): f.set_renderer('api_tokens', self.render_api_tokens) @@ -266,10 +282,10 @@ class UserView(PrincipalMasterView): # fs.confirm_password.attrs(autocomplete='new-password') if self.viewing: - permissions = self.request.registry.settings.get('tailbone_permissions', {}) + permissions = self.request.registry.settings.get('wutta_permissions', {}) f.set_renderer('permissions', PermissionsRenderer(request=self.request, permissions=permissions, - include_guest=True, + include_anonymous=True, include_authenticated=True)) else: f.remove('permissions') @@ -283,19 +299,20 @@ class UserView(PrincipalMasterView): factory = self.get_grid_factory() g = factory( - key='{}.api_tokens'.format(route_prefix), + self.request, + key=f'{route_prefix}.api_tokens', data=[], columns=['description', 'created'], - main_actions=[ + actions=[ self.make_action('delete', icon='trash', click_handler="$emit('api-token-delete', props.row)")]) - button = self.make_buefy_button("New", is_primary=True, - icon_left='plus', - **{'@click': "$emit('api-new-token')"}) + button = self.make_button("New", is_primary=True, + icon_left='plus', + **{'@click': "$emit('api-new-token')"}) table = HTML.literal( - g.render_buefy_table_element(data_prop='apiTokens')) + g.render_table_element(data_prop='apiTokens')) return HTML.tag('div', c=[button, table]) @@ -329,7 +346,7 @@ class UserView(PrincipalMasterView): 'tokens': self.get_api_tokens(user)} def template_kwargs_view(self, **kwargs): - kwargs = super(UserView, self).template_kwargs_view(**kwargs) + kwargs = super().template_kwargs_view(**kwargs) user = kwargs['instance'] kwargs['api_tokens_data'] = self.get_api_tokens(user) @@ -347,17 +364,19 @@ class UserView(PrincipalMasterView): return tokens def get_possible_roles(self): - model = self.model + app = self.get_rattail_app() + auth = app.get_auth_handler() + model = app.model # some roles should never have users "belong" to them excluded = [ - guest_role(self.Session()).uuid, - authenticated_role(self.Session()).uuid, + auth.get_role_anonymous(self.Session()).uuid, + auth.get_role_authenticated(self.Session()).uuid, ] # only allow "root" user to change true admin role membership if not self.request.is_root: - excluded.append(administrator_role(self.Session()).uuid) + excluded.append(auth.get_role_administrator(self.Session()).uuid) # basic list, minus exclusions so far roles = self.Session.query(model.Role)\ @@ -372,12 +391,14 @@ class UserView(PrincipalMasterView): return roles.order_by(model.Role.name) def objectify(self, form, data=None): - model = self.model + app = self.get_rattail_app() + auth = app.get_auth_handler() + model = app.model # create/update user as per normal if data is None: data = form.validated - user = super(UserView, self).objectify(form, data) + user = super().objectify(form, data) # create/update person as needed names = {} @@ -407,7 +428,7 @@ class UserView(PrincipalMasterView): # maybe set user password if 'set_password' in form and data['set_password']: - set_user_password(user, data['set_password']) + auth.set_user_password(user, data['set_password']) # update roles for user self.update_roles(user, data) @@ -420,10 +441,12 @@ class UserView(PrincipalMasterView): if 'roles' not in data: return - model = self.model + app = self.get_rattail_app() + auth = app.get_auth_handler() + model = app.model old_roles = set([r.uuid for r in user.roles]) new_roles = data['roles'] - admin = administrator_role(self.Session()) + admin = auth.get_role_administrator(self.Session()) # add any new roles for the user, taking care not to add the admin role # unless acting as root @@ -487,13 +510,12 @@ class UserView(PrincipalMasterView): .filter(model.UserEvent.user == user) def configure_row_grid(self, g): - super(UserView, self).configure_row_grid(g) + super().configure_row_grid(g) g.width = 'half' g.filterable = False g.set_sort_defaults('occurred', 'desc') g.set_enum('type_code', self.enum.USER_EVENT) g.set_label('type_code', "Event Type") - g.main_actions = [] def get_version_child_classes(self): model = self.model @@ -519,6 +541,21 @@ class UserView(PrincipalMasterView): users.append(user) return users + def find_by_perm_configure_results_grid(self, g): + g.append('username') + g.set_link('username') + + g.append('person') + g.set_link('person') + + def find_by_perm_normalize(self, user): + data = super().find_by_perm_normalize(user) + + data['username'] = user.username + data['person'] = str(user.person or '') + + return data + def preferences(self, user=None): """ View to modify preferences for a particular user. @@ -584,11 +621,14 @@ class UserView(PrincipalMasterView): styles = self.rattail_config.getlist('tailbone', 'themes.styles', default=[]) for name in styles: - css = self.rattail_config.get('tailbone', - 'themes.style.{}'.format(name)) + css = None + if self.request.use_oruga: + css = self.rattail_config.get(f'tailbone.themes.bb_style.{name}') + if not css: + css = self.rattail_config.get(f'tailbone.themes.style.{name}') if css: options.append({'value': css, 'label': name}) - context['buefy_css_options'] = options + context['theme_style_options'] = options return context @@ -601,22 +641,42 @@ class UserView(PrincipalMasterView): The only difference here is that we are given a user account, so the settings involved should only pertain to that user. """ + # TODO: can stop pre-fetching this value only once we are + # confident all settings have been updated in the wild + user_css = self.rattail_config.get(f'tailbone.{user.uuid}', 'user_css') + if not user_css: + user_css = self.rattail_config.get(f'tailbone.{user.uuid}', 'buefy_css') + return [ # display - {'section': 'tailbone.{}'.format(user.uuid), - 'option': 'buefy_css'}, + {'section': f'tailbone.{user.uuid}', + 'option': 'user_css', + 'value': user_css, + 'save_if_empty': False}, ] def preferences_gather_settings(self, data, user): simple_settings = self.preferences_get_simple_settings(user) - return self.configure_gather_settings( + settings = self.configure_gather_settings( data, simple_settings=simple_settings, input_file_templates=False) + # TODO: ugh why does user_css come back as 'default' instead of None? + final_settings = [] + for setting in settings: + if setting['name'].endswith('.user_css'): + if setting['value'] == 'default': + continue + final_settings.append(setting) + + return final_settings + def preferences_remove_settings(self, user): + app = self.get_rattail_app() simple_settings = self.preferences_get_simple_settings(user) self.configure_remove_settings(simple_settings=simple_settings, input_file_templates=False) + app.delete_setting(self.Session(), f'tailbone.{user.uuid}.buefy_css') @classmethod def defaults(cls, config): @@ -699,12 +759,12 @@ class UserEventView(MasterView): ] def get_data(self, session=None): - query = super(UserEventView, self).get_data(session=session) + query = super().get_data(session=session) model = self.model return query.join(model.User) def configure_grid(self, g): - super(UserEventView, self).configure_grid(g) + super().configure_grid(g) model = self.model g.set_joiner('person', lambda q: q.outerjoin(model.Person)) g.set_sorter('user', model.User.username) @@ -741,4 +801,8 @@ def defaults(config, **kwargs): def includeme(config): - defaults(config) + wutta_config = config.registry.settings['wutta_config'] + if wutta_config.get_bool('tailbone.use_wutta_views', default=False, usedb=False): + config.include('tailbone.views.wutta.users') + else: + defaults(config) diff --git a/tailbone/views/vendors/core.py b/tailbone/views/vendors/core.py index 8b9361b7..addf153c 100644 --- a/tailbone/views/vendors/core.py +++ b/tailbone/views/vendors/core.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 @@ Vendor Views """ -from __future__ import unicode_literals, absolute_import - -import six - from rattail.db import model from webhelpers2.html import tags @@ -158,7 +154,7 @@ class VendorView(MasterView): person = vendor.contact if not person: return "" - text = six.text_type(person) + text = str(person) url = self.request.route_url('people.view', uuid=person.uuid) return tags.link_to(text, url) @@ -198,7 +194,7 @@ class VendorView(MasterView): data, **kwargs) supported_vendor_settings = self.configure_get_supported_vendor_settings() - for setting in six.itervalues(supported_vendor_settings): + for setting in supported_vendor_settings.values(): name = 'rattail.vendor.{}'.format(setting['key']) settings.append({'name': name, 'value': data[name]}) @@ -211,7 +207,7 @@ class VendorView(MasterView): names = [] supported_vendor_settings = self.configure_get_supported_vendor_settings() - for setting in six.itervalues(supported_vendor_settings): + for setting in supported_vendor_settings.values(): names.append('rattail.vendor.{}'.format(setting['key'])) if names: @@ -236,7 +232,7 @@ class VendorView(MasterView): settings[key] = { 'key': key, 'value': vendor.uuid if vendor else None, - 'label': six.text_type(vendor) if vendor else None, + 'label': str(vendor) if vendor else None, } return settings diff --git a/tailbone/views/workorders.py b/tailbone/views/workorders.py index a53037bc..d8094e4b 100644 --- a/tailbone/views/workorders.py +++ b/tailbone/views/workorders.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. # @@ -83,12 +83,12 @@ class WorkOrderView(MasterView): ] def __init__(self, request): - super(WorkOrderView, self).__init__(request) + super().__init__(request) app = self.get_rattail_app() self.workorder_handler = app.get_workorder_handler() def configure_grid(self, g): - super(WorkOrderView, self).configure_grid(g) + super().configure_grid(g) model = self.model # customer @@ -113,7 +113,7 @@ class WorkOrderView(MasterView): return 'warning' def configure_form(self, f): - super(WorkOrderView, self).configure_form(f) + super().configure_form(f) model = self.model SelectWidget = forms.widgets.JQuerySelectWidget @@ -208,7 +208,7 @@ class WorkOrderView(MasterView): return event.workorder def configure_row_grid(self, g): - super(WorkOrderView, self).configure_row_grid(g) + super().configure_row_grid(g) g.set_enum('type_code', self.enum.WORKORDER_EVENT) g.set_sort_defaults('occurred') @@ -353,7 +353,7 @@ class WorkOrderView(MasterView): class StatusFilter(grids.filters.AlchemyIntegerFilter): def __init__(self, *args, **kwargs): - super(StatusFilter, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) from drild import enum @@ -369,14 +369,14 @@ class StatusFilter(grids.filters.AlchemyIntegerFilter): @property def verb_labels(self): - labels = dict(super(StatusFilter, self).verb_labels) + labels = dict(super().verb_labels) labels['is_active'] = "Is Active" labels['not_active'] = "Is Not Active" return labels @property def valueless_verbs(self): - verbs = list(super(StatusFilter, self).valueless_verbs) + verbs = list(super().valueless_verbs) verbs.extend([ 'is_active', 'not_active', @@ -385,7 +385,11 @@ class StatusFilter(grids.filters.AlchemyIntegerFilter): @property def default_verbs(self): - verbs = list(super(StatusFilter, self).default_verbs) + verbs = super().default_verbs + if callable(verbs): + verbs = verbs() + + verbs = list(verbs or []) verbs.insert(0, 'is_active') verbs.insert(1, 'not_active') return verbs diff --git a/tailbone/views/wutta/__init__.py b/tailbone/views/wutta/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tailbone/views/wutta/people.py b/tailbone/views/wutta/people.py new file mode 100644 index 00000000..bd96bd4d --- /dev/null +++ b/tailbone/views/wutta/people.py @@ -0,0 +1,143 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2024 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. +# +################################################################################ +""" +Person Views +""" + +import colander +import sqlalchemy as sa +from webhelpers2.html import HTML + +from wuttaweb.views import people as wutta +from tailbone.views import people as tailbone +from tailbone.db import Session +from rattail.db.model import Person +from tailbone.grids import Grid + + +class PersonView(wutta.PersonView): + """ + This is the first attempt at blending newer Wutta views with + legacy Tailbone config. + + So, this is a Wutta-based view but it should be included by a + Tailbone app configurator. + """ + model_class = Person + Session = Session + + labels = { + 'display_name': "Full Name", + } + + grid_columns = [ + 'display_name', + 'first_name', + 'last_name', + 'phone', + 'email', + 'merge_requested', + ] + + filter_defaults = { + 'display_name': {'active': True, 'verb': 'contains'}, + } + sort_defaults = 'display_name' + + form_fields = [ + 'first_name', + 'middle_name', + 'last_name', + 'display_name', + 'phone', + 'email', + # TODO + # 'address', + ] + + ############################## + # CRUD methods + ############################## + + # TODO: must use older grid for now, to render filters correctly + def make_grid(self, **kwargs): + """ """ + return Grid(self.request, **kwargs) + + def configure_grid(self, g): + """ """ + super().configure_grid(g) + + # display_name + g.set_link('display_name') + + # merge_requested + g.set_label('merge_requested', "MR") + g.set_renderer('merge_requested', self.render_merge_requested) + + def configure_form(self, f): + """ """ + super().configure_form(f) + + # email + if self.creating or self.editing: + f.remove('email') + else: + # nb. avoid colanderalchemy + f.set_node('email', colander.String()) + + # phone + if self.creating or self.editing: + f.remove('phone') + else: + # nb. avoid colanderalchemy + f.set_node('phone', colander.String()) + + ############################## + # support methods + ############################## + + def render_merge_requested(self, person, key, value, session=None): + """ """ + model = self.app.model + session = session or self.Session() + merge_request = session.query(model.MergePeopleRequest)\ + .filter(sa.or_( + model.MergePeopleRequest.removing_uuid == person.uuid, + model.MergePeopleRequest.keeping_uuid == person.uuid))\ + .filter(model.MergePeopleRequest.merged == None)\ + .first() + if merge_request: + return HTML.tag('span', + class_='has-text-danger has-text-weight-bold', + title="A merge has been requested for this person.", + c="MR") + + +def defaults(config, **kwargs): + kwargs.setdefault('PersonView', PersonView) + tailbone.defaults(config, **kwargs) + + +def includeme(config): + defaults(config) diff --git a/setup.py b/tailbone/views/wutta/users.py similarity index 51% rename from setup.py rename to tailbone/views/wutta/users.py index 5645ddff..3c3f8d52 100644 --- a/setup.py +++ b/tailbone/views/wutta/users.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,9 +21,37 @@ # ################################################################################ """ -Setup script for Tailbone +User Views """ -from setuptools import setup +from wuttaweb.views import users as wutta +from tailbone.views import users as tailbone +from tailbone.db import Session +from rattail.db.model import User +from tailbone.grids import Grid -setup() + +class UserView(wutta.UserView): + """ + This is the first attempt at blending newer Wutta views with + legacy Tailbone config. + + So, this is a Wutta-based view but it should be included by a + Tailbone app configurator. + """ + model_class = User + Session = Session + + # TODO: must use older grid for now, to render filters correctly + def make_grid(self, **kwargs): + """ """ + return Grid(self.request, **kwargs) + + +def defaults(config, **kwargs): + kwargs.setdefault('UserView', UserView) + tailbone.defaults(config, **kwargs) + + +def includeme(config): + defaults(config) diff --git a/tailbone/webapi.py b/tailbone/webapi.py index 7a2c81b4..d0edb412 100644 --- a/tailbone/webapi.py +++ b/tailbone/webapi.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. # @@ -30,7 +30,7 @@ from cornice.renderer import CorniceRenderer from pyramid.config import Configurator from tailbone import app -from tailbone.auth import TailboneAuthenticationPolicy, TailboneAuthorizationPolicy +from tailbone.auth import TailboneSecurityPolicy from tailbone.providers import get_all_providers @@ -50,8 +50,7 @@ def make_pyramid_config(settings): pyramid_config = Configurator(settings=settings, root_factory=app.Root) # configure user authorization / authentication - pyramid_config.set_authentication_policy(TailboneAuthenticationPolicy()) - pyramid_config.set_authorization_policy(TailboneAuthorizationPolicy()) + pyramid_config.set_security_policy(TailboneSecurityPolicy(api_mode=True)) # always require CSRF token protection pyramid_config.set_default_csrf_options(require_csrf=True, @@ -86,21 +85,34 @@ def make_pyramid_config(settings): provider.configure_db_sessions(rattail_config, pyramid_config) # add some permissions magic - pyramid_config.add_directive('add_tailbone_permission_group', 'tailbone.auth.add_permission_group') - pyramid_config.add_directive('add_tailbone_permission', 'tailbone.auth.add_permission') + pyramid_config.add_directive('add_wutta_permission_group', + 'wuttaweb.auth.add_permission_group') + pyramid_config.add_directive('add_wutta_permission', + 'wuttaweb.auth.add_permission') + # TODO: deprecate / remove these + pyramid_config.add_directive('add_tailbone_permission_group', + 'wuttaweb.auth.add_permission_group') + pyramid_config.add_directive('add_tailbone_permission', + 'wuttaweb.auth.add_permission') return pyramid_config -def main(global_config, **settings): +def main(global_config, views='tailbone.api', **settings): """ This function returns a Pyramid WSGI application. """ rattail_config = make_rattail_config(settings) pyramid_config = make_pyramid_config(settings) - # bring in some Tailbone - pyramid_config.include('tailbone.subscribers') - pyramid_config.include('tailbone.api') + # event hooks + pyramid_config.add_subscriber('tailbone.subscribers.new_request', + 'pyramid.events.NewRequest') + # TODO: is this really needed? + pyramid_config.add_subscriber('tailbone.subscribers.context_found', + 'pyramid.events.ContextFound') + + # views + pyramid_config.include(views) return pyramid_config.make_wsgi_app() diff --git a/tasks.py b/tasks.py index 48b51b39..6983dbea 100644 --- a/tasks.py +++ b/tasks.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,27 +24,25 @@ Tasks for Tailbone """ -from __future__ import unicode_literals, absolute_import - import os import shutil from invoke import task -here = os.path.abspath(os.path.dirname(__file__)) -exec(open(os.path.join(here, 'tailbone', '_version.py')).read()) - - @task -def release(c, tests=False): +def release(c, skip_tests=False): """ Release a new version of 'Tailbone'. """ - if tests: - c.run('tox') + if not skip_tests: + c.run('pytest') + if os.path.exists('dist'): + shutil.rmtree('dist') if os.path.exists('Tailbone.egg-info'): shutil.rmtree('Tailbone.egg-info') + c.run('python -m build --sdist') - c.run('twine upload dist/Tailbone-{}.tar.gz'.format(__version__)) + + c.run('twine upload dist/*') diff --git a/tests/__init__.py b/tests/__init__.py index 7dec63f0..40d8071f 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -12,9 +12,6 @@ class TestCase(unittest.TestCase): def setUp(self): self.config = testing.setUp() - # TODO: this probably shouldn't (need to) be here - self.config.add_directive('add_tailbone_permission_group', 'tailbone.auth.add_permission_group') - self.config.add_directive('add_tailbone_permission', 'tailbone.auth.add_permission') def tearDown(self): testing.tearDown() diff --git a/tests/fixtures.py b/tests/fixtures.py deleted file mode 100644 index a07825fd..00000000 --- a/tests/fixtures.py +++ /dev/null @@ -1,28 +0,0 @@ - -import fixture - -from rattail.db import model - - -class DepartmentData(fixture.DataSet): - - class grocery: - number = 1 - name = 'Grocery' - - class supplements: - number = 2 - name = 'Supplements' - - -def load_fixtures(engine): - - dbfixture = fixture.SQLAlchemyFixture( - env={ - 'DepartmentData': model.Department, - }, - engine=engine) - - data = dbfixture.data(DepartmentData) - - data.setup() diff --git a/tests/forms/__init__.py b/tests/forms/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/forms/test_core.py b/tests/forms/test_core.py new file mode 100644 index 00000000..894d2302 --- /dev/null +++ b/tests/forms/test_core.py @@ -0,0 +1,153 @@ +# -*- coding: utf-8; -*- + +from unittest.mock import patch + +import deform +from pyramid import testing + +from tailbone.forms import core as mod +from tests.util import WebTestCase + + +class TestForm(WebTestCase): + + def setUp(self): + self.setup_web() + self.config.setdefault('rattail.web.menus.handler_spec', 'tests.util:NullMenuHandler') + + def make_form(self, **kwargs): + kwargs.setdefault('request', self.request) + return mod.Form(**kwargs) + + def test_basic(self): + form = self.make_form() + self.assertIsInstance(form, mod.Form) + + def test_vue_tagname(self): + + # default + form = self.make_form() + self.assertEqual(form.vue_tagname, 'tailbone-form') + + # can override with param + form = self.make_form(vue_tagname='something-else') + self.assertEqual(form.vue_tagname, 'something-else') + + # can still pass old param + form = self.make_form(component='legacy-name') + self.assertEqual(form.vue_tagname, 'legacy-name') + + def test_vue_component(self): + + # default + form = self.make_form() + self.assertEqual(form.vue_component, 'TailboneForm') + + # can override with param + form = self.make_form(vue_tagname='something-else') + self.assertEqual(form.vue_component, 'SomethingElse') + + # can still pass old param + form = self.make_form(component='legacy-name') + self.assertEqual(form.vue_component, 'LegacyName') + + def test_component(self): + + # default + form = self.make_form() + self.assertEqual(form.component, 'tailbone-form') + + # can override with param + form = self.make_form(vue_tagname='something-else') + self.assertEqual(form.component, 'something-else') + + # can still pass old param + form = self.make_form(component='legacy-name') + self.assertEqual(form.component, 'legacy-name') + + def test_component_studly(self): + + # default + form = self.make_form() + self.assertEqual(form.component_studly, 'TailboneForm') + + # can override with param + form = self.make_form(vue_tagname='something-else') + self.assertEqual(form.component_studly, 'SomethingElse') + + # can still pass old param + form = self.make_form(component='legacy-name') + self.assertEqual(form.component_studly, 'LegacyName') + + def test_button_label_submit(self): + form = self.make_form() + + # default + self.assertEqual(form.button_label_submit, "Submit") + + # can set submit_label + with patch.object(form, 'submit_label', new="Submit Label", create=True): + self.assertEqual(form.button_label_submit, "Submit Label") + + # can set save_label + with patch.object(form, 'save_label', new="Save Label"): + self.assertEqual(form.button_label_submit, "Save Label") + + # can set button_label_submit + form.button_label_submit = "New Label" + self.assertEqual(form.button_label_submit, "New Label") + + def test_get_deform(self): + model = self.app.model + + # sanity check + form = self.make_form(model_class=model.Setting) + dform = form.get_deform() + self.assertIsInstance(dform, deform.Form) + + def test_render_vue_tag(self): + model = self.app.model + + # sanity check + form = self.make_form(model_class=model.Setting) + html = form.render_vue_tag() + self.assertIn('<tailbone-form', html) + + def test_render_vue_template(self): + self.pyramid_config.include('tailbone.views.common') + model = self.app.model + + # sanity check + form = self.make_form(model_class=model.Setting) + html = form.render_vue_template(session=self.session) + self.assertIn('<form ', html) + + def test_get_vue_field_value(self): + model = self.app.model + form = self.make_form(model_class=model.Setting) + + # TODO: yikes what a hack (?) + dform = form.get_deform() + dform.set_appstruct({'name': 'foo', 'value': 'bar'}) + + # null for missing field + value = form.get_vue_field_value('doesnotexist') + self.assertIsNone(value) + + # normal value is returned + value = form.get_vue_field_value('name') + self.assertEqual(value, 'foo') + + # but not if we remove field from deform + # TODO: what is the use case here again? + dform.children.remove(dform['name']) + value = form.get_vue_field_value('name') + self.assertIsNone(value) + + def test_render_vue_field(self): + model = self.app.model + + # sanity check + form = self.make_form(model_class=model.Setting) + html = form.render_vue_field('name', session=self.session) + self.assertIn('<b-field ', html) diff --git a/tests/grids/__init__.py b/tests/grids/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/grids/test_core.py b/tests/grids/test_core.py new file mode 100644 index 00000000..4d143c85 --- /dev/null +++ b/tests/grids/test_core.py @@ -0,0 +1,579 @@ +# -*- coding: utf-8; -*- + +from unittest.mock import MagicMock, patch + +from sqlalchemy import orm + +from tailbone.grids import core as mod +from tests.util import WebTestCase + + +class TestGrid(WebTestCase): + + def setUp(self): + self.setup_web() + self.config.setdefault('rattail.web.menus.handler_spec', 'tests.util:NullMenuHandler') + + def make_grid(self, key=None, data=[], **kwargs): + return mod.Grid(self.request, key=key, data=data, **kwargs) + + def test_basic(self): + grid = self.make_grid('foo') + self.assertIsInstance(grid, mod.Grid) + + def test_deprecated_params(self): + + # component + grid = self.make_grid() + self.assertEqual(grid.vue_tagname, 'tailbone-grid') + grid = self.make_grid(component='blarg') + self.assertEqual(grid.vue_tagname, 'blarg') + + # default_sortkey, default_sortdir + grid = self.make_grid() + self.assertEqual(grid.sort_defaults, []) + grid = self.make_grid(default_sortkey='name') + self.assertEqual(grid.sort_defaults, [mod.SortInfo('name', 'asc')]) + grid = self.make_grid(default_sortdir='desc') + self.assertEqual(grid.sort_defaults, []) + grid = self.make_grid(default_sortkey='name', default_sortdir='desc') + self.assertEqual(grid.sort_defaults, [mod.SortInfo('name', 'desc')]) + + # pageable + grid = self.make_grid() + self.assertFalse(grid.paginated) + grid = self.make_grid(pageable=True) + self.assertTrue(grid.paginated) + + # default_pagesize + grid = self.make_grid() + self.assertEqual(grid.pagesize, 20) + grid = self.make_grid(default_pagesize=15) + self.assertEqual(grid.pagesize, 15) + + # default_page + grid = self.make_grid() + self.assertEqual(grid.page, 1) + grid = self.make_grid(default_page=42) + self.assertEqual(grid.page, 42) + + # searchable + grid = self.make_grid() + self.assertEqual(grid.searchable_columns, set()) + grid = self.make_grid(searchable={'foo': True}) + self.assertEqual(grid.searchable_columns, {'foo'}) + + def test_vue_tagname(self): + + # default + grid = self.make_grid('foo') + self.assertEqual(grid.vue_tagname, 'tailbone-grid') + + # can override with param + grid = self.make_grid('foo', vue_tagname='something-else') + self.assertEqual(grid.vue_tagname, 'something-else') + + # can still pass old param + grid = self.make_grid('foo', component='legacy-name') + self.assertEqual(grid.vue_tagname, 'legacy-name') + + def test_vue_component(self): + + # default + grid = self.make_grid('foo') + self.assertEqual(grid.vue_component, 'TailboneGrid') + + # can override with param + grid = self.make_grid('foo', vue_tagname='something-else') + self.assertEqual(grid.vue_component, 'SomethingElse') + + # can still pass old param + grid = self.make_grid('foo', component='legacy-name') + self.assertEqual(grid.vue_component, 'LegacyName') + + def test_component(self): + + # default + grid = self.make_grid('foo') + self.assertEqual(grid.component, 'tailbone-grid') + + # can override with param + grid = self.make_grid('foo', vue_tagname='something-else') + self.assertEqual(grid.component, 'something-else') + + # can still pass old param + grid = self.make_grid('foo', component='legacy-name') + self.assertEqual(grid.component, 'legacy-name') + + def test_component_studly(self): + + # default + grid = self.make_grid('foo') + self.assertEqual(grid.component_studly, 'TailboneGrid') + + # can override with param + grid = self.make_grid('foo', vue_tagname='something-else') + self.assertEqual(grid.component_studly, 'SomethingElse') + + # can still pass old param + grid = self.make_grid('foo', component='legacy-name') + self.assertEqual(grid.component_studly, 'LegacyName') + + def test_actions(self): + + # default + grid = self.make_grid('foo') + self.assertEqual(grid.actions, []) + + # main actions + grid = self.make_grid('foo', main_actions=['foo']) + self.assertEqual(grid.actions, ['foo']) + + # more actions + grid = self.make_grid('foo', main_actions=['foo'], more_actions=['bar']) + self.assertEqual(grid.actions, ['foo', 'bar']) + + def test_set_label(self): + model = self.app.model + grid = self.make_grid(model_class=model.Setting, filterable=True) + self.assertEqual(grid.labels, {}) + + # basic + grid.set_label('name', "NAME COL") + self.assertEqual(grid.labels['name'], "NAME COL") + + # can replace label + grid.set_label('name', "Different") + self.assertEqual(grid.labels['name'], "Different") + self.assertEqual(grid.get_label('name'), "Different") + + # can update only column, not filter + self.assertEqual(grid.labels, {'name': "Different"}) + self.assertIn('name', grid.filters) + self.assertEqual(grid.filters['name'].label, "Different") + grid.set_label('name', "COLUMN ONLY", column_only=True) + self.assertEqual(grid.get_label('name'), "COLUMN ONLY") + self.assertEqual(grid.filters['name'].label, "Different") + + def test_get_view_click_handler(self): + model = self.app.model + grid = self.make_grid(model_class=model.Setting) + + grid.actions.append( + mod.GridAction(self.request, 'view', + click_handler='clickHandler(props.row)')) + + handler = grid.get_view_click_handler() + self.assertEqual(handler, 'clickHandler(props.row)') + + def test_set_action_urls(self): + model = self.app.model + grid = self.make_grid(model_class=model.Setting) + + grid.actions.append( + mod.GridAction(self.request, 'view', url='/blarg')) + + setting = {'name': 'foo', 'value': 'bar'} + grid.set_action_urls(setting, setting, 0) + self.assertEqual(setting['_action_url_view'], '/blarg') + + def test_default_sortkey(self): + grid = self.make_grid() + self.assertEqual(grid.sort_defaults, []) + self.assertIsNone(grid.default_sortkey) + grid.default_sortkey = 'name' + self.assertEqual(grid.sort_defaults, [mod.SortInfo('name', 'asc')]) + self.assertEqual(grid.default_sortkey, 'name') + grid.default_sortkey = 'value' + self.assertEqual(grid.sort_defaults, [mod.SortInfo('value', 'asc')]) + self.assertEqual(grid.default_sortkey, 'value') + + def test_default_sortdir(self): + grid = self.make_grid() + self.assertEqual(grid.sort_defaults, []) + self.assertIsNone(grid.default_sortdir) + self.assertRaises(ValueError, setattr, grid, 'default_sortdir', 'asc') + grid.sort_defaults = [mod.SortInfo('name', 'asc')] + grid.default_sortdir = 'desc' + self.assertEqual(grid.sort_defaults, [mod.SortInfo('name', 'desc')]) + self.assertEqual(grid.default_sortdir, 'desc') + + def test_pageable(self): + grid = self.make_grid() + self.assertFalse(grid.paginated) + grid.pageable = True + self.assertTrue(grid.paginated) + grid.paginated = False + self.assertFalse(grid.pageable) + + def test_get_pagesize_options(self): + grid = self.make_grid() + + # default + options = grid.get_pagesize_options() + self.assertEqual(options, [5, 10, 20, 50, 100, 200]) + + # override default + options = grid.get_pagesize_options(default=[42]) + self.assertEqual(options, [42]) + + # from legacy config + self.config.setdefault('tailbone.grid.pagesize_options', '1 2 3') + grid = self.make_grid() + options = grid.get_pagesize_options() + self.assertEqual(options, [1, 2, 3]) + + # from new config + self.config.setdefault('wuttaweb.grids.default_pagesize_options', '4, 5, 6') + grid = self.make_grid() + options = grid.get_pagesize_options() + self.assertEqual(options, [4, 5, 6]) + + def test_get_pagesize(self): + grid = self.make_grid() + + # default + size = grid.get_pagesize() + self.assertEqual(size, 20) + + # override default + size = grid.get_pagesize(default=42) + self.assertEqual(size, 42) + + # override default options + self.config.setdefault('wuttaweb.grids.default_pagesize_options', '10 15 30') + grid = self.make_grid() + size = grid.get_pagesize() + self.assertEqual(size, 10) + + # from legacy config + self.config.setdefault('tailbone.grid.default_pagesize', '12') + grid = self.make_grid() + size = grid.get_pagesize() + self.assertEqual(size, 12) + + # from new config + self.config.setdefault('wuttaweb.grids.default_pagesize', '15') + grid = self.make_grid() + size = grid.get_pagesize() + self.assertEqual(size, 15) + + def test_set_sorter(self): + model = self.app.model + grid = self.make_grid(model_class=model.Setting, + sortable=True, sort_on_backend=True) + + # passing None will remove sorter + self.assertIn('name', grid.sorters) + grid.set_sorter('name', None) + self.assertNotIn('name', grid.sorters) + + # can recreate sorter with just column name + grid.set_sorter('name') + self.assertIn('name', grid.sorters) + grid.remove_sorter('name') + self.assertNotIn('name', grid.sorters) + grid.set_sorter('name', 'name') + self.assertIn('name', grid.sorters) + + # can recreate sorter with model property + grid.remove_sorter('name') + self.assertNotIn('name', grid.sorters) + grid.set_sorter('name', model.Setting.name) + self.assertIn('name', grid.sorters) + + # extra kwargs are ignored + grid.remove_sorter('name') + self.assertNotIn('name', grid.sorters) + grid.set_sorter('name', model.Setting.name, foo='bar') + self.assertIn('name', grid.sorters) + + # passing multiple args will invoke make_filter() directly + grid.remove_sorter('name') + self.assertNotIn('name', grid.sorters) + with patch.object(grid, 'make_sorter') as make_sorter: + make_sorter.return_value = 42 + grid.set_sorter('name', 'foo', 'bar') + make_sorter.assert_called_once_with('foo', 'bar') + self.assertEqual(grid.sorters['name'], 42) + + def test_make_simple_sorter(self): + model = self.app.model + grid = self.make_grid(model_class=model.Setting, + sortable=True, sort_on_backend=True) + + # delegates to grid.make_sorter() + with patch.object(grid, 'make_sorter') as make_sorter: + make_sorter.return_value = 42 + sorter = grid.make_simple_sorter('name', foldcase=True) + make_sorter.assert_called_once_with('name', foldcase=True) + self.assertEqual(sorter, 42) + + def test_load_settings(self): + model = self.app.model + + # nb. first use a paging grid + grid = self.make_grid(key='foo', paginated=True, paginate_on_backend=True, + pagesize=20, page=1) + + # settings are loaded, applied, saved + self.assertEqual(grid.page, 1) + self.assertNotIn('grid.foo.page', self.request.session) + self.request.GET = {'pagesize': '10', 'page': '2'} + grid.load_settings() + self.assertEqual(grid.page, 2) + self.assertEqual(self.request.session['grid.foo.page'], 2) + + # can skip the saving step + self.request.GET = {'pagesize': '10', 'page': '3'} + grid.load_settings(store=False) + self.assertEqual(grid.page, 3) + self.assertEqual(self.request.session['grid.foo.page'], 2) + + # no error for non-paginated grid + grid = self.make_grid(key='foo', paginated=False) + grid.load_settings() + self.assertFalse(grid.paginated) + + # nb. next use a sorting grid + grid = self.make_grid(key='settings', model_class=model.Setting, + sortable=True, sort_on_backend=True) + + # settings are loaded, applied, saved + self.assertEqual(grid.sort_defaults, []) + self.assertFalse(hasattr(grid, 'active_sorters')) + self.request.GET = {'sort1key': 'name', 'sort1dir': 'desc'} + grid.load_settings() + self.assertEqual(grid.active_sorters, [{'key': 'name', 'dir': 'desc'}]) + self.assertEqual(self.request.session['grid.settings.sorters.length'], 1) + self.assertEqual(self.request.session['grid.settings.sorters.1.key'], 'name') + self.assertEqual(self.request.session['grid.settings.sorters.1.dir'], 'desc') + + # can skip the saving step + self.request.GET = {'sort1key': 'name', 'sort1dir': 'asc'} + grid.load_settings(store=False) + self.assertEqual(grid.active_sorters, [{'key': 'name', 'dir': 'asc'}]) + self.assertEqual(self.request.session['grid.settings.sorters.length'], 1) + self.assertEqual(self.request.session['grid.settings.sorters.1.key'], 'name') + self.assertEqual(self.request.session['grid.settings.sorters.1.dir'], 'desc') + + # no error for non-sortable grid + grid = self.make_grid(key='foo', sortable=False) + grid.load_settings() + self.assertFalse(grid.sortable) + + # with sort defaults + grid = self.make_grid(model_class=model.Setting, sortable=True, + sort_on_backend=True, sort_defaults='name') + self.assertFalse(hasattr(grid, 'active_sorters')) + grid.load_settings() + self.assertEqual(grid.active_sorters, [{'key': 'name', 'dir': 'asc'}]) + + # with multi-column sort defaults + grid = self.make_grid(model_class=model.Setting, sortable=True, + sort_on_backend=True) + grid.sort_defaults = [ + mod.SortInfo('name', 'asc'), + mod.SortInfo('value', 'desc'), + ] + self.assertFalse(hasattr(grid, 'active_sorters')) + grid.load_settings() + self.assertEqual(grid.active_sorters, [{'key': 'name', 'dir': 'asc'}]) + + # load settings from session when nothing is in request + self.request.GET = {} + self.request.session.invalidate() + self.assertNotIn('grid.settings.sorters.length', self.request.session) + self.request.session['grid.settings.sorters.length'] = 1 + self.request.session['grid.settings.sorters.1.key'] = 'name' + self.request.session['grid.settings.sorters.1.dir'] = 'desc' + grid = self.make_grid(key='settings', model_class=model.Setting, + sortable=True, sort_on_backend=True, + paginated=True, paginate_on_backend=True) + self.assertFalse(hasattr(grid, 'active_sorters')) + grid.load_settings() + self.assertEqual(grid.active_sorters, [{'key': 'name', 'dir': 'desc'}]) + + def test_persist_settings(self): + model = self.app.model + + # nb. start out with paginated-only grid + grid = self.make_grid(key='foo', paginated=True, paginate_on_backend=True) + + # invalid dest + self.assertRaises(ValueError, grid.persist_settings, {}, dest='doesnotexist') + + # nb. no error if empty settings, but it saves null values + grid.persist_settings({}, dest='session') + self.assertIsNone(self.request.session['grid.foo.page']) + + # provided values are saved + grid.persist_settings({'pagesize': 15, 'page': 3}, dest='session') + self.assertEqual(self.request.session['grid.foo.page'], 3) + + # nb. now switch to sortable-only grid + grid = self.make_grid(key='settings', model_class=model.Setting, + sortable=True, sort_on_backend=True) + + # no error if empty settings; does not save values + grid.persist_settings({}, dest='session') + self.assertNotIn('grid.settings.sorters.length', self.request.session) + + # provided values are saved + grid.persist_settings({'sorters.length': 2, + 'sorters.1.key': 'name', + 'sorters.1.dir': 'desc', + 'sorters.2.key': 'value', + 'sorters.2.dir': 'asc'}, + dest='session') + self.assertEqual(self.request.session['grid.settings.sorters.length'], 2) + self.assertEqual(self.request.session['grid.settings.sorters.1.key'], 'name') + self.assertEqual(self.request.session['grid.settings.sorters.1.dir'], 'desc') + self.assertEqual(self.request.session['grid.settings.sorters.2.key'], 'value') + self.assertEqual(self.request.session['grid.settings.sorters.2.dir'], 'asc') + + # old values removed when new are saved + grid.persist_settings({'sorters.length': 1, + 'sorters.1.key': 'name', + 'sorters.1.dir': 'desc'}, + dest='session') + self.assertEqual(self.request.session['grid.settings.sorters.length'], 1) + self.assertEqual(self.request.session['grid.settings.sorters.1.key'], 'name') + self.assertEqual(self.request.session['grid.settings.sorters.1.dir'], 'desc') + self.assertNotIn('grid.settings.sorters.2.key', self.request.session) + self.assertNotIn('grid.settings.sorters.2.dir', self.request.session) + + def test_sort_data(self): + model = self.app.model + sample_data = [ + {'name': 'foo1', 'value': 'ONE'}, + {'name': 'foo2', 'value': 'two'}, + {'name': 'foo3', 'value': 'ggg'}, + {'name': 'foo4', 'value': 'ggg'}, + {'name': 'foo5', 'value': 'ggg'}, + {'name': 'foo6', 'value': 'six'}, + {'name': 'foo7', 'value': 'seven'}, + {'name': 'foo8', 'value': 'eight'}, + {'name': 'foo9', 'value': 'nine'}, + ] + for setting in sample_data: + self.app.save_setting(self.session, setting['name'], setting['value']) + self.session.commit() + sample_query = self.session.query(model.Setting) + + grid = self.make_grid(model_class=model.Setting, + sortable=True, sort_on_backend=True, + sort_defaults=('name', 'desc')) + grid.load_settings() + + # can sort a simple list of data + sorted_data = grid.sort_data(sample_data) + self.assertIsInstance(sorted_data, list) + self.assertEqual(len(sorted_data), 9) + self.assertEqual(sorted_data[0]['name'], 'foo9') + self.assertEqual(sorted_data[-1]['name'], 'foo1') + + # can also sort a data query + sorted_query = grid.sort_data(sample_query) + self.assertIsInstance(sorted_query, orm.Query) + sorted_data = sorted_query.all() + self.assertEqual(len(sorted_data), 9) + self.assertEqual(sorted_data[0]['name'], 'foo9') + self.assertEqual(sorted_data[-1]['name'], 'foo1') + + # cannot sort data if sorter missing in overrides + sorted_data = grid.sort_data(sample_data, sorters=[]) + # nb. sorted data is in same order as original sample (not sorted) + self.assertEqual(sorted_data[0]['name'], 'foo1') + self.assertEqual(sorted_data[-1]['name'], 'foo9') + + # multi-column sorting for list data + sorted_data = grid.sort_data(sample_data, sorters=[{'key': 'value', 'dir': 'asc'}, + {'key': 'name', 'dir': 'asc'}]) + self.assertEqual(dict(sorted_data[0]), {'name': 'foo8', 'value': 'eight'}) + self.assertEqual(dict(sorted_data[1]), {'name': 'foo3', 'value': 'ggg'}) + self.assertEqual(dict(sorted_data[3]), {'name': 'foo5', 'value': 'ggg'}) + self.assertEqual(dict(sorted_data[-1]), {'name': 'foo2', 'value': 'two'}) + + # multi-column sorting for query + sorted_query = grid.sort_data(sample_query, sorters=[{'key': 'value', 'dir': 'asc'}, + {'key': 'name', 'dir': 'asc'}]) + self.assertEqual(dict(sorted_data[0]), {'name': 'foo8', 'value': 'eight'}) + self.assertEqual(dict(sorted_data[1]), {'name': 'foo3', 'value': 'ggg'}) + self.assertEqual(dict(sorted_data[3]), {'name': 'foo5', 'value': 'ggg'}) + self.assertEqual(dict(sorted_data[-1]), {'name': 'foo2', 'value': 'two'}) + + # cannot sort data if sortfunc is missing for column + grid.remove_sorter('name') + sorted_data = grid.sort_data(sample_data, sorters=[{'key': 'value', 'dir': 'asc'}, + {'key': 'name', 'dir': 'asc'}]) + # nb. sorted data is in same order as original sample (not sorted) + self.assertEqual(sorted_data[0]['name'], 'foo1') + self.assertEqual(sorted_data[-1]['name'], 'foo9') + + def test_render_vue_tag(self): + model = self.app.model + + # standard + grid = self.make_grid('settings', model_class=model.Setting) + html = grid.render_vue_tag() + self.assertIn('<tailbone-grid', html) + self.assertNotIn('@deleteActionClicked', html) + + # with delete hook + master = MagicMock(deletable=True, delete_confirm='simple') + master.has_perm.return_value = True + grid = self.make_grid('settings', model_class=model.Setting) + html = grid.render_vue_tag(master=master) + self.assertIn('<tailbone-grid', html) + self.assertIn('@deleteActionClicked', html) + + def test_render_vue_template(self): + # self.pyramid_config.include('tailbone.views.common') + model = self.app.model + + # sanity check + grid = self.make_grid('settings', model_class=model.Setting) + html = grid.render_vue_template(session=self.session) + self.assertIn('<b-table', html) + + def test_get_vue_columns(self): + model = self.app.model + + # sanity check + grid = self.make_grid('settings', model_class=model.Setting, sortable=True) + columns = grid.get_vue_columns() + self.assertEqual(len(columns), 2) + self.assertEqual(columns[0]['field'], 'name') + self.assertTrue(columns[0]['sortable']) + self.assertEqual(columns[1]['field'], 'value') + self.assertTrue(columns[1]['sortable']) + + def test_get_vue_data(self): + model = self.app.model + + # sanity check + grid = self.make_grid('settings', model_class=model.Setting) + data = grid.get_vue_data() + self.assertEqual(data, []) + + # calling again returns same data + data2 = grid.get_vue_data() + self.assertIs(data2, data) + + +class TestGridAction(WebTestCase): + + def test_constructor(self): + + # null by default + action = mod.GridAction(self.request, 'view') + self.assertIsNone(action.target) + self.assertIsNone(action.click_handler) + + # but can set them + action = mod.GridAction(self.request, 'view', + target='_blank', + click_handler='doSomething(props.row)') + self.assertEqual(action.target, '_blank') + self.assertEqual(action.click_handler, 'doSomething(props.row)') diff --git a/tests/test_app.py b/tests/test_app.py index 2523c424..f49f6b13 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -3,14 +3,11 @@ import os from unittest import TestCase -from sqlalchemy import create_engine +from pyramid.config import Configurator -from rattail.config import RattailConfig from rattail.exceptions import ConfigurationError -from rattail.db import Session as RattailSession - -from tailbone import app -from tailbone.db import Session as TailboneSession +from rattail.testing import DataTestCase +from tailbone import app as mod class TestRattailConfig(TestCase): @@ -18,15 +15,34 @@ class TestRattailConfig(TestCase): config_path = os.path.abspath( os.path.join(os.path.dirname(__file__), 'data', 'tailbone.conf')) - def tearDown(self): - # may or may not be necessary depending on test - TailboneSession.remove() - def test_settings_arg_must_include_config_path_by_default(self): # error raised if path not provided - self.assertRaises(ConfigurationError, app.make_rattail_config, {}) + self.assertRaises(ConfigurationError, mod.make_rattail_config, {}) # get a config object if path provided - result = app.make_rattail_config({'rattail.config': self.config_path}) + result = mod.make_rattail_config({'rattail.config': self.config_path}) # nb. cannot test isinstance(RattailConfig) b/c now uses wrapper! self.assertIsNotNone(result) self.assertTrue(hasattr(result, 'get')) + + +class TestMakePyramidConfig(DataTestCase): + + def make_config(self, **kwargs): + myconf = self.write_file('web.conf', """ +[rattail.db] +default.url = sqlite:// +""") + + self.settings = { + 'rattail.config': myconf, + 'mako.directories': 'tailbone:templates', + } + return mod.make_rattail_config(self.settings) + + def test_basic(self): + model = self.app.model + model.Base.metadata.create_all(bind=self.config.appdb_engine) + + # sanity check + pyramid_config = mod.make_pyramid_config(self.settings) + self.assertIsInstance(pyramid_config, Configurator) diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 00000000..4519e152 --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8; -*- + +from tailbone import auth as mod diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 00000000..0cd1938c --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,12 @@ +# -*- coding: utf-8; -*- + +from tailbone import config as mod +from tests.util import DataTestCase + + +class TestConfigExtension(DataTestCase): + + def test_basic(self): + # sanity / coverage check + ext = mod.ConfigExtension() + ext.configure(self.config) diff --git a/tests/test_db.py b/tests/test_db.py new file mode 100644 index 00000000..88cb9d41 --- /dev/null +++ b/tests/test_db.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8; -*- + +# TODO: add real tests at some point but this at least gives us basic +# coverage when running this "test" module alone + +from tailbone import db + diff --git a/tests/test_subscribers.py b/tests/test_subscribers.py new file mode 100644 index 00000000..81bc2869 --- /dev/null +++ b/tests/test_subscribers.py @@ -0,0 +1,58 @@ +# -*- coding: utf-8; -*- + +from unittest.mock import MagicMock + +from pyramid import testing + +from tailbone import subscribers as mod +from tests.util import DataTestCase + + +class TestNewRequest(DataTestCase): + + def setUp(self): + self.setup_db() + self.request = self.make_request() + self.pyramid_config = testing.setUp(request=self.request, settings={ + 'wutta_config': self.config, + }) + + def tearDown(self): + self.teardown_db() + testing.tearDown() + + def make_request(self, **kwargs): + return testing.DummyRequest(**kwargs) + + def make_event(self): + return MagicMock(request=self.request) + + def test_continuum_remote_addr(self): + event = self.make_event() + + # nothing happens + mod.new_request(event, session=self.session) + self.assertFalse(hasattr(self.session, 'continuum_remote_addr')) + + # unless request has client_addr + self.request.client_addr = '127.0.0.1' + mod.new_request(event, session=self.session) + self.assertEqual(self.session.continuum_remote_addr, '127.0.0.1') + + def test_register_component(self): + event = self.make_event() + + # function added + self.assertFalse(hasattr(self.request, 'register_component')) + mod.new_request(event, session=self.session) + self.assertTrue(callable(self.request.register_component)) + + # call function + self.request.register_component('tailbone-datepicker', 'TailboneDatepicker') + self.assertEqual(self.request._tailbone_registered_components, + {'tailbone-datepicker': 'TailboneDatepicker'}) + + # duplicate registration ignored + self.request.register_component('tailbone-datepicker', 'TailboneDatepicker') + self.assertEqual(self.request._tailbone_registered_components, + {'tailbone-datepicker': 'TailboneDatepicker'}) diff --git a/tests/test_util.py b/tests/test_util.py new file mode 100644 index 00000000..46684f0c --- /dev/null +++ b/tests/test_util.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8; -*- + +from unittest import TestCase + +from pyramid import testing + +from rattail.config import RattailConfig + +from tailbone import util + + +class TestGetFormData(TestCase): + + def setUp(self): + self.config = RattailConfig() + + def make_request(self, **kwargs): + kwargs.setdefault('wutta_config', self.config) + kwargs.setdefault('rattail_config', self.config) + kwargs.setdefault('is_xhr', None) + kwargs.setdefault('content_type', None) + kwargs.setdefault('POST', {'foo1': 'bar'}) + kwargs.setdefault('json_body', {'foo2': 'baz'}) + return testing.DummyRequest(**kwargs) + + def test_default(self): + request = self.make_request() + data = util.get_form_data(request) + self.assertEqual(data, {'foo1': 'bar'}) + + def test_is_xhr(self): + request = self.make_request(POST=None, is_xhr=True) + data = util.get_form_data(request) + self.assertEqual(data, {'foo2': 'baz'}) + + def test_content_type(self): + request = self.make_request(POST=None, content_type='application/json') + data = util.get_form_data(request) + self.assertEqual(data, {'foo2': 'baz'}) diff --git a/tests/util.py b/tests/util.py new file mode 100644 index 00000000..4277a7c3 --- /dev/null +++ b/tests/util.py @@ -0,0 +1,77 @@ +# -*- coding: utf-8; -*- + +from unittest.mock import MagicMock + +from pyramid import testing + +from tailbone import subscribers +from wuttaweb.menus import MenuHandler +# from wuttaweb.subscribers import new_request_set_user +from rattail.testing import DataTestCase + + +class WebTestCase(DataTestCase): + """ + Base class for test suites requiring a full (typical) web app. + """ + + def setUp(self): + self.setup_web() + + def setup_web(self): + self.setup_db() + self.request = self.make_request() + self.pyramid_config = testing.setUp(request=self.request, settings={ + 'wutta_config': self.config, + 'rattail_config': self.config, + 'mako.directories': ['tailbone:templates', 'wuttaweb:templates'], + # 'pyramid_deform.template_search_path': 'wuttaweb:templates/deform', + }) + + # init web + # self.pyramid_config.include('pyramid_deform') + self.pyramid_config.include('pyramid_mako') + self.pyramid_config.add_directive('add_wutta_permission_group', + 'wuttaweb.auth.add_permission_group') + self.pyramid_config.add_directive('add_wutta_permission', + 'wuttaweb.auth.add_permission') + self.pyramid_config.add_directive('add_tailbone_permission_group', + 'wuttaweb.auth.add_permission_group') + self.pyramid_config.add_directive('add_tailbone_permission', + 'wuttaweb.auth.add_permission') + self.pyramid_config.add_directive('add_tailbone_index_page', + 'tailbone.app.add_index_page') + self.pyramid_config.add_directive('add_tailbone_model_view', + 'tailbone.app.add_model_view') + self.pyramid_config.add_directive('add_tailbone_config_page', + 'tailbone.app.add_config_page') + self.pyramid_config.add_subscriber('tailbone.subscribers.before_render', + 'pyramid.events.BeforeRender') + self.pyramid_config.include('tailbone.static') + + # setup new request w/ anonymous user + event = MagicMock(request=self.request) + subscribers.new_request(event, session=self.session) + # def user_getter(request, **kwargs): pass + # new_request_set_user(event, db_session=self.session, + # user_getter=user_getter) + + def tearDown(self): + self.teardown_web() + + def teardown_web(self): + testing.tearDown() + self.teardown_db() + + def make_request(self, **kwargs): + kwargs.setdefault('rattail_config', self.config) + # kwargs.setdefault('wutta_config', self.config) + return testing.DummyRequest(**kwargs) + + +class NullMenuHandler(MenuHandler): + """ + Dummy menu handler for testing. + """ + def make_menus(self, request, **kwargs): + return [] diff --git a/tests/views/test_master.py b/tests/views/test_master.py new file mode 100644 index 00000000..0e459e7d --- /dev/null +++ b/tests/views/test_master.py @@ -0,0 +1,66 @@ +# -*- coding: utf-8; -*- + +from unittest.mock import patch, MagicMock + +from tailbone.views import master as mod +from wuttaweb.grids import GridAction +from tests.util import WebTestCase + + +class TestMasterView(WebTestCase): + + def make_view(self): + return mod.MasterView(self.request) + + def test_make_form_kwargs(self): + self.pyramid_config.add_route('settings.view', '/settings/{name}') + model = self.app.model + setting = model.Setting(name='foo', value='bar') + self.session.add(setting) + self.session.commit() + with patch.multiple(mod.MasterView, create=True, + model_class=model.Setting): + view = self.make_view() + + # sanity / coverage check + kw = view.make_form_kwargs(model_instance=setting) + self.assertIsNotNone(kw['action_url']) + + def test_make_action(self): + model = self.app.model + with patch.multiple(mod.MasterView, create=True, + model_class=model.Setting): + view = self.make_view() + action = view.make_action('view') + self.assertIsInstance(action, GridAction) + + def test_index(self): + self.pyramid_config.include('tailbone.views.common') + self.pyramid_config.include('tailbone.views.auth') + model = self.app.model + + # mimic view for /settings + with patch.object(mod, 'Session', return_value=self.session): + with patch.multiple(mod.MasterView, create=True, + model_class=model.Setting, + Session=MagicMock(return_value=self.session), + get_index_url=MagicMock(return_value='/settings/'), + get_help_url=MagicMock(return_value=None)): + + # basic + view = self.make_view() + response = view.index() + self.assertEqual(response.status_code, 200) + + # then again with data, to include view action url + data = [{'name': 'foo', 'value': 'bar'}] + with patch.object(view, 'get_data', return_value=data): + response = view.index() + self.assertEqual(response.status_code, 200) + self.assertEqual(response.content_type, 'text/html') + + # then once more as 'partial' - aka. data only + self.request.GET = {'partial': '1'} + response = view.index() + self.assertEqual(response.status_code, 200) + self.assertEqual(response.content_type, 'application/json') diff --git a/tests/views/test_people.py b/tests/views/test_people.py new file mode 100644 index 00000000..f85577e7 --- /dev/null +++ b/tests/views/test_people.py @@ -0,0 +1,17 @@ +# -*- coding: utf-8; -*- + +from tailbone.views import users as mod +from tests.util import WebTestCase + + +class TestPersonView(WebTestCase): + + def make_view(self): + return mod.PersonView(self.request) + + def test_includeme(self): + self.pyramid_config.include('tailbone.views.people') + + def test_includeme_wutta(self): + self.config.setdefault('tailbone.use_wutta_views', 'true') + self.pyramid_config.include('tailbone.views.people') diff --git a/tests/views/test_principal.py b/tests/views/test_principal.py new file mode 100644 index 00000000..2b31531c --- /dev/null +++ b/tests/views/test_principal.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8; -*- + +from unittest.mock import patch, MagicMock + +from tailbone.views import principal as mod +from tests.util import WebTestCase + + +class TestPrincipalMasterView(WebTestCase): + + def make_view(self): + return mod.PrincipalMasterView(self.request) + + def test_find_by_perm(self): + model = self.app.model + self.config.setdefault('rattail.web.menus.handler_spec', 'tests.util:NullMenuHandler') + self.pyramid_config.include('tailbone.views.common') + self.pyramid_config.include('tailbone.views.auth') + self.pyramid_config.add_route('roles', '/roles/') + with patch.multiple(mod.PrincipalMasterView, create=True, + model_class=model.Role, + get_help_url=MagicMock(return_value=None), + get_help_markdown=MagicMock(return_value=None), + can_edit_help=MagicMock(return_value=False)): + + # sanity / coverage check + view = self.make_view() + response = view.find_by_perm() + self.assertEqual(response.status_code, 200) diff --git a/tests/views/test_roles.py b/tests/views/test_roles.py new file mode 100644 index 00000000..0cdc724e --- /dev/null +++ b/tests/views/test_roles.py @@ -0,0 +1,80 @@ +# -*- coding: utf-8; -*- + +from unittest.mock import patch + +from tailbone.views import roles as mod +from tests.util import WebTestCase + + +class TestRoleView(WebTestCase): + + def make_view(self): + return mod.RoleView(self.request) + + def test_includeme(self): + self.pyramid_config.include('tailbone.views.roles') + + def get_permissions(self): + return { + 'widgets': { + 'label': "Widgets", + 'perms': { + 'widgets.list': { + 'label': "List widgets", + }, + 'widgets.polish': { + 'label': "Polish the widgets", + }, + 'widgets.view': { + 'label': "View widget", + }, + }, + }, + } + + def test_get_available_permissions(self): + model = self.app.model + auth = self.app.get_auth_handler() + blokes = model.Role(name="Blokes") + auth.grant_permission(blokes, 'widgets.list') + self.session.add(blokes) + barney = model.User(username='barney') + barney.roles.append(blokes) + self.session.add(barney) + self.session.commit() + view = self.make_view() + all_perms = self.get_permissions() + self.request.registry.settings['wutta_permissions'] = all_perms + + def has_perm(perm): + if perm == 'widgets.list': + return True + return False + + with patch.object(self.request, 'has_perm', new=has_perm, create=True): + + # sanity check; current request has 1 perm + self.assertTrue(self.request.has_perm('widgets.list')) + self.assertFalse(self.request.has_perm('widgets.polish')) + self.assertFalse(self.request.has_perm('widgets.view')) + + # when editing, user sees only the 1 perm + with patch.object(view, 'editing', new=True): + perms = view.get_available_permissions() + self.assertEqual(list(perms), ['widgets']) + self.assertEqual(list(perms['widgets']['perms']), ['widgets.list']) + + # but when viewing, same user sees all perms + with patch.object(view, 'viewing', new=True): + perms = view.get_available_permissions() + self.assertEqual(list(perms), ['widgets']) + self.assertEqual(list(perms['widgets']['perms']), + ['widgets.list', 'widgets.polish', 'widgets.view']) + + # also, when admin user is editing, sees all perms + self.request.is_admin = True + with patch.object(view, 'editing', new=True): + perms = view.get_available_permissions() + self.assertEqual(list(perms), ['widgets']) + self.assertEqual(list(perms['widgets']['perms']), + ['widgets.list', 'widgets.polish', 'widgets.view']) diff --git a/tests/views/test_settings.py b/tests/views/test_settings.py new file mode 100644 index 00000000..b8523729 --- /dev/null +++ b/tests/views/test_settings.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8; -*- + +from tailbone.views import settings as mod +from tests.util import WebTestCase + + +class TestSettingView(WebTestCase): + + def test_includeme(self): + self.pyramid_config.include('tailbone.views.settings') diff --git a/tests/views/test_users.py b/tests/views/test_users.py new file mode 100644 index 00000000..4b94caf2 --- /dev/null +++ b/tests/views/test_users.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8; -*- + +from unittest.mock import patch, MagicMock + +from tailbone.views import users as mod +from tailbone.views.principal import PermissionsRenderer +from tests.util import WebTestCase + + +class TestUserView(WebTestCase): + + def make_view(self): + return mod.UserView(self.request) + + def test_includeme(self): + self.pyramid_config.include('tailbone.views.users') + + def test_configure_form(self): + self.pyramid_config.include('tailbone.views.users') + model = self.app.model + barney = model.User(username='barney') + self.session.add(barney) + self.session.commit() + view = self.make_view() + + # must use mock configure when making form + def configure(form): pass + form = view.make_form(instance=barney, configure=configure) + + with patch.object(view, 'viewing', new=True): + self.assertNotIn('permissions', form.renderers) + view.configure_form(form) + self.assertIsInstance(form.renderers['permissions'], PermissionsRenderer) diff --git a/tests/views/wutta/__init__.py b/tests/views/wutta/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/views/wutta/test_people.py b/tests/views/wutta/test_people.py new file mode 100644 index 00000000..31aeb501 --- /dev/null +++ b/tests/views/wutta/test_people.py @@ -0,0 +1,87 @@ +# -*- coding: utf-8; -*- + +from unittest.mock import patch + +from sqlalchemy import orm + +from tailbone.views.wutta import people as mod +from tests.util import WebTestCase + + +class TestPersonView(WebTestCase): + + def make_view(self): + return mod.PersonView(self.request) + + def test_includeme(self): + self.pyramid_config.include('tailbone.views.wutta.people') + + def test_get_query(self): + view = self.make_view() + + # sanity / coverage check + query = view.get_query(session=self.session) + self.assertIsInstance(query, orm.Query) + + def test_configure_grid(self): + model = self.app.model + barney = model.User(username='barney') + self.session.add(barney) + self.session.commit() + view = self.make_view() + + # sanity / coverage check + grid = view.make_grid(model_class=model.Person) + self.assertNotIn('first_name', grid.linked_columns) + view.configure_grid(grid) + self.assertIn('first_name', grid.linked_columns) + + def test_configure_form(self): + model = self.app.model + barney = model.Person(display_name="Barney Rubble") + self.session.add(barney) + self.session.commit() + view = self.make_view() + + # email field remains when viewing + with patch.object(view, 'viewing', new=True): + form = view.make_form(model_instance=barney, + fields=view.get_form_fields()) + self.assertIn('email', form.fields) + view.configure_form(form) + self.assertIn('email', form) + + # email field removed when editing + with patch.object(view, 'editing', new=True): + form = view.make_form(model_instance=barney, + fields=view.get_form_fields()) + self.assertIn('email', form.fields) + view.configure_form(form) + self.assertNotIn('email', form) + + def test_render_merge_requested(self): + model = self.app.model + barney = model.Person(display_name="Barney Rubble") + self.session.add(barney) + user = model.User(username='user') + self.session.add(user) + self.session.commit() + view = self.make_view() + + # null by default + html = view.render_merge_requested(barney, 'merge_requested', None, + session=self.session) + self.assertIsNone(html) + + # unless a merge request exists + barney2 = model.Person(display_name="Barney Rubble") + self.session.add(barney2) + self.session.commit() + mr = model.MergePeopleRequest(removing_uuid=barney2.uuid, + keeping_uuid=barney.uuid, + requested_by=user) + self.session.add(mr) + self.session.commit() + html = view.render_merge_requested(barney, 'merge_requested', None, + session=self.session) + self.assertIn('<span ', html) diff --git a/tox.ini b/tox.ini index 8681465d..3896befb 100644 --- a/tox.ini +++ b/tox.ini @@ -1,25 +1,19 @@ [tox] -envlist = py36, py37, py39 +envlist = py38, py39, py310, py311 [testenv] -commands = - pip install --upgrade pip - pip install --upgrade setuptools wheel - pip install --upgrade --upgrade-strategy eager Tailbone[tests] rattail[bouncer,db] rattail-tempmon - pytest {posargs} +deps = rattail-tempmon +extras = tests +commands = pytest {posargs} [testenv:coverage] basepython = python3 -commands = - pip install --upgrade pip - pip install --upgrade --upgrade-strategy eager Tailbone[tests] rattail[bouncer,db] rattail-tempmon - pytest --cov=tailbone --cov-report=html +extras = tests +commands = pytest --cov=tailbone --cov-report=html [testenv:docs] basepython = python3 changedir = docs -commands = - pip install --upgrade pip - pip install --upgrade --upgrade-strategy eager Tailbone[docs] rattail[bouncer,db] rattail-tempmon - sphinx-build -b html -d {envtmpdir}/doctrees -W -T . {envtmpdir}/docs +extras = docs +commands = sphinx-build -b html -d {envtmpdir}/doctrees -W -T . {envtmpdir}/docs