Compare commits

..

No commits in common. "master" and "v0.9.83" have entirely different histories.

268 changed files with 8116 additions and 16063 deletions

3
.gitignore vendored
View file

@ -1,8 +1,5 @@
*~
*.pyc
.coverage
.tox/
dist/
docs/_build/
htmlcov/
Tailbone.egg-info/

View file

@ -1,683 +0,0 @@
# Changelog
All notable changes to Tailbone will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
## v0.22.7 (2025-02-19)
### Fix
- stop using old config for logo image url on login page
- fix warning msg for deprecated Grid param
## v0.22.6 (2025-02-01)
### Fix
- register vue3 form component for products -> make batch
## v0.22.5 (2024-12-16)
### Fix
- whoops this is latest rattail
- require newer rattail lib
- require newer wuttaweb
- let caller request safe HTML literal for rendered grid table
## v0.22.4 (2024-11-22)
### Fix
- avoid error in product search for duplicated key
- use vmodel for confirm password widget input
## v0.22.3 (2024-11-19)
### Fix
- avoid error for trainwreck query when not a customer
## v0.22.2 (2024-11-18)
### Fix
- use local/custom enum for continuum operations
- add basic master view for Product Costs
- show continuum operation type when viewing version history
- always define `app` attr for ViewSupplement
- avoid deprecated import
## v0.22.1 (2024-11-02)
### Fix
- fix submit button for running problem report
- avoid deprecated grid method
## v0.22.0 (2024-10-22)
### Feat
- add support for new ordering batch from parsed file
### Fix
- avoid deprecated method to suggest username
## v0.21.11 (2024-10-03)
### Fix
- custom method for adding grid action
- become/stop root should redirect to previous url
## v0.21.10 (2024-09-15)
### Fix
- update project repo links, kallithea -> forgejo
- use better icon for submit button on login page
- wrap notes text for batch view
- expose datasync consumer batch size via configure page
## v0.21.9 (2024-08-28)
### Fix
- render custom attrs in form component tag
## v0.21.8 (2024-08-28)
### Fix
- ignore session kwarg for `MasterView.make_row_grid()`
## v0.21.7 (2024-08-28)
### Fix
- avoid error when form value cannot be obtained
## v0.21.6 (2024-08-28)
### Fix
- avoid error when grid value cannot be obtained
## v0.21.5 (2024-08-28)
### Fix
- set empty string for "-new-" file configure option
## v0.21.4 (2024-08-26)
### Fix
- handle differing email profile keys for appinfo/configure
## v0.21.3 (2024-08-26)
### Fix
- show non-standard config values for app info configure email
## v0.21.2 (2024-08-26)
### Fix
- refactor waterpark base template to use wutta feedback component
- fix input/output file upload feature for configure pages, per oruga
- tweak how grid data translates to Vue template context
- merge filters into main grid template
- add basic wutta view for users
- some fixes for wutta people view
- various fixes for waterpark theme
- avoid deprecated `component` form kwarg
## v0.21.1 (2024-08-22)
### Fix
- misc. bugfixes per recent changes
## v0.21.0 (2024-08-22)
### Feat
- move "most" filtering logic for grid class to wuttaweb
- inherit from wuttaweb templates for home, login pages
- inherit from wuttaweb for AppInfoView, appinfo/configure template
- add "has output file templates" config option for master view
### Fix
- change grid reset-view param name to match wuttaweb
- move "searchable columns" grid feature to wuttaweb
- use wuttaweb to get/render csrf token
- inherit from wuttaweb for appinfo/index template
- prefer wuttaweb config for "home redirect to login" feature
- fix master/index template rendering for waterpark theme
- fix spacing for navbar logo/title in waterpark theme
## v0.20.1 (2024-08-20)
### Fix
- fix default filter verbs logic for workorder status
## v0.20.0 (2024-08-20)
### Feat
- add new 'waterpark' theme, based on wuttaweb w/ vue2 + buefy
- refactor templates to simplify base/page/form structure
### Fix
- avoid deprecated reference to app db engine
## v0.19.3 (2024-08-19)
### Fix
- add pager stats to all grid vue data (fixes view history)
## v0.19.2 (2024-08-19)
### Fix
- sort on frontend for appinfo package listing grid
- prefer attr over key lookup when getting model values
- replace all occurrences of `component_studly` => `vue_component`
## v0.19.1 (2024-08-19)
### Fix
- fix broken user auth for web API app
## v0.19.0 (2024-08-18)
### Feat
- move multi-column grid sorting logic to wuttaweb
- move single-column grid sorting logic to wuttaweb
### Fix
- fix misc. errors in grid template per wuttaweb
- fix broken permission directives in web api startup
## v0.18.0 (2024-08-16)
### Feat
- move "basic" grid pagination logic to wuttaweb
- inherit from wutta base class for Grid
- inherit most logic from wuttaweb, for GridAction
### Fix
- avoid route error in user view, when using wutta people view
- fix some more wutta compat for base template
## v0.17.0 (2024-08-15)
### Feat
- use wuttaweb for `get_liburl()` logic
## v0.16.1 (2024-08-15)
### Fix
- improve wutta People view a bit
- update references to `get_class_hierarchy()`
- tweak template for `people/view_profile` per wutta compat
## v0.16.0 (2024-08-15)
### Feat
- add first wutta-based master, for PersonView
- refactor forms/grids/views/templates per wuttaweb compat
## v0.15.6 (2024-08-13)
### Fix
- avoid `before_render` subscriber hook for web API
- simplify verbiage for batch execution panel
## v0.15.5 (2024-08-09)
### Fix
- assign convenience attrs for all views (config, app, enum, model)
## v0.15.4 (2024-08-09)
### Fix
- avoid bug when checking current theme
## v0.15.3 (2024-08-08)
### Fix
- fix timepicker `parseTime()` when value is null
## v0.15.2 (2024-08-06)
### Fix
- use auth handler, avoid legacy calls for role/perm checks
## v0.15.1 (2024-08-05)
### Fix
- move magic `b` template context var to wuttaweb
## v0.15.0 (2024-08-05)
### Feat
- move more subscriber logic to wuttaweb
### Fix
- use wuttaweb logic for `util.get_form_data()`
## v0.14.5 (2024-08-03)
### Fix
- use auth handler instead of deprecated auth functions
- avoid duplicate `partial` param when grid reloads data
## v0.14.4 (2024-07-18)
### Fix
- fix more settings persistence bug(s) for datasync/configure
- fix modals for luigi tasks page, per oruga
## v0.14.3 (2024-07-17)
### Fix
- fix auto-collapse title for viewing trainwreck txn
- allow auto-collapse of header when viewing trainwreck txn
## v0.14.2 (2024-07-15)
### Fix
- add null menu handler, for use with API apps
## v0.14.1 (2024-07-14)
### Fix
- update usage of auth handler, per rattail changes
- fix model reference in menu handler
- fix bug when making "integration" menus
## v0.14.0 (2024-07-14)
### Feat
- move core menu logic to wuttaweb
## v0.13.2 (2024-07-13)
### Fix
- fix logic bug for datasync/config settings save
## v0.13.1 (2024-07-13)
### Fix
- fix settings persistence bug(s) for datasync/configure page
## v0.13.0 (2024-07-12)
### Feat
- begin integrating WuttaWeb as upstream dependency
### Fix
- cast enum as list to satisfy deform widget
## v0.12.1 (2024-07-11)
### Fix
- refactor `config.get_model()` => `app.model`
## v0.12.0 (2024-07-09)
### Feat
- drop python 3.6 support, use pyproject.toml (again)
## v0.11.10 (2024-07-05)
### Fix
- make the Members tab optional, for profile view
## v0.11.9 (2024-07-05)
### Fix
- do not show flash message when changing app theme
- improve collapse panels for butterball theme
- expand input for butterball theme
- add xref button to customer profile, for trainwreck txn view
- add optional Transactions tab for profile view
## v0.11.8 (2024-07-04)
### Fix
- fix grid action icons for datasync/configure, per oruga
- allow view supplements to add extra links for profile employee tab
- leverage import handler method to determine command/subcommand
- add tool to make user account from profile view
## v0.11.7 (2024-07-04)
### Fix
- add stacklevel to deprecation warnings
- require zope.sqlalchemy >= 1.5
- include edit profile email/phone dialogs only if user has perms
- allow view supplements to add to profile member context
- cast enum as list to satisfy deform widget
- expand POD image URL setting input
## v0.11.6 (2024-07-01)
### Fix
- set explicit referrer when changing dbkey
- remove references, dependency for `six` package
## v0.11.5 (2024-06-30)
### Fix
- allow comma in numeric filter input
- add custom url prefix if needed, for fanstatic
- use vue 3.4.31 and oruga 0.8.12 by default
## v0.11.4 (2024-06-30)
### Fix
- start/stop being root should submit POST instead of GET
- require vendor when making new ordering batch via api
- don't escape each address for email attempts grid
## v0.11.3 (2024-06-28)
### Fix
- add link to "resolved by" user for pending products
- handle error when merging 2 records fails
## v0.11.2 (2024-06-18)
### Fix
- hide certain custorder settings if not applicable
- use different logic for buefy/oruga for product lookup keydown
- product records should be touchable
- show flash error message if resolve pending product fails
## v0.11.1 (2024-06-14)
### Fix
- revert back to setup.py + setup.cfg
## v0.11.0 (2024-06-10)
### Feat
- switch from setup.cfg to pyproject.toml + hatchling
## v0.10.16 (2024-06-10)
### Feat
- standardize how app, package versions are determined
### Fix
- avoid deprecated config methods for app/node title
## v0.10.15 (2024-06-07)
### Fix
- do *not* Use `pkg_resources` to determine package versions
## v0.10.14 (2024-06-06)
### Fix
- use `pkg_resources` to determine package versions
## v0.10.13 (2024-06-06)
### Feat
- remove old/unused scaffold for use with `pcreate`
- add 'fanstatic' support for sake of libcache assets
## v0.10.12 (2024-06-04)
### Feat
- require pyramid 2.x; remove 1.x-style auth policies
- remove version cap for deform
- set explicit referrer when changing app theme
- add `<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.

View file

@ -2,129 +2,6 @@
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)
-------------------
@ -4992,7 +4869,7 @@ and related technologies.
0.6.47 (2017-11-08)
-------------------
* Fix manifest to include ``*.pt`` deform templates
* Fix manifest to include *.pt deform templates
0.6.46 (2017-11-08)
@ -5325,13 +5202,13 @@ and related technologies.
0.6.13 (2017-07-26)
-------------------
------------------
* Allow master view to decide whether each grid checkbox is checked
0.6.12 (2017-07-26)
-------------------
------------------
* Add basic support for product inventory and status
@ -5339,7 +5216,7 @@ and related technologies.
0.6.11 (2017-07-18)
-------------------
------------------
* Tweak some basic styles for forms/grids
@ -5347,7 +5224,7 @@ and related technologies.
0.6.10 (2017-07-18)
-------------------
------------------
* Fix grid bug if "current page" becomes invalid

View file

@ -1,8 +1,10 @@
# Tailbone
Tailbone
========
Tailbone is an extensible web application based on Rattail. It provides a
"back-office network environment" (BONE) for use in managing retail data.
Please see Rattail's [home page](http://rattailproject.org/) for more
information.
Please see Rattail's `home page`_ for more information.
.. _home page: http://rattailproject.org/

View file

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

View file

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

View file

@ -1,6 +0,0 @@
``tailbone.forms.widgets``
==========================
.. automodule:: tailbone.forms.widgets
:members:

View file

@ -3,4 +3,5 @@
========================
.. automodule:: tailbone.subscribers
:members:
.. autofunction:: new_request

View file

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

View file

@ -81,12 +81,6 @@ 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
-------------------
@ -106,14 +100,6 @@ 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
---------------

View file

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

View file

@ -1,8 +0,0 @@
Changelog Archive
=================
.. toctree::
:maxdepth: 1
OLDCHANGES

View file

@ -1,21 +1,38 @@
# Configuration file for the Sphinx documentation builder.
# -*- coding: utf-8; -*-
#
# For the full list of built-in configuration values, see the documentation:
# https://www.sphinx-doc.org/en/master/usage/configuration.html
# Tailbone documentation build configuration file, created by
# sphinx-quickstart on Sat Feb 15 23:15:27 2014.
#
# This file is exec()d with the current directory set to its
# containing dir.
#
# Note that not all possible configuration values are present in this
# autogenerated file.
#
# All configuration values have a default; values that are commented out
# serve to show the default.
# -- Project information -----------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
import sys
import os
from importlib.metadata import version as get_version
import sphinx_rtd_theme
project = 'Tailbone'
copyright = '2010 - 2024, Lance Edgar'
author = 'Lance Edgar'
release = get_version('Tailbone')
exec(open(os.path.join(os.pardir, 'tailbone', '_version.py')).read())
# -- General configuration ---------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
# If extensions (or modules to document with autodoc) are in another directory,
# add these directories to sys.path here. If the directory is relative to the
# documentation root, use os.path.abspath to make it absolute, like shown here.
#sys.path.insert(0, os.path.abspath('.'))
# -- General configuration ------------------------------------------------
# If your documentation needs a minimal Sphinx version, state it here.
#needs_sphinx = '1.0'
# Add any Sphinx extension module names here, as strings. They can be
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
# ones.
extensions = [
'sphinx.ext.autodoc',
'sphinx.ext.todo',
@ -23,30 +40,241 @@ extensions = [
'sphinx.ext.viewcode',
]
templates_path = ['_templates']
exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
intersphinx_mapping = {
'rattail': ('https://docs.wuttaproject.org/rattail/', None),
'rattail': ('https://rattailproject.org/docs/rattail/', None),
'webhelpers2': ('https://webhelpers2.readthedocs.io/en/latest/', None),
'wuttaweb': ('https://docs.wuttaproject.org/wuttaweb/', None),
'wuttjamaican': ('https://docs.wuttaproject.org/wuttjamaican/', None),
}
# allow todo entries to show up
# Add any paths that contain templates here, relative to this directory.
templates_path = ['_templates']
# The suffix of source filenames.
source_suffix = '.rst'
# The encoding of source files.
#source_encoding = 'utf-8-sig'
# The master toctree document.
master_doc = 'index'
# General information about the project.
project = u'Tailbone'
copyright = u'2010 - 2020, Lance Edgar'
# The version info for the project you're documenting, acts as replacement for
# |version| and |release|, also used in various other places throughout the
# built documents.
#
# The short X.Y version.
# version = '0.3'
version = '.'.join(__version__.split('.')[:2])
# The full version, including alpha/beta/rc tags.
release = __version__
# The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages.
#language = None
# There are two options for replacing |today|: either, you set today to some
# non-false value, then it is used:
#today = ''
# Else, today_fmt is used as the format for a strftime call.
#today_fmt = '%B %d, %Y'
# List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files.
exclude_patterns = ['_build']
# The reST default role (used for this markup: `text`) to use for all
# documents.
#default_role = None
# If true, '()' will be appended to :func: etc. cross-reference text.
#add_function_parentheses = True
# If true, the current module name will be prepended to all description
# unit titles (such as .. function::).
#add_module_names = True
# If true, sectionauthor and moduleauthor directives will be shown in the
# output. They are ignored by default.
#show_authors = False
# The name of the Pygments (syntax highlighting) style to use.
pygments_style = 'sphinx'
# A list of ignored prefixes for module index sorting.
#modindex_common_prefix = []
# If true, keep warnings as "system message" paragraphs in the built documents.
#keep_warnings = False
# Allow todo entries to show up.
todo_include_todos = True
# -- Options for HTML output -------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output
# -- Options for HTML output ----------------------------------------------
html_theme = 'furo'
html_static_path = ['_static']
# The theme to use for HTML and HTML Help pages. See the documentation for
# a list of builtin themes.
# html_theme = 'classic'
html_theme = 'sphinx_rtd_theme'
# Theme options are theme-specific and customize the look and feel of a theme
# further. For a list of options available for each theme, see the
# documentation.
#html_theme_options = {}
# Add any paths that contain custom themes here, relative to this directory.
#html_theme_path = []
html_theme_path = [sphinx_rtd_theme.get_html_theme_path()]
# The name for this set of Sphinx documents. If None, it defaults to
# "<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
# The name of an image file (relative to this directory) to place at the top
# of the sidebar.
#html_logo = None
#html_logo = 'images/rattail_avatar.png'
html_logo = 'images/rattail_avatar.png'
# The name of an image file (within the static path) to use as favicon of the
# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32
# pixels large.
#html_favicon = None
# Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files,
# so a file named "default.css" will overwrite the builtin "default.css".
html_static_path = ['_static']
# Add any extra paths that contain custom files (such as robots.txt or
# .htaccess) here, relative to this directory. These files are copied
# directly to the root of the documentation.
#html_extra_path = []
# If not '', a 'Last updated on:' timestamp is inserted at every page bottom,
# using the given strftime format.
#html_last_updated_fmt = '%b %d, %Y'
# If true, SmartyPants will be used to convert quotes and dashes to
# typographically correct entities.
#html_use_smartypants = True
# Custom sidebar templates, maps document names to template names.
#html_sidebars = {}
# Additional templates that should be rendered to pages, maps page names to
# template names.
#html_additional_pages = {}
# If false, no module index is generated.
#html_domain_indices = True
# If false, no index is generated.
#html_use_index = True
# If true, the index is split into individual pages for each letter.
#html_split_index = False
# If true, links to the reST sources are added to the pages.
#html_show_sourcelink = True
# If true, "Created using Sphinx" is shown in the HTML footer. Default is True.
#html_show_sphinx = True
# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True.
#html_show_copyright = True
# If true, an OpenSearch description file will be output, and all pages will
# contain a <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
# Output file base name for HTML help builder.
#htmlhelp_basename = 'Tailbonedoc'
htmlhelp_basename = 'Tailbonedoc'
# -- Options for LaTeX output ---------------------------------------------
latex_elements = {
# The paper size ('letterpaper' or 'a4paper').
#'papersize': 'letterpaper',
# The font size ('10pt', '11pt' or '12pt').
#'pointsize': '10pt',
# Additional stuff for the LaTeX preamble.
#'preamble': '',
}
# Grouping the document tree into LaTeX files. List of tuples
# (source start file, target name, title,
# author, documentclass [howto, manual, or own class]).
latex_documents = [
('index', 'Tailbone.tex', u'Tailbone Documentation',
u'Lance Edgar', 'manual'),
]
# The name of an image file (relative to this directory) to place at the top of
# the title page.
#latex_logo = None
# For "manual" documents, if this is true, then toplevel headings are parts,
# not chapters.
#latex_use_parts = False
# If true, show page references after internal links.
#latex_show_pagerefs = False
# If true, show URL addresses after external links.
#latex_show_urls = False
# Documents to append as an appendix to all manuals.
#latex_appendices = []
# If false, no module index is generated.
#latex_domain_indices = True
# -- Options for manual page output ---------------------------------------
# One entry per manual page. List of tuples
# (source start file, name, description, authors, manual section).
man_pages = [
('index', 'tailbone', u'Tailbone Documentation',
[u'Lance Edgar'], 1)
]
# If true, show URL addresses after external links.
#man_show_urls = False
# -- Options for Texinfo output -------------------------------------------
# Grouping the document tree into Texinfo files. List of tuples
# (source start file, target name, title, author,
# dir menu entry, description, category)
texinfo_documents = [
('index', 'Tailbone', u'Tailbone Documentation',
u'Lance Edgar', 'Tailbone', 'One line description of project.',
'Miscellaneous'),
]
# Documents to append as an appendix to all manuals.
#texinfo_appendices = []
# If false, no module index is generated.
#texinfo_domain_indices = True
# How to display URL addresses: 'footnote', 'no', or 'inline'.
#texinfo_show_urls = 'footnote'
# If true, do not generate a @detailmenu in the "Top" node's menu.
#texinfo_no_detailmenu = False

View file

@ -44,32 +44,19 @@ 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
===================

View file

@ -1,103 +0,0 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "Tailbone"
version = "0.22.7"
description = "Backoffice Web Application for Rattail"
readme = "README.md"
authors = [{name = "Lance Edgar", email = "lance@edbob.org"}]
license = {text = "GNU GPL v3+"}
classifiers = [
"Development Status :: 4 - Beta",
"Environment :: Web Environment",
"Framework :: Pyramid",
"Intended Audience :: Developers",
"License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)",
"Natural Language :: English",
"Operating System :: OS Independent",
"Programming Language :: Python",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Topic :: Internet :: WWW/HTTP",
"Topic :: Office/Business",
"Topic :: Software Development :: Libraries :: Python Modules",
]
requires-python = ">= 3.8"
dependencies = [
"asgiref",
"colander",
"ColanderAlchemy",
"cornice",
"cornice-swagger",
"deform",
"humanize",
"Mako",
"markdown",
"openpyxl",
"paginate",
"paginate_sqlalchemy",
"passlib",
"Pillow",
"pyramid>=2",
"pyramid_beaker",
"pyramid_deform",
"pyramid_exclog",
"pyramid_fanstatic",
"pyramid_mako",
"pyramid_retry",
"pyramid_tm",
"rattail[db,bouncer]>=0.20.1",
"sa-filters",
"simplejson",
"transaction",
"waitress",
"WebHelpers2",
"WuttaWeb>=0.21.0",
"zope.sqlalchemy>=1.5",
]
[project.optional-dependencies]
docs = ["Sphinx", "furo"]
tests = ["coverage", "mock", "pytest", "pytest-cov"]
[project.entry-points."paste.app_factory"]
main = "tailbone.app:main"
webapi = "tailbone.webapi:main"
[project.entry-points."rattail.cleaners"]
beaker = "tailbone.cleanup:BeakerCleaner"
[project.entry-points."rattail.config.extensions"]
tailbone = "tailbone.config:ConfigExtension"
[project.urls]
Homepage = "https://rattailproject.org"
Repository = "https://forgejo.wuttaproject.org/rattail/tailbone"
Issues = "https://forgejo.wuttaproject.org/rattail/tailbone/issues"
Changelog = "https://forgejo.wuttaproject.org/rattail/tailbone/src/branch/master/CHANGELOG.md"
[tool.commitizen]
version_provider = "pep621"
tag_format = "v$version"
update_changelog_on_bump = true
[tool.nosetests]
nocapture = 1
cover-package = "tailbone"
cover-erase = 1
cover-html = 1
cover-html-dir = "htmlcov"

108
setup.cfg Normal file
View file

@ -0,0 +1,108 @@
# -*- 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

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2024 Lance Edgar
# Copyright © 2010-2023 Lance Edgar
#
# This file is part of Rattail.
#
@ -21,37 +21,9 @@
#
################################################################################
"""
User Views
Setup script for Tailbone
"""
from wuttaweb.views import users as wutta
from tailbone.views import users as tailbone
from tailbone.db import Session
from rattail.db.model import User
from tailbone.grids import Grid
from setuptools import setup
class UserView(wutta.UserView):
"""
This is the first attempt at blending newer Wutta views with
legacy Tailbone config.
So, this is a Wutta-based view but it should be included by a
Tailbone app configurator.
"""
model_class = User
Session = Session
# TODO: must use older grid for now, to render filters correctly
def make_grid(self, **kwargs):
""" """
return Grid(self.request, **kwargs)
def defaults(config, **kwargs):
kwargs.setdefault('UserView', UserView)
tailbone.defaults(config, **kwargs)
def includeme(config):
defaults(config)
setup()

View file

@ -1,9 +1,3 @@
# -*- coding: utf-8; -*-
try:
from importlib.metadata import version
except ImportError:
from importlib_metadata import version
__version__ = version('Tailbone')
__version__ = '0.9.83'

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2024 Lance Edgar
# Copyright © 2010-2023 Lance Edgar
#
# This file is part of Rattail.
#
@ -24,6 +24,8 @@
Tailbone Web API - Auth Views
"""
from rattail.db.auth import set_user_password
from cornice import Service
from tailbone.api import APIView, api
@ -40,10 +42,11 @@ class AuthenticationView(APIView):
This will establish a server-side web session for the user if none
exists. Note that this also resets the user's session timer.
"""
data = {'ok': True, 'permissions': []}
data = {'ok': True}
if self.request.user:
data['user'] = self.get_user_info(self.request.user)
data['permissions'] = list(self.request.user_permissions)
data['permissions'] = list(self.request.tailbone_cached_permissions)
# background color may be set per-request, by some apps
if hasattr(self.request, 'background_color') and self.request.background_color:
@ -173,8 +176,7 @@ class AuthenticationView(APIView):
return {'error': "The current/old password you provided is incorrect"}
# okay then, set new password
auth = self.app.get_auth_handler()
auth.set_user_password(self.request.user, data['new_password'])
set_user_password(self.request.user, data['new_password'])
return {
'ok': True,
'user': self.get_user_info(self.request.user),

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2024 Lance Edgar
# Copyright © 2010-2022 Lance Edgar
#
# This file is part of Rattail.
#
@ -24,6 +24,10 @@
Tailbone Web API - Label Batches
"""
from __future__ import unicode_literals, absolute_import
import six
from rattail.db import model
from tailbone.api.batch import APIBatchView, APIBatchRowView
@ -52,10 +56,10 @@ class LabelBatchRowViews(APIBatchRowView):
def normalize(self, row):
batch = row.batch
data = super().normalize(row)
data = super(LabelBatchRowViews, self).normalize(row)
data['item_id'] = row.item_id
data['upc'] = str(row.upc)
data['upc'] = six.text_type(row.upc)
data['upc_pretty'] = row.upc.pretty() if row.upc else None
data['brand_name'] = row.brand_name
data['description'] = row.description

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2024 Lance Edgar
# Copyright © 2010-2023 Lance Edgar
#
# This file is part of Rattail.
#
@ -28,23 +28,18 @@ API.
"""
import datetime
import logging
import sqlalchemy as sa
from rattail.db.model import PurchaseBatch, PurchaseBatchRow
from rattail.db import model
from rattail.util import pretty_quantity
from cornice import Service
from tailbone.api.batch import APIBatchView, APIBatchRowView
log = logging.getLogger(__name__)
class OrderingBatchViews(APIBatchView):
model_class = PurchaseBatch
model_class = model.PurchaseBatch
default_handler_spec = 'rattail.batch.purchase:PurchaseBatchHandler'
route_prefix = 'orderingbatchviews'
permission_prefix = 'ordering'
@ -60,13 +55,12 @@ class OrderingBatchViews(APIBatchView):
Adds a condition to the query, to ensure only purchase batches with
"ordering" mode are returned.
"""
model = self.model
query = super().base_query()
query = super(OrderingBatchViews, self).base_query()
query = query.filter(model.PurchaseBatch.mode == self.enum.PURCHASE_BATCH_MODE_ORDERING)
return query
def normalize(self, batch):
data = super().normalize(batch)
data = super(OrderingBatchViews, self).normalize(batch)
data['vendor_uuid'] = batch.vendor.uuid
data['vendor_display'] = str(batch.vendor)
@ -86,10 +80,8 @@ class OrderingBatchViews(APIBatchView):
Sets the mode to "ordering" for the new batch.
"""
data = dict(data)
if not data.get('vendor_uuid'):
raise ValueError("You must specify the vendor")
data['mode'] = self.enum.PURCHASE_BATCH_MODE_ORDERING
batch = super().create_object(data)
batch = super(OrderingBatchViews, self).create_object(data)
return batch
def worksheet(self):
@ -229,7 +221,7 @@ class OrderingBatchViews(APIBatchView):
class OrderingBatchRowViews(APIBatchRowView):
model_class = PurchaseBatchRow
model_class = model.PurchaseBatchRow
default_handler_spec = 'rattail.batch.purchase:PurchaseBatchHandler'
route_prefix = 'ordering.rows'
permission_prefix = 'ordering'
@ -239,9 +231,8 @@ 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)
@ -261,8 +252,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'] = 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['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['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
@ -290,17 +281,7 @@ class OrderingBatchRowViews(APIBatchRowView):
if not self.batch_handler.is_mutable(row.batch):
return {'error': "Batch is not mutable"}
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}
self.batch_handler.update_row_quantity(row, **data)
return row

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2024 Lance Edgar
# Copyright © 2010-2023 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.model import PurchaseBatch, PurchaseBatchRow
from rattail.db import model
from rattail.util import pretty_quantity
from cornice import Service
from deform import widget as dfwidget
@ -44,7 +44,7 @@ log = logging.getLogger(__name__)
class ReceivingBatchViews(APIBatchView):
model_class = PurchaseBatch
model_class = model.PurchaseBatch
default_handler_spec = 'rattail.batch.purchase:PurchaseBatchHandler'
route_prefix = 'receivingbatchviews'
permission_prefix = 'receiving'
@ -54,8 +54,7 @@ class ReceivingBatchViews(APIBatchView):
supports_execute = True
def base_query(self):
model = self.app.model
query = super().base_query()
query = super(ReceivingBatchViews, self).base_query()
query = query.filter(model.PurchaseBatch.mode == self.enum.PURCHASE_BATCH_MODE_RECEIVING)
return query
@ -85,7 +84,7 @@ class ReceivingBatchViews(APIBatchView):
# assume "receive from PO" if given a PO key
if data.get('purchase_key'):
data['workflow'] = 'from_po'
data['receiving_workflow'] = 'from_po'
return super().create_object(data)
@ -120,7 +119,6 @@ class ReceivingBatchViews(APIBatchView):
return self._get(obj=batch)
def eligible_purchases(self):
model = self.app.model
uuid = self.request.params.get('vendor_uuid')
vendor = self.Session.get(model.Vendor, uuid) if uuid else None
if not vendor:
@ -177,7 +175,7 @@ class ReceivingBatchViews(APIBatchView):
class ReceivingBatchRowViews(APIBatchRowView):
model_class = PurchaseBatchRow
model_class = model.PurchaseBatchRow
default_handler_spec = 'rattail.batch.purchase:PurchaseBatchHandler'
route_prefix = 'receiving.rows'
permission_prefix = 'receiving'
@ -186,8 +184,7 @@ class ReceivingBatchRowViews(APIBatchRowView):
supports_quick_entry = True
def make_filter_spec(self):
model = self.app.model
filters = super().make_filter_spec()
filters = super(ReceivingBatchRowViews, self).make_filter_spec()
if filters:
# must translate certain convenience filters
@ -298,11 +295,11 @@ class ReceivingBatchRowViews(APIBatchRowView):
return filters
def normalize(self, row):
data = super().normalize(row)
model = self.app.model
data = super(ReceivingBatchRowViews, self).normalize(row)
batch = row.batch
prodder = self.app.get_products_handler()
app = self.get_rattail_app()
prodder = app.get_products_handler()
data['product_uuid'] = row.product_uuid
data['item_id'] = row.item_id
@ -377,7 +374,7 @@ class ReceivingBatchRowViews(APIBatchRowView):
if accounted_for:
# some product accounted for; button should receive "remainder" only
if remainder:
remainder = self.app.render_quantity(remainder)
remainder = pretty_quantity(remainder)
data['quick_receive_quantity'] = remainder
data['quick_receive_text'] = "Receive Remainder ({} {})".format(
remainder, data['unit_uom'])
@ -388,7 +385,7 @@ class ReceivingBatchRowViews(APIBatchRowView):
else: # nothing yet accounted for, button should receive "all"
if not remainder:
log.warning("quick receive remainder is empty for row %s", row.uuid)
remainder = self.app.render_quantity(remainder)
remainder = pretty_quantity(remainder)
data['quick_receive_quantity'] = remainder
data['quick_receive_text'] = "Receive ALL ({} {})".format(
remainder, data['unit_uom'])
@ -416,7 +413,7 @@ class ReceivingBatchRowViews(APIBatchRowView):
data['received_alert'] = None
if self.batch_handler.get_units_confirmed(row):
msg = "You have already received some of this product; last update was {}.".format(
humanize.naturaltime(self.app.make_utc() - row.modified))
humanize.naturaltime(app.make_utc() - row.modified))
data['received_alert'] = msg
return data
@ -425,8 +422,6 @@ class ReceivingBatchRowViews(APIBatchRowView):
"""
View which handles "receiving" against a particular batch row.
"""
model = self.app.model
# first do basic input validation
schema = ReceiveRow().bind(session=self.Session())
form = forms.Form(schema=schema, request=self.request)
@ -445,17 +440,9 @@ class ReceivingBatchRowViews(APIBatchRowView):
# handler takes care of the row receiving logic for us
kwargs = dict(form.validated)
del kwargs['row']
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.batch_handler.receive_row(row, **kwargs)
self.Session.flush()
return self._get(obj=row)
@classmethod

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2024 Lance Edgar
# Copyright © 2010-2023 Lance Edgar
#
# This file is part of Rattail.
#
@ -26,12 +26,15 @@ Tailbone Web API - "Common" Views
from collections import OrderedDict
from rattail.util import get_pkg_version
import rattail
from rattail.db import model
from rattail.mail import send_email
from cornice import Service
from cornice.service import get_services
from cornice_swagger import CorniceSwagger
import tailbone
from tailbone import forms
from tailbone.forms.common import Feedback
from tailbone.api import APIView, api
@ -63,12 +66,11 @@ class CommonView(APIView):
}
def get_project_title(self):
app = self.get_rattail_app()
return app.get_title()
return self.rattail_config.app_title(default="Tailbone")
def get_project_version(self):
app = self.get_rattail_app()
return app.get_version()
import tailbone
return tailbone.__version__
def get_packages(self):
"""
@ -76,8 +78,8 @@ class CommonView(APIView):
'about' page.
"""
return OrderedDict([
('rattail', get_pkg_version('rattail')),
('Tailbone', get_pkg_version('Tailbone')),
('rattail', rattail.__version__),
('Tailbone', tailbone.__version__),
])
@api
@ -85,8 +87,6 @@ class CommonView(APIView):
"""
View to handle user feedback form submits.
"""
app = self.get_rattail_app()
model = self.model
# TODO: this logic was copied from tailbone.views.common and is largely
# identical; perhaps should merge somehow?
schema = Feedback().bind(session=Session())
@ -106,7 +106,7 @@ class CommonView(APIView):
data['client_ip'] = self.request.client_addr
email_key = data['email_key'] or self.feedback_email_key
app.send_email(email_key, data=data)
send_email(self.rattail_config, email_key, data=data)
return {'ok': True}
return {'error': "Form did not validate!"}

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2024 Lance Edgar
# Copyright © 2010-2023 Lance Edgar
#
# This file is part of Rattail.
#
@ -102,7 +102,7 @@ class APIView(View):
auth = app.get_auth_handler()
# basic / default info
is_admin = auth.user_is_admin(user)
is_admin = user.is_admin()
employee = app.get_employee(user)
info = {
'uuid': user.uuid,

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2024 Lance Edgar
# Copyright © 2010-2022 Lance Edgar
#
# This file is part of Rattail.
#
@ -24,6 +24,10 @@
Tailbone Web API - Customer Views
"""
from __future__ import unicode_literals, absolute_import
import six
from rattail.db import model
from tailbone.api import APIMasterView
@ -42,7 +46,7 @@ class CustomerView(APIMasterView):
def normalize(self, customer):
return {
'uuid': customer.uuid,
'_str': str(customer),
'_str': six.text_type(customer),
'id': customer.id,
'number': customer.number,
'name': customer.name,

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2024 Lance Edgar
# Copyright © 2010-2023 Lance Edgar
#
# This file is part of Rattail.
#
@ -26,11 +26,12 @@ Tailbone Web API - Master View
import json
from rattail.config import parse_bool
from rattail.db.util import get_fieldnames
from cornice import resource, Service
from tailbone.api import APIView
from tailbone.api import APIView, api
from tailbone.db import Session
from tailbone.util import SortColumn
@ -184,7 +185,7 @@ class APIMasterView(APIView):
if sortcol:
spec = {
'field': sortcol.field_name,
'direction': 'asc' if self.config.parse_bool(self.request.params['ascending']) else 'desc',
'direction': 'asc' if parse_bool(self.request.params['ascending']) else 'desc',
}
if sortcol.model_name:
spec['model'] = sortcol.model_name
@ -354,13 +355,9 @@ class APIMasterView(APIView):
data = self.request.json_body
# add instance to session, and return data for it
try:
obj = self.create_object(data)
except Exception as error:
return self.json_response({'error': str(error)})
else:
self.Session.flush()
return self._get(obj)
obj = self.create_object(data)
self.Session.flush()
return self._get(obj)
def create_object(self, data):
"""

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2024 Lance Edgar
# Copyright © 2010-2022 Lance Edgar
#
# This file is part of Rattail.
#
@ -24,6 +24,10 @@
Tailbone Web API - Person Views
"""
from __future__ import unicode_literals, absolute_import
import six
from rattail.db import model
from tailbone.api import APIMasterView
@ -41,7 +45,7 @@ class PersonView(APIMasterView):
def normalize(self, person):
return {
'uuid': person.uuid,
'_str': str(person),
'_str': six.text_type(person),
'first_name': person.first_name,
'last_name': person.last_name,
'display_name': person.display_name,

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2024 Lance Edgar
# Copyright © 2010-2022 Lance Edgar
#
# This file is part of Rattail.
#
@ -24,6 +24,10 @@
Tailbone Web API - Upgrade Views
"""
from __future__ import unicode_literals, absolute_import
import six
from rattail.db import model
from tailbone.api import APIMasterView
@ -49,7 +53,7 @@ class UpgradeView(APIMasterView):
data['status_code'] = None
else:
data['status_code'] = self.enum.UPGRADE_STATUS.get(upgrade.status_code,
str(upgrade.status_code))
six.text_type(upgrade.status_code))
return data

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2024 Lance Edgar
# Copyright © 2010-2022 Lance Edgar
#
# This file is part of Rattail.
#
@ -24,6 +24,10 @@
Tailbone Web API - Vendor Views
"""
from __future__ import unicode_literals, absolute_import
import six
from rattail.db import model
from tailbone.api import APIMasterView
@ -40,7 +44,7 @@ class VendorView(APIMasterView):
def normalize(self, vendor):
return {
'uuid': vendor.uuid,
'_str': str(vendor),
'_str': six.text_type(vendor),
'id': vendor.id,
'name': vendor.name,
}

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2024 Lance Edgar
# Copyright © 2010-2022 Lance Edgar
#
# This file is part of Rattail.
#
@ -24,8 +24,12 @@
Tailbone Web API - Work Order Views
"""
from __future__ import unicode_literals, absolute_import
import datetime
import six
from rattail.db.model import WorkOrder
from cornice import Service
@ -40,19 +44,19 @@ class WorkOrderView(APIMasterView):
object_url_prefix = '/workorder'
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
super(WorkOrderView, self).__init__(*args, **kwargs)
app = self.get_rattail_app()
self.workorder_handler = app.get_workorder_handler()
def normalize(self, workorder):
data = super().normalize(workorder)
data = super(WorkOrderView, self).normalize(workorder)
data.update({
'customer_name': workorder.customer.name,
'status_label': self.enum.WORKORDER_STATUS[workorder.status_code],
'date_submitted': str(workorder.date_submitted or ''),
'date_received': str(workorder.date_received or ''),
'date_released': str(workorder.date_released or ''),
'date_delivered': str(workorder.date_delivered or ''),
'date_submitted': six.text_type(workorder.date_submitted or ''),
'date_received': six.text_type(workorder.date_received or ''),
'date_released': six.text_type(workorder.date_released or ''),
'date_delivered': six.text_type(workorder.date_delivered or ''),
})
return data
@ -83,7 +87,7 @@ class WorkOrderView(APIMasterView):
if 'status_code' in data:
data['status_code'] = int(data['status_code'])
return super().update_object(workorder, data)
return super(WorkOrderView, self).update_object(workorder, data)
def status_codes(self):
"""

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2024 Lance Edgar
# Copyright © 2010-2023 Lance Edgar
#
# This file is part of Rattail.
#
@ -25,19 +25,21 @@ Application Entry Point
"""
import os
import warnings
import sqlalchemy as sa
from sqlalchemy.orm import sessionmaker, scoped_session
from wuttjamaican.util import parse_list
from rattail.config import make_config
from rattail.config import make_config, parse_list
from rattail.exceptions import ConfigurationError
from rattail.db.types import GPCType
from pyramid.config import Configurator
from pyramid.authentication import SessionAuthenticationPolicy
from zope.sqlalchemy import register
import tailbone.db
from tailbone.auth import TailboneSecurityPolicy
from tailbone.auth import TailboneAuthorizationPolicy
from tailbone.config import csrf_token_name, csrf_header_name
from tailbone.util import get_effective_theme, get_theme_template_path
from tailbone.providers import get_all_providers
@ -59,23 +61,9 @@ def make_rattail_config(settings):
rattail_config = make_config(path)
settings['rattail_config'] = rattail_config
# nb. this is for compaibility with wuttaweb
settings['wutta_config'] = rattail_config
# must import all sqlalchemy models before things get rolling,
# otherwise can have errors about continuum TransactionMeta class
# not yet mapped, when relevant pages are first requested...
# cf. https://docs.pylonsproject.org/projects/pyramid_cookbook/en/latest/database/sqlalchemy.html#importing-all-sqlalchemy-models
# hat tip to https://stackoverflow.com/a/59241485
if getattr(rattail_config, 'tempmon_engine', None):
from rattail_tempmon.db import model as tempmon_model, Session as TempmonSession
tempmon_session = TempmonSession()
tempmon_session.query(tempmon_model.Appliance).first()
tempmon_session.close()
# configure database sessions
if hasattr(rattail_config, 'appdb_engine'):
tailbone.db.Session.configure(bind=rattail_config.appdb_engine)
if hasattr(rattail_config, 'rattail_engine'):
tailbone.db.Session.configure(bind=rattail_config.rattail_engine)
if hasattr(rattail_config, 'trainwreck_engine'):
tailbone.db.TrainwreckSession.configure(bind=rattail_config.trainwreck_engine)
if hasattr(rattail_config, 'tempmon_engine'):
@ -135,21 +123,18 @@ 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, for access throughout the app
# add rattail config directly to registry
config.registry['rattail_config'] = rattail_config
# configure user authorization / authentication
config.set_security_policy(TailboneSecurityPolicy())
config.set_authorization_policy(TailboneAuthorizationPolicy())
config.set_authentication_policy(SessionAuthenticationPolicy())
# maybe require CSRF token protection
if configure_csrf:
@ -160,7 +145,6 @@ def make_pyramid_config(settings, configure_csrf=True):
# Bring in some Pyramid goodies.
config.include('tailbone.beaker')
config.include('pyramid_deform')
config.include('pyramid_fanstatic')
config.include('pyramid_mako')
config.include('pyramid_tm')
@ -196,16 +180,9 @@ def make_pyramid_config(settings, configure_csrf=True):
for spec in includes:
config.include(spec)
# add some permissions magic
config.add_directive('add_wutta_permission_group',
'wuttaweb.auth.add_permission_group')
config.add_directive('add_wutta_permission',
'wuttaweb.auth.add_permission')
# TODO: deprecate / remove these
config.add_directive('add_tailbone_permission_group',
'wuttaweb.auth.add_permission_group')
config.add_directive('add_tailbone_permission',
'wuttaweb.auth.add_permission')
# Add some permissions magic.
config.add_directive('add_tailbone_permission_group', 'tailbone.auth.add_permission_group')
config.add_directive('add_tailbone_permission', 'tailbone.auth.add_permission')
# and some similar magic for certain master views
config.add_directive('add_tailbone_index_page', 'tailbone.app.add_index_page')
@ -332,8 +309,7 @@ def main(global_config, **settings):
"""
This function returns a Pyramid WSGI application.
"""
settings.setdefault('mako.directories', ['tailbone:templates',
'wuttaweb:templates'])
settings.setdefault('mako.directories', ['tailbone:templates'])
rattail_config = make_rattail_config(settings)
pyramid_config = make_pyramid_config(settings)
pyramid_config.include('tailbone')

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2024 Lance Edgar
# Copyright © 2010-2022 Lance Edgar
#
# This file is part of Rattail.
#
@ -24,10 +24,14 @@
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
@ -45,12 +49,6 @@ 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', {})
@ -87,7 +85,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, str):
if isinstance(main_app, six.string_types):
make_wsgi_app = load_object(main_app)
elif callable(main_app):
make_wsgi_app = main_app

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2024 Lance Edgar
# Copyright © 2010-2023 Lance Edgar
#
# This file is part of Rattail.
#
@ -27,28 +27,29 @@ Authentication & Authorization
import logging
import re
from wuttjamaican.util import UNSPECIFIED
from rattail import enum
from rattail.util import prettify, NOTSET
from pyramid.security import remember, forget
from zope.interface import implementer
from pyramid.interfaces import IAuthorizationPolicy
from pyramid.security import remember, forget, Everyone, Authenticated
from pyramid.authentication import SessionAuthenticationPolicy
from wuttaweb.auth import WuttaSecurityPolicy
from tailbone.db import Session
log = logging.getLogger(__name__)
def login_user(request, user, timeout=UNSPECIFIED):
def login_user(request, user, timeout=NOTSET):
"""
Perform the steps necessary to login the given user. Note that this
returns a ``headers`` dict which you should pass to the redirect.
"""
config = request.rattail_config
app = config.get_app()
user.record_event(app.enum.USER_EVENT_LOGIN)
user.record_event(enum.USER_EVENT_LOGIN)
headers = remember(request, user.uuid)
if timeout is UNSPECIFIED:
timeout = session_timeout_for_user(config, user)
if timeout is NOTSET:
timeout = session_timeout_for_user(user)
log.debug("setting session timeout for '{}' to {}".format(user.username, timeout))
set_session_timeout(request, timeout)
return headers
@ -59,28 +60,24 @@ 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(app.enum.USER_EVENT_LOGOUT)
user.record_event(enum.USER_EVENT_LOGOUT)
request.session.delete()
request.session.invalidate()
headers = forget(request)
return headers
def session_timeout_for_user(config, user):
def session_timeout_for_user(user):
"""
Returns the "max" session timeout for the user, according to roles
"""
app = config.get_app()
auth = app.get_auth_handler()
from rattail.db.auth import authenticated_role
authenticated = auth.get_role_authenticated(Session())
roles = user.roles + [authenticated]
roles = user.roles + [authenticated_role(Session())]
timeouts = [role.session_timeout for role in roles
if role.session_timeout is not None]
if timeouts and 0 not in timeouts:
return max(timeouts)
@ -92,42 +89,89 @@ def set_session_timeout(request, timeout):
request.session['_timeout'] = timeout or None
class TailboneSecurityPolicy(WuttaSecurityPolicy):
class TailboneAuthenticationPolicy(SessionAuthenticationPolicy):
"""
Custom authentication policy for Tailbone.
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
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 load_identity(self, request):
config = request.registry.settings.get('rattail_config')
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()
app = config.get_app()
user = None
auth = app.get_auth_handler()
if self.api_mode:
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
# 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)
def principals_allowed_by_permission(self, context, permission):
raise NotImplementedError
if not user:
# fetch user uuid from current session
uuid = self.session_helper.authenticated_userid(request)
if not uuid:
return
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 object from db
model = app.model
user = self.db_session.get(model.User, uuid)
if not user:
return
# this user is responsible for data changes in current request
self.db_session.set_continuum_user(user)
return user
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)

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2024 Lance Edgar
# Copyright © 2010-2022 Lance Edgar
#
# This file is part of Rattail.
#
@ -27,11 +27,11 @@ Note that most of the code for this module was copied from the beaker and
pyramid_beaker projects.
"""
from __future__ import unicode_literals, absolute_import
import time
from pkg_resources import parse_version
from rattail.util import get_pkg_version
import beaker
from beaker.session import Session
from beaker.util import coerce_session_params
@ -49,7 +49,7 @@ class TailboneSession(Session):
"Loads the data from this session from persistent storage"
# are we using older version of beaker?
old_beaker = parse_version(get_pkg_version('beaker')) < parse_version('1.12')
old_beaker = parse_version(beaker.__version__) < parse_version('1.12')
self.namespace = self.namespace_class(self.id,
data_dir=self.data_dir,

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2024 Lance Edgar
# Copyright © 2010-2023 Lance Edgar
#
# This file is part of Rattail.
#
@ -26,14 +26,13 @@ Rattail config extension for Tailbone
import warnings
from wuttjamaican.conf import WuttaConfigExtension
from rattail.config import ConfigExtension as BaseExtension
from rattail.db.config import configure_session
from tailbone.db import Session
class ConfigExtension(WuttaConfigExtension):
class ConfigExtension(BaseExtension):
"""
Rattail config extension for Tailbone. Does the following:
@ -50,12 +49,9 @@ class ConfigExtension(WuttaConfigExtension):
configure_session(config, Session)
# provide default theme selection
config.setdefault('tailbone', 'themes.keys', 'default, butterball')
config.setdefault('tailbone', 'themes.keys', 'default, falafel')
config.setdefault('tailbone', 'themes.expose_picker', 'true')
# override oruga detection
config.setdefault('wuttaweb.oruga_detector.spec', 'tailbone.util:should_use_oruga')
def csrf_token_name(config):
return config.get('tailbone', 'csrf_token_name', default='_csrf')
@ -65,6 +61,25 @@ 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')

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2024 Lance Edgar
# Copyright © 2010-2023 Lance Edgar
#
# This file is part of Rattail.
#
@ -21,13 +21,14 @@
#
################################################################################
"""
Database sessions etc.
Database Stuff
"""
import sqlalchemy as sa
from zope.sqlalchemy import datamanager
import sqlalchemy_continuum as continuum
from sqlalchemy.orm import sessionmaker, scoped_session
from pkg_resources import get_distribution, parse_version
from rattail.db import SessionBase
from rattail.db.continuum import versioning_manager
@ -42,28 +43,23 @@ TrainwreckSession = scoped_session(sessionmaker())
# empty dict for now, this must populated on app startup (if needed)
ExtraTrainwreckSessions = {}
# some of the logic below may need to vary somewhat, based on which version of
# zope.sqlalchemy we have installed
zope_sqlalchemy_version = get_distribution('zope.sqlalchemy').version
zope_sqlalchemy_version_parsed = parse_version(zope_sqlalchemy_version)
class TailboneSessionDataManager(datamanager.SessionDataManager):
"""
Integrate a top level sqlalchemy session transaction into a zope
transaction
"""Integrate a top level sqlalchemy session transaction into a zope transaction
One phase variant.
.. note::
This class appears to be necessary in order for the
SQLAlchemy-Continuum integration to work alongside the Zope
transaction integration.
It subclasses
``zope.sqlalchemy.datamanager.SessionDataManager`` but injects
some SQLAlchemy-Continuum logic within :meth:`tpc_vote()`, and
is sort of monkey-patched into the mix.
This class appears to be necessary in order for the Continuum
integration to work alongside the Zope transaction integration.
"""
def tpc_vote(self, trans):
""" """
# for a one phase data manager commit last in tpc_vote
if self.tx is not None: # there may have been no work to do
@ -75,120 +71,126 @@ class TailboneSessionDataManager(datamanager.SessionDataManager):
self._finish('committed')
def join_transaction(
session,
initial_state=datamanager.STATUS_ACTIVE,
transaction_manager=datamanager.zope_transaction.manager,
keep_session=False,
):
"""
Join a session to a transaction using the appropriate datamanager.
def join_transaction(session, initial_state=datamanager.STATUS_ACTIVE, transaction_manager=datamanager.zope_transaction.manager, keep_session=False):
"""Join a session to a transaction using the appropriate datamanager.
It is safe to call this multiple times, if the session is already
joined then it just returns.
It is safe to call this multiple times, if the session is already joined
then it just returns.
`initial_state` is either STATUS_ACTIVE, STATUS_INVALIDATED or
STATUS_READONLY
`initial_state` is either STATUS_ACTIVE, STATUS_INVALIDATED or STATUS_READONLY
If using the default initial status of STATUS_ACTIVE, you must
ensure that mark_changed(session) is called when data is written
to the database.
If using the default initial status of STATUS_ACTIVE, you must ensure that
mark_changed(session) is called when data is written to the database.
The ZopeTransactionExtesion SessionExtension can be used to ensure
that this is called automatically after session write operations.
The ZopeTransactionExtesion SessionExtension can be used to ensure that this is
called automatically after session write operations.
.. note::
This function appears to be necessary in order for the
SQLAlchemy-Continuum integration to work alongside the Zope
transaction integration.
It overrides ``zope.sqlalchemy.datamanager.join_transaction()``
to ensure the custom :class:`TailboneSessionDataManager` is
used, and is sort of monkey-patched into the mix.
This function is copied from upstream, and tweaked so that our custom
:class:`TailboneSessionDataManager` will be used.
"""
# the upstream internals of this function has changed a little over time.
# unfortunately for us, that means we must include each variant here.
if datamanager._SESSION_STATE.get(session, None) is None:
if session.twophase:
DataManager = datamanager.TwoPhaseSessionDataManager
else:
DataManager = TailboneSessionDataManager
DataManager(session, initial_state, transaction_manager, keep_session=keep_session)
if zope_sqlalchemy_version_parsed >= parse_version('1.1'): # 1.1+
if datamanager._SESSION_STATE.get(session, None) is None:
if session.twophase:
DataManager = datamanager.TwoPhaseSessionDataManager
else:
DataManager = TailboneSessionDataManager
DataManager(session, initial_state, transaction_manager, keep_session=keep_session)
else: # pre-1.1
if datamanager._SESSION_STATE.get(id(session), None) is None:
if session.twophase:
DataManager = datamanager.TwoPhaseSessionDataManager
else:
DataManager = TailboneSessionDataManager
DataManager(session, initial_state, transaction_manager, keep_session=keep_session)
class ZopeTransactionEvents(datamanager.ZopeTransactionEvents):
"""
Record that a flush has occurred on a session's connection. This
allows the DataManager to rollback rather than commit on read only
transactions.
if zope_sqlalchemy_version_parsed >= parse_version('1.2'): # 1.2+
.. note::
class ZopeTransactionEvents(datamanager.ZopeTransactionEvents):
"""
Record that a flush has occurred on a session's
connection. This allows the DataManager to rollback rather
than commit on read only transactions.
This class appears to be necessary in order for the
SQLAlchemy-Continuum integration to work alongside the Zope
transaction integration.
.. note::
This class is copied from upstream, and tweaked so that our
custom :func:`join_transaction()` will be used.
"""
It subclasses
``zope.sqlalchemy.datamanager.ZopeTransactionEvents`` but
overrides various methods to ensure the custom
:func:`join_transaction()` is called, and is sort of
monkey-patched into the mix.
"""
def after_begin(self, session, transaction, connection):
join_transaction(session, self.initial_state,
self.transaction_manager, self.keep_session)
def after_begin(self, session, transaction, connection):
""" """
join_transaction(session, self.initial_state,
self.transaction_manager, self.keep_session)
def after_attach(self, session, instance):
join_transaction(session, self.initial_state,
self.transaction_manager, self.keep_session)
def after_attach(self, session, instance):
""" """
join_transaction(session, self.initial_state,
self.transaction_manager, self.keep_session)
def join_transaction(self, session):
join_transaction(session, self.initial_state,
self.transaction_manager, self.keep_session)
def join_transaction(self, session):
""" """
join_transaction(session, self.initial_state,
self.transaction_manager, self.keep_session)
else: # pre-1.2
class ZopeTransactionExtension(datamanager.ZopeTransactionExtension):
"""
Record that a flush has occurred on a session's
connection. This allows the DataManager to rollback rather
than commit on read only transactions.
.. note::
This class is copied from upstream, and tweaked so that our
custom :func:`join_transaction()` will be used.
"""
def after_begin(self, session, transaction, connection):
join_transaction(session, self.initial_state,
self.transaction_manager, self.keep_session)
def after_attach(self, session, instance):
join_transaction(session, self.initial_state,
self.transaction_manager, self.keep_session)
def register(
session,
initial_state=datamanager.STATUS_ACTIVE,
transaction_manager=datamanager.zope_transaction.manager,
keep_session=False,
):
"""
Register ZopeTransaction listener events on the given Session or
Session factory/class.
def register(session, initial_state=datamanager.STATUS_ACTIVE,
transaction_manager=datamanager.zope_transaction.manager, keep_session=False):
"""Register ZopeTransaction listener events on the
given Session or Session factory/class.
This function requires at least SQLAlchemy 0.7 and makes use of
the newer sqlalchemy.event package in order to register event
listeners on the given Session.
This function requires at least SQLAlchemy 0.7 and makes use
of the newer sqlalchemy.event package in order to register event listeners
on the given Session.
The session argument here may be a Session class or subclass, a
sessionmaker or scoped_session instance, or a specific Session
instance. Event listening will be specific to the scope of the
type of argument passed, including specificity to its subclass as
well as its identity.
sessionmaker or scoped_session instance, or a specific Session instance.
Event listening will be specific to the scope of the type of argument
passed, including specificity to its subclass as well as its identity.
.. note::
This function appears to be necessary in order for the
SQLAlchemy-Continuum integration to work alongside the Zope
transaction integration.
It overrides ``zope.sqlalchemy.datamanager.regsiter()`` to
ensure the custom :class:`ZopeTransactionEvents` is used.
This function is copied from upstream, and tweaked so that our custom
:class:`ZopeTransactionExtension` will be used.
"""
from sqlalchemy import event
ext = ZopeTransactionEvents(
initial_state=initial_state,
transaction_manager=transaction_manager,
keep_session=keep_session,
)
if zope_sqlalchemy_version_parsed >= parse_version('1.2'): # 1.2+
ext = ZopeTransactionEvents(
initial_state=initial_state,
transaction_manager=transaction_manager,
keep_session=keep_session,
)
else: # pre-1.2
ext = ZopeTransactionExtension(
initial_state=initial_state,
transaction_manager=transaction_manager,
keep_session=keep_session,
)
event.listen(session, "after_begin", ext.after_begin)
event.listen(session, "after_attach", ext.after_attach)
@ -197,8 +199,9 @@ def register(
event.listen(session, "after_bulk_delete", ext.after_bulk_delete)
event.listen(session, "before_commit", ext.before_commit)
if datamanager.SA_GE_14:
event.listen(session, "do_orm_execute", ext.do_orm_execute)
if zope_sqlalchemy_version_parsed >= parse_version('1.5'): # 1.5+
if datamanager.SA_GE_14:
event.listen(session, "do_orm_execute", ext.do_orm_execute)
register(Session)

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2024 Lance Edgar
# Copyright © 2010-2023 Lance Edgar
#
# This file is part of Rattail.
#
@ -34,38 +34,35 @@ 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, enums=None,
def __init__(self, old_data, new_data, columns=None, fields=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
@ -95,7 +92,7 @@ class Diff(object):
for the given field. May be an empty string, or a snippet of HTML
attribute syntax, e.g.:
.. code-block:: none
.. code-highlight:: none
class="diff" foo="bar"
@ -135,21 +132,7 @@ class Diff(object):
class VersionDiff(Diff):
"""
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.
Special diff class, for use with version history views
"""
def __init__(self, version, *args, **kwargs):
@ -193,40 +176,9 @@ 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
@ -270,21 +222,9 @@ class VersionDiff(Diff):
for field in self.fields:
values[field] = {'before': self.render_old_value(field),
'after': self.render_new_value(field)}
operation = None
if self.version.operation_type == continuum.Operation.INSERT:
operation = 'INSERT'
elif self.version.operation_type == continuum.Operation.UPDATE:
operation = 'UPDATE'
elif self.version.operation_type == continuum.Operation.DELETE:
operation = 'DELETE'
else:
operation = self.version.operation_type
return {
'key': id(self.version),
'model_title': self.title,
'operation': operation,
'diff_class': self.nature,
'fields': self.fields,
'values': values,

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2024 Lance Edgar
# Copyright © 2010-2020 Lance Edgar
#
# This file is part of Rattail.
#
@ -24,6 +24,10 @@
Tailbone Exceptions
"""
from __future__ import unicode_literals, absolute_import
import six
from rattail.exceptions import RattailError
@ -33,6 +37,7 @@ class TailboneError(RattailError):
"""
@six.python_2_unicode_compatible
class TailboneJSONFieldError(TailboneError):
"""
Error raised when JSON serialization of a form field results in an error.

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2024 Lance Edgar
# Copyright © 2010-2023 Lance Edgar
#
# This file is part of Rattail.
#
@ -33,9 +33,10 @@ 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.util import pretty_boolean
from rattail.time import localtime
from rattail.util import prettify, pretty_boolean, pretty_quantity
from rattail.core import UNSPECIFIED
from rattail.db.util import get_fieldnames
import colander
@ -47,14 +48,12 @@ from pyramid_deform import SessionFileUploadTempStore
from pyramid.renderers import render
from webhelpers2.html import tags, HTML
from wuttaweb.util import FieldList, get_form_data, make_json_safe
from tailbone.db import Session
from tailbone.util import raw_datetime, render_markdown
from tailbone.forms import types
from tailbone.forms.widgets import (ReadonlyWidget, PlainDateWidget,
JQueryDateWidget, JQueryTimeWidget,
FileUploadWidget, MultiFileUploadWidget)
from tailbone.util import raw_datetime, get_form_data, render_markdown
from . import types
from .widgets import (ReadonlyWidget, PlainDateWidget,
JQueryDateWidget, JQueryTimeWidget,
MultiFileUploadWidget)
from tailbone.exceptions import TailboneJSONFieldError
@ -226,7 +225,7 @@ class CustomSchemaNode(SQLAlchemySchemaNode):
if excludes:
overrides['excludes'] = excludes
return super().get_schema_from_relationship(prop, overrides)
return super(CustomSchemaNode, self).get_schema_from_relationship(prop, overrides)
def dictify(self, obj):
""" Return a dictified version of `obj` using schema information.
@ -235,7 +234,7 @@ class CustomSchemaNode(SQLAlchemySchemaNode):
This method was copied from upstream and modified to add automatic
handling of "association proxy" fields.
"""
dict_ = super().dictify(obj)
dict_ = super(CustomSchemaNode, self).dictify(obj)
for node in self:
name = node.name
@ -328,7 +327,7 @@ class Form(object):
"""
Base class for all forms.
"""
save_label = "Submit"
save_label = "Save"
update_label = "Save"
show_cancel = True
auto_disable = True
@ -339,12 +338,10 @@ class Form(object):
model_instance=None, model_class=None, appstruct=UNSPECIFIED, nodes={}, enums={}, labels={},
assume_local_times=False, renderers=None, renderer_kwargs={},
hidden={}, widgets={}, defaults={}, validators={}, required={}, helptext={}, focus_spec=None,
action_url=None, cancel_url=None,
vue_tagname=None,
vuejs_component_kwargs=None, vuejs_field_converters={}, json_data={}, included_templates={},
action_url=None, cancel_url=None, component='tailbone-form',
vuejs_component_kwargs=None, vuejs_field_converters={},
# TODO: ugh this is getting out hand!
can_edit_help=False, edit_help_url=None, route_prefix=None,
**kwargs
):
self.fields = None
if fields is not None:
@ -382,78 +379,20 @@ class Form(object):
self.focus_spec = focus_spec
self.action_url = action_url
self.cancel_url = cancel_url
# vue_tagname
self.vue_tagname = vue_tagname
if not self.vue_tagname and kwargs.get('component'):
warnings.warn("component kwarg is deprecated for Form(); "
"please use vue_tagname param instead",
DeprecationWarning, stacklevel=2)
self.vue_tagname = kwargs['component']
if not self.vue_tagname:
self.vue_tagname = 'tailbone-form'
self.component = component
self.vuejs_component_kwargs = vuejs_component_kwargs or {}
self.vuejs_field_converters = vuejs_field_converters or {}
self.json_data = json_data or {}
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 vue_component(self):
"""
String name for the Vue component, e.g. ``'TailboneGrid'``.
This is a generated value based on :attr:`vue_tagname`.
"""
words = self.vue_tagname.split('-')
return ''.join([word.capitalize() for word in words])
@property
def component(self):
"""
DEPRECATED - use :attr:`vue_tagname` instead.
"""
warnings.warn("Form.component is deprecated; "
"please use vue_tagname instead",
DeprecationWarning, stacklevel=2)
return self.vue_tagname
@property
def component_studly(self):
"""
DEPRECATED - use :attr:`vue_component` instead.
"""
warnings.warn("Form.component_studly is deprecated; "
"please use vue_component instead",
DeprecationWarning, stacklevel=2)
return self.vue_component
def get_button_label_submit(self):
""" """
if hasattr(self, '_button_label_submit'):
return self._button_label_submit
label = getattr(self, 'submit_label', None)
if label:
return label
return self.save_label
def set_button_label_submit(self, value):
""" """
self._button_label_submit = value
# wutta compat
button_label_submit = property(get_button_label_submit,
set_button_label_submit)
words = self.component.split('-')
return ''.join([word.capitalize() for word in words])
def __contains__(self, item):
return item in self.fields
@ -630,9 +569,7 @@ class Form(object):
self.schema[key].title = label
def get_label(self, key):
config = self.request.rattail_config
app = config.get_app()
return self.labels.get(key, app.make_title(key))
return self.labels.get(key, prettify(key))
def set_readonly(self, key, readonly=True):
if readonly:
@ -708,7 +645,7 @@ class Form(object):
self.set_widget(key, dfwidget.TextAreaWidget(cols=80, rows=8))
elif type_ == 'file':
tmpstore = SessionFileUploadTempStore(self.request)
kw = {'widget': FileUploadWidget(tmpstore, request=self.request),
kw = {'widget': dfwidget.FileUploadWidget(tmpstore),
'title': self.get_label(key)}
if 'required' in kwargs and not kwargs['required']:
kw['missing'] = colander.null
@ -857,15 +794,12 @@ class Form(object):
def set_vuejs_field_converter(self, field, converter):
self.vuejs_field_converters[field] = converter
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 render(self, template=None, **kwargs):
if not template:
template = '/forms/form.mako'
context = kwargs
context['form'] = self
return render(template, context)
def make_deform_form(self):
if not hasattr(self, 'deform_form'):
@ -905,21 +839,16 @@ class Form(object):
return self.deform_form
def render_vue_template(self, template='/forms/deform.mako', **context):
""" """
output = self.render_deform(template=template, **context)
return HTML.literal(output)
def render_deform(self, dform=None, template=None, **kwargs):
if not template:
template = '/forms/deform.mako'
template = '/forms/deform_buefy.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 dform.render()
# return form.render()
context = kwargs
context['form'] = self
@ -932,8 +861,8 @@ class Form(object):
context.setdefault('form_kwargs', {})
# TODO: deprecate / remove the latter option here
if self.auto_disable_save or self.auto_disable:
context['form_kwargs'].setdefault('ref', self.vue_component)
context['form_kwargs']['@submit'] = 'submit{}'.format(self.vue_component)
context['form_kwargs'].setdefault('ref', self.component_studly)
context['form_kwargs']['@submit'] = 'submit{}'.format(self.component_studly)
if self.focus_spec:
context['form_kwargs']['data-focus'] = self.focus_spec
context['request'] = self.request
@ -945,13 +874,11 @@ class Form(object):
return dict([(field, self.get_label(field))
for field in self])
def get_field_markdowns(self, session=None):
app = self.request.rattail_config.get_app()
model = app.model
session = session or Session()
def get_field_markdowns(self):
model = self.request.rattail_config.get_model()
if not hasattr(self, 'field_markdowns'):
infos = session.query(model.TailboneFieldInfo)\
infos = Session.query(model.TailboneFieldInfo)\
.filter(model.TailboneFieldInfo.route_prefix == self.route_prefix)\
.all()
self.field_markdowns = dict([(info.field_name, info.markdown_text)
@ -959,18 +886,6 @@ class Form(object):
return self.field_markdowns
def get_vue_field_value(self, key):
""" """
if key not in self.fields:
return
dform = self.get_deform()
if key not in dform:
return
field = dform[key]
return make_json_safe(field.cstruct)
def get_vuejs_model_value(self, field):
"""
This method must return "raw" JS which will be assigned as the initial
@ -1037,11 +952,7 @@ class Form(object):
def set_vuejs_component_kwargs(self, **kwargs):
self.vuejs_component_kwargs.update(kwargs)
def render_vue_tag(self, **kwargs):
""" """
return self.render_vuejs_component(**kwargs)
def render_vuejs_component(self, **kwargs):
def render_vuejs_component(self):
"""
Render the Vue.js component HTML for the form.
@ -1052,47 +963,17 @@ class Form(object):
<tailbone-form :configure-fields-help="configureFieldsHelp">
</tailbone-form>
"""
kw = dict(self.vuejs_component_kwargs)
kw.update(kwargs)
kwargs = dict(self.vuejs_component_kwargs)
if self.can_edit_help:
kw.setdefault(':configure-fields-help', 'configureFieldsHelp')
return HTML.tag(self.vue_tagname, **kw)
kwargs.setdefault(':configure-fields-help', 'configureFieldsHelp')
return HTML.tag(self.component, **kwargs)
def set_json_data(self, key, value):
def render_buefy_field(self, fieldname, bfield_attrs={}):
"""
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.
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.
"""
dform = self.make_deform_form()
field = dform[fieldname] if fieldname in dform else None
@ -1105,7 +986,7 @@ class Form(object):
if self.field_visible(fieldname):
label = self.get_label(fieldname)
markdowns = self.get_field_markdowns(session=session)
markdowns = self.get_field_markdowns()
# these attrs will be for the <b-field> (*not* the widget)
attrs = {
@ -1138,17 +1019,9 @@ class Form(object):
if field_type:
attrs['type'] = field_type
if 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]))
attrs[':message'] = '[{}]'.format(', '.join([
"'{}'".format(msg.replace("'", r"\'"))
for msg in messages]))
# merge anything caller provided
attrs.update(bfield_attrs)
@ -1196,27 +1069,15 @@ class Form(object):
label_contents.append(HTML.literal('&nbsp; &nbsp;'))
label_contents.append(icon)
# 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
# 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'))
# and finally wrap it all in a <b-field>
return HTML.tag('b-field', c=html, **attrs)
return HTML.tag('b-field', c=[label_template, html], **attrs)
elif field: # hidden field
@ -1224,18 +1085,6 @@ class Form(object):
# TODO: again, why does serialize() not return literal?
return HTML.literal(field.serialize())
# TODO: this was copied from wuttaweb; can remove when we align
# Form class structure
def render_vue_finalize(self):
""" """
set_data = f"{self.vue_component}.data = function() {{ return {self.vue_component}Data }}"
make_component = f"Vue.component('{self.vue_tagname}', {self.vue_component})"
return HTML.tag('script', c=['\n',
HTML.literal(set_data),
'\n',
HTML.literal(make_component),
'\n'])
def render_field_readonly(self, field_name, **kwargs):
"""
Render the given field completely, but in read-only fashion.
@ -1246,30 +1095,20 @@ 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]
value = self.render_field_value(field_name) or ''
if self.has_helptext(field_name):
contents.append(HTML.tag('span', class_='instructions',
c=[self.render_helptext(field_name)]))
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'})
return HTML.tag('div', class_='field-wrapper {}'.format(field_name), c=contents)
def render_field_value(self, field_name):
record = self.model_instance
@ -1293,8 +1132,7 @@ class Form(object):
value = self.obtain_value(record, field_name)
if value is None:
return ""
app = self.request.rattail_config.get_app()
value = app.localtime(value)
value = localtime(self.request.rattail_config, value)
return raw_datetime(self.request.rattail_config, value)
def render_duration(self, record, field_name):
@ -1323,8 +1161,7 @@ class Form(object):
value = self.obtain_value(obj, field)
if value is None:
return ""
app = self.request.rattail_config.get_app()
return app.render_quantity(value)
return pretty_quantity(value)
def render_percent(self, obj, field):
app = self.request.rattail_config.get_app()
@ -1375,19 +1212,12 @@ class Form(object):
def obtain_value(self, record, field_name):
if record:
if isinstance(record, dict):
return record[field_name]
try:
return getattr(record, field_name)
except AttributeError:
pass
try:
return record[field_name]
except KeyError:
return None
except TypeError:
pass
return getattr(record, field_name, None)
# TODO: is this always safe to do?
elif self.defaults and field_name in self.defaults:
@ -1441,6 +1271,30 @@ class Form(object):
return False
class FieldList(list):
"""
Convenience wrapper for a form's field list.
"""
def insert_before(self, field, newfield):
if field in self:
i = self.index(field)
self.insert(i, newfield)
else:
log.warning("field '%s' not found, will append new field: %s",
field, newfield)
self.append(newfield)
def insert_after(self, field, newfield):
if field in self:
i = self.index(field)
self.insert(i + 1, newfield)
else:
log.warning("field '%s' not found, will append new field: %s",
field, newfield)
self.append(newfield)
@colander.deferred
def upload_widget(node, kw):
request = kw['request']

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2024 Lance Edgar
# Copyright © 2010-2023 Lance Edgar
#
# This file is part of Rattail.
#
@ -27,7 +27,6 @@ Form Widgets
import json
import datetime
import decimal
import re
import colander
from deform import widget as dfwidget
@ -41,7 +40,6 @@ class ReadonlyWidget(dfwidget.HiddenWidget):
readonly = True
def serialize(self, field, cstruct, **kw):
""" """
if cstruct in (colander.null, None):
cstruct = ''
# TODO: is this hacky?
@ -58,11 +56,11 @@ class NumberInputWidget(dfwidget.TextInputWidget):
class NumericInputWidget(NumberInputWidget):
"""
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.
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.
"""
template = 'numericinput'
allow_enter = True
@ -79,17 +77,15 @@ 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().serialize(field, cstruct, **kw)
return super(PercentInputWidget, self).serialize(field, cstruct, **kw)
def deserialize(self, field, pstruct):
""" """
pstruct = super().deserialize(field, pstruct)
pstruct = super(PercentInputWidget, self).deserialize(field, pstruct)
if pstruct is colander.null:
return colander.null
# convert "human-friendly" value to "traditional"
@ -112,7 +108,6 @@ 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)
@ -123,7 +118,6 @@ 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:
@ -154,7 +148,6 @@ class DynamicCheckboxWidget(dfwidget.CheckboxWidget):
template = 'checkbox_dynamic'
# TODO: deprecate / remove this
class PlainSelectWidget(dfwidget.SelectWidget):
template = 'select_plain'
@ -173,7 +166,7 @@ class CustomSelectWidget(dfwidget.SelectWidget):
self.extra_template_values.update(kw)
def get_template_values(self, field, cstruct, kw):
values = super().get_template_values(field, cstruct, kw)
values = super(CustomSelectWidget, self).get_template_values(field, cstruct, kw)
if hasattr(self, 'extra_template_values'):
values.update(self.extra_template_values)
return values
@ -216,7 +209,6 @@ class JQueryDateWidget(dfwidget.DateInputWidget):
)
def serialize(self, field, cstruct, **kw):
""" """
if cstruct in (colander.null, None):
cstruct = ''
readonly = kw.get('readonly', self.readonly)
@ -250,26 +242,15 @@ 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
@ -280,7 +261,6 @@ class FalafelTimeWidget(dfwidget.TimeInputWidget):
template = 'time_falafel'
def deserialize(self, field, pstruct):
""" """
if pstruct == '':
return colander.null
return pstruct
@ -308,7 +288,6 @@ 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 '
@ -337,23 +316,6 @@ 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.
@ -362,7 +324,6 @@ class MultiFileUploadWidget(dfwidget.FileUploadWidget):
requirements = ()
def serialize(self, field, cstruct, **kw):
""" """
if cstruct in (colander.null, None):
cstruct = []
@ -378,7 +339,6 @@ class MultiFileUploadWidget(dfwidget.FileUploadWidget):
return field.renderer(template, **values)
def deserialize(self, field, pstruct):
""" """
if pstruct is colander.null:
return colander.null
@ -399,7 +359,6 @@ 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.
@ -469,16 +428,13 @@ def make_customer_widget(request, **kwargs):
class CustomerAutocompleteWidget(JQueryAutocompleteWidget):
"""
Autocomplete widget for a
:class:`~rattail:rattail.db.model.customers.Customer` reference
field.
Autocomplete widget for a Customer reference field.
"""
def __init__(self, request, *args, **kwargs):
super().__init__(*args, **kwargs)
super(CustomerAutocompleteWidget, self).__init__(*args, **kwargs)
self.request = request
app = self.request.rattail_config.get_app()
model = app.model
model = self.request.rattail_config.get_model()
# must figure out URL providing autocomplete service
if 'service_url' not in kwargs:
@ -496,30 +452,26 @@ 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:
app = self.request.rattail_config.get_app()
model = app.model
model = self.request.rattail_config.get_model()
customer = Session.get(model.Customer, cstruct)
if customer:
self.field_display = str(customer)
return super().serialize(
return super(CustomerAutocompleteWidget, self).serialize(
field, cstruct, **kw)
class CustomerDropdownWidget(dfwidget.SelectWidget):
"""
Dropdown widget for a
:class:`~rattail:rattail.db.model.customers.Customer` reference
field.
Dropdown widget for a Customer reference field.
"""
def __init__(self, request, *args, **kwargs):
super().__init__(*args, **kwargs)
super(CustomerDropdownWidget, self).__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:
@ -531,8 +483,10 @@ class CustomerDropdownWidget(dfwidget.SelectWidget):
customers = customers()
else: # default customer list
customers = app.get_clientele_handler()\
.get_all_customers(Session())
model = self.request.rattail_config.get_model()
customers = Session.query(model.Customer)\
.order_by(model.Customer.name)\
.all()
# convert customer list to option values
self.values = [(c.uuid, c.name)
@ -554,8 +508,7 @@ class DepartmentWidget(dfwidget.SelectWidget):
def __init__(self, request, **kwargs):
if 'values' not in kwargs:
app = request.rattail_config.get_app()
model = app.model
model = request.rattail_config.get_model()
departments = Session.query(model.Department)\
.order_by(model.Department.number)
values = [(dept.uuid, str(dept))
@ -564,7 +517,7 @@ class DepartmentWidget(dfwidget.SelectWidget):
values.insert(0, ('', "(none)"))
kwargs['values'] = values
super().__init__(**kwargs)
super(DepartmentWidget, self).__init__(**kwargs)
def make_vendor_widget(request, **kwargs):
@ -595,10 +548,9 @@ class VendorAutocompleteWidget(JQueryAutocompleteWidget):
"""
def __init__(self, request, *args, **kwargs):
super().__init__(*args, **kwargs)
super(VendorAutocompleteWidget, self).__init__(*args, **kwargs)
self.request = request
app = self.request.rattail_config.get_app()
model = app.model
model = self.request.rattail_config.get_model()
# must figure out URL providing autocomplete service
if 'service_url' not in kwargs:
@ -616,16 +568,15 @@ 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:
app = self.request.rattail_config.get_app()
model = app.model
model = self.request.rattail_config.get_model()
vendor = Session.get(model.Vendor, cstruct)
if vendor:
self.field_display = str(vendor)
return super().serialize(
return super(VendorAutocompleteWidget, self).serialize(
field, cstruct, **kw)
@ -635,7 +586,7 @@ class VendorDropdownWidget(dfwidget.SelectWidget):
"""
def __init__(self, request, *args, **kwargs):
super().__init__(*args, **kwargs)
super(VendorDropdownWidget, self).__init__(*args, **kwargs)
self.request = request
# must figure out dropdown values, if they weren't given
@ -648,8 +599,7 @@ class VendorDropdownWidget(dfwidget.SelectWidget):
vendors = vendors()
else: # default vendor list
app = self.request.rattail_config.get_app()
model = app.model
model = self.request.rattail_config.get_model()
vendors = Session.query(model.Vendor)\
.order_by(model.Vendor.name)\
.all()

File diff suppressed because it is too large Load diff

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2024 Lance Edgar
# Copyright © 2010-2023 Lance Edgar
#
# This file is part of Rattail.
#
@ -26,7 +26,6 @@ Grid Filters
import re
import datetime
import decimal
import logging
from collections import OrderedDict
@ -314,7 +313,7 @@ class AlchemyGridFilter(GridFilter):
def __init__(self, *args, **kwargs):
self.column = kwargs.pop('column')
super().__init__(*args, **kwargs)
super(AlchemyGridFilter, self).__init__(*args, **kwargs)
def filter_equal(self, query, value):
"""
@ -485,13 +484,9 @@ 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))
return query.filter(sa.and_(*criteria))
return query.filter(sa.and_(
*[self.column.ilike(self.encode_value('%{}%'.format(v)))
for v in value.split()]))
def filter_does_not_contain(self, query, value):
"""
@ -500,17 +495,14 @@ 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_(*criteria)))
sa.and_(
*[~self.column.ilike(self.encode_value('%{}%'.format(v)))
for v in value.split()]),
))
def filter_contains_any_of(self, query, value):
"""
@ -539,28 +531,24 @@ class AlchemyStringFilter(AlchemyGridFilter):
conditions = []
for value in values:
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))
conditions.append(sa.and_(
*[self.column.ilike(self.encode_value('%{}%'.format(v)))
for v in value.split()]))
return query.filter(sa.or_(*conditions))
def filter_is_empty(self, query, value):
return query.filter(sa.func.ltrim(sa.func.rtrim(self.column)) == self.encode_value(''))
return query.filter(sa.func.trim(self.column) == self.encode_value(''))
def filter_is_not_empty(self, query, value):
return query.filter(sa.func.ltrim(sa.func.rtrim(self.column)) != self.encode_value(''))
return query.filter(sa.func.trim(self.column) != self.encode_value(''))
def filter_is_empty_or_null(self, query, value):
return query.filter(
sa.or_(
sa.func.ltrim(sa.func.rtrim(self.column)) == self.encode_value(''),
sa.func.trim(self.column) == self.encode_value(''),
self.column == None))
class AlchemyEmptyStringFilter(AlchemyStringFilter):
"""
String filter with special logic to treat empty string values as NULL
@ -570,13 +558,13 @@ class AlchemyEmptyStringFilter(AlchemyStringFilter):
return query.filter(
sa.or_(
self.column == None,
sa.func.ltrim(sa.func.rtrim(self.column)) == self.encode_value('')))
sa.func.trim(self.column) == self.encode_value('')))
def filter_is_not_null(self, query, value):
return query.filter(
sa.and_(
self.column != None,
sa.func.ltrim(sa.func.rtrim(self.column)) != self.encode_value('')))
sa.func.trim(self.column) != self.encode_value('')))
class AlchemyByteStringFilter(AlchemyStringFilter):
@ -588,7 +576,7 @@ class AlchemyByteStringFilter(AlchemyStringFilter):
value_encoding = 'utf-8'
def get_value(self, value=UNSPECIFIED):
value = super().get_value(value)
value = super(AlchemyByteStringFilter, self).get_value(value)
if isinstance(value, str):
value = value.encode(self.value_encoding)
return value
@ -599,13 +587,8 @@ class AlchemyByteStringFilter(AlchemyStringFilter):
"""
if value is None or value == '':
return query
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))
return query.filter(sa.and_(
*[self.column.ilike(b'%{}%'.format(v)) for v in value.split()]))
def filter_does_not_contain(self, query, value):
"""
@ -614,16 +597,13 @@ 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_(*criteria)))
sa.and_(
*[~self.column.ilike(b'%{}%'.format(v)) for v in value.split()]),
))
class AlchemyNumericFilter(AlchemyGridFilter):
@ -648,51 +628,41 @@ class AlchemyNumericFilter(AlchemyGridFilter):
# first just make sure it's somewhat numeric
try:
self.parse_decimal(value)
except decimal.InvalidOperation:
float(value)
except ValueError:
return True
return bool(value and len(str(value)) > 8)
def parse_decimal(self, value):
if value:
value = value.replace(',', '')
return decimal.Decimal(value)
def encode_value(self, value):
if value:
value = str(self.parse_decimal(value))
return super().encode_value(value)
def filter_equal(self, query, value):
if self.value_invalid(value):
return query
return super().filter_equal(query, value)
return super(AlchemyNumericFilter, self).filter_equal(query, value)
def filter_not_equal(self, query, value):
if self.value_invalid(value):
return query
return super().filter_not_equal(query, value)
return super(AlchemyNumericFilter, self).filter_not_equal(query, value)
def filter_greater_than(self, query, value):
if self.value_invalid(value):
return query
return super().filter_greater_than(query, value)
return super(AlchemyNumericFilter, self).filter_greater_than(query, value)
def filter_greater_equal(self, query, value):
if self.value_invalid(value):
return query
return super().filter_greater_equal(query, value)
return super(AlchemyNumericFilter, self).filter_greater_equal(query, value)
def filter_less_than(self, query, value):
if self.value_invalid(value):
return query
return super().filter_less_than(query, value)
return super(AlchemyNumericFilter, self).filter_less_than(query, value)
def filter_less_equal(self, query, value):
if self.value_invalid(value):
return query
return super().filter_less_equal(query, value)
return super(AlchemyNumericFilter, self).filter_less_equal(query, value)
class AlchemyIntegerFilter(AlchemyNumericFilter):
@ -1223,7 +1193,7 @@ class AlchemyPhoneNumberFilter(AlchemyStringFilter):
'ILIKE' query with those parts.
"""
value = self.parse_value(value)
return super().filter_contains(query, value)
return super(AlchemyPhoneNumberFilter, self).filter_contains(query, value)
def filter_does_not_contain(self, query, value):
"""
@ -1231,7 +1201,7 @@ class AlchemyPhoneNumberFilter(AlchemyStringFilter):
'NOT ILIKE' query with those parts.
"""
value = self.parse_value(value)
return super().filter_does_not_contain(query, value)
return super(AlchemyPhoneNumberFilter, self).filter_does_not_contain(query, value)
class GridFilterSet(OrderedDict):
@ -1275,7 +1245,7 @@ class GridFiltersForm(forms.Form):
node = colander.SchemaNode(colander.String(), name=key)
schema.add(node)
kwargs['schema'] = schema
super().__init__(**kwargs)
super(GridFiltersForm, self).__init__(**kwargs)
def iter_filters(self):
return self.filters.values()

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2024 Lance Edgar
# Copyright © 2010-2023 Lance Edgar
#
# This file is part of Rattail.
#
@ -24,8 +24,9 @@
Tailbone Handler
"""
import warnings
from __future__ import unicode_literals, absolute_import
import six
from mako.lookup import TemplateLookup
from rattail.app import GenericHandler
@ -40,7 +41,7 @@ class TailboneHandler(GenericHandler):
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
super(TailboneHandler, self).__init__(*args, **kwargs)
# TODO: make templates dir configurable?
templates = [resource_path('rattail:templates/web')]
@ -48,14 +49,11 @@ class TailboneHandler(GenericHandler):
def get_menu_handler(self, **kwargs):
"""
DEPRECATED; use
:meth:`wuttaweb.handler.WebHandler.get_menu_handler()`
instead.
"""
warnings.warn("TailboneHandler.get_menu_handler() is deprecated; "
"please use WebHandler.get_menu_handler() instead",
DeprecationWarning, stacklevel=2)
Get the configured "menu" handler.
:returns: The :class:`~tailbone.menus.MenuHandler` instance
for the app.
"""
if not hasattr(self, 'menu_handler'):
spec = self.config.get('tailbone.menus', 'handler',
default='tailbone.menus:MenuHandler')
@ -69,7 +67,7 @@ class TailboneHandler(GenericHandler):
Returns an iterator over all registered Tailbone providers.
"""
providers = get_all_providers(self.config)
return providers.values()
return six.itervalues(providers)
def write_model_view(self, data, path, **kwargs):
"""

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2024 Lance Edgar
# Copyright © 2010-2023 Lance Edgar
#
# This file is part of Rattail.
#
@ -24,9 +24,6 @@
Template Context Helpers
"""
# start off with all from wuttaweb
from wuttaweb.helpers import *
import os
import datetime
from decimal import Decimal
@ -36,9 +33,14 @@ from rattail.time import localtime, make_utc
from rattail.util import pretty_quantity, pretty_hours, hours_as_decimal
from rattail.db.util import maxlen
from tailbone.util import (pretty_datetime, raw_datetime,
from webhelpers2.html import *
from webhelpers2.html.tags import *
from tailbone.util import (csrf_token, get_csrf_token,
pretty_datetime, raw_datetime,
render_markdown,
route_exists)
route_exists,
get_liburl)
def pretty_date(date):

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2024 Lance Edgar
# Copyright © 2010-2023 Lance Edgar
#
# This file is part of Rattail.
#
@ -24,48 +24,37 @@
App Menus
"""
import re
import logging
import warnings
from rattail.app import GenericHandler
from rattail.util import prettify, simple_error
from webhelpers2.html import tags, HTML
from wuttaweb.menus import MenuHandler as WuttaMenuHandler
from tailbone.db import Session
log = logging.getLogger(__name__)
class TailboneMenuHandler(WuttaMenuHandler):
class MenuHandler(GenericHandler):
"""
Base class and default implementation for menu handler.
"""
##############################
# internal methods
##############################
def make_raw_menus(self, request, **kwargs):
"""
Generate a full set of "raw" menus for the app.
def _is_allowed(self, request, item):
"""
TODO: must override this until wuttaweb has proper user auth checks
"""
perm = item.get('perm')
if perm:
return request.has_perm(perm)
return True
def _make_raw_menus(self, request, **kwargs):
"""
We are overriding this to allow for making dynamic menus from
config/settings. Which may or may not be a good idea..
The "raw" menus are basically just a set of dicts to represent
the final menus.
"""
# first try to make menus from config, but this is highly
# susceptible to failure, so try to warn user of problems
try:
menus = self._make_menus_from_config(request)
menus = self.make_menus_from_config(request)
if menus:
return menus
except Exception as error:
@ -82,9 +71,9 @@ class TailboneMenuHandler(WuttaMenuHandler):
request.session.flash(msg, 'warning')
# okay, no config, so menus will be built from code
return self.make_menus(request, **kwargs)
return self.make_menus(request)
def _make_menus_from_config(self, request, **kwargs):
def make_menus_from_config(self, request, **kwargs):
"""
Try to build a complete menu set from config/settings.
@ -96,7 +85,7 @@ class TailboneMenuHandler(WuttaMenuHandler):
if not main_keys:
return
model = self.app.model
model = self.model
menus = []
# menu definition can come either from config file or db
@ -112,15 +101,16 @@ class TailboneMenuHandler(WuttaMenuHandler):
query=query, key='name',
normalizer=lambda s: s.value)
for key in main_keys:
menus.append(self._make_single_menu_from_settings(request, key, settings))
menus.append(self.make_single_menu_from_settings(request, key,
settings))
else: # read from config file only
for key in main_keys:
menus.append(self._make_single_menu_from_config(request, key))
menus.append(self.make_single_menu_from_config(request, key))
return menus
def _make_single_menu_from_config(self, request, key, **kwargs):
def make_single_menu_from_config(self, request, key, **kwargs):
"""
Makes a single top-level menu dict from config file. Note
that this will read from config file(s) *only* and avoids
@ -188,7 +178,7 @@ class TailboneMenuHandler(WuttaMenuHandler):
return menu
def _make_single_menu_from_settings(self, request, key, settings, **kwargs):
def make_single_menu_from_settings(self, request, key, settings, **kwargs):
"""
Makes a single top-level menu dict from DB settings.
"""
@ -247,10 +237,6 @@ class TailboneMenuHandler(WuttaMenuHandler):
return menu
##############################
# menu defaults
##############################
def make_menus(self, request, **kwargs):
"""
Make the full set of menus for the app.
@ -281,9 +267,8 @@ class TailboneMenuHandler(WuttaMenuHandler):
"""
Make a set of menus for all registered system integrations.
"""
tb = self.app.get_tailbone_handler()
menus = []
for provider in tb.iter_providers():
for provider in self.tb.iter_providers():
menu = provider.make_integration_menu(request)
if menu:
menus.append(menu)
@ -394,11 +379,6 @@ class TailboneMenuHandler(WuttaMenuHandler):
'route': 'products',
'perm': 'products.list',
},
{
'title': "Product Costs",
'route': 'product_costs',
'perm': 'product_costs.list',
},
{
'title': "Departments",
'route': 'departments',
@ -456,11 +436,6 @@ class TailboneMenuHandler(WuttaMenuHandler):
'route': 'vendors',
'perm': 'vendors.list',
},
{
'title': "Product Costs",
'route': 'product_costs',
'perm': 'product_costs.list',
},
{'type': 'sep'},
{
'title': "Ordering",
@ -713,7 +688,7 @@ class TailboneMenuHandler(WuttaMenuHandler):
},
{'type': 'sep'},
{
'title': "App Info",
'title': "App Details",
'route': 'appinfo',
'perm': 'appinfo.list',
},
@ -748,25 +723,182 @@ class TailboneMenuHandler(WuttaMenuHandler):
}
class MenuHandler(TailboneMenuHandler):
def __init__(self, *args, **kwargs):
warnings.warn("tailbone.menus.MenuHandler is deprecated; "
"please use tailbone.menus.TailboneMenuHandler instead",
DeprecationWarning, stacklevel=2)
super().__init__(*args, **kwargs)
class NullMenuHandler(WuttaMenuHandler):
def make_simple_menus(request):
"""
Null menu handler which uses an empty menu set.
.. note:
This class shouldn't even exist, but for the moment, it is
useful to configure non-traditional (e.g. API) web apps to use
this, in order to avoid most of the overhead.
Build the main menu list for the app.
"""
app = request.rattail_config.get_app()
tailbone_handler = app.get_tailbone_handler()
menu_handler = tailbone_handler.get_menu_handler()
def make_menus(self, request, **kwargs):
return []
raw_menus = menu_handler.make_raw_menus(request)
# now we have "simple" (raw) menus definition, but must refine
# that somewhat to produce our final menus
mark_allowed(request, raw_menus)
final_menus = []
for topitem in raw_menus:
if topitem['allowed']:
if topitem.get('type') == 'link':
final_menus.append(make_menu_entry(request, topitem))
else: # assuming 'menu' type
menu_items = []
for item in topitem['items']:
if not item['allowed']:
continue
# nested submenu
if item.get('type') == 'menu':
submenu_items = []
for subitem in item['items']:
if subitem['allowed']:
submenu_items.append(make_menu_entry(request, subitem))
menu_items.append({
'type': 'submenu',
'title': item['title'],
'items': submenu_items,
'is_menu': True,
'is_sep': False,
})
elif item.get('type') == 'sep':
# we only want to add a sep, *if* we already have some
# menu items (i.e. there is something to separate)
# *and* the last menu item is not a sep (avoid doubles)
if menu_items and not menu_items[-1]['is_sep']:
menu_items.append(make_menu_entry(request, item))
else: # standard menu item
menu_items.append(make_menu_entry(request, item))
# remove final separator if present
if menu_items and menu_items[-1]['is_sep']:
menu_items.pop()
# only add if we wound up with something
assert menu_items
if menu_items:
group = {
'type': 'menu',
'key': topitem.get('key'),
'title': topitem['title'],
'items': menu_items,
'is_menu': True,
'is_link': False,
}
# topitem w/ no key likely means it did not come
# from config but rather explicit definition in
# code. so we are free to "invent" a (safe) key
# for it, since that is only for editing config
if not group['key']:
group['key'] = make_menu_key(request.rattail_config,
topitem['title'])
final_menus.append(group)
return final_menus
def make_menu_key(config, value):
"""
Generate a normalized menu key for the given value.
"""
return re.sub(r'\W', '', value.lower())
def make_menu_entry(request, item):
"""
Convert a simple menu entry dict, into a proper menu-related object, for
use in constructing final menu.
"""
# separator
if item.get('type') == 'sep':
return {
'type': 'sep',
'is_menu': False,
'is_sep': True,
}
# standard menu item
entry = {
'type': 'item',
'title': item['title'],
'perm': item.get('perm'),
'target': item.get('target'),
'is_link': True,
'is_menu': False,
'is_sep': False,
}
if item.get('route'):
entry['route'] = item['route']
try:
entry['url'] = request.route_url(entry['route'])
except KeyError: # happens if no such route
log.warning("invalid route name for menu entry: %s", entry)
entry['url'] = entry['route']
entry['key'] = entry['route']
else:
if item.get('url'):
entry['url'] = item['url']
entry['key'] = make_menu_key(request.rattail_config, entry['title'])
return entry
def is_allowed(request, item):
"""
Logic to determine if a given menu item is "allowed" for current user.
"""
perm = item.get('perm')
if perm:
return request.has_perm(perm)
return True
def mark_allowed(request, menus):
"""
Traverse the menu set, and mark each item as "allowed" (or not) based on
current user permissions.
"""
for topitem in menus:
if topitem.get('type', 'menu') == 'menu':
topitem['allowed'] = False
for item in topitem['items']:
if item.get('type') == 'menu':
for subitem in item['items']:
subitem['allowed'] = is_allowed(request, subitem)
item['allowed'] = False
for subitem in item['items']:
if subitem['allowed'] and subitem.get('type') != 'sep':
item['allowed'] = True
break
else:
item['allowed'] = is_allowed(request, item)
for item in topitem['items']:
if item['allowed'] and item.get('type') != 'sep':
topitem['allowed'] = True
break
def make_admin_menu(request, **kwargs):
"""
Generate a typical Admin menu
"""
warnings.warn("make_admin_menu() function is deprecated; please use "
"MenuHandler.make_admin_menu() instead",
DeprecationWarning, stacklevel=2)
app = request.rattail_config.get_app()
tailbone_handler = app.get_tailbone_handler()
menu_handler = tailbone_handler.get_menu_handler()
return menu_handler.make_admin_menu(request, **kwargs)

45
tailbone/scaffolds.py Normal file
View file

@ -0,0 +1,45 @@
# -*- coding: utf-8 -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2017 Lance Edgar
#
# This file is part of Rattail.
#
# Rattail is free software: you can redistribute it and/or modify it under the
# terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# Rattail. If not, see <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)

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2024 Lance Edgar
# Copyright © 2010-2017 Lance Edgar
#
# This file is part of Rattail.
#
@ -24,8 +24,9 @@
Static Assets
"""
from __future__ import unicode_literals, absolute_import
def includeme(config):
config.include('wuttaweb.static')
config.add_static_view('tailbone', 'tailbone:static')
config.add_static_view('deform', 'deform:static')

View file

@ -3,6 +3,10 @@
* Grid Filters
******************************/
.filters .filter {
margin-bottom: 0.5rem;
}
.filters .filter-fieldname .field,
.filters .filter-fieldname .field label {
width: 100%;

View file

@ -25,11 +25,6 @@
margin: 0;
}
.grid-tools {
display: flex;
gap: 0.5rem;
}
.grid-wrapper .grid-header td.tools {
margin: 0;
padding: 0;

View file

@ -2,7 +2,7 @@
/********************************************************************************
* grids.rowstatus.css
*
* Add "row status" styles for grid tables.
* Add "row status" styles for Buefy grid tables.
********************************************************************************/
/**************************************************

View file

@ -90,11 +90,6 @@ header span.header-text {
* "object helper" panel
******************************/
.object-helpers .panel {
margin: 1rem;
margin-bottom: 1.5rem;
}
.object-helpers .panel-heading {
white-space: nowrap;
}
@ -141,12 +136,6 @@ 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

View file

@ -11,7 +11,7 @@ const TailboneDatepicker = {
'icon="calendar-alt"',
':date-formatter="formatDate"',
':date-parser="parseDate"',
':value="buefyValue"',
':value="value ? parseDate(value) : null"',
'@input="dateChanged"',
':disabled="disabled"',
'ref="trueDatePicker"',
@ -26,18 +26,6 @@ const TailboneDatepicker = {
disabled: Boolean,
},
data() {
return {
buefyValue: this.parseDate(this.value),
}
},
watch: {
value(to, from) {
this.buefyValue = this.parseDate(to)
},
},
methods: {
formatDate(date) {
@ -55,12 +43,9 @@ const TailboneDatepicker = {
},
parseDate(date) {
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
// 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])
},
dateChanged(date) {

View file

@ -0,0 +1,167 @@
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)

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2024 Lance Edgar
# Copyright © 2010-2023 Lance Edgar
#
# This file is part of Rattail.
#
@ -24,10 +24,9 @@
Event Subscribers
"""
import six
import json
import datetime
import logging
import warnings
from collections import OrderedDict
import rattail
@ -36,169 +35,166 @@ 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_available_themes, get_global_search_options
log = logging.getLogger(__name__)
def new_request(event, session=None):
def new_request(event):
"""
Event hook called when processing a new request.
Identify the current user, and cache their current permissions. Also adds
the ``rattail_config`` attribute to the request.
This first invokes the upstream hooks:
A global Rattail ``config`` should already be present within the Pyramid
application registry's settings, which would normally be accessed via::
* :func:`wuttaweb:wuttaweb.subscribers.new_request()`
* :func:`wuttaweb:wuttaweb.subscribers.new_request_set_user()`
request.registry.settings['rattail_config']
It then adds more things to the request object; among them:
This function merely "promotes" that config object so that it is more
directly accessible, a la::
.. attribute:: request.rattail_config
request.rattail_config
Reference to the app :term:`config object`. Note that this
will be the same as :attr:`wuttaweb:request.wutta_config`.
.. note::
This of course assumes that a Rattail ``config`` object *has* in fact
already been placed in the application registry settings. If this is
not the case, this function will do nothing.
.. method:: request.register_component(tagname, classname)
Also, attach some goodies to the request object:
Function to register a Vue component for use with the app.
* The currently logged-in user instance (if any), as ``user``.
This can be called from wherever a component is defined, and
then in the base template all registered components will be
properly loaded.
* ``is_admin`` flag indicating whether user has the Administrator role.
* ``is_root`` flag indicating whether user is currently elevated to root.
* A shortcut method for permission checking, as ``has_perm()``.
"""
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
# invoke main upstream logic
# nb. this sets request.wutta_config
base.new_request(event)
def user(request):
user = None
uuid = request.authenticated_userid
if uuid:
model = request.rattail_config.get_model()
user = Session.get(model.User, uuid)
if user:
Session().set_continuum_user(user)
return user
config = request.wutta_config
app = config.get_app()
auth = app.get_auth_handler()
session = session or Session()
# 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)
request.set_property(user, reify=True)
# assign client IP address to the session, for sake of versioning
if hasattr(request, 'client_addr'):
session.continuum_remote_addr = request.client_addr
Session().continuum_remote_addr = request.client_addr
# request.register_component()
def register_component(tagname, classname):
"""
Register a Vue 3 component, so the base template knows to
declare it for use within the app (page).
"""
if not hasattr(request, '_tailbone_registered_components'):
request._tailbone_registered_components = OrderedDict()
request.is_admin = bool(request.user) and request.user.is_admin()
request.is_root = request.is_admin and request.session.get('is_root', False)
if tagname in request._tailbone_registered_components:
log.warning("component with tagname '%s' already registered "
"with class '%s' but we are replacing that with "
"class '%s'",
tagname,
request._tailbone_registered_components[tagname],
classname)
# TODO: why would this ever be null?
if rattail_config:
request._tailbone_registered_components[tagname] = classname
request.register_component = register_component
app = rattail_config.get_app()
auth = app.get_auth_handler()
request.tailbone_cached_permissions = auth.get_permissions(
Session(), request.user)
def has_perm(name):
if name in request.tailbone_cached_permissions:
return True
return request.is_root
request.has_perm = has_perm
def has_any_perm(*names):
for name in names:
if has_perm(name):
return True
return False
request.has_any_perm = has_any_perm
def 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()
config = request.wutta_config
app = config.get_app()
rattail_config = request.rattail_config
renderer_globals = event
# overrides
renderer_globals['rattail_app'] = request.rattail_config.get_app()
renderer_globals['app_title'] = request.rattail_config.app_title()
renderer_globals['h'] = helpers
# misc.
renderer_globals['url'] = request.route_url
renderer_globals['rattail'] = rattail
renderer_globals['tailbone'] = tailbone
renderer_globals['model'] = request.rattail_config.get_model()
renderer_globals['enum'] = request.rattail_config.get_enum()
renderer_globals['six'] = six
renderer_globals['json'] = json
renderer_globals['datetime'] = datetime
renderer_globals['colander'] = colander
renderer_globals['deform'] = deform
renderer_globals['csrf_header_name'] = csrf_header_name(config)
# TODO: deprecate / remove these
renderer_globals['rattail_app'] = app
renderer_globals['app_title'] = app.get_title()
renderer_globals['app_version'] = app.get_version()
renderer_globals['rattail'] = rattail
renderer_globals['tailbone'] = tailbone
renderer_globals['model'] = app.model
renderer_globals['enum'] = app.enum
renderer_globals['csrf_header_name'] = csrf_header_name(request.rattail_config)
# theme - we only want do this for classic web app, *not* API
# TODO: so, clearly we need a better way to distinguish the two
if 'tailbone.theme' in request.registry.settings:
renderer_globals['theme'] = request.registry.settings['tailbone.theme']
# note, this is just a global flag; user still needs permission to see picker
expose_picker = config.get_bool('tailbone.themes.expose_picker',
default=False)
expose_picker = request.rattail_config.getbool('tailbone', 'themes.expose_picker',
default=False)
renderer_globals['expose_theme_picker'] = expose_picker
if expose_picker:
# TODO: should remove 'falafel' option altogether
available = get_available_themes(config)
available = get_available_themes(request.rattail_config,
include=['falafel'])
options = [tags.Option(theme, value=theme) for theme in available]
renderer_globals['theme_picker_options'] = options
# heck while we're assuming the classic web app here...
# (we don't want this to happen for the API either!)
# TODO: just..awful *shrug*
# note that we assume "simple" menus nowadays
if request.rattail_config.getbool('tailbone', 'menus.simple', default=True):
renderer_globals['menus'] = make_simple_menus(request)
# TODO: ugh, same deal here
renderer_globals['messaging_enabled'] = config.get_bool('tailbone.messaging.enabled',
default=False)
renderer_globals['messaging_enabled'] = request.rattail_config.getbool(
'tailbone', 'messaging.enabled', default=False)
# background color may be set per-request, by some apps
if hasattr(request, 'background_color') and request.background_color:
renderer_globals['background_color'] = request.background_color
else: # otherwise we use the one from config
renderer_globals['background_color'] = config.get('tailbone.background_color')
renderer_globals['background_color'] = request.rattail_config.get(
'tailbone', 'background_color')
# TODO: remove this hack once nothing references it
renderer_globals['buefy_0_8'] = False
# maybe set custom stylesheet
css = None
if request.user:
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
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
# 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 = config.get('tailbone.grids.filters.column_widths')
widths = request.rattail_config.get('tailbone', 'grids.filters.column_widths')
if widths:
widths = widths.split(';')
if len(widths) < 2:
@ -209,7 +205,7 @@ def before_render(event):
renderer_globals['filter_verb_width'] = widths[1]
# declare global support for websockets, or lack thereof
renderer_globals['expose_websockets'] = should_expose_websockets(config)
renderer_globals['expose_websockets'] = should_expose_websockets(rattail_config)
def add_inbox_count(event):
@ -223,9 +219,8 @@ def add_inbox_count(event):
request = event.get('request') or threadlocal.get_current_request()
if request.user:
renderer_globals = event
app = request.rattail_config.get_app()
model = app.model
enum = request.rattail_config.get_enum()
model = request.rattail_config.get_model()
renderer_globals['inbox_count'] = Session.query(model.Message)\
.outerjoin(model.MessageRecipient)\
.filter(model.MessageRecipient.recipient == Session.merge(request.user))\
@ -239,10 +234,27 @@ def context_found(event):
The following is attached to the request:
* ``get_referrer()`` function
* ``get_session_timeout()`` function
"""
request = event.request
def get_referrer(default=None, **kwargs):
if request.params.get('referrer'):
return request.params['referrer']
if request.session.get('referrer'):
return request.session.pop('referrer')
referrer = request.referrer
if (not referrer or referrer == request.current_route_url()
or not referrer.startswith(request.host_url)):
if default:
referrer = default
else:
referrer = request.route_url('home')
return referrer
request.get_referrer = get_referrer
def get_session_timeout():
"""
Returns the timeout in effect for the current session

View file

@ -1,2 +1,241 @@
## -*- coding: utf-8; -*-
<%inherit file="wuttaweb:templates/appinfo/configure.mako" />
<%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()}

View file

@ -1,7 +1,8 @@
## -*- coding: utf-8; -*-
<%inherit file="wuttaweb:templates/appinfo/index.mako" />
<%inherit file="/master/index.mako" />
<%def name="render_grid_component()">
<%def name="page_content()">
<div class="buttons">
<once-button type="is-primary"
@ -27,5 +28,98 @@
</div>
${parent.page_content()}
<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>
</%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()}

View file

@ -15,8 +15,8 @@
<app-settings :groups="groups" :showing-group="showingGroup"></app-settings>
</%def>
<%def name="render_vue_templates()">
${parent.render_vue_templates()}
<%def name="render_this_page_template()">
${parent.render_this_page_template()}
<script type="text/x-template" id="app-settings-template">
<div class="form">
@ -150,18 +150,19 @@
</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.groups = ${json.dumps(settings_data)|n}
ThisPageData.groups = ${json.dumps(buefy_data)|n}
ThisPageData.showingGroup = ${json.dumps(current_group or '')|n}
</script>
</%def>
<%def name="make_vue_components()">
${parent.make_vue_components()}
<script>
<%def name="make_this_page_component()">
${parent.make_this_page_component()}
<script type="text/javascript">
Vue.component('app-settings', {
template: '#app-settings-template',
@ -192,3 +193,6 @@
</script>
</%def>
${parent.body()}

View file

@ -1,10 +1,8 @@
## -*- 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>
@ -35,21 +33,17 @@
</head>
<body>
<div id="app" style="height: 100%;">
${declare_formposter_mixin()}
${self.body()}
<div id="whole-page-app">
<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()}
${self.render_whole_page_template()}
${self.make_whole_page_component()}
${self.make_whole_page_app()}
</body>
</html>
@ -96,6 +90,7 @@
${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">
@ -127,16 +122,16 @@
</%def>
<%def name="vuejs()">
${h.javascript_link(h.get_liburl(request, 'vue', prefix='tailbone'))}
${h.javascript_link(h.get_liburl(request, 'vue_resource', prefix='tailbone'))}
${h.javascript_link(h.get_liburl(request, 'vue'))}
${h.javascript_link(h.get_liburl(request, 'vue_resource'))}
</%def>
<%def name="buefy()">
${h.javascript_link(h.get_liburl(request, 'buefy', prefix='tailbone'))}
${h.javascript_link(h.get_liburl(request, 'buefy'))}
</%def>
<%def name="fontawesome()">
<script defer src="${h.get_liburl(request, 'fontawesome', prefix='tailbone')}"></script>
<script defer src="${h.get_liburl(request, 'fontawesome')}"></script>
</%def>
<%def name="extra_javascript()"></%def>
@ -156,37 +151,31 @@
${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 .button {
% if filter_fieldname_width is not Undefined:
.filters .filter-fieldname {
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 user_css:
${h.stylesheet_link(user_css)}
% if buefy_css:
## custom Buefy CSS
${h.stylesheet_link(buefy_css)}
% else:
## upstream Buefy CSS
${h.stylesheet_link(h.get_liburl(request, 'buefy.css', prefix='tailbone'))}
${h.stylesheet_link(h.get_liburl(request, 'buefy.css'))}
% endif
</%def>
<%def name="extra_styles()">
${base_meta.extra_styles()}
</%def>
<%def name="extra_styles()"></%def>
<%def name="head_tags()"></%def>
<%def name="render_vue_template_whole_page()">
<%def name="render_whole_page_template()">
<script type="text/x-template" id="whole-page-template">
<div>
<header>
@ -285,7 +274,7 @@
<span class="header-text">
${index_title}
</span>
% if master.creatable and getattr(master, 'show_create_link', True) and master.has_perm('create'):
% if master.creatable and master.show_create_link and master.has_perm('create'):
<once-button type="is-primary"
tag="a" href="${url('{}.create'.format(route_prefix))}"
icon-left="plus"
@ -311,7 +300,7 @@
<span class="header-text">
${h.link_to(instance_title, instance_url)}
</span>
% elif master.creatable and getattr(master, 'show_create_link', True) and master.has_perm('create'):
% elif master.creatable and master.show_create_link 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))}"
@ -410,7 +399,6 @@
<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"
@ -523,7 +511,7 @@
<b-button type="is-primary"
@click="showFeedback()"
icon-pack="fas"
icon-left="comment">
icon-left="fas fa-comment">
Feedback
</b-button>
</div>
@ -632,23 +620,9 @@
% 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 root-user">
Stop being root
</a>
${h.end_form()}
${h.link_to("Stop being root", url('stop_root'), class_='navbar-item root-user')}
% 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 root-user">
Become root
</a>
${h.end_form()}
${h.link_to("Become root", url('become_root'), class_='navbar-item root-user')}
% endif
% if messaging_enabled:
${h.link_to("Messages{}".format(" ({})".format(inbox_count) if inbox_count else ''), url('messages.inbox'), class_='navbar-item')}
@ -656,11 +630,7 @@
% if request.is_root or not request.user.prevent_password_change:
${h.link_to("Change Password", url('change_password'), class_='navbar-item')}
% endif
% try:
## nb. does not exist yet for wuttaweb
${h.link_to("Edit Preferences", url('my.preferences'), class_='navbar-item')}
% except:
% endtry
${h.link_to("Edit Preferences", url('my.preferences'), class_='navbar-item')}
${h.link_to("Logout", url('logout'), class_='navbar-item')}
</div>
</div>
@ -681,19 +651,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="${master.get_action_url('edit', instance)}"
<once-button tag="a" href="${action_url('edit', instance)}"
icon-left="edit"
text="Edit This">
</once-button>
% endif
% 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)}"
% if master.cloneable and master.has_perm('clone'):
<once-button tag="a" href="${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)}"
<once-button tag="a" href="${action_url('delete', instance)}"
type="is-danger"
icon-left="trash"
text="Delete This">
@ -702,7 +672,7 @@
% else:
## viewing row
% if instance_deletable and master.has_perm('delete_row'):
<once-button tag="a" href="${master.get_action_url('delete', instance)}"
<once-button tag="a" href="${action_url('delete', instance)}"
type="is-danger"
icon-left="trash"
text="Delete This">
@ -711,13 +681,13 @@
% 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)}"
<once-button tag="a" href="${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)}"
<once-button tag="a" href="${action_url('delete', instance)}"
type="is-danger"
icon-left="trash"
text="Delete This">
@ -725,13 +695,13 @@
% 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)}"
<once-button tag="a" href="${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)}"
<once-button tag="a" href="${action_url('edit', instance)}"
icon-left="edit"
text="Edit This">
</once-button>
@ -772,8 +742,11 @@
% endif
</%def>
<%def name="render_vue_script_whole_page()">
<script>
<%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">
let WholePage = {
template: '#whole-page-template',
@ -880,8 +853,7 @@
feedbackMessage: "",
% if expose_theme_picker and request.has_perm('common.change_app_theme'):
globalTheme: ${json.dumps(theme or None)|n},
referrer: location.href,
globalTheme: ${json.dumps(theme)|n},
% endif
% if can_edit_help:
@ -890,7 +862,7 @@
globalSearchActive: false,
globalSearchTerm: '',
globalSearchData: ${json.dumps(global_search_data or [])|n},
globalSearchData: ${json.dumps(global_search_data)|n},
mountedHooks: [],
}
@ -909,6 +881,54 @@
</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>
@ -930,88 +950,3 @@
</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>

View file

@ -1,7 +1,8 @@
## -*- coding: utf-8; -*-
<%inherit file="wuttaweb:templates/base_meta.mako" />
<%def name="app_title()">${app.get_node_title()}</%def>
<%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="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'))}" />
@ -10,3 +11,9 @@
<%def name="header_logo()">
${h.image(request.rattail_config.get('tailbone', 'header_image_url', default=request.static_url('tailbone:static/img/rattail.ico')), "Header Logo", style="height: 49px;")}
</%def>
<%def name="footer()">
<p class="has-text-centered">
powered by ${h.link_to("Rattail", url('about'))}
</p>
</%def>

View file

@ -68,7 +68,7 @@
% endif
</%def>
<%def name="render_form()">
<%def name="render_buefy_form()">
<div class="form">
<tailbone-form></tailbone-form>
<br />

View file

@ -9,7 +9,7 @@
<b-button type="is-primary"
:disabled="refreshResultsButtonDisabled"
icon-pack="fas"
icon-left="redo"
icon-left="fas fa-redo"
@click="refreshResults()">
{{ refreshResultsButtonText }}
</b-button>
@ -43,7 +43,7 @@
<br />
<div class="form-wrapper">
<div class="form">
${execute_form.render_vue_tag(ref='executeResultsForm')}
<${execute_form.component} ref="executeResultsForm"></${execute_form.component}>
</div>
</div>
</section>
@ -64,17 +64,10 @@
% endif
</%def>
<%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()}
<%def name="modify_this_page_vars()">
${parent.modify_this_page_vars()}
% if master.results_refreshable and master.has_perm('refresh'):
<script>
<script type="text/javascript">
TailboneGridData.refreshResultsButtonText = "Refresh Results"
TailboneGridData.refreshResultsButtonDisabled = false
@ -88,9 +81,9 @@
</script>
% endif
% if master.results_executable and master.has_perm('execute_multiple'):
<script>
<script type="text/javascript">
${execute_form.vue_component}.methods.submit = function() {
${execute_form.component_studly}.methods.submit = function() {
this.$refs.actualExecuteForm.submit()
}
@ -125,9 +118,25 @@
% endif
</%def>
<%def name="make_vue_components()">
${parent.make_vue_components()}
<%def name="make_this_page_component()">
${parent.make_this_page_component()}
% if master.results_executable and master.has_perm('execute_multiple'):
${execute_form.render_vue_finalize()}
<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>
% 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()}

View file

@ -34,7 +34,7 @@
</nav>
</%def>
<%def name="render_form_template()">
<%def name="render_form()">
<script type="text/x-template" id="${form.component}-template">
<div class="product-info">
@ -147,7 +147,7 @@
<script type="text/javascript">
let ${form.vue_component} = {
let ${form.component_studly} = {
template: '#${form.component}-template',
mixins: [SimpleRequestMixin],
@ -278,7 +278,7 @@
},
}
let ${form.vue_component}Data = {
let ${form.component_studly}Data = {
submitting: false,
productUPC: null,
@ -297,9 +297,14 @@
</script>
</%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">
ThisPageData.toggleCompleteSubmitting = false
</script>
</%def>
${parent.body()}

View file

@ -1,9 +1,13 @@
## -*- coding: utf-8; -*-
<%inherit file="/batch/view.mako" />
<%def name="modify_vue_vars()">
${parent.modify_vue_vars()}
<script>
${form.vue_component}Data.taxesData = ${json.dumps(taxes_data)|n}
<%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}
</script>
</%def>
${parent.body()}

View file

@ -39,9 +39,14 @@
</div>
</%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">
ThisPageData.catalogParsers = ${json.dumps(catalog_parsers_data)|n}
</script>
</%def>
${parent.body()}

View file

@ -1,16 +1,16 @@
## -*- coding: utf-8; -*-
<%inherit file="/batch/create.mako" />
<%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">
${form.vue_component}Data.parsers = ${json.dumps(parsers_data)|n}
${form.component_studly}Data.parsers = ${json.dumps(parsers_data)|n}
${form.vue_component}Data.vendorName = null
${form.vue_component}Data.vendorNameReplacement = null
${form.component_studly}Data.vendorName = null
${form.component_studly}Data.vendorNameReplacement = null
${form.vue_component}.watch.field_model_parser_key = function(val) {
${form.component_studly}.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.vue_component}.methods.vendorLabelChanging = function(label) {
${form.component_studly}.methods.vendorLabelChanging = function(label) {
this.vendorNameReplacement = label
}
${form.vue_component}.methods.vendorChanged = function(uuid) {
${form.component_studly}.methods.vendorChanged = function(uuid) {
if (uuid) {
this.vendorName = this.vendorNameReplacement
this.vendorNameReplacement = null
@ -37,3 +37,6 @@
</script>
</%def>
${parent.body()}

View file

@ -1,7 +1,7 @@
## -*- coding: utf-8; -*-
<%inherit file="/master/view_row.mako" />
<%def name="render_form()">
<%def name="render_buefy_form()">
<div class="form">
<tailbone-form></tailbone-form>
<br />

View file

@ -50,12 +50,12 @@
<b-button tag="a"
href="${master.get_action_url('download_worksheet', batch)}"
icon-pack="fas"
icon-left="download">
icon-left="fas fa-download">
Download Worksheet
</b-button>
<b-button type="is-primary"
icon-pack="fas"
icon-left="upload"
icon-left="fas fa-upload"
@click="$emit('show-upload')">
Upload Worksheet
</b-button>
@ -68,28 +68,28 @@
</%def>
<%def name="render_status_breakdown()">
<nav class="panel">
<p class="panel-heading">Row Status</p>
<div class="panel-block">
<div style="width: 100%;">
${status_breakdown_grid}
</div>
<div class="object-helper">
<h3>Row Status Breakdown</h3>
<div class="object-helper-content">
${status_breakdown_grid}
</div>
</nav>
</div>
</%def>
<%def name="render_execute_helper()">
<nav class="panel">
<p class="panel-heading">Execution</p>
<div class="panel-block">
<div style="display: flex; flex-direction: column; gap: 0.5rem;">
<div class="object-helper">
<h3>Batch Execution</h3>
<div class="object-helper-content">
% 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,7 +119,8 @@
<div class="markdown">
${execution_described|n}
</div>
${execute_form.render_vue_tag(ref='executeBatchForm')}
<${execute_form.component} ref="executeBatchForm">
</${execute_form.component}>
</section>
<footer class="modal-card-foot">
@ -143,9 +144,14 @@
% else:
<p>TODO: batch cannot be executed..?</p>
% endif
</div>
</div>
</nav>
</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}
</%def>
<%def name="render_this_page()">
@ -167,7 +173,8 @@
Please be certain to use the right one!
</p>
<br />
${upload_worksheet_form.render_vue_tag(ref='uploadForm')}
<${upload_worksheet_form.component} ref="uploadForm">
</${upload_worksheet_form.component}>
</section>
<footer class="modal-card-foot">
@ -177,7 +184,7 @@
<b-button type="is-primary"
@click="submitUpload()"
icon-pack="fas"
icon-left="upload"
icon-left="fas fa-upload"
:disabled="uploadButtonDisabled">
{{ uploadButtonText }}
</b-button>
@ -189,7 +196,17 @@
</%def>
<%def name="render_form()">
<%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()">
<div class="form">
<${form.component} @show-upload="showUploadDialog = true">
</${form.component}>
@ -249,27 +266,9 @@
% endif
</%def>
<%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>
<%def name="modify_this_page_vars()">
${parent.modify_this_page_vars()}
<script type="text/javascript">
ThisPageData.statusBreakdownData = ${json.dumps(status_breakdown_data)|n}
@ -285,7 +284,7 @@
}
% if not batch.executed and master.has_perm('edit'):
${form.vue_component}Data.togglingBatchComplete = false
${form.component_studly}Data.togglingBatchComplete = false
% endif
% if master.has_worksheet_file and master.allow_worksheet(batch) and master.has_perm('worksheet'):
@ -306,7 +305,7 @@
form.submit()
}
${upload_worksheet_form.vue_component}.methods.submit = function() {
${upload_worksheet_form.component_studly}.methods.submit = function() {
this.$refs.actualUploadForm.submit()
}
@ -321,7 +320,7 @@
this.$refs.executeBatchForm.submit()
}
${execute_form.vue_component}.methods.submit = function() {
${execute_form.component_studly}.methods.submit = function() {
this.$refs.actualExecuteForm.submit()
}
@ -329,9 +328,9 @@
% if master.rows_bulk_deletable and not batch.executed and master.has_perm('delete_rows'):
${rows_grid.vue_component}Data.deleteResultsShowDialog = false
${rows_grid.component_studly}Data.deleteResultsShowDialog = false
${rows_grid.vue_component}.methods.deleteResultsInit = function() {
${rows_grid.component_studly}.methods.deleteResultsInit = function() {
this.deleteResultsShowDialog = true
}
@ -340,12 +339,28 @@
</script>
</%def>
<%def name="make_vue_components()">
${parent.make_vue_components()}
<%def name="make_this_page_component()">
${parent.make_this_page_component()}
% if master.has_worksheet_file and master.allow_worksheet(batch) and master.has_perm('worksheet'):
${upload_worksheet_form.render_vue_finalize()}
<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>
% endif
% if execute_enabled and master.has_perm('execute'):
${execute_form.render_vue_finalize()}
<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>
% endif
</%def>
${parent.body()}

View file

@ -208,9 +208,9 @@
% endif
</%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">
ThisPageData.menuSequence = ${json.dumps([m['key'] for m in menus])|n}
@ -443,3 +443,6 @@
</script>
</%def>
${parent.body()}

View file

@ -92,7 +92,7 @@
<b-select name="${tmpl['setting_file']}"
v-model="inputFileTemplateSettings['${tmpl['setting_file']}']"
@input="settingsNeedSaved = true">
<option value="">-new-</option>
<option :value="null">-new-</option>
<option v-for="option in inputFileTemplateFileOptions['${tmpl['key']}']"
:key="option"
:value="option">
@ -104,40 +104,22 @@
<b-field label="Upload"
v-show="inputFileTemplateSettings['${tmpl['setting_mode']}'] == 'hosted' && !inputFileTemplateSettings['${tmpl['setting_file']}']">
% 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 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>
</b-field>
@ -161,85 +143,6 @@
</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()">
@ -280,14 +183,15 @@
<b-button @click="purgeSettingsShowDialog = false">
Cancel
</b-button>
${h.form(request.current_route_url(), **{'@submit': 'purgingSettings = true'})}
${h.form(request.current_route_url())}
${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">
icon-left="trash"
@click="purgingSettings = true">
{{ purgingSettings ? "Working, please wait..." : "Remove All Settings" }}
</b-button>
${h.end_form()}
@ -301,42 +205,62 @@
${h.end_form()}
</%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 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
}
ThisPage.methods.validateSettings = function() {}
% 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.saveSettings = function() {
ThisPage.methods.validateSettings = function() {
let msg
// nb. this is the future
for (let validator of this.validators) {
msg = validator.call(this)
% if input_file_template_settings is not Undefined:
msg = this.validateInputFileTemplateSettings()
if (msg) {
alert(msg)
return
return msg
}
}
% endif
}
// nb. legacy method
msg = this.validateSettings()
ThisPage.methods.saveSettings = function() {
let msg = this.validateSettings()
if (msg) {
alert(msg)
return
@ -367,65 +291,8 @@
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()}

View file

@ -88,9 +88,9 @@
</%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">
ThisPage.methods.getLabelForKey = function(key) {
switch (key) {
@ -111,3 +111,6 @@
</script>
</%def>
${parent.body()}

View file

@ -106,9 +106,9 @@
% endif
</%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">
ThisPageData.resolvePersonShowDialog = false
ThisPageData.resolvePersonUUID = null
@ -139,3 +139,5 @@
</script>
</%def>
${parent.body()}

View file

@ -9,26 +9,28 @@
% endif
</%def>
<%def name="render_form()">
<%def name="render_buefy_form()">
<div class="form">
<tailbone-form @detach-person="detachPerson">
</tailbone-form>
</div>
</%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 expose_shoppers:
${form.vue_component}Data.shoppers = ${json.dumps(shoppers_data)|n}
${form.component_studly}Data.shoppers = ${json.dumps(shoppers_data)|n}
% endif
% if expose_people:
${form.vue_component}Data.peopleData = ${json.dumps(people_data)|n}
${form.component_studly}Data.peopleData = ${json.dumps(people_data)|n}
% endif
ThisPage.methods.detachPerson = function(url) {
## TODO: this should require POST! but for now we just redirect..
## 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...
if (confirm("Are you sure you want to detach this person from this customer account?")) {
location.href = url
}
@ -36,3 +38,5 @@
</script>
</%def>
${parent.body()}

View file

@ -24,38 +24,29 @@
</b-checkbox>
</b-field>
<div v-show="simpleSettings['rattail.custorders.new_orders.allow_contact_info_choice']"
style="padding-left: 2rem;">
<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>
<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>
<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.&nbsp; Settings for these are at:
</p>
<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.&nbsp; 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>
<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>
<h3 class="block is-size-3">Product Handling</h3>

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,7 @@
## -*- coding: utf-8; -*-
<%inherit file="/master/view.mako" />
<%def name="render_form()">
<%def name="render_buefy_form()">
<div class="form">
<${form.component} ref="mainForm"
% if master.has_perm('confirm_price'):
@ -291,11 +291,11 @@
% endif
</%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">
${form.vue_component}Data.eventsData = ${json.dumps(events_data)|n}
${form.component_studly}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 enum.CUSTORDER_ITEM_STATUS.items()])|n}
ThisPageData.orderItemStatusOptions = ${json.dumps([dict(key=k, label=v) for k, v in six.iteritems(enum.CUSTORDER_ITEM_STATUS)])|n}
ThisPageData.oldStatusCode = ${instance.status_code}
@ -392,9 +392,9 @@
this.$refs.changeStatusForm.submit()
}
${form.vue_component}Data.changeFlaggedSubmitting = false
${form.component_studly}Data.changeFlaggedSubmitting = false
${form.vue_component}.methods.changeFlaggedSubmit = function() {
${form.component_studly}.methods.changeFlaggedSubmit = function() {
this.changeFlaggedSubmitting = true
}
@ -448,3 +448,5 @@
</script>
</%def>
${parent.body()}

View file

@ -1,6 +1,13 @@
## -*- 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()}
@ -26,9 +33,9 @@
</%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 request.has_perm('datasync.restart'):
TailboneGridData.restartDatasyncFormSubmitting = false
@ -50,3 +57,6 @@
</script>
</%def>
${parent.body()}

View file

@ -1,15 +1,6 @@
## -*- 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">
@ -57,12 +48,7 @@
${h.hidden('profiles', **{':value': 'JSON.stringify(profilesData)'})}
<b-notification type="is-warning"
% if request.use_oruga:
v-model:active="showConfigFilesNote"
% else:
:active.sync="showConfigFilesNote"
% endif
>
:active.sync="showConfigFilesNote">
## TODO: should link to some ratman page here, yes?
<p class="block">
This tool works by modifying settings in the DB.&nbsp; It
@ -83,8 +69,8 @@
</b-notification>
<b-field>
<b-checkbox name="rattail.datasync.use_profile_settings"
v-model="simpleSettings['rattail.datasync.use_profile_settings']"
<b-checkbox name="use_profile_settings"
v-model="useProfileSettings"
native-value="true"
@input="settingsNeedSaved = true">
Use these Settings to configure watchers and consumers
@ -99,7 +85,7 @@
</div>
<div class="level-right">
<div class="level-item"
v-show="simpleSettings['rattail.datasync.use_profile_settings']">
v-show="useProfileSettings">
<b-button type="is-primary"
@click="newProfile()"
icon-pack="fas"
@ -115,89 +101,75 @@
</div>
</div>
<${b}-table :data="profilesData"
:row-class="getWatcherRowClass">
<${b}-table-column field="key"
<b-table :data="filteredProfilesData"
:row-class="(row, i) => row.enabled ? null : 'has-background-warning'">
<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="simpleSettings['rattail.datasync.use_profile_settings']">
v-if="useProfileSettings">
<a href="#"
class="grid-action"
@click.prevent="editProfile(props.row)">
% 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
<i class="fas fa-edit"></i>
Edit
</a>
&nbsp;
<a href="#"
class="grid-action has-text-danger"
@click.prevent="deleteProfile(props.row)">
% 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
<i class="fas fa-trash"></i>
Delete
</a>
</${b}-table-column>
<template #empty>
</b-table-column>
<template slot="empty">
<section class="section">
<div class="content has-text-grey has-text-centered">
<p>
<b-icon
pack="fas"
icon="sad-tear"
icon="fas fa-sad-tear"
size="is-large">
</b-icon>
</p>
@ -205,7 +177,7 @@
</div>
</section>
</template>
</${b}-table>
</b-table>
<b-modal :active.sync="editProfileShowDialog">
<div class="card">
@ -227,12 +199,12 @@
</b-field>
<b-field grouped expanded>
<b-field grouped>
<b-field label="Watcher Spec"
:type="editingProfileWatcherSpec ? null : 'is-danger'"
expanded>
<b-input v-model="editingProfileWatcherSpec" expanded>
<b-input v-model="editingProfileWatcherSpec">
</b-input>
</b-field>
@ -321,54 +293,40 @@
</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)">
% 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
<i class="fas fa-edit"></i>
Edit
</a>
&nbsp;
<a href="#"
class="has-text-danger"
@click.prevent="deleteProfileWatcherKwarg(props.row)">
% 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
<i class="fas fa-trash"></i>
Delete
</a>
</${b}-table-column>
<template #empty>
</b-table-column>
<template slot="empty">
<section class="section">
<div class="content has-text-grey has-text-centered">
<p>
<b-icon
pack="fas"
icon="sad-tear"
icon="fas fa-sad-tear"
size="is-large">
</b-icon>
</p>
@ -376,7 +334,7 @@
</div>
</section>
</template>
</${b}-table>
</b-table>
</div>
@ -392,55 +350,41 @@
</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)">
% 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
<i class="fas fa-edit"></i>
Edit
</a>
&nbsp;
<a href="#"
class="grid-action has-text-danger"
@click.prevent="deleteProfileConsumer(props.row)">
% 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
<i class="fas fa-trash"></i>
Delete
</a>
</${b}-table-column>
<template #empty>
</b-table-column>
<template slot="empty">
<section class="section">
<div class="content has-text-grey has-text-centered">
<p>
<b-icon
pack="fas"
icon="sad-tear"
icon="fas fa-sad-tear"
size="is-large">
</b-icon>
</p>
@ -448,7 +392,7 @@
</div>
</section>
</template>
</${b}-table>
</b-table>
</div>
@ -580,41 +524,31 @@
<b-field label="Supervisor Process Name"
message="This should be the complete name, including group - e.g. poser:poser_datasync"
expanded>
<b-input name="rattail.datasync.supervisor_process_name"
v-model="simpleSettings['rattail.datasync.supervisor_process_name']"
@input="settingsNeedSaved = true"
expanded>
<b-input name="supervisor_process_name"
v-model="supervisorProcessName"
@input="settingsNeedSaved = true">
</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="tailbone.datasync.restart"
v-model="simpleSettings['tailbone.datasync.restart']"
@input="settingsNeedSaved = true"
expanded>
<b-input name="restart_command"
v-model="restartCommand"
@input="settingsNeedSaved = true">
</b-input>
</b-field>
</%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">
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
@ -639,6 +573,22 @@
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
@ -666,15 +616,6 @@
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) {
@ -739,9 +680,16 @@
this.editingProfilePendingConsumers = []
for (let consumer of row.consumers_data) {
const pending = {
...consumer,
let pending = {
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)
}
@ -789,8 +737,8 @@
this.editingProfilePendingWatcherKwargs.splice(i, 1)
}
ThisPage.methods.findConsumer = function(profileConsumers, key) {
for (const consumer of profileConsumers) {
ThisPage.methods.findOriginalConsumer = function(key) {
for (let consumer of this.editingProfile.consumers_data) {
if (consumer.key == key) {
return consumer
}
@ -798,15 +746,11 @@
}
ThisPage.methods.updateProfile = function() {
const row = this.editingProfile
let row = this.editingProfile
const newRow = !row.key
let originalProfile = null
if (newRow) {
if (!row.key) {
row.consumers_data = []
this.profilesData.push(row)
} else {
originalProfile = this.findProfile(row)
}
row.key = this.editingProfileKey
@ -854,8 +798,7 @@
for (let pending of this.editingProfilePendingConsumers) {
persistentConsumers.push(pending.key)
if (pending.original_key) {
const consumer = this.findConsumer(originalProfile.consumers_data,
pending.original_key)
let consumer = this.findOriginalConsumer(pending.original_key)
consumer.key = pending.key
consumer.consumer_spec = pending.consumer_spec
consumer.consumer_dbkey = pending.consumer_dbkey
@ -882,31 +825,10 @@
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)
@ -943,10 +865,8 @@
}
ThisPage.methods.updateConsumer = function() {
const pending = this.findConsumer(
this.editingProfilePendingConsumers,
this.editingConsumer.key)
const isNew = !pending.key
let pending = this.editingConsumer
let isNew = !pending.key
pending.key = this.editingConsumerKey
pending.consumer_spec = this.editingConsumerSpec
@ -987,3 +907,6 @@
</script>
</%def>
${parent.body()}

View file

@ -5,6 +5,13 @@
<%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"
@ -40,84 +47,83 @@
</div>
</b-field>
<h3 class="is-size-3">Watcher Status</h3>
<${b}-table :data="watchers">
<${b}-table-column field="key"
<b-field label="Watcher Status">
<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-table-column>
</b-table>
</b-field>
<h3 class="is-size-3">Consumer Status</h3>
<${b}-table :data="consumers">
<${b}-table-column field="key"
<b-field label="Consumer Status">
<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-table-column>
</b-table>
</b-field>
</%def>
<%def name="modify_vue_vars()">
${parent.modify_vue_vars()}
<script>
<%def name="modify_this_page_vars()">
<script type="text/javascript">
ThisPageData.processInfo = ${json.dumps(process_info)|n}
@ -172,3 +178,6 @@
</script>
</%def>
${parent.body()}

View file

@ -1,7 +1,6 @@
<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;">
@ -9,7 +8,7 @@
${field.start_mapping()}
<b-input type="password"
name="${name}"
v-model="${vmodel}"
value="${field.widget.redisplay and cstruct or ''}"
tal:attributes="class string: form-control ${css_class or ''};
style style;
attributes|field.widget.attributes|{};"
@ -19,6 +18,7 @@
</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|{};"

View file

@ -2,14 +2,11 @@
<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;
use_oruga use_oruga;">
field_name field_name|field.name;">
<div tal:define="vmodel vmodel|'field_model_' + field_name;">
${field.start_mapping()}
<b-field class="file"
tal:condition="not use_oruga">
<b-field class="file">
<b-upload name="upload"
v-model="${vmodel}">
<a class="button is-primary">
@ -21,23 +18,6 @@
{{ ${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>

View file

@ -1,9 +1,13 @@
## -*- coding: utf-8; -*-
<%inherit file="/master/view.mako" />
<%def name="modify_vue_vars()">
${parent.modify_vue_vars()}
<script>
${form.vue_component}Data.employeesData = ${json.dumps(employees_data)|n}
<%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}
</script>
</%def>
${parent.body()}

View file

@ -5,64 +5,21 @@
<%def name="render_form_buttons()"></%def>
<%def name="render_form_template()">
${form.render_vue_template(buttons=capture(self.render_form_buttons))|n}
<%def name="render_form()">
${form.render(buttons=capture(self.render_form_buttons))|n}
</%def>
<%def name="render_form()">
<%def name="render_buefy_form()">
<div class="form">
${form.render_vue_tag()}
${form.render_vuejs_component()}
</div>
</%def>
<%def name="page_content()">
% 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>
&nbsp;
<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
<div class="form-wrapper">
<br />
${self.render_buefy_form()}
</div>
</%def>
<%def name="render_this_page()">
@ -90,25 +47,25 @@
<%def name="before_object_helpers()"></%def>
<%def name="render_vue_templates()">
${parent.render_vue_templates()}
<%def name="render_this_page_template()">
% if form is not Undefined:
${self.render_form_template()}
${self.render_form()}
% endif
${parent.render_this_page_template()}
</%def>
<%def name="modify_vue_vars()">
${parent.modify_vue_vars()}
% if main_form_collapsible:
<script>
ThisPageData.mainFormPanelOpen = ${'false' if main_form_autocollapse else 'true'}
<%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})
</script>
% endif
</%def>
<%def name="make_vue_components()">
${parent.make_vue_components()}
% if form is not Undefined:
${form.render_vue_finalize()}
% endif
</%def>
${parent.body()}

View file

@ -39,7 +39,7 @@
simplePOST(action, params, success, failure) {
let csrftoken = ${json.dumps(h.get_csrf_token(request))|n}
let csrftoken = ${json.dumps(request.session.get_csrf_token() or request.session.new_csrf_token())|n}
let headers = {
'${csrf_header_name}': csrftoken,

View file

@ -1,34 +1,32 @@
## -*- coding: utf-8; -*-
<% request.register_component(form.vue_tagname, form.vue_component) %>
<script type="text/x-template" id="${form.vue_tagname}-template">
<script type="text/x-template" id="${form.component}-template">
<div>
% if not form.readonly:
${h.form(form.action_url, id=dform.formid, method='post', enctype='multipart/form-data', **(form_kwargs or {}))}
${h.form(form.action_url, id=dform.formid, method='post', enctype='multipart/form-data', **form_kwargs)}
${h.csrf_token(request)}
% endif
<section>
% if form_body is not Undefined and form_body:
${form_body|n}
% elif getattr(form, 'grouping', None):
% elif form.grouping:
% 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_field_complete(field)}
${form.render_buefy_field(field)}
% endfor
</div>
</div>
</nav>
% endfor
% else:
% for fieldname in form.fields:
${form.render_vue_field(fieldname, session=session)}
% for field in form.fields:
${form.render_buefy_field(field)}
% endfor
% endif
</section>
@ -54,20 +52,16 @@
<input type="reset" value="Reset" class="button" />
% endif
## TODO: deprecate / remove the latter option here
% if getattr(form, 'auto_disable_submit', False) or form.auto_disable_save or form.auto_disable:
% if form.auto_disable_save or form.auto_disable:
<b-button type="is-primary"
native-type="submit"
: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}" }}
:disabled="${form.component_studly}Submitting">
{{ ${form.component_studly}ButtonText }}
</b-button>
% else:
<b-button type="is-primary"
native-type="submit"
icon-pack="fas"
icon-left="save">
${form.button_label_submit}
native-type="submit">
${getattr(form, 'submit_label', getattr(form, 'save_label', "Submit"))}
</b-button>
% endif
</div>
@ -122,8 +116,8 @@
<script type="text/javascript">
let ${form.vue_component} = {
template: '#${form.vue_tagname}-template',
let ${form.component_studly} = {
template: '#${form.component}-template',
mixins: [FormPosterMixin],
components: {},
props: {
@ -136,9 +130,10 @@
methods: {
## TODO: deprecate / remove the latter option here
% 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
% 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..."
},
% endif
@ -177,10 +172,10 @@
}
}
let ${form.vue_component}Data = {
let ${form.component_studly}Data = {
## TODO: should find a better way to handle CSRF token
csrftoken: ${json.dumps(h.get_csrf_token(request))|n},
csrftoken: ${json.dumps(request.session.get_csrf_token() or request.session.new_csrf_token())|n},
% if can_edit_help:
fieldLabels: ${json.dumps(field_labels)|n},
@ -197,14 +192,16 @@
% if not form.readonly:
% for field in form.fields:
% if field in dform:
field_model_${field}: ${json.dumps(form.get_vue_field_value(field))|n},
<% field = dform[field] %>
field_model_${field.name}: ${form.get_vuejs_model_value(field)|n},
% endif
% endfor
% endif
## TODO: deprecate / remove the latter option here
% if getattr(form, 'auto_disable_submit', False) or form.auto_disable_save or form.auto_disable:
${form.vue_component}Submitting: false,
% 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},
% endif
}

View file

@ -0,0 +1,2 @@
## -*- coding: utf-8; -*-
${form.render_deform(buttons=buttons)|n}

View file

@ -0,0 +1,7 @@
## -*- 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>

View file

@ -1,3 +0,0 @@
## -*- coding: utf-8; -*-
<%inherit file="/forms/deform.mako" />
${parent.body()}

View file

@ -87,7 +87,7 @@
<div class="level-item">
<b-button type="is-primary"
icon-pack="fas"
icon-left="plus"
icon-left="fas fa-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="trash"
icon-left="fas fa-trash"
@click="new_table.columns = []"
:disabled="!new_table.columns.length">
Delete All
@ -106,68 +106,55 @@
</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)">
% if request.use_oruga:
<o-icon icon="edit" />
% else:
<i class="fas fa-edit"></i>
% endif
@click.prevent="editColumnRow(props.row)">
<i class="fas fa-edit"></i>
Edit
</a>
&nbsp;
<a href="#" class="grid-action has-text-danger"
@click.prevent="deleteColumn(props.index)">
% if request.use_oruga:
<o-icon icon="trash" />
% else:
<i class="fas fa-trash"></i>
% endif
<i class="fas fa-trash"></i>
Delete
</a>
&nbsp;
</${b}-table-column>
</b-table-column>
</${b}-table>
</b-table>
<${b}-modal has-modal-card
% if request.use_oruga:
v-model:active="showingEditColumn"
% else:
:active.sync="showingEditColumn"
% endif
>
<b-modal has-modal-card
:active.sync="showingEditColumn">
<div class="modal-card">
<header class="modal-card-head">
@ -177,13 +164,11 @@
<section class="modal-card-body">
<b-field label="Name">
<b-input v-model="editingColumnName"
expanded />
<b-input v-model="editingColumnName"></b-input>
</b-field>
<b-field label="Data Type">
<b-input v-model="editingColumnDataType"
expanded />
<b-input v-model="editingColumnDataType"></b-input>
</b-field>
<b-field label="Nullable">
@ -194,8 +179,7 @@
</b-field>
<b-field label="Description">
<b-input v-model="editingColumnDescription"
expanded />
<b-input v-model="editingColumnDescription"></b-input>
</b-field>
</section>
@ -210,7 +194,7 @@
</b-button>
</footer>
</div>
</${b}-modal>
</b-modal>
</div>
</b-field>
@ -276,9 +260,9 @@
</%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">
ThisPageData.featureType = ${json.dumps(feature_type)|n}
ThisPageData.resultGenerated = ${json.dumps(bool(result))|n}
@ -296,7 +280,7 @@
% endfor
}
% for key, form in feature_forms.items():
% for key, form in six.iteritems(feature_forms):
<% safekey = key.replace('-', '_') %>
ThisPageData.${safekey} = {
<% dform = feature_forms[key].make_deform_form() %>
@ -331,7 +315,6 @@
ThisPageData.showingEditColumn = false
ThisPageData.editingColumn = null
ThisPageData.editingColumnIndex = null
ThisPageData.editingColumnName = null
ThisPageData.editingColumnDataType = null
ThisPageData.editingColumnNullable = null
@ -339,7 +322,6 @@
ThisPage.methods.addColumn = function(column) {
this.editingColumn = null
this.editingColumnIndex = null
this.editingColumnName = null
this.editingColumnDataType = null
this.editingColumnNullable = true
@ -347,10 +329,8 @@
this.showingEditColumn = true
}
ThisPage.methods.editColumnRow = function(props) {
const column = props.row
ThisPage.methods.editColumnRow = function(column) {
this.editingColumn = column
this.editingColumnIndex = props.index
this.editingColumnName = column.name
this.editingColumnDataType = column.data_type
this.editingColumnNullable = column.nullable
@ -360,7 +340,7 @@
ThisPage.methods.saveColumn = function() {
if (this.editingColumn) {
column = this.new_table.columns[this.editingColumnIndex]
column = this.editingColumn
} else {
column = {}
this.new_table.columns.push(column)
@ -385,3 +365,6 @@
</script>
</%def>
${parent.body()}

View file

@ -8,8 +8,7 @@
<%def name="page_content()">
% if project_type:
<b-field grouped>
<b-field horizontal expanded label="Project Type"
class="is-expanded">
<b-field horizontal expanded label="Project Type">
${project_type}
</b-field>
<once-button type="is-primary"

View file

@ -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.actions:
<${b}-table-column field="actions"
% if grid.main_actions or grid.more_actions:
<b-table-column field="actions"
label="Actions"
v-slot="props">
% for action in grid.actions:
% for action in grid.main_actions:
<a :href="props.row._action_url_${action.key}"
% if action.link_class:
class="${action.link_class}"
@ -68,19 +68,20 @@
@click.prevent="${action.click_handler}"
% endif
>
${action.render_icon_and_label()}
<i class="fas fa-${action.icon}"></i>
${action.label}
</a>
&nbsp;
% endfor
</${b}-table-column>
</b-table-column>
% endif
<template #empty>
<template slot="empty">
<div class="content has-text-grey has-text-centered">
<p>
<b-icon
pack="fas"
icon="sad-tear"
icon="fas fa-sad-tear"
size="is-large">
</b-icon>
</p>
@ -98,4 +99,4 @@
</template>
% endif
</${b}-table>
</b-table>

View file

@ -0,0 +1,842 @@
## -*- 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>
&nbsp;
% 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>

View file

@ -1,930 +0,0 @@
## -*- 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>
&nbsp;
% 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>

View file

@ -1,350 +0,0 @@
## -*- 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>

View file

@ -0,0 +1,38 @@
## -*- 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 -->

View file

@ -0,0 +1,70 @@
## -*- 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>

View file

@ -1,3 +0,0 @@
## -*- coding: utf-8; -*-
<%inherit file="/grids/complete.mako" />
${parent.body()}

View file

@ -1,7 +1,33 @@
## -*- coding: utf-8; -*-
<%inherit file="wuttaweb:templates/home.mako" />
<%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>
## 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()}

View file

@ -6,65 +6,61 @@
<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 #empty>
</b-table-column>
<template slot="empty">
<section class="section">
<div class="content has-text-grey has-text-centered">
<p>
<b-icon
pack="fas"
icon="sad-tear"
icon="fas fa-sad-tear"
size="is-large">
</b-icon>
</p>
@ -72,7 +68,7 @@
</div>
</section>
</template>
</${b}-table>
</b-table>
<b-modal :active.sync="editHandlerShowDialog">
<div class="card">
@ -144,9 +140,9 @@
</b-modal>
</%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">
ThisPageData.handlersData = ${json.dumps(handlers_data)|n}
@ -203,3 +199,6 @@
</script>
</%def>
${parent.body()}

View file

@ -63,26 +63,28 @@
</div>
</%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">
${form.vue_component}Data.submittingRun = false
${form.vue_component}Data.submittingExplain = false
${form.vue_component}Data.runJob = false
${form.component_studly}Data.submittingRun = false
${form.component_studly}Data.submittingExplain = false
${form.component_studly}Data.runJob = false
${form.vue_component}.methods.submitRun = function() {
${form.component_studly}.methods.submitRun = function() {
this.submittingRun = true
this.runJob = true
this.$nextTick(() => {
this.$refs.${form.vue_component}.submit()
this.$refs.${form.component_studly}.submit()
})
}
${form.vue_component}.methods.submitExplain = function() {
${form.component_studly}.methods.submitExplain = function() {
this.submittingExplain = true
this.$refs.${form.vue_component}.submit()
this.$refs.${form.component_studly}.submit()
}
</script>
</%def>
${parent.body()}

Some files were not shown because too many files have changed in this diff Show more