Compare commits
No commits in common. "master" and "v0.8.259" have entirely different histories.
413 changed files with 24523 additions and 36096 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -1,8 +1,5 @@
|
||||||
*~
|
|
||||||
*.pyc
|
|
||||||
.coverage
|
.coverage
|
||||||
.tox/
|
.tox/
|
||||||
dist/
|
|
||||||
docs/_build/
|
docs/_build/
|
||||||
htmlcov/
|
htmlcov/
|
||||||
Tailbone.egg-info/
|
Tailbone.egg-info/
|
||||||
|
|
683
CHANGELOG.md
683
CHANGELOG.md
|
@ -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.
|
|
File diff suppressed because it is too large
Load diff
|
@ -1,8 +1,10 @@
|
||||||
|
|
||||||
# Tailbone
|
Tailbone
|
||||||
|
========
|
||||||
|
|
||||||
Tailbone is an extensible web application based on Rattail. It provides a
|
Tailbone is an extensible web application based on Rattail. It provides a
|
||||||
"back-office network environment" (BONE) for use in managing retail data.
|
"back-office network environment" (BONE) for use in managing retail data.
|
||||||
|
|
||||||
Please see Rattail's [home page](http://rattailproject.org/) for more
|
Please see Rattail's `home page`_ for more information.
|
||||||
information.
|
|
||||||
|
.. _home page: http://rattailproject.org/
|
|
@ -1,6 +0,0 @@
|
||||||
|
|
||||||
``tailbone.db``
|
|
||||||
===============
|
|
||||||
|
|
||||||
.. automodule:: tailbone.db
|
|
||||||
:members:
|
|
|
@ -1,6 +0,0 @@
|
||||||
|
|
||||||
``tailbone.diffs``
|
|
||||||
==================
|
|
||||||
|
|
||||||
.. automodule:: tailbone.diffs
|
|
||||||
:members:
|
|
|
@ -1,6 +0,0 @@
|
||||||
|
|
||||||
``tailbone.forms.widgets``
|
|
||||||
==========================
|
|
||||||
|
|
||||||
.. automodule:: tailbone.forms.widgets
|
|
||||||
:members:
|
|
|
@ -1,6 +0,0 @@
|
||||||
|
|
||||||
``tailbone.grids.core``
|
|
||||||
=======================
|
|
||||||
|
|
||||||
.. automodule:: tailbone.grids.core
|
|
||||||
:members:
|
|
|
@ -3,4 +3,5 @@
|
||||||
========================
|
========================
|
||||||
|
|
||||||
.. automodule:: tailbone.subscribers
|
.. automodule:: tailbone.subscribers
|
||||||
:members:
|
|
||||||
|
.. autofunction:: new_request
|
||||||
|
|
|
@ -1,6 +0,0 @@
|
||||||
|
|
||||||
``tailbone.util``
|
|
||||||
=================
|
|
||||||
|
|
||||||
.. automodule:: tailbone.util
|
|
||||||
:members:
|
|
|
@ -81,12 +81,6 @@ override when defining your subclass.
|
||||||
override this for certain views, if so that should be done within
|
override this for certain views, if so that should be done within
|
||||||
:meth:`get_help_url()`.
|
: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
|
Methods to Override
|
||||||
-------------------
|
-------------------
|
||||||
|
@ -94,8 +88,6 @@ Methods to Override
|
||||||
The following is a list of methods which you can override when defining your
|
The following is a list of methods which you can override when defining your
|
||||||
subclass.
|
subclass.
|
||||||
|
|
||||||
.. automethod:: MasterView.editable_instance
|
|
||||||
|
|
||||||
.. .. automethod:: MasterView.get_settings
|
.. .. automethod:: MasterView.get_settings
|
||||||
|
|
||||||
.. automethod:: MasterView.get_csv_fields
|
.. automethod:: MasterView.get_csv_fields
|
||||||
|
@ -103,24 +95,3 @@ subclass.
|
||||||
.. automethod:: MasterView.get_csv_row
|
.. automethod:: MasterView.get_csv_row
|
||||||
|
|
||||||
.. automethod:: MasterView.get_help_url
|
.. automethod:: MasterView.get_help_url
|
||||||
|
|
||||||
.. 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
|
|
||||||
---------------
|
|
||||||
|
|
||||||
The following is a list of methods you should (probably) not need to
|
|
||||||
override, but may find useful:
|
|
||||||
|
|
||||||
.. automethod:: MasterView.default_edit_url
|
|
||||||
|
|
||||||
.. automethod:: MasterView.get_action_route_kwargs
|
|
||||||
|
|
|
@ -1,6 +0,0 @@
|
||||||
|
|
||||||
``tailbone.views.members``
|
|
||||||
==========================
|
|
||||||
|
|
||||||
.. automodule:: tailbone.views.members
|
|
||||||
:members:
|
|
|
@ -1,8 +0,0 @@
|
||||||
|
|
||||||
Changelog Archive
|
|
||||||
=================
|
|
||||||
|
|
||||||
.. toctree::
|
|
||||||
:maxdepth: 1
|
|
||||||
|
|
||||||
OLDCHANGES
|
|
278
docs/conf.py
278
docs/conf.py
|
@ -1,21 +1,38 @@
|
||||||
# Configuration file for the Sphinx documentation builder.
|
# -*- coding: utf-8; -*-
|
||||||
#
|
#
|
||||||
# For the full list of built-in configuration values, see the documentation:
|
# Tailbone documentation build configuration file, created by
|
||||||
# https://www.sphinx-doc.org/en/master/usage/configuration.html
|
# 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 -----------------------------------------------------
|
import sys
|
||||||
# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
|
import os
|
||||||
|
|
||||||
from importlib.metadata import version as get_version
|
import sphinx_rtd_theme
|
||||||
|
|
||||||
project = 'Tailbone'
|
exec(open(os.path.join(os.pardir, 'tailbone', '_version.py')).read())
|
||||||
copyright = '2010 - 2024, Lance Edgar'
|
|
||||||
author = 'Lance Edgar'
|
|
||||||
release = get_version('Tailbone')
|
|
||||||
|
|
||||||
# -- General configuration ---------------------------------------------------
|
|
||||||
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
|
|
||||||
|
|
||||||
|
# If extensions (or modules to document with autodoc) are in another directory,
|
||||||
|
# add these directories to sys.path here. If the directory is relative to the
|
||||||
|
# documentation root, use os.path.abspath to make it absolute, like shown here.
|
||||||
|
#sys.path.insert(0, os.path.abspath('.'))
|
||||||
|
|
||||||
|
# -- General configuration ------------------------------------------------
|
||||||
|
|
||||||
|
# If your documentation needs a minimal Sphinx version, state it here.
|
||||||
|
#needs_sphinx = '1.0'
|
||||||
|
|
||||||
|
# Add any Sphinx extension module names here, as strings. They can be
|
||||||
|
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
|
||||||
|
# ones.
|
||||||
extensions = [
|
extensions = [
|
||||||
'sphinx.ext.autodoc',
|
'sphinx.ext.autodoc',
|
||||||
'sphinx.ext.todo',
|
'sphinx.ext.todo',
|
||||||
|
@ -23,30 +40,241 @@ extensions = [
|
||||||
'sphinx.ext.viewcode',
|
'sphinx.ext.viewcode',
|
||||||
]
|
]
|
||||||
|
|
||||||
templates_path = ['_templates']
|
|
||||||
exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
|
|
||||||
|
|
||||||
intersphinx_mapping = {
|
intersphinx_mapping = {
|
||||||
'rattail': ('https://docs.wuttaproject.org/rattail/', None),
|
'rattail': ('https://rattailproject.org/docs/rattail/', None),
|
||||||
'webhelpers2': ('https://webhelpers2.readthedocs.io/en/latest/', 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
|
todo_include_todos = True
|
||||||
|
|
||||||
|
|
||||||
# -- Options for HTML output -------------------------------------------------
|
# -- Options for HTML output ----------------------------------------------
|
||||||
# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output
|
|
||||||
|
|
||||||
html_theme = 'furo'
|
# The theme to use for HTML and HTML Help pages. See the documentation for
|
||||||
html_static_path = ['_static']
|
# 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
|
# The name of an image file (relative to this directory) to place at the top
|
||||||
# of the sidebar.
|
# of the sidebar.
|
||||||
#html_logo = None
|
#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.
|
# 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
|
||||||
|
|
|
@ -44,32 +44,18 @@ Package API:
|
||||||
|
|
||||||
api/api/batch/core
|
api/api/batch/core
|
||||||
api/api/batch/ordering
|
api/api/batch/ordering
|
||||||
api/db
|
|
||||||
api/diffs
|
|
||||||
api/forms
|
api/forms
|
||||||
api/forms.widgets
|
|
||||||
api/grids
|
api/grids
|
||||||
api/grids.core
|
|
||||||
api/progress
|
api/progress
|
||||||
api/subscribers
|
api/subscribers
|
||||||
api/util
|
|
||||||
api/views/batch
|
api/views/batch
|
||||||
api/views/batch.vendorcatalog
|
api/views/batch.vendorcatalog
|
||||||
api/views/core
|
api/views/core
|
||||||
api/views/master
|
api/views/master
|
||||||
api/views/members
|
|
||||||
api/views/purchasing.batch
|
api/views/purchasing.batch
|
||||||
api/views/purchasing.ordering
|
api/views/purchasing.ordering
|
||||||
|
|
||||||
|
|
||||||
Changelog:
|
|
||||||
|
|
||||||
.. toctree::
|
|
||||||
:maxdepth: 1
|
|
||||||
|
|
||||||
changelog
|
|
||||||
|
|
||||||
|
|
||||||
Documentation To-Do
|
Documentation To-Do
|
||||||
===================
|
===================
|
||||||
|
|
||||||
|
|
103
pyproject.toml
103
pyproject.toml
|
@ -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"
|
|
6
setup.cfg
Normal file
6
setup.cfg
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
[nosetests]
|
||||||
|
nocapture = 1
|
||||||
|
cover-package = tailbone
|
||||||
|
cover-erase = 1
|
||||||
|
cover-html = 1
|
||||||
|
cover-html-dir = htmlcov
|
190
setup.py
Normal file
190
setup.py
Normal file
|
@ -0,0 +1,190 @@
|
||||||
|
# -*- coding: utf-8; -*-
|
||||||
|
################################################################################
|
||||||
|
#
|
||||||
|
# Rattail -- Retail Software Framework
|
||||||
|
# Copyright © 2010-2022 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/>.
|
||||||
|
#
|
||||||
|
################################################################################
|
||||||
|
"""
|
||||||
|
Setup script for Tailbone
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import unicode_literals, absolute_import
|
||||||
|
|
||||||
|
import os.path
|
||||||
|
from setuptools import setup, find_packages
|
||||||
|
|
||||||
|
|
||||||
|
here = os.path.abspath(os.path.dirname(__file__))
|
||||||
|
exec(open(os.path.join(here, 'tailbone', '_version.py')).read())
|
||||||
|
README = open(os.path.join(here, 'README.rst')).read()
|
||||||
|
|
||||||
|
|
||||||
|
requires = [
|
||||||
|
#
|
||||||
|
# Version numbers within comments below have specific meanings.
|
||||||
|
# Basically the 'low' value is a "soft low," and 'high' a "soft high."
|
||||||
|
# In other words:
|
||||||
|
#
|
||||||
|
# If either a 'low' or 'high' value exists, the primary point to be
|
||||||
|
# made about the value is that it represents the most current (stable)
|
||||||
|
# version available for the package (assuming typical public access
|
||||||
|
# methods) whenever this project was started and/or documented.
|
||||||
|
# Therefore:
|
||||||
|
#
|
||||||
|
# If a 'low' version is present, you should know that attempts to use
|
||||||
|
# versions of the package significantly older than the 'low' version
|
||||||
|
# may not yield happy results. (A "hard" high limit may or may not be
|
||||||
|
# indicated by a true version requirement.)
|
||||||
|
#
|
||||||
|
# Similarly, if a 'high' version is present, and especially if this
|
||||||
|
# project has laid dormant for a while, you may need to refactor a bit
|
||||||
|
# when attempting to support a more recent version of the package. (A
|
||||||
|
# "hard" low limit should be indicated by a true version requirement
|
||||||
|
# when a 'high' version is present.)
|
||||||
|
#
|
||||||
|
# In any case, developers and other users are encouraged to play
|
||||||
|
# outside the lines with regard to these soft limits. If bugs are
|
||||||
|
# encountered then they should be filed as such.
|
||||||
|
#
|
||||||
|
# package # low high
|
||||||
|
|
||||||
|
# TODO: previously was capping this to pre-1.0 although i'm not sure why.
|
||||||
|
# however the 1.2 release has some breaking changes which require refactor.
|
||||||
|
# cf. https://pypi.org/project/zope.sqlalchemy/#id3
|
||||||
|
'zope.sqlalchemy<1.2', # 0.7 1.1
|
||||||
|
|
||||||
|
# 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', # 0.1
|
||||||
|
|
||||||
|
# TODO: remove version cap once we can drop support for python 2.x
|
||||||
|
'cornice<5.0', # 3.4.2 4.0.1
|
||||||
|
|
||||||
|
# TODO: remove once their bug is fixed? idk what this is about yet...
|
||||||
|
'deform<2.0.15', # 2.0.14
|
||||||
|
|
||||||
|
# TODO: cornice<5 requires pyramid<2 (see above)
|
||||||
|
'pyramid<2', # 1.3b2 1.10.8
|
||||||
|
|
||||||
|
'asgiref', # 3.2.3
|
||||||
|
'colander', # 1.7.0
|
||||||
|
'ColanderAlchemy', # 0.3.3
|
||||||
|
'humanize', # 0.5.1
|
||||||
|
'Mako', # 0.6.2
|
||||||
|
'markdown', # 3.3.3
|
||||||
|
'openpyxl', # 2.4.7
|
||||||
|
'paginate', # 0.5.6
|
||||||
|
'paginate_sqlalchemy', # 0.2.0
|
||||||
|
'passlib', # 1.7.1
|
||||||
|
'Pillow', # 5.3.0
|
||||||
|
'pyramid_beaker>=0.6', # 0.6.1
|
||||||
|
'pyramid_deform', # 0.2
|
||||||
|
'pyramid_exclog', # 0.6
|
||||||
|
'pyramid_mako', # 1.0.2
|
||||||
|
'pyramid_tm', # 0.3
|
||||||
|
'rattail[db,bouncer]', # 0.5.0
|
||||||
|
'six', # 1.10.0
|
||||||
|
'sqlalchemy-filters', # 0.8.0
|
||||||
|
'transaction', # 1.2.0
|
||||||
|
'waitress', # 0.8.1
|
||||||
|
'WebHelpers2', # 2.0
|
||||||
|
'WTForms', # 2.1
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
extras = {
|
||||||
|
|
||||||
|
'docs': [
|
||||||
|
#
|
||||||
|
# package # low high
|
||||||
|
|
||||||
|
# TODO: remove version workaround after next sphinx[-rtd-theme] release
|
||||||
|
# cf. https://github.com/readthedocs/sphinx_rtd_theme/issues/1343
|
||||||
|
'Sphinx!=5.2.0.post0', # 1.2
|
||||||
|
'sphinx-rtd-theme', # 0.2.4
|
||||||
|
],
|
||||||
|
|
||||||
|
'tests': [
|
||||||
|
#
|
||||||
|
# package # low high
|
||||||
|
|
||||||
|
'coverage', # 3.6
|
||||||
|
'fixture', # 1.5
|
||||||
|
'mock', # 1.0.1
|
||||||
|
'nose', # 1.3.0
|
||||||
|
'pytest', # 4.6.11
|
||||||
|
'pytest-cov', # 2.12.1
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
setup(
|
||||||
|
name = "Tailbone",
|
||||||
|
version = __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 = README,
|
||||||
|
|
||||||
|
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 :: 2.7',
|
||||||
|
'Programming Language :: Python :: 3',
|
||||||
|
'Programming Language :: Python :: 3.5',
|
||||||
|
'Topic :: Internet :: WWW/HTTP',
|
||||||
|
'Topic :: Office/Business',
|
||||||
|
'Topic :: Software Development :: Libraries :: Python Modules',
|
||||||
|
],
|
||||||
|
|
||||||
|
install_requires = requires,
|
||||||
|
extras_require = extras,
|
||||||
|
tests_require = ['Tailbone[tests]'],
|
||||||
|
test_suite = 'nose.collector',
|
||||||
|
|
||||||
|
packages = find_packages(exclude=['tests.*', 'tests']),
|
||||||
|
include_package_data = True,
|
||||||
|
zip_safe = False,
|
||||||
|
|
||||||
|
entry_points = {
|
||||||
|
|
||||||
|
'paste.app_factory': [
|
||||||
|
'main = tailbone.app:main',
|
||||||
|
'webapi = tailbone.webapi:main',
|
||||||
|
],
|
||||||
|
|
||||||
|
'rattail.config.extensions': [
|
||||||
|
'tailbone = tailbone.config:ConfigExtension',
|
||||||
|
],
|
||||||
|
|
||||||
|
'pyramid.scaffold': [
|
||||||
|
'rattail = tailbone.scaffolds:RattailTemplate',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
|
@ -1,9 +1,3 @@
|
||||||
# -*- coding: utf-8; -*-
|
# -*- coding: utf-8; -*-
|
||||||
|
|
||||||
try:
|
__version__ = '0.8.259'
|
||||||
from importlib.metadata import version
|
|
||||||
except ImportError:
|
|
||||||
from importlib_metadata import version
|
|
||||||
|
|
||||||
|
|
||||||
__version__ = version('Tailbone')
|
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
################################################################################
|
################################################################################
|
||||||
#
|
#
|
||||||
# Rattail -- Retail Software Framework
|
# Rattail -- Retail Software Framework
|
||||||
# Copyright © 2010-2024 Lance Edgar
|
# Copyright © 2010-2022 Lance Edgar
|
||||||
#
|
#
|
||||||
# This file is part of Rattail.
|
# This file is part of Rattail.
|
||||||
#
|
#
|
||||||
|
@ -24,6 +24,10 @@
|
||||||
Tailbone Web API - Auth Views
|
Tailbone Web API - Auth Views
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from __future__ import unicode_literals, absolute_import
|
||||||
|
|
||||||
|
from rattail.db.auth import set_user_password
|
||||||
|
|
||||||
from cornice import Service
|
from cornice import Service
|
||||||
|
|
||||||
from tailbone.api import APIView, api
|
from tailbone.api import APIView, api
|
||||||
|
@ -40,10 +44,11 @@ class AuthenticationView(APIView):
|
||||||
This will establish a server-side web session for the user if none
|
This will establish a server-side web session for the user if none
|
||||||
exists. Note that this also resets the user's session timer.
|
exists. Note that this also resets the user's session timer.
|
||||||
"""
|
"""
|
||||||
data = {'ok': True, 'permissions': []}
|
data = {'ok': True}
|
||||||
if self.request.user:
|
if self.request.user:
|
||||||
data['user'] = self.get_user_info(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
|
# background color may be set per-request, by some apps
|
||||||
if hasattr(self.request, 'background_color') and self.request.background_color:
|
if hasattr(self.request, 'background_color') and self.request.background_color:
|
||||||
|
@ -163,9 +168,6 @@ class AuthenticationView(APIView):
|
||||||
if not self.request.user:
|
if not self.request.user:
|
||||||
raise self.forbidden()
|
raise self.forbidden()
|
||||||
|
|
||||||
if self.request.user.prevent_password_change and not self.request.is_root:
|
|
||||||
raise self.forbidden()
|
|
||||||
|
|
||||||
data = self.request.json_body
|
data = self.request.json_body
|
||||||
|
|
||||||
# first make sure "current" password is accurate
|
# first make sure "current" password is accurate
|
||||||
|
@ -173,8 +175,7 @@ class AuthenticationView(APIView):
|
||||||
return {'error': "The current/old password you provided is incorrect"}
|
return {'error': "The current/old password you provided is incorrect"}
|
||||||
|
|
||||||
# okay then, set new password
|
# okay then, set new password
|
||||||
auth = self.app.get_auth_handler()
|
set_user_password(self.request.user, data['new_password'])
|
||||||
auth.set_user_password(self.request.user, data['new_password'])
|
|
||||||
return {
|
return {
|
||||||
'ok': True,
|
'ok': True,
|
||||||
'user': self.get_user_info(self.request.user),
|
'user': self.get_user_info(self.request.user),
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
################################################################################
|
################################################################################
|
||||||
#
|
#
|
||||||
# Rattail -- Retail Software Framework
|
# Rattail -- Retail Software Framework
|
||||||
# Copyright © 2010-2023 Lance Edgar
|
# Copyright © 2010-2022 Lance Edgar
|
||||||
#
|
#
|
||||||
# This file is part of Rattail.
|
# This file is part of Rattail.
|
||||||
#
|
#
|
||||||
|
@ -24,9 +24,13 @@
|
||||||
Tailbone Web API - Batch Views
|
Tailbone Web API - Batch Views
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from __future__ import unicode_literals, absolute_import
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import warnings
|
import warnings
|
||||||
|
|
||||||
|
import six
|
||||||
|
|
||||||
from cornice import Service
|
from cornice import Service
|
||||||
|
|
||||||
from tailbone.api import APIMasterView
|
from tailbone.api import APIMasterView
|
||||||
|
@ -66,7 +70,9 @@ class APIBatchMixin(object):
|
||||||
"""
|
"""
|
||||||
app = self.get_rattail_app()
|
app = self.get_rattail_app()
|
||||||
key = self.get_batch_class().batch_key
|
key = self.get_batch_class().batch_key
|
||||||
return app.get_batch_handler(key, default=self.default_handler_spec)
|
spec = self.rattail_config.get('rattail.batch', '{}.handler'.format(key),
|
||||||
|
default=self.default_handler_spec)
|
||||||
|
return app.load_object(spec)(self.rattail_config)
|
||||||
|
|
||||||
|
|
||||||
class APIBatchView(APIBatchMixin, APIMasterView):
|
class APIBatchView(APIBatchMixin, APIMasterView):
|
||||||
|
@ -98,25 +104,25 @@ class APIBatchView(APIBatchMixin, APIMasterView):
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'uuid': batch.uuid,
|
'uuid': batch.uuid,
|
||||||
'_str': str(batch),
|
'_str': six.text_type(batch),
|
||||||
'id': batch.id,
|
'id': batch.id,
|
||||||
'id_str': batch.id_str,
|
'id_str': batch.id_str,
|
||||||
'description': batch.description,
|
'description': batch.description,
|
||||||
'notes': batch.notes,
|
'notes': batch.notes,
|
||||||
'params': batch.params or {},
|
'params': batch.params or {},
|
||||||
'rowcount': batch.rowcount,
|
'rowcount': batch.rowcount,
|
||||||
'created': str(created),
|
'created': six.text_type(created),
|
||||||
'created_display': self.pretty_datetime(created),
|
'created_display': self.pretty_datetime(created),
|
||||||
'created_by_uuid': batch.created_by.uuid,
|
'created_by_uuid': batch.created_by.uuid,
|
||||||
'created_by_display': str(batch.created_by),
|
'created_by_display': six.text_type(batch.created_by),
|
||||||
'complete': batch.complete,
|
'complete': batch.complete,
|
||||||
'status_code': batch.status_code,
|
'status_code': batch.status_code,
|
||||||
'status_display': batch.STATUS.get(batch.status_code,
|
'status_display': batch.STATUS.get(batch.status_code,
|
||||||
str(batch.status_code)),
|
six.text_type(batch.status_code)),
|
||||||
'executed': str(executed) if executed else None,
|
'executed': six.text_type(executed) if executed else None,
|
||||||
'executed_display': self.pretty_datetime(executed) if executed else None,
|
'executed_display': self.pretty_datetime(executed) if executed else None,
|
||||||
'executed_by_uuid': batch.executed_by_uuid,
|
'executed_by_uuid': batch.executed_by_uuid,
|
||||||
'executed_by_display': str(batch.executed_by or ''),
|
'executed_by_display': six.text_type(batch.executed_by or ''),
|
||||||
'mutable': self.batch_handler.is_mutable(batch),
|
'mutable': self.batch_handler.is_mutable(batch),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -267,8 +273,8 @@ class APIBatchRowView(APIBatchMixin, APIMasterView):
|
||||||
batch = row.batch
|
batch = row.batch
|
||||||
return {
|
return {
|
||||||
'uuid': row.uuid,
|
'uuid': row.uuid,
|
||||||
'_str': str(row),
|
'_str': six.text_type(row),
|
||||||
'_parent_str': str(batch),
|
'_parent_str': six.text_type(batch),
|
||||||
'_parent_uuid': batch.uuid,
|
'_parent_uuid': batch.uuid,
|
||||||
'batch_uuid': batch.uuid,
|
'batch_uuid': batch.uuid,
|
||||||
'batch_id': batch.id,
|
'batch_id': batch.id,
|
||||||
|
@ -279,7 +285,7 @@ class APIBatchRowView(APIBatchMixin, APIMasterView):
|
||||||
'batch_mutable': self.batch_handler.is_mutable(batch),
|
'batch_mutable': self.batch_handler.is_mutable(batch),
|
||||||
'sequence': row.sequence,
|
'sequence': row.sequence,
|
||||||
'status_code': row.status_code,
|
'status_code': row.status_code,
|
||||||
'status_display': row.STATUS.get(row.status_code, str(row.status_code)),
|
'status_display': row.STATUS.get(row.status_code, six.text_type(row.status_code)),
|
||||||
}
|
}
|
||||||
|
|
||||||
def update_object(self, row, data):
|
def update_object(self, row, data):
|
||||||
|
@ -314,7 +320,7 @@ class APIBatchRowView(APIBatchMixin, APIMasterView):
|
||||||
data = self.request.json_body
|
data = self.request.json_body
|
||||||
|
|
||||||
uuid = data['batch_uuid']
|
uuid = data['batch_uuid']
|
||||||
batch = self.Session.get(self.get_batch_class(), uuid)
|
batch = self.Session.query(self.get_batch_class()).get(uuid)
|
||||||
if not batch:
|
if not batch:
|
||||||
raise self.notfound()
|
raise self.notfound()
|
||||||
|
|
||||||
|
@ -326,14 +332,11 @@ class APIBatchRowView(APIBatchMixin, APIMasterView):
|
||||||
log.warning("quick entry failed for '%s' batch %s: %s",
|
log.warning("quick entry failed for '%s' batch %s: %s",
|
||||||
self.batch_handler.batch_key, batch.id_str, entry,
|
self.batch_handler.batch_key, batch.id_str, entry,
|
||||||
exc_info=True)
|
exc_info=True)
|
||||||
msg = str(error)
|
msg = six.text_type(error)
|
||||||
if not msg and isinstance(error, NotImplementedError):
|
if not msg and isinstance(error, NotImplementedError):
|
||||||
msg = "Feature is not implemented"
|
msg = "Feature is not implemented"
|
||||||
return {'error': msg}
|
return {'error': msg}
|
||||||
|
|
||||||
if not row:
|
|
||||||
return {'error': "Could not identify product"}
|
|
||||||
|
|
||||||
self.Session.flush()
|
self.Session.flush()
|
||||||
result = self._get(obj=row)
|
result = self._get(obj=row)
|
||||||
result['ok'] = True
|
result['ok'] = True
|
||||||
|
@ -349,12 +352,13 @@ class APIBatchRowView(APIBatchMixin, APIMasterView):
|
||||||
route_prefix = cls.get_route_prefix()
|
route_prefix = cls.get_route_prefix()
|
||||||
permission_prefix = cls.get_permission_prefix()
|
permission_prefix = cls.get_permission_prefix()
|
||||||
collection_url_prefix = cls.get_collection_url_prefix()
|
collection_url_prefix = cls.get_collection_url_prefix()
|
||||||
|
object_url_prefix = cls.get_object_url_prefix()
|
||||||
|
|
||||||
if cls.supports_quick_entry:
|
if cls.supports_quick_entry:
|
||||||
|
|
||||||
# quick entry
|
# quick entry
|
||||||
quick_entry = Service(name='{}.quick_entry'.format(route_prefix),
|
config.add_route('{}.quick_entry'.format(route_prefix), '{}/quick-entry'.format(collection_url_prefix),
|
||||||
path='{}/quick-entry'.format(collection_url_prefix))
|
request_method=('OPTIONS', 'POST'))
|
||||||
quick_entry.add_view('POST', 'quick_entry', klass=cls,
|
config.add_view(cls, attr='quick_entry', route_name='{}.quick_entry'.format(route_prefix),
|
||||||
permission='{}.edit'.format(permission_prefix))
|
permission='{}.edit'.format(permission_prefix),
|
||||||
config.add_cornice_service(quick_entry)
|
renderer='json')
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
################################################################################
|
################################################################################
|
||||||
#
|
#
|
||||||
# Rattail -- Retail Software Framework
|
# Rattail -- Retail Software Framework
|
||||||
# Copyright © 2010-2023 Lance Edgar
|
# Copyright © 2010-2022 Lance Edgar
|
||||||
#
|
#
|
||||||
# This file is part of Rattail.
|
# This file is part of Rattail.
|
||||||
#
|
#
|
||||||
|
@ -24,12 +24,15 @@
|
||||||
Tailbone Web API - Inventory Batches
|
Tailbone Web API - Inventory Batches
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from __future__ import unicode_literals, absolute_import
|
||||||
|
|
||||||
import decimal
|
import decimal
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import six
|
||||||
|
|
||||||
from rattail import pod
|
from rattail import pod
|
||||||
from rattail.db.model import InventoryBatch, InventoryBatchRow
|
from rattail.db import model
|
||||||
|
from rattail.util import pretty_quantity
|
||||||
|
|
||||||
from cornice import Service
|
from cornice import Service
|
||||||
|
|
||||||
|
@ -38,7 +41,7 @@ from tailbone.api.batch import APIBatchView, APIBatchRowView
|
||||||
|
|
||||||
class InventoryBatchViews(APIBatchView):
|
class InventoryBatchViews(APIBatchView):
|
||||||
|
|
||||||
model_class = InventoryBatch
|
model_class = model.InventoryBatch
|
||||||
default_handler_spec = 'rattail.batch.inventory:InventoryBatchHandler'
|
default_handler_spec = 'rattail.batch.inventory:InventoryBatchHandler'
|
||||||
route_prefix = 'inventory'
|
route_prefix = 'inventory'
|
||||||
permission_prefix = 'batch.inventory'
|
permission_prefix = 'batch.inventory'
|
||||||
|
@ -47,12 +50,12 @@ class InventoryBatchViews(APIBatchView):
|
||||||
supports_toggle_complete = True
|
supports_toggle_complete = True
|
||||||
|
|
||||||
def normalize(self, batch):
|
def normalize(self, batch):
|
||||||
data = super().normalize(batch)
|
data = super(InventoryBatchViews, self).normalize(batch)
|
||||||
|
|
||||||
data['mode'] = batch.mode
|
data['mode'] = batch.mode
|
||||||
data['mode_display'] = self.enum.INVENTORY_MODE.get(batch.mode)
|
data['mode_display'] = self.enum.INVENTORY_MODE.get(batch.mode)
|
||||||
if data['mode_display'] is None and batch.mode is not None:
|
if data['mode_display'] is None and batch.mode is not None:
|
||||||
data['mode_display'] = str(batch.mode)
|
data['mode_display'] = six.text_type(batch.mode)
|
||||||
|
|
||||||
data['reason_code'] = batch.reason_code
|
data['reason_code'] = batch.reason_code
|
||||||
|
|
||||||
|
@ -116,7 +119,7 @@ class InventoryBatchViews(APIBatchView):
|
||||||
|
|
||||||
class InventoryBatchRowViews(APIBatchRowView):
|
class InventoryBatchRowViews(APIBatchRowView):
|
||||||
|
|
||||||
model_class = InventoryBatchRow
|
model_class = model.InventoryBatchRow
|
||||||
default_handler_spec = 'rattail.batch.inventory:InventoryBatchHandler'
|
default_handler_spec = 'rattail.batch.inventory:InventoryBatchHandler'
|
||||||
route_prefix = 'inventory.rows'
|
route_prefix = 'inventory.rows'
|
||||||
permission_prefix = 'batch.inventory'
|
permission_prefix = 'batch.inventory'
|
||||||
|
@ -127,24 +130,23 @@ class InventoryBatchRowViews(APIBatchRowView):
|
||||||
|
|
||||||
def normalize(self, row):
|
def normalize(self, row):
|
||||||
batch = row.batch
|
batch = row.batch
|
||||||
data = super().normalize(row)
|
data = super(InventoryBatchRowViews, self).normalize(row)
|
||||||
app = self.get_rattail_app()
|
|
||||||
|
|
||||||
data['item_id'] = row.item_id
|
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['upc_pretty'] = row.upc.pretty() if row.upc else None
|
||||||
data['brand_name'] = row.brand_name
|
data['brand_name'] = row.brand_name
|
||||||
data['description'] = row.description
|
data['description'] = row.description
|
||||||
data['size'] = row.size
|
data['size'] = row.size
|
||||||
data['full_description'] = row.product.full_description if row.product else row.description
|
data['full_description'] = row.product.full_description if row.product else row.description
|
||||||
data['image_url'] = pod.get_image_url(self.rattail_config, row.upc) if row.upc else None
|
data['image_url'] = pod.get_image_url(self.rattail_config, row.upc) if row.upc else None
|
||||||
data['case_quantity'] = app.render_quantity(row.case_quantity or 1)
|
data['case_quantity'] = pretty_quantity(row.case_quantity or 1)
|
||||||
|
|
||||||
data['cases'] = row.cases
|
data['cases'] = row.cases
|
||||||
data['units'] = row.units
|
data['units'] = row.units
|
||||||
data['unit_uom'] = 'LB' if row.product and row.product.weighed else 'EA'
|
data['unit_uom'] = 'LB' if row.product and row.product.weighed else 'EA'
|
||||||
data['quantity_display'] = "{} {}".format(
|
data['quantity_display'] = "{} {}".format(
|
||||||
app.render_quantity(row.cases or row.units),
|
pretty_quantity(row.cases or row.units),
|
||||||
'CS' if row.cases else data['unit_uom'])
|
'CS' if row.cases else data['unit_uom'])
|
||||||
|
|
||||||
data['allow_cases'] = self.batch_handler.allow_cases(batch)
|
data['allow_cases'] = self.batch_handler.allow_cases(batch)
|
||||||
|
@ -172,17 +174,7 @@ class InventoryBatchRowViews(APIBatchRowView):
|
||||||
data['units'] = decimal.Decimal(data['units'])
|
data['units'] = decimal.Decimal(data['units'])
|
||||||
|
|
||||||
# update row per usual
|
# update row per usual
|
||||||
try:
|
row = super(InventoryBatchRowViews, self).update_object(row, data)
|
||||||
row = super().update_object(row, data)
|
|
||||||
except sa.exc.DataError as error:
|
|
||||||
# detect when user scans barcode for cases/units field
|
|
||||||
if hasattr(error, 'orig'):
|
|
||||||
orig = type(error.orig)
|
|
||||||
if hasattr(orig, '__name__'):
|
|
||||||
# nb. this particular error is from psycopg2
|
|
||||||
if orig.__name__ == 'NumericValueOutOfRange':
|
|
||||||
return {'error': "Numeric value out of range"}
|
|
||||||
raise
|
|
||||||
return row
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
################################################################################
|
################################################################################
|
||||||
#
|
#
|
||||||
# Rattail -- Retail Software Framework
|
# Rattail -- Retail Software Framework
|
||||||
# Copyright © 2010-2024 Lance Edgar
|
# Copyright © 2010-2022 Lance Edgar
|
||||||
#
|
#
|
||||||
# This file is part of Rattail.
|
# This file is part of Rattail.
|
||||||
#
|
#
|
||||||
|
@ -24,6 +24,10 @@
|
||||||
Tailbone Web API - Label Batches
|
Tailbone Web API - Label Batches
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from __future__ import unicode_literals, absolute_import
|
||||||
|
|
||||||
|
import six
|
||||||
|
|
||||||
from rattail.db import model
|
from rattail.db import model
|
||||||
|
|
||||||
from tailbone.api.batch import APIBatchView, APIBatchRowView
|
from tailbone.api.batch import APIBatchView, APIBatchRowView
|
||||||
|
@ -52,10 +56,10 @@ class LabelBatchRowViews(APIBatchRowView):
|
||||||
|
|
||||||
def normalize(self, row):
|
def normalize(self, row):
|
||||||
batch = row.batch
|
batch = row.batch
|
||||||
data = super().normalize(row)
|
data = super(LabelBatchRowViews, self).normalize(row)
|
||||||
|
|
||||||
data['item_id'] = row.item_id
|
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['upc_pretty'] = row.upc.pretty() if row.upc else None
|
||||||
data['brand_name'] = row.brand_name
|
data['brand_name'] = row.brand_name
|
||||||
data['description'] = row.description
|
data['description'] = row.description
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
################################################################################
|
################################################################################
|
||||||
#
|
#
|
||||||
# Rattail -- Retail Software Framework
|
# Rattail -- Retail Software Framework
|
||||||
# Copyright © 2010-2024 Lance Edgar
|
# Copyright © 2010-2022 Lance Edgar
|
||||||
#
|
#
|
||||||
# This file is part of Rattail.
|
# This file is part of Rattail.
|
||||||
#
|
#
|
||||||
|
@ -27,24 +27,21 @@ These views expose the basic CRUD interface to "ordering" batches, for the web
|
||||||
API.
|
API.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import datetime
|
from __future__ import unicode_literals, absolute_import
|
||||||
import logging
|
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import six
|
||||||
|
|
||||||
from rattail.db.model import PurchaseBatch, PurchaseBatchRow
|
from rattail.db import model
|
||||||
|
from rattail.util import pretty_quantity
|
||||||
|
|
||||||
from cornice import Service
|
from cornice import Service
|
||||||
|
|
||||||
from tailbone.api.batch import APIBatchView, APIBatchRowView
|
from tailbone.api.batch import APIBatchView, APIBatchRowView
|
||||||
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class OrderingBatchViews(APIBatchView):
|
class OrderingBatchViews(APIBatchView):
|
||||||
|
|
||||||
model_class = PurchaseBatch
|
model_class = model.PurchaseBatch
|
||||||
default_handler_spec = 'rattail.batch.purchase:PurchaseBatchHandler'
|
default_handler_spec = 'rattail.batch.purchase:PurchaseBatchHandler'
|
||||||
route_prefix = 'orderingbatchviews'
|
route_prefix = 'orderingbatchviews'
|
||||||
permission_prefix = 'ordering'
|
permission_prefix = 'ordering'
|
||||||
|
@ -60,19 +57,18 @@ class OrderingBatchViews(APIBatchView):
|
||||||
Adds a condition to the query, to ensure only purchase batches with
|
Adds a condition to the query, to ensure only purchase batches with
|
||||||
"ordering" mode are returned.
|
"ordering" mode are returned.
|
||||||
"""
|
"""
|
||||||
model = self.model
|
query = super(OrderingBatchViews, self).base_query()
|
||||||
query = super().base_query()
|
|
||||||
query = query.filter(model.PurchaseBatch.mode == self.enum.PURCHASE_BATCH_MODE_ORDERING)
|
query = query.filter(model.PurchaseBatch.mode == self.enum.PURCHASE_BATCH_MODE_ORDERING)
|
||||||
return query
|
return query
|
||||||
|
|
||||||
def normalize(self, batch):
|
def normalize(self, batch):
|
||||||
data = super().normalize(batch)
|
data = super(OrderingBatchViews, self).normalize(batch)
|
||||||
|
|
||||||
data['vendor_uuid'] = batch.vendor.uuid
|
data['vendor_uuid'] = batch.vendor.uuid
|
||||||
data['vendor_display'] = str(batch.vendor)
|
data['vendor_display'] = six.text_type(batch.vendor)
|
||||||
|
|
||||||
data['department_uuid'] = batch.department_uuid
|
data['department_uuid'] = batch.department_uuid
|
||||||
data['department_display'] = str(batch.department) if batch.department else None
|
data['department_display'] = six.text_type(batch.department) if batch.department else None
|
||||||
|
|
||||||
data['po_total_calculated_display'] = "${:0.2f}".format(batch.po_total_calculated or 0)
|
data['po_total_calculated_display'] = "${:0.2f}".format(batch.po_total_calculated or 0)
|
||||||
data['ship_method'] = batch.ship_method
|
data['ship_method'] = batch.ship_method
|
||||||
|
@ -86,10 +82,8 @@ class OrderingBatchViews(APIBatchView):
|
||||||
Sets the mode to "ordering" for the new batch.
|
Sets the mode to "ordering" for the new batch.
|
||||||
"""
|
"""
|
||||||
data = dict(data)
|
data = dict(data)
|
||||||
if not data.get('vendor_uuid'):
|
|
||||||
raise ValueError("You must specify the vendor")
|
|
||||||
data['mode'] = self.enum.PURCHASE_BATCH_MODE_ORDERING
|
data['mode'] = self.enum.PURCHASE_BATCH_MODE_ORDERING
|
||||||
batch = super().create_object(data)
|
batch = super(OrderingBatchViews, self).create_object(data)
|
||||||
return batch
|
return batch
|
||||||
|
|
||||||
def worksheet(self):
|
def worksheet(self):
|
||||||
|
@ -100,8 +94,6 @@ class OrderingBatchViews(APIBatchView):
|
||||||
if batch.executed:
|
if batch.executed:
|
||||||
raise self.forbidden()
|
raise self.forbidden()
|
||||||
|
|
||||||
app = self.get_rattail_app()
|
|
||||||
|
|
||||||
# TODO: much of the logic below was copied from the traditional master
|
# TODO: much of the logic below was copied from the traditional master
|
||||||
# view for ordering batches. should maybe let them share it somehow?
|
# view for ordering batches. should maybe let them share it somehow?
|
||||||
|
|
||||||
|
@ -156,7 +148,7 @@ class OrderingBatchViews(APIBatchView):
|
||||||
product = cost.product
|
product = cost.product
|
||||||
subdept_costs.append({
|
subdept_costs.append({
|
||||||
'uuid': cost.uuid,
|
'uuid': cost.uuid,
|
||||||
'upc': str(product.upc),
|
'upc': six.text_type(product.upc),
|
||||||
'upc_pretty': product.upc.pretty() if product.upc else None,
|
'upc_pretty': product.upc.pretty() if product.upc else None,
|
||||||
'brand_name': product.brand.name if product.brand else None,
|
'brand_name': product.brand.name if product.brand else None,
|
||||||
'description': product.description,
|
'description': product.description,
|
||||||
|
@ -177,8 +169,8 @@ class OrderingBatchViews(APIBatchView):
|
||||||
|
|
||||||
# sort the (sub)department groupings
|
# sort the (sub)department groupings
|
||||||
sorted_departments = []
|
sorted_departments = []
|
||||||
for dept in sorted(departments.values(), key=lambda d: d['name']):
|
for dept in sorted(six.itervalues(departments), key=lambda d: d['name']):
|
||||||
dept['subdepartments'] = sorted(dept['subdepartments'].values(),
|
dept['subdepartments'] = sorted(six.itervalues(dept['subdepartments']),
|
||||||
key=lambda s: s['name'])
|
key=lambda s: s['name'])
|
||||||
sorted_departments.append(dept)
|
sorted_departments.append(dept)
|
||||||
|
|
||||||
|
@ -187,18 +179,6 @@ class OrderingBatchViews(APIBatchView):
|
||||||
for i in range(6 - len(history)):
|
for i in range(6 - len(history)):
|
||||||
history.append(None)
|
history.append(None)
|
||||||
history = list(reversed(history))
|
history = list(reversed(history))
|
||||||
# must convert some date objects to string, for JSON sake
|
|
||||||
for h in history:
|
|
||||||
if not h:
|
|
||||||
continue
|
|
||||||
purchase = h.get('purchase')
|
|
||||||
if purchase:
|
|
||||||
dt = purchase.get('date_ordered')
|
|
||||||
if dt and isinstance(dt, datetime.date):
|
|
||||||
purchase['date_ordered'] = app.render_date(dt)
|
|
||||||
dt = purchase.get('date_received')
|
|
||||||
if dt and isinstance(dt, datetime.date):
|
|
||||||
purchase['date_received'] = app.render_date(dt)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'batch': self.normalize(batch),
|
'batch': self.normalize(batch),
|
||||||
|
@ -229,7 +209,7 @@ class OrderingBatchViews(APIBatchView):
|
||||||
|
|
||||||
class OrderingBatchRowViews(APIBatchRowView):
|
class OrderingBatchRowViews(APIBatchRowView):
|
||||||
|
|
||||||
model_class = PurchaseBatchRow
|
model_class = model.PurchaseBatchRow
|
||||||
default_handler_spec = 'rattail.batch.purchase:PurchaseBatchHandler'
|
default_handler_spec = 'rattail.batch.purchase:PurchaseBatchHandler'
|
||||||
route_prefix = 'ordering.rows'
|
route_prefix = 'ordering.rows'
|
||||||
permission_prefix = 'ordering'
|
permission_prefix = 'ordering'
|
||||||
|
@ -239,12 +219,11 @@ class OrderingBatchRowViews(APIBatchRowView):
|
||||||
editable = True
|
editable = True
|
||||||
|
|
||||||
def normalize(self, row):
|
def normalize(self, row):
|
||||||
data = super().normalize(row)
|
|
||||||
app = self.get_rattail_app()
|
|
||||||
batch = row.batch
|
batch = row.batch
|
||||||
|
data = super(OrderingBatchRowViews, self).normalize(row)
|
||||||
|
|
||||||
data['item_id'] = row.item_id
|
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['upc_pretty'] = row.upc.pretty() if row.upc else None
|
||||||
data['brand_name'] = row.brand_name
|
data['brand_name'] = row.brand_name
|
||||||
data['description'] = row.description
|
data['description'] = row.description
|
||||||
|
@ -261,15 +240,15 @@ class OrderingBatchRowViews(APIBatchRowView):
|
||||||
data['case_quantity'] = row.case_quantity
|
data['case_quantity'] = row.case_quantity
|
||||||
data['cases_ordered'] = row.cases_ordered
|
data['cases_ordered'] = row.cases_ordered
|
||||||
data['units_ordered'] = row.units_ordered
|
data['units_ordered'] = row.units_ordered
|
||||||
data['cases_ordered_display'] = app.render_quantity(row.cases_ordered or 0, empty_zero=False)
|
data['cases_ordered_display'] = pretty_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['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'] = 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
|
data['po_unit_cost_display'] = "${:0.2f}".format(row.po_unit_cost) if row.po_unit_cost is not None else None
|
||||||
data['po_total_calculated'] = row.po_total_calculated
|
data['po_total_calculated'] = row.po_total_calculated
|
||||||
data['po_total_calculated_display'] = "${:0.2f}".format(row.po_total_calculated) if row.po_total_calculated is not None else None
|
data['po_total_calculated_display'] = "${:0.2f}".format(row.po_total_calculated) if row.po_total_calculated is not None else None
|
||||||
data['status_code'] = row.status_code
|
data['status_code'] = row.status_code
|
||||||
data['status_display'] = row.STATUS.get(row.status_code, str(row.status_code))
|
data['status_display'] = row.STATUS.get(row.status_code, six.text_type(row.status_code))
|
||||||
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
@ -290,17 +269,7 @@ class OrderingBatchRowViews(APIBatchRowView):
|
||||||
if not self.batch_handler.is_mutable(row.batch):
|
if not self.batch_handler.is_mutable(row.batch):
|
||||||
return {'error': "Batch is not mutable"}
|
return {'error': "Batch is not mutable"}
|
||||||
|
|
||||||
try:
|
self.batch_handler.update_row_quantity(row, **data)
|
||||||
self.batch_handler.update_row_quantity(row, **data)
|
|
||||||
self.Session.flush()
|
|
||||||
except Exception as error:
|
|
||||||
log.warning("update_row_quantity failed", exc_info=True)
|
|
||||||
if isinstance(error, sa.exc.DataError) and hasattr(error, 'orig'):
|
|
||||||
error = str(error.orig)
|
|
||||||
else:
|
|
||||||
error = str(error)
|
|
||||||
return {'error': error}
|
|
||||||
|
|
||||||
return row
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
################################################################################
|
################################################################################
|
||||||
#
|
#
|
||||||
# Rattail -- Retail Software Framework
|
# Rattail -- Retail Software Framework
|
||||||
# Copyright © 2010-2024 Lance Edgar
|
# Copyright © 2010-2022 Lance Edgar
|
||||||
#
|
#
|
||||||
# This file is part of Rattail.
|
# This file is part of Rattail.
|
||||||
#
|
#
|
||||||
|
@ -24,14 +24,16 @@
|
||||||
Tailbone Web API - Receiving Batches
|
Tailbone Web API - Receiving Batches
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from __future__ import unicode_literals, absolute_import
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
import six
|
||||||
import humanize
|
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
|
from deform import widget as dfwidget
|
||||||
|
|
||||||
from tailbone import forms
|
from tailbone import forms
|
||||||
|
@ -44,7 +46,7 @@ log = logging.getLogger(__name__)
|
||||||
|
|
||||||
class ReceivingBatchViews(APIBatchView):
|
class ReceivingBatchViews(APIBatchView):
|
||||||
|
|
||||||
model_class = PurchaseBatch
|
model_class = model.PurchaseBatch
|
||||||
default_handler_spec = 'rattail.batch.purchase:PurchaseBatchHandler'
|
default_handler_spec = 'rattail.batch.purchase:PurchaseBatchHandler'
|
||||||
route_prefix = 'receivingbatchviews'
|
route_prefix = 'receivingbatchviews'
|
||||||
permission_prefix = 'receiving'
|
permission_prefix = 'receiving'
|
||||||
|
@ -54,21 +56,19 @@ class ReceivingBatchViews(APIBatchView):
|
||||||
supports_execute = True
|
supports_execute = True
|
||||||
|
|
||||||
def base_query(self):
|
def base_query(self):
|
||||||
model = self.app.model
|
query = super(ReceivingBatchViews, self).base_query()
|
||||||
query = super().base_query()
|
|
||||||
query = query.filter(model.PurchaseBatch.mode == self.enum.PURCHASE_BATCH_MODE_RECEIVING)
|
query = query.filter(model.PurchaseBatch.mode == self.enum.PURCHASE_BATCH_MODE_RECEIVING)
|
||||||
return query
|
return query
|
||||||
|
|
||||||
def normalize(self, batch):
|
def normalize(self, batch):
|
||||||
data = super().normalize(batch)
|
data = super(ReceivingBatchViews, self).normalize(batch)
|
||||||
|
|
||||||
data['vendor_uuid'] = batch.vendor.uuid
|
data['vendor_uuid'] = batch.vendor.uuid
|
||||||
data['vendor_display'] = str(batch.vendor)
|
data['vendor_display'] = six.text_type(batch.vendor)
|
||||||
|
|
||||||
data['department_uuid'] = batch.department_uuid
|
data['department_uuid'] = batch.department_uuid
|
||||||
data['department_display'] = str(batch.department) if batch.department else None
|
data['department_display'] = six.text_type(batch.department) if batch.department else None
|
||||||
|
|
||||||
data['po_number'] = batch.po_number
|
|
||||||
data['po_total'] = batch.po_total
|
data['po_total'] = batch.po_total
|
||||||
data['invoice_total'] = batch.invoice_total
|
data['invoice_total'] = batch.invoice_total
|
||||||
data['invoice_total_calculated'] = batch.invoice_total_calculated
|
data['invoice_total_calculated'] = batch.invoice_total_calculated
|
||||||
|
@ -79,15 +79,9 @@ class ReceivingBatchViews(APIBatchView):
|
||||||
|
|
||||||
def create_object(self, data):
|
def create_object(self, data):
|
||||||
data = dict(data)
|
data = dict(data)
|
||||||
|
|
||||||
# all about receiving mode here
|
|
||||||
data['mode'] = self.enum.PURCHASE_BATCH_MODE_RECEIVING
|
data['mode'] = self.enum.PURCHASE_BATCH_MODE_RECEIVING
|
||||||
|
batch = super(ReceivingBatchViews, self).create_object(data)
|
||||||
# assume "receive from PO" if given a PO key
|
return batch
|
||||||
if data.get('purchase_key'):
|
|
||||||
data['workflow'] = 'from_po'
|
|
||||||
|
|
||||||
return super().create_object(data)
|
|
||||||
|
|
||||||
def auto_receive(self):
|
def auto_receive(self):
|
||||||
"""
|
"""
|
||||||
|
@ -120,9 +114,8 @@ class ReceivingBatchViews(APIBatchView):
|
||||||
return self._get(obj=batch)
|
return self._get(obj=batch)
|
||||||
|
|
||||||
def eligible_purchases(self):
|
def eligible_purchases(self):
|
||||||
model = self.app.model
|
|
||||||
uuid = self.request.params.get('vendor_uuid')
|
uuid = self.request.params.get('vendor_uuid')
|
||||||
vendor = self.Session.get(model.Vendor, uuid) if uuid else None
|
vendor = self.Session.query(model.Vendor).get(uuid) if uuid else None
|
||||||
if not vendor:
|
if not vendor:
|
||||||
return {'error': "Vendor not found"}
|
return {'error': "Vendor not found"}
|
||||||
|
|
||||||
|
@ -153,31 +146,31 @@ class ReceivingBatchViews(APIBatchView):
|
||||||
collection_url_prefix = cls.get_collection_url_prefix()
|
collection_url_prefix = cls.get_collection_url_prefix()
|
||||||
object_url_prefix = cls.get_object_url_prefix()
|
object_url_prefix = cls.get_object_url_prefix()
|
||||||
|
|
||||||
# auto_receive
|
# auto-receive
|
||||||
auto_receive = Service(name='{}.auto_receive'.format(route_prefix),
|
config.add_route('{}.auto_receive'.format(route_prefix),
|
||||||
path='{}/{{uuid}}/auto-receive'.format(object_url_prefix))
|
'{}/{{uuid}}/auto-receive'.format(object_url_prefix))
|
||||||
auto_receive.add_view('GET', 'auto_receive', klass=cls,
|
config.add_view(cls, attr='auto_receive',
|
||||||
permission='{}.auto_receive'.format(permission_prefix))
|
route_name='{}.auto_receive'.format(route_prefix),
|
||||||
config.add_cornice_service(auto_receive)
|
permission='{}.auto_receive'.format(permission_prefix),
|
||||||
|
renderer='json')
|
||||||
|
|
||||||
# mark_receiving_complete
|
# mark receiving complete
|
||||||
mark_receiving_complete = Service(name='{}.mark_receiving_complete'.format(route_prefix),
|
config.add_route('{}.mark_receiving_complete'.format(route_prefix), '{}/{{uuid}}/mark-receiving-complete'.format(object_url_prefix))
|
||||||
path='{}/{{uuid}}/mark-receiving-complete'.format(object_url_prefix))
|
config.add_view(cls, attr='mark_receiving_complete', route_name='{}.mark_receiving_complete'.format(route_prefix),
|
||||||
mark_receiving_complete.add_view('POST', 'mark_receiving_complete', klass=cls,
|
permission='{}.edit'.format(permission_prefix),
|
||||||
permission='{}.edit'.format(permission_prefix))
|
renderer='json')
|
||||||
config.add_cornice_service(mark_receiving_complete)
|
|
||||||
|
|
||||||
# eligible purchases
|
# eligible purchases
|
||||||
eligible_purchases = Service(name='{}.eligible_purchases'.format(route_prefix),
|
config.add_route('{}.eligible_purchases'.format(route_prefix), '{}/eligible-purchases'.format(collection_url_prefix),
|
||||||
path='{}/eligible-purchases'.format(collection_url_prefix))
|
request_method='GET')
|
||||||
eligible_purchases.add_view('GET', 'eligible_purchases', klass=cls,
|
config.add_view(cls, attr='eligible_purchases', route_name='{}.eligible_purchases'.format(route_prefix),
|
||||||
permission='{}.create'.format(permission_prefix))
|
permission='{}.create'.format(permission_prefix),
|
||||||
config.add_cornice_service(eligible_purchases)
|
renderer='json')
|
||||||
|
|
||||||
|
|
||||||
class ReceivingBatchRowViews(APIBatchRowView):
|
class ReceivingBatchRowViews(APIBatchRowView):
|
||||||
|
|
||||||
model_class = PurchaseBatchRow
|
model_class = model.PurchaseBatchRow
|
||||||
default_handler_spec = 'rattail.batch.purchase:PurchaseBatchHandler'
|
default_handler_spec = 'rattail.batch.purchase:PurchaseBatchHandler'
|
||||||
route_prefix = 'receiving.rows'
|
route_prefix = 'receiving.rows'
|
||||||
permission_prefix = 'receiving'
|
permission_prefix = 'receiving'
|
||||||
|
@ -186,8 +179,7 @@ class ReceivingBatchRowViews(APIBatchRowView):
|
||||||
supports_quick_entry = True
|
supports_quick_entry = True
|
||||||
|
|
||||||
def make_filter_spec(self):
|
def make_filter_spec(self):
|
||||||
model = self.app.model
|
filters = super(ReceivingBatchRowViews, self).make_filter_spec()
|
||||||
filters = super().make_filter_spec()
|
|
||||||
if filters:
|
if filters:
|
||||||
|
|
||||||
# must translate certain convenience filters
|
# must translate certain convenience filters
|
||||||
|
@ -283,30 +275,21 @@ class ReceivingBatchRowViews(APIBatchRowView):
|
||||||
]},
|
]},
|
||||||
])
|
])
|
||||||
|
|
||||||
# is_missing
|
|
||||||
elif filtr['field'] == 'is_missing' and filtr['op'] == 'eq' and filtr['value'] is True:
|
|
||||||
filters.extend([
|
|
||||||
{'or': [
|
|
||||||
{'field': 'cases_missing', 'op': '!=', 'value': 0},
|
|
||||||
{'field': 'units_missing', 'op': '!=', 'value': 0},
|
|
||||||
]},
|
|
||||||
])
|
|
||||||
|
|
||||||
else: # just some filter, use as-is
|
else: # just some filter, use as-is
|
||||||
filters.append(filtr)
|
filters.append(filtr)
|
||||||
|
|
||||||
return filters
|
return filters
|
||||||
|
|
||||||
def normalize(self, row):
|
def normalize(self, row):
|
||||||
data = super().normalize(row)
|
data = super(ReceivingBatchRowViews, self).normalize(row)
|
||||||
model = self.app.model
|
|
||||||
|
|
||||||
batch = row.batch
|
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['product_uuid'] = row.product_uuid
|
||||||
data['item_id'] = row.item_id
|
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['upc_pretty'] = row.upc.pretty() if row.upc else None
|
||||||
data['brand_name'] = row.brand_name
|
data['brand_name'] = row.brand_name
|
||||||
data['description'] = row.description
|
data['description'] = row.description
|
||||||
|
@ -338,9 +321,6 @@ class ReceivingBatchRowViews(APIBatchRowView):
|
||||||
data['cases_expired'] = row.cases_expired
|
data['cases_expired'] = row.cases_expired
|
||||||
data['units_expired'] = row.units_expired
|
data['units_expired'] = row.units_expired
|
||||||
|
|
||||||
data['cases_missing'] = row.cases_missing
|
|
||||||
data['units_missing'] = row.units_missing
|
|
||||||
|
|
||||||
cases, units = self.batch_handler.get_unconfirmed_counts(row)
|
cases, units = self.batch_handler.get_unconfirmed_counts(row)
|
||||||
data['cases_unconfirmed'] = cases
|
data['cases_unconfirmed'] = cases
|
||||||
data['units_unconfirmed'] = units
|
data['units_unconfirmed'] = units
|
||||||
|
@ -348,7 +328,6 @@ class ReceivingBatchRowViews(APIBatchRowView):
|
||||||
data['po_unit_cost'] = row.po_unit_cost
|
data['po_unit_cost'] = row.po_unit_cost
|
||||||
data['po_total'] = row.po_total
|
data['po_total'] = row.po_total
|
||||||
|
|
||||||
data['invoice_number'] = row.invoice_number
|
|
||||||
data['invoice_unit_cost'] = row.invoice_unit_cost
|
data['invoice_unit_cost'] = row.invoice_unit_cost
|
||||||
data['invoice_total'] = row.invoice_total
|
data['invoice_total'] = row.invoice_total
|
||||||
data['invoice_total_calculated'] = row.invoice_total_calculated
|
data['invoice_total_calculated'] = row.invoice_total_calculated
|
||||||
|
@ -377,7 +356,7 @@ class ReceivingBatchRowViews(APIBatchRowView):
|
||||||
if accounted_for:
|
if accounted_for:
|
||||||
# some product accounted for; button should receive "remainder" only
|
# some product accounted for; button should receive "remainder" only
|
||||||
if remainder:
|
if remainder:
|
||||||
remainder = self.app.render_quantity(remainder)
|
remainder = pretty_quantity(remainder)
|
||||||
data['quick_receive_quantity'] = remainder
|
data['quick_receive_quantity'] = remainder
|
||||||
data['quick_receive_text'] = "Receive Remainder ({} {})".format(
|
data['quick_receive_text'] = "Receive Remainder ({} {})".format(
|
||||||
remainder, data['unit_uom'])
|
remainder, data['unit_uom'])
|
||||||
|
@ -388,7 +367,7 @@ class ReceivingBatchRowViews(APIBatchRowView):
|
||||||
else: # nothing yet accounted for, button should receive "all"
|
else: # nothing yet accounted for, button should receive "all"
|
||||||
if not remainder:
|
if not remainder:
|
||||||
log.warning("quick receive remainder is empty for row %s", row.uuid)
|
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_quantity'] = remainder
|
||||||
data['quick_receive_text'] = "Receive ALL ({} {})".format(
|
data['quick_receive_text'] = "Receive ALL ({} {})".format(
|
||||||
remainder, data['unit_uom'])
|
remainder, data['unit_uom'])
|
||||||
|
@ -416,7 +395,7 @@ class ReceivingBatchRowViews(APIBatchRowView):
|
||||||
data['received_alert'] = None
|
data['received_alert'] = None
|
||||||
if self.batch_handler.get_units_confirmed(row):
|
if self.batch_handler.get_units_confirmed(row):
|
||||||
msg = "You have already received some of this product; last update was {}.".format(
|
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
|
data['received_alert'] = msg
|
||||||
|
|
||||||
return data
|
return data
|
||||||
|
@ -425,37 +404,27 @@ class ReceivingBatchRowViews(APIBatchRowView):
|
||||||
"""
|
"""
|
||||||
View which handles "receiving" against a particular batch row.
|
View which handles "receiving" against a particular batch row.
|
||||||
"""
|
"""
|
||||||
model = self.app.model
|
|
||||||
|
|
||||||
# first do basic input validation
|
# first do basic input validation
|
||||||
schema = ReceiveRow().bind(session=self.Session())
|
schema = ReceiveRow().bind(session=self.Session())
|
||||||
form = forms.Form(schema=schema, request=self.request)
|
form = forms.Form(schema=schema, request=self.request)
|
||||||
# TODO: this seems hacky, but avoids "complex" date value parsing
|
# TODO: this seems hacky, but avoids "complex" date value parsing
|
||||||
form.set_widget('expiration_date', dfwidget.TextInputWidget())
|
form.set_widget('expiration_date', dfwidget.TextInputWidget())
|
||||||
if not form.validate():
|
if not form.validate(newstyle=True):
|
||||||
log.warning("form did not validate: %s",
|
log.debug("form did not validate: %s",
|
||||||
form.make_deform_form().error)
|
form.make_deform_form().error)
|
||||||
return {'error': "Form did not validate"}
|
return {'error': "Form did not validate"}
|
||||||
|
|
||||||
# fetch / validate row object
|
# fetch / validate row object
|
||||||
row = self.Session.get(model.PurchaseBatchRow, form.validated['row'])
|
row = self.Session.query(model.PurchaseBatchRow).get(form.validated['row'])
|
||||||
if row is not self.get_object():
|
if row is not self.get_object():
|
||||||
return {'error': "Specified row does not match the route!"}
|
return {'error': "Specified row does not match the route!"}
|
||||||
|
|
||||||
# handler takes care of the row receiving logic for us
|
# handler takes care of the row receiving logic for us
|
||||||
kwargs = dict(form.validated)
|
kwargs = dict(form.validated)
|
||||||
del kwargs['row']
|
del kwargs['row']
|
||||||
try:
|
self.batch_handler.receive_row(row, **kwargs)
|
||||||
self.batch_handler.receive_row(row, **kwargs)
|
|
||||||
self.Session.flush()
|
|
||||||
except Exception as error:
|
|
||||||
log.warning("receive() failed", exc_info=True)
|
|
||||||
if isinstance(error, sa.exc.DataError) and hasattr(error, 'orig'):
|
|
||||||
error = str(error.orig)
|
|
||||||
else:
|
|
||||||
error = str(error)
|
|
||||||
return {'error': error}
|
|
||||||
|
|
||||||
|
self.Session.flush()
|
||||||
return self._get(obj=row)
|
return self._get(obj=row)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
@ -471,11 +440,11 @@ class ReceivingBatchRowViews(APIBatchRowView):
|
||||||
object_url_prefix = cls.get_object_url_prefix()
|
object_url_prefix = cls.get_object_url_prefix()
|
||||||
|
|
||||||
# receive (row)
|
# receive (row)
|
||||||
receive = Service(name='{}.receive'.format(route_prefix),
|
config.add_route('{}.receive'.format(route_prefix), '{}/{{uuid}}/receive'.format(object_url_prefix),
|
||||||
path='{}/{{uuid}}/receive'.format(object_url_prefix))
|
request_method=('OPTIONS', 'POST'))
|
||||||
receive.add_view('POST', 'receive', klass=cls,
|
config.add_view(cls, attr='receive', route_name='{}.receive'.format(route_prefix),
|
||||||
permission='{}.edit_row'.format(permission_prefix))
|
permission='{}.edit_row'.format(permission_prefix),
|
||||||
config.add_cornice_service(receive)
|
renderer='json')
|
||||||
|
|
||||||
|
|
||||||
def defaults(config, **kwargs):
|
def defaults(config, **kwargs):
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
################################################################################
|
################################################################################
|
||||||
#
|
#
|
||||||
# Rattail -- Retail Software Framework
|
# Rattail -- Retail Software Framework
|
||||||
# Copyright © 2010-2024 Lance Edgar
|
# Copyright © 2010-2022 Lance Edgar
|
||||||
#
|
#
|
||||||
# This file is part of Rattail.
|
# This file is part of Rattail.
|
||||||
#
|
#
|
||||||
|
@ -24,14 +24,16 @@
|
||||||
Tailbone Web API - "Common" Views
|
Tailbone Web API - "Common" Views
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from collections import OrderedDict
|
from __future__ import unicode_literals, absolute_import
|
||||||
|
|
||||||
from rattail.util import get_pkg_version
|
import rattail
|
||||||
|
from rattail.db import model
|
||||||
|
from rattail.mail import send_email
|
||||||
|
from rattail.util import OrderedDict
|
||||||
|
|
||||||
from cornice import Service
|
from cornice import Service
|
||||||
from cornice.service import get_services
|
|
||||||
from cornice_swagger import CorniceSwagger
|
|
||||||
|
|
||||||
|
import tailbone
|
||||||
from tailbone import forms
|
from tailbone import forms
|
||||||
from tailbone.forms.common import Feedback
|
from tailbone.forms.common import Feedback
|
||||||
from tailbone.api import APIView, api
|
from tailbone.api import APIView, api
|
||||||
|
@ -63,12 +65,11 @@ class CommonView(APIView):
|
||||||
}
|
}
|
||||||
|
|
||||||
def get_project_title(self):
|
def get_project_title(self):
|
||||||
app = self.get_rattail_app()
|
return self.rattail_config.app_title(default="Tailbone")
|
||||||
return app.get_title()
|
|
||||||
|
|
||||||
def get_project_version(self):
|
def get_project_version(self):
|
||||||
app = self.get_rattail_app()
|
import tailbone
|
||||||
return app.get_version()
|
return tailbone.__version__
|
||||||
|
|
||||||
def get_packages(self):
|
def get_packages(self):
|
||||||
"""
|
"""
|
||||||
|
@ -76,8 +77,8 @@ class CommonView(APIView):
|
||||||
'about' page.
|
'about' page.
|
||||||
"""
|
"""
|
||||||
return OrderedDict([
|
return OrderedDict([
|
||||||
('rattail', get_pkg_version('rattail')),
|
('rattail', rattail.__version__),
|
||||||
('Tailbone', get_pkg_version('Tailbone')),
|
('Tailbone', tailbone.__version__),
|
||||||
])
|
])
|
||||||
|
|
||||||
@api
|
@api
|
||||||
|
@ -85,20 +86,18 @@ class CommonView(APIView):
|
||||||
"""
|
"""
|
||||||
View to handle user feedback form submits.
|
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
|
# TODO: this logic was copied from tailbone.views.common and is largely
|
||||||
# identical; perhaps should merge somehow?
|
# identical; perhaps should merge somehow?
|
||||||
schema = Feedback().bind(session=Session())
|
schema = Feedback().bind(session=Session())
|
||||||
form = forms.Form(schema=schema, request=self.request)
|
form = forms.Form(schema=schema, request=self.request)
|
||||||
if form.validate():
|
if form.validate(newstyle=True):
|
||||||
data = dict(form.validated)
|
data = dict(form.validated)
|
||||||
|
|
||||||
# figure out who the sending user is, if any
|
# figure out who the sending user is, if any
|
||||||
if self.request.user:
|
if self.request.user:
|
||||||
data['user'] = self.request.user
|
data['user'] = self.request.user
|
||||||
elif data['user']:
|
elif data['user']:
|
||||||
data['user'] = Session.get(model.User, data['user'])
|
data['user'] = Session.query(model.User).get(data['user'])
|
||||||
|
|
||||||
# TODO: should provide URL to view user
|
# TODO: should provide URL to view user
|
||||||
if data['user']:
|
if data['user']:
|
||||||
|
@ -106,27 +105,17 @@ class CommonView(APIView):
|
||||||
|
|
||||||
data['client_ip'] = self.request.client_addr
|
data['client_ip'] = self.request.client_addr
|
||||||
email_key = data['email_key'] or self.feedback_email_key
|
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 {'ok': True}
|
||||||
|
|
||||||
return {'error': "Form did not validate!"}
|
return {'error': "Form did not validate!"}
|
||||||
|
|
||||||
def swagger(self):
|
|
||||||
doc = CorniceSwagger(get_services())
|
|
||||||
app = self.get_rattail_app()
|
|
||||||
spec = doc.generate(f"{app.get_node_title()} API docs",
|
|
||||||
app.get_version(),
|
|
||||||
base_path='/api') # TODO
|
|
||||||
return spec
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def defaults(cls, config):
|
def defaults(cls, config):
|
||||||
cls._common_defaults(config)
|
cls._common_defaults(config)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _common_defaults(cls, config):
|
def _common_defaults(cls, config):
|
||||||
rattail_config = config.registry.settings.get('rattail_config')
|
|
||||||
app = rattail_config.get_app()
|
|
||||||
|
|
||||||
# about
|
# about
|
||||||
about = Service(name='about', path='/about')
|
about = Service(name='about', path='/about')
|
||||||
|
@ -139,14 +128,6 @@ class CommonView(APIView):
|
||||||
permission='common.feedback')
|
permission='common.feedback')
|
||||||
config.add_cornice_service(feedback)
|
config.add_cornice_service(feedback)
|
||||||
|
|
||||||
# swagger
|
|
||||||
swagger = Service(name='swagger',
|
|
||||||
path='/swagger.json',
|
|
||||||
description=f"OpenAPI documentation for {app.get_title()}")
|
|
||||||
swagger.add_view('GET', 'swagger', klass=cls,
|
|
||||||
permission='common.api_swagger')
|
|
||||||
config.add_cornice_service(swagger)
|
|
||||||
|
|
||||||
|
|
||||||
def defaults(config, **kwargs):
|
def defaults(config, **kwargs):
|
||||||
base = globals()
|
base = globals()
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
################################################################################
|
################################################################################
|
||||||
#
|
#
|
||||||
# Rattail -- Retail Software Framework
|
# Rattail -- Retail Software Framework
|
||||||
# Copyright © 2010-2024 Lance Edgar
|
# Copyright © 2010-2022 Lance Edgar
|
||||||
#
|
#
|
||||||
# This file is part of Rattail.
|
# This file is part of Rattail.
|
||||||
#
|
#
|
||||||
|
@ -24,6 +24,8 @@
|
||||||
Tailbone Web API - Core Views
|
Tailbone Web API - Core Views
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from __future__ import unicode_literals, absolute_import
|
||||||
|
|
||||||
from tailbone.views import View
|
from tailbone.views import View
|
||||||
|
|
||||||
|
|
||||||
|
@ -99,20 +101,18 @@ class APIView(View):
|
||||||
return info
|
return info
|
||||||
"""
|
"""
|
||||||
app = self.get_rattail_app()
|
app = self.get_rattail_app()
|
||||||
auth = app.get_auth_handler()
|
|
||||||
|
|
||||||
# basic / default info
|
# basic / default info
|
||||||
is_admin = auth.user_is_admin(user)
|
is_admin = user.is_admin()
|
||||||
employee = app.get_employee(user)
|
employee = user.employee
|
||||||
info = {
|
info = {
|
||||||
'uuid': user.uuid,
|
'uuid': user.uuid,
|
||||||
'username': user.username,
|
'username': user.username,
|
||||||
'display_name': user.display_name,
|
'display_name': user.display_name,
|
||||||
'short_name': auth.get_short_display_name(user),
|
'short_name': user.get_short_name(),
|
||||||
'is_admin': is_admin,
|
'is_admin': is_admin,
|
||||||
'is_root': is_admin and self.request.session.get('is_root', False),
|
'is_root': is_admin and self.request.session.get('is_root', False),
|
||||||
'employee_uuid': employee.uuid if employee else None,
|
'employee_uuid': employee.uuid if employee else None,
|
||||||
'email_address': app.get_contact_email_address(user),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# maybe get/use "extra" info
|
# maybe get/use "extra" info
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
################################################################################
|
################################################################################
|
||||||
#
|
#
|
||||||
# Rattail -- Retail Software Framework
|
# Rattail -- Retail Software Framework
|
||||||
# Copyright © 2010-2024 Lance Edgar
|
# Copyright © 2010-2022 Lance Edgar
|
||||||
#
|
#
|
||||||
# This file is part of Rattail.
|
# This file is part of Rattail.
|
||||||
#
|
#
|
||||||
|
@ -24,6 +24,10 @@
|
||||||
Tailbone Web API - Customer Views
|
Tailbone Web API - Customer Views
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from __future__ import unicode_literals, absolute_import
|
||||||
|
|
||||||
|
import six
|
||||||
|
|
||||||
from rattail.db import model
|
from rattail.db import model
|
||||||
|
|
||||||
from tailbone.api import APIMasterView
|
from tailbone.api import APIMasterView
|
||||||
|
@ -42,7 +46,7 @@ class CustomerView(APIMasterView):
|
||||||
def normalize(self, customer):
|
def normalize(self, customer):
|
||||||
return {
|
return {
|
||||||
'uuid': customer.uuid,
|
'uuid': customer.uuid,
|
||||||
'_str': str(customer),
|
'_str': six.text_type(customer),
|
||||||
'id': customer.id,
|
'id': customer.id,
|
||||||
'number': customer.number,
|
'number': customer.number,
|
||||||
'name': customer.name,
|
'name': customer.name,
|
||||||
|
|
|
@ -1,36 +0,0 @@
|
||||||
# -*- coding: utf-8; -*-
|
|
||||||
################################################################################
|
|
||||||
#
|
|
||||||
# Rattail -- Retail Software Framework
|
|
||||||
# Copyright © 2010-2023 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/>.
|
|
||||||
#
|
|
||||||
################################################################################
|
|
||||||
"""
|
|
||||||
Essential views for convenient includes
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
def defaults(config, **kwargs):
|
|
||||||
mod = lambda spec: kwargs.get(spec, spec)
|
|
||||||
|
|
||||||
config.include(mod('tailbone.api.auth'))
|
|
||||||
config.include(mod('tailbone.api.common'))
|
|
||||||
|
|
||||||
|
|
||||||
def includeme(config):
|
|
||||||
defaults(config)
|
|
|
@ -2,7 +2,7 @@
|
||||||
################################################################################
|
################################################################################
|
||||||
#
|
#
|
||||||
# Rattail -- Retail Software Framework
|
# Rattail -- Retail Software Framework
|
||||||
# Copyright © 2010-2024 Lance Edgar
|
# Copyright © 2010-2022 Lance Edgar
|
||||||
#
|
#
|
||||||
# This file is part of Rattail.
|
# This file is part of Rattail.
|
||||||
#
|
#
|
||||||
|
@ -24,15 +24,26 @@
|
||||||
Tailbone Web API - Master View
|
Tailbone Web API - Master View
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from __future__ import unicode_literals, absolute_import
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
|
||||||
|
import six
|
||||||
|
|
||||||
|
from rattail.config import parse_bool
|
||||||
from rattail.db.util import get_fieldnames
|
from rattail.db.util import get_fieldnames
|
||||||
|
|
||||||
from cornice import resource, Service
|
from cornice import resource, Service
|
||||||
|
|
||||||
from tailbone.api import APIView
|
from tailbone.api import APIView, api
|
||||||
from tailbone.db import Session
|
from tailbone.db import Session
|
||||||
from tailbone.util import SortColumn
|
|
||||||
|
|
||||||
|
class SortColumn(object):
|
||||||
|
|
||||||
|
def __init__(self, field_name, model_name=None):
|
||||||
|
self.field_name = field_name
|
||||||
|
self.model_name = model_name
|
||||||
|
|
||||||
|
|
||||||
class APIMasterView(APIView):
|
class APIMasterView(APIView):
|
||||||
|
@ -184,7 +195,7 @@ class APIMasterView(APIView):
|
||||||
if sortcol:
|
if sortcol:
|
||||||
spec = {
|
spec = {
|
||||||
'field': sortcol.field_name,
|
'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:
|
if sortcol.model_name:
|
||||||
spec['model'] = sortcol.model_name
|
spec['model'] = sortcol.model_name
|
||||||
|
@ -268,7 +279,7 @@ class APIMasterView(APIView):
|
||||||
return self._fieldnames
|
return self._fieldnames
|
||||||
|
|
||||||
def normalize(self, obj):
|
def normalize(self, obj):
|
||||||
data = {'_str': str(obj)}
|
data = {'_str': six.text_type(obj)}
|
||||||
|
|
||||||
for field in self.get_fieldnames():
|
for field in self.get_fieldnames():
|
||||||
data[field] = getattr(obj, field)
|
data[field] = getattr(obj, field)
|
||||||
|
@ -276,7 +287,7 @@ class APIMasterView(APIView):
|
||||||
return data
|
return data
|
||||||
|
|
||||||
def _collection_get(self):
|
def _collection_get(self):
|
||||||
from sa_filters import apply_filters, apply_sort, apply_pagination
|
from sqlalchemy_filters import apply_filters, apply_sort, apply_pagination
|
||||||
|
|
||||||
query = self.base_query()
|
query = self.base_query()
|
||||||
context = {}
|
context = {}
|
||||||
|
@ -332,7 +343,7 @@ class APIMasterView(APIView):
|
||||||
if not uuid:
|
if not uuid:
|
||||||
uuid = self.request.matchdict['uuid']
|
uuid = self.request.matchdict['uuid']
|
||||||
|
|
||||||
obj = self.Session.get(self.get_model_class(), uuid)
|
obj = self.Session.query(self.get_model_class()).get(uuid)
|
||||||
if obj:
|
if obj:
|
||||||
return obj
|
return obj
|
||||||
|
|
||||||
|
@ -354,13 +365,9 @@ class APIMasterView(APIView):
|
||||||
data = self.request.json_body
|
data = self.request.json_body
|
||||||
|
|
||||||
# add instance to session, and return data for it
|
# add instance to session, and return data for it
|
||||||
try:
|
obj = self.create_object(data)
|
||||||
obj = self.create_object(data)
|
self.Session.flush()
|
||||||
except Exception as error:
|
return self._get(obj)
|
||||||
return self.json_response({'error': str(error)})
|
|
||||||
else:
|
|
||||||
self.Session.flush()
|
|
||||||
return self._get(obj)
|
|
||||||
|
|
||||||
def create_object(self, data):
|
def create_object(self, data):
|
||||||
"""
|
"""
|
||||||
|
@ -387,7 +394,7 @@ class APIMasterView(APIView):
|
||||||
"""
|
"""
|
||||||
if not uuid:
|
if not uuid:
|
||||||
uuid = self.request.matchdict['uuid']
|
uuid = self.request.matchdict['uuid']
|
||||||
obj = self.Session.get(self.get_model_class(), uuid)
|
obj = self.Session.query(self.get_model_class()).get(uuid)
|
||||||
if not obj:
|
if not obj:
|
||||||
raise self.notfound()
|
raise self.notfound()
|
||||||
|
|
||||||
|
@ -472,16 +479,13 @@ class APIMasterView(APIView):
|
||||||
"""
|
"""
|
||||||
obj = self.get_object()
|
obj = self.get_object()
|
||||||
|
|
||||||
# TODO: is this really needed?
|
filename = self.request.GET.get('filename', None)
|
||||||
# filename = self.request.GET.get('filename', None)
|
if not filename:
|
||||||
# if filename:
|
raise self.notfound()
|
||||||
# path = self.download_path(obj, filename)
|
path = self.download_path(obj, filename)
|
||||||
# return self.file_response(path, attachment=False)
|
|
||||||
|
|
||||||
return self.rawbytes_response(obj)
|
response = self.file_response(path, attachment=False)
|
||||||
|
return response
|
||||||
def rawbytes_response(self, obj):
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
##############################
|
##############################
|
||||||
# autocomplete
|
# autocomplete
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
################################################################################
|
################################################################################
|
||||||
#
|
#
|
||||||
# Rattail -- Retail Software Framework
|
# Rattail -- Retail Software Framework
|
||||||
# Copyright © 2010-2024 Lance Edgar
|
# Copyright © 2010-2022 Lance Edgar
|
||||||
#
|
#
|
||||||
# This file is part of Rattail.
|
# This file is part of Rattail.
|
||||||
#
|
#
|
||||||
|
@ -24,6 +24,10 @@
|
||||||
Tailbone Web API - Person Views
|
Tailbone Web API - Person Views
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from __future__ import unicode_literals, absolute_import
|
||||||
|
|
||||||
|
import six
|
||||||
|
|
||||||
from rattail.db import model
|
from rattail.db import model
|
||||||
|
|
||||||
from tailbone.api import APIMasterView
|
from tailbone.api import APIMasterView
|
||||||
|
@ -41,7 +45,7 @@ class PersonView(APIMasterView):
|
||||||
def normalize(self, person):
|
def normalize(self, person):
|
||||||
return {
|
return {
|
||||||
'uuid': person.uuid,
|
'uuid': person.uuid,
|
||||||
'_str': str(person),
|
'_str': six.text_type(person),
|
||||||
'first_name': person.first_name,
|
'first_name': person.first_name,
|
||||||
'last_name': person.last_name,
|
'last_name': person.last_name,
|
||||||
'display_name': person.display_name,
|
'display_name': person.display_name,
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
################################################################################
|
################################################################################
|
||||||
#
|
#
|
||||||
# Rattail -- Retail Software Framework
|
# Rattail -- Retail Software Framework
|
||||||
# Copyright © 2010-2023 Lance Edgar
|
# Copyright © 2010-2022 Lance Edgar
|
||||||
#
|
#
|
||||||
# This file is part of Rattail.
|
# This file is part of Rattail.
|
||||||
#
|
#
|
||||||
|
@ -24,21 +24,17 @@
|
||||||
Tailbone Web API - Product Views
|
Tailbone Web API - Product Views
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
from __future__ import unicode_literals, absolute_import
|
||||||
|
|
||||||
|
import six
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from sqlalchemy import orm
|
from sqlalchemy import orm
|
||||||
|
|
||||||
from cornice import Service
|
|
||||||
|
|
||||||
from rattail.db import model
|
from rattail.db import model
|
||||||
|
|
||||||
from tailbone.api import APIMasterView
|
from tailbone.api import APIMasterView
|
||||||
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class ProductView(APIMasterView):
|
class ProductView(APIMasterView):
|
||||||
"""
|
"""
|
||||||
API views for Product data
|
API views for Product data
|
||||||
|
@ -48,49 +44,20 @@ class ProductView(APIMasterView):
|
||||||
object_url_prefix = '/product'
|
object_url_prefix = '/product'
|
||||||
supports_autocomplete = True
|
supports_autocomplete = True
|
||||||
|
|
||||||
def __init__(self, request, context=None):
|
|
||||||
super(ProductView, self).__init__(request, context=context)
|
|
||||||
app = self.get_rattail_app()
|
|
||||||
self.products_handler = app.get_products_handler()
|
|
||||||
|
|
||||||
def normalize(self, product):
|
def normalize(self, product):
|
||||||
|
|
||||||
# get what we can from handler
|
|
||||||
data = self.products_handler.normalize_product(product, fields=[
|
|
||||||
'brand_name',
|
|
||||||
'full_description',
|
|
||||||
'department_name',
|
|
||||||
'unit_price_display',
|
|
||||||
'sale_price',
|
|
||||||
'sale_price_display',
|
|
||||||
'sale_ends',
|
|
||||||
'sale_ends_display',
|
|
||||||
'tpr_price',
|
|
||||||
'tpr_price_display',
|
|
||||||
'tpr_ends',
|
|
||||||
'tpr_ends_display',
|
|
||||||
'current_price',
|
|
||||||
'current_price_display',
|
|
||||||
'current_ends',
|
|
||||||
'current_ends_display',
|
|
||||||
'vendor_name',
|
|
||||||
'costs',
|
|
||||||
'image_url',
|
|
||||||
])
|
|
||||||
|
|
||||||
# but must supplement
|
|
||||||
cost = product.cost
|
cost = product.cost
|
||||||
data.update({
|
return {
|
||||||
'upc': str(product.upc),
|
'uuid': product.uuid,
|
||||||
|
'_str': six.text_type(product),
|
||||||
|
'upc': six.text_type(product.upc),
|
||||||
'scancode': product.scancode,
|
'scancode': product.scancode,
|
||||||
'item_id': product.item_id,
|
'item_id': product.item_id,
|
||||||
'item_type': product.item_type,
|
'item_type': product.item_type,
|
||||||
|
'description': product.description,
|
||||||
'status_code': product.status_code,
|
'status_code': product.status_code,
|
||||||
'default_unit_cost': cost.unit_cost if cost else None,
|
'default_unit_cost': cost.unit_cost if cost else None,
|
||||||
'default_unit_cost_display': "${:0.2f}".format(cost.unit_cost) if cost and cost.unit_cost is not None else None,
|
'default_unit_cost_display': "${:0.2f}".format(cost.unit_cost) if cost and cost.unit_cost is not None else None,
|
||||||
})
|
}
|
||||||
|
|
||||||
return data
|
|
||||||
|
|
||||||
def make_autocomplete_query(self, term):
|
def make_autocomplete_query(self, term):
|
||||||
query = self.Session.query(model.Product)\
|
query = self.Session.query(model.Product)\
|
||||||
|
@ -110,104 +77,6 @@ class ProductView(APIMasterView):
|
||||||
def autocomplete_display(self, product):
|
def autocomplete_display(self, product):
|
||||||
return product.full_description
|
return product.full_description
|
||||||
|
|
||||||
def quick_lookup(self):
|
|
||||||
"""
|
|
||||||
View for handling "quick lookup" user input, for index page.
|
|
||||||
"""
|
|
||||||
data = self.request.GET
|
|
||||||
entry = data['entry']
|
|
||||||
|
|
||||||
product = self.products_handler.locate_product_for_entry(self.Session(),
|
|
||||||
entry)
|
|
||||||
if not product:
|
|
||||||
return {'error': "Product not found"}
|
|
||||||
|
|
||||||
return {'ok': True,
|
|
||||||
'product': self.normalize(product)}
|
|
||||||
|
|
||||||
def label_profiles(self):
|
|
||||||
"""
|
|
||||||
Returns the set of label profiles available for use with
|
|
||||||
printing label for product.
|
|
||||||
"""
|
|
||||||
app = self.get_rattail_app()
|
|
||||||
label_handler = app.get_label_handler()
|
|
||||||
model = self.model
|
|
||||||
|
|
||||||
profiles = []
|
|
||||||
for profile in label_handler.get_label_profiles(self.Session()):
|
|
||||||
profiles.append({
|
|
||||||
'uuid': profile.uuid,
|
|
||||||
'description': profile.description,
|
|
||||||
})
|
|
||||||
|
|
||||||
return {'label_profiles': profiles}
|
|
||||||
|
|
||||||
def print_labels(self):
|
|
||||||
app = self.get_rattail_app()
|
|
||||||
label_handler = app.get_label_handler()
|
|
||||||
model = self.model
|
|
||||||
data = self.request.json_body
|
|
||||||
|
|
||||||
uuid = data.get('label_profile_uuid')
|
|
||||||
profile = self.Session.get(model.LabelProfile, uuid) if uuid else None
|
|
||||||
if not profile:
|
|
||||||
return {'error': "Label profile not found"}
|
|
||||||
|
|
||||||
uuid = data.get('product_uuid')
|
|
||||||
product = self.Session.get(model.Product, uuid) if uuid else None
|
|
||||||
if not product:
|
|
||||||
return {'error': "Product not found"}
|
|
||||||
|
|
||||||
try:
|
|
||||||
quantity = int(data.get('quantity'))
|
|
||||||
except:
|
|
||||||
return {'error': "Quantity must be integer"}
|
|
||||||
|
|
||||||
printer = label_handler.get_printer(profile)
|
|
||||||
if not printer:
|
|
||||||
return {'error': "Couldn't get printer from label profile"}
|
|
||||||
|
|
||||||
try:
|
|
||||||
printer.print_labels([({'product': product}, quantity)])
|
|
||||||
except Exception as error:
|
|
||||||
log.warning("error occurred while printing labels", exc_info=True)
|
|
||||||
return {'error': str(error)}
|
|
||||||
|
|
||||||
return {'ok': True}
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def defaults(cls, config):
|
|
||||||
cls._defaults(config)
|
|
||||||
cls._product_defaults(config)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _product_defaults(cls, config):
|
|
||||||
route_prefix = cls.get_route_prefix()
|
|
||||||
permission_prefix = cls.get_permission_prefix()
|
|
||||||
collection_url_prefix = cls.get_collection_url_prefix()
|
|
||||||
|
|
||||||
# quick lookup
|
|
||||||
quick_lookup = Service(name='{}.quick_lookup'.format(route_prefix),
|
|
||||||
path='{}/quick-lookup'.format(collection_url_prefix))
|
|
||||||
quick_lookup.add_view('GET', 'quick_lookup', klass=cls,
|
|
||||||
permission='{}.list'.format(permission_prefix))
|
|
||||||
config.add_cornice_service(quick_lookup)
|
|
||||||
|
|
||||||
# label profiles
|
|
||||||
label_profiles = Service(name=f'{route_prefix}.label_profiles',
|
|
||||||
path=f'{collection_url_prefix}/label-profiles')
|
|
||||||
label_profiles.add_view('GET', 'label_profiles', klass=cls,
|
|
||||||
permission=f'{permission_prefix}.print_labels')
|
|
||||||
config.add_cornice_service(label_profiles)
|
|
||||||
|
|
||||||
# print labels
|
|
||||||
print_labels = Service(name='{}.print_labels'.format(route_prefix),
|
|
||||||
path='{}/print-labels'.format(collection_url_prefix))
|
|
||||||
print_labels.add_view('POST', 'print_labels', klass=cls,
|
|
||||||
permission='{}.print_labels'.format(permission_prefix))
|
|
||||||
config.add_cornice_service(print_labels)
|
|
||||||
|
|
||||||
|
|
||||||
def defaults(config, **kwargs):
|
def defaults(config, **kwargs):
|
||||||
base = globals()
|
base = globals()
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
################################################################################
|
################################################################################
|
||||||
#
|
#
|
||||||
# Rattail -- Retail Software Framework
|
# Rattail -- Retail Software Framework
|
||||||
# Copyright © 2010-2024 Lance Edgar
|
# Copyright © 2010-2022 Lance Edgar
|
||||||
#
|
#
|
||||||
# This file is part of Rattail.
|
# This file is part of Rattail.
|
||||||
#
|
#
|
||||||
|
@ -24,6 +24,10 @@
|
||||||
Tailbone Web API - Upgrade Views
|
Tailbone Web API - Upgrade Views
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from __future__ import unicode_literals, absolute_import
|
||||||
|
|
||||||
|
import six
|
||||||
|
|
||||||
from rattail.db import model
|
from rattail.db import model
|
||||||
|
|
||||||
from tailbone.api import APIMasterView
|
from tailbone.api import APIMasterView
|
||||||
|
@ -49,7 +53,7 @@ class UpgradeView(APIMasterView):
|
||||||
data['status_code'] = None
|
data['status_code'] = None
|
||||||
else:
|
else:
|
||||||
data['status_code'] = self.enum.UPGRADE_STATUS.get(upgrade.status_code,
|
data['status_code'] = self.enum.UPGRADE_STATUS.get(upgrade.status_code,
|
||||||
str(upgrade.status_code))
|
six.text_type(upgrade.status_code))
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
################################################################################
|
################################################################################
|
||||||
#
|
#
|
||||||
# Rattail -- Retail Software Framework
|
# Rattail -- Retail Software Framework
|
||||||
# Copyright © 2010-2023 Lance Edgar
|
# Copyright © 2010-2022 Lance Edgar
|
||||||
#
|
#
|
||||||
# This file is part of Rattail.
|
# This file is part of Rattail.
|
||||||
#
|
#
|
||||||
|
@ -24,6 +24,8 @@
|
||||||
Tailbone Web API - User Views
|
Tailbone Web API - User Views
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from __future__ import unicode_literals, absolute_import
|
||||||
|
|
||||||
from rattail.db import model
|
from rattail.db import model
|
||||||
|
|
||||||
from tailbone.api import APIMasterView
|
from tailbone.api import APIMasterView
|
||||||
|
@ -55,10 +57,6 @@ class UserView(APIMasterView):
|
||||||
query = query.outerjoin(model.Person)
|
query = query.outerjoin(model.Person)
|
||||||
return query
|
return query
|
||||||
|
|
||||||
def update_object(self, user, data):
|
|
||||||
# TODO: should ensure prevent_password_change is respected
|
|
||||||
return super(UserView, self).update_object(user, data)
|
|
||||||
|
|
||||||
|
|
||||||
def defaults(config, **kwargs):
|
def defaults(config, **kwargs):
|
||||||
base = globals()
|
base = globals()
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
################################################################################
|
################################################################################
|
||||||
#
|
#
|
||||||
# Rattail -- Retail Software Framework
|
# Rattail -- Retail Software Framework
|
||||||
# Copyright © 2010-2024 Lance Edgar
|
# Copyright © 2010-2022 Lance Edgar
|
||||||
#
|
#
|
||||||
# This file is part of Rattail.
|
# This file is part of Rattail.
|
||||||
#
|
#
|
||||||
|
@ -24,6 +24,10 @@
|
||||||
Tailbone Web API - Vendor Views
|
Tailbone Web API - Vendor Views
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from __future__ import unicode_literals, absolute_import
|
||||||
|
|
||||||
|
import six
|
||||||
|
|
||||||
from rattail.db import model
|
from rattail.db import model
|
||||||
|
|
||||||
from tailbone.api import APIMasterView
|
from tailbone.api import APIMasterView
|
||||||
|
@ -40,7 +44,7 @@ class VendorView(APIMasterView):
|
||||||
def normalize(self, vendor):
|
def normalize(self, vendor):
|
||||||
return {
|
return {
|
||||||
'uuid': vendor.uuid,
|
'uuid': vendor.uuid,
|
||||||
'_str': str(vendor),
|
'_str': six.text_type(vendor),
|
||||||
'id': vendor.id,
|
'id': vendor.id,
|
||||||
'name': vendor.name,
|
'name': vendor.name,
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
################################################################################
|
################################################################################
|
||||||
#
|
#
|
||||||
# Rattail -- Retail Software Framework
|
# Rattail -- Retail Software Framework
|
||||||
# Copyright © 2010-2024 Lance Edgar
|
# Copyright © 2010-2022 Lance Edgar
|
||||||
#
|
#
|
||||||
# This file is part of Rattail.
|
# This file is part of Rattail.
|
||||||
#
|
#
|
||||||
|
@ -24,8 +24,12 @@
|
||||||
Tailbone Web API - Work Order Views
|
Tailbone Web API - Work Order Views
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from __future__ import unicode_literals, absolute_import
|
||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
|
|
||||||
|
import six
|
||||||
|
|
||||||
from rattail.db.model import WorkOrder
|
from rattail.db.model import WorkOrder
|
||||||
|
|
||||||
from cornice import Service
|
from cornice import Service
|
||||||
|
@ -40,19 +44,19 @@ class WorkOrderView(APIMasterView):
|
||||||
object_url_prefix = '/workorder'
|
object_url_prefix = '/workorder'
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super(WorkOrderView, self).__init__(*args, **kwargs)
|
||||||
app = self.get_rattail_app()
|
app = self.get_rattail_app()
|
||||||
self.workorder_handler = app.get_workorder_handler()
|
self.workorder_handler = app.get_workorder_handler()
|
||||||
|
|
||||||
def normalize(self, workorder):
|
def normalize(self, workorder):
|
||||||
data = super().normalize(workorder)
|
data = super(WorkOrderView, self).normalize(workorder)
|
||||||
data.update({
|
data.update({
|
||||||
'customer_name': workorder.customer.name,
|
'customer_name': workorder.customer.name,
|
||||||
'status_label': self.enum.WORKORDER_STATUS[workorder.status_code],
|
'status_label': self.enum.WORKORDER_STATUS[workorder.status_code],
|
||||||
'date_submitted': str(workorder.date_submitted or ''),
|
'date_submitted': six.text_type(workorder.date_submitted or ''),
|
||||||
'date_received': str(workorder.date_received or ''),
|
'date_received': six.text_type(workorder.date_received or ''),
|
||||||
'date_released': str(workorder.date_released or ''),
|
'date_released': six.text_type(workorder.date_released or ''),
|
||||||
'date_delivered': str(workorder.date_delivered or ''),
|
'date_delivered': six.text_type(workorder.date_delivered or ''),
|
||||||
})
|
})
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
@ -83,7 +87,7 @@ class WorkOrderView(APIMasterView):
|
||||||
if 'status_code' in data:
|
if 'status_code' in data:
|
||||||
data['status_code'] = int(data['status_code'])
|
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):
|
def status_codes(self):
|
||||||
"""
|
"""
|
||||||
|
|
114
tailbone/app.py
114
tailbone/app.py
|
@ -2,7 +2,7 @@
|
||||||
################################################################################
|
################################################################################
|
||||||
#
|
#
|
||||||
# Rattail -- Retail Software Framework
|
# Rattail -- Retail Software Framework
|
||||||
# Copyright © 2010-2024 Lance Edgar
|
# Copyright © 2010-2022 Lance Edgar
|
||||||
#
|
#
|
||||||
# This file is part of Rattail.
|
# This file is part of Rattail.
|
||||||
#
|
#
|
||||||
|
@ -24,20 +24,25 @@
|
||||||
Application Entry Point
|
Application Entry Point
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
from __future__ import unicode_literals, absolute_import
|
||||||
|
|
||||||
|
import os
|
||||||
|
import warnings
|
||||||
|
|
||||||
|
import six
|
||||||
|
import sqlalchemy as sa
|
||||||
from sqlalchemy.orm import sessionmaker, scoped_session
|
from sqlalchemy.orm import sessionmaker, scoped_session
|
||||||
|
|
||||||
from wuttjamaican.util import parse_list
|
from rattail.config import make_config, parse_list
|
||||||
|
|
||||||
from rattail.config import make_config
|
|
||||||
from rattail.exceptions import ConfigurationError
|
from rattail.exceptions import ConfigurationError
|
||||||
|
from rattail.db.types import GPCType
|
||||||
|
|
||||||
from pyramid.config import Configurator
|
from pyramid.config import Configurator
|
||||||
|
from pyramid.authentication import SessionAuthenticationPolicy
|
||||||
from zope.sqlalchemy import register
|
from zope.sqlalchemy import register
|
||||||
|
|
||||||
import tailbone.db
|
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.config import csrf_token_name, csrf_header_name
|
||||||
from tailbone.util import get_effective_theme, get_theme_template_path
|
from tailbone.util import get_effective_theme, get_theme_template_path
|
||||||
from tailbone.providers import get_all_providers
|
from tailbone.providers import get_all_providers
|
||||||
|
@ -58,33 +63,16 @@ def make_rattail_config(settings):
|
||||||
"to the path of your config file. Lame, but necessary.")
|
"to the path of your config file. Lame, but necessary.")
|
||||||
rattail_config = make_config(path)
|
rattail_config = make_config(path)
|
||||||
settings['rattail_config'] = rattail_config
|
settings['rattail_config'] = rattail_config
|
||||||
|
rattail_config.configure_logging()
|
||||||
# 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
|
# configure database sessions
|
||||||
if hasattr(rattail_config, 'appdb_engine'):
|
if hasattr(rattail_config, 'rattail_engine'):
|
||||||
tailbone.db.Session.configure(bind=rattail_config.appdb_engine)
|
tailbone.db.Session.configure(bind=rattail_config.rattail_engine)
|
||||||
if hasattr(rattail_config, 'trainwreck_engine'):
|
if hasattr(rattail_config, 'trainwreck_engine'):
|
||||||
tailbone.db.TrainwreckSession.configure(bind=rattail_config.trainwreck_engine)
|
tailbone.db.TrainwreckSession.configure(bind=rattail_config.trainwreck_engine)
|
||||||
if hasattr(rattail_config, 'tempmon_engine'):
|
if hasattr(rattail_config, 'tempmon_engine'):
|
||||||
tailbone.db.TempmonSession.configure(bind=rattail_config.tempmon_engine)
|
tailbone.db.TempmonSession.configure(bind=rattail_config.tempmon_engine)
|
||||||
|
|
||||||
# maybe set "future" behavior for SQLAlchemy
|
|
||||||
if rattail_config.getbool('rattail.db', 'sqlalchemy_future_mode', usedb=False):
|
|
||||||
tailbone.db.Session.configure(future=True)
|
|
||||||
|
|
||||||
# create session wrappers for each "extra" Trainwreck engine
|
# create session wrappers for each "extra" Trainwreck engine
|
||||||
for key, engine in rattail_config.trainwreck_engines.items():
|
for key, engine in rattail_config.trainwreck_engines.items():
|
||||||
if key != 'default':
|
if key != 'default':
|
||||||
|
@ -135,21 +123,18 @@ def make_pyramid_config(settings, configure_csrf=True):
|
||||||
config.set_root_factory(Root)
|
config.set_root_factory(Root)
|
||||||
else:
|
else:
|
||||||
|
|
||||||
# declare this web app of the "classic" variety
|
|
||||||
settings.setdefault('tailbone.classic', 'true')
|
|
||||||
|
|
||||||
# we want the new themes feature!
|
# we want the new themes feature!
|
||||||
establish_theme(settings)
|
establish_theme(settings)
|
||||||
|
|
||||||
settings.setdefault('fanstatic.versioning', 'true')
|
|
||||||
settings.setdefault('pyramid_deform.template_search_path', 'tailbone:templates/deform')
|
settings.setdefault('pyramid_deform.template_search_path', 'tailbone:templates/deform')
|
||||||
config = Configurator(settings=settings, root_factory=Root)
|
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
|
config.registry['rattail_config'] = rattail_config
|
||||||
|
|
||||||
# configure user authorization / authentication
|
# configure user authorization / authentication
|
||||||
config.set_security_policy(TailboneSecurityPolicy())
|
config.set_authorization_policy(TailboneAuthorizationPolicy())
|
||||||
|
config.set_authentication_policy(SessionAuthenticationPolicy())
|
||||||
|
|
||||||
# maybe require CSRF token protection
|
# maybe require CSRF token protection
|
||||||
if configure_csrf:
|
if configure_csrf:
|
||||||
|
@ -160,20 +145,9 @@ def make_pyramid_config(settings, configure_csrf=True):
|
||||||
# Bring in some Pyramid goodies.
|
# Bring in some Pyramid goodies.
|
||||||
config.include('tailbone.beaker')
|
config.include('tailbone.beaker')
|
||||||
config.include('pyramid_deform')
|
config.include('pyramid_deform')
|
||||||
config.include('pyramid_fanstatic')
|
|
||||||
config.include('pyramid_mako')
|
config.include('pyramid_mako')
|
||||||
config.include('pyramid_tm')
|
config.include('pyramid_tm')
|
||||||
|
|
||||||
# TODO: this may be a good idea some day, if wanting to leverage
|
|
||||||
# deform resources for component JS? cf. also base.mako template
|
|
||||||
# # override default script mapping for deform
|
|
||||||
# from deform import Field
|
|
||||||
# from deform.widget import ResourceRegistry, default_resources
|
|
||||||
# registry = ResourceRegistry(use_defaults=False)
|
|
||||||
# for key in default_resources:
|
|
||||||
# registry.set_js_resources(key, None, {'js': []})
|
|
||||||
# Field.set_default_resource_registry(registry)
|
|
||||||
|
|
||||||
# bring in the pyramid_retry logic, if available
|
# bring in the pyramid_retry logic, if available
|
||||||
# TODO: pretty soon we can require this package, hopefully..
|
# TODO: pretty soon we can require this package, hopefully..
|
||||||
try:
|
try:
|
||||||
|
@ -185,7 +159,7 @@ def make_pyramid_config(settings, configure_csrf=True):
|
||||||
|
|
||||||
# fetch all tailbone providers
|
# fetch all tailbone providers
|
||||||
providers = get_all_providers(rattail_config)
|
providers = get_all_providers(rattail_config)
|
||||||
for provider in providers.values():
|
for provider in six.itervalues(providers):
|
||||||
|
|
||||||
# configure DB sessions associated with transaction manager
|
# configure DB sessions associated with transaction manager
|
||||||
provider.configure_db_sessions(rattail_config, config)
|
provider.configure_db_sessions(rattail_config, config)
|
||||||
|
@ -196,22 +170,13 @@ def make_pyramid_config(settings, configure_csrf=True):
|
||||||
for spec in includes:
|
for spec in includes:
|
||||||
config.include(spec)
|
config.include(spec)
|
||||||
|
|
||||||
# add some permissions magic
|
# Add some permissions magic.
|
||||||
config.add_directive('add_wutta_permission_group',
|
config.add_directive('add_tailbone_permission_group', 'tailbone.auth.add_permission_group')
|
||||||
'wuttaweb.auth.add_permission_group')
|
config.add_directive('add_tailbone_permission', 'tailbone.auth.add_permission')
|
||||||
config.add_directive('add_wutta_permission',
|
|
||||||
'wuttaweb.auth.add_permission')
|
|
||||||
# TODO: deprecate / remove these
|
|
||||||
config.add_directive('add_tailbone_permission_group',
|
|
||||||
'wuttaweb.auth.add_permission_group')
|
|
||||||
config.add_directive('add_tailbone_permission',
|
|
||||||
'wuttaweb.auth.add_permission')
|
|
||||||
|
|
||||||
# and some similar magic for certain master views
|
# and some similar magic for certain master views
|
||||||
config.add_directive('add_tailbone_index_page', 'tailbone.app.add_index_page')
|
config.add_directive('add_tailbone_index_page', 'tailbone.app.add_index_page')
|
||||||
config.add_directive('add_tailbone_config_page', 'tailbone.app.add_config_page')
|
config.add_directive('add_tailbone_config_page', 'tailbone.app.add_config_page')
|
||||||
config.add_directive('add_tailbone_model_view', 'tailbone.app.add_model_view')
|
|
||||||
config.add_directive('add_tailbone_view_supplement', 'tailbone.app.add_view_supplement')
|
|
||||||
|
|
||||||
config.add_directive('add_tailbone_websocket', 'tailbone.app.add_websocket')
|
config.add_directive('add_tailbone_websocket', 'tailbone.app.add_websocket')
|
||||||
|
|
||||||
|
@ -226,7 +191,7 @@ def add_websocket(config, name, view, attr=None):
|
||||||
rattail_config = config.registry.settings['rattail_config']
|
rattail_config = config.registry.settings['rattail_config']
|
||||||
rattail_app = rattail_config.get_app()
|
rattail_app = rattail_config.get_app()
|
||||||
|
|
||||||
if isinstance(view, str):
|
if isinstance(view, six.string_types):
|
||||||
view_callable = rattail_app.load_object(view)
|
view_callable = rattail_app.load_object(view)
|
||||||
else:
|
else:
|
||||||
view_callable = view
|
view_callable = view
|
||||||
|
@ -274,36 +239,6 @@ def add_config_page(config, route_name, label, permission):
|
||||||
config.action(None, action)
|
config.action(None, action)
|
||||||
|
|
||||||
|
|
||||||
def add_model_view(config, model_name, label, route_prefix, permission_prefix):
|
|
||||||
"""
|
|
||||||
Register a model view for the app.
|
|
||||||
"""
|
|
||||||
def action():
|
|
||||||
all_views = config.get_settings().get('tailbone_model_views', {})
|
|
||||||
|
|
||||||
model_views = all_views.setdefault(model_name, [])
|
|
||||||
model_views.append({
|
|
||||||
'label': label,
|
|
||||||
'route_prefix': route_prefix,
|
|
||||||
'permission_prefix': permission_prefix,
|
|
||||||
})
|
|
||||||
|
|
||||||
config.add_settings({'tailbone_model_views': all_views})
|
|
||||||
|
|
||||||
config.action(None, action)
|
|
||||||
|
|
||||||
|
|
||||||
def add_view_supplement(config, route_prefix, cls):
|
|
||||||
"""
|
|
||||||
Register a master view supplement for the app.
|
|
||||||
"""
|
|
||||||
def action():
|
|
||||||
supplements = config.get_settings().get('tailbone_view_supplements', {})
|
|
||||||
supplements.setdefault(route_prefix, []).append(cls)
|
|
||||||
config.add_settings({'tailbone_view_supplements': supplements})
|
|
||||||
config.action(None, action)
|
|
||||||
|
|
||||||
|
|
||||||
def establish_theme(settings):
|
def establish_theme(settings):
|
||||||
rattail_config = settings['rattail_config']
|
rattail_config = settings['rattail_config']
|
||||||
|
|
||||||
|
@ -311,7 +246,7 @@ def establish_theme(settings):
|
||||||
settings['tailbone.theme'] = theme
|
settings['tailbone.theme'] = theme
|
||||||
|
|
||||||
directories = settings['mako.directories']
|
directories = settings['mako.directories']
|
||||||
if isinstance(directories, str):
|
if isinstance(directories, six.string_types):
|
||||||
directories = parse_list(directories)
|
directories = parse_list(directories)
|
||||||
|
|
||||||
path = get_theme_template_path(rattail_config)
|
path = get_theme_template_path(rattail_config)
|
||||||
|
@ -332,8 +267,7 @@ def main(global_config, **settings):
|
||||||
"""
|
"""
|
||||||
This function returns a Pyramid WSGI application.
|
This function returns a Pyramid WSGI application.
|
||||||
"""
|
"""
|
||||||
settings.setdefault('mako.directories', ['tailbone:templates',
|
settings.setdefault('mako.directories', ['tailbone:templates'])
|
||||||
'wuttaweb:templates'])
|
|
||||||
rattail_config = make_rattail_config(settings)
|
rattail_config = make_rattail_config(settings)
|
||||||
pyramid_config = make_pyramid_config(settings)
|
pyramid_config = make_pyramid_config(settings)
|
||||||
pyramid_config.include('tailbone')
|
pyramid_config.include('tailbone')
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
################################################################################
|
################################################################################
|
||||||
#
|
#
|
||||||
# Rattail -- Retail Software Framework
|
# Rattail -- Retail Software Framework
|
||||||
# Copyright © 2010-2024 Lance Edgar
|
# Copyright © 2010-2022 Lance Edgar
|
||||||
#
|
#
|
||||||
# This file is part of Rattail.
|
# This file is part of Rattail.
|
||||||
#
|
#
|
||||||
|
@ -24,10 +24,14 @@
|
||||||
ASGI App Utilities
|
ASGI App Utilities
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from __future__ import unicode_literals, absolute_import
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import configparser
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
import six
|
||||||
|
from six.moves import configparser
|
||||||
|
|
||||||
from rattail.util import load_object
|
from rattail.util import load_object
|
||||||
|
|
||||||
from asgiref.wsgi import WsgiToAsgi
|
from asgiref.wsgi import WsgiToAsgi
|
||||||
|
@ -45,12 +49,6 @@ class TailboneWsgiToAsgi(WsgiToAsgi):
|
||||||
protocol = scope['type']
|
protocol = scope['type']
|
||||||
path = scope['path']
|
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':
|
if protocol == 'websocket':
|
||||||
websockets = self.wsgi_application.registry.get(
|
websockets = self.wsgi_application.registry.get(
|
||||||
'tailbone_websockets', {})
|
'tailbone_websockets', {})
|
||||||
|
@ -87,7 +85,7 @@ def make_asgi_app(main_app=None):
|
||||||
# parse the settings needed for pyramid app
|
# parse the settings needed for pyramid app
|
||||||
settings = dict(parser.items('app:main'))
|
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)
|
make_wsgi_app = load_object(main_app)
|
||||||
elif callable(main_app):
|
elif callable(main_app):
|
||||||
make_wsgi_app = main_app
|
make_wsgi_app = main_app
|
||||||
|
|
113
tailbone/auth.py
113
tailbone/auth.py
|
@ -2,7 +2,7 @@
|
||||||
################################################################################
|
################################################################################
|
||||||
#
|
#
|
||||||
# Rattail -- Retail Software Framework
|
# Rattail -- Retail Software Framework
|
||||||
# Copyright © 2010-2024 Lance Edgar
|
# Copyright © 2010-2021 Lance Edgar
|
||||||
#
|
#
|
||||||
# This file is part of Rattail.
|
# This file is part of Rattail.
|
||||||
#
|
#
|
||||||
|
@ -24,31 +24,32 @@
|
||||||
Authentication & Authorization
|
Authentication & Authorization
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from __future__ import unicode_literals, absolute_import
|
||||||
|
|
||||||
import logging
|
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 wuttaweb.auth import WuttaSecurityPolicy
|
|
||||||
from tailbone.db import Session
|
from tailbone.db import Session
|
||||||
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
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
|
Perform the steps necessary to login the given user. Note that this
|
||||||
returns a ``headers`` dict which you should pass to the redirect.
|
returns a ``headers`` dict which you should pass to the redirect.
|
||||||
"""
|
"""
|
||||||
config = request.rattail_config
|
user.record_event(enum.USER_EVENT_LOGIN)
|
||||||
app = config.get_app()
|
|
||||||
user.record_event(app.enum.USER_EVENT_LOGIN)
|
|
||||||
headers = remember(request, user.uuid)
|
headers = remember(request, user.uuid)
|
||||||
if timeout is UNSPECIFIED:
|
if timeout is NOTSET:
|
||||||
timeout = session_timeout_for_user(config, user)
|
timeout = session_timeout_for_user(user)
|
||||||
log.debug("setting session timeout for '{}' to {}".format(user.username, timeout))
|
log.debug("setting session timeout for '{}' to {}".format(user.username, timeout))
|
||||||
set_session_timeout(request, timeout)
|
set_session_timeout(request, timeout)
|
||||||
return headers
|
return headers
|
||||||
|
@ -59,28 +60,24 @@ def logout_user(request):
|
||||||
Perform the logout action for the given request. Note that this returns a
|
Perform the logout action for the given request. Note that this returns a
|
||||||
``headers`` dict which you should pass to the redirect.
|
``headers`` dict which you should pass to the redirect.
|
||||||
"""
|
"""
|
||||||
app = request.rattail_config.get_app()
|
|
||||||
user = request.user
|
user = request.user
|
||||||
if user:
|
if user:
|
||||||
user.record_event(app.enum.USER_EVENT_LOGOUT)
|
user.record_event(enum.USER_EVENT_LOGOUT)
|
||||||
request.session.delete()
|
request.session.delete()
|
||||||
request.session.invalidate()
|
request.session.invalidate()
|
||||||
headers = forget(request)
|
headers = forget(request)
|
||||||
return headers
|
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
|
Returns the "max" session timeout for the user, according to roles
|
||||||
"""
|
"""
|
||||||
app = config.get_app()
|
from rattail.db.auth import authenticated_role
|
||||||
auth = app.get_auth_handler()
|
|
||||||
|
|
||||||
authenticated = auth.get_role_authenticated(Session())
|
roles = user.roles + [authenticated_role(Session())]
|
||||||
roles = user.roles + [authenticated]
|
|
||||||
timeouts = [role.session_timeout for role in roles
|
timeouts = [role.session_timeout for role in roles
|
||||||
if role.session_timeout is not None]
|
if role.session_timeout is not None]
|
||||||
|
|
||||||
if timeouts and 0 not in timeouts:
|
if timeouts and 0 not in timeouts:
|
||||||
return max(timeouts)
|
return max(timeouts)
|
||||||
|
|
||||||
|
@ -92,42 +89,58 @@ def set_session_timeout(request, timeout):
|
||||||
request.session['_timeout'] = timeout or None
|
request.session['_timeout'] = timeout or None
|
||||||
|
|
||||||
|
|
||||||
class TailboneSecurityPolicy(WuttaSecurityPolicy):
|
@implementer(IAuthorizationPolicy)
|
||||||
|
class TailboneAuthorizationPolicy(object):
|
||||||
|
|
||||||
def __init__(self, db_session=None, api_mode=False, **kwargs):
|
def permits(self, context, principals, permission):
|
||||||
kwargs['db_session'] = db_session or Session()
|
config = context.request.rattail_config
|
||||||
super().__init__(**kwargs)
|
model = config.get_model()
|
||||||
self.api_mode = api_mode
|
|
||||||
|
|
||||||
def load_identity(self, request):
|
|
||||||
config = request.registry.settings.get('rattail_config')
|
|
||||||
app = config.get_app()
|
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.query(model.User).get(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
|
def principals_allowed_by_permission(self, context, permission):
|
||||||
credentials = request.headers.get('Authorization')
|
raise NotImplementedError
|
||||||
if credentials:
|
|
||||||
match = re.match(r'^Bearer (\S+)$', credentials)
|
|
||||||
if match:
|
|
||||||
token = match.group(1)
|
|
||||||
auth = app.get_auth_handler()
|
|
||||||
user = auth.authenticate_user_token(self.db_session, token)
|
|
||||||
|
|
||||||
if not user:
|
|
||||||
|
|
||||||
# fetch user uuid from current session
|
def add_permission_group(config, key, label=None, overwrite=True):
|
||||||
uuid = self.session_helper.authenticated_userid(request)
|
"""
|
||||||
if not uuid:
|
Add a permission group to the app configuration.
|
||||||
return
|
"""
|
||||||
|
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
|
def add_permission(config, groupkey, key, label=None):
|
||||||
self.db_session.set_continuum_user(user)
|
"""
|
||||||
return user
|
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)
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
# -*- coding: utf-8; -*-
|
# -*- coding: utf-8 -*-
|
||||||
################################################################################
|
################################################################################
|
||||||
#
|
#
|
||||||
# Rattail -- Retail Software Framework
|
# Rattail -- Retail Software Framework
|
||||||
# Copyright © 2010-2024 Lance Edgar
|
# Copyright © 2010-2017 Lance Edgar
|
||||||
#
|
#
|
||||||
# This file is part of Rattail.
|
# This file is part of Rattail.
|
||||||
#
|
#
|
||||||
|
@ -27,12 +27,10 @@ Note that most of the code for this module was copied from the beaker and
|
||||||
pyramid_beaker projects.
|
pyramid_beaker projects.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from __future__ import unicode_literals, absolute_import
|
||||||
|
|
||||||
import time
|
import time
|
||||||
from pkg_resources import parse_version
|
|
||||||
|
|
||||||
from rattail.util import get_pkg_version
|
|
||||||
|
|
||||||
import beaker
|
|
||||||
from beaker.session import Session
|
from beaker.session import Session
|
||||||
from beaker.util import coerce_session_params
|
from beaker.util import coerce_session_params
|
||||||
from pyramid.settings import asbool
|
from pyramid.settings import asbool
|
||||||
|
@ -47,10 +45,6 @@ class TailboneSession(Session):
|
||||||
|
|
||||||
def load(self):
|
def load(self):
|
||||||
"Loads the data from this session from persistent storage"
|
"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')
|
|
||||||
|
|
||||||
self.namespace = self.namespace_class(self.id,
|
self.namespace = self.namespace_class(self.id,
|
||||||
data_dir=self.data_dir,
|
data_dir=self.data_dir,
|
||||||
digest_filenames=False,
|
digest_filenames=False,
|
||||||
|
@ -66,12 +60,8 @@ class TailboneSession(Session):
|
||||||
try:
|
try:
|
||||||
session_data = self.namespace['session']
|
session_data = self.namespace['session']
|
||||||
|
|
||||||
if old_beaker:
|
if (session_data is not None and self.encrypt_key):
|
||||||
if (session_data is not None and self.encrypt_key):
|
session_data = self._decrypt_data(session_data)
|
||||||
session_data = self._decrypt_data(session_data)
|
|
||||||
else: # beaker >= 1.12
|
|
||||||
if session_data is not None:
|
|
||||||
session_data = self._decrypt_data(session_data)
|
|
||||||
|
|
||||||
# Memcached always returns a key, its None when its not
|
# Memcached always returns a key, its None when its not
|
||||||
# present
|
# present
|
||||||
|
@ -100,7 +90,6 @@ class TailboneSession(Session):
|
||||||
# for this module entirely...
|
# for this module entirely...
|
||||||
timeout = session_data.get('_timeout', self.timeout)
|
timeout = session_data.get('_timeout', self.timeout)
|
||||||
if timeout is not None and \
|
if timeout is not None and \
|
||||||
'_accessed_time' in session_data and \
|
|
||||||
now - session_data['_accessed_time'] > timeout:
|
now - session_data['_accessed_time'] > timeout:
|
||||||
timed_out = True
|
timed_out = True
|
||||||
else:
|
else:
|
||||||
|
@ -114,6 +103,9 @@ class TailboneSession(Session):
|
||||||
# Update the current _accessed_time
|
# Update the current _accessed_time
|
||||||
session_data['_accessed_time'] = now
|
session_data['_accessed_time'] = now
|
||||||
|
|
||||||
|
# Set the path if applicable
|
||||||
|
if '_path' in session_data:
|
||||||
|
self._path = session_data['_path']
|
||||||
self.update(session_data)
|
self.update(session_data)
|
||||||
self.accessed_dict = session_data.copy()
|
self.accessed_dict = session_data.copy()
|
||||||
finally:
|
finally:
|
||||||
|
|
|
@ -1,80 +0,0 @@
|
||||||
# -*- coding: utf-8; -*-
|
|
||||||
################################################################################
|
|
||||||
#
|
|
||||||
# Rattail -- Retail Software Framework
|
|
||||||
# Copyright © 2010-2022 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/>.
|
|
||||||
#
|
|
||||||
################################################################################
|
|
||||||
"""
|
|
||||||
Cleanup logic
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import unicode_literals, absolute_import
|
|
||||||
|
|
||||||
import os
|
|
||||||
import logging
|
|
||||||
import time
|
|
||||||
|
|
||||||
from rattail.cleanup import Cleaner
|
|
||||||
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class BeakerCleaner(Cleaner):
|
|
||||||
"""
|
|
||||||
Cleanup logic for old Beaker session files.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def get_session_dir(self):
|
|
||||||
session_dir = self.config.get('rattail.cleanup', 'beaker.session_dir')
|
|
||||||
if session_dir and os.path.isdir(session_dir):
|
|
||||||
return session_dir
|
|
||||||
|
|
||||||
session_dir = os.path.join(self.config.appdir(), 'sessions')
|
|
||||||
if os.path.isdir(session_dir):
|
|
||||||
return session_dir
|
|
||||||
|
|
||||||
def cleanup(self, session, dry_run=False, progress=None, **kwargs):
|
|
||||||
session_dir = self.get_session_dir()
|
|
||||||
if not session_dir:
|
|
||||||
return
|
|
||||||
|
|
||||||
data_dir = os.path.join(session_dir, 'data')
|
|
||||||
lock_dir = os.path.join(session_dir, 'lock')
|
|
||||||
|
|
||||||
# looking for files older than X days
|
|
||||||
days = self.config.getint('rattail.cleanup',
|
|
||||||
'beaker.session_cutoff_days',
|
|
||||||
default=30)
|
|
||||||
cutoff = time.time() - 3600 * 24 * days
|
|
||||||
|
|
||||||
for topdir in (data_dir, lock_dir):
|
|
||||||
if not os.path.isdir(topdir):
|
|
||||||
continue
|
|
||||||
|
|
||||||
for dirpath, dirnames, filenames in os.walk(topdir):
|
|
||||||
for fname in filenames:
|
|
||||||
path = os.path.join(dirpath, fname)
|
|
||||||
ts = os.path.getmtime(path)
|
|
||||||
if ts <= cutoff:
|
|
||||||
if dry_run:
|
|
||||||
log.debug("would delete file: %s", path)
|
|
||||||
else:
|
|
||||||
os.remove(path)
|
|
||||||
log.debug("deleted file: %s", path)
|
|
|
@ -2,7 +2,7 @@
|
||||||
################################################################################
|
################################################################################
|
||||||
#
|
#
|
||||||
# Rattail -- Retail Software Framework
|
# Rattail -- Retail Software Framework
|
||||||
# Copyright © 2010-2024 Lance Edgar
|
# Copyright © 2010-2022 Lance Edgar
|
||||||
#
|
#
|
||||||
# This file is part of Rattail.
|
# This file is part of Rattail.
|
||||||
#
|
#
|
||||||
|
@ -24,16 +24,15 @@
|
||||||
Rattail config extension for Tailbone
|
Rattail config extension for Tailbone
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import warnings
|
from __future__ import unicode_literals, absolute_import
|
||||||
|
|
||||||
from wuttjamaican.conf import WuttaConfigExtension
|
|
||||||
|
|
||||||
|
from rattail.config import ConfigExtension as BaseExtension
|
||||||
from rattail.db.config import configure_session
|
from rattail.db.config import configure_session
|
||||||
|
|
||||||
from tailbone.db import Session
|
from tailbone.db import Session
|
||||||
|
|
||||||
|
|
||||||
class ConfigExtension(WuttaConfigExtension):
|
class ConfigExtension(BaseExtension):
|
||||||
"""
|
"""
|
||||||
Rattail config extension for Tailbone. Does the following:
|
Rattail config extension for Tailbone. Does the following:
|
||||||
|
|
||||||
|
@ -50,12 +49,9 @@ class ConfigExtension(WuttaConfigExtension):
|
||||||
configure_session(config, Session)
|
configure_session(config, Session)
|
||||||
|
|
||||||
# provide default theme selection
|
# provide default theme selection
|
||||||
config.setdefault('tailbone', 'themes.keys', 'default, butterball')
|
config.setdefault('tailbone', 'themes', 'default, falafel')
|
||||||
config.setdefault('tailbone', 'themes.expose_picker', 'true')
|
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):
|
def csrf_token_name(config):
|
||||||
return config.get('tailbone', 'csrf_token_name', default='_csrf')
|
return config.get('tailbone', 'csrf_token_name', default='_csrf')
|
||||||
|
|
160
tailbone/db.py
160
tailbone/db.py
|
@ -2,7 +2,7 @@
|
||||||
################################################################################
|
################################################################################
|
||||||
#
|
#
|
||||||
# Rattail -- Retail Software Framework
|
# Rattail -- Retail Software Framework
|
||||||
# Copyright © 2010-2024 Lance Edgar
|
# Copyright © 2010-2021 Lance Edgar
|
||||||
#
|
#
|
||||||
# This file is part of Rattail.
|
# This file is part of Rattail.
|
||||||
#
|
#
|
||||||
|
@ -21,13 +21,16 @@
|
||||||
#
|
#
|
||||||
################################################################################
|
################################################################################
|
||||||
"""
|
"""
|
||||||
Database sessions etc.
|
Database Stuff
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from __future__ import unicode_literals, absolute_import
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from zope.sqlalchemy import datamanager
|
from zope.sqlalchemy import datamanager
|
||||||
import sqlalchemy_continuum as continuum
|
import sqlalchemy_continuum as continuum
|
||||||
from sqlalchemy.orm import sessionmaker, scoped_session
|
from sqlalchemy.orm import sessionmaker, scoped_session
|
||||||
|
from pkg_resources import get_distribution, parse_version
|
||||||
|
|
||||||
from rattail.db import SessionBase
|
from rattail.db import SessionBase
|
||||||
from rattail.db.continuum import versioning_manager
|
from rattail.db.continuum import versioning_manager
|
||||||
|
@ -42,28 +45,23 @@ TrainwreckSession = scoped_session(sessionmaker())
|
||||||
# empty dict for now, this must populated on app startup (if needed)
|
# empty dict for now, this must populated on app startup (if needed)
|
||||||
ExtraTrainwreckSessions = {}
|
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):
|
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.
|
One phase variant.
|
||||||
|
|
||||||
.. note::
|
.. note::
|
||||||
|
This class appears to be necessary in order for the Continuum
|
||||||
This class appears to be necessary in order for the
|
integration to work alongside the Zope transaction integration.
|
||||||
SQLAlchemy-Continuum integration to work alongside the Zope
|
|
||||||
transaction integration.
|
|
||||||
|
|
||||||
It subclasses
|
|
||||||
``zope.sqlalchemy.datamanager.SessionDataManager`` but injects
|
|
||||||
some SQLAlchemy-Continuum logic within :meth:`tpc_vote()`, and
|
|
||||||
is sort of monkey-patched into the mix.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def tpc_vote(self, trans):
|
def tpc_vote(self, trans):
|
||||||
""" """
|
|
||||||
# for a one phase data manager commit last in tpc_vote
|
# 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
|
if self.tx is not None: # there may have been no work to do
|
||||||
|
|
||||||
|
@ -75,117 +73,82 @@ class TailboneSessionDataManager(datamanager.SessionDataManager):
|
||||||
self._finish('committed')
|
self._finish('committed')
|
||||||
|
|
||||||
|
|
||||||
def join_transaction(
|
def join_transaction(session, initial_state=datamanager.STATUS_ACTIVE, transaction_manager=datamanager.zope_transaction.manager, keep_session=False):
|
||||||
session,
|
"""Join a session to a transaction using the appropriate datamanager.
|
||||||
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
|
It is safe to call this multiple times, if the session is already joined
|
||||||
joined then it just returns.
|
then it just returns.
|
||||||
|
|
||||||
`initial_state` is either STATUS_ACTIVE, STATUS_INVALIDATED or
|
`initial_state` is either STATUS_ACTIVE, STATUS_INVALIDATED or STATUS_READONLY
|
||||||
STATUS_READONLY
|
|
||||||
|
|
||||||
If using the default initial status of STATUS_ACTIVE, you must
|
If using the default initial status of STATUS_ACTIVE, you must ensure that
|
||||||
ensure that mark_changed(session) is called when data is written
|
mark_changed(session) is called when data is written to the database.
|
||||||
to the database.
|
|
||||||
|
|
||||||
The ZopeTransactionExtesion SessionExtension can be used to ensure
|
The ZopeTransactionExtesion SessionExtension can be used to ensure that this is
|
||||||
that this is called automatically after session write operations.
|
called automatically after session write operations.
|
||||||
|
|
||||||
.. note::
|
.. note::
|
||||||
|
This function is copied from upstream, and tweaked so that our custom
|
||||||
This function appears to be necessary in order for the
|
:class:`TailboneSessionDataManager` will be used.
|
||||||
SQLAlchemy-Continuum integration to work alongside the Zope
|
|
||||||
transaction integration.
|
|
||||||
|
|
||||||
It overrides ``zope.sqlalchemy.datamanager.join_transaction()``
|
|
||||||
to ensure the custom :class:`TailboneSessionDataManager` is
|
|
||||||
used, and is sort of monkey-patched into the mix.
|
|
||||||
"""
|
"""
|
||||||
# the upstream internals of this function has changed a little over time.
|
# the upstream internals of this function has changed a little over time.
|
||||||
# unfortunately for us, that means we must include each variant here.
|
# unfortunately for us, that means we must include each variant here.
|
||||||
|
|
||||||
if datamanager._SESSION_STATE.get(session, None) is None:
|
if zope_sqlalchemy_version_parsed >= parse_version('1.1'): # 1.1+
|
||||||
if session.twophase:
|
if datamanager._SESSION_STATE.get(session, None) is None:
|
||||||
DataManager = datamanager.TwoPhaseSessionDataManager
|
if session.twophase:
|
||||||
else:
|
DataManager = datamanager.TwoPhaseSessionDataManager
|
||||||
DataManager = TailboneSessionDataManager
|
else:
|
||||||
DataManager(session, initial_state, transaction_manager, keep_session=keep_session)
|
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):
|
class ZopeTransactionExtension(datamanager.ZopeTransactionExtension):
|
||||||
"""
|
"""Record that a flush has occurred on a session's connection. This allows
|
||||||
Record that a flush has occurred on a session's connection. This
|
the DataManager to rollback rather than commit on read only transactions.
|
||||||
allows the DataManager to rollback rather than commit on read only
|
|
||||||
transactions.
|
|
||||||
|
|
||||||
.. note::
|
.. note::
|
||||||
|
This class is copied from upstream, and tweaked so that our custom
|
||||||
This class appears to be necessary in order for the
|
:func:`join_transaction()` will be used.
|
||||||
SQLAlchemy-Continuum integration to work alongside the Zope
|
|
||||||
transaction integration.
|
|
||||||
|
|
||||||
It subclasses
|
|
||||||
``zope.sqlalchemy.datamanager.ZopeTransactionEvents`` but
|
|
||||||
overrides various methods to ensure the custom
|
|
||||||
:func:`join_transaction()` is called, and is sort of
|
|
||||||
monkey-patched into the mix.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def after_begin(self, session, transaction, connection):
|
def after_begin(self, session, transaction, connection):
|
||||||
""" """
|
join_transaction(session, self.initial_state, self.transaction_manager, self.keep_session)
|
||||||
join_transaction(session, self.initial_state,
|
|
||||||
self.transaction_manager, self.keep_session)
|
|
||||||
|
|
||||||
def after_attach(self, session, instance):
|
def after_attach(self, session, instance):
|
||||||
""" """
|
join_transaction(session, self.initial_state, self.transaction_manager, self.keep_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)
|
|
||||||
|
|
||||||
|
|
||||||
def register(
|
def register(session, initial_state=datamanager.STATUS_ACTIVE,
|
||||||
session,
|
transaction_manager=datamanager.zope_transaction.manager, keep_session=False):
|
||||||
initial_state=datamanager.STATUS_ACTIVE,
|
"""Register ZopeTransaction listener events on the
|
||||||
transaction_manager=datamanager.zope_transaction.manager,
|
given Session or Session factory/class.
|
||||||
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
|
This function requires at least SQLAlchemy 0.7 and makes use
|
||||||
the newer sqlalchemy.event package in order to register event
|
of the newer sqlalchemy.event package in order to register event listeners
|
||||||
listeners on the given Session.
|
on the given Session.
|
||||||
|
|
||||||
The session argument here may be a Session class or subclass, a
|
The session argument here may be a Session class or subclass, a
|
||||||
sessionmaker or scoped_session instance, or a specific Session
|
sessionmaker or scoped_session instance, or a specific Session instance.
|
||||||
instance. Event listening will be specific to the scope of the
|
Event listening will be specific to the scope of the type of argument
|
||||||
type of argument passed, including specificity to its subclass as
|
passed, including specificity to its subclass as well as its identity.
|
||||||
well as its identity.
|
|
||||||
|
|
||||||
.. note::
|
.. note::
|
||||||
|
This function is copied from upstream, and tweaked so that our custom
|
||||||
This function appears to be necessary in order for the
|
:class:`ZopeTransactionExtension` will be used.
|
||||||
SQLAlchemy-Continuum integration to work alongside the Zope
|
|
||||||
transaction integration.
|
|
||||||
|
|
||||||
It overrides ``zope.sqlalchemy.datamanager.regsiter()`` to
|
|
||||||
ensure the custom :class:`ZopeTransactionEvents` is used.
|
|
||||||
"""
|
"""
|
||||||
from sqlalchemy import event
|
from sqlalchemy import event
|
||||||
|
|
||||||
ext = ZopeTransactionEvents(
|
ext = ZopeTransactionExtension(
|
||||||
initial_state=initial_state,
|
initial_state=initial_state,
|
||||||
transaction_manager=transaction_manager,
|
transaction_manager=transaction_manager,
|
||||||
keep_session=keep_session,
|
keep_session=keep_session,
|
||||||
)
|
)
|
||||||
|
@ -197,9 +160,6 @@ def register(
|
||||||
event.listen(session, "after_bulk_delete", ext.after_bulk_delete)
|
event.listen(session, "after_bulk_delete", ext.after_bulk_delete)
|
||||||
event.listen(session, "before_commit", ext.before_commit)
|
event.listen(session, "before_commit", ext.before_commit)
|
||||||
|
|
||||||
if datamanager.SA_GE_14:
|
|
||||||
event.listen(session, "do_orm_execute", ext.do_orm_execute)
|
|
||||||
|
|
||||||
|
|
||||||
register(Session)
|
register(Session)
|
||||||
register(TempmonSession)
|
register(TempmonSession)
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
################################################################################
|
################################################################################
|
||||||
#
|
#
|
||||||
# Rattail -- Retail Software Framework
|
# Rattail -- Retail Software Framework
|
||||||
# Copyright © 2010-2024 Lance Edgar
|
# Copyright © 2010-2019 Lance Edgar
|
||||||
#
|
#
|
||||||
# This file is part of Rattail.
|
# This file is part of Rattail.
|
||||||
#
|
#
|
||||||
|
@ -24,8 +24,7 @@
|
||||||
Tools for displaying data diffs
|
Tools for displaying data diffs
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sqlalchemy as sa
|
from __future__ import unicode_literals, absolute_import
|
||||||
import sqlalchemy_continuum as continuum
|
|
||||||
|
|
||||||
from pyramid.renderers import render
|
from pyramid.renderers import render
|
||||||
from webhelpers2.html import HTML
|
from webhelpers2.html import HTML
|
||||||
|
@ -34,41 +33,37 @@ from webhelpers2.html import HTML
|
||||||
class Diff(object):
|
class Diff(object):
|
||||||
"""
|
"""
|
||||||
Core diff class. In sore need of documentation.
|
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',
|
render_field=None, render_value=None,
|
||||||
monospace=False, extra_row_attrs=None):
|
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.old_data = old_data
|
||||||
self.new_data = new_data
|
self.new_data = new_data
|
||||||
self.columns = columns or ["field name", "old value", "new value"]
|
self.columns = columns or ["field name", "old value", "new value"]
|
||||||
self.fields = fields or self.make_fields()
|
self.fields = fields or self.make_fields()
|
||||||
self.enums = enums or {}
|
|
||||||
self._render_field = render_field or self.render_field_default
|
self._render_field = render_field or self.render_field_default
|
||||||
self.render_value = render_value or self.render_value_default
|
self.render_value = render_value or self.render_value_default
|
||||||
self.nature = nature
|
|
||||||
self.monospace = monospace
|
self.monospace = monospace
|
||||||
self.extra_row_attrs = extra_row_attrs
|
self.extra_row_attrs = extra_row_attrs
|
||||||
|
|
||||||
|
@ -95,7 +90,7 @@ class Diff(object):
|
||||||
for the given field. May be an empty string, or a snippet of HTML
|
for the given field. May be an empty string, or a snippet of HTML
|
||||||
attribute syntax, e.g.:
|
attribute syntax, e.g.:
|
||||||
|
|
||||||
.. code-block:: none
|
.. code-highlight:: none
|
||||||
|
|
||||||
class="diff" foo="bar"
|
class="diff" foo="bar"
|
||||||
|
|
||||||
|
@ -131,161 +126,3 @@ class Diff(object):
|
||||||
def render_new_value(self, field):
|
def render_new_value(self, field):
|
||||||
value = self.new_value(field)
|
value = self.new_value(field)
|
||||||
return self.render_value(field, value)
|
return self.render_value(field, value)
|
||||||
|
|
||||||
|
|
||||||
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.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, version, *args, **kwargs):
|
|
||||||
self.version = version
|
|
||||||
self.mapper = sa.inspect(continuum.parent_class(type(self.version)))
|
|
||||||
self.version_mapper = sa.inspect(type(self.version))
|
|
||||||
self.title = kwargs.pop('title', None)
|
|
||||||
|
|
||||||
if 'nature' not in kwargs:
|
|
||||||
if version.previous and version.operation_type == continuum.Operation.DELETE:
|
|
||||||
kwargs['nature'] = 'deleted'
|
|
||||||
elif version.previous:
|
|
||||||
kwargs['nature'] = 'dirty'
|
|
||||||
else:
|
|
||||||
kwargs['nature'] = 'new'
|
|
||||||
|
|
||||||
if 'fields' not in kwargs:
|
|
||||||
kwargs['fields'] = self.get_default_fields()
|
|
||||||
|
|
||||||
if not args:
|
|
||||||
old_data = {}
|
|
||||||
new_data = {}
|
|
||||||
for field in kwargs['fields']:
|
|
||||||
if version.previous:
|
|
||||||
old_data[field] = getattr(version.previous, field)
|
|
||||||
new_data[field] = getattr(version, field)
|
|
||||||
args = (old_data, new_data)
|
|
||||||
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
|
|
||||||
def get_default_fields(self):
|
|
||||||
fields = sorted(self.version_mapper.columns.keys())
|
|
||||||
|
|
||||||
unwanted = [
|
|
||||||
'transaction_id',
|
|
||||||
'end_transaction_id',
|
|
||||||
'operation_type',
|
|
||||||
]
|
|
||||||
|
|
||||||
return [field for field in fields
|
|
||||||
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
|
|
||||||
|
|
||||||
for col in prop.local_columns:
|
|
||||||
if col.name != field:
|
|
||||||
continue
|
|
||||||
|
|
||||||
if not hasattr(version, prop.key):
|
|
||||||
continue
|
|
||||||
|
|
||||||
if col in self.mapper.primary_key:
|
|
||||||
continue
|
|
||||||
|
|
||||||
ref = getattr(version, prop.key)
|
|
||||||
if ref:
|
|
||||||
ref = getattr(ref, 'version_parent', None)
|
|
||||||
if ref:
|
|
||||||
return HTML.tag('span', c=[
|
|
||||||
text,
|
|
||||||
HTML.tag('span', c=[str(ref)],
|
|
||||||
style='margin-left: 2rem; font-style: italic; font-weight: bold;'),
|
|
||||||
])
|
|
||||||
|
|
||||||
return text
|
|
||||||
|
|
||||||
def render_old_value(self, field):
|
|
||||||
if self.nature == 'new':
|
|
||||||
return ''
|
|
||||||
value = self.old_value(field)
|
|
||||||
return self.render_version_value(field, value, self.version.previous)
|
|
||||||
|
|
||||||
def render_new_value(self, field):
|
|
||||||
if self.nature == 'deleted':
|
|
||||||
return ''
|
|
||||||
value = self.new_value(field)
|
|
||||||
return self.render_version_value(field, value, self.version)
|
|
||||||
|
|
||||||
def as_struct(self):
|
|
||||||
values = {}
|
|
||||||
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,
|
|
||||||
}
|
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
################################################################################
|
################################################################################
|
||||||
#
|
#
|
||||||
# Rattail -- Retail Software Framework
|
# Rattail -- Retail Software Framework
|
||||||
# Copyright © 2010-2024 Lance Edgar
|
# Copyright © 2010-2020 Lance Edgar
|
||||||
#
|
#
|
||||||
# This file is part of Rattail.
|
# This file is part of Rattail.
|
||||||
#
|
#
|
||||||
|
@ -24,6 +24,10 @@
|
||||||
Tailbone Exceptions
|
Tailbone Exceptions
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from __future__ import unicode_literals, absolute_import
|
||||||
|
|
||||||
|
import six
|
||||||
|
|
||||||
from rattail.exceptions import RattailError
|
from rattail.exceptions import RattailError
|
||||||
|
|
||||||
|
|
||||||
|
@ -33,6 +37,7 @@ class TailboneError(RattailError):
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@six.python_2_unicode_compatible
|
||||||
class TailboneJSONFieldError(TailboneError):
|
class TailboneJSONFieldError(TailboneError):
|
||||||
"""
|
"""
|
||||||
Error raised when JSON serialization of a form field results in an error.
|
Error raised when JSON serialization of a form field results in an error.
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
################################################################################
|
################################################################################
|
||||||
#
|
#
|
||||||
# Rattail -- Retail Software Framework
|
# Rattail -- Retail Software Framework
|
||||||
# Copyright © 2010-2023 Lance Edgar
|
# Copyright © 2010-2018 Lance Edgar
|
||||||
#
|
#
|
||||||
# This file is part of Rattail.
|
# This file is part of Rattail.
|
||||||
#
|
#
|
||||||
|
@ -24,7 +24,8 @@
|
||||||
Forms Library
|
Forms Library
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# nb. import widgets before types, b/c types may refer to widgets
|
from __future__ import unicode_literals, absolute_import
|
||||||
from . import widgets
|
|
||||||
from . import types
|
from . import types
|
||||||
|
from . import widgets
|
||||||
from .core import Form, SimpleFileImport
|
from .core import Form, SimpleFileImport
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
################################################################################
|
################################################################################
|
||||||
#
|
#
|
||||||
# Rattail -- Retail Software Framework
|
# Rattail -- Retail Software Framework
|
||||||
# Copyright © 2010-2023 Lance Edgar
|
# Copyright © 2010-2022 Lance Edgar
|
||||||
#
|
#
|
||||||
# This file is part of Rattail.
|
# This file is part of Rattail.
|
||||||
#
|
#
|
||||||
|
@ -24,6 +24,8 @@
|
||||||
Common Forms
|
Common Forms
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from __future__ import unicode_literals, absolute_import
|
||||||
|
|
||||||
from rattail.db import model
|
from rattail.db import model
|
||||||
|
|
||||||
import colander
|
import colander
|
||||||
|
@ -33,7 +35,7 @@ import colander
|
||||||
def validate_user(node, kw):
|
def validate_user(node, kw):
|
||||||
session = kw['session']
|
session = kw['session']
|
||||||
def validate(node, value):
|
def validate(node, value):
|
||||||
user = session.get(model.User, value)
|
user = session.query(model.User).get(value)
|
||||||
if not user:
|
if not user:
|
||||||
raise colander.Invalid(node, "User not found")
|
raise colander.Invalid(node, "User not found")
|
||||||
return user.uuid
|
return user.uuid
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
################################################################################
|
################################################################################
|
||||||
#
|
#
|
||||||
# Rattail -- Retail Software Framework
|
# Rattail -- Retail Software Framework
|
||||||
# Copyright © 2010-2024 Lance Edgar
|
# Copyright © 2010-2022 Lance Edgar
|
||||||
#
|
#
|
||||||
# This file is part of Rattail.
|
# This file is part of Rattail.
|
||||||
#
|
#
|
||||||
|
@ -24,18 +24,19 @@
|
||||||
Forms Core
|
Forms Core
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import hashlib
|
from __future__ import unicode_literals, absolute_import
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import warnings
|
|
||||||
from collections import OrderedDict
|
|
||||||
|
|
||||||
|
import six
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from sqlalchemy import orm
|
from sqlalchemy import orm
|
||||||
from sqlalchemy.ext.associationproxy import AssociationProxy, ASSOCIATION_PROXY
|
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
|
from rattail.db.util import get_fieldnames
|
||||||
|
|
||||||
import colander
|
import colander
|
||||||
|
@ -47,14 +48,9 @@ from pyramid_deform import SessionFileUploadTempStore
|
||||||
from pyramid.renderers import render
|
from pyramid.renderers import render
|
||||||
from webhelpers2.html import tags, HTML
|
from webhelpers2.html import tags, HTML
|
||||||
|
|
||||||
from wuttaweb.util import FieldList, get_form_data, make_json_safe
|
from tailbone.util import raw_datetime
|
||||||
|
from . import types
|
||||||
from tailbone.db import Session
|
from .widgets import ReadonlyWidget, PlainDateWidget, JQueryDateWidget, JQueryTimeWidget
|
||||||
from tailbone.util import raw_datetime, render_markdown
|
|
||||||
from tailbone.forms import types
|
|
||||||
from tailbone.forms.widgets import (ReadonlyWidget, PlainDateWidget,
|
|
||||||
JQueryDateWidget, JQueryTimeWidget,
|
|
||||||
FileUploadWidget, MultiFileUploadWidget)
|
|
||||||
from tailbone.exceptions import TailboneJSONFieldError
|
from tailbone.exceptions import TailboneJSONFieldError
|
||||||
|
|
||||||
|
|
||||||
|
@ -226,7 +222,7 @@ class CustomSchemaNode(SQLAlchemySchemaNode):
|
||||||
if excludes:
|
if excludes:
|
||||||
overrides['excludes'] = 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):
|
def dictify(self, obj):
|
||||||
""" Return a dictified version of `obj` using schema information.
|
""" Return a dictified version of `obj` using schema information.
|
||||||
|
@ -235,7 +231,7 @@ class CustomSchemaNode(SQLAlchemySchemaNode):
|
||||||
This method was copied from upstream and modified to add automatic
|
This method was copied from upstream and modified to add automatic
|
||||||
handling of "association proxy" fields.
|
handling of "association proxy" fields.
|
||||||
"""
|
"""
|
||||||
dict_ = super().dictify(obj)
|
dict_ = super(CustomSchemaNode, self).dictify(obj)
|
||||||
for node in self:
|
for node in self:
|
||||||
|
|
||||||
name = node.name
|
name = node.name
|
||||||
|
@ -328,7 +324,7 @@ class Form(object):
|
||||||
"""
|
"""
|
||||||
Base class for all forms.
|
Base class for all forms.
|
||||||
"""
|
"""
|
||||||
save_label = "Submit"
|
save_label = "Save"
|
||||||
update_label = "Save"
|
update_label = "Save"
|
||||||
show_cancel = True
|
show_cancel = True
|
||||||
auto_disable = True
|
auto_disable = True
|
||||||
|
@ -339,12 +335,8 @@ class Form(object):
|
||||||
model_instance=None, model_class=None, appstruct=UNSPECIFIED, nodes={}, enums={}, labels={},
|
model_instance=None, model_class=None, appstruct=UNSPECIFIED, nodes={}, enums={}, labels={},
|
||||||
assume_local_times=False, renderers=None, renderer_kwargs={},
|
assume_local_times=False, renderers=None, renderer_kwargs={},
|
||||||
hidden={}, widgets={}, defaults={}, validators={}, required={}, helptext={}, focus_spec=None,
|
hidden={}, widgets={}, defaults={}, validators={}, required={}, helptext={}, focus_spec=None,
|
||||||
action_url=None, cancel_url=None,
|
action_url=None, cancel_url=None, use_buefy=None, component='tailbone-form',
|
||||||
vue_tagname=None,
|
vuejs_field_converters={},
|
||||||
vuejs_component_kwargs=None, vuejs_field_converters={}, json_data={}, included_templates={},
|
|
||||||
# TODO: ugh this is getting out hand!
|
|
||||||
can_edit_help=False, edit_help_url=None, route_prefix=None,
|
|
||||||
**kwargs
|
|
||||||
):
|
):
|
||||||
self.fields = None
|
self.fields = None
|
||||||
if fields is not None:
|
if fields is not None:
|
||||||
|
@ -352,7 +344,6 @@ class Form(object):
|
||||||
self.schema = schema
|
self.schema = schema
|
||||||
if self.fields is None and self.schema:
|
if self.fields is None and self.schema:
|
||||||
self.set_fields([f.name for f in self.schema])
|
self.set_fields([f.name for f in self.schema])
|
||||||
self.grouping = None
|
|
||||||
self.request = request
|
self.request = request
|
||||||
self.readonly = readonly
|
self.readonly = readonly
|
||||||
self.readonly_fields = set(readonly_fields or [])
|
self.readonly_fields = set(readonly_fields or [])
|
||||||
|
@ -378,82 +369,20 @@ class Form(object):
|
||||||
self.validators = validators or {}
|
self.validators = validators or {}
|
||||||
self.required = required or {}
|
self.required = required or {}
|
||||||
self.helptext = helptext or {}
|
self.helptext = helptext or {}
|
||||||
self.dynamic_helptext = {}
|
|
||||||
self.focus_spec = focus_spec
|
self.focus_spec = focus_spec
|
||||||
self.action_url = action_url
|
self.action_url = action_url
|
||||||
self.cancel_url = cancel_url
|
self.cancel_url = cancel_url
|
||||||
|
self.use_buefy = use_buefy
|
||||||
# vue_tagname
|
self.component = component
|
||||||
self.vue_tagname = vue_tagname
|
|
||||||
if not self.vue_tagname and kwargs.get('component'):
|
|
||||||
warnings.warn("component kwarg is deprecated for Form(); "
|
|
||||||
"please use vue_tagname param instead",
|
|
||||||
DeprecationWarning, stacklevel=2)
|
|
||||||
self.vue_tagname = kwargs['component']
|
|
||||||
if not self.vue_tagname:
|
|
||||||
self.vue_tagname = 'tailbone-form'
|
|
||||||
|
|
||||||
self.vuejs_component_kwargs = vuejs_component_kwargs or {}
|
|
||||||
self.vuejs_field_converters = vuejs_field_converters or {}
|
self.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):
|
def __iter__(self):
|
||||||
return iter(self.fields)
|
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
|
@property
|
||||||
def component_studly(self):
|
def component_studly(self):
|
||||||
"""
|
words = self.component.split('-')
|
||||||
DEPRECATED - use :attr:`vue_component` instead.
|
return ''.join([word.capitalize() for word in words])
|
||||||
"""
|
|
||||||
warnings.warn("Form.component_studly is deprecated; "
|
|
||||||
"please use vue_component instead",
|
|
||||||
DeprecationWarning, stacklevel=2)
|
|
||||||
return self.vue_component
|
|
||||||
|
|
||||||
def get_button_label_submit(self):
|
|
||||||
""" """
|
|
||||||
if hasattr(self, '_button_label_submit'):
|
|
||||||
return self._button_label_submit
|
|
||||||
|
|
||||||
label = getattr(self, 'submit_label', None)
|
|
||||||
if label:
|
|
||||||
return label
|
|
||||||
|
|
||||||
return self.save_label
|
|
||||||
|
|
||||||
def set_button_label_submit(self, value):
|
|
||||||
""" """
|
|
||||||
self._button_label_submit = value
|
|
||||||
|
|
||||||
# wutta compat
|
|
||||||
button_label_submit = property(get_button_label_submit,
|
|
||||||
set_button_label_submit)
|
|
||||||
|
|
||||||
def __contains__(self, item):
|
def __contains__(self, item):
|
||||||
return item in self.fields
|
return item in self.fields
|
||||||
|
@ -471,9 +400,6 @@ class Form(object):
|
||||||
return get_fieldnames(self.request.rattail_config, self.model_class,
|
return get_fieldnames(self.request.rattail_config, self.model_class,
|
||||||
columns=True, proxies=True, relations=True)
|
columns=True, proxies=True, relations=True)
|
||||||
|
|
||||||
def set_grouping(self, items):
|
|
||||||
self.grouping = OrderedDict(items)
|
|
||||||
|
|
||||||
def make_renderers(self):
|
def make_renderers(self):
|
||||||
"""
|
"""
|
||||||
Return a default set of field renderers, based on :attr:`model_class`.
|
Return a default set of field renderers, based on :attr:`model_class`.
|
||||||
|
@ -630,9 +556,7 @@ class Form(object):
|
||||||
self.schema[key].title = label
|
self.schema[key].title = label
|
||||||
|
|
||||||
def get_label(self, key):
|
def get_label(self, key):
|
||||||
config = self.request.rattail_config
|
return self.labels.get(key, prettify(key))
|
||||||
app = config.get_app()
|
|
||||||
return self.labels.get(key, app.make_title(key))
|
|
||||||
|
|
||||||
def set_readonly(self, key, readonly=True):
|
def set_readonly(self, key, readonly=True):
|
||||||
if readonly:
|
if readonly:
|
||||||
|
@ -649,23 +573,9 @@ class Form(object):
|
||||||
node = colander.SchemaNode(nodeinfo, **kwargs)
|
node = colander.SchemaNode(nodeinfo, **kwargs)
|
||||||
self.nodes[key] = node
|
self.nodes[key] = node
|
||||||
|
|
||||||
# must explicitly replace node, if we already have a schema
|
|
||||||
if self.schema:
|
|
||||||
self.schema[key] = node
|
|
||||||
|
|
||||||
def set_type(self, key, type_, **kwargs):
|
def set_type(self, key, type_, **kwargs):
|
||||||
|
|
||||||
if type_ == 'datetime':
|
if type_ == 'datetime':
|
||||||
self.set_renderer(key, self.render_datetime)
|
self.set_renderer(key, self.render_datetime)
|
||||||
|
|
||||||
elif type_ == 'datetime_falafel':
|
|
||||||
self.set_renderer(key, self.render_datetime)
|
|
||||||
self.set_node(key, types.FalafelDateTime(request=self.request))
|
|
||||||
if kwargs.get('helptext'):
|
|
||||||
app = self.request.rattail_config.get_app()
|
|
||||||
timezone = app.get_timezone()
|
|
||||||
self.set_helptext(key, f"NOTE: all times are local to {timezone}")
|
|
||||||
|
|
||||||
elif type_ == 'datetime_local':
|
elif type_ == 'datetime_local':
|
||||||
self.set_renderer(key, self.render_datetime_local)
|
self.set_renderer(key, self.render_datetime_local)
|
||||||
elif type_ == 'date_plain':
|
elif type_ == 'date_plain':
|
||||||
|
@ -674,14 +584,9 @@ class Form(object):
|
||||||
# TODO: is this safe / a good idea?
|
# TODO: is this safe / a good idea?
|
||||||
# self.set_node(key, colander.Date())
|
# self.set_node(key, colander.Date())
|
||||||
self.set_widget(key, JQueryDateWidget())
|
self.set_widget(key, JQueryDateWidget())
|
||||||
|
|
||||||
elif type_ == 'time_jquery':
|
elif type_ == 'time_jquery':
|
||||||
self.set_node(key, types.JQueryTime())
|
self.set_node(key, types.JQueryTime())
|
||||||
self.set_widget(key, JQueryTimeWidget())
|
self.set_widget(key, JQueryTimeWidget())
|
||||||
|
|
||||||
elif type_ == 'time_falafel':
|
|
||||||
self.set_node(key, types.FalafelTime(request=self.request))
|
|
||||||
|
|
||||||
elif type_ == 'duration':
|
elif type_ == 'duration':
|
||||||
self.set_renderer(key, self.render_duration)
|
self.set_renderer(key, self.render_duration)
|
||||||
elif type_ == 'boolean':
|
elif type_ == 'boolean':
|
||||||
|
@ -708,40 +613,17 @@ class Form(object):
|
||||||
self.set_widget(key, dfwidget.TextAreaWidget(cols=80, rows=8))
|
self.set_widget(key, dfwidget.TextAreaWidget(cols=80, rows=8))
|
||||||
elif type_ == 'file':
|
elif type_ == 'file':
|
||||||
tmpstore = SessionFileUploadTempStore(self.request)
|
tmpstore = SessionFileUploadTempStore(self.request)
|
||||||
kw = {'widget': FileUploadWidget(tmpstore, request=self.request),
|
kw = {'widget': dfwidget.FileUploadWidget(tmpstore),
|
||||||
'title': self.get_label(key)}
|
'title': self.get_label(key)}
|
||||||
if 'required' in kwargs and not kwargs['required']:
|
if 'required' in kwargs and not kwargs['required']:
|
||||||
kw['missing'] = colander.null
|
kw['missing'] = colander.null
|
||||||
self.set_node(key, colander.SchemaNode(deform.FileData(), **kw))
|
self.set_node(key, colander.SchemaNode(deform.FileData(), **kw))
|
||||||
elif type_ == 'multi_file':
|
# must explicitly replace node, if we already have a schema
|
||||||
tmpstore = SessionFileUploadTempStore(self.request)
|
if self.schema:
|
||||||
file_node = colander.SchemaNode(deform.FileData(),
|
self.schema[key] = self.nodes[key]
|
||||||
name='upload')
|
|
||||||
|
|
||||||
kw = {'name': key,
|
|
||||||
'title': self.get_label(key),
|
|
||||||
'widget': MultiFileUploadWidget(tmpstore)}
|
|
||||||
# if 'required' in kwargs and not kwargs['required']:
|
|
||||||
# kw['missing'] = colander.null
|
|
||||||
if kwargs.get('validate_unique'):
|
|
||||||
kw['validator'] = self.validate_multiple_files_unique
|
|
||||||
files_node = colander.SequenceSchema(file_node, **kw)
|
|
||||||
self.set_node(key, files_node)
|
|
||||||
else:
|
else:
|
||||||
raise ValueError("unknown type for '{}' field: {}".format(key, type_))
|
raise ValueError("unknown type for '{}' field: {}".format(key, type_))
|
||||||
|
|
||||||
def validate_multiple_files_unique(self, node, value):
|
|
||||||
|
|
||||||
# get SHA256 hash for each file; error if duplicates encountered
|
|
||||||
hashes = {}
|
|
||||||
for fileinfo in value:
|
|
||||||
fp = fileinfo['fp']
|
|
||||||
fp.seek(0)
|
|
||||||
filehash = hashlib.sha256(fp.read()).hexdigest()
|
|
||||||
if filehash in hashes:
|
|
||||||
node.raise_invalid(f"Duplicate file detected: {fileinfo['filename']}")
|
|
||||||
hashes[filehash] = fileinfo
|
|
||||||
|
|
||||||
def set_enum(self, key, enum, empty=None):
|
def set_enum(self, key, enum, empty=None):
|
||||||
if enum:
|
if enum:
|
||||||
self.enums[key] = enum
|
self.enums[key] = enum
|
||||||
|
@ -805,8 +687,9 @@ class Form(object):
|
||||||
case the validator pertains to the form at large instead of
|
case the validator pertains to the form at large instead of
|
||||||
one of the fields.
|
one of the fields.
|
||||||
|
|
||||||
:param validator: Callable which accepts ``(node, value)``
|
TODO: what should the validator look like?
|
||||||
args.
|
|
||||||
|
:param validator: Callable validator for the node.
|
||||||
"""
|
"""
|
||||||
self.validators[key] = validator
|
self.validators[key] = validator
|
||||||
|
|
||||||
|
@ -828,16 +711,11 @@ class Form(object):
|
||||||
"""
|
"""
|
||||||
self.defaults[key] = value
|
self.defaults[key] = value
|
||||||
|
|
||||||
def set_helptext(self, key, value, dynamic=False):
|
def set_helptext(self, key, value):
|
||||||
"""
|
"""
|
||||||
Set the help text for a given field.
|
Set the help text for a given field.
|
||||||
"""
|
"""
|
||||||
# nb. must avoid newlines, they cause some weird "blank page" error?!
|
self.helptext[key] = value
|
||||||
self.helptext[key] = value.replace('\n', ' ')
|
|
||||||
if value and dynamic:
|
|
||||||
self.dynamic_helptext[key] = True
|
|
||||||
else:
|
|
||||||
self.dynamic_helptext.pop(key, None)
|
|
||||||
|
|
||||||
def has_helptext(self, key):
|
def has_helptext(self, key):
|
||||||
"""
|
"""
|
||||||
|
@ -857,15 +735,15 @@ class Form(object):
|
||||||
def set_vuejs_field_converter(self, field, converter):
|
def set_vuejs_field_converter(self, field, converter):
|
||||||
self.vuejs_field_converters[field] = converter
|
self.vuejs_field_converters[field] = converter
|
||||||
|
|
||||||
def render(self, **kwargs):
|
def render(self, template=None, **kwargs):
|
||||||
warnings.warn("Form.render() is deprecated (for now?); "
|
if not template:
|
||||||
"please use Form.render_deform() instead",
|
if self.readonly and not self.use_buefy:
|
||||||
DeprecationWarning, stacklevel=2)
|
template = '/forms/form_readonly.mako'
|
||||||
return self.render_deform(**kwargs)
|
else:
|
||||||
|
template = '/forms/form.mako'
|
||||||
def get_deform(self):
|
context = kwargs
|
||||||
""" """
|
context['form'] = self
|
||||||
return self.make_deform_form()
|
return render(template, context)
|
||||||
|
|
||||||
def make_deform_form(self):
|
def make_deform_form(self):
|
||||||
if not hasattr(self, 'deform_form'):
|
if not hasattr(self, 'deform_form'):
|
||||||
|
@ -905,35 +783,31 @@ class Form(object):
|
||||||
|
|
||||||
return self.deform_form
|
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):
|
def render_deform(self, dform=None, template=None, **kwargs):
|
||||||
if not template:
|
if not template:
|
||||||
template = '/forms/deform.mako'
|
if self.use_buefy:
|
||||||
|
template = '/forms/deform_buefy.mako'
|
||||||
|
else:
|
||||||
|
template = '/forms/deform.mako'
|
||||||
|
|
||||||
if dform is None:
|
if dform is None:
|
||||||
dform = self.make_deform_form()
|
dform = self.make_deform_form()
|
||||||
|
|
||||||
# TODO: would perhaps be nice to leverage deform's default rendering
|
# TODO: would perhaps be nice to leverage deform's default rendering
|
||||||
# someday..? i.e. using Chameleon *.pt templates
|
# someday..? i.e. using Chameleon *.pt templates
|
||||||
# return dform.render()
|
# return form.render()
|
||||||
|
|
||||||
context = kwargs
|
context = kwargs
|
||||||
context['form'] = self
|
context['form'] = self
|
||||||
context['dform'] = dform
|
context['dform'] = dform
|
||||||
context.setdefault('can_edit_help', self.can_edit_help)
|
|
||||||
if context['can_edit_help']:
|
|
||||||
context.setdefault('edit_help_url', self.edit_help_url)
|
|
||||||
context['field_labels'] = self.get_field_labels()
|
|
||||||
context['field_markdowns'] = self.get_field_markdowns()
|
|
||||||
context.setdefault('form_kwargs', {})
|
context.setdefault('form_kwargs', {})
|
||||||
# TODO: deprecate / remove the latter option here
|
# TODO: deprecate / remove the latter option here
|
||||||
if self.auto_disable_save or self.auto_disable:
|
if self.auto_disable_save or self.auto_disable:
|
||||||
context['form_kwargs'].setdefault('ref', self.vue_component)
|
if self.use_buefy:
|
||||||
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)
|
||||||
|
else:
|
||||||
|
context['form_kwargs']['class_'] = 'autodisable'
|
||||||
if self.focus_spec:
|
if self.focus_spec:
|
||||||
context['form_kwargs']['data-focus'] = self.focus_spec
|
context['form_kwargs']['data-focus'] = self.focus_spec
|
||||||
context['request'] = self.request
|
context['request'] = self.request
|
||||||
|
@ -941,36 +815,6 @@ class Form(object):
|
||||||
context['render_field_readonly'] = self.render_field_readonly
|
context['render_field_readonly'] = self.render_field_readonly
|
||||||
return render(template, context)
|
return render(template, context)
|
||||||
|
|
||||||
def get_field_labels(self):
|
|
||||||
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()
|
|
||||||
|
|
||||||
if not hasattr(self, 'field_markdowns'):
|
|
||||||
infos = session.query(model.TailboneFieldInfo)\
|
|
||||||
.filter(model.TailboneFieldInfo.route_prefix == self.route_prefix)\
|
|
||||||
.all()
|
|
||||||
self.field_markdowns = dict([(info.field_name, info.markdown_text)
|
|
||||||
for info in infos])
|
|
||||||
|
|
||||||
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):
|
def get_vuejs_model_value(self, field):
|
||||||
"""
|
"""
|
||||||
This method must return "raw" JS which will be assigned as the initial
|
This method must return "raw" JS which will be assigned as the initial
|
||||||
|
@ -982,38 +826,25 @@ class Form(object):
|
||||||
value = convert(field.cstruct)
|
value = convert(field.cstruct)
|
||||||
return json.dumps(value)
|
return json.dumps(value)
|
||||||
|
|
||||||
|
if isinstance(field.schema.typ, deform.FileData):
|
||||||
|
# TODO: we used to always/only return 'null' here but hopefully
|
||||||
|
# this also works, to show existing filename when present
|
||||||
|
if field.cstruct and field.cstruct['filename']:
|
||||||
|
return json.dumps({'name': field.cstruct['filename']})
|
||||||
|
return 'null'
|
||||||
|
|
||||||
if isinstance(field.schema.typ, colander.Set):
|
if isinstance(field.schema.typ, colander.Set):
|
||||||
if field.cstruct is colander.null:
|
if field.cstruct is colander.null:
|
||||||
return '[]'
|
return '[]'
|
||||||
|
|
||||||
|
if field.cstruct is colander.null:
|
||||||
|
return 'null'
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return self.jsonify_value(field.cstruct)
|
return json.dumps(field.cstruct)
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
raise TailboneJSONFieldError(field.name, error)
|
raise TailboneJSONFieldError(field.name, error)
|
||||||
|
|
||||||
def jsonify_value(self, value):
|
|
||||||
"""
|
|
||||||
Take a Python value and convert to JSON
|
|
||||||
"""
|
|
||||||
if value is colander.null:
|
|
||||||
return 'null'
|
|
||||||
|
|
||||||
if isinstance(value, dfwidget.filedict):
|
|
||||||
# TODO: we used to always/only return 'null' here but hopefully
|
|
||||||
# this also works, to show existing filename when present
|
|
||||||
if value and value['filename']:
|
|
||||||
return json.dumps({'name': value['filename']})
|
|
||||||
return 'null'
|
|
||||||
|
|
||||||
elif isinstance(value, list) and all([isinstance(f, dfwidget.filedict)
|
|
||||||
for f in value]):
|
|
||||||
return json.dumps([{'name': f['filename']}
|
|
||||||
for f in value])
|
|
||||||
|
|
||||||
app = self.request.rattail_config.get_app()
|
|
||||||
value = app.json_friendly(value)
|
|
||||||
return json.dumps(value)
|
|
||||||
|
|
||||||
def get_error_messages(self, field):
|
def get_error_messages(self, field):
|
||||||
if field.error:
|
if field.error:
|
||||||
return field.error.messages()
|
return field.error.messages()
|
||||||
|
@ -1034,208 +865,68 @@ class Form(object):
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def set_vuejs_component_kwargs(self, **kwargs):
|
def render_buefy_field(self, fieldname, bfield_attrs={}):
|
||||||
self.vuejs_component_kwargs.update(kwargs)
|
|
||||||
|
|
||||||
def render_vue_tag(self, **kwargs):
|
|
||||||
""" """
|
|
||||||
return self.render_vuejs_component(**kwargs)
|
|
||||||
|
|
||||||
def render_vuejs_component(self, **kwargs):
|
|
||||||
"""
|
"""
|
||||||
Render the Vue.js component HTML for the form.
|
Render the given field in a Buefy-compatible way. Note that
|
||||||
|
this is meant to render *editable* fields, i.e. showing a
|
||||||
Most typically this is something like:
|
widget, unless the field input is hidden. In other words it's
|
||||||
|
not for "readonly" fields.
|
||||||
.. code-block:: html
|
|
||||||
|
|
||||||
<tailbone-form :configure-fields-help="configureFieldsHelp">
|
|
||||||
</tailbone-form>
|
|
||||||
"""
|
|
||||||
kw = dict(self.vuejs_component_kwargs)
|
|
||||||
kw.update(kwargs)
|
|
||||||
if self.can_edit_help:
|
|
||||||
kw.setdefault(':configure-fields-help', 'configureFieldsHelp')
|
|
||||||
return HTML.tag(self.vue_tagname, **kw)
|
|
||||||
|
|
||||||
def set_json_data(self, key, value):
|
|
||||||
"""
|
|
||||||
Establish a data value for use in client-side JS. This value
|
|
||||||
will be JSON-encoded and made available to the
|
|
||||||
`<tailbone-form>` component within the client page.
|
|
||||||
"""
|
|
||||||
self.json_data[key] = value
|
|
||||||
|
|
||||||
def include_template(self, template, context):
|
|
||||||
"""
|
|
||||||
Declare a JS template as required by the current form. This
|
|
||||||
template will then be included in the final page, so all
|
|
||||||
widgets behave correctly.
|
|
||||||
"""
|
|
||||||
self.included_templates[template] = context
|
|
||||||
|
|
||||||
def render_included_templates(self):
|
|
||||||
templates = []
|
|
||||||
for template, context in self.included_templates.items():
|
|
||||||
context = dict(context)
|
|
||||||
context['form'] = self
|
|
||||||
templates.append(HTML.literal(render(template, context)))
|
|
||||||
return HTML.literal('\n').join(templates)
|
|
||||||
|
|
||||||
def render_vue_field(self, fieldname, **kwargs):
|
|
||||||
""" """
|
|
||||||
return self.render_field_complete(fieldname, **kwargs)
|
|
||||||
|
|
||||||
def render_field_complete(self, fieldname, bfield_attrs={},
|
|
||||||
session=None):
|
|
||||||
"""
|
|
||||||
Render the given field completely, i.e. with ``<b-field>``
|
|
||||||
wrapper. Note that this is meant to render *editable* fields,
|
|
||||||
i.e. showing a widget, unless the field input is hidden. In
|
|
||||||
other words it's not for "readonly" fields.
|
|
||||||
"""
|
"""
|
||||||
dform = self.make_deform_form()
|
dform = self.make_deform_form()
|
||||||
field = dform[fieldname] if fieldname in dform else None
|
field = dform[fieldname]
|
||||||
|
|
||||||
include = bool(field)
|
|
||||||
if self.readonly or (not field and fieldname in self.readonly_fields):
|
|
||||||
include = True
|
|
||||||
if not include:
|
|
||||||
return
|
|
||||||
|
|
||||||
if self.field_visible(fieldname):
|
if self.field_visible(fieldname):
|
||||||
label = self.get_label(fieldname)
|
|
||||||
markdowns = self.get_field_markdowns(session=session)
|
|
||||||
|
|
||||||
# these attrs will be for the <b-field> (*not* the widget)
|
# these attrs will be for the <b-field> (*not* the widget)
|
||||||
attrs = {
|
attrs = {
|
||||||
':horizontal': 'true',
|
':horizontal': 'true',
|
||||||
|
'label': self.get_label(fieldname),
|
||||||
}
|
}
|
||||||
|
|
||||||
# add some magic for file input fields
|
# add some magic for file input fields
|
||||||
if field and isinstance(field.schema.typ, deform.FileData):
|
if isinstance(field.schema.typ, deform.FileData):
|
||||||
attrs['class_'] = 'file'
|
attrs['class_'] = 'file'
|
||||||
|
|
||||||
# next we will build array of messages to display..some
|
# show helptext if present
|
||||||
# fields always show a "helptext" msg, and some may have
|
if self.has_helptext(fieldname):
|
||||||
# validation errors..
|
attrs['message'] = self.render_helptext(fieldname)
|
||||||
field_type = None
|
|
||||||
messages = []
|
|
||||||
|
|
||||||
# show errors if present
|
# show errors if present
|
||||||
error_messages = self.get_error_messages(field) if field else None
|
error_messages = self.get_error_messages(field)
|
||||||
if error_messages:
|
if error_messages:
|
||||||
field_type = 'is-danger'
|
|
||||||
messages.extend(error_messages)
|
|
||||||
|
|
||||||
# show helptext if present
|
# TODO: this surely can't be what we ought to do
|
||||||
# TODO: older logic did this only if field was *not*
|
# here..? seems like we must pass JS but not JSON,
|
||||||
# readonly, perhaps should add that back..
|
# sort of, so we custom-write the JS code to ensure
|
||||||
if self.has_helptext(fieldname):
|
# single instead of double quotes delimit strings
|
||||||
messages.append(self.render_helptext(fieldname))
|
# within the code.
|
||||||
|
message = '[{}]'.format(', '.join([
|
||||||
|
"'{}'".format(msg.replace("'", r"\'"))
|
||||||
|
for msg in error_messages]))
|
||||||
|
|
||||||
# ..okay now we can declare the field messages and type
|
attrs.update({
|
||||||
if field_type:
|
'type': 'is-danger',
|
||||||
attrs['type'] = field_type
|
':message': message,
|
||||||
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]))
|
|
||||||
|
|
||||||
# merge anything caller provided
|
# merge anything caller provided
|
||||||
attrs.update(bfield_attrs)
|
attrs.update(bfield_attrs)
|
||||||
|
|
||||||
# render the field widget or whatever
|
# render the field widget or whatever
|
||||||
if self.readonly or fieldname in self.readonly_fields:
|
html = field.serialize(use_buefy=True,
|
||||||
html = self.render_field_value(fieldname) or HTML.tag('span')
|
**self.get_renderer_kwargs(fieldname))
|
||||||
if type(html) is str:
|
# TODO: why do we not get HTML literal from serialize() ?
|
||||||
html = HTML.tag('span', c=[html])
|
html = HTML.literal(html)
|
||||||
elif field:
|
|
||||||
html = field.serialize(**self.get_renderer_kwargs(fieldname))
|
|
||||||
html = HTML.literal(html)
|
|
||||||
|
|
||||||
# may need a complex label
|
|
||||||
label_contents = [label]
|
|
||||||
|
|
||||||
# add 'help' icon/tooltip if defined
|
|
||||||
if markdowns.get(fieldname):
|
|
||||||
icon = HTML.tag('b-icon', size='is-small', pack='fas',
|
|
||||||
icon='question-circle')
|
|
||||||
tooltip = render_markdown(markdowns[fieldname])
|
|
||||||
|
|
||||||
# nb. must apply hack to get <template #content> as final result
|
|
||||||
tooltip_template = HTML.tag('template', c=[tooltip],
|
|
||||||
**{'#content': 1})
|
|
||||||
tooltip_template = tooltip_template.replace(
|
|
||||||
HTML.literal('<template #content="1"'),
|
|
||||||
HTML.literal('<template #content'))
|
|
||||||
|
|
||||||
tooltip = HTML.tag('b-tooltip',
|
|
||||||
type='is-white',
|
|
||||||
size='is-large',
|
|
||||||
multilined='multilined',
|
|
||||||
c=[icon, tooltip_template])
|
|
||||||
label_contents.append(HTML.literal(' '))
|
|
||||||
label_contents.append(tooltip)
|
|
||||||
|
|
||||||
# add 'configure' icon if allowed
|
|
||||||
if self.can_edit_help:
|
|
||||||
icon = HTML.tag('b-icon', size='is-small', pack='fas',
|
|
||||||
icon='cog')
|
|
||||||
icon = HTML.tag('a', title="Configure field", c=[icon],
|
|
||||||
**{'@click.prevent': "configureFieldInit('{}')".format(fieldname),
|
|
||||||
'v-show': 'configureFieldsHelp'})
|
|
||||||
label_contents.append(HTML.literal(' '))
|
|
||||||
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
|
|
||||||
|
|
||||||
# and finally wrap it all in a <b-field>
|
# and finally wrap it all in a <b-field>
|
||||||
return HTML.tag('b-field', c=html, **attrs)
|
return HTML.tag('b-field', c=[html], **attrs)
|
||||||
|
|
||||||
elif field: # hidden field
|
else: # hidden field
|
||||||
|
|
||||||
# can just do normal thing for these
|
# can just do normal thing for these
|
||||||
# TODO: again, why does serialize() not return literal?
|
# TODO: again, why does serialize() not return literal?
|
||||||
return HTML.literal(field.serialize())
|
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):
|
def render_field_readonly(self, field_name, **kwargs):
|
||||||
"""
|
"""
|
||||||
Render the given field completely, but in read-only fashion.
|
Render the given field completely, but in read-only fashion.
|
||||||
|
@ -1246,30 +937,20 @@ class Form(object):
|
||||||
if field_name not in self.fields:
|
if field_name not in self.fields:
|
||||||
return ''
|
return ''
|
||||||
|
|
||||||
|
# TODO: fair bit of duplication here, should merge with deform.mako
|
||||||
label = kwargs.get('label')
|
label = kwargs.get('label')
|
||||||
if not label:
|
if not label:
|
||||||
label = self.get_label(field_name)
|
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:
|
return HTML.tag('div', class_='field-wrapper {}'.format(field_name), c=contents)
|
||||||
|
|
||||||
label = HTML.tag('label', label, for_=field_name)
|
|
||||||
field_div = HTML.tag('div', class_='field', c=[value])
|
|
||||||
contents = [label, field_div]
|
|
||||||
|
|
||||||
if self.has_helptext(field_name):
|
|
||||||
contents.append(HTML.tag('span', class_='instructions',
|
|
||||||
c=[self.render_helptext(field_name)]))
|
|
||||||
|
|
||||||
return HTML.tag('div', class_='field-wrapper {}'.format(field_name), c=contents)
|
|
||||||
|
|
||||||
# nb. for some reason we must wrap once more for oruga,
|
|
||||||
# otherwise it splits up the field?!
|
|
||||||
value = HTML.tag('span', c=[value])
|
|
||||||
|
|
||||||
# oruga uses <o-field>
|
|
||||||
return HTML.tag('o-field', label=label, c=[value], **{':horizontal': 'true'})
|
|
||||||
|
|
||||||
def render_field_value(self, field_name):
|
def render_field_value(self, field_name):
|
||||||
record = self.model_instance
|
record = self.model_instance
|
||||||
|
@ -1281,7 +962,7 @@ class Form(object):
|
||||||
value = self.obtain_value(record, field_name)
|
value = self.obtain_value(record, field_name)
|
||||||
if value is None:
|
if value is None:
|
||||||
return ""
|
return ""
|
||||||
return str(value)
|
return six.text_type(value)
|
||||||
|
|
||||||
def render_datetime(self, record, field_name):
|
def render_datetime(self, record, field_name):
|
||||||
value = self.obtain_value(record, field_name)
|
value = self.obtain_value(record, field_name)
|
||||||
|
@ -1293,8 +974,7 @@ class Form(object):
|
||||||
value = self.obtain_value(record, field_name)
|
value = self.obtain_value(record, field_name)
|
||||||
if value is None:
|
if value is None:
|
||||||
return ""
|
return ""
|
||||||
app = self.request.rattail_config.get_app()
|
value = localtime(self.request.rattail_config, value)
|
||||||
value = app.localtime(value)
|
|
||||||
return raw_datetime(self.request.rattail_config, value)
|
return raw_datetime(self.request.rattail_config, value)
|
||||||
|
|
||||||
def render_duration(self, record, field_name):
|
def render_duration(self, record, field_name):
|
||||||
|
@ -1317,14 +997,13 @@ class Form(object):
|
||||||
return "(${:0,.2f})".format(0 - value)
|
return "(${:0,.2f})".format(0 - value)
|
||||||
return "${:0,.2f}".format(value)
|
return "${:0,.2f}".format(value)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return str(value)
|
return six.text_type(value)
|
||||||
|
|
||||||
def render_quantity(self, obj, field):
|
def render_quantity(self, obj, field):
|
||||||
value = self.obtain_value(obj, field)
|
value = self.obtain_value(obj, field)
|
||||||
if value is None:
|
if value is None:
|
||||||
return ""
|
return ""
|
||||||
app = self.request.rattail_config.get_app()
|
return pretty_quantity(value)
|
||||||
return app.render_quantity(value)
|
|
||||||
|
|
||||||
def render_percent(self, obj, field):
|
def render_percent(self, obj, field):
|
||||||
app = self.request.rattail_config.get_app()
|
app = self.request.rattail_config.get_app()
|
||||||
|
@ -1343,8 +1022,8 @@ class Form(object):
|
||||||
return ""
|
return ""
|
||||||
enum = self.enums.get(field_name)
|
enum = self.enums.get(field_name)
|
||||||
if enum and value in enum:
|
if enum and value in enum:
|
||||||
return str(enum[value])
|
return six.text_type(enum[value])
|
||||||
return str(value)
|
return six.text_type(value)
|
||||||
|
|
||||||
def render_codeblock(self, record, field_name):
|
def render_codeblock(self, record, field_name):
|
||||||
value = self.obtain_value(record, field_name)
|
value = self.obtain_value(record, field_name)
|
||||||
|
@ -1375,70 +1054,88 @@ class Form(object):
|
||||||
|
|
||||||
def obtain_value(self, record, field_name):
|
def obtain_value(self, record, field_name):
|
||||||
if record:
|
if record:
|
||||||
|
|
||||||
if isinstance(record, dict):
|
|
||||||
return record[field_name]
|
|
||||||
|
|
||||||
try:
|
|
||||||
return getattr(record, field_name)
|
|
||||||
except AttributeError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return record[field_name]
|
return record[field_name]
|
||||||
except TypeError:
|
except TypeError:
|
||||||
pass
|
return getattr(record, field_name, None)
|
||||||
|
|
||||||
# TODO: is this always safe to do?
|
# TODO: is this always safe to do?
|
||||||
elif self.defaults and field_name in self.defaults:
|
elif self.defaults and field_name in self.defaults:
|
||||||
return self.defaults[field_name]
|
return self.defaults[field_name]
|
||||||
|
|
||||||
def validate(self, *args, **kwargs):
|
def validate(self, *args, **kwargs):
|
||||||
"""
|
if kwargs.pop('newstyle', False):
|
||||||
Try to validate the form.
|
# yay, new behavior!
|
||||||
|
if hasattr(self, 'validated'):
|
||||||
|
del self.validated
|
||||||
|
if self.request.method != 'POST':
|
||||||
|
return False
|
||||||
|
|
||||||
This should work whether data was submitted as classic POST
|
# use POST or JSON body, whichever is present
|
||||||
data, or as JSON body.
|
# TODO: per docs, some JS libraries may not set this flag?
|
||||||
|
# https://docs.pylonsproject.org/projects/pyramid/en/latest/api/request.html#pyramid.request.Request.is_xhr
|
||||||
|
if self.request.is_xhr and not self.request.POST:
|
||||||
|
controls = self.request.json_body.items()
|
||||||
|
|
||||||
:returns: ``True`` if form data is valid, otherwise ``False``.
|
# unfortunately the normal form logic (i.e. peppercorn) is
|
||||||
"""
|
# expecting all values to be strings, whereas the JSON body we
|
||||||
if 'newstyle' in kwargs:
|
# just parsed, may have given us some Pythonic objects. so
|
||||||
warnings.warn("the `newstyle` kwarg is no longer used "
|
# here we must convert them *back* to strings...
|
||||||
"for Form.validate()",
|
# TODO: this seems like a hack, i must be missing something
|
||||||
DeprecationWarning, stacklevel=2)
|
controls = [[key, val] for key, val in controls]
|
||||||
|
for i in range(len(controls)):
|
||||||
|
key, value = controls[i]
|
||||||
|
if value is None:
|
||||||
|
controls[i][1] = ''
|
||||||
|
elif value is True:
|
||||||
|
controls[i][1] = 'true'
|
||||||
|
elif value is False:
|
||||||
|
controls[i][1] = 'false'
|
||||||
|
elif not isinstance(value, six.string_types):
|
||||||
|
controls[i][1] = six.text_type(value)
|
||||||
|
|
||||||
if hasattr(self, 'validated'):
|
else:
|
||||||
del self.validated
|
controls = self.request.POST.items()
|
||||||
if self.request.method != 'POST':
|
|
||||||
return False
|
|
||||||
|
|
||||||
controls = get_form_data(self.request).items()
|
dform = self.make_deform_form()
|
||||||
|
try:
|
||||||
|
self.validated = dform.validate(controls)
|
||||||
|
return True
|
||||||
|
except deform.ValidationFailure:
|
||||||
|
return False
|
||||||
|
|
||||||
# unfortunately the normal form logic (i.e. peppercorn) is
|
else: # legacy behavior
|
||||||
# expecting all values to be strings, whereas if our data
|
raise_error = kwargs.pop('raise_error', True)
|
||||||
# came from JSON body, may have given us some Pythonic
|
dform = self.make_deform_form()
|
||||||
# objects. so here we must convert them *back* to strings
|
try:
|
||||||
# TODO: this seems like a hack, i must be missing something
|
return dform.validate(*args, **kwargs)
|
||||||
# TODO: also this uses same "JSON" check as get_form_data()
|
except deform.ValidationFailure:
|
||||||
if self.request.is_xhr and not self.request.POST:
|
if raise_error:
|
||||||
controls = [[key, val] for key, val in controls]
|
raise
|
||||||
for i in range(len(controls)):
|
|
||||||
key, value = controls[i]
|
|
||||||
if value is None:
|
|
||||||
controls[i][1] = ''
|
|
||||||
elif value is True:
|
|
||||||
controls[i][1] = 'true'
|
|
||||||
elif value is False:
|
|
||||||
controls[i][1] = 'false'
|
|
||||||
elif not isinstance(value, str):
|
|
||||||
controls[i][1] = str(value)
|
|
||||||
|
|
||||||
dform = self.make_deform_form()
|
|
||||||
try:
|
class FieldList(list):
|
||||||
self.validated = dform.validate(controls)
|
"""
|
||||||
return True
|
Convenience wrapper for a form's field list.
|
||||||
except deform.ValidationFailure:
|
"""
|
||||||
return False
|
|
||||||
|
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
|
@colander.deferred
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
################################################################################
|
################################################################################
|
||||||
#
|
#
|
||||||
# Rattail -- Retail Software Framework
|
# Rattail -- Retail Software Framework
|
||||||
# Copyright © 2010-2023 Lance Edgar
|
# Copyright © 2010-2019 Lance Edgar
|
||||||
#
|
#
|
||||||
# This file is part of Rattail.
|
# This file is part of Rattail.
|
||||||
#
|
#
|
||||||
|
@ -24,6 +24,8 @@
|
||||||
Forms for Receiving
|
Forms for Receiving
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from __future__ import unicode_literals, absolute_import
|
||||||
|
|
||||||
from rattail.db import model
|
from rattail.db import model
|
||||||
|
|
||||||
import colander
|
import colander
|
||||||
|
@ -33,7 +35,7 @@ import colander
|
||||||
def valid_purchase_batch_row(node, kw):
|
def valid_purchase_batch_row(node, kw):
|
||||||
session = kw['session']
|
session = kw['session']
|
||||||
def validate(node, value):
|
def validate(node, value):
|
||||||
row = session.get(model.PurchaseBatchRow, value)
|
row = session.query(model.PurchaseBatchRow).get(value)
|
||||||
if not row:
|
if not row:
|
||||||
raise colander.Invalid(node, "Batch row not found")
|
raise colander.Invalid(node, "Batch row not found")
|
||||||
if row.batch.executed:
|
if row.batch.executed:
|
||||||
|
@ -52,7 +54,6 @@ class ReceiveRow(colander.MappingSchema):
|
||||||
'received',
|
'received',
|
||||||
'damaged',
|
'damaged',
|
||||||
'expired',
|
'expired',
|
||||||
'missing',
|
|
||||||
# 'mispick',
|
# 'mispick',
|
||||||
]))
|
]))
|
||||||
|
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
################################################################################
|
################################################################################
|
||||||
#
|
#
|
||||||
# Rattail -- Retail Software Framework
|
# Rattail -- Retail Software Framework
|
||||||
# Copyright © 2010-2023 Lance Edgar
|
# Copyright © 2010-2019 Lance Edgar
|
||||||
#
|
#
|
||||||
# This file is part of Rattail.
|
# This file is part of Rattail.
|
||||||
#
|
#
|
||||||
|
@ -24,9 +24,12 @@
|
||||||
Form Schema Types
|
Form Schema Types
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from __future__ import unicode_literals, absolute_import
|
||||||
|
|
||||||
import re
|
import re
|
||||||
import datetime
|
import datetime
|
||||||
import json
|
|
||||||
|
import six
|
||||||
|
|
||||||
from rattail.db import model
|
from rattail.db import model
|
||||||
from rattail.gpc import GPC
|
from rattail.gpc import GPC
|
||||||
|
@ -34,7 +37,6 @@ from rattail.gpc import GPC
|
||||||
import colander
|
import colander
|
||||||
|
|
||||||
from tailbone.db import Session
|
from tailbone.db import Session
|
||||||
from tailbone.forms import widgets
|
|
||||||
|
|
||||||
|
|
||||||
class JQueryTime(colander.Time):
|
class JQueryTime(colander.Time):
|
||||||
|
@ -74,76 +76,6 @@ class DateTimeBoolean(colander.Boolean):
|
||||||
return datetime.datetime.utcnow()
|
return datetime.datetime.utcnow()
|
||||||
|
|
||||||
|
|
||||||
class FalafelDateTime(colander.DateTime):
|
|
||||||
"""
|
|
||||||
Custom schema node type for rattail UTC datetimes
|
|
||||||
"""
|
|
||||||
widget_maker = widgets.FalafelDateTimeWidget
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
request = kwargs.pop('request')
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
self.request = request
|
|
||||||
|
|
||||||
def serialize(self, node, appstruct):
|
|
||||||
if not appstruct:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
# cant use isinstance; dt subs date
|
|
||||||
if type(appstruct) is datetime.date:
|
|
||||||
appstruct = datetime.datetime.combine(appstruct, datetime.time())
|
|
||||||
|
|
||||||
if not isinstance(appstruct, datetime.datetime):
|
|
||||||
raise colander.Invalid(node, f'"{appstruct}" is not a datetime object')
|
|
||||||
|
|
||||||
if appstruct.tzinfo is None:
|
|
||||||
appstruct = appstruct.replace(tzinfo=self.default_tzinfo)
|
|
||||||
|
|
||||||
app = self.request.rattail_config.get_app()
|
|
||||||
dt = app.localtime(appstruct, from_utc=True)
|
|
||||||
|
|
||||||
return {
|
|
||||||
'date': str(dt.date()),
|
|
||||||
'time': str(dt.time()),
|
|
||||||
}
|
|
||||||
|
|
||||||
def deserialize(self, node, cstruct):
|
|
||||||
if not cstruct:
|
|
||||||
return colander.null
|
|
||||||
|
|
||||||
if not cstruct['date'] and not cstruct['time']:
|
|
||||||
return colander.null
|
|
||||||
|
|
||||||
try:
|
|
||||||
date = datetime.datetime.strptime(cstruct['date'], '%Y-%m-%d').date()
|
|
||||||
except:
|
|
||||||
node.raise_invalid("Missing or invalid date")
|
|
||||||
|
|
||||||
try:
|
|
||||||
time = datetime.datetime.strptime(cstruct['time'], '%H:%M:%S').time()
|
|
||||||
except:
|
|
||||||
node.raise_invalid("Missing or invalid time")
|
|
||||||
|
|
||||||
result = datetime.datetime.combine(date, time)
|
|
||||||
|
|
||||||
app = self.request.rattail_config.get_app()
|
|
||||||
result = app.localtime(result)
|
|
||||||
result = app.make_utc(result)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
class FalafelTime(colander.Time):
|
|
||||||
"""
|
|
||||||
Custom schema node type for simple time fields
|
|
||||||
"""
|
|
||||||
widget_maker = widgets.FalafelTimeWidget
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
request = kwargs.pop('request')
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
self.request = request
|
|
||||||
|
|
||||||
|
|
||||||
class GPCType(colander.SchemaType):
|
class GPCType(colander.SchemaType):
|
||||||
"""
|
"""
|
||||||
Schema type for product GPC data.
|
Schema type for product GPC data.
|
||||||
|
@ -152,7 +84,7 @@ class GPCType(colander.SchemaType):
|
||||||
def serialize(self, node, appstruct):
|
def serialize(self, node, appstruct):
|
||||||
if appstruct is colander.null:
|
if appstruct is colander.null:
|
||||||
return colander.null
|
return colander.null
|
||||||
return str(appstruct)
|
return six.text_type(appstruct)
|
||||||
|
|
||||||
def deserialize(self, node, cstruct):
|
def deserialize(self, node, cstruct):
|
||||||
if not cstruct:
|
if not cstruct:
|
||||||
|
@ -163,7 +95,7 @@ class GPCType(colander.SchemaType):
|
||||||
try:
|
try:
|
||||||
return GPC(digits)
|
return GPC(digits)
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
raise colander.Invalid(node, str(err))
|
raise colander.Invalid(node, six.text_type(err))
|
||||||
|
|
||||||
|
|
||||||
class ProductQuantity(colander.MappingSchema):
|
class ProductQuantity(colander.MappingSchema):
|
||||||
|
@ -201,12 +133,12 @@ class ModelType(colander.SchemaType):
|
||||||
def serialize(self, node, appstruct):
|
def serialize(self, node, appstruct):
|
||||||
if appstruct is colander.null:
|
if appstruct is colander.null:
|
||||||
return colander.null
|
return colander.null
|
||||||
return str(appstruct)
|
return six.text_type(appstruct)
|
||||||
|
|
||||||
def deserialize(self, node, cstruct):
|
def deserialize(self, node, cstruct):
|
||||||
if not cstruct:
|
if not cstruct:
|
||||||
return None
|
return None
|
||||||
obj = self.session.get(self.model_class, cstruct)
|
obj = self.session.query(self.model_class).get(cstruct)
|
||||||
if not obj:
|
if not obj:
|
||||||
raise colander.Invalid(node, "{} not found".format(self.model_title))
|
raise colander.Invalid(node, "{} not found".format(self.model_title))
|
||||||
return obj
|
return obj
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
################################################################################
|
################################################################################
|
||||||
#
|
#
|
||||||
# Rattail -- Retail Software Framework
|
# Rattail -- Retail Software Framework
|
||||||
# Copyright © 2010-2024 Lance Edgar
|
# Copyright © 2010-2022 Lance Edgar
|
||||||
#
|
#
|
||||||
# This file is part of Rattail.
|
# This file is part of Rattail.
|
||||||
#
|
#
|
||||||
|
@ -24,16 +24,20 @@
|
||||||
Form Widgets
|
Form Widgets
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from __future__ import unicode_literals, absolute_import, division
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import datetime
|
import datetime
|
||||||
import decimal
|
import decimal
|
||||||
import re
|
|
||||||
|
import six
|
||||||
|
|
||||||
import colander
|
import colander
|
||||||
from deform import widget as dfwidget
|
from deform import widget as dfwidget
|
||||||
from webhelpers2.html import tags, HTML
|
from webhelpers2.html import tags, HTML
|
||||||
|
|
||||||
from tailbone.db import Session
|
from tailbone.db import Session
|
||||||
|
from tailbone.forms.types import ProductQuantity
|
||||||
|
|
||||||
|
|
||||||
class ReadonlyWidget(dfwidget.HiddenWidget):
|
class ReadonlyWidget(dfwidget.HiddenWidget):
|
||||||
|
@ -41,7 +45,6 @@ class ReadonlyWidget(dfwidget.HiddenWidget):
|
||||||
readonly = True
|
readonly = True
|
||||||
|
|
||||||
def serialize(self, field, cstruct, **kw):
|
def serialize(self, field, cstruct, **kw):
|
||||||
""" """
|
|
||||||
if cstruct in (colander.null, None):
|
if cstruct in (colander.null, None):
|
||||||
cstruct = ''
|
cstruct = ''
|
||||||
# TODO: is this hacky?
|
# TODO: is this hacky?
|
||||||
|
@ -58,11 +61,11 @@ class NumberInputWidget(dfwidget.TextInputWidget):
|
||||||
|
|
||||||
class NumericInputWidget(NumberInputWidget):
|
class NumericInputWidget(NumberInputWidget):
|
||||||
"""
|
"""
|
||||||
This widget uses a ``<numeric-input>`` component, which will
|
This widget only supports Buefy themes for now. It uses a
|
||||||
leverage the ``numeric.js`` functions to ensure user doesn't enter
|
``<numeric-input>`` component, which will leverage the ``numeric.js``
|
||||||
any non-numeric values. Note that this still uses a normal "text"
|
functions to ensure user doesn't enter any non-numeric values. Note that
|
||||||
input on the HTML side, as opposed to a "number" input, since the
|
this still uses a normal "text" input on the HTML side, as opposed to a
|
||||||
latter is a bit ugly IMHO.
|
"number" input, since the latter is a bit ugly IMHO.
|
||||||
"""
|
"""
|
||||||
template = 'numericinput'
|
template = 'numericinput'
|
||||||
allow_enter = True
|
allow_enter = True
|
||||||
|
@ -79,17 +82,15 @@ class PercentInputWidget(dfwidget.TextInputWidget):
|
||||||
autocomplete = 'off'
|
autocomplete = 'off'
|
||||||
|
|
||||||
def serialize(self, field, cstruct, **kw):
|
def serialize(self, field, cstruct, **kw):
|
||||||
""" """
|
|
||||||
if cstruct not in (colander.null, None):
|
if cstruct not in (colander.null, None):
|
||||||
# convert "traditional" value to "human-friendly"
|
# convert "traditional" value to "human-friendly"
|
||||||
value = decimal.Decimal(cstruct) * 100
|
value = decimal.Decimal(cstruct) * 100
|
||||||
value = value.quantize(decimal.Decimal('0.001'))
|
value = value.quantize(decimal.Decimal('0.001'))
|
||||||
cstruct = str(value)
|
cstruct = six.text_type(value)
|
||||||
return super().serialize(field, cstruct, **kw)
|
return super(PercentInputWidget, self).serialize(field, cstruct, **kw)
|
||||||
|
|
||||||
def deserialize(self, field, pstruct):
|
def deserialize(self, field, pstruct):
|
||||||
""" """
|
pstruct = super(PercentInputWidget, self).deserialize(field, pstruct)
|
||||||
pstruct = super().deserialize(field, pstruct)
|
|
||||||
if pstruct is colander.null:
|
if pstruct is colander.null:
|
||||||
return colander.null
|
return colander.null
|
||||||
# convert "human-friendly" value to "traditional"
|
# convert "human-friendly" value to "traditional"
|
||||||
|
@ -99,7 +100,7 @@ class PercentInputWidget(dfwidget.TextInputWidget):
|
||||||
raise colander.Invalid(field.schema, "Invalid decimal string: {}".format(pstruct))
|
raise colander.Invalid(field.schema, "Invalid decimal string: {}".format(pstruct))
|
||||||
value = value.quantize(decimal.Decimal('0.00001'))
|
value = value.quantize(decimal.Decimal('0.00001'))
|
||||||
value /= 100
|
value /= 100
|
||||||
return str(value)
|
return six.text_type(value)
|
||||||
|
|
||||||
|
|
||||||
class CasesUnitsWidget(dfwidget.Widget):
|
class CasesUnitsWidget(dfwidget.Widget):
|
||||||
|
@ -112,7 +113,6 @@ class CasesUnitsWidget(dfwidget.Widget):
|
||||||
one_amount_only = False
|
one_amount_only = False
|
||||||
|
|
||||||
def serialize(self, field, cstruct, **kw):
|
def serialize(self, field, cstruct, **kw):
|
||||||
""" """
|
|
||||||
if cstruct in (colander.null, None):
|
if cstruct in (colander.null, None):
|
||||||
cstruct = ''
|
cstruct = ''
|
||||||
readonly = kw.get('readonly', self.readonly)
|
readonly = kw.get('readonly', self.readonly)
|
||||||
|
@ -123,9 +123,6 @@ class CasesUnitsWidget(dfwidget.Widget):
|
||||||
return field.renderer(template, **values)
|
return field.renderer(template, **values)
|
||||||
|
|
||||||
def deserialize(self, field, pstruct):
|
def deserialize(self, field, pstruct):
|
||||||
""" """
|
|
||||||
from tailbone.forms.types import ProductQuantity
|
|
||||||
|
|
||||||
if pstruct is colander.null:
|
if pstruct is colander.null:
|
||||||
return colander.null
|
return colander.null
|
||||||
|
|
||||||
|
@ -154,7 +151,6 @@ class DynamicCheckboxWidget(dfwidget.CheckboxWidget):
|
||||||
template = 'checkbox_dynamic'
|
template = 'checkbox_dynamic'
|
||||||
|
|
||||||
|
|
||||||
# TODO: deprecate / remove this
|
|
||||||
class PlainSelectWidget(dfwidget.SelectWidget):
|
class PlainSelectWidget(dfwidget.SelectWidget):
|
||||||
template = 'select_plain'
|
template = 'select_plain'
|
||||||
|
|
||||||
|
@ -173,7 +169,7 @@ class CustomSelectWidget(dfwidget.SelectWidget):
|
||||||
self.extra_template_values.update(kw)
|
self.extra_template_values.update(kw)
|
||||||
|
|
||||||
def get_template_values(self, field, cstruct, 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'):
|
if hasattr(self, 'extra_template_values'):
|
||||||
values.update(self.extra_template_values)
|
values.update(self.extra_template_values)
|
||||||
return values
|
return values
|
||||||
|
@ -216,7 +212,6 @@ class JQueryDateWidget(dfwidget.DateInputWidget):
|
||||||
)
|
)
|
||||||
|
|
||||||
def serialize(self, field, cstruct, **kw):
|
def serialize(self, field, cstruct, **kw):
|
||||||
""" """
|
|
||||||
if cstruct in (colander.null, None):
|
if cstruct in (colander.null, None):
|
||||||
cstruct = ''
|
cstruct = ''
|
||||||
readonly = kw.get('readonly', self.readonly)
|
readonly = kw.get('readonly', self.readonly)
|
||||||
|
@ -244,48 +239,6 @@ class JQueryTimeWidget(dfwidget.TimeInputWidget):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class FalafelDateTimeWidget(dfwidget.DateTimeInputWidget):
|
|
||||||
"""
|
|
||||||
Custom widget for rattail UTC datetimes
|
|
||||||
"""
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
class FalafelTimeWidget(dfwidget.TimeInputWidget):
|
|
||||||
"""
|
|
||||||
Custom widget for simple time fields
|
|
||||||
"""
|
|
||||||
template = 'time_falafel'
|
|
||||||
|
|
||||||
def deserialize(self, field, pstruct):
|
|
||||||
""" """
|
|
||||||
if pstruct == '':
|
|
||||||
return colander.null
|
|
||||||
return pstruct
|
|
||||||
|
|
||||||
|
|
||||||
class JQueryAutocompleteWidget(dfwidget.AutocompleteInputWidget):
|
class JQueryAutocompleteWidget(dfwidget.AutocompleteInputWidget):
|
||||||
"""
|
"""
|
||||||
Uses the jQuery autocomplete plugin, instead of whatever it is deform uses
|
Uses the jQuery autocomplete plugin, instead of whatever it is deform uses
|
||||||
|
@ -308,7 +261,6 @@ class JQueryAutocompleteWidget(dfwidget.AutocompleteInputWidget):
|
||||||
options = None
|
options = None
|
||||||
|
|
||||||
def serialize(self, field, cstruct, **kw):
|
def serialize(self, field, cstruct, **kw):
|
||||||
""" """
|
|
||||||
if 'delay' in kw or getattr(self, 'delay', None):
|
if 'delay' in kw or getattr(self, 'delay', None):
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
'AutocompleteWidget does not support *delay* parameter '
|
'AutocompleteWidget does not support *delay* parameter '
|
||||||
|
@ -337,114 +289,6 @@ class JQueryAutocompleteWidget(dfwidget.AutocompleteInputWidget):
|
||||||
return field.renderer(template, **tmpl_values)
|
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.
|
|
||||||
"""
|
|
||||||
template = 'multi_file_upload'
|
|
||||||
requirements = ()
|
|
||||||
|
|
||||||
def serialize(self, field, cstruct, **kw):
|
|
||||||
""" """
|
|
||||||
if cstruct in (colander.null, None):
|
|
||||||
cstruct = []
|
|
||||||
|
|
||||||
if cstruct:
|
|
||||||
for fileinfo in cstruct:
|
|
||||||
uid = fileinfo['uid']
|
|
||||||
if uid not in self.tmpstore:
|
|
||||||
self.tmpstore[uid] = fileinfo
|
|
||||||
|
|
||||||
readonly = kw.get("readonly", self.readonly)
|
|
||||||
template = readonly and self.readonly_template or self.template
|
|
||||||
values = self.get_template_values(field, cstruct, kw)
|
|
||||||
return field.renderer(template, **values)
|
|
||||||
|
|
||||||
def deserialize(self, field, pstruct):
|
|
||||||
""" """
|
|
||||||
if pstruct is colander.null:
|
|
||||||
return colander.null
|
|
||||||
|
|
||||||
# TODO: why is this a thing? pstruct == [b'']
|
|
||||||
if len(pstruct) == 1 and pstruct[0] == b'':
|
|
||||||
return colander.null
|
|
||||||
|
|
||||||
files_data = []
|
|
||||||
for upload in pstruct:
|
|
||||||
|
|
||||||
data = self.deserialize_upload(upload)
|
|
||||||
if data:
|
|
||||||
files_data.append(data)
|
|
||||||
|
|
||||||
if not files_data:
|
|
||||||
return colander.null
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
uid = None # TODO?
|
|
||||||
|
|
||||||
if hasattr(upload, "file"):
|
|
||||||
# the upload control had a file selected
|
|
||||||
data = dfwidget.filedict()
|
|
||||||
data["fp"] = upload.file
|
|
||||||
filename = upload.filename
|
|
||||||
# sanitize IE whole-path filenames
|
|
||||||
filename = filename[filename.rfind("\\") + 1 :].strip()
|
|
||||||
data["filename"] = filename
|
|
||||||
data["mimetype"] = upload.type
|
|
||||||
data["size"] = upload.length
|
|
||||||
if uid is None:
|
|
||||||
# no previous file exists
|
|
||||||
while 1:
|
|
||||||
uid = self.random_id()
|
|
||||||
if self.tmpstore.get(uid) is None:
|
|
||||||
data["uid"] = uid
|
|
||||||
self.tmpstore[uid] = data
|
|
||||||
preview_url = self.tmpstore.preview_url(uid)
|
|
||||||
self.tmpstore[uid]["preview_url"] = preview_url
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
# a previous file exists
|
|
||||||
data["uid"] = uid
|
|
||||||
self.tmpstore[uid] = data
|
|
||||||
preview_url = self.tmpstore.preview_url(uid)
|
|
||||||
self.tmpstore[uid]["preview_url"] = preview_url
|
|
||||||
else:
|
|
||||||
# the upload control had no file selected
|
|
||||||
if uid is None:
|
|
||||||
# no previous file exists
|
|
||||||
return colander.null
|
|
||||||
else:
|
|
||||||
# a previous file should exist
|
|
||||||
data = self.tmpstore.get(uid)
|
|
||||||
# but if it doesn't, don't blow up
|
|
||||||
if data is None:
|
|
||||||
return colander.null
|
|
||||||
return data
|
|
||||||
|
|
||||||
|
|
||||||
def make_customer_widget(request, **kwargs):
|
def make_customer_widget(request, **kwargs):
|
||||||
"""
|
"""
|
||||||
Make a customer widget; will be either autocomplete or dropdown
|
Make a customer widget; will be either autocomplete or dropdown
|
||||||
|
@ -469,16 +313,13 @@ def make_customer_widget(request, **kwargs):
|
||||||
|
|
||||||
class CustomerAutocompleteWidget(JQueryAutocompleteWidget):
|
class CustomerAutocompleteWidget(JQueryAutocompleteWidget):
|
||||||
"""
|
"""
|
||||||
Autocomplete widget for a
|
Autocomplete widget for a Customer reference field.
|
||||||
:class:`~rattail:rattail.db.model.customers.Customer` reference
|
|
||||||
field.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, request, *args, **kwargs):
|
def __init__(self, request, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super(CustomerAutocompleteWidget, self).__init__(*args, **kwargs)
|
||||||
self.request = request
|
self.request = request
|
||||||
app = self.request.rattail_config.get_app()
|
model = self.request.rattail_config.get_model()
|
||||||
model = app.model
|
|
||||||
|
|
||||||
# must figure out URL providing autocomplete service
|
# must figure out URL providing autocomplete service
|
||||||
if 'service_url' not in kwargs:
|
if 'service_url' not in kwargs:
|
||||||
|
@ -496,30 +337,26 @@ class CustomerAutocompleteWidget(JQueryAutocompleteWidget):
|
||||||
self.input_callback = input_handler
|
self.input_callback = input_handler
|
||||||
|
|
||||||
def serialize(self, field, cstruct, **kw):
|
def serialize(self, field, cstruct, **kw):
|
||||||
""" """
|
|
||||||
# fetch customer to provide button label, if we have a value
|
# fetch customer to provide button label, if we have a value
|
||||||
if cstruct:
|
if cstruct:
|
||||||
app = self.request.rattail_config.get_app()
|
model = self.request.rattail_config.get_model()
|
||||||
model = app.model
|
customer = Session.query(model.Customer).get(cstruct)
|
||||||
customer = Session.get(model.Customer, cstruct)
|
|
||||||
if customer:
|
if customer:
|
||||||
self.field_display = str(customer)
|
self.field_display = six.text_type(customer)
|
||||||
|
|
||||||
return super().serialize(
|
return super(CustomerAutocompleteWidget, self).serialize(
|
||||||
field, cstruct, **kw)
|
field, cstruct, **kw)
|
||||||
|
|
||||||
|
|
||||||
class CustomerDropdownWidget(dfwidget.SelectWidget):
|
class CustomerDropdownWidget(dfwidget.SelectWidget):
|
||||||
"""
|
"""
|
||||||
Dropdown widget for a
|
Dropdown widget for a Customer reference field.
|
||||||
:class:`~rattail:rattail.db.model.customers.Customer` reference
|
|
||||||
field.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, request, *args, **kwargs):
|
def __init__(self, request, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super(CustomerDropdownWidget, self).__init__(*args, **kwargs)
|
||||||
self.request = request
|
self.request = request
|
||||||
app = self.request.rattail_config.get_app()
|
|
||||||
|
|
||||||
# must figure out dropdown values, if they weren't given
|
# must figure out dropdown values, if they weren't given
|
||||||
if 'values' not in kwargs:
|
if 'values' not in kwargs:
|
||||||
|
@ -531,8 +368,10 @@ class CustomerDropdownWidget(dfwidget.SelectWidget):
|
||||||
customers = customers()
|
customers = customers()
|
||||||
|
|
||||||
else: # default customer list
|
else: # default customer list
|
||||||
customers = app.get_clientele_handler()\
|
model = self.request.rattail_config.get_model()
|
||||||
.get_all_customers(Session())
|
customers = Session.query(model.Customer)\
|
||||||
|
.order_by(model.Customer.name)\
|
||||||
|
.all()
|
||||||
|
|
||||||
# convert customer list to option values
|
# convert customer list to option values
|
||||||
self.values = [(c.uuid, c.name)
|
self.values = [(c.uuid, c.name)
|
||||||
|
@ -554,106 +393,13 @@ class DepartmentWidget(dfwidget.SelectWidget):
|
||||||
def __init__(self, request, **kwargs):
|
def __init__(self, request, **kwargs):
|
||||||
|
|
||||||
if 'values' not in kwargs:
|
if 'values' not in kwargs:
|
||||||
app = request.rattail_config.get_app()
|
model = request.rattail_config.get_model()
|
||||||
model = app.model
|
|
||||||
departments = Session.query(model.Department)\
|
departments = Session.query(model.Department)\
|
||||||
.order_by(model.Department.number)
|
.order_by(model.Department.number)
|
||||||
values = [(dept.uuid, str(dept))
|
values = [(dept.uuid, six.text_type(dept))
|
||||||
for dept in departments]
|
for dept in departments]
|
||||||
if not kwargs.pop('required', True):
|
if not kwargs.pop('required', True):
|
||||||
values.insert(0, ('', "(none)"))
|
values.insert(0, ('', "(none)"))
|
||||||
kwargs['values'] = values
|
kwargs['values'] = values
|
||||||
|
|
||||||
super().__init__(**kwargs)
|
super(DepartmentWidget, self).__init__(**kwargs)
|
||||||
|
|
||||||
|
|
||||||
def make_vendor_widget(request, **kwargs):
|
|
||||||
"""
|
|
||||||
Make a vendor widget; will be either autocomplete or dropdown
|
|
||||||
depending on config.
|
|
||||||
"""
|
|
||||||
# use autocomplete widget by default
|
|
||||||
factory = VendorAutocompleteWidget
|
|
||||||
|
|
||||||
# caller may request dropdown widget
|
|
||||||
if kwargs.pop('dropdown', False):
|
|
||||||
factory = VendorDropdownWidget
|
|
||||||
|
|
||||||
else: # or, config may say to use dropdown
|
|
||||||
app = request.rattail_config.get_app()
|
|
||||||
vendor_handler = app.get_vendor_handler()
|
|
||||||
if vendor_handler.choice_uses_dropdown():
|
|
||||||
factory = VendorDropdownWidget
|
|
||||||
|
|
||||||
# instantiate whichever
|
|
||||||
return factory(request, **kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
class VendorAutocompleteWidget(JQueryAutocompleteWidget):
|
|
||||||
"""
|
|
||||||
Autocomplete widget for a Vendor reference field.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, request, *args, **kwargs):
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
self.request = request
|
|
||||||
app = self.request.rattail_config.get_app()
|
|
||||||
model = app.model
|
|
||||||
|
|
||||||
# must figure out URL providing autocomplete service
|
|
||||||
if 'service_url' not in kwargs:
|
|
||||||
|
|
||||||
# caller can just pass 'url' instead of 'service_url'
|
|
||||||
if 'url' in kwargs:
|
|
||||||
self.service_url = kwargs['url']
|
|
||||||
|
|
||||||
else: # use default url
|
|
||||||
self.service_url = self.request.route_url('vendors.autocomplete')
|
|
||||||
|
|
||||||
# # TODO
|
|
||||||
# if 'input_callback' not in kwargs:
|
|
||||||
# if 'input_handler' in kwargs:
|
|
||||||
# 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
|
|
||||||
vendor = Session.get(model.Vendor, cstruct)
|
|
||||||
if vendor:
|
|
||||||
self.field_display = str(vendor)
|
|
||||||
|
|
||||||
return super().serialize(
|
|
||||||
field, cstruct, **kw)
|
|
||||||
|
|
||||||
|
|
||||||
class VendorDropdownWidget(dfwidget.SelectWidget):
|
|
||||||
"""
|
|
||||||
Dropdown widget for a Vendor reference field.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, request, *args, **kwargs):
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
self.request = request
|
|
||||||
|
|
||||||
# must figure out dropdown values, if they weren't given
|
|
||||||
if 'values' not in kwargs:
|
|
||||||
|
|
||||||
# use what caller gave us, if they did
|
|
||||||
if 'vendors' in kwargs:
|
|
||||||
vendors = kwargs['vendors']
|
|
||||||
if callable(vendors):
|
|
||||||
vendors = vendors()
|
|
||||||
|
|
||||||
else: # default vendor list
|
|
||||||
app = self.request.rattail_config.get_app()
|
|
||||||
model = app.model
|
|
||||||
vendors = Session.query(model.Vendor)\
|
|
||||||
.order_by(model.Vendor.name)\
|
|
||||||
.all()
|
|
||||||
|
|
||||||
# convert vendor list to option values
|
|
||||||
self.values = [(c.uuid, c.name)
|
|
||||||
for c in vendors]
|
|
||||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -2,7 +2,7 @@
|
||||||
################################################################################
|
################################################################################
|
||||||
#
|
#
|
||||||
# Rattail -- Retail Software Framework
|
# Rattail -- Retail Software Framework
|
||||||
# Copyright © 2010-2024 Lance Edgar
|
# Copyright © 2010-2022 Lance Edgar
|
||||||
#
|
#
|
||||||
# This file is part of Rattail.
|
# This file is part of Rattail.
|
||||||
#
|
#
|
||||||
|
@ -24,15 +24,17 @@
|
||||||
Grid Filters
|
Grid Filters
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from __future__ import unicode_literals, absolute_import
|
||||||
|
|
||||||
import re
|
import re
|
||||||
import datetime
|
import datetime
|
||||||
import decimal
|
|
||||||
import logging
|
import logging
|
||||||
from collections import OrderedDict
|
|
||||||
|
|
||||||
|
import six
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
|
||||||
from rattail.gpc import GPC
|
from rattail.gpc import GPC
|
||||||
|
from rattail.util import OrderedDict
|
||||||
from rattail.core import UNSPECIFIED
|
from rattail.core import UNSPECIFIED
|
||||||
from rattail.time import localtime, make_utc
|
from rattail.time import localtime, make_utc
|
||||||
from rattail.util import prettify
|
from rattail.util import prettify
|
||||||
|
@ -115,7 +117,7 @@ class EnumValueRenderer(ChoiceValueRenderer):
|
||||||
sorted_keys = list(enum.keys())
|
sorted_keys = list(enum.keys())
|
||||||
else:
|
else:
|
||||||
sorted_keys = sorted(enum, key=lambda k: enum[k].lower())
|
sorted_keys = sorted(enum, key=lambda k: enum[k].lower())
|
||||||
self.options = [tags.Option(enum[k], str(k)) for k in sorted_keys]
|
self.options = [tags.Option(enum[k], six.text_type(k)) for k in sorted_keys]
|
||||||
|
|
||||||
|
|
||||||
class GridFilter(object):
|
class GridFilter(object):
|
||||||
|
@ -171,25 +173,18 @@ class GridFilter(object):
|
||||||
data_type = 'string' # default, but will be set from value renderer
|
data_type = 'string' # default, but will be set from value renderer
|
||||||
choices = {}
|
choices = {}
|
||||||
|
|
||||||
def __init__(self, key, config=None, label=None, verbs=None,
|
def __init__(self, key, label=None, verbs=None, value_enum=None, value_renderer=None,
|
||||||
value_enum=None, value_renderer=None,
|
|
||||||
default_active=False, default_verb=None, default_value=None,
|
default_active=False, default_verb=None, default_value=None,
|
||||||
encode_values=False, value_encoding='utf-8', **kwargs):
|
encode_values=False, value_encoding='utf-8', **kwargs):
|
||||||
self.key = key
|
self.key = key
|
||||||
self.config = config
|
|
||||||
self.label = label or prettify(key)
|
self.label = label or prettify(key)
|
||||||
|
self.verbs = verbs or self.get_default_verbs()
|
||||||
if value_renderer:
|
if value_renderer:
|
||||||
self.set_value_renderer(value_renderer)
|
self.set_value_renderer(value_renderer)
|
||||||
elif value_enum:
|
elif value_enum:
|
||||||
self.set_choices(value_enum)
|
self.set_choices(value_enum)
|
||||||
else:
|
else:
|
||||||
self.set_value_renderer(self.value_renderer_factory)
|
self.set_value_renderer(self.value_renderer_factory)
|
||||||
|
|
||||||
# nb. do this after setting choices, if applicable, since that
|
|
||||||
# could change default verbs
|
|
||||||
self.verbs = verbs or self.get_default_verbs()
|
|
||||||
|
|
||||||
self.default_active = default_active
|
self.default_active = default_active
|
||||||
self.default_verb = default_verb
|
self.default_verb = default_verb
|
||||||
self.default_value = default_value
|
self.default_value = default_value
|
||||||
|
@ -277,15 +272,14 @@ class GridFilter(object):
|
||||||
value = self.get_value(value)
|
value = self.get_value(value)
|
||||||
filtr = getattr(self, 'filter_{0}'.format(verb), None)
|
filtr = getattr(self, 'filter_{0}'.format(verb), None)
|
||||||
if not filtr:
|
if not filtr:
|
||||||
log.warning("unknown filter verb: %s", verb)
|
raise ValueError("Unknown filter verb: {0}".format(repr(verb)))
|
||||||
return data
|
|
||||||
return filtr(data, value)
|
return filtr(data, value)
|
||||||
|
|
||||||
def get_value(self, value=UNSPECIFIED):
|
def get_value(self, value=UNSPECIFIED):
|
||||||
return value if value is not UNSPECIFIED else self.value
|
return value if value is not UNSPECIFIED else self.value
|
||||||
|
|
||||||
def encode_value(self, value):
|
def encode_value(self, value):
|
||||||
if self.encode_values and isinstance(value, str):
|
if self.encode_values and isinstance(value, six.string_types):
|
||||||
return value.encode('utf-8')
|
return value.encode('utf-8')
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
@ -314,7 +308,7 @@ class AlchemyGridFilter(GridFilter):
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
self.column = kwargs.pop('column')
|
self.column = kwargs.pop('column')
|
||||||
super().__init__(*args, **kwargs)
|
super(AlchemyGridFilter, self).__init__(*args, **kwargs)
|
||||||
|
|
||||||
def filter_equal(self, query, value):
|
def filter_equal(self, query, value):
|
||||||
"""
|
"""
|
||||||
|
@ -338,38 +332,6 @@ class AlchemyGridFilter(GridFilter):
|
||||||
self.column != self.encode_value(value),
|
self.column != self.encode_value(value),
|
||||||
))
|
))
|
||||||
|
|
||||||
def filter_equal_any_of(self, query, value):
|
|
||||||
"""
|
|
||||||
This filter expects "multiple values" separated by newline
|
|
||||||
character, and will add an "OR" condition with each value
|
|
||||||
being checked separately. For instance if the user submits a
|
|
||||||
"value" like this:
|
|
||||||
|
|
||||||
.. code-block:: none
|
|
||||||
|
|
||||||
foo bar
|
|
||||||
baz
|
|
||||||
|
|
||||||
This will result in SQL condition like this:
|
|
||||||
|
|
||||||
.. code-block:: sql
|
|
||||||
|
|
||||||
name = 'foo bar' OR name = 'baz'
|
|
||||||
"""
|
|
||||||
if not value:
|
|
||||||
return query
|
|
||||||
|
|
||||||
values = value.split('\n')
|
|
||||||
values = [value for value in values if value]
|
|
||||||
if not values:
|
|
||||||
return query
|
|
||||||
|
|
||||||
conditions = []
|
|
||||||
for value in values:
|
|
||||||
conditions.append(self.column == self.encode_value(value))
|
|
||||||
|
|
||||||
return query.filter(sa.or_(*conditions))
|
|
||||||
|
|
||||||
def filter_is_null(self, query, value):
|
def filter_is_null(self, query, value):
|
||||||
"""
|
"""
|
||||||
Filter data with an 'IS NULL' query. Note that this filter does not
|
Filter data with an 'IS NULL' query. Note that this filter does not
|
||||||
|
@ -448,12 +410,12 @@ class AlchemyGridFilter(GridFilter):
|
||||||
if start_value:
|
if start_value:
|
||||||
if self.value_invalid(start_value):
|
if self.value_invalid(start_value):
|
||||||
return query
|
return query
|
||||||
query = query.filter(self.column >= self.encode_value(start_value))
|
query = query.filter(self.column >= start_value)
|
||||||
|
|
||||||
if end_value:
|
if end_value:
|
||||||
if self.value_invalid(end_value):
|
if self.value_invalid(end_value):
|
||||||
return query
|
return query
|
||||||
query = query.filter(self.column <= self.encode_value(end_value))
|
query = query.filter(self.column <= end_value)
|
||||||
|
|
||||||
return query
|
return query
|
||||||
|
|
||||||
|
@ -467,13 +429,9 @@ class AlchemyStringFilter(AlchemyGridFilter):
|
||||||
"""
|
"""
|
||||||
Expose contains / does-not-contain verbs in addition to core.
|
Expose contains / does-not-contain verbs in addition to core.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if self.choices:
|
|
||||||
return ['equal', 'not_equal', 'is_null', 'is_not_null', 'is_any']
|
|
||||||
|
|
||||||
return ['contains', 'does_not_contain',
|
return ['contains', 'does_not_contain',
|
||||||
'contains_any_of',
|
'contains_any_of',
|
||||||
'equal', 'not_equal', 'equal_any_of',
|
'equal', 'not_equal',
|
||||||
'is_empty', 'is_not_empty',
|
'is_empty', 'is_not_empty',
|
||||||
'is_null', 'is_not_null',
|
'is_null', 'is_not_null',
|
||||||
'is_empty_or_null',
|
'is_empty_or_null',
|
||||||
|
@ -485,13 +443,9 @@ class AlchemyStringFilter(AlchemyGridFilter):
|
||||||
"""
|
"""
|
||||||
if value is None or value == '':
|
if value is None or value == '':
|
||||||
return query
|
return query
|
||||||
|
return query.filter(sa.and_(
|
||||||
criteria = []
|
*[self.column.ilike(self.encode_value('%{}%'.format(v)))
|
||||||
for val in value.split():
|
for v in value.split()]))
|
||||||
val = val.replace('_', r'\_')
|
|
||||||
val = self.encode_value(f'%{val}%')
|
|
||||||
criteria.append(self.column.ilike(val))
|
|
||||||
return query.filter(sa.and_(*criteria))
|
|
||||||
|
|
||||||
def filter_does_not_contain(self, query, value):
|
def filter_does_not_contain(self, query, value):
|
||||||
"""
|
"""
|
||||||
|
@ -500,17 +454,14 @@ class AlchemyStringFilter(AlchemyGridFilter):
|
||||||
if value is None or value == '':
|
if value is None or value == '':
|
||||||
return query
|
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
|
# When saying something is 'not like' something else, we must also
|
||||||
# include things which are nothing at all, in our result set.
|
# include things which are nothing at all, in our result set.
|
||||||
return query.filter(sa.or_(
|
return query.filter(sa.or_(
|
||||||
self.column == None,
|
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):
|
def filter_contains_any_of(self, query, value):
|
||||||
"""
|
"""
|
||||||
|
@ -539,28 +490,24 @@ class AlchemyStringFilter(AlchemyGridFilter):
|
||||||
|
|
||||||
conditions = []
|
conditions = []
|
||||||
for value in values:
|
for value in values:
|
||||||
criteria = []
|
conditions.append(sa.and_(
|
||||||
for val in value.split():
|
*[self.column.ilike(self.encode_value('%{}%'.format(v)))
|
||||||
val = val.replace('_', r'\_')
|
for v in value.split()]))
|
||||||
val = self.encode_value(f'%{val}%')
|
|
||||||
criteria.append(self.column.ilike(val))
|
|
||||||
conditions.append(sa.and_(*criteria))
|
|
||||||
|
|
||||||
return query.filter(sa.or_(*conditions))
|
return query.filter(sa.or_(*conditions))
|
||||||
|
|
||||||
def filter_is_empty(self, query, value):
|
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):
|
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):
|
def filter_is_empty_or_null(self, query, value):
|
||||||
return query.filter(
|
return query.filter(
|
||||||
sa.or_(
|
sa.or_(
|
||||||
sa.func.ltrim(sa.func.rtrim(self.column)) == self.encode_value(''),
|
sa.func.trim(self.column) == self.encode_value(''),
|
||||||
self.column == None))
|
self.column == None))
|
||||||
|
|
||||||
|
|
||||||
class AlchemyEmptyStringFilter(AlchemyStringFilter):
|
class AlchemyEmptyStringFilter(AlchemyStringFilter):
|
||||||
"""
|
"""
|
||||||
String filter with special logic to treat empty string values as NULL
|
String filter with special logic to treat empty string values as NULL
|
||||||
|
@ -570,13 +517,13 @@ class AlchemyEmptyStringFilter(AlchemyStringFilter):
|
||||||
return query.filter(
|
return query.filter(
|
||||||
sa.or_(
|
sa.or_(
|
||||||
self.column == None,
|
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):
|
def filter_is_not_null(self, query, value):
|
||||||
return query.filter(
|
return query.filter(
|
||||||
sa.and_(
|
sa.and_(
|
||||||
self.column != None,
|
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):
|
class AlchemyByteStringFilter(AlchemyStringFilter):
|
||||||
|
@ -588,8 +535,8 @@ class AlchemyByteStringFilter(AlchemyStringFilter):
|
||||||
value_encoding = 'utf-8'
|
value_encoding = 'utf-8'
|
||||||
|
|
||||||
def get_value(self, value=UNSPECIFIED):
|
def get_value(self, value=UNSPECIFIED):
|
||||||
value = super().get_value(value)
|
value = super(AlchemyByteStringFilter, self).get_value(value)
|
||||||
if isinstance(value, str):
|
if isinstance(value, six.text_type):
|
||||||
value = value.encode(self.value_encoding)
|
value = value.encode(self.value_encoding)
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
@ -599,13 +546,8 @@ class AlchemyByteStringFilter(AlchemyStringFilter):
|
||||||
"""
|
"""
|
||||||
if value is None or value == '':
|
if value is None or value == '':
|
||||||
return query
|
return query
|
||||||
|
return query.filter(sa.and_(
|
||||||
criteria = []
|
*[self.column.ilike(b'%{}%'.format(v)) for v in value.split()]))
|
||||||
for val in value.split():
|
|
||||||
val = val.replace('_', r'\_')
|
|
||||||
val = b'%{}%'.format(val)
|
|
||||||
criteria.append(self.column.ilike(val))
|
|
||||||
return query.filters(sa.and_(*criteria))
|
|
||||||
|
|
||||||
def filter_does_not_contain(self, query, value):
|
def filter_does_not_contain(self, query, value):
|
||||||
"""
|
"""
|
||||||
|
@ -614,16 +556,13 @@ class AlchemyByteStringFilter(AlchemyStringFilter):
|
||||||
if value is None or value == '':
|
if value is None or value == '':
|
||||||
return query
|
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
|
# When saying something is 'not like' something else, we must also
|
||||||
# include things which are nothing at all, in our result set.
|
# include things which are nothing at all, in our result set.
|
||||||
return query.filter(sa.or_(
|
return query.filter(sa.or_(
|
||||||
self.column == None,
|
self.column == None,
|
||||||
sa.and_(*criteria)))
|
sa.and_(
|
||||||
|
*[~self.column.ilike(b'%{}%'.format(v)) for v in value.split()]),
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
class AlchemyNumericFilter(AlchemyGridFilter):
|
class AlchemyNumericFilter(AlchemyGridFilter):
|
||||||
|
@ -632,11 +571,10 @@ class AlchemyNumericFilter(AlchemyGridFilter):
|
||||||
"""
|
"""
|
||||||
value_renderer_factory = NumericValueRenderer
|
value_renderer_factory = NumericValueRenderer
|
||||||
|
|
||||||
def default_verbs(self):
|
# expose greater-than / less-than verbs in addition to core
|
||||||
# expose greater-than / less-than verbs in addition to core
|
default_verbs = ['equal', 'not_equal', 'greater_than', 'greater_equal',
|
||||||
return ['equal', 'not_equal', 'greater_than', 'greater_equal',
|
'less_than', 'less_equal', 'between',
|
||||||
'less_than', 'less_equal', 'between',
|
'is_null', 'is_not_null', 'is_any']
|
||||||
'is_null', 'is_not_null', 'is_any']
|
|
||||||
|
|
||||||
# TODO: what follows "works" in that it prevents an error...but from the
|
# TODO: what follows "works" in that it prevents an error...but from the
|
||||||
# user's perspective it still fails silently...need to improve on front-end
|
# user's perspective it still fails silently...need to improve on front-end
|
||||||
|
@ -648,66 +586,47 @@ class AlchemyNumericFilter(AlchemyGridFilter):
|
||||||
|
|
||||||
# first just make sure it's somewhat numeric
|
# first just make sure it's somewhat numeric
|
||||||
try:
|
try:
|
||||||
self.parse_decimal(value)
|
float(value)
|
||||||
except decimal.InvalidOperation:
|
except ValueError:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
return bool(value and len(str(value)) > 8)
|
return bool(value and len(six.text_type(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):
|
def filter_equal(self, query, value):
|
||||||
if self.value_invalid(value):
|
if self.value_invalid(value):
|
||||||
return query
|
return query
|
||||||
return super().filter_equal(query, value)
|
return super(AlchemyNumericFilter, self).filter_equal(query, value)
|
||||||
|
|
||||||
def filter_not_equal(self, query, value):
|
def filter_not_equal(self, query, value):
|
||||||
if self.value_invalid(value):
|
if self.value_invalid(value):
|
||||||
return query
|
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):
|
def filter_greater_than(self, query, value):
|
||||||
if self.value_invalid(value):
|
if self.value_invalid(value):
|
||||||
return query
|
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):
|
def filter_greater_equal(self, query, value):
|
||||||
if self.value_invalid(value):
|
if self.value_invalid(value):
|
||||||
return query
|
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):
|
def filter_less_than(self, query, value):
|
||||||
if self.value_invalid(value):
|
if self.value_invalid(value):
|
||||||
return query
|
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):
|
def filter_less_equal(self, query, value):
|
||||||
if self.value_invalid(value):
|
if self.value_invalid(value):
|
||||||
return query
|
return query
|
||||||
return super().filter_less_equal(query, value)
|
return super(AlchemyNumericFilter, self).filter_less_equal(query, value)
|
||||||
|
|
||||||
|
|
||||||
class AlchemyIntegerFilter(AlchemyNumericFilter):
|
class AlchemyIntegerFilter(AlchemyNumericFilter):
|
||||||
"""
|
"""
|
||||||
Integer filter for SQLAlchemy.
|
Integer filter for SQLAlchemy.
|
||||||
"""
|
"""
|
||||||
bigint = False
|
|
||||||
|
|
||||||
def default_verbs(self):
|
|
||||||
|
|
||||||
# limited verbs if choices are defined
|
|
||||||
if self.choices:
|
|
||||||
return ['equal', 'not_equal', 'is_null', 'is_not_null', 'is_any']
|
|
||||||
|
|
||||||
return super().default_verbs()
|
|
||||||
|
|
||||||
def value_invalid(self, value):
|
def value_invalid(self, value):
|
||||||
if value:
|
if value:
|
||||||
|
@ -715,10 +634,9 @@ class AlchemyIntegerFilter(AlchemyNumericFilter):
|
||||||
return True
|
return True
|
||||||
if not value.isdigit():
|
if not value.isdigit():
|
||||||
return True
|
return True
|
||||||
# normal Integer columns have a max value, beyond which PG
|
# TODO: this one is to avoid DataError from PG, but perhaps that
|
||||||
# will throw an error if we try to query for larger values
|
# isn't a good enough reason to make this global logic?
|
||||||
# TODO: this seems hacky, how to better handle it?
|
if int(value) > 2147483647:
|
||||||
if not self.bigint and int(value) > 2147483647:
|
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
@ -728,13 +646,6 @@ class AlchemyIntegerFilter(AlchemyNumericFilter):
|
||||||
return int(value)
|
return int(value)
|
||||||
|
|
||||||
|
|
||||||
class AlchemyBigIntegerFilter(AlchemyIntegerFilter):
|
|
||||||
"""
|
|
||||||
BigInteger filter for SQLAlchemy.
|
|
||||||
"""
|
|
||||||
bigint = True
|
|
||||||
|
|
||||||
|
|
||||||
class AlchemyBooleanFilter(AlchemyGridFilter):
|
class AlchemyBooleanFilter(AlchemyGridFilter):
|
||||||
"""
|
"""
|
||||||
Boolean filter for SQLAlchemy.
|
Boolean filter for SQLAlchemy.
|
||||||
|
@ -1223,7 +1134,7 @@ class AlchemyPhoneNumberFilter(AlchemyStringFilter):
|
||||||
'ILIKE' query with those parts.
|
'ILIKE' query with those parts.
|
||||||
"""
|
"""
|
||||||
value = self.parse_value(value)
|
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):
|
def filter_does_not_contain(self, query, value):
|
||||||
"""
|
"""
|
||||||
|
@ -1231,7 +1142,7 @@ class AlchemyPhoneNumberFilter(AlchemyStringFilter):
|
||||||
'NOT ILIKE' query with those parts.
|
'NOT ILIKE' query with those parts.
|
||||||
"""
|
"""
|
||||||
value = self.parse_value(value)
|
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):
|
class GridFilterSet(OrderedDict):
|
||||||
|
@ -1275,7 +1186,7 @@ class GridFiltersForm(forms.Form):
|
||||||
node = colander.SchemaNode(colander.String(), name=key)
|
node = colander.SchemaNode(colander.String(), name=key)
|
||||||
schema.add(node)
|
schema.add(node)
|
||||||
kwargs['schema'] = schema
|
kwargs['schema'] = schema
|
||||||
super().__init__(**kwargs)
|
super(GridFiltersForm, self).__init__(**kwargs)
|
||||||
|
|
||||||
def iter_filters(self):
|
def iter_filters(self):
|
||||||
return self.filters.values()
|
return self.filters.values()
|
||||||
|
|
|
@ -1,82 +0,0 @@
|
||||||
# -*- coding: utf-8; -*-
|
|
||||||
################################################################################
|
|
||||||
#
|
|
||||||
# Rattail -- Retail Software Framework
|
|
||||||
# Copyright © 2010-2024 Lance Edgar
|
|
||||||
#
|
|
||||||
# This file is part of Rattail.
|
|
||||||
#
|
|
||||||
# Rattail is free software: you can redistribute it and/or modify it under the
|
|
||||||
# terms of the GNU General Public License as published by the Free Software
|
|
||||||
# Foundation, either version 3 of the License, or (at your option) any later
|
|
||||||
# version.
|
|
||||||
#
|
|
||||||
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
|
|
||||||
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
|
||||||
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
|
|
||||||
# details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU General Public License along with
|
|
||||||
# Rattail. If not, see <http://www.gnu.org/licenses/>.
|
|
||||||
#
|
|
||||||
################################################################################
|
|
||||||
"""
|
|
||||||
Tailbone Handler
|
|
||||||
"""
|
|
||||||
|
|
||||||
import warnings
|
|
||||||
|
|
||||||
from mako.lookup import TemplateLookup
|
|
||||||
|
|
||||||
from rattail.app import GenericHandler
|
|
||||||
from rattail.files import resource_path
|
|
||||||
|
|
||||||
from tailbone.providers import get_all_providers
|
|
||||||
|
|
||||||
|
|
||||||
class TailboneHandler(GenericHandler):
|
|
||||||
"""
|
|
||||||
Base class and default implementation for Tailbone handler.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
|
|
||||||
# TODO: make templates dir configurable?
|
|
||||||
templates = [resource_path('rattail:templates/web')]
|
|
||||||
self.templates = TemplateLookup(directories=templates)
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
if not hasattr(self, 'menu_handler'):
|
|
||||||
spec = self.config.get('tailbone.menus', 'handler',
|
|
||||||
default='tailbone.menus:MenuHandler')
|
|
||||||
Handler = self.app.load_object(spec)
|
|
||||||
self.menu_handler = Handler(self.config)
|
|
||||||
self.menu_handler.tb = self
|
|
||||||
return self.menu_handler
|
|
||||||
|
|
||||||
def iter_providers(self):
|
|
||||||
"""
|
|
||||||
Returns an iterator over all registered Tailbone providers.
|
|
||||||
"""
|
|
||||||
providers = get_all_providers(self.config)
|
|
||||||
return providers.values()
|
|
||||||
|
|
||||||
def write_model_view(self, data, path, **kwargs):
|
|
||||||
"""
|
|
||||||
Write code for a new model view, based on the given data dict,
|
|
||||||
to the given path.
|
|
||||||
"""
|
|
||||||
template = self.templates.get_template('/new-model-view.mako')
|
|
||||||
content = template.render(**data)
|
|
||||||
with open(path, 'wt') as f:
|
|
||||||
f.write(content)
|
|
|
@ -2,7 +2,7 @@
|
||||||
################################################################################
|
################################################################################
|
||||||
#
|
#
|
||||||
# Rattail -- Retail Software Framework
|
# Rattail -- Retail Software Framework
|
||||||
# Copyright © 2010-2024 Lance Edgar
|
# Copyright © 2010-2021 Lance Edgar
|
||||||
#
|
#
|
||||||
# This file is part of Rattail.
|
# This file is part of Rattail.
|
||||||
#
|
#
|
||||||
|
@ -24,20 +24,22 @@
|
||||||
Template Context Helpers
|
Template Context Helpers
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# start off with all from wuttaweb
|
from __future__ import unicode_literals, absolute_import
|
||||||
from wuttaweb.helpers import *
|
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import datetime
|
import datetime
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from collections import OrderedDict
|
|
||||||
|
|
||||||
from rattail.time import localtime, make_utc
|
from rattail.time import localtime, make_utc
|
||||||
from rattail.util import pretty_quantity, pretty_hours, hours_as_decimal
|
from rattail.util import (pretty_quantity, pretty_hours, hours_as_decimal,
|
||||||
|
OrderedDict)
|
||||||
from rattail.db.util import maxlen
|
from rattail.db.util import maxlen
|
||||||
|
|
||||||
from tailbone.util import (pretty_datetime, raw_datetime,
|
from webhelpers2.html import *
|
||||||
render_markdown,
|
from webhelpers2.html.tags import *
|
||||||
|
|
||||||
|
from tailbone.util import (csrf_token, get_csrf_token,
|
||||||
|
pretty_datetime, raw_datetime,
|
||||||
route_exists)
|
route_exists)
|
||||||
|
|
||||||
|
|
||||||
|
|
1025
tailbone/menus.py
1025
tailbone/menus.py
File diff suppressed because it is too large
Load diff
|
@ -2,7 +2,7 @@
|
||||||
################################################################################
|
################################################################################
|
||||||
#
|
#
|
||||||
# Rattail -- Retail Software Framework
|
# Rattail -- Retail Software Framework
|
||||||
# Copyright © 2010-2023 Lance Edgar
|
# Copyright © 2010-2022 Lance Edgar
|
||||||
#
|
#
|
||||||
# This file is part of Rattail.
|
# This file is part of Rattail.
|
||||||
#
|
#
|
||||||
|
@ -48,9 +48,6 @@ class TailboneProvider(object):
|
||||||
def get_provided_views(self):
|
def get_provided_views(self):
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
def make_integration_menu(self, request, **kwargs):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def get_all_providers(config):
|
def get_all_providers(config):
|
||||||
"""
|
"""
|
||||||
|
|
|
@ -111,7 +111,7 @@
|
||||||
<td class="brand">${cost.product.brand or ''}</td>
|
<td class="brand">${cost.product.brand or ''}</td>
|
||||||
<td class="desc">${cost.product.description}</td>
|
<td class="desc">${cost.product.description}</td>
|
||||||
<td class="size">${cost.product.size or ''}</td>
|
<td class="size">${cost.product.size or ''}</td>
|
||||||
<td class="case-qty">${app.render_quantity(cost.case_size)} ${"LB" if cost.product.weighed else "EA"}</td>
|
<td class="case-qty">${cost.case_size} ${"LB" if cost.product.weighed else "EA"}</td>
|
||||||
<td class="code">${cost.code or ''}</td>
|
<td class="code">${cost.code or ''}</td>
|
||||||
<td class="preferred">${'X' if cost.preference == 1 else ''}</td>
|
<td class="preferred">${'X' if cost.preference == 1 else ''}</td>
|
||||||
% for i in range(14):
|
% for i in range(14):
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
# -*- coding: utf-8; -*-
|
# -*- coding: utf-8 -*-
|
||||||
################################################################################
|
################################################################################
|
||||||
#
|
#
|
||||||
# Rattail -- Retail Software Framework
|
# Rattail -- Retail Software Framework
|
||||||
# Copyright © 2010-2023 Lance Edgar
|
# Copyright © 2010-2017 Lance Edgar
|
||||||
#
|
#
|
||||||
# This file is part of Rattail.
|
# This file is part of Rattail.
|
||||||
#
|
#
|
||||||
|
@ -21,31 +21,25 @@
|
||||||
#
|
#
|
||||||
################################################################################
|
################################################################################
|
||||||
"""
|
"""
|
||||||
Tailbone Web API - Label Views
|
Pyramid scaffold templates
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import unicode_literals, absolute_import
|
from __future__ import unicode_literals, absolute_import
|
||||||
|
|
||||||
from rattail.db.model import LabelProfile
|
from rattail.files import resource_path
|
||||||
|
from rattail.util import prettify
|
||||||
|
|
||||||
from tailbone.api import APIMasterView
|
from pyramid.scaffolds import PyramidTemplate
|
||||||
|
|
||||||
|
|
||||||
class LabelProfileView(APIMasterView):
|
class RattailTemplate(PyramidTemplate):
|
||||||
"""
|
_template_dir = resource_path('rattail:data/project')
|
||||||
API views for Label Profile data
|
summary = "Starter project based on Rattail / Tailbone"
|
||||||
"""
|
|
||||||
model_class = LabelProfile
|
|
||||||
collection_url_prefix = '/label-profiles'
|
|
||||||
object_url_prefix = '/label-profile'
|
|
||||||
|
|
||||||
|
def pre(self, command, output_dir, vars):
|
||||||
def defaults(config, **kwargs):
|
"""
|
||||||
base = globals()
|
Adds some more variables to the template context.
|
||||||
|
"""
|
||||||
LabelProfileView = kwargs.get('LabelProfileView', base['LabelProfileView'])
|
vars['project_title'] = prettify(vars['project'])
|
||||||
LabelProfileView.defaults(config)
|
vars['package_title'] = vars['package'].capitalize()
|
||||||
|
return super(RattailTemplate, self).pre(command, output_dir, vars)
|
||||||
|
|
||||||
def includeme(config):
|
|
||||||
defaults(config)
|
|
|
@ -2,7 +2,7 @@
|
||||||
################################################################################
|
################################################################################
|
||||||
#
|
#
|
||||||
# Rattail -- Retail Software Framework
|
# Rattail -- Retail Software Framework
|
||||||
# Copyright © 2010-2024 Lance Edgar
|
# Copyright © 2010-2017 Lance Edgar
|
||||||
#
|
#
|
||||||
# This file is part of Rattail.
|
# This file is part of Rattail.
|
||||||
#
|
#
|
||||||
|
@ -24,8 +24,9 @@
|
||||||
Static Assets
|
Static Assets
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from __future__ import unicode_literals, absolute_import
|
||||||
|
|
||||||
|
|
||||||
def includeme(config):
|
def includeme(config):
|
||||||
config.include('wuttaweb.static')
|
|
||||||
config.add_static_view('tailbone', 'tailbone:static')
|
config.add_static_view('tailbone', 'tailbone:static')
|
||||||
config.add_static_view('deform', 'deform:static')
|
config.add_static_view('deform', 'deform:static')
|
||||||
|
|
|
@ -1,14 +1,122 @@
|
||||||
|
|
||||||
|
/******************************
|
||||||
|
* General
|
||||||
|
******************************/
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: Verdana, Arial, sans-serif;
|
||||||
|
font-size: 11pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: #0972a5;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 12pt;
|
||||||
|
margin: 20px auto 10px auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
li {
|
||||||
|
line-height: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.left {
|
||||||
|
float: left;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.right {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapper {
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.buttons {
|
||||||
|
clear: both;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.dialog {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.flash-message {
|
||||||
|
background-color: #dddddd;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
padding: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.flash-messages div.ui-state-highlight {
|
||||||
|
padding: .3em;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.error-messages div.ui-state-error {
|
||||||
|
padding: .3em;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flash-messages,
|
||||||
|
.error-messages {
|
||||||
|
margin: 0.5em 0 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul.error {
|
||||||
|
color: #dd6666;
|
||||||
|
font-weight: bold;
|
||||||
|
padding: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul.error li {
|
||||||
|
list-style-type: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre.is-family-sans-serif {
|
||||||
|
background-color: white;
|
||||||
|
font-family: Verdana, Arial, sans-serif;
|
||||||
|
font-size: 11pt;
|
||||||
|
padding: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/******************************
|
||||||
|
* jQuery UI tweaks
|
||||||
|
******************************/
|
||||||
|
|
||||||
|
ul.ui-menu {
|
||||||
|
max-height: 30em;
|
||||||
|
}
|
||||||
|
|
||||||
/******************************
|
/******************************
|
||||||
* tweaks for root user
|
* tweaks for root user
|
||||||
******************************/
|
******************************/
|
||||||
|
|
||||||
.navbar .navbar-end .navbar-link.root-user,
|
.menubar .root-user .ui-button-text,
|
||||||
.navbar .navbar-end .navbar-link.root-user:hover,
|
.menubar .root-user.ui-menu-item a {
|
||||||
.navbar .navbar-end .navbar-link.root-user.is_active,
|
|
||||||
.navbar .navbar-end .navbar-item.root-user,
|
|
||||||
.navbar .navbar-end .navbar-item.root-user:hover,
|
|
||||||
.navbar .navbar-end .navbar-item.root-user.is_active {
|
|
||||||
background-color: red;
|
background-color: red;
|
||||||
|
color: black;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.menubar .root-user.ui-menu-item a {
|
||||||
|
padding-left: 1em;
|
||||||
|
}
|
||||||
|
|
|
@ -1,18 +1,28 @@
|
||||||
|
|
||||||
/******************************
|
/******************************
|
||||||
* Grid Filters
|
* Filters
|
||||||
******************************/
|
******************************/
|
||||||
|
|
||||||
.filters .filter-fieldname .field,
|
div.filters form {
|
||||||
.filters .filter-fieldname .field label {
|
margin-bottom: 10px;
|
||||||
width: 100%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.filters .filter-fieldname .field label {
|
div.filters div.filter {
|
||||||
justify-content: left;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filters .filter-verb .select,
|
div.filters div.filter label {
|
||||||
.filters .filter-verb .select select {
|
margin-right: 8px;
|
||||||
width: 100%;
|
}
|
||||||
|
|
||||||
|
div.filters div.filter select.filter-type {
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.filters div.filter div.value {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.filters div.buttons * {
|
||||||
|
margin-right: 8px;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,37 +1,34 @@
|
||||||
|
|
||||||
/******************************
|
/******************************
|
||||||
* forms
|
* Form Wrapper
|
||||||
******************************/
|
******************************/
|
||||||
|
|
||||||
/* note that this should only apply to "normal" primary forms */
|
div.form-wrapper {
|
||||||
/* TODO: replace this with bulma equivalent */
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/******************************
|
||||||
|
* Forms
|
||||||
|
******************************/
|
||||||
|
|
||||||
|
div.form,
|
||||||
|
div.fieldset-form,
|
||||||
|
div.fieldset {
|
||||||
|
clear: left;
|
||||||
|
float: left;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.form {
|
.form {
|
||||||
padding-left: 5em;
|
padding-left: 5em;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* note that this should only apply to "normal" primary forms */
|
|
||||||
.form-wrapper .form .field.is-horizontal .field-label .label {
|
|
||||||
text-align: left;
|
|
||||||
white-space: nowrap;
|
|
||||||
width: 18em;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* note that this should only apply to "normal" primary forms */
|
|
||||||
.form-wrapper .form .field.is-horizontal .field-body {
|
|
||||||
min-width: 30em;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* note that this should only apply to "normal" primary forms */
|
|
||||||
.form-wrapper .form .field.is-horizontal .field-body .select,
|
|
||||||
.form-wrapper .form .field.is-horizontal .field-body .select select {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
/******************************
|
/******************************
|
||||||
* field-wrappers
|
* Fieldsets
|
||||||
******************************/
|
******************************/
|
||||||
|
|
||||||
/* TODO: replace this with bulma equivalent */
|
|
||||||
.field-wrapper {
|
.field-wrapper {
|
||||||
clear: both;
|
clear: both;
|
||||||
min-height: 30px;
|
min-height: 30px;
|
||||||
|
@ -39,12 +36,16 @@
|
||||||
margin: 15px;
|
margin: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* TODO: replace this with bulma equivalent */
|
.field-wrapper.with-error {
|
||||||
|
background-color: #ddcccc;
|
||||||
|
border: 2px solid #dd6666;
|
||||||
|
padding-bottom: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
.field-wrapper .field-row {
|
.field-wrapper .field-row {
|
||||||
display: table-row;
|
display: table-row;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* TODO: replace this with bulma equivalent */
|
|
||||||
.field-wrapper label {
|
.field-wrapper label {
|
||||||
display: table-cell;
|
display: table-cell;
|
||||||
vertical-align: top;
|
vertical-align: top;
|
||||||
|
@ -54,8 +55,47 @@
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* TODO: replace this with bulma equivalent */
|
.field-wrapper.with-error label {
|
||||||
|
padding-left: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-wrapper .field-error {
|
||||||
|
padding: 1em 0 0.5em 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-wrapper .field-error .error-msg {
|
||||||
|
color: #dd6666;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
.field-wrapper .field {
|
.field-wrapper .field {
|
||||||
display: table-cell;
|
display: table-cell;
|
||||||
line-height: 25px;
|
line-height: 25px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.field-wrapper .field input[type=text],
|
||||||
|
.field-wrapper .field input[type=password],
|
||||||
|
.field-wrapper .field select,
|
||||||
|
.field-wrapper .field textarea {
|
||||||
|
width: 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
label input[type="checkbox"],
|
||||||
|
label input[type="radio"] {
|
||||||
|
margin-right: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field ul {
|
||||||
|
margin: 0px;
|
||||||
|
padding-left: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/******************************
|
||||||
|
* Buttons
|
||||||
|
******************************/
|
||||||
|
|
||||||
|
div.buttons {
|
||||||
|
clear: both;
|
||||||
|
margin: 10px 0px;
|
||||||
|
}
|
||||||
|
|
|
@ -25,11 +25,6 @@
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.grid-tools {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.grid-wrapper .grid-header td.tools {
|
.grid-wrapper .grid-header td.tools {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
@ -266,10 +261,6 @@
|
||||||
* main actions
|
* main actions
|
||||||
******************************/
|
******************************/
|
||||||
|
|
||||||
a.grid-action {
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.grid .actions {
|
.grid .actions {
|
||||||
width: 1px;
|
width: 1px;
|
||||||
}
|
}
|
||||||
|
|
40
tailbone/static/css/jquery.loadmask.css
Normal file
40
tailbone/static/css/jquery.loadmask.css
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
.loadmask {
|
||||||
|
z-index: 100;
|
||||||
|
position: absolute;
|
||||||
|
top:0;
|
||||||
|
left:0;
|
||||||
|
-moz-opacity: 0.5;
|
||||||
|
opacity: .50;
|
||||||
|
filter: alpha(opacity=50);
|
||||||
|
background-color: #CCC;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
zoom: 1;
|
||||||
|
}
|
||||||
|
.loadmask-msg {
|
||||||
|
z-index: 20001;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
border:1px solid #6593cf;
|
||||||
|
background: #c3daf9;
|
||||||
|
padding:2px;
|
||||||
|
}
|
||||||
|
.loadmask-msg div {
|
||||||
|
padding:5px 10px 5px 25px;
|
||||||
|
background: #fbfbfb url('../img/loading.gif') no-repeat 5px 5px;
|
||||||
|
line-height: 16px;
|
||||||
|
border:1px solid #a3bad9;
|
||||||
|
color:#222;
|
||||||
|
font:normal 11px tahoma, arial, helvetica, sans-serif;
|
||||||
|
cursor:wait;
|
||||||
|
}
|
||||||
|
.masked {
|
||||||
|
overflow: hidden !important;
|
||||||
|
}
|
||||||
|
.masked-relative {
|
||||||
|
position: relative !important;
|
||||||
|
}
|
||||||
|
.masked-hidden {
|
||||||
|
visibility: hidden !important;
|
||||||
|
}
|
69
tailbone/static/css/jquery.tagit.css
Normal file
69
tailbone/static/css/jquery.tagit.css
Normal file
|
@ -0,0 +1,69 @@
|
||||||
|
ul.tagit {
|
||||||
|
padding: 1px 5px;
|
||||||
|
overflow: auto;
|
||||||
|
margin-left: inherit; /* usually we don't want the regular ul margins. */
|
||||||
|
margin-right: inherit;
|
||||||
|
}
|
||||||
|
ul.tagit li {
|
||||||
|
display: block;
|
||||||
|
float: left;
|
||||||
|
margin: 2px 5px 2px 0;
|
||||||
|
}
|
||||||
|
ul.tagit li.tagit-choice {
|
||||||
|
position: relative;
|
||||||
|
line-height: inherit;
|
||||||
|
}
|
||||||
|
input.tagit-hidden-field {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
ul.tagit li.tagit-choice-read-only {
|
||||||
|
padding: .2em .5em .2em .5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul.tagit li.tagit-choice-editable {
|
||||||
|
padding: .2em 18px .2em .5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul.tagit li.tagit-new {
|
||||||
|
padding: .25em 4px .25em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul.tagit li.tagit-choice a.tagit-label {
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
ul.tagit li.tagit-choice .tagit-close {
|
||||||
|
cursor: pointer;
|
||||||
|
position: absolute;
|
||||||
|
right: .1em;
|
||||||
|
top: 50%;
|
||||||
|
margin-top: -8px;
|
||||||
|
line-height: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* used for some custom themes that don't need image icons */
|
||||||
|
ul.tagit li.tagit-choice .tagit-close .text-icon {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul.tagit li.tagit-choice input {
|
||||||
|
display: block;
|
||||||
|
float: left;
|
||||||
|
margin: 2px 5px 2px 0;
|
||||||
|
}
|
||||||
|
ul.tagit input[type="text"] {
|
||||||
|
-moz-box-sizing: border-box;
|
||||||
|
-webkit-box-sizing: border-box;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
-moz-box-shadow: none;
|
||||||
|
-webkit-box-shadow: none;
|
||||||
|
box-shadow: none;
|
||||||
|
|
||||||
|
border: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
width: inherit;
|
||||||
|
background-color: inherit;
|
||||||
|
outline: none;
|
||||||
|
}
|
15
tailbone/static/css/jquery.ui.menubar.css
vendored
Normal file
15
tailbone/static/css/jquery.ui.menubar.css
vendored
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
/*
|
||||||
|
* jQuery UI Menubar @VERSION
|
||||||
|
*
|
||||||
|
* Copyright 2011, AUTHORS.txt (http://jqueryui.com/about)
|
||||||
|
* Dual licensed under the MIT or GPL Version 2 licenses.
|
||||||
|
* http://jquery.org/license
|
||||||
|
*/
|
||||||
|
.ui-menubar { list-style: none; margin: 0; padding-left: 0; }
|
||||||
|
|
||||||
|
.ui-menubar-item { float: left; }
|
||||||
|
|
||||||
|
.ui-menubar .ui-button { float: left; font-weight: normal; border-top-width: 0 !important; border-bottom-width: 0 !important; margin: 0; outline: none; }
|
||||||
|
.ui-menubar .ui-menubar-link { border-right: 1px dashed transparent; border-left: 1px dashed transparent; }
|
||||||
|
|
||||||
|
.ui-menubar .ui-menu { width: 200px; position: absolute; z-index: 9999; font-weight: normal; }
|
14
tailbone/static/css/jquery.ui.tailbone.css
vendored
Normal file
14
tailbone/static/css/jquery.ui.tailbone.css
vendored
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
|
||||||
|
/**********************************************************************
|
||||||
|
* jquery.ui.tailbone.css
|
||||||
|
*
|
||||||
|
* jQuery UI tweaks for Tailbone
|
||||||
|
**********************************************************************/
|
||||||
|
|
||||||
|
.ui-widget {
|
||||||
|
font-size: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-menu-item a {
|
||||||
|
display: block;
|
||||||
|
}
|
57
tailbone/static/css/jquery.ui.timepicker.css
vendored
Normal file
57
tailbone/static/css/jquery.ui.timepicker.css
vendored
Normal file
|
@ -0,0 +1,57 @@
|
||||||
|
/*
|
||||||
|
* Timepicker stylesheet
|
||||||
|
* Highly inspired from datepicker
|
||||||
|
* FG - Nov 2010 - Web3R
|
||||||
|
*
|
||||||
|
* version 0.0.3 : Fixed some settings, more dynamic
|
||||||
|
* version 0.0.4 : Removed width:100% on tables
|
||||||
|
* version 0.1.1 : set width 0 on tables to fix an ie6 bug
|
||||||
|
*/
|
||||||
|
|
||||||
|
.ui-timepicker-inline { display: inline; }
|
||||||
|
|
||||||
|
#ui-timepicker-div { padding: 0.2em; }
|
||||||
|
.ui-timepicker-table { display: inline-table; width: 0; }
|
||||||
|
.ui-timepicker-table table { margin:0.15em 0 0 0; border-collapse: collapse; }
|
||||||
|
|
||||||
|
.ui-timepicker-hours, .ui-timepicker-minutes { padding: 0.2em; }
|
||||||
|
|
||||||
|
.ui-timepicker-table .ui-timepicker-title { line-height: 1.8em; text-align: center; }
|
||||||
|
.ui-timepicker-table td { padding: 0.1em; width: 2.2em; }
|
||||||
|
.ui-timepicker-table th.periods { padding: 0.1em; width: 2.2em; }
|
||||||
|
|
||||||
|
/* span for disabled cells */
|
||||||
|
.ui-timepicker-table td span {
|
||||||
|
display:block;
|
||||||
|
padding:0.2em 0.3em 0.2em 0.5em;
|
||||||
|
width: 1.2em;
|
||||||
|
|
||||||
|
text-align:right;
|
||||||
|
text-decoration:none;
|
||||||
|
}
|
||||||
|
/* anchors for clickable cells */
|
||||||
|
.ui-timepicker-table td a {
|
||||||
|
display:block;
|
||||||
|
padding:0.2em 0.3em 0.2em 0.5em;
|
||||||
|
width: 1.2em;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align:right;
|
||||||
|
text-decoration:none;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* buttons and button pane styling */
|
||||||
|
.ui-timepicker .ui-timepicker-buttonpane {
|
||||||
|
background-image: none; margin: .7em 0 0 0; padding:0 .2em; border-left: 0; border-right: 0; border-bottom: 0;
|
||||||
|
}
|
||||||
|
.ui-timepicker .ui-timepicker-buttonpane button { margin: .5em .2em .4em; cursor: pointer; padding: .2em .6em .3em .6em; width:auto; overflow:visible; }
|
||||||
|
/* The close button */
|
||||||
|
.ui-timepicker .ui-timepicker-close { float: right }
|
||||||
|
|
||||||
|
/* the now button */
|
||||||
|
.ui-timepicker .ui-timepicker-now { float: left; }
|
||||||
|
|
||||||
|
/* the deselect button */
|
||||||
|
.ui-timepicker .ui-timepicker-deselect { float: left; }
|
||||||
|
|
||||||
|
|
|
@ -1,87 +1,152 @@
|
||||||
|
|
||||||
/******************************
|
/******************************
|
||||||
* main layout
|
* Main Layout
|
||||||
******************************/
|
******************************/
|
||||||
|
|
||||||
body {
|
html, body, #body-wrapper {
|
||||||
display: flex;
|
height: 100%;
|
||||||
flex-direction: column;
|
}
|
||||||
min-height: 100vh;
|
|
||||||
|
body > #body-wrapper {
|
||||||
|
height: auto;
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#body-wrapper {
|
||||||
|
margin: 0 1em;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
#header {
|
||||||
|
height: 50px;
|
||||||
|
line-height: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#body {
|
||||||
|
padding-top: 10px;
|
||||||
|
padding-bottom: 5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
#footer {
|
||||||
|
clear: both;
|
||||||
|
margin-top: -4em;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/******************************
|
||||||
|
* Header
|
||||||
|
******************************/
|
||||||
|
|
||||||
|
#header h1 {
|
||||||
|
float: left;
|
||||||
|
font-size: 25px;
|
||||||
|
margin: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#header div.login {
|
||||||
|
float: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* new stuff from 'better' theme begins here */
|
||||||
|
|
||||||
|
header .global {
|
||||||
|
background-color: #eaeaea;
|
||||||
|
height: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
header .global a.home,
|
||||||
|
header .global a.global,
|
||||||
|
header .global span.global {
|
||||||
|
display: block;
|
||||||
|
float: left;
|
||||||
|
font-size: 2em;
|
||||||
|
font-weight: bold;
|
||||||
|
line-height: 60px;
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
header .global a.home img {
|
||||||
|
display: block;
|
||||||
|
float: left;
|
||||||
|
padding: 5px 5px 5px 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
header .global .grid-nav {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: bold;
|
||||||
|
line-height: 60px;
|
||||||
|
margin-left: 5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
header .global .grid-nav .ui-button,
|
||||||
|
header .global .grid-nav span.viewing {
|
||||||
|
margin-left: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
header .global .feedback {
|
||||||
|
float: right;
|
||||||
|
line-height: 60px;
|
||||||
|
margin-right: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
header .global .after-feedback {
|
||||||
|
float: right;
|
||||||
|
line-height: 60px;
|
||||||
|
margin-right: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
header .page {
|
||||||
|
border-bottom: 1px solid lightgrey;
|
||||||
|
padding: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
header .page h1 {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 0 0 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/******************************
|
||||||
|
* Logo
|
||||||
|
******************************/
|
||||||
|
|
||||||
|
#logo {
|
||||||
|
display: block;
|
||||||
|
margin: 40px auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/****************************************
|
||||||
|
* content
|
||||||
|
****************************************/
|
||||||
|
|
||||||
|
body > #body-wrapper {
|
||||||
|
margin: 0px;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content-wrapper {
|
.content-wrapper {
|
||||||
display: flex;
|
height: 100%;
|
||||||
flex: 1;
|
padding-bottom: 30px;
|
||||||
flex-direction: column;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#scrollpane {
|
||||||
/******************************
|
height: 100%;
|
||||||
* header
|
|
||||||
******************************/
|
|
||||||
|
|
||||||
/* this is the one in the very top left of screen, next to logo and linked to
|
|
||||||
the home page */
|
|
||||||
#global-header-title {
|
|
||||||
margin-left: 0.3rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
header .level {
|
#scrollpane .inner-content {
|
||||||
/* TODO: not sure what this 60px was supposed to do? but it broke the */
|
padding: 0 0.5em 0.5em 0.5em;
|
||||||
/* styles for the feedback dialog, so disabled it is.
|
|
||||||
/* height: 60px; */
|
|
||||||
/* line-height: 60px; */
|
|
||||||
padding-left: 0.5em;
|
|
||||||
padding-right: 0.5em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
header .level #header-logo {
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
|
|
||||||
header .level .global-title,
|
|
||||||
header .level-left .global-title {
|
|
||||||
font-size: 2em;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* indent nested menu items a bit */
|
|
||||||
header .navbar-item.nested {
|
|
||||||
padding-left: 2.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
header span.header-text {
|
|
||||||
font-size: 2em;
|
|
||||||
font-weight: bold;
|
|
||||||
margin-right: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#content-title h1 {
|
|
||||||
margin-bottom: 0;
|
|
||||||
margin-right: 1rem;
|
|
||||||
max-width: 50%;
|
|
||||||
overflow: hidden;
|
|
||||||
padding: 0 0.3rem;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/******************************
|
|
||||||
* content
|
|
||||||
******************************/
|
|
||||||
|
|
||||||
#page-body {
|
|
||||||
padding: 0.4em;
|
|
||||||
}
|
|
||||||
|
|
||||||
/******************************
|
/******************************
|
||||||
* context menu
|
* context menu
|
||||||
******************************/
|
******************************/
|
||||||
|
|
||||||
#context-menu {
|
#context-menu {
|
||||||
margin-bottom: 1em;
|
list-style-type: none;
|
||||||
margin-left: 1em;
|
margin: 0.5em;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
@ -90,24 +155,11 @@ header span.header-text {
|
||||||
* "object helper" panel
|
* "object helper" panel
|
||||||
******************************/
|
******************************/
|
||||||
|
|
||||||
.object-helpers .panel {
|
|
||||||
margin: 1rem;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.object-helpers .panel-heading {
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.object-helpers a {
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.object-helper {
|
.object-helper {
|
||||||
border: 1px solid black;
|
border: 1px solid black;
|
||||||
margin: 1em;
|
margin: 1em;
|
||||||
padding: 1em;
|
padding: 1em;
|
||||||
width: 20em;
|
min-width: 20em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.object-helper-content {
|
.object-helper-content {
|
||||||
|
@ -115,44 +167,87 @@ header span.header-text {
|
||||||
}
|
}
|
||||||
|
|
||||||
/******************************
|
/******************************
|
||||||
* markdown
|
* Panels
|
||||||
******************************/
|
******************************/
|
||||||
|
|
||||||
.rendered-markdown p,
|
.panel,
|
||||||
.rendered-markdown ul {
|
.panel-grid {
|
||||||
margin-bottom: 1rem;
|
border-left: 1px solid Black;
|
||||||
|
margin-bottom: 1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rendered-markdown .codehilite {
|
.panel {
|
||||||
margin-bottom: 2rem;
|
border-bottom: 1px solid Black;
|
||||||
|
border-right: 1px solid Black;
|
||||||
|
padding: 0px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/******************************
|
.panel h2,
|
||||||
* fix datepicker within modals
|
.panel-grid h2 {
|
||||||
* TODO: someday this may not be necessary? cf.
|
border-bottom: 1px solid Black;
|
||||||
* https://github.com/buefy/buefy/issues/292#issuecomment-347365637
|
border-top: 1px solid Black;
|
||||||
******************************/
|
padding: 5px;
|
||||||
|
margin: 0px;
|
||||||
.modal .animation-content .modal-card {
|
|
||||||
overflow: visible !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-card-body {
|
.panel-grid h2 {
|
||||||
overflow: visible !important;
|
border-right: 1px solid Black;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* TODO: a simpler option we might try sometime instead? */
|
.panel-body {
|
||||||
/* cf. https://github.com/buefy/buefy/issues/292#issuecomment-1073851313 */
|
overflow: auto;
|
||||||
|
padding: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
/* .dropdown-content{ */
|
/****************************************
|
||||||
/* position: fixed; */
|
* footer
|
||||||
/* } */
|
****************************************/
|
||||||
|
|
||||||
|
#footer {
|
||||||
|
border-top: 1px solid lightgray;
|
||||||
|
bottom: 0;
|
||||||
|
font-size: 9pt;
|
||||||
|
height: 20px;
|
||||||
|
left: 0;
|
||||||
|
line-height: 20px;
|
||||||
|
margin: 0;
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
/******************************
|
/******************************
|
||||||
* feedback
|
* feedback
|
||||||
******************************/
|
******************************/
|
||||||
|
|
||||||
.feedback-dialog .red {
|
#feedback-dialog {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#feedback-dialog p {
|
||||||
|
margin-top: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
#feedback-dialog .red {
|
||||||
color: red;
|
color: red;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#feedback-dialog .field-wrapper {
|
||||||
|
margin-top: 1em;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#feedback-dialog .field {
|
||||||
|
margin-bottom: 0;
|
||||||
|
margin-top: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
#feedback-dialog .referrer .field {
|
||||||
|
clear: both;
|
||||||
|
float: none;
|
||||||
|
margin-top: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
#feedback-dialog textarea {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
48
tailbone/static/css/login.css
Normal file
48
tailbone/static/css/login.css
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
|
||||||
|
/******************************
|
||||||
|
* login.css
|
||||||
|
******************************/
|
||||||
|
|
||||||
|
.logo img,
|
||||||
|
#logo {
|
||||||
|
display: block;
|
||||||
|
margin: 40px auto;
|
||||||
|
max-height: 350px;
|
||||||
|
max-width: 800px;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.form {
|
||||||
|
margin: auto;
|
||||||
|
float: none;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.field-wrapper {
|
||||||
|
margin: 10px auto;
|
||||||
|
width: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.field-wrapper label {
|
||||||
|
text-align: right;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.field-wrapper div.field input[type="text"],
|
||||||
|
div.field-wrapper div.field input[type="password"] {
|
||||||
|
margin-left: 1em;
|
||||||
|
width: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.buttons {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.buttons input {
|
||||||
|
margin: auto 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* this is for "login as chuck" tip in demo mode */
|
||||||
|
.tips {
|
||||||
|
margin-top: 2em;
|
||||||
|
text-align: center;
|
||||||
|
}
|
Binary file not shown.
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 4.7 KiB |
452
tailbone/static/js/jquery.ui.tailbone.js
vendored
Normal file
452
tailbone/static/js/jquery.ui.tailbone.js
vendored
Normal file
|
@ -0,0 +1,452 @@
|
||||||
|
|
||||||
|
/**********************************************************************
|
||||||
|
* jQuery UI plugins for Tailbone
|
||||||
|
**********************************************************************/
|
||||||
|
|
||||||
|
/**********************************************************************
|
||||||
|
* gridcore plugin
|
||||||
|
**********************************************************************/
|
||||||
|
|
||||||
|
(function($) {
|
||||||
|
|
||||||
|
$.widget('tailbone.gridcore', {
|
||||||
|
|
||||||
|
_create: function() {
|
||||||
|
|
||||||
|
var that = this;
|
||||||
|
|
||||||
|
// Add hover highlight effect to grid rows during mouse-over.
|
||||||
|
// this.element.on('mouseenter', 'tbody tr:not(.header)', function() {
|
||||||
|
this.element.on('mouseenter', 'tr:not(.header)', function() {
|
||||||
|
$(this).addClass('hovering');
|
||||||
|
});
|
||||||
|
// this.element.on('mouseleave', 'tbody tr:not(.header)', function() {
|
||||||
|
this.element.on('mouseleave', 'tr:not(.header)', function() {
|
||||||
|
$(this).removeClass('hovering');
|
||||||
|
});
|
||||||
|
|
||||||
|
// do some extra stuff for grids with checkboxes
|
||||||
|
|
||||||
|
// mark rows selected on page load, as needed
|
||||||
|
this.element.find('tr:not(.header) td.checkbox :checkbox:checked').each(function() {
|
||||||
|
$(this).parents('tr:first').addClass('selected');
|
||||||
|
});
|
||||||
|
|
||||||
|
// (un-)check all rows when clicking check-all box in header
|
||||||
|
if (this.element.find('tr.header td.checkbox :checkbox').length) {
|
||||||
|
this.element.on('click', 'tr.header td.checkbox :checkbox', function() {
|
||||||
|
var checked = $(this).prop('checked');
|
||||||
|
var rows = that.element.find('tr:not(.header)');
|
||||||
|
rows.find('td.checkbox :checkbox').prop('checked', checked);
|
||||||
|
if (checked) {
|
||||||
|
rows.addClass('selected');
|
||||||
|
} else {
|
||||||
|
rows.removeClass('selected');
|
||||||
|
}
|
||||||
|
that.element.trigger('gridchecked', that.count_selected());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// when row with checkbox is clicked, toggle selected status,
|
||||||
|
// unless clicking checkbox (since that already toggles it) or a
|
||||||
|
// link (since that does something completely different)
|
||||||
|
this.element.on('click', 'tr:not(.header)', function(event) {
|
||||||
|
var el = $(event.target);
|
||||||
|
if (!el.is('a') && !el.is(':checkbox')) {
|
||||||
|
$(this).find('td.checkbox :checkbox').click();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.element.on('change', 'tr:not(.header) td.checkbox :checkbox', function() {
|
||||||
|
if (this.checked) {
|
||||||
|
$(this).parents('tr:first').addClass('selected');
|
||||||
|
} else {
|
||||||
|
$(this).parents('tr:first').removeClass('selected');
|
||||||
|
}
|
||||||
|
that.element.trigger('gridchecked', that.count_selected());
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show 'more' actions when user hovers over 'more' link.
|
||||||
|
this.element.on('mouseenter', '.actions a.more', function() {
|
||||||
|
that.element.find('.actions div.more').hide();
|
||||||
|
$(this).siblings('div.more')
|
||||||
|
.show()
|
||||||
|
.position({my: 'left-5 top-4', at: 'left top', of: $(this)});
|
||||||
|
});
|
||||||
|
this.element.on('mouseleave', '.actions div.more', function() {
|
||||||
|
$(this).hide();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add speed bump for "Delete Row" action, if grid is so configured.
|
||||||
|
if (this.element.data('delete-speedbump')) {
|
||||||
|
this.element.on('click', 'tr:not(.header) .actions a.delete', function() {
|
||||||
|
return confirm("Are you sure you wish to delete this object?");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
count_selected: function() {
|
||||||
|
return this.element.find('tr:not(.header) td.checkbox :checkbox:checked').length;
|
||||||
|
},
|
||||||
|
|
||||||
|
// TODO: deprecate / remove this?
|
||||||
|
count_checked: function() {
|
||||||
|
return this.count_selected();
|
||||||
|
},
|
||||||
|
|
||||||
|
selected_rows: function() {
|
||||||
|
return this.element.find('tr:not(.header) td.checkbox :checkbox:checked').parents('tr:first');
|
||||||
|
},
|
||||||
|
|
||||||
|
all_uuids: function() {
|
||||||
|
var uuids = [];
|
||||||
|
this.element.find('tr:not(.header)').each(function() {
|
||||||
|
uuids.push($(this).data('uuid'));
|
||||||
|
});
|
||||||
|
return uuids;
|
||||||
|
},
|
||||||
|
|
||||||
|
selected_uuids: function() {
|
||||||
|
var uuids = [];
|
||||||
|
this.element.find('tr:not(.header) td.checkbox :checkbox:checked').each(function() {
|
||||||
|
uuids.push($(this).parents('tr:first').data('uuid'));
|
||||||
|
});
|
||||||
|
return uuids;
|
||||||
|
}
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
})( jQuery );
|
||||||
|
|
||||||
|
|
||||||
|
/**********************************************************************
|
||||||
|
* gridwrapper plugin
|
||||||
|
**********************************************************************/
|
||||||
|
|
||||||
|
(function($) {
|
||||||
|
|
||||||
|
$.widget('tailbone.gridwrapper', {
|
||||||
|
|
||||||
|
_create: function() {
|
||||||
|
|
||||||
|
var that = this;
|
||||||
|
|
||||||
|
// Snag some element references.
|
||||||
|
this.filters = this.element.find('.newfilters');
|
||||||
|
this.filters_form = this.filters.find('form');
|
||||||
|
this.add_filter = this.filters.find('#add-filter');
|
||||||
|
this.apply_filters = this.filters.find('#apply-filters');
|
||||||
|
this.default_filters = this.filters.find('#default-filters');
|
||||||
|
this.clear_filters = this.filters.find('#clear-filters');
|
||||||
|
this.save_defaults = this.filters.find('#save-defaults');
|
||||||
|
this.grid = this.element.find('.grid');
|
||||||
|
|
||||||
|
// add standard grid behavior
|
||||||
|
this.grid.gridcore();
|
||||||
|
|
||||||
|
// Enhance filters etc.
|
||||||
|
this.filters.find('.filter').gridfilter();
|
||||||
|
this.apply_filters.button('option', 'icons', {primary: 'ui-icon-search'});
|
||||||
|
this.default_filters.button('option', 'icons', {primary: 'ui-icon-home'});
|
||||||
|
this.clear_filters.button('option', 'icons', {primary: 'ui-icon-trash'});
|
||||||
|
this.save_defaults.button('option', 'icons', {primary: 'ui-icon-disk'});
|
||||||
|
if (! this.filters.find('.active:checked').length) {
|
||||||
|
this.apply_filters.button('disable');
|
||||||
|
}
|
||||||
|
this.add_filter.selectmenu({
|
||||||
|
width: '15em',
|
||||||
|
|
||||||
|
// Initially disabled if contains no enabled filter options.
|
||||||
|
disabled: this.add_filter.find('option:enabled').length == 1,
|
||||||
|
|
||||||
|
// When add-filter choice is made, show/focus new filter value input,
|
||||||
|
// and maybe hide the add-filter selection or show the apply button.
|
||||||
|
change: function (event, ui) {
|
||||||
|
var filter = that.filters.find('#filter-' + ui.item.value);
|
||||||
|
var select = $(this);
|
||||||
|
var option = ui.item.element;
|
||||||
|
filter.gridfilter('active', true);
|
||||||
|
filter.gridfilter('focus');
|
||||||
|
select.val('');
|
||||||
|
option.attr('disabled', 'disabled');
|
||||||
|
select.selectmenu('refresh');
|
||||||
|
if (select.find('option:enabled').length == 1) { // prompt is always enabled
|
||||||
|
select.selectmenu('disable');
|
||||||
|
}
|
||||||
|
that.apply_filters.button('enable');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.add_filter.on('selectmenuopen', function(event, ui) {
|
||||||
|
show_all_options($(this));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Intercept filters form submittal, and submit via AJAX instead.
|
||||||
|
this.filters_form.on('submit', function() {
|
||||||
|
var settings = {filter: true, partial: true};
|
||||||
|
if (that.filters_form.find('input[name="save-current-filters-as-defaults"]').val() == 'true') {
|
||||||
|
settings['save-current-filters-as-defaults'] = true;
|
||||||
|
}
|
||||||
|
that.filters.find('.filter').each(function() {
|
||||||
|
|
||||||
|
// currently active filters will be included in form data
|
||||||
|
if ($(this).gridfilter('active')) {
|
||||||
|
settings[$(this).data('key')] = $(this).gridfilter('value');
|
||||||
|
settings[$(this).data('key') + '.verb'] = $(this).gridfilter('verb');
|
||||||
|
|
||||||
|
// others will be hidden from view
|
||||||
|
} else {
|
||||||
|
$(this).gridfilter('hide');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// if no filters are visible, disable submit button
|
||||||
|
if (! that.filters.find('.filter:visible').length) {
|
||||||
|
that.apply_filters.button('disable');
|
||||||
|
}
|
||||||
|
|
||||||
|
// okay, submit filters to server and refresh grid
|
||||||
|
that.refresh(settings);
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
// When user clicks Default Filters button, refresh page with
|
||||||
|
// instructions for the server to reset filters to default settings.
|
||||||
|
this.default_filters.click(function() {
|
||||||
|
that.filters_form.off('submit');
|
||||||
|
that.filters_form.find('input[name="reset-to-default-filters"]').val('true');
|
||||||
|
that.element.mask("Refreshing data...");
|
||||||
|
that.filters_form.get(0).submit();
|
||||||
|
});
|
||||||
|
|
||||||
|
// When user clicks Save Defaults button, refresh the grid as with
|
||||||
|
// Apply Filters, but add an instruction for the server to save
|
||||||
|
// current settings as defaults for the user.
|
||||||
|
this.save_defaults.click(function() {
|
||||||
|
that.filters_form.find('input[name="save-current-filters-as-defaults"]').val('true');
|
||||||
|
that.filters_form.submit();
|
||||||
|
that.filters_form.find('input[name="save-current-filters-as-defaults"]').val('false');
|
||||||
|
});
|
||||||
|
|
||||||
|
// When user clicks Clear Filters button, deactivate all filters
|
||||||
|
// and refresh the grid.
|
||||||
|
this.clear_filters.click(function() {
|
||||||
|
that.filters.find('.filter').each(function() {
|
||||||
|
if ($(this).gridfilter('active')) {
|
||||||
|
$(this).gridfilter('active', false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
that.filters_form.submit();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Refresh data when user clicks a sortable column header.
|
||||||
|
this.element.on('click', 'tr.header a', function() {
|
||||||
|
var td = $(this).parent();
|
||||||
|
var data = {
|
||||||
|
sortkey: $(this).data('sortkey'),
|
||||||
|
sortdir: (td.hasClass('asc')) ? 'desc' : 'asc',
|
||||||
|
page: 1,
|
||||||
|
partial: true
|
||||||
|
};
|
||||||
|
that.refresh(data);
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Refresh data when user chooses a new page size setting.
|
||||||
|
this.element.on('change', '.pager #pagesize', function() {
|
||||||
|
var settings = {
|
||||||
|
partial: true,
|
||||||
|
pagesize: $(this).val()
|
||||||
|
};
|
||||||
|
that.refresh(settings);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Refresh data when user clicks a pager link.
|
||||||
|
this.element.on('click', '.pager a', function() {
|
||||||
|
that.refresh(this.search.substring(1)); // remove leading '?'
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Refreshes the visible data within the grid, according to the given settings.
|
||||||
|
refresh: function(settings) {
|
||||||
|
var that = this;
|
||||||
|
this.element.mask("Refreshing data...");
|
||||||
|
$.get(this.grid.data('url'), settings, function(data) {
|
||||||
|
that.grid.replaceWith(data);
|
||||||
|
that.grid = that.element.find('.grid');
|
||||||
|
that.grid.gridcore();
|
||||||
|
that.element.unmask();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
results_count: function(as_text) {
|
||||||
|
var count = null;
|
||||||
|
var match = /showing \d+ thru \d+ of (\S+)/.exec(this.element.find('.pager .showing').text());
|
||||||
|
if (match) {
|
||||||
|
count = match[1];
|
||||||
|
if (!as_text) {
|
||||||
|
count = parseInt(count, 10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
},
|
||||||
|
|
||||||
|
all_uuids: function() {
|
||||||
|
return this.grid.gridcore('all_uuids');
|
||||||
|
},
|
||||||
|
|
||||||
|
selected_uuids: function() {
|
||||||
|
return this.grid.gridcore('selected_uuids');
|
||||||
|
}
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
})( jQuery );
|
||||||
|
|
||||||
|
|
||||||
|
/**********************************************************************
|
||||||
|
* gridfilter plugin
|
||||||
|
**********************************************************************/
|
||||||
|
|
||||||
|
(function($) {
|
||||||
|
|
||||||
|
$.widget('tailbone.gridfilter', {
|
||||||
|
|
||||||
|
_create: function() {
|
||||||
|
|
||||||
|
var that = this;
|
||||||
|
|
||||||
|
// Track down some important elements.
|
||||||
|
this.checkbox = this.element.find('input[name$="-active"]');
|
||||||
|
this.label = this.element.find('label');
|
||||||
|
this.inputs = this.element.find('.inputs');
|
||||||
|
this.add_filter = this.element.parents('.grid-wrapper').find('#add-filter');
|
||||||
|
|
||||||
|
// Hide the checkbox and label, and add button for toggling active status.
|
||||||
|
this.checkbox.addClass('ui-helper-hidden-accessible');
|
||||||
|
this.label.hide();
|
||||||
|
this.activebutton = $('<button type="button" class="toggle" />')
|
||||||
|
.insertAfter(this.label)
|
||||||
|
.text(this.label.text())
|
||||||
|
.button({
|
||||||
|
icons: {primary: 'ui-icon-blank'}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Enhance verb dropdown as selectmenu.
|
||||||
|
this.verb_select = this.inputs.find('.verb');
|
||||||
|
this.valueless_verbs = {};
|
||||||
|
$.each(this.verb_select.data('hide-value-for').split(' '), function(index, value) {
|
||||||
|
that.valueless_verbs[value] = true;
|
||||||
|
});
|
||||||
|
this.verb_select.selectmenu({
|
||||||
|
width: '15em',
|
||||||
|
change: function(event, ui) {
|
||||||
|
if (ui.item.value in that.valueless_verbs) {
|
||||||
|
that.inputs.find('.value').hide();
|
||||||
|
} else {
|
||||||
|
that.inputs.find('.value').show();
|
||||||
|
that.focus();
|
||||||
|
that.select();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.verb_select.on('selectmenuopen', function(event, ui) {
|
||||||
|
show_all_options($(this));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Enhance any date values with datepicker widget.
|
||||||
|
this.inputs.find('.value input[data-datepicker="true"]').datepicker({
|
||||||
|
dateFormat: 'yy-mm-dd',
|
||||||
|
changeYear: true,
|
||||||
|
changeMonth: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Enhance any choice/dropdown values with selectmenu.
|
||||||
|
this.inputs.find('.value select').selectmenu({
|
||||||
|
// provide sane width for value dropdown
|
||||||
|
width: '15em'
|
||||||
|
});
|
||||||
|
|
||||||
|
this.inputs.find('.value select').on('selectmenuopen', function(event, ui) {
|
||||||
|
show_all_options($(this));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listen for button click, to keep checkbox in sync.
|
||||||
|
this._on(this.activebutton, {
|
||||||
|
click: function(e) {
|
||||||
|
var checked = !this.checkbox.is(':checked');
|
||||||
|
this.checkbox.prop('checked', checked);
|
||||||
|
this.refresh();
|
||||||
|
if (checked) {
|
||||||
|
this.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update the initial state of the button according to checkbox.
|
||||||
|
this.refresh();
|
||||||
|
},
|
||||||
|
|
||||||
|
refresh: function() {
|
||||||
|
if (this.checkbox.is(':checked')) {
|
||||||
|
this.activebutton.button('option', 'icons', {primary: 'ui-icon-check'});
|
||||||
|
if (this.verb() in this.valueless_verbs) {
|
||||||
|
this.inputs.find('.value').hide();
|
||||||
|
} else {
|
||||||
|
this.inputs.find('.value').show();
|
||||||
|
}
|
||||||
|
this.inputs.show();
|
||||||
|
} else {
|
||||||
|
this.activebutton.button('option', 'icons', {primary: 'ui-icon-blank'});
|
||||||
|
this.inputs.hide();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
active: function(value) {
|
||||||
|
if (value === undefined) {
|
||||||
|
return this.checkbox.is(':checked');
|
||||||
|
}
|
||||||
|
if (value) {
|
||||||
|
if (!this.checkbox.is(':checked')) {
|
||||||
|
this.checkbox.prop('checked', true);
|
||||||
|
this.refresh();
|
||||||
|
this.element.show();
|
||||||
|
}
|
||||||
|
} else if (this.checkbox.is(':checked')) {
|
||||||
|
this.checkbox.prop('checked', false);
|
||||||
|
this.refresh();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
hide: function() {
|
||||||
|
this.active(false);
|
||||||
|
this.element.hide();
|
||||||
|
var option = this.add_filter.find('option[value="' + this.element.data('key') + '"]');
|
||||||
|
option.attr('disabled', false);
|
||||||
|
if (this.add_filter.selectmenu('option', 'disabled')) {
|
||||||
|
this.add_filter.selectmenu('enable');
|
||||||
|
}
|
||||||
|
this.add_filter.selectmenu('refresh');
|
||||||
|
},
|
||||||
|
|
||||||
|
focus: function() {
|
||||||
|
this.inputs.find('.value input').focus();
|
||||||
|
},
|
||||||
|
|
||||||
|
select: function() {
|
||||||
|
this.inputs.find('.value input').select();
|
||||||
|
},
|
||||||
|
|
||||||
|
value: function() {
|
||||||
|
return this.inputs.find('.value input, .value select').val();
|
||||||
|
},
|
||||||
|
|
||||||
|
verb: function() {
|
||||||
|
return this.inputs.find('.verb').val();
|
||||||
|
}
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
})( jQuery );
|
10
tailbone/static/js/lib/jquery.loadmask.min.js
vendored
Normal file
10
tailbone/static/js/lib/jquery.loadmask.min.js
vendored
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
/**
|
||||||
|
* Copyright (c) 2009 Sergiy Kovalchuk (serg472@gmail.com)
|
||||||
|
*
|
||||||
|
* Dual licensed under the MIT (http://www.opensource.org/licenses/mit-license.php)
|
||||||
|
* and GPL (http://www.opensource.org/licenses/gpl-license.php) licenses.
|
||||||
|
*
|
||||||
|
* Following code is based on Element.mask() implementation from ExtJS framework (http://extjs.com/)
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
(function(a){a.fn.mask=function(c,b){a(this).each(function(){if(b!==undefined&&b>0){var d=a(this);d.data("_mask_timeout",setTimeout(function(){a.maskElement(d,c)},b))}else{a.maskElement(a(this),c)}})};a.fn.unmask=function(){a(this).each(function(){a.unmaskElement(a(this))})};a.fn.isMasked=function(){return this.hasClass("masked")};a.maskElement=function(d,c){if(d.data("_mask_timeout")!==undefined){clearTimeout(d.data("_mask_timeout"));d.removeData("_mask_timeout")}if(d.isMasked()){a.unmaskElement(d)}if(d.css("position")=="static"){d.addClass("masked-relative")}d.addClass("masked");var e=a('<div class="loadmask"></div>');if(navigator.userAgent.toLowerCase().indexOf("msie")>-1){e.height(d.height()+parseInt(d.css("padding-top"))+parseInt(d.css("padding-bottom")));e.width(d.width()+parseInt(d.css("padding-left"))+parseInt(d.css("padding-right")))}if(navigator.userAgent.toLowerCase().indexOf("msie 6")>-1){d.find("select").addClass("masked-hidden")}d.append(e);if(c!==undefined){var b=a('<div class="loadmask-msg" style="display:none;"></div>');b.append("<div>"+c+"</div>");d.append(b);b.css("top",Math.round(d.height()/2-(b.height()-parseInt(b.css("padding-top"))-parseInt(b.css("padding-bottom")))/2)+"px");b.css("left",Math.round(d.width()/2-(b.width()-parseInt(b.css("padding-left"))-parseInt(b.css("padding-right")))/2)+"px");b.show()}};a.unmaskElement=function(b){if(b.data("_mask_timeout")!==undefined){clearTimeout(b.data("_mask_timeout"));b.removeData("_mask_timeout")}b.find(".loadmask-msg,.loadmask").remove();b.removeClass("masked");b.removeClass("masked-relative");b.find("select").removeClass("masked-hidden")}})(jQuery);
|
331
tailbone/static/js/lib/jquery.ui.menubar.js
vendored
Normal file
331
tailbone/static/js/lib/jquery.ui.menubar.js
vendored
Normal file
|
@ -0,0 +1,331 @@
|
||||||
|
/*
|
||||||
|
* jQuery UI Menubar @VERSION
|
||||||
|
*
|
||||||
|
* Copyright 2011, AUTHORS.txt (http://jqueryui.com/about)
|
||||||
|
* Dual licensed under the MIT or GPL Version 2 licenses.
|
||||||
|
* http://jquery.org/license
|
||||||
|
*
|
||||||
|
* http://docs.jquery.com/UI/Menubar
|
||||||
|
*
|
||||||
|
* Depends:
|
||||||
|
* jquery.ui.core.js
|
||||||
|
* jquery.ui.widget.js
|
||||||
|
* jquery.ui.position.js
|
||||||
|
* jquery.ui.menu.js
|
||||||
|
*/
|
||||||
|
(function( $ ) {
|
||||||
|
|
||||||
|
// TODO when mixing clicking menus and keyboard navigation, focus handling is broken
|
||||||
|
// there has to be just one item that has tabindex
|
||||||
|
$.widget( "ui.menubar", {
|
||||||
|
version: "@VERSION",
|
||||||
|
options: {
|
||||||
|
autoExpand: false,
|
||||||
|
buttons: false,
|
||||||
|
items: "li",
|
||||||
|
menuElement: "ul",
|
||||||
|
menuIcon: false,
|
||||||
|
position: {
|
||||||
|
my: "left top",
|
||||||
|
at: "left bottom"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
_create: function() {
|
||||||
|
var that = this;
|
||||||
|
this.menuItems = this.element.children( this.options.items );
|
||||||
|
this.items = this.menuItems.children( "button, a" );
|
||||||
|
|
||||||
|
this.menuItems
|
||||||
|
.addClass( "ui-menubar-item" )
|
||||||
|
.attr( "role", "presentation" );
|
||||||
|
// let only the first item receive focus
|
||||||
|
this.items.slice(1).attr( "tabIndex", -1 );
|
||||||
|
|
||||||
|
this.element
|
||||||
|
.addClass( "ui-menubar ui-widget-header ui-helper-clearfix" )
|
||||||
|
.attr( "role", "menubar" );
|
||||||
|
this._focusable( this.items );
|
||||||
|
this._hoverable( this.items );
|
||||||
|
this.items.siblings( this.options.menuElement )
|
||||||
|
.menu({
|
||||||
|
position: {
|
||||||
|
within: this.options.position.within
|
||||||
|
},
|
||||||
|
select: function( event, ui ) {
|
||||||
|
ui.item.parents( "ul.ui-menu:last" ).hide();
|
||||||
|
that._close();
|
||||||
|
// TODO what is this targetting? there's probably a better way to access it
|
||||||
|
$(event.target).prev().focus();
|
||||||
|
that._trigger( "select", event, ui );
|
||||||
|
},
|
||||||
|
menus: that.options.menuElement
|
||||||
|
})
|
||||||
|
.hide()
|
||||||
|
.attr({
|
||||||
|
"aria-hidden": "true",
|
||||||
|
"aria-expanded": "false"
|
||||||
|
})
|
||||||
|
// TODO use _on
|
||||||
|
.bind( "keydown.menubar", function( event ) {
|
||||||
|
var menu = $( this );
|
||||||
|
if ( menu.is( ":hidden" ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
switch ( event.keyCode ) {
|
||||||
|
case $.ui.keyCode.LEFT:
|
||||||
|
that.previous( event );
|
||||||
|
event.preventDefault();
|
||||||
|
break;
|
||||||
|
case $.ui.keyCode.RIGHT:
|
||||||
|
that.next( event );
|
||||||
|
event.preventDefault();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.items.each(function() {
|
||||||
|
var input = $(this),
|
||||||
|
// TODO menu var is only used on two places, doesn't quite justify the .each
|
||||||
|
menu = input.next( that.options.menuElement );
|
||||||
|
|
||||||
|
// might be a non-menu button
|
||||||
|
if ( menu.length ) {
|
||||||
|
// TODO use _on
|
||||||
|
input.bind( "click.menubar focus.menubar mouseenter.menubar", function( event ) {
|
||||||
|
// ignore triggered focus event
|
||||||
|
if ( event.type === "focus" && !event.originalEvent ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
// TODO can we simplify or extractthis check? especially the last two expressions
|
||||||
|
// there's a similar active[0] == menu[0] check in _open
|
||||||
|
if ( event.type === "click" && menu.is( ":visible" ) && that.active && that.active[0] === menu[0] ) {
|
||||||
|
that._close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ( ( that.open && event.type === "mouseenter" ) || event.type === "click" || that.options.autoExpand ) {
|
||||||
|
if( that.options.autoExpand ) {
|
||||||
|
clearTimeout( that.closeTimer );
|
||||||
|
}
|
||||||
|
|
||||||
|
that._open( event, menu );
|
||||||
|
}
|
||||||
|
})
|
||||||
|
// TODO use _on
|
||||||
|
.bind( "keydown", function( event ) {
|
||||||
|
switch ( event.keyCode ) {
|
||||||
|
case $.ui.keyCode.SPACE:
|
||||||
|
case $.ui.keyCode.UP:
|
||||||
|
case $.ui.keyCode.DOWN:
|
||||||
|
that._open( event, $( this ).next() );
|
||||||
|
event.preventDefault();
|
||||||
|
break;
|
||||||
|
case $.ui.keyCode.LEFT:
|
||||||
|
that.previous( event );
|
||||||
|
event.preventDefault();
|
||||||
|
break;
|
||||||
|
case $.ui.keyCode.RIGHT:
|
||||||
|
that.next( event );
|
||||||
|
event.preventDefault();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.attr( "aria-haspopup", "true" );
|
||||||
|
|
||||||
|
// TODO review if these options (menuIcon and buttons) are a good choice, maybe they can be merged
|
||||||
|
if ( that.options.menuIcon ) {
|
||||||
|
input.addClass( "ui-state-default" ).append( "<span class='ui-button-icon-secondary ui-icon ui-icon-triangle-1-s'></span>" );
|
||||||
|
input.removeClass( "ui-button-text-only" ).addClass( "ui-button-text-icon-secondary" );
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// TODO use _on
|
||||||
|
input.bind( "click.menubar mouseenter.menubar", function( event ) {
|
||||||
|
if ( ( that.open && event.type === "mouseenter" ) || event.type === "click" ) {
|
||||||
|
that._close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
input
|
||||||
|
.addClass( "ui-button ui-widget ui-button-text-only ui-menubar-link" )
|
||||||
|
.attr( "role", "menuitem" )
|
||||||
|
.wrapInner( "<span class='ui-button-text'></span>" );
|
||||||
|
|
||||||
|
if ( that.options.buttons ) {
|
||||||
|
input.removeClass( "ui-menubar-link" ).addClass( "ui-state-default" );
|
||||||
|
}
|
||||||
|
});
|
||||||
|
that._on( {
|
||||||
|
keydown: function( event ) {
|
||||||
|
if ( event.keyCode === $.ui.keyCode.ESCAPE && that.active && that.active.menu( "collapse", event ) !== true ) {
|
||||||
|
var active = that.active;
|
||||||
|
that.active.blur();
|
||||||
|
that._close( event );
|
||||||
|
active.prev().focus();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
focusin: function( event ) {
|
||||||
|
clearTimeout( that.closeTimer );
|
||||||
|
},
|
||||||
|
focusout: function( event ) {
|
||||||
|
that.closeTimer = setTimeout( function() {
|
||||||
|
that._close( event );
|
||||||
|
}, 150);
|
||||||
|
},
|
||||||
|
"mouseleave .ui-menubar-item": function( event ) {
|
||||||
|
if ( that.options.autoExpand ) {
|
||||||
|
that.closeTimer = setTimeout( function() {
|
||||||
|
that._close( event );
|
||||||
|
}, 150);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"mouseenter .ui-menubar-item": function( event ) {
|
||||||
|
clearTimeout( that.closeTimer );
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keep track of open submenus
|
||||||
|
this.openSubmenus = 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
_destroy : function() {
|
||||||
|
this.menuItems
|
||||||
|
.removeClass( "ui-menubar-item" )
|
||||||
|
.removeAttr( "role" );
|
||||||
|
|
||||||
|
this.element
|
||||||
|
.removeClass( "ui-menubar ui-widget-header ui-helper-clearfix" )
|
||||||
|
.removeAttr( "role" )
|
||||||
|
.unbind( ".menubar" );
|
||||||
|
|
||||||
|
this.items
|
||||||
|
.unbind( ".menubar" )
|
||||||
|
.removeClass( "ui-button ui-widget ui-button-text-only ui-menubar-link ui-state-default" )
|
||||||
|
.removeAttr( "role" )
|
||||||
|
.removeAttr( "aria-haspopup" )
|
||||||
|
// TODO unwrap?
|
||||||
|
.children( "span.ui-button-text" ).each(function( i, e ) {
|
||||||
|
var item = $( this );
|
||||||
|
item.parent().html( item.html() );
|
||||||
|
})
|
||||||
|
.end()
|
||||||
|
.children( ".ui-icon" ).remove();
|
||||||
|
|
||||||
|
this.element.find( ":ui-menu" )
|
||||||
|
.menu( "destroy" )
|
||||||
|
.show()
|
||||||
|
.removeAttr( "aria-hidden" )
|
||||||
|
.removeAttr( "aria-expanded" )
|
||||||
|
.removeAttr( "tabindex" )
|
||||||
|
.unbind( ".menubar" );
|
||||||
|
},
|
||||||
|
|
||||||
|
_close: function() {
|
||||||
|
if ( !this.active || !this.active.length ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.active
|
||||||
|
.menu( "collapseAll" )
|
||||||
|
.hide()
|
||||||
|
.attr({
|
||||||
|
"aria-hidden": "true",
|
||||||
|
"aria-expanded": "false"
|
||||||
|
});
|
||||||
|
this.active
|
||||||
|
.prev()
|
||||||
|
.removeClass( "ui-state-active" )
|
||||||
|
.removeAttr( "tabIndex" );
|
||||||
|
this.active = null;
|
||||||
|
this.open = false;
|
||||||
|
this.openSubmenus = 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
_open: function( event, menu ) {
|
||||||
|
// on a single-button menubar, ignore reopening the same menu
|
||||||
|
if ( this.active && this.active[0] === menu[0] ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// TODO refactor, almost the same as _close above, but don't remove tabIndex
|
||||||
|
if ( this.active ) {
|
||||||
|
this.active
|
||||||
|
.menu( "collapseAll" )
|
||||||
|
.hide()
|
||||||
|
.attr({
|
||||||
|
"aria-hidden": "true",
|
||||||
|
"aria-expanded": "false"
|
||||||
|
});
|
||||||
|
this.active
|
||||||
|
.prev()
|
||||||
|
.removeClass( "ui-state-active" );
|
||||||
|
}
|
||||||
|
// set tabIndex -1 to have the button skipped on shift-tab when menu is open (it gets focus)
|
||||||
|
var button = menu.prev().addClass( "ui-state-active" ).attr( "tabIndex", -1 );
|
||||||
|
this.active = menu
|
||||||
|
.show()
|
||||||
|
.position( $.extend({
|
||||||
|
of: button
|
||||||
|
}, this.options.position ) )
|
||||||
|
.removeAttr( "aria-hidden" )
|
||||||
|
.attr( "aria-expanded", "true" )
|
||||||
|
.menu("focus", event, menu.children( ".ui-menu-item" ).first() )
|
||||||
|
// TODO need a comment here why both events are triggered
|
||||||
|
// TODO: heh well given the above comment i'm not sure what the
|
||||||
|
// implications might be for disabling the focus() call..but it
|
||||||
|
// messes with text input focus in undesirable ways..so disable it
|
||||||
|
// we will..until we know why we shouldn't
|
||||||
|
// .focus()
|
||||||
|
.focusin();
|
||||||
|
this.open = true;
|
||||||
|
},
|
||||||
|
|
||||||
|
next: function( event ) {
|
||||||
|
if ( this.open && this.active.data( "menu" ).active.has( ".ui-menu" ).length ) {
|
||||||
|
// Track number of open submenus and prevent moving to next menubar item
|
||||||
|
this.openSubmenus++;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.openSubmenus = 0;
|
||||||
|
this._move( "next", "first", event );
|
||||||
|
},
|
||||||
|
|
||||||
|
previous: function( event ) {
|
||||||
|
if ( this.open && this.openSubmenus ) {
|
||||||
|
// Track number of open submenus and prevent moving to previous menubar item
|
||||||
|
this.openSubmenus--;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.openSubmenus = 0;
|
||||||
|
this._move( "prev", "last", event );
|
||||||
|
},
|
||||||
|
|
||||||
|
_move: function( direction, filter, event ) {
|
||||||
|
var next,
|
||||||
|
wrapItem;
|
||||||
|
if ( this.open ) {
|
||||||
|
next = this.active.closest( ".ui-menubar-item" )[ direction + "All" ]( this.options.items ).first().children( ".ui-menu" ).eq( 0 );
|
||||||
|
wrapItem = this.menuItems[ filter ]().children( ".ui-menu" ).eq( 0 );
|
||||||
|
} else {
|
||||||
|
if ( event ) {
|
||||||
|
next = $( event.target ).closest( ".ui-menubar-item" )[ direction + "All" ]( this.options.items ).children( ".ui-menubar-link" ).eq( 0 );
|
||||||
|
wrapItem = this.menuItems[ filter ]().children( ".ui-menubar-link" ).eq( 0 );
|
||||||
|
} else {
|
||||||
|
next = wrapItem = this.menuItems.children( "a" ).eq( 0 );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( next.length ) {
|
||||||
|
if ( this.open ) {
|
||||||
|
this._open( event, next );
|
||||||
|
} else {
|
||||||
|
next.removeAttr( "tabIndex")[0].focus();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if ( this.open ) {
|
||||||
|
this._open( event, wrapItem );
|
||||||
|
} else {
|
||||||
|
wrapItem.removeAttr( "tabIndex")[0].focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
}( jQuery ));
|
1496
tailbone/static/js/lib/jquery.ui.timepicker.js
vendored
Normal file
1496
tailbone/static/js/lib/jquery.ui.timepicker.js
vendored
Normal file
File diff suppressed because it is too large
Load diff
17
tailbone/static/js/lib/tag-it.min.js
vendored
Normal file
17
tailbone/static/js/lib/tag-it.min.js
vendored
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
(function(b){b.widget("ui.tagit",{options:{allowDuplicates:!1,caseSensitive:!0,fieldName:"tags",placeholderText:null,readOnly:!1,removeConfirmation:!1,tagLimit:null,availableTags:[],autocomplete:{},showAutocompleteOnFocus:!1,allowSpaces:!1,singleField:!1,singleFieldDelimiter:",",singleFieldNode:null,animate:!0,tabIndex:null,beforeTagAdded:null,afterTagAdded:null,beforeTagRemoved:null,afterTagRemoved:null,onTagClicked:null,onTagLimitExceeded:null,onTagAdded:null,onTagRemoved:null,tagSource:null},_create:function(){var a=
|
||||||
|
this;this.element.is("input")?(this.tagList=b("<ul></ul>").insertAfter(this.element),this.options.singleField=!0,this.options.singleFieldNode=this.element,this.element.addClass("tagit-hidden-field")):this.tagList=this.element.find("ul, ol").andSelf().last();this.tagInput=b('<input type="text" />').addClass("ui-widget-content");this.options.readOnly&&this.tagInput.attr("disabled","disabled");this.options.tabIndex&&this.tagInput.attr("tabindex",this.options.tabIndex);this.options.placeholderText&&this.tagInput.attr("placeholder",
|
||||||
|
this.options.placeholderText);this.options.autocomplete.source||(this.options.autocomplete.source=function(a,e){var d=a.term.toLowerCase(),c=b.grep(this.options.availableTags,function(a){return 0===a.toLowerCase().indexOf(d)});this.options.allowDuplicates||(c=this._subtractArray(c,this.assignedTags()));e(c)});this.options.showAutocompleteOnFocus&&(this.tagInput.focus(function(b,d){a._showAutocomplete()}),"undefined"===typeof this.options.autocomplete.minLength&&(this.options.autocomplete.minLength=
|
||||||
|
0));b.isFunction(this.options.autocomplete.source)&&(this.options.autocomplete.source=b.proxy(this.options.autocomplete.source,this));b.isFunction(this.options.tagSource)&&(this.options.tagSource=b.proxy(this.options.tagSource,this));this.tagList.addClass("tagit").addClass("ui-widget ui-widget-content ui-corner-all").append(b('<li class="tagit-new"></li>').append(this.tagInput)).click(function(d){var c=b(d.target);c.hasClass("tagit-label")?(c=c.closest(".tagit-choice"),c.hasClass("removed")||a._trigger("onTagClicked",
|
||||||
|
d,{tag:c,tagLabel:a.tagLabel(c)})):a.tagInput.focus()});var c=!1;if(this.options.singleField)if(this.options.singleFieldNode){var d=b(this.options.singleFieldNode),f=d.val().split(this.options.singleFieldDelimiter);d.val("");b.each(f,function(b,d){a.createTag(d,null,!0);c=!0})}else this.options.singleFieldNode=b('<input type="hidden" style="display:none;" value="" name="'+this.options.fieldName+'" />'),this.tagList.after(this.options.singleFieldNode);c||this.tagList.children("li").each(function(){b(this).hasClass("tagit-new")||
|
||||||
|
(a.createTag(b(this).text(),b(this).attr("class"),!0),b(this).remove())});this.tagInput.keydown(function(c){if(c.which==b.ui.keyCode.BACKSPACE&&""===a.tagInput.val()){var d=a._lastTag();!a.options.removeConfirmation||d.hasClass("remove")?a.removeTag(d):a.options.removeConfirmation&&d.addClass("remove ui-state-highlight")}else a.options.removeConfirmation&&a._lastTag().removeClass("remove ui-state-highlight");if(c.which===b.ui.keyCode.COMMA&&!1===c.shiftKey||c.which===b.ui.keyCode.ENTER||c.which==
|
||||||
|
b.ui.keyCode.TAB&&""!==a.tagInput.val()||c.which==b.ui.keyCode.SPACE&&!0!==a.options.allowSpaces&&('"'!=b.trim(a.tagInput.val()).replace(/^s*/,"").charAt(0)||'"'==b.trim(a.tagInput.val()).charAt(0)&&'"'==b.trim(a.tagInput.val()).charAt(b.trim(a.tagInput.val()).length-1)&&0!==b.trim(a.tagInput.val()).length-1))c.which===b.ui.keyCode.ENTER&&""===a.tagInput.val()||c.preventDefault(),a.options.autocomplete.autoFocus&&a.tagInput.data("autocomplete-open")||(a.tagInput.autocomplete("close"),a.createTag(a._cleanedInput()))}).blur(function(b){a.tagInput.data("autocomplete-open")||
|
||||||
|
a.createTag(a._cleanedInput())});if(this.options.availableTags||this.options.tagSource||this.options.autocomplete.source)d={select:function(b,c){a.createTag(c.item.value);return!1}},b.extend(d,this.options.autocomplete),d.source=this.options.tagSource||d.source,this.tagInput.autocomplete(d).bind("autocompleteopen.tagit",function(b,c){a.tagInput.data("autocomplete-open",!0)}).bind("autocompleteclose.tagit",function(b,c){a.tagInput.data("autocomplete-open",!1)}),this.tagInput.autocomplete("widget").addClass("tagit-autocomplete")},
|
||||||
|
destroy:function(){b.Widget.prototype.destroy.call(this);this.element.unbind(".tagit");this.tagList.unbind(".tagit");this.tagInput.removeData("autocomplete-open");this.tagList.removeClass("tagit ui-widget ui-widget-content ui-corner-all tagit-hidden-field");this.element.is("input")?(this.element.removeClass("tagit-hidden-field"),this.tagList.remove()):(this.element.children("li").each(function(){b(this).hasClass("tagit-new")?b(this).remove():(b(this).removeClass("tagit-choice ui-widget-content ui-state-default ui-state-highlight ui-corner-all remove tagit-choice-editable tagit-choice-read-only"),
|
||||||
|
b(this).text(b(this).children(".tagit-label").text()))}),this.singleFieldNode&&this.singleFieldNode.remove());return this},_cleanedInput:function(){return b.trim(this.tagInput.val().replace(/^"(.*)"$/,"$1"))},_lastTag:function(){return this.tagList.find(".tagit-choice:last:not(.removed)")},_tags:function(){return this.tagList.find(".tagit-choice:not(.removed)")},assignedTags:function(){var a=this,c=[];this.options.singleField?(c=b(this.options.singleFieldNode).val().split(this.options.singleFieldDelimiter),
|
||||||
|
""===c[0]&&(c=[])):this._tags().each(function(){c.push(a.tagLabel(this))});return c},_updateSingleTagsField:function(a){b(this.options.singleFieldNode).val(a.join(this.options.singleFieldDelimiter)).trigger("change")},_subtractArray:function(a,c){for(var d=[],f=0;f<a.length;f++)-1==b.inArray(a[f],c)&&d.push(a[f]);return d},tagLabel:function(a){return this.options.singleField?b(a).find(".tagit-label:first").text():b(a).find("input:first").val()},_showAutocomplete:function(){this.tagInput.autocomplete("search",
|
||||||
|
"")},_findTagByLabel:function(a){var c=this,d=null;this._tags().each(function(f){if(c._formatStr(a)==c._formatStr(c.tagLabel(this)))return d=b(this),!1});return d},_isNew:function(a){return!this._findTagByLabel(a)},_formatStr:function(a){return this.options.caseSensitive?a:b.trim(a.toLowerCase())},_effectExists:function(a){return Boolean(b.effects&&(b.effects[a]||b.effects.effect&&b.effects.effect[a]))},createTag:function(a,c,d){var f=this;a=b.trim(a);this.options.preprocessTag&&(a=this.options.preprocessTag(a));
|
||||||
|
if(""===a)return!1;if(!this.options.allowDuplicates&&!this._isNew(a))return a=this._findTagByLabel(a),!1!==this._trigger("onTagExists",null,{existingTag:a,duringInitialization:d})&&this._effectExists("highlight")&&a.effect("highlight"),!1;if(this.options.tagLimit&&this._tags().length>=this.options.tagLimit)return this._trigger("onTagLimitExceeded",null,{duringInitialization:d}),!1;var g=b(this.options.onTagClicked?'<a class="tagit-label"></a>':'<span class="tagit-label"></span>').text(a),e=b("<li></li>").addClass("tagit-choice ui-widget-content ui-state-default ui-corner-all").addClass(c).append(g);
|
||||||
|
this.options.readOnly?e.addClass("tagit-choice-read-only"):(e.addClass("tagit-choice-editable"),c=b("<span></span>").addClass("ui-icon ui-icon-close"),c=b('<a><span class="text-icon">\u00d7</span></a>').addClass("tagit-close").append(c).click(function(a){f.removeTag(e)}),e.append(c));this.options.singleField||(g=g.html(),e.append('<input type="hidden" value="'+g+'" name="'+this.options.fieldName+'" class="tagit-hidden-field" />'));!1!==this._trigger("beforeTagAdded",null,{tag:e,tagLabel:this.tagLabel(e),
|
||||||
|
duringInitialization:d})&&(this.options.singleField&&(g=this.assignedTags(),g.push(a),this._updateSingleTagsField(g)),this._trigger("onTagAdded",null,e),this.tagInput.val(""),this.tagInput.parent().before(e),this._trigger("afterTagAdded",null,{tag:e,tagLabel:this.tagLabel(e),duringInitialization:d}),this.options.showAutocompleteOnFocus&&!d&&setTimeout(function(){f._showAutocomplete()},0))},removeTag:function(a,c){c="undefined"===typeof c?this.options.animate:c;a=b(a);this._trigger("onTagRemoved",
|
||||||
|
null,a);if(!1!==this._trigger("beforeTagRemoved",null,{tag:a,tagLabel:this.tagLabel(a)})){if(this.options.singleField){var d=this.assignedTags(),f=this.tagLabel(a),d=b.grep(d,function(a){return a!=f});this._updateSingleTagsField(d)}if(c){a.addClass("removed");var d=this._effectExists("blind")?["blind",{direction:"horizontal"},"fast"]:["fast"],g=this;d.push(function(){a.remove();g._trigger("afterTagRemoved",null,{tag:a,tagLabel:g.tagLabel(a)})});a.fadeOut("fast").hide.apply(a,d).dequeue()}else a.remove(),
|
||||||
|
this._trigger("afterTagRemoved",null,{tag:a,tagLabel:this.tagLabel(a)})}},removeTagByLabel:function(a,b){var d=this._findTagByLabel(a);if(!d)throw"No such tag exists with the name '"+a+"'";this.removeTag(d,b)},removeAll:function(){var a=this;this._tags().each(function(b,d){a.removeTag(d,!1)})}})})(jQuery);
|
32
tailbone/static/js/login.js
Normal file
32
tailbone/static/js/login.js
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
|
||||||
|
$(function() {
|
||||||
|
|
||||||
|
$('input[name="username"]').keydown(function(event) {
|
||||||
|
if (event.which == 13) {
|
||||||
|
$('input[name="password"]').focus().select();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
$('form').submit(function() {
|
||||||
|
if (! $('input[name="username"]').val()) {
|
||||||
|
with ($('input[name="username"]').get(0)) {
|
||||||
|
select();
|
||||||
|
focus();
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (! $('input[name="password"]').val()) {
|
||||||
|
with ($('input[name="password"]').get(0)) {
|
||||||
|
select();
|
||||||
|
focus();
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
$('input[name="username"]').focus();
|
||||||
|
|
||||||
|
});
|
29
tailbone/static/js/tailbone.appsettings.js
Normal file
29
tailbone/static/js/tailbone.appsettings.js
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
|
||||||
|
/************************************************************
|
||||||
|
*
|
||||||
|
* tailbone.appsettings.js
|
||||||
|
*
|
||||||
|
* Logic for App Settings page.
|
||||||
|
*
|
||||||
|
************************************************************/
|
||||||
|
|
||||||
|
|
||||||
|
function show_group(group) {
|
||||||
|
if (group == "(All)") {
|
||||||
|
$('.panel').show();
|
||||||
|
} else {
|
||||||
|
$('.panel').hide();
|
||||||
|
$('.panel[data-groupname="' + group + '"]').show();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
$(function() {
|
||||||
|
|
||||||
|
$('#settings-group').on('selectmenuchange', function(event, ui) {
|
||||||
|
show_group(ui.item.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
show_group($('#settings-group').val());
|
||||||
|
|
||||||
|
});
|
41
tailbone/static/js/tailbone.batch.js
Normal file
41
tailbone/static/js/tailbone.batch.js
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
|
||||||
|
/************************************************************
|
||||||
|
*
|
||||||
|
* tailbone.batch.js
|
||||||
|
*
|
||||||
|
* Common logic for view/edit batch pages
|
||||||
|
*
|
||||||
|
************************************************************/
|
||||||
|
|
||||||
|
|
||||||
|
$(function() {
|
||||||
|
|
||||||
|
$('#execute-batch').click(function() {
|
||||||
|
if (has_execution_options) {
|
||||||
|
$('#execution-options-dialog').dialog({
|
||||||
|
title: "Execution Options",
|
||||||
|
width: 600,
|
||||||
|
modal: true,
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
text: "Execute",
|
||||||
|
click: function(event) {
|
||||||
|
dialog_button(event).button('option', 'label', "Executing, please wait...").button('disable');
|
||||||
|
$('form[name="batch-execution"]').submit();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: "Cancel",
|
||||||
|
click: function() {
|
||||||
|
$(this).dialog('close');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
$(this).button('option', 'label', "Executing, please wait...").button('disable');
|
||||||
|
$('form[name="batch-execution"]').submit();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
|
@ -11,7 +11,7 @@ const TailboneDatepicker = {
|
||||||
'icon="calendar-alt"',
|
'icon="calendar-alt"',
|
||||||
':date-formatter="formatDate"',
|
':date-formatter="formatDate"',
|
||||||
':date-parser="parseDate"',
|
':date-parser="parseDate"',
|
||||||
':value="buefyValue"',
|
':value="value ? parseDate(value) : null"',
|
||||||
'@input="dateChanged"',
|
'@input="dateChanged"',
|
||||||
':disabled="disabled"',
|
':disabled="disabled"',
|
||||||
'ref="trueDatePicker"',
|
'ref="trueDatePicker"',
|
||||||
|
@ -26,18 +26,6 @@ const TailboneDatepicker = {
|
||||||
disabled: Boolean,
|
disabled: Boolean,
|
||||||
},
|
},
|
||||||
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
buefyValue: this.parseDate(this.value),
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
watch: {
|
|
||||||
value(to, from) {
|
|
||||||
this.buefyValue = this.parseDate(to)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
|
|
||||||
formatDate(date) {
|
formatDate(date) {
|
||||||
|
@ -55,12 +43,9 @@ const TailboneDatepicker = {
|
||||||
},
|
},
|
||||||
|
|
||||||
parseDate(date) {
|
parseDate(date) {
|
||||||
if (typeof(date) == 'string') {
|
// note, this assumes classic YYYY-MM-DD (i.e. ISO?) format
|
||||||
// note, this assumes classic YYYY-MM-DD (i.e. ISO?) format
|
var parts = date.split('-')
|
||||||
var parts = date.split('-')
|
return new Date(parts[0], parseInt(parts[1]) - 1, parts[2])
|
||||||
return new Date(parts[0], parseInt(parts[1]) - 1, parts[2])
|
|
||||||
}
|
|
||||||
return date
|
|
||||||
},
|
},
|
||||||
|
|
||||||
dateChanged(date) {
|
dateChanged(date) {
|
||||||
|
|
156
tailbone/static/js/tailbone.buefy.grid.js
Normal file
156
tailbone/static/js/tailbone.buefy.grid.js
Normal file
|
@ -0,0 +1,156 @@
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
},
|
||||||
|
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)
|
|
@ -9,55 +9,15 @@ const TailboneTimepicker = {
|
||||||
'placeholder="Click to select ..."',
|
'placeholder="Click to select ..."',
|
||||||
'icon-pack="fas"',
|
'icon-pack="fas"',
|
||||||
'icon="clock"',
|
'icon="clock"',
|
||||||
':value="value ? parseTime(value) : null"',
|
|
||||||
'hour-format="12"',
|
'hour-format="12"',
|
||||||
'@input="timeChanged"',
|
|
||||||
':time-formatter="formatTime"',
|
|
||||||
'>',
|
'>',
|
||||||
'</b-timepicker>'
|
'</b-timepicker>'
|
||||||
].join(' '),
|
].join(' '),
|
||||||
|
|
||||||
props: {
|
props: {
|
||||||
name: String,
|
name: String,
|
||||||
id: String,
|
id: String
|
||||||
value: String,
|
}
|
||||||
},
|
|
||||||
|
|
||||||
methods: {
|
|
||||||
|
|
||||||
formatTime(time) {
|
|
||||||
if (time === null) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
let h = time.getHours()
|
|
||||||
let m = time.getMinutes()
|
|
||||||
let s = time.getSeconds()
|
|
||||||
|
|
||||||
h = h < 10 ? '0' + h : h
|
|
||||||
m = m < 10 ? '0' + m : m
|
|
||||||
s = s < 10 ? '0' + s : s
|
|
||||||
|
|
||||||
return h + ':' + m + ':' + s
|
|
||||||
},
|
|
||||||
|
|
||||||
parseTime(time) {
|
|
||||||
|
|
||||||
if (time.getHours) {
|
|
||||||
return time
|
|
||||||
}
|
|
||||||
|
|
||||||
let found = time.match(/^(\d\d):(\d\d):\d\d$/)
|
|
||||||
if (found) {
|
|
||||||
return new Date(null, null, null,
|
|
||||||
parseInt(found[1]), parseInt(found[2]))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
timeChanged(time) {
|
|
||||||
this.$emit('input', time)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Vue.component('tailbone-timepicker', TailboneTimepicker)
|
Vue.component('tailbone-timepicker', TailboneTimepicker)
|
||||||
|
|
193
tailbone/static/js/tailbone.edit-shifts.js
Normal file
193
tailbone/static/js/tailbone.edit-shifts.js
Normal file
|
@ -0,0 +1,193 @@
|
||||||
|
|
||||||
|
/************************************************************
|
||||||
|
*
|
||||||
|
* tailbone.edit-shifts.js
|
||||||
|
*
|
||||||
|
* Common logic for editing time sheet / schedule data.
|
||||||
|
*
|
||||||
|
************************************************************/
|
||||||
|
|
||||||
|
|
||||||
|
var editing_day = null;
|
||||||
|
var new_shift_id = 1;
|
||||||
|
|
||||||
|
function add_shift(focus, uuid, start_time, end_time) {
|
||||||
|
var shift = $('#snippets .shift').clone();
|
||||||
|
if (! uuid) {
|
||||||
|
uuid = 'new-' + (new_shift_id++).toString();
|
||||||
|
}
|
||||||
|
shift.attr('data-uuid', uuid);
|
||||||
|
shift.children('input').each(function() {
|
||||||
|
var name = $(this).attr('name') + '-' + uuid;
|
||||||
|
$(this).attr('name', name);
|
||||||
|
$(this).attr('id', name);
|
||||||
|
});
|
||||||
|
shift.children('input[name|="edit_start_time"]').val(start_time || '');
|
||||||
|
shift.children('input[name|="edit_end_time"]').val(end_time || '');
|
||||||
|
$('#day-editor .shifts').append(shift);
|
||||||
|
shift.children('input').timepicker({showPeriod: true});
|
||||||
|
if (focus) {
|
||||||
|
shift.children('input:first').focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function calc_minutes(start_time, end_time) {
|
||||||
|
var start = parseTime(start_time);
|
||||||
|
start = new Date(2000, 0, 1, start.hh, start.mm);
|
||||||
|
var end = parseTime(end_time);
|
||||||
|
end = new Date(2000, 0, 1, end.hh, end.mm);
|
||||||
|
return Math.floor((end - start) / 1000 / 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
function format_minutes(minutes) {
|
||||||
|
var hours = Math.floor(minutes / 60);
|
||||||
|
if (hours) {
|
||||||
|
minutes -= hours * 60;
|
||||||
|
}
|
||||||
|
return hours.toString() + ':' + (minutes < 10 ? '0' : '') + minutes.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// stolen from http://stackoverflow.com/a/1788084
|
||||||
|
function parseTime(s) {
|
||||||
|
var part = s.match(/(\d+):(\d+)(?: )?(am|pm)?/i);
|
||||||
|
var hh = parseInt(part[1], 10);
|
||||||
|
var mm = parseInt(part[2], 10);
|
||||||
|
var ap = part[3] ? part[3].toUpperCase() : null;
|
||||||
|
if (ap == 'AM') {
|
||||||
|
if (hh == 12) {
|
||||||
|
hh = 0;
|
||||||
|
}
|
||||||
|
} else if (ap == 'PM') {
|
||||||
|
if (hh != 12) {
|
||||||
|
hh += 12;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { hh: hh, mm: mm };
|
||||||
|
}
|
||||||
|
|
||||||
|
function time_input(shift, type) {
|
||||||
|
var input = shift.children('input[name|="' + type + '_time"]');
|
||||||
|
if (! input.length) {
|
||||||
|
input = $('<input type="hidden" name="' + type + '_time-' + shift.data('uuid') + '" />');
|
||||||
|
shift.append(input);
|
||||||
|
}
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
||||||
|
function update_row_hours(row) {
|
||||||
|
var minutes = 0;
|
||||||
|
row.find('.day .shift:not(.deleted)').each(function() {
|
||||||
|
var time_range = $.trim($(this).children('span').text()).split(' - ');
|
||||||
|
minutes += calc_minutes(time_range[0], time_range[1]);
|
||||||
|
});
|
||||||
|
row.children('.total').text(minutes ? format_minutes(minutes) : '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
$(function() {
|
||||||
|
|
||||||
|
$('.timesheet').on('click', '.day', function() {
|
||||||
|
editing_day = $(this);
|
||||||
|
var editor = $('#day-editor');
|
||||||
|
var employee = editing_day.siblings('.employee').text();
|
||||||
|
var date = weekdays[editing_day.get(0).cellIndex - 1];
|
||||||
|
var shifts = editor.children('.shifts');
|
||||||
|
shifts.empty();
|
||||||
|
editing_day.children('.shift:not(.deleted)').each(function() {
|
||||||
|
var uuid = $(this).data('uuid');
|
||||||
|
var time_range = $.trim($(this).children('span').text()).split(' - ');
|
||||||
|
add_shift(false, uuid, time_range[0], time_range[1]);
|
||||||
|
});
|
||||||
|
if (! shifts.children('.shift').length) {
|
||||||
|
add_shift();
|
||||||
|
}
|
||||||
|
editor.dialog({
|
||||||
|
modal: true,
|
||||||
|
title: employee + ' - ' + date,
|
||||||
|
position: {my: 'center', at: 'center', of: editing_day},
|
||||||
|
width: 'auto',
|
||||||
|
autoResize: true,
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
text: "Update",
|
||||||
|
click: function() {
|
||||||
|
|
||||||
|
// TODO: is this hacky? invoking timepicker to format the time values
|
||||||
|
// in all cases, to avoid "invalid format" from user input
|
||||||
|
editor.find('.shifts .shift').each(function() {
|
||||||
|
var start_time = $(this).children('input[name|="edit_start_time"]');
|
||||||
|
var end_time = $(this).children('input[name|="edit_end_time"]');
|
||||||
|
$.timepicker._setTime(start_time.data('timepicker'), start_time.val());
|
||||||
|
$.timepicker._setTime(end_time.data('timepicker'), end_time.val());
|
||||||
|
});
|
||||||
|
|
||||||
|
// create / update shifts in time table, as needed
|
||||||
|
editor.find('.shifts .shift').each(function() {
|
||||||
|
var uuid = $(this).data('uuid');
|
||||||
|
var start_time = $(this).children('input[name|="edit_start_time"]').val();
|
||||||
|
var end_time = $(this).children('input[name|="edit_end_time"]').val();
|
||||||
|
var shift = editing_day.children('.shift[data-uuid="' + uuid + '"]');
|
||||||
|
if (! shift.length) {
|
||||||
|
shift = $('<p class="shift" data-uuid="' + uuid + '"><span></span></p>');
|
||||||
|
shift.append($('<input type="hidden" name="employee_uuid-' + uuid + '" value="'
|
||||||
|
+ editing_day.parents('tr:first').data('employee-uuid') + '" />'));
|
||||||
|
editing_day.append(shift);
|
||||||
|
}
|
||||||
|
shift.children('span').text(start_time + ' - ' + end_time);
|
||||||
|
time_input(shift, 'start').val(date + ' ' + start_time);
|
||||||
|
time_input(shift, 'end').val(date + ' ' + end_time);
|
||||||
|
});
|
||||||
|
|
||||||
|
// remove shifts from time table, as needed
|
||||||
|
editing_day.children('.shift').each(function() {
|
||||||
|
var uuid = $(this).data('uuid');
|
||||||
|
if (! editor.find('.shifts .shift[data-uuid="' + uuid + '"]').length) {
|
||||||
|
if (uuid.match(/^new-/)) {
|
||||||
|
$(this).remove();
|
||||||
|
} else {
|
||||||
|
$(this).addClass('deleted');
|
||||||
|
$(this).append($('<input type="hidden" name="delete-' + uuid + '" value="delete" />'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// mark day as modified, close dialog
|
||||||
|
editing_day.addClass('modified');
|
||||||
|
$('.save-changes').button('enable');
|
||||||
|
$('.undo-changes').button('enable');
|
||||||
|
update_row_hours(editing_day.parents('tr:first'));
|
||||||
|
editor.dialog('close');
|
||||||
|
data_modified = true;
|
||||||
|
okay_to_leave = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: "Cancel",
|
||||||
|
click: function() {
|
||||||
|
editor.dialog('close');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
$('#day-editor #add-shift').click(function() {
|
||||||
|
add_shift(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
$('#day-editor').on('click', '.shifts button', function() {
|
||||||
|
$(this).parents('.shift:first').remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
$('.save-changes').click(function() {
|
||||||
|
$(this).button('disable').button('option', 'label', "Saving Changes...");
|
||||||
|
okay_to_leave = true;
|
||||||
|
$('#timetable-form').submit();
|
||||||
|
});
|
||||||
|
|
||||||
|
$('.undo-changes').click(function() {
|
||||||
|
$(this).button('disable').button('option', 'label', "Refreshing...");
|
||||||
|
okay_to_leave = true;
|
||||||
|
location.href = location.href;
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
|
@ -1,55 +1,58 @@
|
||||||
|
|
||||||
let FeedbackForm = {
|
$(function() {
|
||||||
props: ['action', 'message'],
|
|
||||||
template: '#feedback-template',
|
|
||||||
mixins: [FormPosterMixin],
|
|
||||||
methods: {
|
|
||||||
|
|
||||||
pleaseReplyChanged(value) {
|
$('#feedback').click(function() {
|
||||||
this.$nextTick(() => {
|
var dialog = $('#feedback-dialog');
|
||||||
this.$refs.userEmail.focus()
|
var form = dialog.find('form');
|
||||||
})
|
var textarea = form.find('textarea');
|
||||||
},
|
dialog.find('.referrer .field').html(location.href);
|
||||||
|
textarea.val('');
|
||||||
|
dialog.dialog({
|
||||||
|
title: "User Feedback",
|
||||||
|
width: 600,
|
||||||
|
modal: true,
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
text: "Send",
|
||||||
|
click: function(event) {
|
||||||
|
|
||||||
showFeedback() {
|
var msg = $.trim(textarea.val());
|
||||||
this.referrer = location.href
|
if (! msg) {
|
||||||
this.showDialog = true
|
alert("Please enter a message.");
|
||||||
this.$nextTick(function() {
|
textarea.select();
|
||||||
this.$refs.textarea.focus()
|
textarea.focus();
|
||||||
})
|
return;
|
||||||
},
|
}
|
||||||
|
|
||||||
sendFeedback() {
|
disable_button(dialog_button(event));
|
||||||
|
|
||||||
let params = {
|
var data = {
|
||||||
referrer: this.referrer,
|
_csrf: form.find('input[name="_csrf"]').val(),
|
||||||
user: this.userUUID,
|
referrer: location.href,
|
||||||
user_name: this.userName,
|
user: form.find('input[name="user"]').val(),
|
||||||
please_reply_to: this.pleaseReply ? this.userEmail : null,
|
user_name: form.find('input[name="user_name"]').val(),
|
||||||
message: this.message.trim(),
|
message: msg
|
||||||
}
|
};
|
||||||
|
|
||||||
this.submitForm(this.action, params, response => {
|
$.ajax(form.attr('action'), {
|
||||||
|
method: 'POST',
|
||||||
|
data: data,
|
||||||
|
success: function(data) {
|
||||||
|
dialog.dialog('close');
|
||||||
|
alert("Message successfully sent.\n\nThank you for your feedback.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
this.$buefy.toast.open({
|
}
|
||||||
message: "Message sent! Thank you for your feedback.",
|
},
|
||||||
type: 'is-info',
|
{
|
||||||
duration: 4000, // 4 seconds
|
text: "Cancel",
|
||||||
})
|
click: function() {
|
||||||
|
dialog.dialog('close');
|
||||||
this.showDialog = false
|
}
|
||||||
// clear out message, in case they need to send another
|
}
|
||||||
this.message = ""
|
]
|
||||||
})
|
});
|
||||||
},
|
});
|
||||||
}
|
|
||||||
}
|
});
|
||||||
|
|
||||||
let FeedbackFormData = {
|
|
||||||
referrer: null,
|
|
||||||
userUUID: null,
|
|
||||||
userName: null,
|
|
||||||
pleaseReply: false,
|
|
||||||
userEmail: null,
|
|
||||||
showDialog: false,
|
|
||||||
}
|
|
||||||
|
|
386
tailbone/static/js/tailbone.js
Normal file
386
tailbone/static/js/tailbone.js
Normal file
|
@ -0,0 +1,386 @@
|
||||||
|
|
||||||
|
/************************************************************
|
||||||
|
*
|
||||||
|
* tailbone.js
|
||||||
|
*
|
||||||
|
************************************************************/
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Initialize the disabled filters array. This is populated from within the
|
||||||
|
* /grids/search.mako template.
|
||||||
|
*/
|
||||||
|
var filters_to_disable = [];
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Disables options within the "add filter" dropdown which correspond to those
|
||||||
|
* filters already being displayed. Called from /grids/search.mako template.
|
||||||
|
*/
|
||||||
|
function disable_filter_options() {
|
||||||
|
while (filters_to_disable.length) {
|
||||||
|
var filter = filters_to_disable.shift();
|
||||||
|
var option = $('#add-filter option[value="' + filter + '"]');
|
||||||
|
option.attr('disabled', 'disabled');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Convenience function to disable a UI button.
|
||||||
|
*/
|
||||||
|
function disable_button(button, label) {
|
||||||
|
$(button).button('disable');
|
||||||
|
if (label === undefined) {
|
||||||
|
label = $(button).data('working-label') || "Working, please wait...";
|
||||||
|
}
|
||||||
|
if (label) {
|
||||||
|
if (label.slice(-3) != '...') {
|
||||||
|
label += '...';
|
||||||
|
}
|
||||||
|
$(button).button('option', 'label', label);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function disable_submit_button(form, label) {
|
||||||
|
// for some reason chrome requires us to do things this way...
|
||||||
|
// https://stackoverflow.com/questions/16867080/onclick-javascript-stops-form-submit-in-chrome
|
||||||
|
// https://stackoverflow.com/questions/5691054/disable-submit-button-on-form-submit
|
||||||
|
var submit = $(form).find('input[type="submit"]');
|
||||||
|
if (! submit.length) {
|
||||||
|
submit = $(form).find('button[type="submit"]');
|
||||||
|
}
|
||||||
|
if (submit.length) {
|
||||||
|
disable_button(submit, label);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Load next / previous page of results to grid. This function is called on
|
||||||
|
* the click event from the pager links, via inline script code.
|
||||||
|
*/
|
||||||
|
function grid_navigate_page(link, url) {
|
||||||
|
var wrapper = $(link).parents('div.grid-wrapper');
|
||||||
|
var grid = wrapper.find('div.grid');
|
||||||
|
wrapper.mask("Loading...");
|
||||||
|
$.get(url, function(data) {
|
||||||
|
wrapper.unmask();
|
||||||
|
grid.replaceWith(data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Fetch the UUID value associated with a table row.
|
||||||
|
*/
|
||||||
|
function get_uuid(obj) {
|
||||||
|
obj = $(obj);
|
||||||
|
if (obj.attr('uuid')) {
|
||||||
|
return obj.attr('uuid');
|
||||||
|
}
|
||||||
|
var tr = obj.parents('tr:first');
|
||||||
|
if (tr.attr('uuid')) {
|
||||||
|
return tr.attr('uuid');
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Return a jQuery object containing a button from a dialog. This is a
|
||||||
|
* convenience function to help with browser differences. It is assumed
|
||||||
|
* that it is being called from within the relevant button click handler.
|
||||||
|
* @param {event} event - Click event object.
|
||||||
|
*/
|
||||||
|
function dialog_button(event) {
|
||||||
|
var button = $(event.target);
|
||||||
|
|
||||||
|
// TODO: not sure why this workaround is needed for Chrome..?
|
||||||
|
if (! button.hasClass('ui-button')) {
|
||||||
|
button = button.parents('.ui-button:first');
|
||||||
|
}
|
||||||
|
|
||||||
|
return button;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scroll screen as needed to ensure all options are visible, for the given
|
||||||
|
* select menu widget.
|
||||||
|
*/
|
||||||
|
function show_all_options(select) {
|
||||||
|
if (! select.is(':visible')) {
|
||||||
|
/*
|
||||||
|
* Note that the following code was largely stolen from
|
||||||
|
* http://brianseekford.com/2013/06/03/how-to-scroll-a-container-or-element-into-view-using-jquery-javascript-in-your-html/
|
||||||
|
*/
|
||||||
|
|
||||||
|
var docViewTop = $(window).scrollTop();
|
||||||
|
var docViewBottom = docViewTop + $(window).height();
|
||||||
|
|
||||||
|
var widget = select.selectmenu('menuWidget');
|
||||||
|
var elemTop = widget.offset().top;
|
||||||
|
var elemBottom = elemTop + widget.height();
|
||||||
|
|
||||||
|
var isScrolled = ((elemBottom <= docViewBottom) && (elemTop >= docViewTop));
|
||||||
|
|
||||||
|
if (!isScrolled) {
|
||||||
|
if (widget.height() > $(window).height()) { //then just bring to top of the container
|
||||||
|
$(window).scrollTop(elemTop)
|
||||||
|
} else { //try and and bring bottom of container to bottom of screen
|
||||||
|
$(window).scrollTop(elemTop - ($(window).height() - widget.height()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
* reference to existing timeout warning dialog, if any
|
||||||
|
*/
|
||||||
|
var session_timeout_warning = null;
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Warn user of impending session timeout.
|
||||||
|
*/
|
||||||
|
function timeout_warning() {
|
||||||
|
if (! session_timeout_warning) {
|
||||||
|
session_timeout_warning = $('<div id="session-timeout-warning">' +
|
||||||
|
'You will be logged out in <span class="seconds"></span> ' +
|
||||||
|
'seconds...</div>');
|
||||||
|
}
|
||||||
|
session_timeout_warning.find('.seconds').text('60');
|
||||||
|
session_timeout_warning.dialog({
|
||||||
|
title: "Session Timeout Warning",
|
||||||
|
modal: true,
|
||||||
|
buttons: {
|
||||||
|
"Stay Logged In": function() {
|
||||||
|
session_timeout_warning.dialog('close');
|
||||||
|
$.get(noop_url, set_timeout_warning_timer);
|
||||||
|
},
|
||||||
|
"Logout Now": function() {
|
||||||
|
location.href = logout_url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
window.setTimeout(timeout_warning_update, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrement the 'seconds' counter for the current timeout warning
|
||||||
|
*/
|
||||||
|
function timeout_warning_update() {
|
||||||
|
if (session_timeout_warning.is(':visible')) {
|
||||||
|
var span = session_timeout_warning.find('.seconds');
|
||||||
|
var seconds = parseInt(span.text()) - 1;
|
||||||
|
if (seconds) {
|
||||||
|
span.text(seconds.toString());
|
||||||
|
window.setTimeout(timeout_warning_update, 1000);
|
||||||
|
} else {
|
||||||
|
location.href = logout_url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Warn user of impending session timeout.
|
||||||
|
*/
|
||||||
|
function set_timeout_warning_timer() {
|
||||||
|
// timout dialog says we're 60 seconds away, but we actually trigger when
|
||||||
|
// 70 seconds away from supposed timeout, in case of timer drift?
|
||||||
|
window.setTimeout(timeout_warning, session_timeout * 1000 - 70000);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
* set initial timer for timeout warning, if applicable
|
||||||
|
*/
|
||||||
|
if (session_timeout) {
|
||||||
|
set_timeout_warning_timer();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
$(function() {
|
||||||
|
|
||||||
|
/*
|
||||||
|
* enhance buttons
|
||||||
|
*/
|
||||||
|
$('button, a.button').button();
|
||||||
|
$('input[type=submit]').button();
|
||||||
|
$('input[type=reset]').button();
|
||||||
|
$('a.button.autodisable').click(function() {
|
||||||
|
disable_button(this);
|
||||||
|
});
|
||||||
|
$('form.autodisable').submit(function() {
|
||||||
|
disable_submit_button(this);
|
||||||
|
});
|
||||||
|
|
||||||
|
// quickie button
|
||||||
|
$('#submit-quickie').button('option', 'icons', {primary: 'ui-icon-zoomin'});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* enhance dropdowns
|
||||||
|
*/
|
||||||
|
$('select[auto-enhance="true"]').selectmenu();
|
||||||
|
$('select[auto-enhance="true"]').on('selectmenuopen', function(event, ui) {
|
||||||
|
show_all_options($(this));
|
||||||
|
});
|
||||||
|
|
||||||
|
/* Also automatically disable any buttons marked for that. */
|
||||||
|
$('a.button[disabled=disabled]').button('option', 'disabled', true);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Apply timepicker behavior to text inputs which are marked for it.
|
||||||
|
*/
|
||||||
|
$('input[type=text].timepicker').timepicker({
|
||||||
|
showPeriod: true
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* When filter labels are clicked, (un)check the associated checkbox.
|
||||||
|
*/
|
||||||
|
$('body').on('click', '.grid-wrapper .filter label', function() {
|
||||||
|
var checkbox = $(this).prev('input[type="checkbox"]');
|
||||||
|
if (checkbox.prop('checked')) {
|
||||||
|
checkbox.prop('checked', false);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
checkbox.prop('checked', true);
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* When a new filter is selected in the "add filter" dropdown, show it in
|
||||||
|
* the UI. This selects the filter's checkbox and puts focus to its input
|
||||||
|
* element. If all available filters have been displayed, the "add filter"
|
||||||
|
* dropdown will be hidden.
|
||||||
|
*/
|
||||||
|
$('body').on('change', '#add-filter', function() {
|
||||||
|
var select = $(this);
|
||||||
|
var filters = select.parents('div.filters:first');
|
||||||
|
var filter = filters.find('#filter-' + select.val());
|
||||||
|
var checkbox = filter.find('input[type="checkbox"]:first');
|
||||||
|
var input = filter.find(':last-child');
|
||||||
|
|
||||||
|
checkbox.prop('checked', true);
|
||||||
|
filter.show();
|
||||||
|
input.select();
|
||||||
|
input.focus();
|
||||||
|
|
||||||
|
filters.find('input[type="submit"]').show();
|
||||||
|
filters.find('button[type="reset"]').show();
|
||||||
|
|
||||||
|
select.find('option:selected').attr('disabled', true);
|
||||||
|
select.val('add a filter');
|
||||||
|
if (select.find('option:enabled').length == 1) {
|
||||||
|
select.hide();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* When user clicks the grid filters search button, perform the search in
|
||||||
|
* the background and reload the grid in-place.
|
||||||
|
*/
|
||||||
|
$('body').on('submit', '.filters form', function() {
|
||||||
|
var form = $(this);
|
||||||
|
var wrapper = form.parents('div.grid-wrapper');
|
||||||
|
var grid = wrapper.find('div.grid');
|
||||||
|
var data = form.serializeArray();
|
||||||
|
data.push({name: 'partial', value: true});
|
||||||
|
wrapper.mask("Loading...");
|
||||||
|
$.get(grid.attr('url'), data, function(data) {
|
||||||
|
wrapper.unmask();
|
||||||
|
grid.replaceWith(data);
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* When user clicks the grid filters reset button, manually clear all
|
||||||
|
* filter input elements, and submit a new search.
|
||||||
|
*/
|
||||||
|
$('body').on('click', '.filters form button[type="reset"]', function() {
|
||||||
|
var form = $(this).parents('form');
|
||||||
|
form.find('div.filter').each(function() {
|
||||||
|
$(this).find('div.value input').val('');
|
||||||
|
});
|
||||||
|
form.submit();
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
$('body').on('click', '.grid thead th.sortable a', function() {
|
||||||
|
var th = $(this).parent();
|
||||||
|
var wrapper = th.parents('div.grid-wrapper');
|
||||||
|
var grid = wrapper.find('div.grid');
|
||||||
|
var data = {
|
||||||
|
sort: th.attr('field'),
|
||||||
|
dir: (th.hasClass('sorted') && th.hasClass('asc')) ? 'desc' : 'asc',
|
||||||
|
page: 1,
|
||||||
|
partial: true
|
||||||
|
};
|
||||||
|
wrapper.mask("Loading...");
|
||||||
|
$.get(grid.attr('url'), data, function(data) {
|
||||||
|
wrapper.unmask();
|
||||||
|
grid.replaceWith(data);
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
$('body').on('mouseenter', '.grid.hoverable tbody tr', function() {
|
||||||
|
$(this).addClass('hovering');
|
||||||
|
});
|
||||||
|
|
||||||
|
$('body').on('mouseleave', '.grid.hoverable tbody tr', function() {
|
||||||
|
$(this).removeClass('hovering');
|
||||||
|
});
|
||||||
|
|
||||||
|
$('body').on('click', '.grid tbody td.view', function() {
|
||||||
|
var url = $(this).attr('url');
|
||||||
|
if (url) {
|
||||||
|
location.href = url;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$('body').on('click', '.grid tbody td.edit', function() {
|
||||||
|
var url = $(this).attr('url');
|
||||||
|
if (url) {
|
||||||
|
location.href = url;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$('body').on('click', '.grid tbody td.delete', function() {
|
||||||
|
var url = $(this).attr('url');
|
||||||
|
if (url) {
|
||||||
|
if (confirm("Do you really wish to delete this object?")) {
|
||||||
|
location.href = url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// $('div.grid-wrapper').on('change', 'div.grid div.pager select#grid-page-count', function() {
|
||||||
|
$('body').on('change', '.grid .pager #grid-page-count', function() {
|
||||||
|
var select = $(this);
|
||||||
|
var wrapper = select.parents('div.grid-wrapper');
|
||||||
|
var grid = wrapper.find('div.grid');
|
||||||
|
var data = {
|
||||||
|
per_page: select.val(),
|
||||||
|
partial: true
|
||||||
|
};
|
||||||
|
wrapper.mask("Loading...");
|
||||||
|
$.get(grid.attr('url'), data, function(data) {
|
||||||
|
wrapper.unmask();
|
||||||
|
grid.replaceWith(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
$('body').on('click', 'div.dialog button.close', function() {
|
||||||
|
var dialog = $(this).parents('div.dialog:first');
|
||||||
|
dialog.dialog('close');
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
267
tailbone/static/js/tailbone.timesheet.edit.js
Normal file
267
tailbone/static/js/tailbone.timesheet.edit.js
Normal file
|
@ -0,0 +1,267 @@
|
||||||
|
|
||||||
|
/************************************************************
|
||||||
|
*
|
||||||
|
* tailbone.timesheet.edit.js
|
||||||
|
*
|
||||||
|
* Common logic for editing time sheet / schedule data.
|
||||||
|
*
|
||||||
|
************************************************************/
|
||||||
|
|
||||||
|
|
||||||
|
var editing_day = null;
|
||||||
|
var new_shift_id = 1;
|
||||||
|
var show_timepicker = true;
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Add a new shift entry to the editor dialog.
|
||||||
|
* @param {boolean} focus - Whether to set focus to the start_time input
|
||||||
|
* element after adding the shift.
|
||||||
|
* @param {string} uuid - UUID value for the shift, if applicable.
|
||||||
|
* @param {string} start_time - Value for start_time input element.
|
||||||
|
* @param {string} end_time - Value for end_time input element.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function add_shift(focus, uuid, start_time, end_time) {
|
||||||
|
var shift = $('#snippets .shift').clone();
|
||||||
|
if (! uuid) {
|
||||||
|
uuid = 'new-' + (new_shift_id++).toString();
|
||||||
|
}
|
||||||
|
shift.attr('data-uuid', uuid);
|
||||||
|
shift.children('input').each(function() {
|
||||||
|
var name = $(this).attr('name') + '-' + uuid;
|
||||||
|
$(this).attr('name', name);
|
||||||
|
$(this).attr('id', name);
|
||||||
|
});
|
||||||
|
shift.children('input[name|="edit_start_time"]').val(start_time);
|
||||||
|
shift.children('input[name|="edit_end_time"]').val(end_time);
|
||||||
|
$('#day-editor .shifts').append(shift);
|
||||||
|
|
||||||
|
// maybe trick timepicker into never showing itself
|
||||||
|
var args = {showPeriod: true};
|
||||||
|
if (! show_timepicker) {
|
||||||
|
args.showOn = 'button';
|
||||||
|
args.button = '#nevershow';
|
||||||
|
}
|
||||||
|
shift.children('input').timepicker(args);
|
||||||
|
|
||||||
|
if (focus) {
|
||||||
|
shift.children('input:first').focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate the number of minutes between given the times.
|
||||||
|
* @param {string} start_time - Value from start_time input element.
|
||||||
|
* @param {string} end_time - Value from end_time input element.
|
||||||
|
*/
|
||||||
|
function calc_minutes(start_time, end_time) {
|
||||||
|
var start = parseTime(start_time);
|
||||||
|
var end = parseTime(end_time);
|
||||||
|
if (start && end) {
|
||||||
|
start = new Date(2000, 0, 1, start.hh, start.mm);
|
||||||
|
end = new Date(2000, 0, 1, end.hh, end.mm);
|
||||||
|
return Math.floor((end - start) / 1000 / 60);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a number of minutes into string of HH:MM format.
|
||||||
|
* @param {number} minutes - Number of minutes to be converted.
|
||||||
|
*/
|
||||||
|
function format_minutes(minutes) {
|
||||||
|
var hours = Math.floor(minutes / 60);
|
||||||
|
if (hours) {
|
||||||
|
minutes -= hours * 60;
|
||||||
|
}
|
||||||
|
return hours.toString() + ':' + (minutes < 10 ? '0' : '') + minutes.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NOTE: most of this logic was stolen from http://stackoverflow.com/a/1788084
|
||||||
|
*
|
||||||
|
* Parse a time string and convert to simple object with hh and mm keys.
|
||||||
|
* @param {string} time - Time value in 'HH:MM PP' format, or close enough.
|
||||||
|
*/
|
||||||
|
function parseTime(time) {
|
||||||
|
if (time) {
|
||||||
|
var part = time.match(/(\d+):(\d+)(?: )?(am|pm)?/i);
|
||||||
|
if (part) {
|
||||||
|
var hh = parseInt(part[1], 10);
|
||||||
|
var mm = parseInt(part[2], 10);
|
||||||
|
var ap = part[3] ? part[3].toUpperCase() : null;
|
||||||
|
if (ap == 'AM') {
|
||||||
|
if (hh == 12) {
|
||||||
|
hh = 0;
|
||||||
|
}
|
||||||
|
} else if (ap == 'PM') {
|
||||||
|
if (hh != 12) {
|
||||||
|
hh += 12;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { hh: hh, mm: mm };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return a jQuery object containing the hidden start or end time input element
|
||||||
|
* for the shift (i.e. within the *main* timesheet form). This will create the
|
||||||
|
* input if necessary.
|
||||||
|
* @param {jQuery} shift - A jQuery object for the shift itself.
|
||||||
|
* @param {string} type - Should be 'start' or 'end' only.
|
||||||
|
*/
|
||||||
|
function time_input(shift, type) {
|
||||||
|
var input = shift.children('input[name|="' + type + '_time"]');
|
||||||
|
if (! input.length) {
|
||||||
|
input = $('<input type="hidden" name="' + type + '_time-' + shift.data('uuid') + '" />');
|
||||||
|
shift.append(input);
|
||||||
|
}
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the weekly hour total for a given row (employee).
|
||||||
|
* @param {jQuery} row - A jQuery object for the row to be updated.
|
||||||
|
*/
|
||||||
|
function update_row_hours(row) {
|
||||||
|
var minutes = 0;
|
||||||
|
row.find('.day .shift:not(.deleted)').each(function() {
|
||||||
|
var time_range = $.trim($(this).children('span').text()).split(' - ');
|
||||||
|
minutes += calc_minutes(time_range[0], time_range[1]);
|
||||||
|
});
|
||||||
|
row.children('.total').text(minutes ? format_minutes(minutes) : '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up user input within the editor dialog, e.g. '8:30am' => '08:30 AM'.
|
||||||
|
* This also should ensure invalid input will become empty string.
|
||||||
|
*/
|
||||||
|
function cleanup_editor_input() {
|
||||||
|
// TODO: is this hacky? invoking timepicker to format the time values
|
||||||
|
// in all cases, to avoid "invalid format" from user input
|
||||||
|
var backward = false;
|
||||||
|
$('#day-editor .shifts .shift').each(function() {
|
||||||
|
var start_time = $(this).children('input[name|="edit_start_time"]');
|
||||||
|
var end_time = $(this).children('input[name|="edit_end_time"]');
|
||||||
|
$.timepicker._setTime(start_time.data('timepicker'), start_time.val() || '??');
|
||||||
|
$.timepicker._setTime(end_time.data('timepicker'), end_time.val() || '??');
|
||||||
|
var t_start = parseTime(start_time.val());
|
||||||
|
var t_end = parseTime(end_time.val());
|
||||||
|
if (t_start && t_end) {
|
||||||
|
if ((t_start.hh > t_end.hh) || ((t_start.hh == t_end.hh) && (t_start.mm > t_end.mm))) {
|
||||||
|
alert("Start time falls *after* end time! Please fix...");
|
||||||
|
start_time.focus().select();
|
||||||
|
backward = true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return !backward;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the main timesheet table based on editor dialog input. This updates
|
||||||
|
* both the displayed timesheet, as well as any hidden input elements on the
|
||||||
|
* main form.
|
||||||
|
*/
|
||||||
|
function update_timetable() {
|
||||||
|
|
||||||
|
var date = weekdays[editing_day.get(0).cellIndex - 1];
|
||||||
|
|
||||||
|
// add or update
|
||||||
|
$('#day-editor .shifts .shift').each(function() {
|
||||||
|
var uuid = $(this).data('uuid');
|
||||||
|
var start_time = $(this).children('input[name|="edit_start_time"]').val();
|
||||||
|
var end_time = $(this).children('input[name|="edit_end_time"]').val();
|
||||||
|
var shift = editing_day.children('.shift[data-uuid="' + uuid + '"]');
|
||||||
|
if (! shift.length) {
|
||||||
|
if (! (start_time || end_time)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
shift = $('<p class="shift" data-uuid="' + uuid + '"><span></span></p>');
|
||||||
|
shift.append($('<input type="hidden" name="employee_uuid-' + uuid + '" value="'
|
||||||
|
+ editing_day.parents('tr:first').data('employee-uuid') + '" />'));
|
||||||
|
editing_day.append(shift);
|
||||||
|
}
|
||||||
|
shift.children('span').text((start_time || '??') + ' - ' + (end_time || '??'));
|
||||||
|
start_time = start_time ? (date + ' ' + start_time) : '';
|
||||||
|
end_time = end_time ? (date + ' ' + end_time) : '';
|
||||||
|
time_input(shift, 'start').val(start_time);
|
||||||
|
time_input(shift, 'end').val(end_time);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// remove / mark for deletion
|
||||||
|
editing_day.children('.shift').each(function() {
|
||||||
|
var uuid = $(this).data('uuid');
|
||||||
|
if (! $('#day-editor .shifts .shift[data-uuid="' + uuid + '"]').length) {
|
||||||
|
if (uuid.match(/^new-/)) {
|
||||||
|
$(this).remove();
|
||||||
|
} else {
|
||||||
|
$(this).addClass('deleted');
|
||||||
|
$(this).append($('<input type="hidden" name="delete-' + uuid + '" value="delete" />'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perform full "save" action for time sheet form, direct from day editor dialog.
|
||||||
|
*/
|
||||||
|
function save_dialog() {
|
||||||
|
if (! cleanup_editor_input()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
var save = $('#day-editor').parents('.ui-dialog').find('.ui-dialog-buttonpane button:first');
|
||||||
|
save.button('disable').button('option', 'label', "Saving...");
|
||||||
|
update_timetable();
|
||||||
|
$('#timetable-form').submit();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
* on document load...
|
||||||
|
*/
|
||||||
|
$(function() {
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Within editor dialog, clicking Add Shift button will create a new/empty
|
||||||
|
* shift and set focus to its start_time input.
|
||||||
|
*/
|
||||||
|
$('#day-editor #add-shift').click(function() {
|
||||||
|
add_shift(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Within editor dialog, clicking a shift's "trash can" button will remove
|
||||||
|
* the shift.
|
||||||
|
*/
|
||||||
|
$('#day-editor').on('click', '.shifts button', function() {
|
||||||
|
$(this).parents('.shift:first').remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Within editor dialog, Enter press within time field "might" trigger
|
||||||
|
* save. Note that this is only done for timesheet editing, not schedule.
|
||||||
|
*/
|
||||||
|
$('#day-editor').on('keydown', '.shifts input[type="text"]', function(event) {
|
||||||
|
if (!show_timepicker) { // TODO: this implies too much, should be cleaner
|
||||||
|
if (event.which == 13) {
|
||||||
|
save_dialog();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
14
tailbone/static/themes/falafel/css/base.css
Normal file
14
tailbone/static/themes/falafel/css/base.css
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
|
||||||
|
/******************************
|
||||||
|
* tweaks for root user
|
||||||
|
******************************/
|
||||||
|
|
||||||
|
.navbar .navbar-end .navbar-link.root-user,
|
||||||
|
.navbar .navbar-end .navbar-link.root-user:hover,
|
||||||
|
.navbar .navbar-end .navbar-link.root-user.is_active,
|
||||||
|
.navbar .navbar-end .navbar-item.root-user,
|
||||||
|
.navbar .navbar-end .navbar-item.root-user:hover,
|
||||||
|
.navbar .navbar-end .navbar-item.root-user.is_active {
|
||||||
|
background-color: red;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
22
tailbone/static/themes/falafel/css/filters.css
Normal file
22
tailbone/static/themes/falafel/css/filters.css
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
|
||||||
|
/******************************
|
||||||
|
* Grid Filters
|
||||||
|
******************************/
|
||||||
|
|
||||||
|
.filters .filter {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters .filter-fieldname .field,
|
||||||
|
.filters .filter-fieldname .field label {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters .filter-fieldname .field label {
|
||||||
|
justify-content: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters .filter-verb .select,
|
||||||
|
.filters .filter-verb .select select {
|
||||||
|
width: 100%;
|
||||||
|
}
|
61
tailbone/static/themes/falafel/css/forms.css
Normal file
61
tailbone/static/themes/falafel/css/forms.css
Normal file
|
@ -0,0 +1,61 @@
|
||||||
|
|
||||||
|
/******************************
|
||||||
|
* forms
|
||||||
|
******************************/
|
||||||
|
|
||||||
|
/* note that this should only apply to "normal" primary forms */
|
||||||
|
/* TODO: replace this with bulma equivalent */
|
||||||
|
.form {
|
||||||
|
padding-left: 5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* note that this should only apply to "normal" primary forms */
|
||||||
|
.form-wrapper .form .field.is-horizontal .field-label .label {
|
||||||
|
text-align: left;
|
||||||
|
white-space: nowrap;
|
||||||
|
width: 18em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* note that this should only apply to "normal" primary forms */
|
||||||
|
.form-wrapper .form .field.is-horizontal .field-body {
|
||||||
|
min-width: 30em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* note that this should only apply to "normal" primary forms */
|
||||||
|
.form-wrapper .form .field.is-horizontal .field-body .select,
|
||||||
|
.form-wrapper .form .field.is-horizontal .field-body .select select {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/******************************
|
||||||
|
* field-wrappers
|
||||||
|
******************************/
|
||||||
|
|
||||||
|
/* TODO: replace this with bulma equivalent */
|
||||||
|
.field-wrapper {
|
||||||
|
clear: both;
|
||||||
|
min-height: 30px;
|
||||||
|
overflow: auto;
|
||||||
|
margin: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* TODO: replace this with bulma equivalent */
|
||||||
|
.field-wrapper .field-row {
|
||||||
|
display: table-row;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* TODO: replace this with bulma equivalent */
|
||||||
|
.field-wrapper label {
|
||||||
|
display: table-cell;
|
||||||
|
vertical-align: top;
|
||||||
|
width: 18em;
|
||||||
|
font-weight: bold;
|
||||||
|
padding-top: 2px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* TODO: replace this with bulma equivalent */
|
||||||
|
.field-wrapper .field {
|
||||||
|
display: table-cell;
|
||||||
|
line-height: 25px;
|
||||||
|
}
|
15
tailbone/static/themes/falafel/css/grids.css
Normal file
15
tailbone/static/themes/falafel/css/grids.css
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
|
||||||
|
/********************************************************************************
|
||||||
|
* grids.css
|
||||||
|
*
|
||||||
|
* Style tweaks for the Buefy grids.
|
||||||
|
********************************************************************************/
|
||||||
|
|
||||||
|
|
||||||
|
/******************************
|
||||||
|
* actions column
|
||||||
|
******************************/
|
||||||
|
|
||||||
|
a.grid-action {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
|
@ -2,7 +2,7 @@
|
||||||
/********************************************************************************
|
/********************************************************************************
|
||||||
* grids.rowstatus.css
|
* grids.rowstatus.css
|
||||||
*
|
*
|
||||||
* Add "row status" styles for grid tables.
|
* Add "row status" styles for Buefy grid tables.
|
||||||
********************************************************************************/
|
********************************************************************************/
|
||||||
|
|
||||||
/**************************************************
|
/**************************************************
|
133
tailbone/static/themes/falafel/css/layout.css
Normal file
133
tailbone/static/themes/falafel/css/layout.css
Normal file
|
@ -0,0 +1,133 @@
|
||||||
|
|
||||||
|
/******************************
|
||||||
|
* main layout
|
||||||
|
******************************/
|
||||||
|
|
||||||
|
body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/******************************
|
||||||
|
* header
|
||||||
|
******************************/
|
||||||
|
|
||||||
|
/* this is the one in the very top left of screen, next to logo and linked to
|
||||||
|
the home page */
|
||||||
|
#global-header-title {
|
||||||
|
margin-left: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
header .level {
|
||||||
|
/* TODO: not sure what this 60px was supposed to do? but it broke the */
|
||||||
|
/* styles for the feedback dialog, so disabled it is.
|
||||||
|
/* height: 60px; */
|
||||||
|
/* line-height: 60px; */
|
||||||
|
padding-left: 0.5em;
|
||||||
|
padding-right: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
header .level #header-logo {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
header .level .global-title,
|
||||||
|
header .level-left .global-title {
|
||||||
|
font-size: 2em;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* indent nested menu items a bit */
|
||||||
|
header .navbar-item.nested {
|
||||||
|
padding-left: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
header span.header-text {
|
||||||
|
font-size: 2em;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
header .level .theme-picker {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
#content-title {
|
||||||
|
padding: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#content-title h1 {
|
||||||
|
font-size: 2rem;
|
||||||
|
margin-left: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/******************************
|
||||||
|
* content
|
||||||
|
******************************/
|
||||||
|
|
||||||
|
#page-body {
|
||||||
|
padding: 0.4em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/******************************
|
||||||
|
* context menu
|
||||||
|
******************************/
|
||||||
|
|
||||||
|
#context-menu {
|
||||||
|
margin-bottom: 1em;
|
||||||
|
margin-left: 1em;
|
||||||
|
text-align: right;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/******************************
|
||||||
|
* "object helper" panel
|
||||||
|
******************************/
|
||||||
|
|
||||||
|
.object-helpers a {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.object-helper {
|
||||||
|
border: 1px solid black;
|
||||||
|
margin: 1em;
|
||||||
|
padding: 1em;
|
||||||
|
width: 20em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.object-helper-content {
|
||||||
|
margin-top: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/******************************
|
||||||
|
* fix datepicker within modals
|
||||||
|
* TODO: someday this may not be necessary? cf.
|
||||||
|
* https://github.com/buefy/buefy/issues/292#issuecomment-347365637
|
||||||
|
******************************/
|
||||||
|
|
||||||
|
.modal .animation-content .modal-card {
|
||||||
|
overflow: visible !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-card-body {
|
||||||
|
overflow: visible !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/******************************
|
||||||
|
* feedback
|
||||||
|
******************************/
|
||||||
|
|
||||||
|
.feedback-dialog .red {
|
||||||
|
color: red;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
54
tailbone/static/themes/falafel/js/tailbone.feedback.js
Normal file
54
tailbone/static/themes/falafel/js/tailbone.feedback.js
Normal file
|
@ -0,0 +1,54 @@
|
||||||
|
|
||||||
|
let FeedbackForm = {
|
||||||
|
props: ['action', 'message'],
|
||||||
|
template: '#feedback-template',
|
||||||
|
mixins: [FormPosterMixin],
|
||||||
|
methods: {
|
||||||
|
|
||||||
|
pleaseReplyChanged(value) {
|
||||||
|
this.$nextTick(() => {
|
||||||
|
this.$refs.userEmail.focus()
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
showFeedback() {
|
||||||
|
this.showDialog = true
|
||||||
|
this.$nextTick(function() {
|
||||||
|
this.$refs.textarea.focus()
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
sendFeedback() {
|
||||||
|
|
||||||
|
let params = {
|
||||||
|
referrer: this.referrer,
|
||||||
|
user: this.userUUID,
|
||||||
|
user_name: this.userName,
|
||||||
|
please_reply_to: this.pleaseReply ? this.userEmail : null,
|
||||||
|
message: this.message.trim(),
|
||||||
|
}
|
||||||
|
|
||||||
|
this.submitForm(this.action, params, response => {
|
||||||
|
|
||||||
|
this.$buefy.toast.open({
|
||||||
|
message: "Message sent! Thank you for your feedback.",
|
||||||
|
type: 'is-info',
|
||||||
|
duration: 4000, // 4 seconds
|
||||||
|
})
|
||||||
|
|
||||||
|
this.showDialog = false
|
||||||
|
// clear out message, in case they need to send another
|
||||||
|
this.message = ""
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let FeedbackFormData = {
|
||||||
|
referrer: null,
|
||||||
|
userUUID: null,
|
||||||
|
userName: null,
|
||||||
|
pleaseReply: false,
|
||||||
|
userEmail: null,
|
||||||
|
showDialog: false,
|
||||||
|
}
|
|
@ -2,7 +2,7 @@
|
||||||
################################################################################
|
################################################################################
|
||||||
#
|
#
|
||||||
# Rattail -- Retail Software Framework
|
# Rattail -- Retail Software Framework
|
||||||
# Copyright © 2010-2024 Lance Edgar
|
# Copyright © 2010-2022 Lance Edgar
|
||||||
#
|
#
|
||||||
# This file is part of Rattail.
|
# This file is part of Rattail.
|
||||||
#
|
#
|
||||||
|
@ -24,10 +24,11 @@
|
||||||
Event Subscribers
|
Event Subscribers
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from __future__ import unicode_literals, absolute_import
|
||||||
|
|
||||||
|
import six
|
||||||
|
import json
|
||||||
import datetime
|
import datetime
|
||||||
import logging
|
|
||||||
import warnings
|
|
||||||
from collections import OrderedDict
|
|
||||||
|
|
||||||
import rattail
|
import rattail
|
||||||
|
|
||||||
|
@ -36,169 +37,147 @@ import deform
|
||||||
from pyramid import threadlocal
|
from pyramid import threadlocal
|
||||||
from webhelpers2.html import tags
|
from webhelpers2.html import tags
|
||||||
|
|
||||||
from wuttaweb import subscribers as base
|
|
||||||
|
|
||||||
import tailbone
|
import tailbone
|
||||||
from tailbone import helpers
|
from tailbone import helpers
|
||||||
from tailbone.db import Session
|
from tailbone.db import Session
|
||||||
from tailbone.config import csrf_header_name, should_expose_websockets
|
from tailbone.config import csrf_header_name, should_expose_websockets
|
||||||
from tailbone.util import get_available_themes, get_global_search_options
|
from tailbone.menus import make_simple_menus
|
||||||
|
from tailbone.util import should_use_buefy
|
||||||
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
def new_request(event):
|
||||||
|
|
||||||
|
|
||||||
def new_request(event, session=None):
|
|
||||||
"""
|
"""
|
||||||
Event hook called when processing a new request.
|
Identify the current user, and cache their current permissions. Also adds
|
||||||
|
the ``rattail_config`` attribute to the request.
|
||||||
|
|
||||||
This first invokes the upstream hooks:
|
A global Rattail ``config`` should already be present within the Pyramid
|
||||||
|
application registry's settings, which would normally be accessed via::
|
||||||
|
|
||||||
|
request.registry.settings['rattail_config']
|
||||||
|
|
||||||
* :func:`wuttaweb:wuttaweb.subscribers.new_request()`
|
This function merely "promotes" that config object so that it is more
|
||||||
* :func:`wuttaweb:wuttaweb.subscribers.new_request_set_user()`
|
directly accessible, a la::
|
||||||
|
|
||||||
It then adds more things to the request object; among them:
|
request.rattail_config
|
||||||
|
|
||||||
.. attribute:: request.rattail_config
|
.. note::
|
||||||
|
This of course assumes that a Rattail ``config`` object *has* in fact
|
||||||
Reference to the app :term:`config object`. Note that this
|
already been placed in the application registry settings. If this is
|
||||||
will be the same as :attr:`wuttaweb:request.wutta_config`.
|
not the case, this function will do nothing.
|
||||||
|
|
||||||
.. method:: request.register_component(tagname, classname)
|
|
||||||
|
|
||||||
Function to register a Vue component for use with the app.
|
|
||||||
|
|
||||||
This can be called from wherever a component is defined, and
|
|
||||||
then in the base template all registered components will be
|
|
||||||
properly loaded.
|
|
||||||
"""
|
"""
|
||||||
request = event.request
|
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
|
def user(request):
|
||||||
# nb. this sets request.wutta_config
|
user = None
|
||||||
base.new_request(event)
|
uuid = request.authenticated_userid
|
||||||
|
if uuid:
|
||||||
|
model = request.rattail_config.get_model()
|
||||||
|
user = Session.query(model.User).get(uuid)
|
||||||
|
if user:
|
||||||
|
Session().set_continuum_user(user)
|
||||||
|
return user
|
||||||
|
|
||||||
config = request.wutta_config
|
request.set_property(user, reify=True)
|
||||||
app = config.get_app()
|
|
||||||
auth = app.get_auth_handler()
|
|
||||||
session = session or Session()
|
|
||||||
|
|
||||||
# compatibility
|
|
||||||
rattail_config = config
|
|
||||||
request.rattail_config = rattail_config
|
|
||||||
|
|
||||||
def user_getter(request, db_session=None):
|
|
||||||
user = base.default_user_getter(request, db_session=db_session)
|
|
||||||
if user:
|
|
||||||
# nb. we also assign continuum user to session
|
|
||||||
session = db_session or Session()
|
|
||||||
session.set_continuum_user(user)
|
|
||||||
return user
|
|
||||||
|
|
||||||
# invoke upstream hook to set user
|
|
||||||
base.new_request_set_user(event, user_getter=user_getter, db_session=session)
|
|
||||||
|
|
||||||
# assign client IP address to the session, for sake of versioning
|
# 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()
|
request.is_admin = bool(request.user) and request.user.is_admin()
|
||||||
def register_component(tagname, classname):
|
request.is_root = request.is_admin and request.session.get('is_root', False)
|
||||||
"""
|
|
||||||
Register a Vue 3 component, so the base template knows to
|
|
||||||
declare it for use within the app (page).
|
|
||||||
"""
|
|
||||||
if not hasattr(request, '_tailbone_registered_components'):
|
|
||||||
request._tailbone_registered_components = OrderedDict()
|
|
||||||
|
|
||||||
if tagname in request._tailbone_registered_components:
|
if rattail_config:
|
||||||
log.warning("component with tagname '%s' already registered "
|
app = rattail_config.get_app()
|
||||||
"with class '%s' but we are replacing that with "
|
auth = app.get_auth_handler()
|
||||||
"class '%s'",
|
request.tailbone_cached_permissions = auth.get_permissions(
|
||||||
tagname,
|
Session(), request.user)
|
||||||
request._tailbone_registered_components[tagname],
|
|
||||||
classname)
|
|
||||||
|
|
||||||
request._tailbone_registered_components[tagname] = classname
|
|
||||||
request.register_component = register_component
|
|
||||||
|
|
||||||
|
|
||||||
def before_render(event):
|
def before_render(event):
|
||||||
"""
|
"""
|
||||||
Adds goodies to the global template renderer context.
|
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()
|
request = event.get('request') or threadlocal.get_current_request()
|
||||||
config = request.wutta_config
|
rattail_config = request.rattail_config
|
||||||
app = config.get_app()
|
|
||||||
|
|
||||||
renderer_globals = event
|
renderer_globals = event
|
||||||
|
renderer_globals['rattail_app'] = request.rattail_config.get_app()
|
||||||
# overrides
|
renderer_globals['app_title'] = request.rattail_config.app_title()
|
||||||
renderer_globals['h'] = helpers
|
renderer_globals['h'] = helpers
|
||||||
|
renderer_globals['url'] = request.route_url
|
||||||
# misc.
|
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['datetime'] = datetime
|
||||||
renderer_globals['colander'] = colander
|
renderer_globals['colander'] = colander
|
||||||
renderer_globals['deform'] = deform
|
renderer_globals['deform'] = deform
|
||||||
renderer_globals['csrf_header_name'] = csrf_header_name(config)
|
renderer_globals['csrf_header_name'] = csrf_header_name(request.rattail_config)
|
||||||
|
|
||||||
# TODO: deprecate / remove these
|
|
||||||
renderer_globals['rattail_app'] = app
|
|
||||||
renderer_globals['app_title'] = app.get_title()
|
|
||||||
renderer_globals['app_version'] = app.get_version()
|
|
||||||
renderer_globals['rattail'] = rattail
|
|
||||||
renderer_globals['tailbone'] = tailbone
|
|
||||||
renderer_globals['model'] = app.model
|
|
||||||
renderer_globals['enum'] = app.enum
|
|
||||||
|
|
||||||
# theme - we only want do this for classic web app, *not* API
|
# theme - we only want do this for classic web app, *not* API
|
||||||
# TODO: so, clearly we need a better way to distinguish the two
|
# TODO: so, clearly we need a better way to distinguish the two
|
||||||
if 'tailbone.theme' in request.registry.settings:
|
if 'tailbone.theme' in request.registry.settings:
|
||||||
renderer_globals['theme'] = request.registry.settings['tailbone.theme']
|
renderer_globals['theme'] = request.registry.settings['tailbone.theme']
|
||||||
# note, this is just a global flag; user still needs permission to see picker
|
# note, this is just a global flag; user still needs permission to see picker
|
||||||
expose_picker = config.get_bool('tailbone.themes.expose_picker',
|
expose_picker = request.rattail_config.getbool('tailbone', 'themes.expose_picker',
|
||||||
default=False)
|
default=False)
|
||||||
renderer_globals['expose_theme_picker'] = expose_picker
|
renderer_globals['expose_theme_picker'] = expose_picker
|
||||||
if expose_picker:
|
if expose_picker:
|
||||||
|
# tailbone's config extension provides a default theme selection,
|
||||||
# TODO: should remove 'falafel' option altogether
|
# so the default we specify here *probably* should not matter
|
||||||
available = get_available_themes(config)
|
available = request.rattail_config.getlist('tailbone', 'themes',
|
||||||
|
default=['falafel'])
|
||||||
options = [tags.Option(theme, value=theme) for theme in available]
|
if 'default' not in available:
|
||||||
|
available.insert(0, 'default')
|
||||||
|
options = [tags.Option(theme) for theme in available]
|
||||||
renderer_globals['theme_picker_options'] = options
|
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
|
# TODO: ugh, same deal here
|
||||||
renderer_globals['messaging_enabled'] = config.get_bool('tailbone.messaging.enabled',
|
renderer_globals['messaging_enabled'] = request.rattail_config.getbool(
|
||||||
default=False)
|
'tailbone', 'messaging.enabled', default=False)
|
||||||
|
|
||||||
# background color may be set per-request, by some apps
|
# background color may be set per-request, by some apps
|
||||||
if hasattr(request, 'background_color') and request.background_color:
|
if hasattr(request, 'background_color') and request.background_color:
|
||||||
renderer_globals['background_color'] = request.background_color
|
renderer_globals['background_color'] = request.background_color
|
||||||
else: # otherwise we use the one from config
|
else: # otherwise we use the one from config
|
||||||
renderer_globals['background_color'] = config.get('tailbone.background_color')
|
renderer_globals['background_color'] = request.rattail_config.get(
|
||||||
|
'tailbone', 'background_color')
|
||||||
|
|
||||||
# maybe set custom stylesheet
|
# buefy themes get some extra treatment
|
||||||
css = None
|
if should_use_buefy(request):
|
||||||
if request.user:
|
|
||||||
css = config.get(f'tailbone.{request.user.uuid}', 'user_css')
|
# declare vue.js and buefy versions to use. the default
|
||||||
|
# values here are "quite conservative" as of this writing,
|
||||||
|
# perhaps too much so, but at least they should work fine.
|
||||||
|
renderer_globals['vue_version'] = request.rattail_config.get(
|
||||||
|
'tailbone', 'vue_version') or '2.6.10'
|
||||||
|
renderer_globals['buefy_version'] = request.rattail_config.get(
|
||||||
|
'tailbone', 'buefy_version') or '0.8.13'
|
||||||
|
|
||||||
|
# maybe set custom stylesheet
|
||||||
|
css = None
|
||||||
|
if request.user:
|
||||||
|
css = request.rattail_config.get('tailbone.{}'.format(request.user.uuid),
|
||||||
|
'buefy_css')
|
||||||
if not css:
|
if not css:
|
||||||
css = config.get(f'tailbone.{request.user.uuid}', 'buefy_css')
|
css = request.rattail_config.get('tailbone', 'theme.falafel.buefy_css')
|
||||||
if css:
|
renderer_globals['buefy_css'] = css
|
||||||
warnings.warn(f"setting 'tailbone.{request.user.uuid}.buefy_css' should be"
|
|
||||||
f"changed to 'tailbone.{request.user.uuid}.user_css'",
|
|
||||||
DeprecationWarning)
|
|
||||||
renderer_globals['user_css'] = css
|
|
||||||
|
|
||||||
# add global search data for quick access
|
|
||||||
renderer_globals['global_search_data'] = get_global_search_options(request)
|
|
||||||
|
|
||||||
# here we globally declare widths for grid filter pseudo-columns
|
# 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:
|
if widths:
|
||||||
widths = widths.split(';')
|
widths = widths.split(';')
|
||||||
if len(widths) < 2:
|
if len(widths) < 2:
|
||||||
|
@ -209,7 +188,7 @@ def before_render(event):
|
||||||
renderer_globals['filter_verb_width'] = widths[1]
|
renderer_globals['filter_verb_width'] = widths[1]
|
||||||
|
|
||||||
# declare global support for websockets, or lack thereof
|
# 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):
|
def add_inbox_count(event):
|
||||||
|
@ -223,9 +202,8 @@ def add_inbox_count(event):
|
||||||
request = event.get('request') or threadlocal.get_current_request()
|
request = event.get('request') or threadlocal.get_current_request()
|
||||||
if request.user:
|
if request.user:
|
||||||
renderer_globals = event
|
renderer_globals = event
|
||||||
app = request.rattail_config.get_app()
|
|
||||||
model = app.model
|
|
||||||
enum = request.rattail_config.get_enum()
|
enum = request.rattail_config.get_enum()
|
||||||
|
model = request.rattail_config.get_model()
|
||||||
renderer_globals['inbox_count'] = Session.query(model.Message)\
|
renderer_globals['inbox_count'] = Session.query(model.Message)\
|
||||||
.outerjoin(model.MessageRecipient)\
|
.outerjoin(model.MessageRecipient)\
|
||||||
.filter(model.MessageRecipient.recipient == Session.merge(request.user))\
|
.filter(model.MessageRecipient.recipient == Session.merge(request.user))\
|
||||||
|
@ -235,14 +213,51 @@ def add_inbox_count(event):
|
||||||
|
|
||||||
def context_found(event):
|
def context_found(event):
|
||||||
"""
|
"""
|
||||||
Attach some more goodies to the request object:
|
Attach some goodies to the request object.
|
||||||
|
|
||||||
The following is attached to the request:
|
The following is attached to the request:
|
||||||
|
|
||||||
* ``get_session_timeout()`` function
|
* The currently logged-in user instance (if any), as ``user``.
|
||||||
|
|
||||||
|
* ``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()``.
|
||||||
|
|
||||||
|
* A shortcut method for fetching the referrer, as ``get_referrer()``.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
request = event.request
|
request = event.request
|
||||||
|
|
||||||
|
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 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():
|
def get_session_timeout():
|
||||||
"""
|
"""
|
||||||
Returns the timeout in effect for the current session
|
Returns the timeout in effect for the current session
|
||||||
|
|
|
@ -1,2 +0,0 @@
|
||||||
## -*- coding: utf-8; -*-
|
|
||||||
<%inherit file="wuttaweb:templates/appinfo/configure.mako" />
|
|
|
@ -1,31 +0,0 @@
|
||||||
## -*- coding: utf-8; -*-
|
|
||||||
<%inherit file="wuttaweb:templates/appinfo/index.mako" />
|
|
||||||
|
|
||||||
<%def name="page_content()">
|
|
||||||
<div class="buttons">
|
|
||||||
|
|
||||||
<once-button type="is-primary"
|
|
||||||
tag="a" href="${url('tables')}"
|
|
||||||
icon-pack="fas"
|
|
||||||
icon-left="eye"
|
|
||||||
text="Tables">
|
|
||||||
</once-button>
|
|
||||||
|
|
||||||
<once-button type="is-primary"
|
|
||||||
tag="a" href="${url('model_views')}"
|
|
||||||
icon-pack="fas"
|
|
||||||
icon-left="eye"
|
|
||||||
text="Model Views">
|
|
||||||
</once-button>
|
|
||||||
|
|
||||||
<once-button type="is-primary"
|
|
||||||
tag="a" href="${url('configure_menus')}"
|
|
||||||
icon-pack="fas"
|
|
||||||
icon-left="cog"
|
|
||||||
text="Configure Menus">
|
|
||||||
</once-button>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
${parent.page_content()}
|
|
||||||
</%def>
|
|
|
@ -5,6 +5,34 @@
|
||||||
|
|
||||||
<%def name="content_title()"></%def>
|
<%def name="content_title()"></%def>
|
||||||
|
|
||||||
|
<%def name="extra_javascript()">
|
||||||
|
${parent.extra_javascript()}
|
||||||
|
% if not use_buefy:
|
||||||
|
${h.javascript_link(request.static_url('tailbone:static/js/tailbone.appsettings.js') + '?ver={}'.format(tailbone.__version__))}
|
||||||
|
% endif
|
||||||
|
</%def>
|
||||||
|
|
||||||
|
<%def name="extra_styles()">
|
||||||
|
${parent.extra_styles()}
|
||||||
|
% if not use_buefy:
|
||||||
|
<style type="text/css">
|
||||||
|
div.form {
|
||||||
|
float: none;
|
||||||
|
}
|
||||||
|
div.panel {
|
||||||
|
width: 85%;
|
||||||
|
}
|
||||||
|
.field-wrapper {
|
||||||
|
margin-bottom: 2em;
|
||||||
|
}
|
||||||
|
.panel .field-wrapper label {
|
||||||
|
font-family: monospace;
|
||||||
|
width: 50em;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
% endif
|
||||||
|
</%def>
|
||||||
|
|
||||||
<%def name="context_menu_items()">
|
<%def name="context_menu_items()">
|
||||||
% if request.has_perm('settings.list'):
|
% if request.has_perm('settings.list'):
|
||||||
<li>${h.link_to("View Raw Settings", url('settings'))}</li>
|
<li>${h.link_to("View Raw Settings", url('settings'))}</li>
|
||||||
|
@ -15,8 +43,8 @@
|
||||||
<app-settings :groups="groups" :showing-group="showingGroup"></app-settings>
|
<app-settings :groups="groups" :showing-group="showingGroup"></app-settings>
|
||||||
</%def>
|
</%def>
|
||||||
|
|
||||||
<%def name="render_vue_templates()">
|
<%def name="render_this_page_template()">
|
||||||
${parent.render_vue_templates()}
|
${parent.render_this_page_template()}
|
||||||
<script type="text/x-template" id="app-settings-template">
|
<script type="text/x-template" id="app-settings-template">
|
||||||
|
|
||||||
<div class="form">
|
<div class="form">
|
||||||
|
@ -150,18 +178,19 @@
|
||||||
</script>
|
</script>
|
||||||
</%def>
|
</%def>
|
||||||
|
|
||||||
|
<%def name="modify_this_page_vars()">
|
||||||
|
${parent.modify_this_page_vars()}
|
||||||
|
<script type="text/javascript">
|
||||||
|
|
||||||
<%def name="modify_vue_vars()">
|
ThisPageData.groups = ${json.dumps(buefy_data)|n}
|
||||||
${parent.modify_vue_vars()}
|
|
||||||
<script>
|
|
||||||
ThisPageData.groups = ${json.dumps(settings_data)|n}
|
|
||||||
ThisPageData.showingGroup = ${json.dumps(current_group or '')|n}
|
ThisPageData.showingGroup = ${json.dumps(current_group or '')|n}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
</%def>
|
</%def>
|
||||||
|
|
||||||
<%def name="make_vue_components()">
|
<%def name="make_this_page_component()">
|
||||||
${parent.make_vue_components()}
|
${parent.make_this_page_component()}
|
||||||
<script>
|
<script type="text/javascript">
|
||||||
|
|
||||||
Vue.component('app-settings', {
|
Vue.component('app-settings', {
|
||||||
template: '#app-settings-template',
|
template: '#app-settings-template',
|
||||||
|
@ -192,3 +221,78 @@
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
</%def>
|
</%def>
|
||||||
|
|
||||||
|
|
||||||
|
% if use_buefy:
|
||||||
|
${parent.body()}
|
||||||
|
|
||||||
|
% else:
|
||||||
|
## legacy / not buefy
|
||||||
|
<div class="form">
|
||||||
|
${h.form(form.action_url, id=dform.formid, method='post', class_='autodisable')}
|
||||||
|
${h.csrf_token(request)}
|
||||||
|
|
||||||
|
% if dform.error:
|
||||||
|
<div class="error-messages">
|
||||||
|
<div class="ui-state-error ui-corner-all">
|
||||||
|
<span style="float: left; margin-right: .3em;" class="ui-icon ui-icon-alert"></span>
|
||||||
|
Please see errors below.
|
||||||
|
</div>
|
||||||
|
<div class="ui-state-error ui-corner-all">
|
||||||
|
<span style="float: left; margin-right: .3em;" class="ui-icon ui-icon-alert"></span>
|
||||||
|
${dform.error}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
% endif
|
||||||
|
|
||||||
|
<div class="group-picker">
|
||||||
|
<div class="field-wrapper">
|
||||||
|
<label for="settings-group">Showing Group</label>
|
||||||
|
<div class="field select">
|
||||||
|
${h.select('settings-group', current_group, group_options, **{'auto-enhance': 'true'})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
% for group in groups:
|
||||||
|
<div class="panel" data-groupname="${group}">
|
||||||
|
<h2>${group}</h2>
|
||||||
|
<div class="panel-body">
|
||||||
|
|
||||||
|
% for setting in settings:
|
||||||
|
% if setting.group == group:
|
||||||
|
<% field = dform[setting.node_name] %>
|
||||||
|
|
||||||
|
<div class="field-wrapper ${field.name} ${'with-error' if field.error else ''}">
|
||||||
|
% if field.error:
|
||||||
|
<div class="field-error">
|
||||||
|
% for msg in field.error.messages():
|
||||||
|
<span class="error-msg">${msg}</span>
|
||||||
|
% endfor
|
||||||
|
</div>
|
||||||
|
% endif
|
||||||
|
<div class="field-row">
|
||||||
|
<label for="${field.oid}">${form.get_label(field.name)}</label>
|
||||||
|
<div class="field">
|
||||||
|
${field.serialize()|n}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
% if form.has_helptext(field.name):
|
||||||
|
<span class="instructions">${form.render_helptext(field.name)}</span>
|
||||||
|
% endif
|
||||||
|
</div>
|
||||||
|
% endif
|
||||||
|
% endfor
|
||||||
|
|
||||||
|
</div><!-- panel-body -->
|
||||||
|
</div><! -- panel -->
|
||||||
|
% endfor
|
||||||
|
|
||||||
|
<div class="buttons">
|
||||||
|
${h.submit('save', getattr(form, 'submit_label', getattr(form, 'save_label', "Submit")), class_='button is-primary')}
|
||||||
|
${h.link_to("Cancel", form.cancel_url, class_='cancel button{}'.format(' autodisable' if form.auto_disable_cancel else ''))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${h.end_form()}
|
||||||
|
</div>
|
||||||
|
% endif
|
||||||
|
|
|
@ -1,5 +1,63 @@
|
||||||
## -*- coding: utf-8; -*-
|
## -*- coding: utf-8; -*-
|
||||||
|
|
||||||
|
## TODO: This function signature is getting out of hand...
|
||||||
|
<%def name="autocomplete(field_name, service_url, field_value=None, field_display=None, width='300px', select=None, selected=None, cleared=None, change_clicked=None, options={})">
|
||||||
|
<div id="${field_name}-container" class="autocomplete-container">
|
||||||
|
${h.hidden(field_name, id=field_name, value=field_value)}
|
||||||
|
${h.text(field_name+'-textbox', id=field_name+'-textbox', value=field_display,
|
||||||
|
class_='autocomplete-textbox', style='display: none;' if field_value else '')}
|
||||||
|
<div id="${field_name}-display" class="autocomplete-display"${'' if field_value else ' style="display: none;"'|n}>
|
||||||
|
<span>${field_display or ''}</span>
|
||||||
|
<button type="button" id="${field_name}-change" class="autocomplete-change">Change</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script type="text/javascript">
|
||||||
|
$(function() {
|
||||||
|
$('#${field_name}-textbox').autocomplete({
|
||||||
|
source: '${service_url}',
|
||||||
|
autoFocus: true,
|
||||||
|
% for key, value in options.items():
|
||||||
|
${key}: ${value},
|
||||||
|
% endfor
|
||||||
|
focus: function(event, ui) {
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
% if select:
|
||||||
|
select: ${select}
|
||||||
|
% else:
|
||||||
|
select: function(event, ui) {
|
||||||
|
$('#${field_name}').val(ui.item.value);
|
||||||
|
$('#${field_name}-display span:first').text(ui.item.label);
|
||||||
|
$('#${field_name}-textbox').hide();
|
||||||
|
$('#${field_name}-display').show();
|
||||||
|
% if selected:
|
||||||
|
${selected}(ui.item.value, ui.item.label);
|
||||||
|
% endif
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
% endif
|
||||||
|
});
|
||||||
|
$('#${field_name}-change').click(function() {
|
||||||
|
% if change_clicked:
|
||||||
|
if (! ${change_clicked}()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
% endif
|
||||||
|
$('#${field_name}').val('');
|
||||||
|
$('#${field_name}-display').hide();
|
||||||
|
with ($('#${field_name}-textbox')) {
|
||||||
|
val('');
|
||||||
|
show();
|
||||||
|
focus();
|
||||||
|
}
|
||||||
|
% if cleared:
|
||||||
|
${cleared}();
|
||||||
|
% endif
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</%def>
|
||||||
|
|
||||||
<%def name="tailbone_autocomplete_template()">
|
<%def name="tailbone_autocomplete_template()">
|
||||||
<script type="text/x-template" id="tailbone-autocomplete-template">
|
<script type="text/x-template" id="tailbone-autocomplete-template">
|
||||||
<div>
|
<div>
|
||||||
|
|
File diff suppressed because it is too large
Load diff
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue