Compare commits
1 commit
master
...
custorders
Author | SHA1 | Date | |
---|---|---|---|
1150e6f7a6 |
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/
|
||||||
|
|
654
CHANGELOG.md
654
CHANGELOG.md
|
@ -1,654 +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.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.
|
|
1813
CHANGES.rst
Normal file
1813
CHANGES.rst
Normal file
File diff suppressed because it is too large
Load diff
141
COPYING.txt
141
COPYING.txt
|
@ -1,5 +1,5 @@
|
||||||
GNU GENERAL PUBLIC LICENSE
|
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||||
Version 3, 29 June 2007
|
Version 3, 19 November 2007
|
||||||
|
|
||||||
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
|
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
|
||||||
Everyone is permitted to copy and distribute verbatim copies
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
@ -7,17 +7,15 @@
|
||||||
|
|
||||||
Preamble
|
Preamble
|
||||||
|
|
||||||
The GNU General Public License is a free, copyleft license for
|
The GNU Affero General Public License is a free, copyleft license for
|
||||||
software and other kinds of works.
|
software and other kinds of works, specifically designed to ensure
|
||||||
|
cooperation with the community in the case of network server software.
|
||||||
|
|
||||||
The licenses for most software and other practical works are designed
|
The licenses for most software and other practical works are designed
|
||||||
to take away your freedom to share and change the works. By contrast,
|
to take away your freedom to share and change the works. By contrast,
|
||||||
the GNU General Public License is intended to guarantee your freedom to
|
our General Public Licenses are intended to guarantee your freedom to
|
||||||
share and change all versions of a program--to make sure it remains free
|
share and change all versions of a program--to make sure it remains free
|
||||||
software for all its users. We, the Free Software Foundation, use the
|
software for all its users.
|
||||||
GNU General Public License for most of our software; it applies also to
|
|
||||||
any other work released this way by its authors. You can apply it to
|
|
||||||
your programs, too.
|
|
||||||
|
|
||||||
When we speak of free software, we are referring to freedom, not
|
When we speak of free software, we are referring to freedom, not
|
||||||
price. Our General Public Licenses are designed to make sure that you
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
|
@ -26,44 +24,34 @@ them if you wish), that you receive source code or can get it if you
|
||||||
want it, that you can change the software or use pieces of it in new
|
want it, that you can change the software or use pieces of it in new
|
||||||
free programs, and that you know you can do these things.
|
free programs, and that you know you can do these things.
|
||||||
|
|
||||||
To protect your rights, we need to prevent others from denying you
|
Developers that use our General Public Licenses protect your rights
|
||||||
these rights or asking you to surrender the rights. Therefore, you have
|
with two steps: (1) assert copyright on the software, and (2) offer
|
||||||
certain responsibilities if you distribute copies of the software, or if
|
you this License which gives you legal permission to copy, distribute
|
||||||
you modify it: responsibilities to respect the freedom of others.
|
and/or modify the software.
|
||||||
|
|
||||||
For example, if you distribute copies of such a program, whether
|
A secondary benefit of defending all users' freedom is that
|
||||||
gratis or for a fee, you must pass on to the recipients the same
|
improvements made in alternate versions of the program, if they
|
||||||
freedoms that you received. You must make sure that they, too, receive
|
receive widespread use, become available for other developers to
|
||||||
or can get the source code. And you must show them these terms so they
|
incorporate. Many developers of free software are heartened and
|
||||||
know their rights.
|
encouraged by the resulting cooperation. However, in the case of
|
||||||
|
software used on network servers, this result may fail to come about.
|
||||||
|
The GNU General Public License permits making a modified version and
|
||||||
|
letting the public access it on a server without ever releasing its
|
||||||
|
source code to the public.
|
||||||
|
|
||||||
Developers that use the GNU GPL protect your rights with two steps:
|
The GNU Affero General Public License is designed specifically to
|
||||||
(1) assert copyright on the software, and (2) offer you this License
|
ensure that, in such cases, the modified source code becomes available
|
||||||
giving you legal permission to copy, distribute and/or modify it.
|
to the community. It requires the operator of a network server to
|
||||||
|
provide the source code of the modified version running there to the
|
||||||
|
users of that server. Therefore, public use of a modified version, on
|
||||||
|
a publicly accessible server, gives the public access to the source
|
||||||
|
code of the modified version.
|
||||||
|
|
||||||
For the developers' and authors' protection, the GPL clearly explains
|
An older license, called the Affero General Public License and
|
||||||
that there is no warranty for this free software. For both users' and
|
published by Affero, was designed to accomplish similar goals. This is
|
||||||
authors' sake, the GPL requires that modified versions be marked as
|
a different license, not a version of the Affero GPL, but Affero has
|
||||||
changed, so that their problems will not be attributed erroneously to
|
released a new version of the Affero GPL which permits relicensing under
|
||||||
authors of previous versions.
|
this license.
|
||||||
|
|
||||||
Some devices are designed to deny users access to install or run
|
|
||||||
modified versions of the software inside them, although the manufacturer
|
|
||||||
can do so. This is fundamentally incompatible with the aim of
|
|
||||||
protecting users' freedom to change the software. The systematic
|
|
||||||
pattern of such abuse occurs in the area of products for individuals to
|
|
||||||
use, which is precisely where it is most unacceptable. Therefore, we
|
|
||||||
have designed this version of the GPL to prohibit the practice for those
|
|
||||||
products. If such problems arise substantially in other domains, we
|
|
||||||
stand ready to extend this provision to those domains in future versions
|
|
||||||
of the GPL, as needed to protect the freedom of users.
|
|
||||||
|
|
||||||
Finally, every program is threatened constantly by software patents.
|
|
||||||
States should not allow patents to restrict development and use of
|
|
||||||
software on general-purpose computers, but in those that do, we wish to
|
|
||||||
avoid the special danger that patents applied to a free program could
|
|
||||||
make it effectively proprietary. To prevent this, the GPL assures that
|
|
||||||
patents cannot be used to render the program non-free.
|
|
||||||
|
|
||||||
The precise terms and conditions for copying, distribution and
|
The precise terms and conditions for copying, distribution and
|
||||||
modification follow.
|
modification follow.
|
||||||
|
@ -72,7 +60,7 @@ modification follow.
|
||||||
|
|
||||||
0. Definitions.
|
0. Definitions.
|
||||||
|
|
||||||
"This License" refers to version 3 of the GNU General Public License.
|
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||||
|
|
||||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||||
works, such as semiconductor masks.
|
works, such as semiconductor masks.
|
||||||
|
@ -549,35 +537,45 @@ to collect a royalty for further conveying from those to whom you convey
|
||||||
the Program, the only way you could satisfy both those terms and this
|
the Program, the only way you could satisfy both those terms and this
|
||||||
License would be to refrain entirely from conveying the Program.
|
License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
13. Use with the GNU Affero General Public License.
|
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, if you modify the
|
||||||
|
Program, your modified version must prominently offer all users
|
||||||
|
interacting with it remotely through a computer network (if your version
|
||||||
|
supports such interaction) an opportunity to receive the Corresponding
|
||||||
|
Source of your version by providing access to the Corresponding Source
|
||||||
|
from a network server at no charge, through some standard or customary
|
||||||
|
means of facilitating copying of software. This Corresponding Source
|
||||||
|
shall include the Corresponding Source for any work covered by version 3
|
||||||
|
of the GNU General Public License that is incorporated pursuant to the
|
||||||
|
following paragraph.
|
||||||
|
|
||||||
Notwithstanding any other provision of this License, you have
|
Notwithstanding any other provision of this License, you have
|
||||||
permission to link or combine any covered work with a work licensed
|
permission to link or combine any covered work with a work licensed
|
||||||
under version 3 of the GNU Affero General Public License into a single
|
under version 3 of the GNU General Public License into a single
|
||||||
combined work, and to convey the resulting work. The terms of this
|
combined work, and to convey the resulting work. The terms of this
|
||||||
License will continue to apply to the part which is the covered work,
|
License will continue to apply to the part which is the covered work,
|
||||||
but the special requirements of the GNU Affero General Public License,
|
but the work with which it is combined will remain governed by version
|
||||||
section 13, concerning interaction through a network will apply to the
|
3 of the GNU General Public License.
|
||||||
combination as such.
|
|
||||||
|
|
||||||
14. Revised Versions of this License.
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
The Free Software Foundation may publish revised and/or new versions of
|
The Free Software Foundation may publish revised and/or new versions of
|
||||||
the GNU General Public License from time to time. Such new versions will
|
the GNU Affero General Public License from time to time. Such new versions
|
||||||
be similar in spirit to the present version, but may differ in detail to
|
will be similar in spirit to the present version, but may differ in detail to
|
||||||
address new problems or concerns.
|
address new problems or concerns.
|
||||||
|
|
||||||
Each version is given a distinguishing version number. If the
|
Each version is given a distinguishing version number. If the
|
||||||
Program specifies that a certain numbered version of the GNU General
|
Program specifies that a certain numbered version of the GNU Affero General
|
||||||
Public License "or any later version" applies to it, you have the
|
Public License "or any later version" applies to it, you have the
|
||||||
option of following the terms and conditions either of that numbered
|
option of following the terms and conditions either of that numbered
|
||||||
version or of any later version published by the Free Software
|
version or of any later version published by the Free Software
|
||||||
Foundation. If the Program does not specify a version number of the
|
Foundation. If the Program does not specify a version number of the
|
||||||
GNU General Public License, you may choose any version ever published
|
GNU Affero General Public License, you may choose any version ever published
|
||||||
by the Free Software Foundation.
|
by the Free Software Foundation.
|
||||||
|
|
||||||
If the Program specifies that a proxy can decide which future
|
If the Program specifies that a proxy can decide which future
|
||||||
versions of the GNU General Public License can be used, that proxy's
|
versions of the GNU Affero General Public License can be used, that proxy's
|
||||||
public statement of acceptance of a version permanently authorizes you
|
public statement of acceptance of a version permanently authorizes you
|
||||||
to choose that version for the Program.
|
to choose that version for the Program.
|
||||||
|
|
||||||
|
@ -635,40 +633,29 @@ the "copyright" line and a pointer to where the full notice is found.
|
||||||
Copyright (C) <year> <name of author>
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
This program is free software: you can redistribute it and/or modify
|
||||||
it under the terms of the GNU General Public License as published by
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
the Free Software Foundation, either version 3 of the License, or
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
(at your option) any later version.
|
(at your option) any later version.
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
This program is distributed in the hope that it will be useful,
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
GNU General Public License for more details.
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
You should have received a copy of the GNU General Public License
|
You should have received a copy of the GNU Affero General Public License
|
||||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
Also add information on how to contact you by electronic and paper mail.
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
If the program does terminal interaction, make it output a short
|
If your software can interact with users remotely through a computer
|
||||||
notice like this when it starts in an interactive mode:
|
network, you should also make sure that it provides a way for users to
|
||||||
|
get its source. For example, if your program is a web application, its
|
||||||
<program> Copyright (C) <year> <name of author>
|
interface could display a "Source" link that leads users to an archive
|
||||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
of the code. There are many ways you could offer source, and different
|
||||||
This is free software, and you are welcome to redistribute it
|
solutions will be better for different programs; see section 13 for the
|
||||||
under certain conditions; type `show c' for details.
|
specific requirements.
|
||||||
|
|
||||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
|
||||||
parts of the General Public License. Of course, your program's commands
|
|
||||||
might be different; for a GUI interface, you would use an "about box".
|
|
||||||
|
|
||||||
You should also get your employer (if you work as a programmer) or school,
|
You should also get your employer (if you work as a programmer) or school,
|
||||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||||
For more information on this, and how to apply and follow the GNU GPL, see
|
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||||
<http://www.gnu.org/licenses/>.
|
<http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
The GNU General Public License does not permit incorporating your program
|
|
||||||
into proprietary programs. If your program is a subroutine library, you
|
|
||||||
may consider it more useful to permit linking proprietary applications with
|
|
||||||
the library. If this is what you want to do, use the GNU Lesser General
|
|
||||||
Public License instead of this License. But first, please read
|
|
||||||
<http://www.gnu.org/philosophy/why-not-lgpl.html>.
|
|
||||||
|
|
|
@ -3,7 +3,6 @@ include *.txt
|
||||||
include *.rst
|
include *.rst
|
||||||
include *.py
|
include *.py
|
||||||
|
|
||||||
include tailbone/static/robots.txt
|
|
||||||
recursive-include tailbone/static *.js
|
recursive-include tailbone/static *.js
|
||||||
recursive-include tailbone/static *.css
|
recursive-include tailbone/static *.css
|
||||||
recursive-include tailbone/static *.png
|
recursive-include tailbone/static *.png
|
||||||
|
@ -11,8 +10,5 @@ recursive-include tailbone/static *.jpg
|
||||||
recursive-include tailbone/static *.gif
|
recursive-include tailbone/static *.gif
|
||||||
recursive-include tailbone/static *.ico
|
recursive-include tailbone/static *.ico
|
||||||
|
|
||||||
recursive-include tailbone/static/files *
|
|
||||||
|
|
||||||
recursive-include tailbone/templates *.mako
|
recursive-include tailbone/templates *.mako
|
||||||
recursive-include tailbone/templates *.pt
|
|
||||||
recursive-include tailbone/reports *.mako
|
recursive-include tailbone/reports *.mako
|
||||||
|
|
|
@ -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/
|
7539
docs/OLDCHANGES.rst
7539
docs/OLDCHANGES.rst
File diff suppressed because it is too large
Load diff
|
@ -1,15 +0,0 @@
|
||||||
|
|
||||||
``tailbone.api.batch.core``
|
|
||||||
===========================
|
|
||||||
|
|
||||||
.. automodule:: tailbone.api.batch.core
|
|
||||||
|
|
||||||
.. autoclass:: APIBatchMixin
|
|
||||||
|
|
||||||
.. autoclass:: APIBatchView
|
|
||||||
|
|
||||||
.. autoclass:: APIBatchRowView
|
|
||||||
|
|
||||||
.. autoattribute:: editable
|
|
||||||
|
|
||||||
.. autoattribute:: supports_quick_entry
|
|
|
@ -1,41 +0,0 @@
|
||||||
|
|
||||||
``tailbone.api.batch.ordering``
|
|
||||||
===============================
|
|
||||||
|
|
||||||
.. automodule:: tailbone.api.batch.ordering
|
|
||||||
|
|
||||||
.. autoclass:: OrderingBatchViews
|
|
||||||
|
|
||||||
.. autoattribute:: collection_url_prefix
|
|
||||||
|
|
||||||
.. autoattribute:: object_url_prefix
|
|
||||||
|
|
||||||
.. autoattribute:: model_class
|
|
||||||
|
|
||||||
.. autoattribute:: route_prefix
|
|
||||||
|
|
||||||
.. autoattribute:: permission_prefix
|
|
||||||
|
|
||||||
.. autoattribute:: default_handler_spec
|
|
||||||
|
|
||||||
.. automethod:: base_query
|
|
||||||
|
|
||||||
.. automethod:: create_object
|
|
||||||
|
|
||||||
.. autoclass:: OrderingBatchRowViews
|
|
||||||
|
|
||||||
.. autoattribute:: collection_url_prefix
|
|
||||||
|
|
||||||
.. autoattribute:: object_url_prefix
|
|
||||||
|
|
||||||
.. autoattribute:: model_class
|
|
||||||
|
|
||||||
.. autoattribute:: route_prefix
|
|
||||||
|
|
||||||
.. autoattribute:: permission_prefix
|
|
||||||
|
|
||||||
.. autoattribute:: default_handler_spec
|
|
||||||
|
|
||||||
.. autoattribute:: supports_quick_entry
|
|
||||||
|
|
||||||
.. automethod:: update_object
|
|
|
@ -1,6 +0,0 @@
|
||||||
|
|
||||||
``tailbone.db``
|
|
||||||
===============
|
|
||||||
|
|
||||||
.. automodule:: tailbone.db
|
|
||||||
:members:
|
|
|
@ -1,6 +0,0 @@
|
||||||
|
|
||||||
``tailbone.diffs``
|
|
||||||
==================
|
|
||||||
|
|
||||||
.. automodule:: tailbone.diffs
|
|
||||||
:members:
|
|
|
@ -1,9 +0,0 @@
|
||||||
|
|
||||||
``tailbone.forms``
|
|
||||||
==================
|
|
||||||
|
|
||||||
.. automodule:: tailbone.forms
|
|
||||||
:members:
|
|
||||||
|
|
||||||
.. autoclass:: tailbone.forms.Form
|
|
||||||
: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:
|
|
|
@ -1,6 +0,0 @@
|
||||||
|
|
||||||
``tailbone.grids``
|
|
||||||
==================
|
|
||||||
|
|
||||||
.. automodule:: tailbone.grids
|
|
||||||
:members:
|
|
10
docs/api/newgrids.rst
Normal file
10
docs/api/newgrids.rst
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
.. -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
``tailbone.newgrids``
|
||||||
|
=====================
|
||||||
|
|
||||||
|
.. automodule:: tailbone.newgrids
|
||||||
|
:members:
|
||||||
|
|
||||||
|
.. automodule:: tailbone.newgrids.alchemy
|
||||||
|
:members:
|
|
@ -1,6 +0,0 @@
|
||||||
|
|
||||||
``tailbone.progress``
|
|
||||||
=====================
|
|
||||||
|
|
||||||
.. automodule:: tailbone.progress
|
|
||||||
:members:
|
|
|
@ -3,4 +3,5 @@
|
||||||
========================
|
========================
|
||||||
|
|
||||||
.. automodule:: tailbone.subscribers
|
.. automodule:: tailbone.subscribers
|
||||||
:members:
|
|
||||||
|
.. autofunction:: add_rattail_config_attribute_to_request
|
||||||
|
|
|
@ -1,6 +0,0 @@
|
||||||
|
|
||||||
``tailbone.util``
|
|
||||||
=================
|
|
||||||
|
|
||||||
.. automodule:: tailbone.util
|
|
||||||
:members:
|
|
|
@ -1,5 +1,29 @@
|
||||||
|
.. -*- coding: utf-8 -*-
|
||||||
|
|
||||||
``tailbone.views.batch``
|
``tailbone.views.batch``
|
||||||
========================
|
========================
|
||||||
|
|
||||||
.. automodule:: tailbone.views.batch
|
.. automodule:: tailbone.views.batch
|
||||||
|
|
||||||
|
.. autoclass:: BatchGrid
|
||||||
|
:members:
|
||||||
|
|
||||||
|
.. autoclass:: FileBatchGrid
|
||||||
|
:members:
|
||||||
|
|
||||||
|
.. autoclass:: BatchCrud
|
||||||
|
:members:
|
||||||
|
|
||||||
|
.. autoclass:: FileBatchCrud
|
||||||
|
:members:
|
||||||
|
|
||||||
|
.. autoclass:: BatchRowGrid
|
||||||
|
:members:
|
||||||
|
|
||||||
|
.. autoclass:: ProductBatchRowGrid
|
||||||
|
:members:
|
||||||
|
|
||||||
|
.. autoclass:: BatchRowCrud
|
||||||
|
:members:
|
||||||
|
|
||||||
|
.. autofunction:: defaults
|
||||||
|
|
|
@ -1,10 +0,0 @@
|
||||||
|
|
||||||
``tailbone.views.batch.vendorcatalog``
|
|
||||||
======================================
|
|
||||||
|
|
||||||
.. automodule:: tailbone.views.batch.vendorcatalog
|
|
||||||
|
|
||||||
.. autoclass:: VendorCatalogsView
|
|
||||||
:members:
|
|
||||||
|
|
||||||
.. autofunction:: includeme
|
|
|
@ -1,6 +0,0 @@
|
||||||
|
|
||||||
``tailbone.views.core``
|
|
||||||
=======================
|
|
||||||
|
|
||||||
.. automodule:: tailbone.views.core
|
|
||||||
:members:
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
.. -*- coding: utf-8 -*-
|
||||||
|
|
||||||
``tailbone.views.master``
|
``tailbone.views.master``
|
||||||
=========================
|
=========================
|
||||||
|
@ -66,61 +67,12 @@ override when defining your subclass.
|
||||||
.. attribute:: MasterView.grid_factory
|
.. attribute:: MasterView.grid_factory
|
||||||
|
|
||||||
Factory callable to be used when creating new grid instances; defaults to
|
Factory callable to be used when creating new grid instances; defaults to
|
||||||
:class:`tailbone.grids.Grid`.
|
:class:`tailbone.newgrids.alchemy.AlchemyGrid`.
|
||||||
|
|
||||||
.. attribute:: MasterView.results_downloadable_csv
|
.. Methods to Override
|
||||||
|
.. -------------------
|
||||||
Flag indicating whether the view should allow CSV download of grid data,
|
..
|
||||||
i.e. primary search results.
|
.. The following is a list of methods which you can override when defining your
|
||||||
|
.. subclass.
|
||||||
.. attribute:: MasterView.help_url
|
..
|
||||||
|
.. .. automethod:: MasterView.get_settings
|
||||||
If set, this defines the "default" help URL for all views provided by the
|
|
||||||
master. Default value for this is simply ``None`` which would mean the
|
|
||||||
Help button is not shown at all. Note that the master may choose to
|
|
||||||
override this for certain views, if so that should be done within
|
|
||||||
:meth:`get_help_url()`.
|
|
||||||
|
|
||||||
.. attribute:: MasterView.version_diff_factory
|
|
||||||
|
|
||||||
Optional factory to use for version diff objects. By default
|
|
||||||
this is *not set* but a subclass is free to set it. See also
|
|
||||||
:meth:`get_version_diff_factory()`.
|
|
||||||
|
|
||||||
|
|
||||||
Methods to Override
|
|
||||||
-------------------
|
|
||||||
|
|
||||||
The following is a list of methods which you can override when defining your
|
|
||||||
subclass.
|
|
||||||
|
|
||||||
.. automethod:: MasterView.editable_instance
|
|
||||||
|
|
||||||
.. .. automethod:: MasterView.get_settings
|
|
||||||
|
|
||||||
.. automethod:: MasterView.get_csv_fields
|
|
||||||
|
|
||||||
.. automethod:: MasterView.get_csv_row
|
|
||||||
|
|
||||||
.. 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,9 +0,0 @@
|
||||||
|
|
||||||
``tailbone.views.purchasing.batch``
|
|
||||||
===================================
|
|
||||||
|
|
||||||
.. automodule:: tailbone.views.purchasing.batch
|
|
||||||
|
|
||||||
.. autoclass:: PurchasingBatchView
|
|
||||||
|
|
||||||
.. automethod:: save_edit_row_form
|
|
|
@ -1,15 +0,0 @@
|
||||||
|
|
||||||
``tailbone.views.purchasing.ordering``
|
|
||||||
======================================
|
|
||||||
|
|
||||||
.. automodule:: tailbone.views.purchasing.ordering
|
|
||||||
|
|
||||||
.. autoclass:: OrderingBatchView
|
|
||||||
|
|
||||||
.. autoattribute:: model_class
|
|
||||||
|
|
||||||
.. autoattribute:: default_handler_spec
|
|
||||||
|
|
||||||
.. automethod:: configure_row_form
|
|
||||||
|
|
||||||
.. automethod:: worksheet_update
|
|
10
docs/api/views/vendors.catalogs.rst
Normal file
10
docs/api/views/vendors.catalogs.rst
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
|
||||||
|
``tailbone.views.vendors.catalogs``
|
||||||
|
===================================
|
||||||
|
|
||||||
|
.. automodule:: tailbone.views.vendors.catalogs
|
||||||
|
|
||||||
|
.. autoclass:: VendorCatalogsView
|
||||||
|
:members:
|
||||||
|
|
||||||
|
.. autofunction:: includeme
|
|
@ -1,8 +0,0 @@
|
||||||
|
|
||||||
Changelog Archive
|
|
||||||
=================
|
|
||||||
|
|
||||||
.. toctree::
|
|
||||||
:maxdepth: 1
|
|
||||||
|
|
||||||
OLDCHANGES
|
|
|
@ -1,65 +0,0 @@
|
||||||
|
|
||||||
Data Batches
|
|
||||||
============
|
|
||||||
|
|
||||||
.. contents:: :local:
|
|
||||||
|
|
||||||
Data "batches" are one of the most powerful features of Rattail / Tailbone.
|
|
||||||
However each "batch type" is different, and they usually require custom
|
|
||||||
development. In all cases they require a Rattail-based app database, for
|
|
||||||
storage.
|
|
||||||
|
|
||||||
|
|
||||||
General Overview
|
|
||||||
----------------
|
|
||||||
|
|
||||||
You can think of data batches as a sort of "temporary spreadsheet" feature.
|
|
||||||
When a batch is created, it is usually populated with rows, from some data
|
|
||||||
source. The user(s) may then manipulate the batch data as needed, with the
|
|
||||||
final goal being to "execute" the batch. What execution specifically means
|
|
||||||
will depend on context, e.g. type of batch, but generally it will "commit" the
|
|
||||||
"pending changes" which are represented by the batch.
|
|
||||||
|
|
||||||
Note that when a batch is executed, it becomes read-only ("frozen in time") and
|
|
||||||
at that point may be considered part of an audit trail of sorts. The utility
|
|
||||||
of this may vary depending on the nature of the batch data.
|
|
||||||
|
|
||||||
Beyond that it's difficult to describe batches very well at this level,
|
|
||||||
precisely because they're all different.
|
|
||||||
|
|
||||||
..
|
|
||||||
This graphic tries to show how batches are created and executed over time.
|
|
||||||
Note that each batch type is free to target a different system(s) upon
|
|
||||||
execution.
|
|
||||||
|
|
||||||
TODO: need graphic
|
|
||||||
|
|
||||||
|
|
||||||
Batch Tables
|
|
||||||
------------
|
|
||||||
|
|
||||||
In most cases the table(s) underlying a particular batch type, have a "static"
|
|
||||||
schema and must be defined as ORM classes, e.g. within the ``poser.db.model``
|
|
||||||
package.
|
|
||||||
|
|
||||||
In some rare cases the batch data (row) table may be dynamic; however the batch
|
|
||||||
header table must still be defined.
|
|
||||||
|
|
||||||
|
|
||||||
Batch Handlers
|
|
||||||
--------------
|
|
||||||
|
|
||||||
Once the batch table(s) are present, the next puzzle piece is the batch
|
|
||||||
handler. Again there is generally (at least) one handler defined for each
|
|
||||||
batch type.
|
|
||||||
|
|
||||||
The batch "handler" is considered part of the data layer and provides logic for
|
|
||||||
populating the batch, executing it etc.
|
|
||||||
|
|
||||||
|
|
||||||
Batch Views
|
|
||||||
-----------
|
|
||||||
|
|
||||||
This discussion would not be complete without mentioning the web views for the
|
|
||||||
batch. Again each batch type will require a custom view(s) although these
|
|
||||||
"usually" are simple wrappers as most logic is provided by the base view.
|
|
|
@ -1,115 +0,0 @@
|
||||||
|
|
||||||
Configuration
|
|
||||||
=============
|
|
||||||
|
|
||||||
.. contents:: :local:
|
|
||||||
|
|
||||||
Configuration for an app can come from two sources: configuration file(s), and
|
|
||||||
the Settings table in the database.
|
|
||||||
|
|
||||||
|
|
||||||
Config File Inheritance
|
|
||||||
-----------------------
|
|
||||||
|
|
||||||
An important thing to understand regarding Rattail config files, is that one
|
|
||||||
file may "include" another file(s), which in turn may "include" others etc.
|
|
||||||
Invocation of the app will often require only a single config file to be
|
|
||||||
specified, since that file may include others as needed.
|
|
||||||
|
|
||||||
For example ``web.conf`` will typically include ``rattail.conf`` but the web
|
|
||||||
app need only be invoked with ``web.conf`` - config from both files will inform
|
|
||||||
the app's behavior.
|
|
||||||
|
|
||||||
|
|
||||||
Typical Config Files
|
|
||||||
--------------------
|
|
||||||
|
|
||||||
A typical Poser (Rattail-based) app will have at the very least, one file named
|
|
||||||
``rattail.conf`` - this is considered the most fundamental config file. It
|
|
||||||
will usually define database connections, logging config, and any other "core"
|
|
||||||
things which would be required for any invocation of the app, regardless of the
|
|
||||||
environment (e.g. console vs. web).
|
|
||||||
|
|
||||||
Note that even ``rattail.conf`` is free to include other files. This may be
|
|
||||||
useful for instance, if you have a single site-wide config file which is shared
|
|
||||||
among all Rattail apps.
|
|
||||||
|
|
||||||
There is no *strict* requirement for having a ``rattail.conf`` file, but these
|
|
||||||
docs will assume its presence. Here are some other typical files, which the
|
|
||||||
docs also may reference occasionally:
|
|
||||||
|
|
||||||
**web.conf** - This is the "core" config file for the web app, although it
|
|
||||||
still includes the ``rattail.conf`` file. In production (running on Apache
|
|
||||||
etc.) it is specified within the WSGI module which is responsible for
|
|
||||||
instantiating the web app. When running the development server, it is
|
|
||||||
specified via command line.
|
|
||||||
|
|
||||||
**quiet.conf** - This is a slight wrapper around ``rattail.conf`` for the sake
|
|
||||||
of a "quieter" console, when running app commands via console. It may be used
|
|
||||||
in place of ``rattail.conf`` - i.e. you would specify ``-c quiet.conf`` when
|
|
||||||
running the command. The only function of this wrapper is to set the level to
|
|
||||||
INFO for the console logging handler. In practice this hides DEBUG logging
|
|
||||||
messages which are shown by default when using ``rattail.conf`` as the app
|
|
||||||
config file.
|
|
||||||
|
|
||||||
**cron.conf** - Another wrapper around ``rattail.conf`` which suppresses
|
|
||||||
logging even further. The idea is that this config file would be used by cron
|
|
||||||
jobs; that way the only actual output is warnings and errors, hence cron would
|
|
||||||
not send email unless something actually went wrong. It may be used in place
|
|
||||||
of ``rattail.conf`` - i.e. you would specify ``-c cron.conf`` when running the
|
|
||||||
command. The only function of this wrapper is to set the level to WARNING for
|
|
||||||
the console logging handler.
|
|
||||||
|
|
||||||
**ignore-changes.conf** - This file is only relevant if your ``rattail.conf``
|
|
||||||
says to "record changes" when write activity occurs in the database(s). Note
|
|
||||||
that this file does *not* include ``rattail.conf`` because it is meant to be
|
|
||||||
supplemental only. For instance on the command line, you would need to specify
|
|
||||||
two config files, first ``rattail.conf`` or a suitable alternative, but then
|
|
||||||
``ignore-changes.conf`` also. If specified, this file will cause changes to be
|
|
||||||
ignored, i.e. **not recorded** when write activity occurs.
|
|
||||||
|
|
||||||
**without-versioning.conf** - This file is only relevant if your
|
|
||||||
``rattail.conf`` says to enable "data versioning" when write activity occurs in
|
|
||||||
the database(s). Note that this file does *not* include ``rattail.conf``
|
|
||||||
because it is meant to be supplemental only. For instance on the command line,
|
|
||||||
you would need to specify two config files, first ``rattail.conf`` or a
|
|
||||||
suitable alternative, but then ``without-versioning.conf`` also. If specified,
|
|
||||||
this file will disable the data versioning system entirely. Note that if
|
|
||||||
versioning is undesirable for a given app run, this is the only way to
|
|
||||||
effectively disable it; once loaded that feature cannot be disabled.
|
|
||||||
|
|
||||||
|
|
||||||
Settings from Database
|
|
||||||
----------------------
|
|
||||||
|
|
||||||
The other (often more convenient) source of app configuration is the Settings
|
|
||||||
table within the app database. Whether or not this table is a valid source for
|
|
||||||
app configuration, ultimately depends on what the config file(s) has to say
|
|
||||||
about it.
|
|
||||||
|
|
||||||
Assuming the config file(s) defines a database connection and declares it a
|
|
||||||
valid source for config values, then the Settings table may contribute to the
|
|
||||||
running app config. The nice thing about this is that these settings are
|
|
||||||
checked in real-time. So whereas changing a config file will require an app
|
|
||||||
restart, any edits to the settings table should take effect immediately.
|
|
||||||
|
|
||||||
Usually the settings table will *override* values found in the config file.
|
|
||||||
This behavior also is configurable to some extent, and in some cases a config
|
|
||||||
value may *only* come from a config file and never the settings table.
|
|
||||||
|
|
||||||
An example may help here. If the config file contained the following value:
|
|
||||||
|
|
||||||
.. code-block:: ini
|
|
||||||
|
|
||||||
[poser]
|
|
||||||
foo = bar
|
|
||||||
|
|
||||||
Then you could create a new Setting in the database with the following fields:
|
|
||||||
|
|
||||||
* **name** = poser.foo
|
|
||||||
* **value** = baz
|
|
||||||
|
|
||||||
Assuming typical setup, i.e. where settings table may override config file, the
|
|
||||||
app would consider 'baz' to be the config value. So basically the setting name
|
|
||||||
must correspond to a combination of the config file "section" name, then a dot,
|
|
||||||
then the "option" name.
|
|
|
@ -1,7 +0,0 @@
|
||||||
|
|
||||||
Console Commands
|
|
||||||
================
|
|
||||||
|
|
||||||
.. contents:: :local:
|
|
||||||
|
|
||||||
TODO
|
|
|
@ -1,45 +0,0 @@
|
||||||
|
|
||||||
Database Schema
|
|
||||||
===============
|
|
||||||
|
|
||||||
.. contents:: :local:
|
|
||||||
|
|
||||||
Rattail provides a "core" schema which is assumed to be the foundation of any
|
|
||||||
Poser app database.
|
|
||||||
|
|
||||||
|
|
||||||
Core Tables
|
|
||||||
-----------
|
|
||||||
|
|
||||||
All tables which are considered part of the Rattail "core" schema, are defined
|
|
||||||
as ORM classes within the ``rattail.db.model`` package.
|
|
||||||
|
|
||||||
.. note::
|
|
||||||
|
|
||||||
The Rattail project has its roots in retail grocery-type stores, and its
|
|
||||||
schema reflects that to a large degree. In practice however the software
|
|
||||||
may be used to support a wide variety of apps. The next section describes
|
|
||||||
that a bit more.
|
|
||||||
|
|
||||||
|
|
||||||
Customizing the Schema
|
|
||||||
----------------------
|
|
||||||
|
|
||||||
Almost certainly a custom app will need some of the core tables, but just as
|
|
||||||
certainly, it will *not* need others. And to make things even more
|
|
||||||
interesting, it may need some tables but also need to "supplement" them
|
|
||||||
somehow, to track additional data for each record etc.
|
|
||||||
|
|
||||||
Any table in the core schema which is *not* needed, may simply be ignored,
|
|
||||||
i.e. hidden from the app UI etc.
|
|
||||||
|
|
||||||
Any table which is "missing" from core schema, from the custom app's
|
|
||||||
perspective, should be added as a custom table.
|
|
||||||
|
|
||||||
Also, any table which is "present but missing columns" from the app's
|
|
||||||
perspective, will require a custom table. In this case each record in the
|
|
||||||
custom table will "tie back to" the core table record. The custom record will
|
|
||||||
then supply any additional data for the core record.
|
|
||||||
|
|
||||||
Defining custom tables, and associated tasks, are documented in
|
|
||||||
:doc:`../schemachange`.
|
|
273
docs/conf.py
273
docs/conf.py
|
@ -1,21 +1,36 @@
|
||||||
# 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 execfile()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
|
execfile(os.path.join(os.pardir, 'tailbone', '_version.py'))
|
||||||
|
|
||||||
project = 'Tailbone'
|
|
||||||
copyright = '2010 - 2024, Lance Edgar'
|
|
||||||
author = 'Lance Edgar'
|
|
||||||
release = get_version('Tailbone')
|
|
||||||
|
|
||||||
# -- General configuration ---------------------------------------------------
|
# If extensions (or modules to document with autodoc) are in another directory,
|
||||||
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
|
# 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 +38,234 @@ extensions = [
|
||||||
'sphinx.ext.viewcode',
|
'sphinx.ext.viewcode',
|
||||||
]
|
]
|
||||||
|
|
||||||
templates_path = ['_templates']
|
|
||||||
exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
|
|
||||||
|
|
||||||
intersphinx_mapping = {
|
intersphinx_mapping = {
|
||||||
'rattail': ('https://rattailproject.org/docs/rattail/', None),
|
# TODO: Add this back, when the FA site is back online...
|
||||||
'webhelpers2': ('https://webhelpers2.readthedocs.io/en/latest/', None),
|
#'formalchemy': ('http://docs.formalchemy.org/formalchemy/', None),
|
||||||
'wuttaweb': ('https://rattailproject.org/docs/wuttaweb/', None),
|
|
||||||
'wuttjamaican': ('https://rattailproject.org/docs/wuttjamaican/', None),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# allow todo entries to show up
|
# Add any paths that contain templates here, relative to this directory.
|
||||||
todo_include_todos = True
|
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'2015, 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'
|
||||||
|
# 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
|
||||||
|
|
||||||
|
|
||||||
# -- 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'
|
||||||
|
|
||||||
|
# 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 = []
|
||||||
|
|
||||||
|
# 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'
|
|
||||||
|
# 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
|
||||||
|
|
|
@ -1,78 +0,0 @@
|
||||||
|
|
||||||
Development Environment
|
|
||||||
=======================
|
|
||||||
|
|
||||||
.. contents:: :local:
|
|
||||||
|
|
||||||
Base System
|
|
||||||
-----------
|
|
||||||
|
|
||||||
Development for Tailbone in particular is assumed to occur on a Linux machine.
|
|
||||||
This is because it's assumed that the web app would run on Linux. It should be
|
|
||||||
possible (presumably) to do either on Windows or Mac but that is not officially
|
|
||||||
supported.
|
|
||||||
|
|
||||||
Furthermore it is assumed the Linux flavor in use is either Debian or Ubuntu,
|
|
||||||
or a similar alternative. Presumably any Linux would work although some
|
|
||||||
details may differ from what's shown here.
|
|
||||||
|
|
||||||
Prerequisites
|
|
||||||
-------------
|
|
||||||
|
|
||||||
Python
|
|
||||||
^^^^^^
|
|
||||||
|
|
||||||
The only supported Python is 2.7. Of course that should already be present on
|
|
||||||
Linux.
|
|
||||||
|
|
||||||
It usually is required at some point to compile C code for certain Python
|
|
||||||
extension modules. In practice this means you probably want the Python header
|
|
||||||
files as well:
|
|
||||||
|
|
||||||
.. code-block:: sh
|
|
||||||
|
|
||||||
sudo apt-get install python-dev
|
|
||||||
|
|
||||||
pip
|
|
||||||
^^^
|
|
||||||
|
|
||||||
The only supported Python package manager is ``pip``. This can be installed a
|
|
||||||
few ways, one of which is:
|
|
||||||
|
|
||||||
.. code-block:: sh
|
|
||||||
|
|
||||||
sudo apt-get install python-pip
|
|
||||||
|
|
||||||
virtualenvwrapper
|
|
||||||
^^^^^^^^^^^^^^^^^
|
|
||||||
|
|
||||||
While not technically required, it is recommended to use ``virtualenvwrapper``
|
|
||||||
as well. There is more than one way to set this up, e.g.:
|
|
||||||
|
|
||||||
.. code-block:: sh
|
|
||||||
|
|
||||||
sudo apt-get install python-virtualenvwrapper
|
|
||||||
|
|
||||||
The main variable as concerns these docs, is where your virtual environment(s)
|
|
||||||
will live. If you install virtualenvwrapper via the above command, then most
|
|
||||||
likely your ``$WORKON_HOME`` environment variable will be set to
|
|
||||||
``~/.virtualenvs`` - however these docs will assume ``/srv/envs`` instead.
|
|
||||||
Please adjust any commands as needed.
|
|
||||||
|
|
||||||
PostgreSQL
|
|
||||||
^^^^^^^^^^
|
|
||||||
|
|
||||||
The other primary requirement is PostgreSQL. Technically that may be installed
|
|
||||||
on a separate machine, which allows connection from the development machine.
|
|
||||||
But of course it will usually just be installed on the dev machine:
|
|
||||||
|
|
||||||
.. code-block:: sh
|
|
||||||
|
|
||||||
sudo apt-get install postgresql
|
|
||||||
|
|
||||||
Regardless of where your PG server lives, you will probably need some extras in
|
|
||||||
order to compile extensions for the ``psycopg2`` package:
|
|
||||||
|
|
||||||
.. code-block:: sh
|
|
||||||
|
|
||||||
sudo apt-get install libpq-dev
|
|
Binary file not shown.
Before Width: | Height: | Size: 22 KiB |
Binary file not shown.
Before Width: | Height: | Size: 7.6 KiB |
|
@ -2,35 +2,15 @@
|
||||||
Tailbone
|
Tailbone
|
||||||
========
|
========
|
||||||
|
|
||||||
Welcome to Tailbone, part of the Rattail project. While the core Rattail
|
Welcome to Tailbone, part of the Rattail project.
|
||||||
package provides the data layer, the Tailbone package provides the (default,
|
|
||||||
back-end) web application layer.
|
|
||||||
|
|
||||||
Some additional information is available on the `website`_. Certainly not
|
The documentation you are currently reading is for the Tailbone web application
|
||||||
everything is documented yet, but here you can see what has received some
|
package. Some additional information is available on the `website`_. Clearly
|
||||||
|
not everything is documented yet. Below you can see what has received some
|
||||||
attention thus far.
|
attention thus far.
|
||||||
|
|
||||||
.. _website: https://rattailproject.org/
|
.. _website: https://rattailproject.org/
|
||||||
|
|
||||||
Quick Start for Custom Apps:
|
|
||||||
|
|
||||||
.. toctree::
|
|
||||||
:maxdepth: 1
|
|
||||||
|
|
||||||
structure
|
|
||||||
devenv
|
|
||||||
newproject
|
|
||||||
schemachange
|
|
||||||
|
|
||||||
Concept Guide:
|
|
||||||
|
|
||||||
.. toctree::
|
|
||||||
|
|
||||||
concepts/config
|
|
||||||
concepts/console
|
|
||||||
concepts/schema
|
|
||||||
concepts/batches
|
|
||||||
|
|
||||||
Narrative Documentation:
|
Narrative Documentation:
|
||||||
|
|
||||||
.. toctree::
|
.. toctree::
|
||||||
|
@ -42,32 +22,11 @@ Package API:
|
||||||
.. toctree::
|
.. toctree::
|
||||||
:maxdepth: 1
|
:maxdepth: 1
|
||||||
|
|
||||||
api/api/batch/core
|
api/newgrids
|
||||||
api/api/batch/ordering
|
|
||||||
api/db
|
|
||||||
api/diffs
|
|
||||||
api/forms
|
|
||||||
api/forms.widgets
|
|
||||||
api/grids
|
|
||||||
api/grids.core
|
|
||||||
api/progress
|
|
||||||
api/subscribers
|
api/subscribers
|
||||||
api/util
|
|
||||||
api/views/batch
|
api/views/batch
|
||||||
api/views/batch.vendorcatalog
|
|
||||||
api/views/core
|
|
||||||
api/views/master
|
api/views/master
|
||||||
api/views/members
|
api/views/vendors.catalogs
|
||||||
api/views/purchasing.batch
|
|
||||||
api/views/purchasing.ordering
|
|
||||||
|
|
||||||
|
|
||||||
Changelog:
|
|
||||||
|
|
||||||
.. toctree::
|
|
||||||
:maxdepth: 1
|
|
||||||
|
|
||||||
changelog
|
|
||||||
|
|
||||||
|
|
||||||
Documentation To-Do
|
Documentation To-Do
|
||||||
|
|
|
@ -1,154 +0,0 @@
|
||||||
|
|
||||||
Creating a New Project
|
|
||||||
======================
|
|
||||||
|
|
||||||
.. contents:: :local:
|
|
||||||
|
|
||||||
.. highlight:: bash
|
|
||||||
|
|
||||||
This describes the process of creating a new app project based on
|
|
||||||
Rattail/Tailbone. It assumes you are working from a supported :doc:`devenv`.
|
|
||||||
|
|
||||||
Per convention, this doc uses "Poser" (and ``poser``) to represent the custom
|
|
||||||
app. Please adjust commands etc. accordingly. See also :doc:`structure`.
|
|
||||||
|
|
||||||
|
|
||||||
Create the Virtual Environment
|
|
||||||
------------------------------
|
|
||||||
|
|
||||||
First step is simple enough::
|
|
||||||
|
|
||||||
mkvirtualenv poser
|
|
||||||
|
|
||||||
Then with your new environment activated, install the Tailbone package::
|
|
||||||
|
|
||||||
pip install Tailbone
|
|
||||||
|
|
||||||
|
|
||||||
Create the Project
|
|
||||||
------------------
|
|
||||||
|
|
||||||
Now with your environment still activated, ``cd`` to wherever you like
|
|
||||||
(e.g. ``~/src``) and create a new project skeleton like so::
|
|
||||||
|
|
||||||
mkdir -p ~/src
|
|
||||||
cd ~/src
|
|
||||||
pcreate -s rattail poser
|
|
||||||
|
|
||||||
This will have created a new project at ``~/src/poser`` which you can then edit
|
|
||||||
as you wish. At some point you will need to "install" this project to the
|
|
||||||
environment like so (again with environment active)::
|
|
||||||
|
|
||||||
cd ~/src/poser
|
|
||||||
pip install -e .
|
|
||||||
|
|
||||||
|
|
||||||
Setup the App Environment
|
|
||||||
-------------------------
|
|
||||||
|
|
||||||
Any project based on Rattail will effectively be its own "app" (usually), but
|
|
||||||
Rattail itself provides some app functionality as well. However all such apps
|
|
||||||
require config files, usually. If running a web app then you may also need to
|
|
||||||
have configured a folder for session storage, etc. To hopefully simplify all
|
|
||||||
this, there are a few commands you should now run, with your virtual
|
|
||||||
environment still active::
|
|
||||||
|
|
||||||
rattail make-appdir
|
|
||||||
cdvirtualenv app
|
|
||||||
rattail make-config -T rattail
|
|
||||||
rattail make-config -T quiet
|
|
||||||
rattail make-config -T web
|
|
||||||
|
|
||||||
This will have created a new 'app' folder in your environment (e.g. at
|
|
||||||
``/srv/envs/poser/app``) and then created ``rattail.conf`` and ``web.conf``
|
|
||||||
files within that app dir. Note that there will be other folders inside the
|
|
||||||
app dir as well; these are referenced by the config files.
|
|
||||||
|
|
||||||
But you're not done yet... You should likely edit the config files, at the
|
|
||||||
very least edit ``rattail.conf`` and change the ``default.url`` value (under
|
|
||||||
``[rattail.db]`` section) which defines the Rattail database connection.
|
|
||||||
|
|
||||||
|
|
||||||
Create the Database
|
|
||||||
-------------------
|
|
||||||
|
|
||||||
If applicable, it's time for that. First you must literally create the user
|
|
||||||
and database on your PostgreSQL server, e.g.::
|
|
||||||
|
|
||||||
sudo -u postgres createuser --no-createdb --no-createrole --no-superuser poser
|
|
||||||
sudo -u postgres psql -c "alter user poser password 'mypassword'"
|
|
||||||
sudo -u postgres createdb --owner poser poser
|
|
||||||
|
|
||||||
Then you can install the schema; with your virtual environment activated::
|
|
||||||
|
|
||||||
cdvirtualenv
|
|
||||||
alembic -c app/rattail.conf upgrade heads
|
|
||||||
|
|
||||||
At this point your 'poser' database should have some empty tables. To confirm,
|
|
||||||
on your PG server do::
|
|
||||||
|
|
||||||
sudo -u postgres psql -c '\d' poser
|
|
||||||
|
|
||||||
|
|
||||||
Create Admin User
|
|
||||||
-----------------
|
|
||||||
|
|
||||||
If your intention is to have a web app, or at least to test one, you'll
|
|
||||||
probably want to create the initial admin user. With your env active::
|
|
||||||
|
|
||||||
cdvirtualenv
|
|
||||||
rattail -c app/quiet.conf make-user --admin myusername
|
|
||||||
|
|
||||||
This should prompt you for a password, then create a single user and assign it
|
|
||||||
to the Administrator role.
|
|
||||||
|
|
||||||
|
|
||||||
Install Sample Data
|
|
||||||
-------------------
|
|
||||||
|
|
||||||
If desired, you can install a bit of sample data to your fresh Rattail
|
|
||||||
database. With your env active do::
|
|
||||||
|
|
||||||
cdvirtualenv
|
|
||||||
rattail -c app/quiet.conf -P import-sample
|
|
||||||
|
|
||||||
|
|
||||||
Run Dev Web Server
|
|
||||||
------------------
|
|
||||||
|
|
||||||
With all the above in place, you may now run the web server in dev mode::
|
|
||||||
|
|
||||||
cdvirtualenv
|
|
||||||
pserve --reload app/web.conf
|
|
||||||
|
|
||||||
And finally..you may browse your new project dev site at http://localhost:9080/
|
|
||||||
(unless you changed the port etc.)
|
|
||||||
|
|
||||||
|
|
||||||
Schema Migrations
|
|
||||||
-----------------
|
|
||||||
|
|
||||||
Often a new project will require custom schema additions to track/manage data
|
|
||||||
unique to the project. Rattail uses `Alembic`_ for handling schema migrations.
|
|
||||||
General usage of that is documented elsewhere, but a little should be said here
|
|
||||||
regarding new projects.
|
|
||||||
|
|
||||||
.. _Alembic: https://pypi.python.org/pypi/alembic
|
|
||||||
|
|
||||||
The new project template includes most of an Alembic "repo" for schema
|
|
||||||
migrations. However there is one step required to really bootstrap it, i.e. to
|
|
||||||
the point where normal Alembic usage will work: you must create the initial
|
|
||||||
version script. Before you do this, you should be reasonably happy with any
|
|
||||||
ORM classes you've defined, as the initial version script will be used to
|
|
||||||
create that schema. Once you're ready for the script, this command should do
|
|
||||||
it::
|
|
||||||
|
|
||||||
cdvirtualenv
|
|
||||||
bin/alembic -c app/rattail.conf revision --autogenerate --version-path ~/src/poser/poser/db/alembic/versions/ -m 'initial Poser tables'
|
|
||||||
|
|
||||||
You should of course look over and edit the generated script as needed. One
|
|
||||||
change in particular you should make is to add a branch label, e.g.:
|
|
||||||
|
|
||||||
.. code-block:: python
|
|
||||||
|
|
||||||
branch_labels = ('poser',)
|
|
|
@ -1,63 +0,0 @@
|
||||||
|
|
||||||
Migrating the Schema
|
|
||||||
====================
|
|
||||||
|
|
||||||
.. contents:: :local:
|
|
||||||
|
|
||||||
As development progresses for your custom app, you may need to migrate the
|
|
||||||
database schema from time to time.
|
|
||||||
|
|
||||||
See also this general discussion of the :doc:`concepts/schema`.
|
|
||||||
|
|
||||||
.. note::
|
|
||||||
|
|
||||||
The only "safe" migrations are those which add or modify (or remove)
|
|
||||||
"custom" tables, i.e. those *not* provided by the ``rattail.db.model``
|
|
||||||
package. This doc assumes you are aware of this and are only attempting a
|
|
||||||
safe migration.
|
|
||||||
|
|
||||||
|
|
||||||
Modify ORM Classes
|
|
||||||
------------------
|
|
||||||
|
|
||||||
First step is to modify the ORM classes defined by your app, so they reflect
|
|
||||||
the "desired" schema. Typically this will mean editing files under the
|
|
||||||
``poser.db.model`` package within your source. In particular when adding new
|
|
||||||
tables, you must be sure to include them within ``poser/db/model/__init__.py``.
|
|
||||||
|
|
||||||
As noted above, only those classes *not* provided by ``rattail.db.model``
|
|
||||||
should be modified here, to be safe. If you wish to "extend" an existing
|
|
||||||
table, you must create a secondary table which ties back to the first via
|
|
||||||
one-to-one foreign key relationship.
|
|
||||||
|
|
||||||
|
|
||||||
Create Migration Script
|
|
||||||
-----------------------
|
|
||||||
|
|
||||||
Next you will create the Alembic script which is responsible for performing the
|
|
||||||
schema migration against a database. This is typically done like so:
|
|
||||||
|
|
||||||
.. code-block:: sh
|
|
||||||
|
|
||||||
workon poser
|
|
||||||
cdvirtualenv
|
|
||||||
bin/alembic -c app/rattail.conf revision --autogenerate --head poser@head -m "describe migration here"
|
|
||||||
|
|
||||||
This will create a new file under
|
|
||||||
e.g. ``~/src/poser/poser/db/alembic/versions/``. You should edit this file as
|
|
||||||
needed to ensure it performs all steps required for the migration. Technically
|
|
||||||
it should support downgrade as well as upgrade, although in practice that isn't
|
|
||||||
always required.
|
|
||||||
|
|
||||||
|
|
||||||
Upgrade Database Schema
|
|
||||||
-----------------------
|
|
||||||
|
|
||||||
Once you're happy with the new script, you can apply it against your dev
|
|
||||||
database with something like:
|
|
||||||
|
|
||||||
.. code-block:: sh
|
|
||||||
|
|
||||||
workon poser
|
|
||||||
cdvirtualenv
|
|
||||||
bin/alembic -c app/rattail.conf upgrade heads
|
|
|
@ -1,130 +0,0 @@
|
||||||
|
|
||||||
App Organization & Structure
|
|
||||||
============================
|
|
||||||
|
|
||||||
.. contents:: :local:
|
|
||||||
|
|
||||||
Tailbone doesn't try to be an "app" proper. But it does try to provide just
|
|
||||||
about everything you'd need to make one. These docs assume you are making a
|
|
||||||
custom app, and will refer to the app as "Poser" to be consistent. In practice
|
|
||||||
you would give your app a unique name which is meaningful to you. Please
|
|
||||||
mentally replace "Poser" with your app name as you read.
|
|
||||||
|
|
||||||
.. note::
|
|
||||||
|
|
||||||
Technically it *is possible* to use Tailbone directly as the app. You may
|
|
||||||
do so for basic testing of the concepts, but you'd be stuck with Tailbone
|
|
||||||
logic, with far fewer customization options. All docs will assume a custom
|
|
||||||
"Poser" app which wraps and (as necessary) overrides Tailbone and Rattail.
|
|
||||||
|
|
||||||
|
|
||||||
Architecture
|
|
||||||
------------
|
|
||||||
|
|
||||||
In terms of how the Poser app hangs together, here is a conceptual diagram.
|
|
||||||
Note that all systems on the right-hand side are *external* to Poser, i.e. they
|
|
||||||
are not "plugins" although Poser may use plugin-like logic for the sake of
|
|
||||||
integrating with these systems.
|
|
||||||
|
|
||||||
.. image:: images/poser-architecture.png
|
|
||||||
|
|
||||||
|
|
||||||
Data Layer vs. Web Layer
|
|
||||||
^^^^^^^^^^^^^^^^^^^^^^^^
|
|
||||||
|
|
||||||
While the above graphic doesn't do a great job highlighting the difference, it
|
|
||||||
will (presumably) help to understand the difference in purpose and function of
|
|
||||||
Tailbone vs. Rattail packages.
|
|
||||||
|
|
||||||
**Rattail** is the data layer, and is responsible for database connectivity,
|
|
||||||
table schema information, and some business rules logic (among other things).
|
|
||||||
|
|
||||||
**Tailbone** is the web app layer, and is responsible for presentation and
|
|
||||||
management of data objects which are made available by Rattail (and others).
|
|
||||||
|
|
||||||
**Poser** is a custom layer which can make use of both data and web app layers,
|
|
||||||
supplementing each as necessary. In practice the lines may get blurry within
|
|
||||||
Poser.
|
|
||||||
|
|
||||||
The reason for this distinction between layers, is to allow creation of custom
|
|
||||||
apps which use only the data layer but not the web app layer. This can be
|
|
||||||
useful for console-based apps; a traditional GUI app would also be possible
|
|
||||||
although none is yet planned.
|
|
||||||
|
|
||||||
|
|
||||||
File Layout
|
|
||||||
-----------
|
|
||||||
|
|
||||||
Below is an example file layout for a Poser app project. This tries to be
|
|
||||||
"complete" and show most kinds of files a typical project may need. In
|
|
||||||
practice you can usually ignore anything which doesn't apply to your app,
|
|
||||||
i.e. relatively few of the files shown here are actually required. Of course
|
|
||||||
some apps may need many more files than this to achieve their goals.
|
|
||||||
|
|
||||||
Note that all files in the root ``poser`` package namespace would correspond to
|
|
||||||
the "data layer" mentioned above, whereas everything under ``poser.web`` would
|
|
||||||
of course supply the web app layer.
|
|
||||||
|
|
||||||
.. code-block:: none
|
|
||||||
|
|
||||||
~/src/poser/
|
|
||||||
├── CHANGELOG.md
|
|
||||||
├── docs/
|
|
||||||
├── fabfile.py
|
|
||||||
├── MANIFEST.in
|
|
||||||
├── poser/
|
|
||||||
│ ├── __init__.py
|
|
||||||
│ ├── batch/
|
|
||||||
│ │ ├── __init__.py
|
|
||||||
│ │ └── foobatch.py
|
|
||||||
│ ├── commands.py
|
|
||||||
│ ├── config.py
|
|
||||||
│ ├── datasync/
|
|
||||||
│ ├── db/
|
|
||||||
│ │ ├── __init__.py
|
|
||||||
│ │ ├── alembic/
|
|
||||||
│ │ └── model/
|
|
||||||
│ │ ├── __init__.py
|
|
||||||
│ │ ├── batch/
|
|
||||||
│ │ │ ├── __init__.py
|
|
||||||
│ │ │ └── foobatch.py
|
|
||||||
│ │ └── customers.py
|
|
||||||
│ ├── emails.py
|
|
||||||
│ ├── enum.py
|
|
||||||
│ ├── importing/
|
|
||||||
│ │ ├── __init__.py
|
|
||||||
│ │ ├── model.py
|
|
||||||
│ │ ├── poser.py
|
|
||||||
│ │ └── versions.py
|
|
||||||
│ ├── problems.py
|
|
||||||
│ ├── templates/
|
|
||||||
│ │ └── mail/
|
|
||||||
│ │ └── warn_about_foo.html.mako
|
|
||||||
│ ├── _version.py
|
|
||||||
│ └── web/
|
|
||||||
│ ├── __init__.py
|
|
||||||
│ ├── app.py
|
|
||||||
│ ├── static/
|
|
||||||
│ │ ├── __init__.py
|
|
||||||
│ │ ├── css/
|
|
||||||
│ │ ├── favicon.ico
|
|
||||||
│ │ ├── img/
|
|
||||||
│ │ └── js/
|
|
||||||
│ ├── subscribers.py
|
|
||||||
│ ├── templates/
|
|
||||||
│ │ ├── base.mako
|
|
||||||
│ │ ├── batch/
|
|
||||||
│ │ │ └── foobatch/
|
|
||||||
│ │ ├── customers/
|
|
||||||
│ │ ├── menu.mako
|
|
||||||
│ │ └── products/
|
|
||||||
│ └── views/
|
|
||||||
│ ├── __init__.py
|
|
||||||
│ ├── batch/
|
|
||||||
│ │ ├── __init__.py
|
|
||||||
│ │ └── foobatch.py
|
|
||||||
│ ├── common.py
|
|
||||||
│ ├── customers.py
|
|
||||||
│ └── products.py
|
|
||||||
├── README.rst
|
|
||||||
└── setup.py
|
|
39
fabfile.py
vendored
Normal file
39
fabfile.py
vendored
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
################################################################################
|
||||||
|
#
|
||||||
|
# Rattail -- Retail Software Framework
|
||||||
|
# Copyright © 2010-2016 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 Affero 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 Affero General Public License for
|
||||||
|
# more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with Rattail. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
################################################################################
|
||||||
|
"""
|
||||||
|
Fabric script for Tailbone
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import unicode_literals, absolute_import
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
from fabric.api import task, local
|
||||||
|
|
||||||
|
|
||||||
|
@task
|
||||||
|
def release():
|
||||||
|
"""
|
||||||
|
Release a new version of 'Tailbone'.
|
||||||
|
"""
|
||||||
|
shutil.rmtree('Tailbone.egg-info')
|
||||||
|
local('python setup.py sdist --formats=gztar upload')
|
103
pyproject.toml
103
pyproject.toml
|
@ -1,103 +0,0 @@
|
||||||
|
|
||||||
[build-system]
|
|
||||||
requires = ["hatchling"]
|
|
||||||
build-backend = "hatchling.build"
|
|
||||||
|
|
||||||
|
|
||||||
[project]
|
|
||||||
name = "Tailbone"
|
|
||||||
version = "0.22.3"
|
|
||||||
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.18.5",
|
|
||||||
"sa-filters",
|
|
||||||
"simplejson",
|
|
||||||
"transaction",
|
|
||||||
"waitress",
|
|
||||||
"WebHelpers2",
|
|
||||||
"WuttaWeb>=0.14.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
|
173
setup.py
Normal file
173
setup.py
Normal file
|
@ -0,0 +1,173 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
################################################################################
|
||||||
|
#
|
||||||
|
# Rattail -- Retail Software Framework
|
||||||
|
# Copyright © 2010-2016 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 Affero 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 Affero General Public License for
|
||||||
|
# more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero 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__))
|
||||||
|
execfile(os.path.join(here, 'tailbone', '_version.py'))
|
||||||
|
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
|
||||||
|
|
||||||
|
# For now, let's restrict FormEncode to 1.2 since the 1.3 release
|
||||||
|
# introduces some deprecation warnings. Once we're running 1.2 everywhere
|
||||||
|
# in production, we can start looking at adding 1.3 support.
|
||||||
|
# TODO: Remove this restriction.
|
||||||
|
'FormEncode<=1.2.99', # 1.2.4 1.2.6
|
||||||
|
|
||||||
|
# FormAlchemy 1.5 supports Python 3 but is being a little aggressive about
|
||||||
|
# it, for our needs...We'll have to stick with 1.4 for now.
|
||||||
|
u'FormAlchemy<=1.4.99', # 1.4.3
|
||||||
|
|
||||||
|
# Pyramid 1.3 introduced 'pcreate' command (and friends) to replace
|
||||||
|
# deprecated 'paster create' (and friends).
|
||||||
|
'pyramid>=1.3a1', # 1.3b2 1.4.5
|
||||||
|
|
||||||
|
'humanize', # 0.5.1
|
||||||
|
'Mako', # 0.6.2
|
||||||
|
'pyramid_beaker>=0.6', # 0.6.1
|
||||||
|
'pyramid_debugtoolbar', # 1.0
|
||||||
|
'pyramid_exclog', # 0.6
|
||||||
|
'pyramid_mako', # 1.0.2
|
||||||
|
'pyramid_simpleform', # 0.6.1
|
||||||
|
'pyramid_tm', # 0.3
|
||||||
|
'rattail[db,auth,bouncer]', # 0.5.0
|
||||||
|
'six', # 1.10.0
|
||||||
|
'transaction', # 1.2.0
|
||||||
|
'waitress', # 0.8.1
|
||||||
|
'WebHelpers', # 1.3
|
||||||
|
'WTForms', # 2.1
|
||||||
|
'zope.sqlalchemy', # 0.7
|
||||||
|
|
||||||
|
# TODO: Need to figure out what to do about this...
|
||||||
|
# # This is used to obtain POD image dimensions.
|
||||||
|
# 'PIL', # 1.1.7
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
extras = {
|
||||||
|
|
||||||
|
'docs': [
|
||||||
|
#
|
||||||
|
# package # low high
|
||||||
|
|
||||||
|
'Sphinx', # 1.2
|
||||||
|
],
|
||||||
|
|
||||||
|
'tests': [
|
||||||
|
#
|
||||||
|
# package # low high
|
||||||
|
|
||||||
|
'coverage', # 3.6
|
||||||
|
'fixture', # 1.5
|
||||||
|
'mock', # 1.0.1
|
||||||
|
'nose', # 1.3.0
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
setup(
|
||||||
|
name = "Tailbone",
|
||||||
|
version = __version__,
|
||||||
|
author = "Lance Edgar",
|
||||||
|
author_email = "lance@edbob.org",
|
||||||
|
url = "http://rattailproject.org/",
|
||||||
|
license = "GNU Affero GPL v3",
|
||||||
|
description = "Backoffice Web Application for Rattail",
|
||||||
|
long_description = README,
|
||||||
|
|
||||||
|
classifiers = [
|
||||||
|
'Development Status :: 3 - Alpha',
|
||||||
|
'Environment :: Web Environment',
|
||||||
|
'Framework :: Pyramid',
|
||||||
|
'Intended Audience :: Developers',
|
||||||
|
'License :: OSI Approved :: GNU Affero General Public License v3',
|
||||||
|
'Natural Language :: English',
|
||||||
|
'Operating System :: OS Independent',
|
||||||
|
'Programming Language :: Python',
|
||||||
|
'Programming Language :: Python :: 2.6',
|
||||||
|
'Programming Language :: Python :: 2.7',
|
||||||
|
'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',
|
||||||
|
],
|
||||||
|
|
||||||
|
'rattail.config.extensions': [
|
||||||
|
'tailbone = tailbone.config:ConfigExtension',
|
||||||
|
],
|
||||||
|
|
||||||
|
'pyramid.scaffold': [
|
||||||
|
'rattail = tailbone.scaffolds:RattailTemplate',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
|
@ -1,23 +1,24 @@
|
||||||
# -*- coding: utf-8; -*-
|
#!/usr/bin/env python
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
################################################################################
|
################################################################################
|
||||||
#
|
#
|
||||||
# Rattail -- Retail Software Framework
|
# Rattail -- Retail Software Framework
|
||||||
# Copyright © 2010-2017 Lance Edgar
|
# Copyright © 2010-2012 Lance Edgar
|
||||||
#
|
#
|
||||||
# This file is part of Rattail.
|
# This file is part of Rattail.
|
||||||
#
|
#
|
||||||
# Rattail is free software: you can redistribute it and/or modify it under the
|
# 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
|
# terms of the GNU Affero General Public License as published by the Free
|
||||||
# Foundation, either version 3 of the License, or (at your option) any later
|
# Software Foundation, either version 3 of the License, or (at your option)
|
||||||
# version.
|
# any later version.
|
||||||
#
|
#
|
||||||
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
|
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||||
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
||||||
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
|
# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for
|
||||||
# details.
|
# more details.
|
||||||
#
|
#
|
||||||
# You should have received a copy of the GNU General Public License along with
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
# Rattail. If not, see <http://www.gnu.org/licenses/>.
|
# along with Rattail. If not, see <http://www.gnu.org/licenses/>.
|
||||||
#
|
#
|
||||||
################################################################################
|
################################################################################
|
||||||
|
|
||||||
|
|
|
@ -1,9 +1,3 @@
|
||||||
# -*- coding: utf-8; -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
try:
|
__version__ = u'0.5.83'
|
||||||
from importlib.metadata import version
|
|
||||||
except ImportError:
|
|
||||||
from importlib_metadata import version
|
|
||||||
|
|
||||||
|
|
||||||
__version__ = version('Tailbone')
|
|
||||||
|
|
|
@ -1,40 +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/>.
|
|
||||||
#
|
|
||||||
################################################################################
|
|
||||||
"""
|
|
||||||
Tailbone Web API
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import unicode_literals, absolute_import
|
|
||||||
|
|
||||||
from .core import APIView, api
|
|
||||||
from .master import APIMasterView, SortColumn
|
|
||||||
# TODO: remove this
|
|
||||||
from .master2 import APIMasterView2
|
|
||||||
|
|
||||||
|
|
||||||
def includeme(config):
|
|
||||||
config.include('tailbone.api.common')
|
|
||||||
config.include('tailbone.api.auth')
|
|
||||||
config.include('tailbone.api.customers')
|
|
||||||
config.include('tailbone.api.upgrades')
|
|
||||||
config.include('tailbone.api.users')
|
|
|
@ -1,229 +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 Web API - Auth Views
|
|
||||||
"""
|
|
||||||
|
|
||||||
from cornice import Service
|
|
||||||
|
|
||||||
from tailbone.api import APIView, api
|
|
||||||
from tailbone.db import Session
|
|
||||||
from tailbone.auth import login_user, logout_user
|
|
||||||
|
|
||||||
|
|
||||||
class AuthenticationView(APIView):
|
|
||||||
|
|
||||||
@api
|
|
||||||
def check_session(self):
|
|
||||||
"""
|
|
||||||
View to serve as "no-op" / ping action to check current user's session.
|
|
||||||
This will establish a server-side web session for the user if none
|
|
||||||
exists. Note that this also resets the user's session timer.
|
|
||||||
"""
|
|
||||||
data = {'ok': True, 'permissions': []}
|
|
||||||
if self.request.user:
|
|
||||||
data['user'] = self.get_user_info(self.request.user)
|
|
||||||
data['permissions'] = list(self.request.user_permissions)
|
|
||||||
|
|
||||||
# background color may be set per-request, by some apps
|
|
||||||
if hasattr(self.request, 'background_color') and self.request.background_color:
|
|
||||||
data['background_color'] = self.request.background_color
|
|
||||||
else: # otherwise we use the one from config
|
|
||||||
data['background_color'] = self.rattail_config.get(
|
|
||||||
'tailbone', 'background_color')
|
|
||||||
|
|
||||||
# TODO: this seems the best place to return some global app
|
|
||||||
# settings, but maybe not desirable in all cases..in which
|
|
||||||
# case should caller need to ask for these explicitly? or
|
|
||||||
# make a different call altogether to get them..?
|
|
||||||
app = self.get_rattail_app()
|
|
||||||
customer_handler = app.get_clientele_handler()
|
|
||||||
data['settings'] = {
|
|
||||||
'customer_field_dropdown': customer_handler.choice_uses_dropdown(),
|
|
||||||
}
|
|
||||||
|
|
||||||
return data
|
|
||||||
|
|
||||||
@api
|
|
||||||
def login(self):
|
|
||||||
"""
|
|
||||||
API login view.
|
|
||||||
"""
|
|
||||||
if self.request.method == 'OPTIONS':
|
|
||||||
return self.request.response
|
|
||||||
|
|
||||||
username = self.request.json.get('username')
|
|
||||||
password = self.request.json.get('password')
|
|
||||||
if not (username and password):
|
|
||||||
return {'error': "Invalid username or password"}
|
|
||||||
|
|
||||||
# make sure credentials are valid
|
|
||||||
user = self.authenticate_user(username, password)
|
|
||||||
if not user:
|
|
||||||
return {'error': "Invalid username or password"}
|
|
||||||
|
|
||||||
# is there some reason this user should not login?
|
|
||||||
error = self.why_cant_user_login(user)
|
|
||||||
if error:
|
|
||||||
return {'error': error}
|
|
||||||
|
|
||||||
app = self.get_rattail_app()
|
|
||||||
auth = app.get_auth_handler()
|
|
||||||
|
|
||||||
login_user(self.request, user)
|
|
||||||
return {
|
|
||||||
'ok': True,
|
|
||||||
'user': self.get_user_info(user),
|
|
||||||
'permissions': list(auth.get_permissions(Session(), user)),
|
|
||||||
}
|
|
||||||
|
|
||||||
def authenticate_user(self, username, password):
|
|
||||||
app = self.get_rattail_app()
|
|
||||||
auth = app.get_auth_handler()
|
|
||||||
return auth.authenticate_user(Session(), username, password)
|
|
||||||
|
|
||||||
def why_cant_user_login(self, user):
|
|
||||||
"""
|
|
||||||
This method is given a ``User`` instance, which represents someone who
|
|
||||||
is just now trying to login, and has already cleared the basic hurdle
|
|
||||||
of providing the correct credentials for a user on file. This method
|
|
||||||
is responsible then, for further verification that this user *should*
|
|
||||||
in fact be allowed to login to this app node. If the method determines
|
|
||||||
a reason the user should *not* be allowed to login, then it should
|
|
||||||
return that reason as a simple string.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@api
|
|
||||||
def logout(self):
|
|
||||||
"""
|
|
||||||
API logout view.
|
|
||||||
"""
|
|
||||||
if self.request.method == 'OPTIONS':
|
|
||||||
return self.request.response
|
|
||||||
|
|
||||||
logout_user(self.request)
|
|
||||||
return {'ok': True}
|
|
||||||
|
|
||||||
@api
|
|
||||||
def become_root(self):
|
|
||||||
"""
|
|
||||||
Elevate the current request to 'root' for full system access.
|
|
||||||
"""
|
|
||||||
if not self.request.is_admin:
|
|
||||||
raise self.forbidden()
|
|
||||||
self.request.user.record_event(self.enum.USER_EVENT_BECOME_ROOT)
|
|
||||||
self.request.session['is_root'] = True
|
|
||||||
return {
|
|
||||||
'ok': True,
|
|
||||||
'user': self.get_user_info(self.request.user),
|
|
||||||
}
|
|
||||||
|
|
||||||
@api
|
|
||||||
def stop_root(self):
|
|
||||||
"""
|
|
||||||
Lower the current request from 'root' back to normal access.
|
|
||||||
"""
|
|
||||||
if not self.request.is_admin:
|
|
||||||
raise self.forbidden()
|
|
||||||
self.request.user.record_event(self.enum.USER_EVENT_STOP_ROOT)
|
|
||||||
self.request.session['is_root'] = False
|
|
||||||
return {
|
|
||||||
'ok': True,
|
|
||||||
'user': self.get_user_info(self.request.user),
|
|
||||||
}
|
|
||||||
|
|
||||||
@api
|
|
||||||
def change_password(self):
|
|
||||||
"""
|
|
||||||
View which allows a user to change their password.
|
|
||||||
"""
|
|
||||||
if self.request.method == 'OPTIONS':
|
|
||||||
return self.request.response
|
|
||||||
|
|
||||||
if not self.request.user:
|
|
||||||
raise self.forbidden()
|
|
||||||
|
|
||||||
if self.request.user.prevent_password_change and not self.request.is_root:
|
|
||||||
raise self.forbidden()
|
|
||||||
|
|
||||||
data = self.request.json_body
|
|
||||||
|
|
||||||
# first make sure "current" password is accurate
|
|
||||||
if not self.authenticate_user(self.request.user, data['current_password']):
|
|
||||||
return {'error': "The current/old password you provided is incorrect"}
|
|
||||||
|
|
||||||
# okay then, set new password
|
|
||||||
auth = self.app.get_auth_handler()
|
|
||||||
auth.set_user_password(self.request.user, data['new_password'])
|
|
||||||
return {
|
|
||||||
'ok': True,
|
|
||||||
'user': self.get_user_info(self.request.user),
|
|
||||||
}
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def defaults(cls, config):
|
|
||||||
cls._auth_defaults(config)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _auth_defaults(cls, config):
|
|
||||||
|
|
||||||
# session
|
|
||||||
check_session = Service(name='check_session', path='/session')
|
|
||||||
check_session.add_view('GET', 'check_session', klass=cls)
|
|
||||||
config.add_cornice_service(check_session)
|
|
||||||
|
|
||||||
# login
|
|
||||||
login = Service(name='login', path='/login')
|
|
||||||
login.add_view('POST', 'login', klass=cls)
|
|
||||||
config.add_cornice_service(login)
|
|
||||||
|
|
||||||
# logout
|
|
||||||
logout = Service(name='logout', path='/logout')
|
|
||||||
logout.add_view('POST', 'logout', klass=cls)
|
|
||||||
config.add_cornice_service(logout)
|
|
||||||
|
|
||||||
# become root
|
|
||||||
become_root = Service(name='become_root', path='/become-root')
|
|
||||||
become_root.add_view('POST', 'become_root', klass=cls)
|
|
||||||
config.add_cornice_service(become_root)
|
|
||||||
|
|
||||||
# stop root
|
|
||||||
stop_root = Service(name='stop_root', path='/stop-root')
|
|
||||||
stop_root.add_view('POST', 'stop_root', klass=cls)
|
|
||||||
config.add_cornice_service(stop_root)
|
|
||||||
|
|
||||||
# change password
|
|
||||||
change_password = Service(name='change_password', path='/change-password')
|
|
||||||
change_password.add_view('POST', 'change_password', klass=cls)
|
|
||||||
config.add_cornice_service(change_password)
|
|
||||||
|
|
||||||
|
|
||||||
def defaults(config, **kwargs):
|
|
||||||
base = globals()
|
|
||||||
|
|
||||||
AuthenticationView = kwargs.get('AuthenticationView', base['AuthenticationView'])
|
|
||||||
AuthenticationView.defaults(config)
|
|
||||||
|
|
||||||
|
|
||||||
def includeme(config):
|
|
||||||
defaults(config)
|
|
|
@ -1,29 +0,0 @@
|
||||||
# -*- coding: utf-8; -*-
|
|
||||||
################################################################################
|
|
||||||
#
|
|
||||||
# Rattail -- Retail Software Framework
|
|
||||||
# Copyright © 2010-2019 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 Web API - Batches
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import unicode_literals, absolute_import
|
|
||||||
|
|
||||||
from .core import APIBatchView, APIBatchRowView, BatchAPIMasterView
|
|
|
@ -1,360 +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/>.
|
|
||||||
#
|
|
||||||
################################################################################
|
|
||||||
"""
|
|
||||||
Tailbone Web API - Batch Views
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import warnings
|
|
||||||
|
|
||||||
from cornice import Service
|
|
||||||
|
|
||||||
from tailbone.api import APIMasterView
|
|
||||||
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class APIBatchMixin(object):
|
|
||||||
"""
|
|
||||||
Base class for all API views which are meant to handle "batch" *and/or*
|
|
||||||
"batch row" data.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def get_batch_class(self):
|
|
||||||
model_class = self.get_model_class()
|
|
||||||
if hasattr(model_class, '__batch_class__'):
|
|
||||||
return model_class.__batch_class__
|
|
||||||
return model_class
|
|
||||||
|
|
||||||
def get_handler(self):
|
|
||||||
"""
|
|
||||||
Returns a `BatchHandler` instance for the view. All (?) custom batch
|
|
||||||
API views should define a default handler class; however this may in all
|
|
||||||
(?) cases be overridden by config also. The specific setting required
|
|
||||||
to do so will depend on the 'key' for the type of batch involved, e.g.
|
|
||||||
assuming the 'vendor_catalog' batch:
|
|
||||||
|
|
||||||
.. code-block:: ini
|
|
||||||
|
|
||||||
[rattail.batch]
|
|
||||||
vendor_catalog.handler = myapp.batch.vendorcatalog:CustomCatalogHandler
|
|
||||||
|
|
||||||
Note that the 'key' for a batch is generally the same as its primary
|
|
||||||
table name, although technically it is whatever value returns from the
|
|
||||||
``batch_key`` attribute of the main batch model class.
|
|
||||||
"""
|
|
||||||
app = self.get_rattail_app()
|
|
||||||
key = self.get_batch_class().batch_key
|
|
||||||
return app.get_batch_handler(key, default=self.default_handler_spec)
|
|
||||||
|
|
||||||
|
|
||||||
class APIBatchView(APIBatchMixin, APIMasterView):
|
|
||||||
"""
|
|
||||||
Base class for all API views which are meant to handle "batch" *and/or*
|
|
||||||
"batch row" data.
|
|
||||||
"""
|
|
||||||
supports_toggle_complete = False
|
|
||||||
supports_execute = False
|
|
||||||
|
|
||||||
def __init__(self, request, **kwargs):
|
|
||||||
super(APIBatchView, self).__init__(request, **kwargs)
|
|
||||||
self.batch_handler = self.get_handler()
|
|
||||||
|
|
||||||
@property
|
|
||||||
def handler(self):
|
|
||||||
warnings.warn("the `handler` property is deprecated; "
|
|
||||||
"please use `batch_handler` instead",
|
|
||||||
DeprecationWarning, stacklevel=2)
|
|
||||||
return self.batch_handler
|
|
||||||
|
|
||||||
def normalize(self, batch):
|
|
||||||
app = self.get_rattail_app()
|
|
||||||
created = app.localtime(batch.created, from_utc=True)
|
|
||||||
|
|
||||||
executed = None
|
|
||||||
if batch.executed:
|
|
||||||
executed = app.localtime(batch.executed, from_utc=True)
|
|
||||||
|
|
||||||
return {
|
|
||||||
'uuid': batch.uuid,
|
|
||||||
'_str': str(batch),
|
|
||||||
'id': batch.id,
|
|
||||||
'id_str': batch.id_str,
|
|
||||||
'description': batch.description,
|
|
||||||
'notes': batch.notes,
|
|
||||||
'params': batch.params or {},
|
|
||||||
'rowcount': batch.rowcount,
|
|
||||||
'created': str(created),
|
|
||||||
'created_display': self.pretty_datetime(created),
|
|
||||||
'created_by_uuid': batch.created_by.uuid,
|
|
||||||
'created_by_display': str(batch.created_by),
|
|
||||||
'complete': batch.complete,
|
|
||||||
'status_code': batch.status_code,
|
|
||||||
'status_display': batch.STATUS.get(batch.status_code,
|
|
||||||
str(batch.status_code)),
|
|
||||||
'executed': str(executed) if executed else None,
|
|
||||||
'executed_display': self.pretty_datetime(executed) if executed else None,
|
|
||||||
'executed_by_uuid': batch.executed_by_uuid,
|
|
||||||
'executed_by_display': str(batch.executed_by or ''),
|
|
||||||
'mutable': self.batch_handler.is_mutable(batch),
|
|
||||||
}
|
|
||||||
|
|
||||||
def create_object(self, data):
|
|
||||||
"""
|
|
||||||
Create a new object instance and populate it with the given data.
|
|
||||||
|
|
||||||
Here we'll invoke the handler for actual batch creation, instead of
|
|
||||||
typical logic used for simple records.
|
|
||||||
"""
|
|
||||||
user = self.request.user
|
|
||||||
kwargs = dict(data)
|
|
||||||
kwargs['user'] = user
|
|
||||||
batch = self.batch_handler.make_batch(self.Session(), **kwargs)
|
|
||||||
if self.batch_handler.should_populate(batch):
|
|
||||||
self.batch_handler.do_populate(batch, user)
|
|
||||||
return batch
|
|
||||||
|
|
||||||
def update_object(self, batch, data):
|
|
||||||
"""
|
|
||||||
Logic for updating a main object record.
|
|
||||||
|
|
||||||
Here we want to make sure we set "created by" to the current user, when
|
|
||||||
creating a new batch.
|
|
||||||
"""
|
|
||||||
# we're only concerned with *new* batches here
|
|
||||||
if not batch.uuid:
|
|
||||||
|
|
||||||
# assign creator; initialize row count
|
|
||||||
batch.created_by_uuid = self.request.user.uuid
|
|
||||||
if batch.rowcount is None:
|
|
||||||
batch.rowcount = 0
|
|
||||||
|
|
||||||
# then go ahead with usual logic
|
|
||||||
return super(APIBatchView, self).update_object(batch, data)
|
|
||||||
|
|
||||||
def mark_complete(self):
|
|
||||||
"""
|
|
||||||
Mark the given batch as "complete".
|
|
||||||
"""
|
|
||||||
batch = self.get_object()
|
|
||||||
|
|
||||||
if batch.executed:
|
|
||||||
return {'error': "Batch {} has already been executed: {}".format(
|
|
||||||
batch.id_str, batch.description)}
|
|
||||||
|
|
||||||
if batch.complete:
|
|
||||||
return {'error': "Batch {} is already marked complete: {}".format(
|
|
||||||
batch.id_str, batch.description)}
|
|
||||||
|
|
||||||
batch.complete = True
|
|
||||||
return self._get(obj=batch)
|
|
||||||
|
|
||||||
def mark_incomplete(self):
|
|
||||||
"""
|
|
||||||
Mark the given batch as "incomplete".
|
|
||||||
"""
|
|
||||||
batch = self.get_object()
|
|
||||||
|
|
||||||
if batch.executed:
|
|
||||||
return {'error': "Batch {} has already been executed: {}".format(
|
|
||||||
batch.id_str, batch.description)}
|
|
||||||
|
|
||||||
if not batch.complete:
|
|
||||||
return {'error': "Batch {} is already marked incomplete: {}".format(
|
|
||||||
batch.id_str, batch.description)}
|
|
||||||
|
|
||||||
batch.complete = False
|
|
||||||
return self._get(obj=batch)
|
|
||||||
|
|
||||||
def execute(self):
|
|
||||||
"""
|
|
||||||
Execute the given batch.
|
|
||||||
"""
|
|
||||||
batch = self.get_object()
|
|
||||||
|
|
||||||
if batch.executed:
|
|
||||||
return {'error': "Batch {} has already been executed: {}".format(
|
|
||||||
batch.id_str, batch.description)}
|
|
||||||
|
|
||||||
kwargs = dict(self.request.json_body)
|
|
||||||
kwargs.pop('user', None)
|
|
||||||
kwargs.pop('progress', None)
|
|
||||||
result = self.batch_handler.do_execute(batch, self.request.user, **kwargs)
|
|
||||||
return {'ok': bool(result), 'batch': self.normalize(batch)}
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def defaults(cls, config):
|
|
||||||
cls._defaults(config)
|
|
||||||
cls._batch_defaults(config)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _batch_defaults(cls, config):
|
|
||||||
route_prefix = cls.get_route_prefix()
|
|
||||||
permission_prefix = cls.get_permission_prefix()
|
|
||||||
collection_url_prefix = cls.get_collection_url_prefix()
|
|
||||||
object_url_prefix = cls.get_object_url_prefix()
|
|
||||||
|
|
||||||
if cls.supports_toggle_complete:
|
|
||||||
|
|
||||||
# mark complete
|
|
||||||
mark_complete = Service(name='{}.mark_complete'.format(route_prefix),
|
|
||||||
path='{}/{{uuid}}/mark-complete'.format(object_url_prefix))
|
|
||||||
mark_complete.add_view('POST', 'mark_complete', klass=cls,
|
|
||||||
permission='{}.edit'.format(permission_prefix))
|
|
||||||
config.add_cornice_service(mark_complete)
|
|
||||||
|
|
||||||
# mark incomplete
|
|
||||||
mark_incomplete = Service(name='{}.mark_incomplete'.format(route_prefix),
|
|
||||||
path='{}/{{uuid}}/mark-incomplete'.format(object_url_prefix))
|
|
||||||
mark_incomplete.add_view('POST', 'mark_incomplete', klass=cls,
|
|
||||||
permission='{}.edit'.format(permission_prefix))
|
|
||||||
config.add_cornice_service(mark_incomplete)
|
|
||||||
|
|
||||||
if cls.supports_execute:
|
|
||||||
|
|
||||||
# execute batch
|
|
||||||
execute = Service(name='{}.execute'.format(route_prefix),
|
|
||||||
path='{}/{{uuid}}/execute'.format(object_url_prefix))
|
|
||||||
execute.add_view('POST', 'execute', klass=cls,
|
|
||||||
permission='{}.execute'.format(permission_prefix))
|
|
||||||
config.add_cornice_service(execute)
|
|
||||||
|
|
||||||
|
|
||||||
# TODO: deprecate / remove this
|
|
||||||
BatchAPIMasterView = APIBatchView
|
|
||||||
|
|
||||||
|
|
||||||
class APIBatchRowView(APIBatchMixin, APIMasterView):
|
|
||||||
"""
|
|
||||||
Base class for all API views which are meant to handle "batch rows" data.
|
|
||||||
"""
|
|
||||||
editable = False
|
|
||||||
supports_quick_entry = False
|
|
||||||
|
|
||||||
def __init__(self, request, **kwargs):
|
|
||||||
super(APIBatchRowView, self).__init__(request, **kwargs)
|
|
||||||
self.batch_handler = self.get_handler()
|
|
||||||
|
|
||||||
@property
|
|
||||||
def handler(self):
|
|
||||||
warnings.warn("the `handler` property is deprecated; "
|
|
||||||
"please use `batch_handler` instead",
|
|
||||||
DeprecationWarning, stacklevel=2)
|
|
||||||
return self.batch_handler
|
|
||||||
|
|
||||||
def normalize(self, row):
|
|
||||||
batch = row.batch
|
|
||||||
return {
|
|
||||||
'uuid': row.uuid,
|
|
||||||
'_str': str(row),
|
|
||||||
'_parent_str': str(batch),
|
|
||||||
'_parent_uuid': batch.uuid,
|
|
||||||
'batch_uuid': batch.uuid,
|
|
||||||
'batch_id': batch.id,
|
|
||||||
'batch_id_str': batch.id_str,
|
|
||||||
'batch_description': batch.description,
|
|
||||||
'batch_complete': batch.complete,
|
|
||||||
'batch_executed': bool(batch.executed),
|
|
||||||
'batch_mutable': self.batch_handler.is_mutable(batch),
|
|
||||||
'sequence': row.sequence,
|
|
||||||
'status_code': row.status_code,
|
|
||||||
'status_display': row.STATUS.get(row.status_code, str(row.status_code)),
|
|
||||||
}
|
|
||||||
|
|
||||||
def update_object(self, row, data):
|
|
||||||
"""
|
|
||||||
Supplements the default logic as follows:
|
|
||||||
|
|
||||||
Invokes the batch handler's ``refresh_row()`` method after updating the
|
|
||||||
row's field data per usual.
|
|
||||||
"""
|
|
||||||
if not self.batch_handler.is_mutable(row.batch):
|
|
||||||
return {'error': "Batch is not mutable"}
|
|
||||||
|
|
||||||
# update row per usual
|
|
||||||
row = super(APIBatchRowView, self).update_object(row, data)
|
|
||||||
|
|
||||||
# okay now we apply handler refresh logic
|
|
||||||
self.batch_handler.refresh_row(row)
|
|
||||||
return row
|
|
||||||
|
|
||||||
def delete_object(self, row):
|
|
||||||
"""
|
|
||||||
Overrides the default logic as follows:
|
|
||||||
|
|
||||||
Delegates deletion of the row to the batch handler.
|
|
||||||
"""
|
|
||||||
self.batch_handler.do_remove_row(row)
|
|
||||||
|
|
||||||
def quick_entry(self):
|
|
||||||
"""
|
|
||||||
View for handling "quick entry" user input, for a batch.
|
|
||||||
"""
|
|
||||||
data = self.request.json_body
|
|
||||||
|
|
||||||
uuid = data['batch_uuid']
|
|
||||||
batch = self.Session.get(self.get_batch_class(), uuid)
|
|
||||||
if not batch:
|
|
||||||
raise self.notfound()
|
|
||||||
|
|
||||||
entry = data['quick_entry']
|
|
||||||
|
|
||||||
try:
|
|
||||||
row = self.batch_handler.quick_entry(self.Session(), batch, entry)
|
|
||||||
except Exception as error:
|
|
||||||
log.warning("quick entry failed for '%s' batch %s: %s",
|
|
||||||
self.batch_handler.batch_key, batch.id_str, entry,
|
|
||||||
exc_info=True)
|
|
||||||
msg = str(error)
|
|
||||||
if not msg and isinstance(error, NotImplementedError):
|
|
||||||
msg = "Feature is not implemented"
|
|
||||||
return {'error': msg}
|
|
||||||
|
|
||||||
if not row:
|
|
||||||
return {'error': "Could not identify product"}
|
|
||||||
|
|
||||||
self.Session.flush()
|
|
||||||
result = self._get(obj=row)
|
|
||||||
result['ok'] = True
|
|
||||||
return result
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def defaults(cls, config):
|
|
||||||
cls._defaults(config)
|
|
||||||
cls._batch_row_defaults(config)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _batch_row_defaults(cls, config):
|
|
||||||
route_prefix = cls.get_route_prefix()
|
|
||||||
permission_prefix = cls.get_permission_prefix()
|
|
||||||
collection_url_prefix = cls.get_collection_url_prefix()
|
|
||||||
|
|
||||||
if cls.supports_quick_entry:
|
|
||||||
|
|
||||||
# quick entry
|
|
||||||
quick_entry = Service(name='{}.quick_entry'.format(route_prefix),
|
|
||||||
path='{}/quick-entry'.format(collection_url_prefix))
|
|
||||||
quick_entry.add_view('POST', 'quick_entry', klass=cls,
|
|
||||||
permission='{}.edit'.format(permission_prefix))
|
|
||||||
config.add_cornice_service(quick_entry)
|
|
|
@ -1,200 +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/>.
|
|
||||||
#
|
|
||||||
################################################################################
|
|
||||||
"""
|
|
||||||
Tailbone Web API - Inventory Batches
|
|
||||||
"""
|
|
||||||
|
|
||||||
import decimal
|
|
||||||
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
from rattail import pod
|
|
||||||
from rattail.db.model import InventoryBatch, InventoryBatchRow
|
|
||||||
|
|
||||||
from cornice import Service
|
|
||||||
|
|
||||||
from tailbone.api.batch import APIBatchView, APIBatchRowView
|
|
||||||
|
|
||||||
|
|
||||||
class InventoryBatchViews(APIBatchView):
|
|
||||||
|
|
||||||
model_class = InventoryBatch
|
|
||||||
default_handler_spec = 'rattail.batch.inventory:InventoryBatchHandler'
|
|
||||||
route_prefix = 'inventory'
|
|
||||||
permission_prefix = 'batch.inventory'
|
|
||||||
collection_url_prefix = '/inventory-batches'
|
|
||||||
object_url_prefix = '/inventory-batch'
|
|
||||||
supports_toggle_complete = True
|
|
||||||
|
|
||||||
def normalize(self, batch):
|
|
||||||
data = super().normalize(batch)
|
|
||||||
|
|
||||||
data['mode'] = batch.mode
|
|
||||||
data['mode_display'] = self.enum.INVENTORY_MODE.get(batch.mode)
|
|
||||||
if data['mode_display'] is None and batch.mode is not None:
|
|
||||||
data['mode_display'] = str(batch.mode)
|
|
||||||
|
|
||||||
data['reason_code'] = batch.reason_code
|
|
||||||
|
|
||||||
return data
|
|
||||||
|
|
||||||
def count_modes(self):
|
|
||||||
"""
|
|
||||||
Retrieve info about the available batch count modes.
|
|
||||||
"""
|
|
||||||
permission_prefix = self.get_permission_prefix()
|
|
||||||
if self.request.is_root:
|
|
||||||
modes = self.batch_handler.get_count_modes()
|
|
||||||
else:
|
|
||||||
modes = self.batch_handler.get_allowed_count_modes(
|
|
||||||
self.Session(), self.request.user,
|
|
||||||
permission_prefix=permission_prefix)
|
|
||||||
return modes
|
|
||||||
|
|
||||||
def adjustment_reasons(self):
|
|
||||||
"""
|
|
||||||
Retrieve info about the available "reasons" for inventory adjustment
|
|
||||||
batches.
|
|
||||||
"""
|
|
||||||
raw_reasons = self.batch_handler.get_adjustment_reasons(self.Session())
|
|
||||||
reasons = []
|
|
||||||
for reason in raw_reasons:
|
|
||||||
reasons.append({
|
|
||||||
'uuid': reason.uuid,
|
|
||||||
'code': reason.code,
|
|
||||||
'description': reason.description,
|
|
||||||
'hidden': reason.hidden,
|
|
||||||
})
|
|
||||||
return reasons
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def defaults(cls, config):
|
|
||||||
cls._defaults(config)
|
|
||||||
cls._batch_defaults(config)
|
|
||||||
cls._inventory_defaults(config)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _inventory_defaults(cls, config):
|
|
||||||
route_prefix = cls.get_route_prefix()
|
|
||||||
permission_prefix = cls.get_permission_prefix()
|
|
||||||
collection_url_prefix = cls.get_collection_url_prefix()
|
|
||||||
|
|
||||||
# get count modes
|
|
||||||
count_modes = Service(name='{}.count_modes'.format(route_prefix),
|
|
||||||
path='{}/count-modes'.format(collection_url_prefix))
|
|
||||||
count_modes.add_view('GET', 'count_modes', klass=cls,
|
|
||||||
permission='{}.list'.format(permission_prefix))
|
|
||||||
config.add_cornice_service(count_modes)
|
|
||||||
|
|
||||||
# get adjustment reasons
|
|
||||||
adjustment_reasons = Service(name='{}.adjustment_reasons'.format(route_prefix),
|
|
||||||
path='{}/adjustment-reasons'.format(collection_url_prefix))
|
|
||||||
adjustment_reasons.add_view('GET', 'adjustment_reasons', klass=cls,
|
|
||||||
permission='{}.list'.format(permission_prefix))
|
|
||||||
config.add_cornice_service(adjustment_reasons)
|
|
||||||
|
|
||||||
|
|
||||||
class InventoryBatchRowViews(APIBatchRowView):
|
|
||||||
|
|
||||||
model_class = InventoryBatchRow
|
|
||||||
default_handler_spec = 'rattail.batch.inventory:InventoryBatchHandler'
|
|
||||||
route_prefix = 'inventory.rows'
|
|
||||||
permission_prefix = 'batch.inventory'
|
|
||||||
collection_url_prefix = '/inventory-batch-rows'
|
|
||||||
object_url_prefix = '/inventory-batch-row'
|
|
||||||
editable = True
|
|
||||||
supports_quick_entry = True
|
|
||||||
|
|
||||||
def normalize(self, row):
|
|
||||||
batch = row.batch
|
|
||||||
data = super().normalize(row)
|
|
||||||
app = self.get_rattail_app()
|
|
||||||
|
|
||||||
data['item_id'] = row.item_id
|
|
||||||
data['upc'] = str(row.upc)
|
|
||||||
data['upc_pretty'] = row.upc.pretty() if row.upc else None
|
|
||||||
data['brand_name'] = row.brand_name
|
|
||||||
data['description'] = row.description
|
|
||||||
data['size'] = row.size
|
|
||||||
data['full_description'] = row.product.full_description if row.product else row.description
|
|
||||||
data['image_url'] = pod.get_image_url(self.rattail_config, row.upc) if row.upc else None
|
|
||||||
data['case_quantity'] = app.render_quantity(row.case_quantity or 1)
|
|
||||||
|
|
||||||
data['cases'] = row.cases
|
|
||||||
data['units'] = row.units
|
|
||||||
data['unit_uom'] = 'LB' if row.product and row.product.weighed else 'EA'
|
|
||||||
data['quantity_display'] = "{} {}".format(
|
|
||||||
app.render_quantity(row.cases or row.units),
|
|
||||||
'CS' if row.cases else data['unit_uom'])
|
|
||||||
|
|
||||||
data['allow_cases'] = self.batch_handler.allow_cases(batch)
|
|
||||||
|
|
||||||
return data
|
|
||||||
|
|
||||||
def update_object(self, row, data):
|
|
||||||
"""
|
|
||||||
Supplements the default logic as follows:
|
|
||||||
|
|
||||||
Converts certain fields within the data, to proper "native" types.
|
|
||||||
"""
|
|
||||||
data = dict(data)
|
|
||||||
|
|
||||||
# convert some data types as needed
|
|
||||||
if 'cases' in data:
|
|
||||||
if data['cases'] == '':
|
|
||||||
data['cases'] = None
|
|
||||||
elif data['cases']:
|
|
||||||
data['cases'] = decimal.Decimal(data['cases'])
|
|
||||||
if 'units' in data:
|
|
||||||
if data['units'] == '':
|
|
||||||
data['units'] = None
|
|
||||||
elif data['units']:
|
|
||||||
data['units'] = decimal.Decimal(data['units'])
|
|
||||||
|
|
||||||
# update row per usual
|
|
||||||
try:
|
|
||||||
row = super().update_object(row, data)
|
|
||||||
except sa.exc.DataError as error:
|
|
||||||
# detect when user scans barcode for cases/units field
|
|
||||||
if hasattr(error, 'orig'):
|
|
||||||
orig = type(error.orig)
|
|
||||||
if hasattr(orig, '__name__'):
|
|
||||||
# nb. this particular error is from psycopg2
|
|
||||||
if orig.__name__ == 'NumericValueOutOfRange':
|
|
||||||
return {'error': "Numeric value out of range"}
|
|
||||||
raise
|
|
||||||
return row
|
|
||||||
|
|
||||||
|
|
||||||
def defaults(config, **kwargs):
|
|
||||||
base = globals()
|
|
||||||
|
|
||||||
InventoryBatchViews = kwargs.get('InventoryBatchViews', base['InventoryBatchViews'])
|
|
||||||
InventoryBatchViews.defaults(config)
|
|
||||||
|
|
||||||
InventoryBatchRowViews = kwargs.get('InventoryBatchRowViews', base['InventoryBatchRowViews'])
|
|
||||||
InventoryBatchRowViews.defaults(config)
|
|
||||||
|
|
||||||
|
|
||||||
def includeme(config):
|
|
||||||
defaults(config)
|
|
|
@ -1,78 +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 Web API - Label Batches
|
|
||||||
"""
|
|
||||||
|
|
||||||
from rattail.db import model
|
|
||||||
|
|
||||||
from tailbone.api.batch import APIBatchView, APIBatchRowView
|
|
||||||
|
|
||||||
|
|
||||||
class LabelBatchViews(APIBatchView):
|
|
||||||
|
|
||||||
model_class = model.LabelBatch
|
|
||||||
default_handler_spec = 'rattail.batch.labels:LabelBatchHandler'
|
|
||||||
route_prefix = 'labelbatchviews'
|
|
||||||
permission_prefix = 'labels.batch'
|
|
||||||
collection_url_prefix = '/label-batches'
|
|
||||||
object_url_prefix = '/label-batch'
|
|
||||||
supports_toggle_complete = True
|
|
||||||
|
|
||||||
|
|
||||||
class LabelBatchRowViews(APIBatchRowView):
|
|
||||||
|
|
||||||
model_class = model.LabelBatchRow
|
|
||||||
default_handler_spec = 'rattail.batch.labels:LabelBatchHandler'
|
|
||||||
route_prefix = 'api.label_batch_rows'
|
|
||||||
permission_prefix = 'labels.batch'
|
|
||||||
collection_url_prefix = '/label-batch-rows'
|
|
||||||
object_url_prefix = '/label-batch-row'
|
|
||||||
supports_quick_entry = True
|
|
||||||
|
|
||||||
def normalize(self, row):
|
|
||||||
batch = row.batch
|
|
||||||
data = super().normalize(row)
|
|
||||||
|
|
||||||
data['item_id'] = row.item_id
|
|
||||||
data['upc'] = str(row.upc)
|
|
||||||
data['upc_pretty'] = row.upc.pretty() if row.upc else None
|
|
||||||
data['brand_name'] = row.brand_name
|
|
||||||
data['description'] = row.description
|
|
||||||
data['size'] = row.size
|
|
||||||
data['full_description'] = row.product.full_description if row.product else row.description
|
|
||||||
return data
|
|
||||||
|
|
||||||
|
|
||||||
def defaults(config, **kwargs):
|
|
||||||
base = globals()
|
|
||||||
|
|
||||||
LabelBatchViews = kwargs.get('LabelBatchViews', base['LabelBatchViews'])
|
|
||||||
LabelBatchViews.defaults(config)
|
|
||||||
|
|
||||||
LabelBatchRowViews = kwargs.get('LabelBatchRowViews', base['LabelBatchRowViews'])
|
|
||||||
LabelBatchRowViews.defaults(config)
|
|
||||||
|
|
||||||
|
|
||||||
def includeme(config):
|
|
||||||
defaults(config)
|
|
|
@ -1,318 +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 Web API - Ordering Batches
|
|
||||||
|
|
||||||
These views expose the basic CRUD interface to "ordering" batches, for the web
|
|
||||||
API.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import datetime
|
|
||||||
import logging
|
|
||||||
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
from rattail.db.model import PurchaseBatch, PurchaseBatchRow
|
|
||||||
|
|
||||||
from cornice import Service
|
|
||||||
|
|
||||||
from tailbone.api.batch import APIBatchView, APIBatchRowView
|
|
||||||
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class OrderingBatchViews(APIBatchView):
|
|
||||||
|
|
||||||
model_class = PurchaseBatch
|
|
||||||
default_handler_spec = 'rattail.batch.purchase:PurchaseBatchHandler'
|
|
||||||
route_prefix = 'orderingbatchviews'
|
|
||||||
permission_prefix = 'ordering'
|
|
||||||
collection_url_prefix = '/ordering-batches'
|
|
||||||
object_url_prefix = '/ordering-batch'
|
|
||||||
supports_toggle_complete = True
|
|
||||||
supports_execute = True
|
|
||||||
|
|
||||||
def base_query(self):
|
|
||||||
"""
|
|
||||||
Modifies the default logic as follows:
|
|
||||||
|
|
||||||
Adds a condition to the query, to ensure only purchase batches with
|
|
||||||
"ordering" mode are returned.
|
|
||||||
"""
|
|
||||||
model = self.model
|
|
||||||
query = super().base_query()
|
|
||||||
query = query.filter(model.PurchaseBatch.mode == self.enum.PURCHASE_BATCH_MODE_ORDERING)
|
|
||||||
return query
|
|
||||||
|
|
||||||
def normalize(self, batch):
|
|
||||||
data = super().normalize(batch)
|
|
||||||
|
|
||||||
data['vendor_uuid'] = batch.vendor.uuid
|
|
||||||
data['vendor_display'] = str(batch.vendor)
|
|
||||||
|
|
||||||
data['department_uuid'] = batch.department_uuid
|
|
||||||
data['department_display'] = str(batch.department) if batch.department else None
|
|
||||||
|
|
||||||
data['po_total_calculated_display'] = "${:0.2f}".format(batch.po_total_calculated or 0)
|
|
||||||
data['ship_method'] = batch.ship_method
|
|
||||||
data['notes_to_vendor'] = batch.notes_to_vendor
|
|
||||||
return data
|
|
||||||
|
|
||||||
def create_object(self, data):
|
|
||||||
"""
|
|
||||||
Modifies the default logic as follows:
|
|
||||||
|
|
||||||
Sets the mode to "ordering" for the new batch.
|
|
||||||
"""
|
|
||||||
data = dict(data)
|
|
||||||
if not data.get('vendor_uuid'):
|
|
||||||
raise ValueError("You must specify the vendor")
|
|
||||||
data['mode'] = self.enum.PURCHASE_BATCH_MODE_ORDERING
|
|
||||||
batch = super().create_object(data)
|
|
||||||
return batch
|
|
||||||
|
|
||||||
def worksheet(self):
|
|
||||||
"""
|
|
||||||
Returns primary data for the Ordering Worksheet view.
|
|
||||||
"""
|
|
||||||
batch = self.get_object()
|
|
||||||
if batch.executed:
|
|
||||||
raise self.forbidden()
|
|
||||||
|
|
||||||
app = self.get_rattail_app()
|
|
||||||
|
|
||||||
# TODO: much of the logic below was copied from the traditional master
|
|
||||||
# view for ordering batches. should maybe let them share it somehow?
|
|
||||||
|
|
||||||
# organize existing batch rows by product
|
|
||||||
order_items = {}
|
|
||||||
for row in batch.active_rows():
|
|
||||||
order_items[row.product_uuid] = row
|
|
||||||
|
|
||||||
# organize vendor catalog costs by dept / subdept
|
|
||||||
departments = {}
|
|
||||||
costs = self.batch_handler.get_order_form_costs(self.Session(), batch.vendor)
|
|
||||||
costs = self.batch_handler.sort_order_form_costs(costs)
|
|
||||||
costs = list(costs) # we must have a stable list for the rest of this
|
|
||||||
self.batch_handler.decorate_order_form_costs(batch, costs)
|
|
||||||
for cost in costs:
|
|
||||||
|
|
||||||
department = cost.product.department
|
|
||||||
if department:
|
|
||||||
department_dict = departments.setdefault(department.uuid, {
|
|
||||||
'uuid': department.uuid,
|
|
||||||
'number': department.number,
|
|
||||||
'name': department.name,
|
|
||||||
})
|
|
||||||
else:
|
|
||||||
if None not in departments:
|
|
||||||
departments[None] = {
|
|
||||||
'uuid': None,
|
|
||||||
'number': None,
|
|
||||||
'name': "",
|
|
||||||
}
|
|
||||||
department_dict = departments[None]
|
|
||||||
|
|
||||||
subdepartments = department_dict.setdefault('subdepartments', {})
|
|
||||||
|
|
||||||
subdepartment = cost.product.subdepartment
|
|
||||||
if subdepartment:
|
|
||||||
subdepartment_dict = subdepartments.setdefault(subdepartment.uuid, {
|
|
||||||
'uuid': subdepartment.uuid,
|
|
||||||
'number': subdepartment.number,
|
|
||||||
'name': subdepartment.name,
|
|
||||||
})
|
|
||||||
else:
|
|
||||||
if None not in subdepartments:
|
|
||||||
subdepartments[None] = {
|
|
||||||
'uuid': None,
|
|
||||||
'number': None,
|
|
||||||
'name': "",
|
|
||||||
}
|
|
||||||
subdepartment_dict = subdepartments[None]
|
|
||||||
|
|
||||||
subdept_costs = subdepartment_dict.setdefault('costs', [])
|
|
||||||
product = cost.product
|
|
||||||
subdept_costs.append({
|
|
||||||
'uuid': cost.uuid,
|
|
||||||
'upc': str(product.upc),
|
|
||||||
'upc_pretty': product.upc.pretty() if product.upc else None,
|
|
||||||
'brand_name': product.brand.name if product.brand else None,
|
|
||||||
'description': product.description,
|
|
||||||
'size': product.size,
|
|
||||||
'case_size': cost.case_size,
|
|
||||||
'uom_display': "LB" if product.weighed else "EA",
|
|
||||||
'vendor_item_code': cost.code,
|
|
||||||
'preference': cost.preference,
|
|
||||||
'preferred': cost.preference == 1,
|
|
||||||
'unit_cost': cost.unit_cost,
|
|
||||||
'unit_cost_display': "${:0.2f}".format(cost.unit_cost) if cost.unit_cost is not None else "",
|
|
||||||
# TODO
|
|
||||||
# 'cases_ordered': None,
|
|
||||||
# 'units_ordered': None,
|
|
||||||
# 'po_total': None,
|
|
||||||
# 'po_total_display': None,
|
|
||||||
})
|
|
||||||
|
|
||||||
# sort the (sub)department groupings
|
|
||||||
sorted_departments = []
|
|
||||||
for dept in sorted(departments.values(), key=lambda d: d['name']):
|
|
||||||
dept['subdepartments'] = sorted(dept['subdepartments'].values(),
|
|
||||||
key=lambda s: s['name'])
|
|
||||||
sorted_departments.append(dept)
|
|
||||||
|
|
||||||
# fetch recent purchase history, sort/pad for template convenience
|
|
||||||
history = self.batch_handler.get_order_form_history(batch, costs, 6)
|
|
||||||
for i in range(6 - len(history)):
|
|
||||||
history.append(None)
|
|
||||||
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 {
|
|
||||||
'batch': self.normalize(batch),
|
|
||||||
'departments': departments,
|
|
||||||
'sorted_departments': sorted_departments,
|
|
||||||
'history': history,
|
|
||||||
}
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def defaults(cls, config):
|
|
||||||
cls._defaults(config)
|
|
||||||
cls._batch_defaults(config)
|
|
||||||
cls._ordering_batch_defaults(config)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _ordering_batch_defaults(cls, config):
|
|
||||||
route_prefix = cls.get_route_prefix()
|
|
||||||
permission_prefix = cls.get_permission_prefix()
|
|
||||||
object_url_prefix = cls.get_object_url_prefix()
|
|
||||||
|
|
||||||
# worksheet
|
|
||||||
worksheet = Service(name='{}.worksheet'.format(route_prefix),
|
|
||||||
path='{}/{{uuid}}/worksheet'.format(object_url_prefix))
|
|
||||||
worksheet.add_view('GET', 'worksheet', klass=cls,
|
|
||||||
permission='{}.worksheet'.format(permission_prefix))
|
|
||||||
config.add_cornice_service(worksheet)
|
|
||||||
|
|
||||||
|
|
||||||
class OrderingBatchRowViews(APIBatchRowView):
|
|
||||||
|
|
||||||
model_class = PurchaseBatchRow
|
|
||||||
default_handler_spec = 'rattail.batch.purchase:PurchaseBatchHandler'
|
|
||||||
route_prefix = 'ordering.rows'
|
|
||||||
permission_prefix = 'ordering'
|
|
||||||
collection_url_prefix = '/ordering-batch-rows'
|
|
||||||
object_url_prefix = '/ordering-batch-row'
|
|
||||||
supports_quick_entry = True
|
|
||||||
editable = True
|
|
||||||
|
|
||||||
def normalize(self, row):
|
|
||||||
data = super().normalize(row)
|
|
||||||
app = self.get_rattail_app()
|
|
||||||
batch = row.batch
|
|
||||||
|
|
||||||
data['item_id'] = row.item_id
|
|
||||||
data['upc'] = str(row.upc)
|
|
||||||
data['upc_pretty'] = row.upc.pretty() if row.upc else None
|
|
||||||
data['brand_name'] = row.brand_name
|
|
||||||
data['description'] = row.description
|
|
||||||
data['size'] = row.size
|
|
||||||
data['full_description'] = row.product.full_description if row.product else row.description
|
|
||||||
|
|
||||||
# # only provide image url if so configured
|
|
||||||
# if self.rattail_config.getbool('rattail.batch', 'purchase.mobile_images', default=True):
|
|
||||||
# data['image_url'] = pod.get_image_url(self.rattail_config, row.upc) if row.upc else None
|
|
||||||
|
|
||||||
# unit_uom can vary by product
|
|
||||||
data['unit_uom'] = 'LB' if row.product and row.product.weighed else 'EA'
|
|
||||||
|
|
||||||
data['case_quantity'] = row.case_quantity
|
|
||||||
data['cases_ordered'] = row.cases_ordered
|
|
||||||
data['units_ordered'] = row.units_ordered
|
|
||||||
data['cases_ordered_display'] = app.render_quantity(row.cases_ordered or 0, empty_zero=False)
|
|
||||||
data['units_ordered_display'] = app.render_quantity(row.units_ordered or 0, empty_zero=False)
|
|
||||||
|
|
||||||
data['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_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['status_code'] = row.status_code
|
|
||||||
data['status_display'] = row.STATUS.get(row.status_code, str(row.status_code))
|
|
||||||
|
|
||||||
return data
|
|
||||||
|
|
||||||
def update_object(self, row, data):
|
|
||||||
"""
|
|
||||||
Overrides the default logic as follows:
|
|
||||||
|
|
||||||
So far, we only allow updating the ``cases_ordered`` and/or
|
|
||||||
``units_ordered`` quantities; therefore ``data`` should have one or
|
|
||||||
both of those keys.
|
|
||||||
|
|
||||||
This data is then passed to the
|
|
||||||
:meth:`~rattail:rattail.batch.purchase.PurchaseBatchHandler.update_row_quantity()`
|
|
||||||
method of the batch handler.
|
|
||||||
|
|
||||||
Note that the "normal" logic for this method is not invoked at all.
|
|
||||||
"""
|
|
||||||
if not self.batch_handler.is_mutable(row.batch):
|
|
||||||
return {'error': "Batch is not mutable"}
|
|
||||||
|
|
||||||
try:
|
|
||||||
self.batch_handler.update_row_quantity(row, **data)
|
|
||||||
self.Session.flush()
|
|
||||||
except Exception as error:
|
|
||||||
log.warning("update_row_quantity failed", exc_info=True)
|
|
||||||
if isinstance(error, sa.exc.DataError) and hasattr(error, 'orig'):
|
|
||||||
error = str(error.orig)
|
|
||||||
else:
|
|
||||||
error = str(error)
|
|
||||||
return {'error': error}
|
|
||||||
|
|
||||||
return row
|
|
||||||
|
|
||||||
|
|
||||||
def defaults(config, **kwargs):
|
|
||||||
base = globals()
|
|
||||||
|
|
||||||
OrderingBatchViews = kwargs.get('OrderingBatchViews', base['OrderingBatchViews'])
|
|
||||||
OrderingBatchViews.defaults(config)
|
|
||||||
|
|
||||||
OrderingBatchRowViews = kwargs.get('OrderingBatchRowViews', base['OrderingBatchRowViews'])
|
|
||||||
OrderingBatchRowViews.defaults(config)
|
|
||||||
|
|
||||||
|
|
||||||
def includeme(config):
|
|
||||||
defaults(config)
|
|
|
@ -1,492 +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 Web API - Receiving Batches
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
|
|
||||||
import humanize
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
from rattail.db.model import PurchaseBatch, PurchaseBatchRow
|
|
||||||
|
|
||||||
from cornice import Service
|
|
||||||
from deform import widget as dfwidget
|
|
||||||
|
|
||||||
from tailbone import forms
|
|
||||||
from tailbone.api.batch import APIBatchView, APIBatchRowView
|
|
||||||
from tailbone.forms.receiving import ReceiveRow
|
|
||||||
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class ReceivingBatchViews(APIBatchView):
|
|
||||||
|
|
||||||
model_class = PurchaseBatch
|
|
||||||
default_handler_spec = 'rattail.batch.purchase:PurchaseBatchHandler'
|
|
||||||
route_prefix = 'receivingbatchviews'
|
|
||||||
permission_prefix = 'receiving'
|
|
||||||
collection_url_prefix = '/receiving-batches'
|
|
||||||
object_url_prefix = '/receiving-batch'
|
|
||||||
supports_toggle_complete = True
|
|
||||||
supports_execute = True
|
|
||||||
|
|
||||||
def base_query(self):
|
|
||||||
model = self.app.model
|
|
||||||
query = super().base_query()
|
|
||||||
query = query.filter(model.PurchaseBatch.mode == self.enum.PURCHASE_BATCH_MODE_RECEIVING)
|
|
||||||
return query
|
|
||||||
|
|
||||||
def normalize(self, batch):
|
|
||||||
data = super().normalize(batch)
|
|
||||||
|
|
||||||
data['vendor_uuid'] = batch.vendor.uuid
|
|
||||||
data['vendor_display'] = str(batch.vendor)
|
|
||||||
|
|
||||||
data['department_uuid'] = batch.department_uuid
|
|
||||||
data['department_display'] = str(batch.department) if batch.department else None
|
|
||||||
|
|
||||||
data['po_number'] = batch.po_number
|
|
||||||
data['po_total'] = batch.po_total
|
|
||||||
data['invoice_total'] = batch.invoice_total
|
|
||||||
data['invoice_total_calculated'] = batch.invoice_total_calculated
|
|
||||||
|
|
||||||
data['can_auto_receive'] = self.batch_handler.can_auto_receive(batch)
|
|
||||||
|
|
||||||
return data
|
|
||||||
|
|
||||||
def create_object(self, data):
|
|
||||||
data = dict(data)
|
|
||||||
|
|
||||||
# all about receiving mode here
|
|
||||||
data['mode'] = self.enum.PURCHASE_BATCH_MODE_RECEIVING
|
|
||||||
|
|
||||||
# assume "receive from PO" if given a PO key
|
|
||||||
if data.get('purchase_key'):
|
|
||||||
data['workflow'] = 'from_po'
|
|
||||||
|
|
||||||
return super().create_object(data)
|
|
||||||
|
|
||||||
def auto_receive(self):
|
|
||||||
"""
|
|
||||||
View which handles auto-marking as received, all items within
|
|
||||||
a pending batch.
|
|
||||||
"""
|
|
||||||
batch = self.get_object()
|
|
||||||
self.batch_handler.auto_receive_all_items(batch)
|
|
||||||
return self._get(obj=batch)
|
|
||||||
|
|
||||||
def mark_receiving_complete(self):
|
|
||||||
"""
|
|
||||||
Mark the given batch as "receiving complete".
|
|
||||||
"""
|
|
||||||
batch = self.get_object()
|
|
||||||
|
|
||||||
if batch.executed:
|
|
||||||
return {'error': "Batch {} has already been executed: {}".format(
|
|
||||||
batch.id_str, batch.description)}
|
|
||||||
|
|
||||||
if batch.complete:
|
|
||||||
return {'error': "Batch {} is already marked complete: {}".format(
|
|
||||||
batch.id_str, batch.description)}
|
|
||||||
|
|
||||||
if batch.receiving_complete:
|
|
||||||
return {'error': "Receiving is already complete for batch {}: {}".format(
|
|
||||||
batch.id_str, batch.description)}
|
|
||||||
|
|
||||||
batch.receiving_complete = True
|
|
||||||
return self._get(obj=batch)
|
|
||||||
|
|
||||||
def eligible_purchases(self):
|
|
||||||
model = self.app.model
|
|
||||||
uuid = self.request.params.get('vendor_uuid')
|
|
||||||
vendor = self.Session.get(model.Vendor, uuid) if uuid else None
|
|
||||||
if not vendor:
|
|
||||||
return {'error': "Vendor not found"}
|
|
||||||
|
|
||||||
purchases = self.batch_handler.get_eligible_purchases(
|
|
||||||
vendor, self.enum.PURCHASE_BATCH_MODE_RECEIVING)
|
|
||||||
|
|
||||||
purchases = [self.normalize_eligible_purchase(p)
|
|
||||||
for p in purchases]
|
|
||||||
|
|
||||||
return {'purchases': purchases}
|
|
||||||
|
|
||||||
def normalize_eligible_purchase(self, purchase):
|
|
||||||
return self.batch_handler.normalize_eligible_purchase(purchase)
|
|
||||||
|
|
||||||
def render_eligible_purchase(self, purchase):
|
|
||||||
return self.batch_handler.render_eligible_purchase(purchase)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def defaults(cls, config):
|
|
||||||
cls._defaults(config)
|
|
||||||
cls._batch_defaults(config)
|
|
||||||
cls._receiving_batch_defaults(config)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _receiving_batch_defaults(cls, config):
|
|
||||||
route_prefix = cls.get_route_prefix()
|
|
||||||
permission_prefix = cls.get_permission_prefix()
|
|
||||||
collection_url_prefix = cls.get_collection_url_prefix()
|
|
||||||
object_url_prefix = cls.get_object_url_prefix()
|
|
||||||
|
|
||||||
# auto_receive
|
|
||||||
auto_receive = Service(name='{}.auto_receive'.format(route_prefix),
|
|
||||||
path='{}/{{uuid}}/auto-receive'.format(object_url_prefix))
|
|
||||||
auto_receive.add_view('GET', 'auto_receive', klass=cls,
|
|
||||||
permission='{}.auto_receive'.format(permission_prefix))
|
|
||||||
config.add_cornice_service(auto_receive)
|
|
||||||
|
|
||||||
# mark_receiving_complete
|
|
||||||
mark_receiving_complete = Service(name='{}.mark_receiving_complete'.format(route_prefix),
|
|
||||||
path='{}/{{uuid}}/mark-receiving-complete'.format(object_url_prefix))
|
|
||||||
mark_receiving_complete.add_view('POST', 'mark_receiving_complete', klass=cls,
|
|
||||||
permission='{}.edit'.format(permission_prefix))
|
|
||||||
config.add_cornice_service(mark_receiving_complete)
|
|
||||||
|
|
||||||
# eligible purchases
|
|
||||||
eligible_purchases = Service(name='{}.eligible_purchases'.format(route_prefix),
|
|
||||||
path='{}/eligible-purchases'.format(collection_url_prefix))
|
|
||||||
eligible_purchases.add_view('GET', 'eligible_purchases', klass=cls,
|
|
||||||
permission='{}.create'.format(permission_prefix))
|
|
||||||
config.add_cornice_service(eligible_purchases)
|
|
||||||
|
|
||||||
|
|
||||||
class ReceivingBatchRowViews(APIBatchRowView):
|
|
||||||
|
|
||||||
model_class = PurchaseBatchRow
|
|
||||||
default_handler_spec = 'rattail.batch.purchase:PurchaseBatchHandler'
|
|
||||||
route_prefix = 'receiving.rows'
|
|
||||||
permission_prefix = 'receiving'
|
|
||||||
collection_url_prefix = '/receiving-batch-rows'
|
|
||||||
object_url_prefix = '/receiving-batch-row'
|
|
||||||
supports_quick_entry = True
|
|
||||||
|
|
||||||
def make_filter_spec(self):
|
|
||||||
model = self.app.model
|
|
||||||
filters = super().make_filter_spec()
|
|
||||||
if filters:
|
|
||||||
|
|
||||||
# must translate certain convenience filters
|
|
||||||
orig_filters, filters = filters, []
|
|
||||||
for filtr in orig_filters:
|
|
||||||
|
|
||||||
# # is_received
|
|
||||||
# # NOTE: this is only relevant for truck dump or "from scratch"
|
|
||||||
# if filtr['field'] == 'is_received' and filtr['op'] == 'eq' and filtr['value'] is True:
|
|
||||||
# filters.extend([
|
|
||||||
# {'or': [
|
|
||||||
# {'field': 'cases_received', 'op': '!=', 'value': 0},
|
|
||||||
# {'field': 'units_received', 'op': '!=', 'value': 0},
|
|
||||||
# ]},
|
|
||||||
# ])
|
|
||||||
|
|
||||||
# is_incomplete
|
|
||||||
if filtr['field'] == 'is_incomplete' and filtr['op'] == 'eq' and filtr['value'] is True:
|
|
||||||
# looking for any rows with "ordered" quantity, but where the
|
|
||||||
# status does *not* signify a "settled" row so to speak
|
|
||||||
# TODO: would be nice if we had a simple flag to leverage?
|
|
||||||
filters.extend([
|
|
||||||
{'or': [
|
|
||||||
{'field': 'cases_ordered', 'op': '!=', 'value': 0},
|
|
||||||
{'field': 'units_ordered', 'op': '!=', 'value': 0},
|
|
||||||
]},
|
|
||||||
{'field': 'status_code', 'op': 'not_in', 'value': [
|
|
||||||
model.PurchaseBatchRow.STATUS_OK,
|
|
||||||
model.PurchaseBatchRow.STATUS_PRODUCT_NOT_FOUND,
|
|
||||||
model.PurchaseBatchRow.STATUS_CASE_QUANTITY_DIFFERS,
|
|
||||||
]},
|
|
||||||
])
|
|
||||||
|
|
||||||
# is_invalid
|
|
||||||
elif filtr['field'] == 'is_invalid' and filtr['op'] == 'eq' and filtr['value'] is True:
|
|
||||||
filters.extend([
|
|
||||||
{'field': 'status_code', 'op': 'in', 'value': [
|
|
||||||
model.PurchaseBatchRow.STATUS_PRODUCT_NOT_FOUND,
|
|
||||||
model.PurchaseBatchRow.STATUS_COST_NOT_FOUND,
|
|
||||||
model.PurchaseBatchRow.STATUS_CASE_QUANTITY_UNKNOWN,
|
|
||||||
model.PurchaseBatchRow.STATUS_CASE_QUANTITY_DIFFERS,
|
|
||||||
]},
|
|
||||||
])
|
|
||||||
|
|
||||||
# is_unexpected
|
|
||||||
elif filtr['field'] == 'is_unexpected' and filtr['op'] == 'eq' and filtr['value'] is True:
|
|
||||||
# looking for any rows which do *not* have "ordered/shipped" quantity
|
|
||||||
filters.extend([
|
|
||||||
{'and': [
|
|
||||||
{'or': [
|
|
||||||
{'field': 'cases_ordered', 'op': 'is_null'},
|
|
||||||
{'field': 'cases_ordered', 'op': '==', 'value': 0},
|
|
||||||
]},
|
|
||||||
{'or': [
|
|
||||||
{'field': 'units_ordered', 'op': 'is_null'},
|
|
||||||
{'field': 'units_ordered', 'op': '==', 'value': 0},
|
|
||||||
]},
|
|
||||||
{'or': [
|
|
||||||
{'field': 'cases_shipped', 'op': 'is_null'},
|
|
||||||
{'field': 'cases_shipped', 'op': '==', 'value': 0},
|
|
||||||
]},
|
|
||||||
{'or': [
|
|
||||||
{'field': 'units_shipped', 'op': 'is_null'},
|
|
||||||
{'field': 'units_shipped', 'op': '==', 'value': 0},
|
|
||||||
]},
|
|
||||||
{'or': [
|
|
||||||
# but "unexpected" also implies we have some confirmed amount(s)
|
|
||||||
{'field': 'cases_received', 'op': '!=', 'value': 0},
|
|
||||||
{'field': 'units_received', 'op': '!=', 'value': 0},
|
|
||||||
{'field': 'cases_damaged', 'op': '!=', 'value': 0},
|
|
||||||
{'field': 'units_damaged', 'op': '!=', 'value': 0},
|
|
||||||
{'field': 'cases_expired', 'op': '!=', 'value': 0},
|
|
||||||
{'field': 'units_expired', 'op': '!=', 'value': 0},
|
|
||||||
]},
|
|
||||||
]},
|
|
||||||
])
|
|
||||||
|
|
||||||
# is_damaged
|
|
||||||
elif filtr['field'] == 'is_damaged' and filtr['op'] == 'eq' and filtr['value'] is True:
|
|
||||||
filters.extend([
|
|
||||||
{'or': [
|
|
||||||
{'field': 'cases_damaged', 'op': '!=', 'value': 0},
|
|
||||||
{'field': 'units_damaged', 'op': '!=', 'value': 0},
|
|
||||||
]},
|
|
||||||
])
|
|
||||||
|
|
||||||
# is_expired
|
|
||||||
elif filtr['field'] == 'is_expired' and filtr['op'] == 'eq' and filtr['value'] is True:
|
|
||||||
filters.extend([
|
|
||||||
{'or': [
|
|
||||||
{'field': 'cases_expired', 'op': '!=', 'value': 0},
|
|
||||||
{'field': 'units_expired', 'op': '!=', 'value': 0},
|
|
||||||
]},
|
|
||||||
])
|
|
||||||
|
|
||||||
# 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
|
|
||||||
filters.append(filtr)
|
|
||||||
|
|
||||||
return filters
|
|
||||||
|
|
||||||
def normalize(self, row):
|
|
||||||
data = super().normalize(row)
|
|
||||||
model = self.app.model
|
|
||||||
|
|
||||||
batch = row.batch
|
|
||||||
prodder = self.app.get_products_handler()
|
|
||||||
|
|
||||||
data['product_uuid'] = row.product_uuid
|
|
||||||
data['item_id'] = row.item_id
|
|
||||||
data['upc'] = str(row.upc)
|
|
||||||
data['upc_pretty'] = row.upc.pretty() if row.upc else None
|
|
||||||
data['brand_name'] = row.brand_name
|
|
||||||
data['description'] = row.description
|
|
||||||
data['size'] = row.size
|
|
||||||
data['full_description'] = row.product.full_description if row.product else row.description
|
|
||||||
|
|
||||||
# only provide image url if so configured
|
|
||||||
if self.rattail_config.getbool('rattail.batch', 'purchase.mobile_images', default=True):
|
|
||||||
data['image_url'] = prodder.get_image_url(product=row.product, upc=row.upc)
|
|
||||||
|
|
||||||
# unit_uom can vary by product
|
|
||||||
data['unit_uom'] = 'LB' if row.product and row.product.weighed else 'EA'
|
|
||||||
|
|
||||||
data['case_quantity'] = row.case_quantity
|
|
||||||
data['order_quantities_known'] = batch.order_quantities_known
|
|
||||||
|
|
||||||
data['cases_ordered'] = row.cases_ordered
|
|
||||||
data['units_ordered'] = row.units_ordered
|
|
||||||
|
|
||||||
data['cases_shipped'] = row.cases_shipped
|
|
||||||
data['units_shipped'] = row.units_shipped
|
|
||||||
|
|
||||||
data['cases_received'] = row.cases_received
|
|
||||||
data['units_received'] = row.units_received
|
|
||||||
|
|
||||||
data['cases_damaged'] = row.cases_damaged
|
|
||||||
data['units_damaged'] = row.units_damaged
|
|
||||||
|
|
||||||
data['cases_expired'] = row.cases_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)
|
|
||||||
data['cases_unconfirmed'] = cases
|
|
||||||
data['units_unconfirmed'] = units
|
|
||||||
|
|
||||||
data['po_unit_cost'] = row.po_unit_cost
|
|
||||||
data['po_total'] = row.po_total
|
|
||||||
|
|
||||||
data['invoice_number'] = row.invoice_number
|
|
||||||
data['invoice_unit_cost'] = row.invoice_unit_cost
|
|
||||||
data['invoice_total'] = row.invoice_total
|
|
||||||
data['invoice_total_calculated'] = row.invoice_total_calculated
|
|
||||||
|
|
||||||
data['allow_cases'] = self.batch_handler.allow_cases()
|
|
||||||
|
|
||||||
data['quick_receive'] = self.rattail_config.getbool(
|
|
||||||
'rattail.batch', 'purchase.mobile_quick_receive',
|
|
||||||
default=True)
|
|
||||||
|
|
||||||
if batch.order_quantities_known:
|
|
||||||
data['quick_receive_all'] = self.rattail_config.getbool(
|
|
||||||
'rattail.batch', 'purchase.mobile_quick_receive_all',
|
|
||||||
default=False)
|
|
||||||
|
|
||||||
# TODO: this was copied from regular view receive_row() method; should merge
|
|
||||||
if data['quick_receive'] and data.get('quick_receive_all'):
|
|
||||||
if data['allow_cases']:
|
|
||||||
data['quick_receive_uom'] = 'CS'
|
|
||||||
raise NotImplementedError("TODO: add CS support for quick_receive_all")
|
|
||||||
else:
|
|
||||||
data['quick_receive_uom'] = data['unit_uom']
|
|
||||||
accounted_for = self.batch_handler.get_units_accounted_for(row)
|
|
||||||
remainder = self.batch_handler.get_units_ordered(row) - accounted_for
|
|
||||||
|
|
||||||
if accounted_for:
|
|
||||||
# some product accounted for; button should receive "remainder" only
|
|
||||||
if remainder:
|
|
||||||
remainder = self.app.render_quantity(remainder)
|
|
||||||
data['quick_receive_quantity'] = remainder
|
|
||||||
data['quick_receive_text'] = "Receive Remainder ({} {})".format(
|
|
||||||
remainder, data['unit_uom'])
|
|
||||||
else:
|
|
||||||
# unless there is no remainder, in which case disable it
|
|
||||||
data['quick_receive'] = False
|
|
||||||
|
|
||||||
else: # nothing yet accounted for, button should receive "all"
|
|
||||||
if not remainder:
|
|
||||||
log.warning("quick receive remainder is empty for row %s", row.uuid)
|
|
||||||
remainder = self.app.render_quantity(remainder)
|
|
||||||
data['quick_receive_quantity'] = remainder
|
|
||||||
data['quick_receive_text'] = "Receive ALL ({} {})".format(
|
|
||||||
remainder, data['unit_uom'])
|
|
||||||
|
|
||||||
data['unexpected_alert'] = None
|
|
||||||
if batch.order_quantities_known and not row.cases_ordered and not row.units_ordered:
|
|
||||||
warn = True
|
|
||||||
if batch.is_truck_dump_parent() and row.product:
|
|
||||||
uuids = [child.uuid for child in batch.truck_dump_children]
|
|
||||||
if uuids:
|
|
||||||
count = self.Session.query(model.PurchaseBatchRow)\
|
|
||||||
.filter(model.PurchaseBatchRow.batch_uuid.in_(uuids))\
|
|
||||||
.filter(model.PurchaseBatchRow.product == row.product)\
|
|
||||||
.count()
|
|
||||||
if count:
|
|
||||||
warn = False
|
|
||||||
if warn:
|
|
||||||
data['unexpected_alert'] = "This item was NOT on the original purchase order."
|
|
||||||
|
|
||||||
# TODO: surely the caller of API should determine this flag?
|
|
||||||
# maybe alert user if they've already received some of this product
|
|
||||||
alert_received = self.rattail_config.getbool('tailbone', 'receiving.alert_already_received',
|
|
||||||
default=False)
|
|
||||||
if alert_received:
|
|
||||||
data['received_alert'] = None
|
|
||||||
if self.batch_handler.get_units_confirmed(row):
|
|
||||||
msg = "You have already received some of this product; last update was {}.".format(
|
|
||||||
humanize.naturaltime(self.app.make_utc() - row.modified))
|
|
||||||
data['received_alert'] = msg
|
|
||||||
|
|
||||||
return data
|
|
||||||
|
|
||||||
def receive(self):
|
|
||||||
"""
|
|
||||||
View which handles "receiving" against a particular batch row.
|
|
||||||
"""
|
|
||||||
model = self.app.model
|
|
||||||
|
|
||||||
# first do basic input validation
|
|
||||||
schema = ReceiveRow().bind(session=self.Session())
|
|
||||||
form = forms.Form(schema=schema, request=self.request)
|
|
||||||
# TODO: this seems hacky, but avoids "complex" date value parsing
|
|
||||||
form.set_widget('expiration_date', dfwidget.TextInputWidget())
|
|
||||||
if not form.validate():
|
|
||||||
log.warning("form did not validate: %s",
|
|
||||||
form.make_deform_form().error)
|
|
||||||
return {'error': "Form did not validate"}
|
|
||||||
|
|
||||||
# fetch / validate row object
|
|
||||||
row = self.Session.get(model.PurchaseBatchRow, form.validated['row'])
|
|
||||||
if row is not self.get_object():
|
|
||||||
return {'error': "Specified row does not match the route!"}
|
|
||||||
|
|
||||||
# handler takes care of the row receiving logic for us
|
|
||||||
kwargs = dict(form.validated)
|
|
||||||
del kwargs['row']
|
|
||||||
try:
|
|
||||||
self.batch_handler.receive_row(row, **kwargs)
|
|
||||||
self.Session.flush()
|
|
||||||
except Exception as error:
|
|
||||||
log.warning("receive() failed", exc_info=True)
|
|
||||||
if isinstance(error, sa.exc.DataError) and hasattr(error, 'orig'):
|
|
||||||
error = str(error.orig)
|
|
||||||
else:
|
|
||||||
error = str(error)
|
|
||||||
return {'error': error}
|
|
||||||
|
|
||||||
return self._get(obj=row)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def defaults(cls, config):
|
|
||||||
cls._defaults(config)
|
|
||||||
cls._batch_row_defaults(config)
|
|
||||||
cls._receiving_batch_row_defaults(config)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _receiving_batch_row_defaults(cls, config):
|
|
||||||
route_prefix = cls.get_route_prefix()
|
|
||||||
permission_prefix = cls.get_permission_prefix()
|
|
||||||
object_url_prefix = cls.get_object_url_prefix()
|
|
||||||
|
|
||||||
# receive (row)
|
|
||||||
receive = Service(name='{}.receive'.format(route_prefix),
|
|
||||||
path='{}/{{uuid}}/receive'.format(object_url_prefix))
|
|
||||||
receive.add_view('POST', 'receive', klass=cls,
|
|
||||||
permission='{}.edit_row'.format(permission_prefix))
|
|
||||||
config.add_cornice_service(receive)
|
|
||||||
|
|
||||||
|
|
||||||
def defaults(config, **kwargs):
|
|
||||||
base = globals()
|
|
||||||
|
|
||||||
ReceivingBatchViews = kwargs.get('ReceivingBatchViews', base['ReceivingBatchViews'])
|
|
||||||
ReceivingBatchViews.defaults(config)
|
|
||||||
|
|
||||||
ReceivingBatchRowViews = kwargs.get('ReceivingBatchRowViews', base['ReceivingBatchRowViews'])
|
|
||||||
ReceivingBatchRowViews.defaults(config)
|
|
||||||
|
|
||||||
|
|
||||||
def includeme(config):
|
|
||||||
defaults(config)
|
|
|
@ -1,159 +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 Web API - "Common" Views
|
|
||||||
"""
|
|
||||||
|
|
||||||
from collections import OrderedDict
|
|
||||||
|
|
||||||
from rattail.util import get_pkg_version
|
|
||||||
|
|
||||||
from cornice import Service
|
|
||||||
from cornice.service import get_services
|
|
||||||
from cornice_swagger import CorniceSwagger
|
|
||||||
|
|
||||||
from tailbone import forms
|
|
||||||
from tailbone.forms.common import Feedback
|
|
||||||
from tailbone.api import APIView, api
|
|
||||||
from tailbone.db import Session
|
|
||||||
|
|
||||||
|
|
||||||
class CommonView(APIView):
|
|
||||||
"""
|
|
||||||
Misc. "common" views for the API.
|
|
||||||
|
|
||||||
.. attribute:: feedback_email_key
|
|
||||||
|
|
||||||
This is the email key which will be used when sending "user feedback"
|
|
||||||
email. Default value is ``'user_feedback'``.
|
|
||||||
"""
|
|
||||||
feedback_email_key = 'user_feedback'
|
|
||||||
|
|
||||||
@api
|
|
||||||
def about(self):
|
|
||||||
"""
|
|
||||||
Generic view to show "about project" info page.
|
|
||||||
"""
|
|
||||||
packages = self.get_packages()
|
|
||||||
return {
|
|
||||||
'project_title': self.get_project_title(),
|
|
||||||
'project_version': self.get_project_version(),
|
|
||||||
'packages': packages,
|
|
||||||
'package_names': list(packages),
|
|
||||||
}
|
|
||||||
|
|
||||||
def get_project_title(self):
|
|
||||||
app = self.get_rattail_app()
|
|
||||||
return app.get_title()
|
|
||||||
|
|
||||||
def get_project_version(self):
|
|
||||||
app = self.get_rattail_app()
|
|
||||||
return app.get_version()
|
|
||||||
|
|
||||||
def get_packages(self):
|
|
||||||
"""
|
|
||||||
Should return the full set of packages which should be displayed on the
|
|
||||||
'about' page.
|
|
||||||
"""
|
|
||||||
return OrderedDict([
|
|
||||||
('rattail', get_pkg_version('rattail')),
|
|
||||||
('Tailbone', get_pkg_version('Tailbone')),
|
|
||||||
])
|
|
||||||
|
|
||||||
@api
|
|
||||||
def feedback(self):
|
|
||||||
"""
|
|
||||||
View to handle user feedback form submits.
|
|
||||||
"""
|
|
||||||
app = self.get_rattail_app()
|
|
||||||
model = self.model
|
|
||||||
# TODO: this logic was copied from tailbone.views.common and is largely
|
|
||||||
# identical; perhaps should merge somehow?
|
|
||||||
schema = Feedback().bind(session=Session())
|
|
||||||
form = forms.Form(schema=schema, request=self.request)
|
|
||||||
if form.validate():
|
|
||||||
data = dict(form.validated)
|
|
||||||
|
|
||||||
# figure out who the sending user is, if any
|
|
||||||
if self.request.user:
|
|
||||||
data['user'] = self.request.user
|
|
||||||
elif data['user']:
|
|
||||||
data['user'] = Session.get(model.User, data['user'])
|
|
||||||
|
|
||||||
# TODO: should provide URL to view user
|
|
||||||
if data['user']:
|
|
||||||
data['user_url'] = '#' # TODO: could get from config?
|
|
||||||
|
|
||||||
data['client_ip'] = self.request.client_addr
|
|
||||||
email_key = data['email_key'] or self.feedback_email_key
|
|
||||||
app.send_email(email_key, data=data)
|
|
||||||
return {'ok': True}
|
|
||||||
|
|
||||||
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
|
|
||||||
def defaults(cls, config):
|
|
||||||
cls._common_defaults(config)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _common_defaults(cls, config):
|
|
||||||
rattail_config = config.registry.settings.get('rattail_config')
|
|
||||||
app = rattail_config.get_app()
|
|
||||||
|
|
||||||
# about
|
|
||||||
about = Service(name='about', path='/about')
|
|
||||||
about.add_view('GET', 'about', klass=cls)
|
|
||||||
config.add_cornice_service(about)
|
|
||||||
|
|
||||||
# feedback
|
|
||||||
feedback = Service(name='feedback', path='/feedback')
|
|
||||||
feedback.add_view('POST', 'feedback', klass=cls,
|
|
||||||
permission='common.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):
|
|
||||||
base = globals()
|
|
||||||
|
|
||||||
CommonView = kwargs.get('CommonView', base['CommonView'])
|
|
||||||
CommonView.defaults(config)
|
|
||||||
|
|
||||||
|
|
||||||
def includeme(config):
|
|
||||||
defaults(config)
|
|
|
@ -1,125 +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 Web API - Core Views
|
|
||||||
"""
|
|
||||||
|
|
||||||
from tailbone.views import View
|
|
||||||
|
|
||||||
|
|
||||||
def api(view_meth):
|
|
||||||
"""
|
|
||||||
Common decorator for all API views. Ideally this would not be needed..but
|
|
||||||
for now, alas, it is.
|
|
||||||
"""
|
|
||||||
def wrapped(view, *args, **kwargs):
|
|
||||||
|
|
||||||
# TODO: why doesn't this work here...? (instead we have to repeat this
|
|
||||||
# code in lots of other places)
|
|
||||||
# if view.request.method == 'OPTIONS':
|
|
||||||
# return view.request.response
|
|
||||||
|
|
||||||
# invoke the view logic first, since presumably it may involve a
|
|
||||||
# redirect in which case we don't really need to add the CSRF token.
|
|
||||||
# main known use case for this is the /logout endpoint - if that gets
|
|
||||||
# hit then the "current" (old) session will be destroyed, in which case
|
|
||||||
# we can't use the token from that, but instead must generate a new one.
|
|
||||||
result = view_meth(view, *args, **kwargs)
|
|
||||||
|
|
||||||
# explicitly set CSRF token cookie, unless OPTIONS request
|
|
||||||
# TODO: why doesn't pyramid do this for us again?
|
|
||||||
if view.request.method != 'OPTIONS':
|
|
||||||
view.request.response.set_cookie(name='XSRF-TOKEN',
|
|
||||||
value=view.request.session.get_csrf_token())
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
return wrapped
|
|
||||||
|
|
||||||
|
|
||||||
class APIView(View):
|
|
||||||
"""
|
|
||||||
Base class for all API views.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def pretty_datetime(self, dt):
|
|
||||||
if not dt:
|
|
||||||
return ""
|
|
||||||
return dt.strftime('%Y-%m-%d @ %I:%M %p')
|
|
||||||
|
|
||||||
def get_user_info(self, user):
|
|
||||||
"""
|
|
||||||
This method is present on *all* API views, and is meant to provide a
|
|
||||||
single means of obtaining "common" user info, for return to the caller.
|
|
||||||
Such info may be returned in several places, e.g. upon login but also
|
|
||||||
in the "check session" call, or e.g. as part of a broader return value
|
|
||||||
from any other call.
|
|
||||||
|
|
||||||
:returns: Dictionary of user info data, ready for JSON serialization.
|
|
||||||
|
|
||||||
Note that you should *not* (usually) override this method in any view,
|
|
||||||
but instead configure a "supplemental" function which can then add or
|
|
||||||
replace info entries. Config for that looks like e.g.:
|
|
||||||
|
|
||||||
.. code-block:: ini
|
|
||||||
|
|
||||||
[tailbone.api]
|
|
||||||
extra_user_info = poser.web.api.util:extra_user_info
|
|
||||||
|
|
||||||
Note that the above config assumes a simple *function* defined in your
|
|
||||||
``util`` module; such a function would look like e.g.::
|
|
||||||
|
|
||||||
def extra_user_info(request, user, **info):
|
|
||||||
# add favorite color
|
|
||||||
info['favorite_color'] = 'green'
|
|
||||||
# override display name
|
|
||||||
info['display_name'] = "TODO"
|
|
||||||
# remove short_name
|
|
||||||
info.pop('short_name', None)
|
|
||||||
return info
|
|
||||||
"""
|
|
||||||
app = self.get_rattail_app()
|
|
||||||
auth = app.get_auth_handler()
|
|
||||||
|
|
||||||
# basic / default info
|
|
||||||
is_admin = auth.user_is_admin(user)
|
|
||||||
employee = app.get_employee(user)
|
|
||||||
info = {
|
|
||||||
'uuid': user.uuid,
|
|
||||||
'username': user.username,
|
|
||||||
'display_name': user.display_name,
|
|
||||||
'short_name': auth.get_short_display_name(user),
|
|
||||||
'is_admin': is_admin,
|
|
||||||
'is_root': is_admin and self.request.session.get('is_root', False),
|
|
||||||
'employee_uuid': employee.uuid if employee else None,
|
|
||||||
'email_address': app.get_contact_email_address(user),
|
|
||||||
}
|
|
||||||
|
|
||||||
# maybe get/use "extra" info
|
|
||||||
extra = self.rattail_config.get('tailbone.api', 'extra_user_info',
|
|
||||||
usedb=False)
|
|
||||||
if extra:
|
|
||||||
extra = app.load_object(extra)
|
|
||||||
info = extra(self.request, user, **info)
|
|
||||||
|
|
||||||
return info
|
|
|
@ -1,60 +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 Web API - Customer Views
|
|
||||||
"""
|
|
||||||
|
|
||||||
from rattail.db import model
|
|
||||||
|
|
||||||
from tailbone.api import APIMasterView
|
|
||||||
|
|
||||||
|
|
||||||
class CustomerView(APIMasterView):
|
|
||||||
"""
|
|
||||||
API views for Customer data
|
|
||||||
"""
|
|
||||||
model_class = model.Customer
|
|
||||||
collection_url_prefix = '/customers'
|
|
||||||
object_url_prefix = '/customer'
|
|
||||||
supports_autocomplete = True
|
|
||||||
autocomplete_fieldname = 'name'
|
|
||||||
|
|
||||||
def normalize(self, customer):
|
|
||||||
return {
|
|
||||||
'uuid': customer.uuid,
|
|
||||||
'_str': str(customer),
|
|
||||||
'id': customer.id,
|
|
||||||
'number': customer.number,
|
|
||||||
'name': customer.name,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def defaults(config, **kwargs):
|
|
||||||
base = globals()
|
|
||||||
|
|
||||||
CustomerView = kwargs.get('CustomerView', base['CustomerView'])
|
|
||||||
CustomerView.defaults(config)
|
|
||||||
|
|
||||||
|
|
||||||
def includeme(config):
|
|
||||||
defaults(config)
|
|
|
@ -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)
|
|
|
@ -1,51 +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/>.
|
|
||||||
#
|
|
||||||
################################################################################
|
|
||||||
"""
|
|
||||||
Tailbone Web API - Label Views
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import unicode_literals, absolute_import
|
|
||||||
|
|
||||||
from rattail.db.model import LabelProfile
|
|
||||||
|
|
||||||
from tailbone.api import APIMasterView
|
|
||||||
|
|
||||||
|
|
||||||
class LabelProfileView(APIMasterView):
|
|
||||||
"""
|
|
||||||
API views for Label Profile data
|
|
||||||
"""
|
|
||||||
model_class = LabelProfile
|
|
||||||
collection_url_prefix = '/label-profiles'
|
|
||||||
object_url_prefix = '/label-profile'
|
|
||||||
|
|
||||||
|
|
||||||
def defaults(config, **kwargs):
|
|
||||||
base = globals()
|
|
||||||
|
|
||||||
LabelProfileView = kwargs.get('LabelProfileView', base['LabelProfileView'])
|
|
||||||
LabelProfileView.defaults(config)
|
|
||||||
|
|
||||||
|
|
||||||
def includeme(config):
|
|
||||||
defaults(config)
|
|
|
@ -1,618 +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 Web API - Master View
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
|
|
||||||
from rattail.db.util import get_fieldnames
|
|
||||||
|
|
||||||
from cornice import resource, Service
|
|
||||||
|
|
||||||
from tailbone.api import APIView
|
|
||||||
from tailbone.db import Session
|
|
||||||
from tailbone.util import SortColumn
|
|
||||||
|
|
||||||
|
|
||||||
class APIMasterView(APIView):
|
|
||||||
"""
|
|
||||||
Base class for data model REST API views.
|
|
||||||
"""
|
|
||||||
listable = True
|
|
||||||
creatable = True
|
|
||||||
viewable = True
|
|
||||||
editable = True
|
|
||||||
deletable = True
|
|
||||||
supports_autocomplete = False
|
|
||||||
supports_download = False
|
|
||||||
supports_rawbytes = False
|
|
||||||
|
|
||||||
@property
|
|
||||||
def Session(self):
|
|
||||||
return Session
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_model_class(cls):
|
|
||||||
if hasattr(cls, 'model_class'):
|
|
||||||
return cls.model_class
|
|
||||||
raise NotImplementedError("must set `model_class` for {}".format(cls.__name__))
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_normalized_model_name(cls):
|
|
||||||
if hasattr(cls, 'normalized_model_name'):
|
|
||||||
return cls.normalized_model_name
|
|
||||||
return cls.get_model_class().__name__.lower()
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_route_prefix(cls):
|
|
||||||
"""
|
|
||||||
Returns a prefix which (by default) applies to all routes provided by
|
|
||||||
this view class.
|
|
||||||
"""
|
|
||||||
prefix = getattr(cls, 'route_prefix', None)
|
|
||||||
if prefix:
|
|
||||||
return prefix
|
|
||||||
model_name = cls.get_normalized_model_name()
|
|
||||||
return '{}s'.format(model_name)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_permission_prefix(cls):
|
|
||||||
"""
|
|
||||||
Returns a prefix which (by default) applies to all permissions
|
|
||||||
leveraged by this view class.
|
|
||||||
"""
|
|
||||||
prefix = getattr(cls, 'permission_prefix', None)
|
|
||||||
if prefix:
|
|
||||||
return prefix
|
|
||||||
return cls.get_route_prefix()
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_collection_url_prefix(cls):
|
|
||||||
"""
|
|
||||||
Returns a prefix which (by default) applies to all "collection" URLs
|
|
||||||
provided by this view class.
|
|
||||||
"""
|
|
||||||
prefix = getattr(cls, 'collection_url_prefix', None)
|
|
||||||
if prefix:
|
|
||||||
return prefix
|
|
||||||
return '/{}'.format(cls.get_route_prefix())
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_object_url_prefix(cls):
|
|
||||||
"""
|
|
||||||
Returns a prefix which (by default) applies to all "object" URLs
|
|
||||||
provided by this view class.
|
|
||||||
"""
|
|
||||||
prefix = getattr(cls, 'object_url_prefix', None)
|
|
||||||
if prefix:
|
|
||||||
return prefix
|
|
||||||
return '/{}'.format(cls.get_route_prefix())
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_object_key(cls):
|
|
||||||
if hasattr(cls, 'object_key'):
|
|
||||||
return cls.object_key
|
|
||||||
return cls.get_normalized_model_name()
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_collection_key(cls):
|
|
||||||
if hasattr(cls, 'collection_key'):
|
|
||||||
return cls.collection_key
|
|
||||||
return '{}s'.format(cls.get_object_key())
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def establish_method(cls, method_name):
|
|
||||||
"""
|
|
||||||
Establish the given HTTP method for this Cornice Resource.
|
|
||||||
|
|
||||||
Cornice will auto-register any class methods for a resource, if they
|
|
||||||
are named according to what it expects (i.e. 'get', 'collection_get'
|
|
||||||
etc.). Tailbone API tries to make things automagical for the sake of
|
|
||||||
e.g. Poser logic, but in this case if we predefine all of these methods
|
|
||||||
and then some subclass view wants to *not* allow one, it's not clear
|
|
||||||
how to "undefine" it per se. Or at least, the more straightforward
|
|
||||||
thing (I think) is to not define such a method in the first place, if
|
|
||||||
it was not wanted.
|
|
||||||
|
|
||||||
Enter ``establish_method()``, which is what finally "defines" each
|
|
||||||
resource method according to what the subclass has declared via its
|
|
||||||
various attributes (:attr:`creatable`, :attr:`deletable` etc.).
|
|
||||||
|
|
||||||
Note that you will not likely have any need to use this
|
|
||||||
``establish_method()`` yourself! But we describe its purpose here, for
|
|
||||||
clarity.
|
|
||||||
"""
|
|
||||||
def method(self):
|
|
||||||
internal_method = getattr(self, '_{}'.format(method_name))
|
|
||||||
return internal_method()
|
|
||||||
|
|
||||||
setattr(cls, method_name, method)
|
|
||||||
|
|
||||||
def make_filter_spec(self):
|
|
||||||
if not self.request.GET.has_key('filters'):
|
|
||||||
return []
|
|
||||||
|
|
||||||
filters = json.loads(self.request.GET.getone('filters'))
|
|
||||||
return filters
|
|
||||||
|
|
||||||
def make_sort_spec(self):
|
|
||||||
|
|
||||||
# we prefer a "native sort"
|
|
||||||
if self.request.GET.has_key('nativeSort'):
|
|
||||||
return json.loads(self.request.GET.getone('nativeSort'))
|
|
||||||
|
|
||||||
# these params are based on 'vuetable-2'
|
|
||||||
# https://www.vuetable.com/guide/sorting.html#initial-sorting-order
|
|
||||||
if 'sort' in self.request.params:
|
|
||||||
sort = self.request.params['sort']
|
|
||||||
sortkey, sortdir = sort.split('|')
|
|
||||||
if sortdir != 'desc':
|
|
||||||
sortdir = 'asc'
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
# 'model': self.model_class.__name__,
|
|
||||||
'field': sortkey,
|
|
||||||
'direction': sortdir,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
# these params are based on 'vue-tables-2'
|
|
||||||
# https://github.com/matfish2/vue-tables-2#server-side
|
|
||||||
if 'orderBy' in self.request.params and 'ascending' in self.request.params:
|
|
||||||
sortcol = self.interpret_sortcol(self.request.params['orderBy'])
|
|
||||||
if sortcol:
|
|
||||||
spec = {
|
|
||||||
'field': sortcol.field_name,
|
|
||||||
'direction': 'asc' if self.config.parse_bool(self.request.params['ascending']) else 'desc',
|
|
||||||
}
|
|
||||||
if sortcol.model_name:
|
|
||||||
spec['model'] = sortcol.model_name
|
|
||||||
return [spec]
|
|
||||||
|
|
||||||
def interpret_sortcol(self, order_by):
|
|
||||||
"""
|
|
||||||
This must return a ``SortColumn`` object based on parsing of the given
|
|
||||||
``order_by`` string, which is "raw" as received from the client.
|
|
||||||
|
|
||||||
Please override as necessary, but in all cases you should invoke
|
|
||||||
:meth:`sortcol()` to obtain your return value. Default behavior
|
|
||||||
for this method is to simply do (only) that::
|
|
||||||
|
|
||||||
return self.sortcol(order_by)
|
|
||||||
|
|
||||||
Note that you can also return ``None`` here, if the given ``order_by``
|
|
||||||
string does not represent a valid sort.
|
|
||||||
"""
|
|
||||||
return self.sortcol(order_by)
|
|
||||||
|
|
||||||
def sortcol(self, field_name, model_name=None):
|
|
||||||
"""
|
|
||||||
Return a simple ``SortColumn`` object which denotes the field and
|
|
||||||
optionally, the model, to be used when sorting.
|
|
||||||
"""
|
|
||||||
if not model_name:
|
|
||||||
model_name = self.model_class.__name__
|
|
||||||
return SortColumn(field_name, model_name)
|
|
||||||
|
|
||||||
def join_for_sort_spec(self, query, sort_spec):
|
|
||||||
"""
|
|
||||||
This should apply any joins needed on the given query, to accommodate
|
|
||||||
requested sorting as per ``sort_spec`` - which will be non-empty but
|
|
||||||
otherwise no claims are made regarding its contents.
|
|
||||||
|
|
||||||
Please override as necessary, but in all cases you should return a
|
|
||||||
query, either untouched or else with join(s) applied.
|
|
||||||
"""
|
|
||||||
model_name = sort_spec[0].get('model')
|
|
||||||
return self.join_for_sort_model(query, model_name)
|
|
||||||
|
|
||||||
def join_for_sort_model(self, query, model_name):
|
|
||||||
"""
|
|
||||||
This should apply any joins needed on the given query, to accommodate
|
|
||||||
requested sorting on a field associated with the given model.
|
|
||||||
|
|
||||||
Please override as necessary, but in all cases you should return a
|
|
||||||
query, either untouched or else with join(s) applied.
|
|
||||||
"""
|
|
||||||
return query
|
|
||||||
|
|
||||||
def make_pagination_spec(self):
|
|
||||||
|
|
||||||
# these params are based on 'vuetable-2'
|
|
||||||
# https://github.com/ratiw/vuetable-2-tutorial/wiki/prerequisite#sample-api-endpoint
|
|
||||||
if 'page' in self.request.params and 'per_page' in self.request.params:
|
|
||||||
page = self.request.params['page']
|
|
||||||
per_page = self.request.params['per_page']
|
|
||||||
if page.isdigit() and per_page.isdigit():
|
|
||||||
return int(page), int(per_page)
|
|
||||||
|
|
||||||
# these params are based on 'vue-tables-2'
|
|
||||||
# https://github.com/matfish2/vue-tables-2#server-side
|
|
||||||
if 'page' in self.request.params and 'limit' in self.request.params:
|
|
||||||
page = self.request.params['page']
|
|
||||||
limit = self.request.params['limit']
|
|
||||||
if page.isdigit() and limit.isdigit():
|
|
||||||
return int(page), int(limit)
|
|
||||||
|
|
||||||
def base_query(self):
|
|
||||||
cls = self.get_model_class()
|
|
||||||
query = self.Session.query(cls)
|
|
||||||
return query
|
|
||||||
|
|
||||||
def get_fieldnames(self):
|
|
||||||
if not hasattr(self, '_fieldnames'):
|
|
||||||
self._fieldnames = get_fieldnames(
|
|
||||||
self.rattail_config, self.model_class,
|
|
||||||
columns=True, proxies=True, relations=False)
|
|
||||||
return self._fieldnames
|
|
||||||
|
|
||||||
def normalize(self, obj):
|
|
||||||
data = {'_str': str(obj)}
|
|
||||||
|
|
||||||
for field in self.get_fieldnames():
|
|
||||||
data[field] = getattr(obj, field)
|
|
||||||
|
|
||||||
return data
|
|
||||||
|
|
||||||
def _collection_get(self):
|
|
||||||
from sa_filters import apply_filters, apply_sort, apply_pagination
|
|
||||||
|
|
||||||
query = self.base_query()
|
|
||||||
context = {}
|
|
||||||
|
|
||||||
# maybe filter query
|
|
||||||
filter_spec = self.make_filter_spec()
|
|
||||||
if filter_spec:
|
|
||||||
query = apply_filters(query, filter_spec)
|
|
||||||
|
|
||||||
# maybe sort query
|
|
||||||
sort_spec = self.make_sort_spec()
|
|
||||||
if sort_spec:
|
|
||||||
query = self.join_for_sort_spec(query, sort_spec)
|
|
||||||
query = apply_sort(query, sort_spec)
|
|
||||||
|
|
||||||
# maybe paginate query
|
|
||||||
pagination_spec = self.make_pagination_spec()
|
|
||||||
if pagination_spec:
|
|
||||||
number, size = pagination_spec
|
|
||||||
query, pagination = apply_pagination(query, page_number=number, page_size=size)
|
|
||||||
|
|
||||||
# these properties are based on 'vuetable-2'
|
|
||||||
# https://www.vuetable.com/guide/pagination.html#how-the-pagination-component-works
|
|
||||||
context['total'] = pagination.total_results
|
|
||||||
context['per_page'] = pagination.page_size
|
|
||||||
context['current_page'] = pagination.page_number
|
|
||||||
context['last_page'] = pagination.num_pages
|
|
||||||
context['from'] = pagination.page_size * (pagination.page_number - 1) + 1
|
|
||||||
to = pagination.page_size * (pagination.page_number - 1) + pagination.page_size
|
|
||||||
if to > pagination.total_results:
|
|
||||||
context['to'] = pagination.total_results
|
|
||||||
else:
|
|
||||||
context['to'] = to
|
|
||||||
|
|
||||||
# these properties are based on 'vue-tables-2'
|
|
||||||
# https://github.com/matfish2/vue-tables-2#server-side
|
|
||||||
context['count'] = pagination.total_results
|
|
||||||
|
|
||||||
objects = [self.normalize(obj) for obj in query]
|
|
||||||
|
|
||||||
# TODO: test this for ratbob!
|
|
||||||
context[self.get_collection_key()] = objects
|
|
||||||
|
|
||||||
# these properties are based on 'vue-tables-2'
|
|
||||||
# https://github.com/matfish2/vue-tables-2#server-side
|
|
||||||
context['data'] = objects
|
|
||||||
if 'count' not in context:
|
|
||||||
context['count'] = len(objects)
|
|
||||||
|
|
||||||
return context
|
|
||||||
|
|
||||||
def get_object(self, uuid=None):
|
|
||||||
if not uuid:
|
|
||||||
uuid = self.request.matchdict['uuid']
|
|
||||||
|
|
||||||
obj = self.Session.get(self.get_model_class(), uuid)
|
|
||||||
if obj:
|
|
||||||
return obj
|
|
||||||
|
|
||||||
raise self.notfound()
|
|
||||||
|
|
||||||
def _get(self, obj=None, uuid=None):
|
|
||||||
if not obj:
|
|
||||||
obj = self.get_object(uuid=uuid)
|
|
||||||
key = self.get_object_key()
|
|
||||||
normal = self.normalize(obj)
|
|
||||||
return {key: normal, 'data': normal}
|
|
||||||
|
|
||||||
def _collection_post(self):
|
|
||||||
"""
|
|
||||||
Default method for actually processing a POST request for the
|
|
||||||
collection, aka. "create new object".
|
|
||||||
"""
|
|
||||||
# assume our data comes only from request JSON body
|
|
||||||
data = self.request.json_body
|
|
||||||
|
|
||||||
# add instance to session, and return data for it
|
|
||||||
try:
|
|
||||||
obj = self.create_object(data)
|
|
||||||
except Exception as error:
|
|
||||||
return self.json_response({'error': str(error)})
|
|
||||||
else:
|
|
||||||
self.Session.flush()
|
|
||||||
return self._get(obj)
|
|
||||||
|
|
||||||
def create_object(self, data):
|
|
||||||
"""
|
|
||||||
Create a new object instance and populate it with the given data.
|
|
||||||
|
|
||||||
Note that this method by default will only populate *simple* fields, so
|
|
||||||
you may need to subclass and override to add more complex field logic.
|
|
||||||
"""
|
|
||||||
# create new instance of model class
|
|
||||||
cls = self.get_model_class()
|
|
||||||
obj = cls()
|
|
||||||
|
|
||||||
# "update" new object with given data
|
|
||||||
obj = self.update_object(obj, data)
|
|
||||||
|
|
||||||
# that's all we can do here, subclass must override if more needed
|
|
||||||
self.Session.add(obj)
|
|
||||||
return obj
|
|
||||||
|
|
||||||
def _post(self, uuid=None):
|
|
||||||
"""
|
|
||||||
Default method for actually processing a POST request for an object,
|
|
||||||
aka. "update existing object".
|
|
||||||
"""
|
|
||||||
if not uuid:
|
|
||||||
uuid = self.request.matchdict['uuid']
|
|
||||||
obj = self.Session.get(self.get_model_class(), uuid)
|
|
||||||
if not obj:
|
|
||||||
raise self.notfound()
|
|
||||||
|
|
||||||
# assume our data comes only from request JSON body
|
|
||||||
data = self.request.json_body
|
|
||||||
|
|
||||||
# try to update data for object, returning error as necessary
|
|
||||||
obj = self.update_object(obj, data)
|
|
||||||
if isinstance(obj, dict) and 'error' in obj:
|
|
||||||
return {'error': obj['error']}
|
|
||||||
|
|
||||||
# return data for object
|
|
||||||
self.Session.flush()
|
|
||||||
return self._get(obj)
|
|
||||||
|
|
||||||
def update_object(self, obj, data):
|
|
||||||
"""
|
|
||||||
Update the given object instance with the given data.
|
|
||||||
|
|
||||||
Note that this method by default will only update *simple* fields, so
|
|
||||||
you may need to subclass and override to add more complex field logic.
|
|
||||||
"""
|
|
||||||
# set values for simple fields only
|
|
||||||
for key, value in data.items():
|
|
||||||
if hasattr(obj, key):
|
|
||||||
# TODO: what about datetime, decimal etc.?
|
|
||||||
setattr(obj, key, value)
|
|
||||||
|
|
||||||
# that's all we can do here, subclass must override if more needed
|
|
||||||
return obj
|
|
||||||
|
|
||||||
##############################
|
|
||||||
# delete
|
|
||||||
##############################
|
|
||||||
|
|
||||||
def _delete(self):
|
|
||||||
"""
|
|
||||||
View to handle DELETE action for an existing record/object.
|
|
||||||
"""
|
|
||||||
obj = self.get_object()
|
|
||||||
self.delete_object(obj)
|
|
||||||
|
|
||||||
def delete_object(self, obj):
|
|
||||||
"""
|
|
||||||
Delete the object, or mark it as deleted, or whatever you need to do.
|
|
||||||
"""
|
|
||||||
# flush immediately to force any pending integrity errors etc.
|
|
||||||
self.Session.delete(obj)
|
|
||||||
self.Session.flush()
|
|
||||||
|
|
||||||
##############################
|
|
||||||
# download
|
|
||||||
##############################
|
|
||||||
|
|
||||||
def download(self):
|
|
||||||
"""
|
|
||||||
GET view allowing for download of a single file, which is attached to a
|
|
||||||
given record.
|
|
||||||
"""
|
|
||||||
obj = self.get_object()
|
|
||||||
|
|
||||||
filename = self.request.GET.get('filename', None)
|
|
||||||
if not filename:
|
|
||||||
raise self.notfound()
|
|
||||||
path = self.download_path(obj, filename)
|
|
||||||
|
|
||||||
response = self.file_response(path)
|
|
||||||
return response
|
|
||||||
|
|
||||||
def download_path(self, obj, filename):
|
|
||||||
"""
|
|
||||||
Should return absolute path on disk, for the given object and filename.
|
|
||||||
Result will be used to return a file response to client.
|
|
||||||
"""
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
def rawbytes(self):
|
|
||||||
"""
|
|
||||||
GET view allowing for direct access to the raw bytes of a file, which
|
|
||||||
is attached to a given record. Basically the same as 'download' except
|
|
||||||
this does not come as an attachment.
|
|
||||||
"""
|
|
||||||
obj = self.get_object()
|
|
||||||
|
|
||||||
# TODO: is this really needed?
|
|
||||||
# filename = self.request.GET.get('filename', None)
|
|
||||||
# if filename:
|
|
||||||
# path = self.download_path(obj, filename)
|
|
||||||
# return self.file_response(path, attachment=False)
|
|
||||||
|
|
||||||
return self.rawbytes_response(obj)
|
|
||||||
|
|
||||||
def rawbytes_response(self, obj):
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
##############################
|
|
||||||
# autocomplete
|
|
||||||
##############################
|
|
||||||
|
|
||||||
def autocomplete(self):
|
|
||||||
"""
|
|
||||||
View which accepts a single ``term`` param, and returns a list of
|
|
||||||
autocomplete results to match.
|
|
||||||
"""
|
|
||||||
term = self.request.params.get('term', '').strip()
|
|
||||||
term = self.prepare_autocomplete_term(term)
|
|
||||||
if not term:
|
|
||||||
return []
|
|
||||||
|
|
||||||
results = self.get_autocomplete_data(term)
|
|
||||||
return [{'label': self.autocomplete_display(x),
|
|
||||||
'value': self.autocomplete_value(x)}
|
|
||||||
for x in results]
|
|
||||||
|
|
||||||
@property
|
|
||||||
def autocomplete_fieldname(self):
|
|
||||||
raise NotImplementedError("You must define `autocomplete_fieldname` "
|
|
||||||
"attribute for API view class: {}".format(
|
|
||||||
self.__class__))
|
|
||||||
|
|
||||||
def autocomplete_display(self, obj):
|
|
||||||
return getattr(obj, self.autocomplete_fieldname)
|
|
||||||
|
|
||||||
def autocomplete_value(self, obj):
|
|
||||||
return obj.uuid
|
|
||||||
|
|
||||||
def get_autocomplete_data(self, term):
|
|
||||||
query = self.make_autocomplete_query(term)
|
|
||||||
return query.all()
|
|
||||||
|
|
||||||
def make_autocomplete_query(self, term):
|
|
||||||
model_class = self.get_model_class()
|
|
||||||
query = self.Session.query(model_class)
|
|
||||||
query = self.filter_autocomplete_query(query)
|
|
||||||
|
|
||||||
field = getattr(model_class, self.autocomplete_fieldname)
|
|
||||||
query = query.filter(field.ilike('%%%s%%' % term))\
|
|
||||||
.order_by(field)
|
|
||||||
|
|
||||||
return query
|
|
||||||
|
|
||||||
def filter_autocomplete_query(self, query):
|
|
||||||
return query
|
|
||||||
|
|
||||||
def prepare_autocomplete_term(self, term):
|
|
||||||
"""
|
|
||||||
If necessary, massage the incoming search term for use with the
|
|
||||||
autocomplete query.
|
|
||||||
"""
|
|
||||||
return term
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def defaults(cls, config):
|
|
||||||
cls._defaults(config)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _defaults(cls, config):
|
|
||||||
route_prefix = cls.get_route_prefix()
|
|
||||||
permission_prefix = cls.get_permission_prefix()
|
|
||||||
collection_url_prefix = cls.get_collection_url_prefix()
|
|
||||||
object_url_prefix = cls.get_object_url_prefix()
|
|
||||||
|
|
||||||
# first, the primary resource API
|
|
||||||
|
|
||||||
# list/search
|
|
||||||
if cls.listable:
|
|
||||||
cls.establish_method('collection_get')
|
|
||||||
resource.add_view(cls.collection_get, permission='{}.list'.format(permission_prefix))
|
|
||||||
|
|
||||||
# create
|
|
||||||
if cls.creatable:
|
|
||||||
cls.establish_method('collection_post')
|
|
||||||
if hasattr(cls, 'permission_to_create'):
|
|
||||||
permission = cls.permission_to_create
|
|
||||||
else:
|
|
||||||
permission = '{}.create'.format(permission_prefix)
|
|
||||||
resource.add_view(cls.collection_post, permission=permission)
|
|
||||||
|
|
||||||
# view
|
|
||||||
if cls.viewable:
|
|
||||||
cls.establish_method('get')
|
|
||||||
resource.add_view(cls.get, permission='{}.view'.format(permission_prefix))
|
|
||||||
|
|
||||||
# edit
|
|
||||||
if cls.editable:
|
|
||||||
cls.establish_method('post')
|
|
||||||
resource.add_view(cls.post, permission='{}.edit'.format(permission_prefix))
|
|
||||||
|
|
||||||
# delete
|
|
||||||
if cls.deletable:
|
|
||||||
cls.establish_method('delete')
|
|
||||||
resource.add_view(cls.delete, permission='{}.delete'.format(permission_prefix))
|
|
||||||
|
|
||||||
# register primary resource API via cornice
|
|
||||||
object_resource = resource.add_resource(
|
|
||||||
cls,
|
|
||||||
collection_path=collection_url_prefix,
|
|
||||||
# TODO: probably should allow for other (composite?) key fields
|
|
||||||
path='{}/{{uuid}}'.format(object_url_prefix))
|
|
||||||
config.add_cornice_resource(object_resource)
|
|
||||||
|
|
||||||
# now for some more "custom" things, which are still somewhat generic
|
|
||||||
|
|
||||||
# autocomplete
|
|
||||||
if cls.supports_autocomplete:
|
|
||||||
autocomplete = Service(name='{}.autocomplete'.format(route_prefix),
|
|
||||||
path='{}/autocomplete'.format(collection_url_prefix))
|
|
||||||
autocomplete.add_view('GET', 'autocomplete', klass=cls,
|
|
||||||
permission='{}.list'.format(permission_prefix))
|
|
||||||
config.add_cornice_service(autocomplete)
|
|
||||||
|
|
||||||
# download
|
|
||||||
if cls.supports_download:
|
|
||||||
download = Service(name='{}.download'.format(route_prefix),
|
|
||||||
# TODO: probably should allow for other (composite?) key fields
|
|
||||||
path='{}/{{uuid}}/download'.format(object_url_prefix))
|
|
||||||
download.add_view('GET', 'download', klass=cls,
|
|
||||||
permission='{}.download'.format(permission_prefix))
|
|
||||||
config.add_cornice_service(download)
|
|
||||||
|
|
||||||
# rawbytes
|
|
||||||
if cls.supports_rawbytes:
|
|
||||||
rawbytes = Service(name='{}.rawbytes'.format(route_prefix),
|
|
||||||
# TODO: probably should allow for other (composite?) key fields
|
|
||||||
path='{}/{{uuid}}/rawbytes'.format(object_url_prefix))
|
|
||||||
rawbytes.add_view('GET', 'rawbytes', klass=cls,
|
|
||||||
permission='{}.download'.format(permission_prefix))
|
|
||||||
config.add_cornice_service(rawbytes)
|
|
|
@ -1,43 +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/>.
|
|
||||||
#
|
|
||||||
################################################################################
|
|
||||||
"""
|
|
||||||
Tailbone Web API - Master View (v2)
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import unicode_literals, absolute_import
|
|
||||||
|
|
||||||
import warnings
|
|
||||||
|
|
||||||
from tailbone.api import APIMasterView
|
|
||||||
|
|
||||||
|
|
||||||
class APIMasterView2(APIMasterView):
|
|
||||||
"""
|
|
||||||
Base class for data model REST API views.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, request, context=None):
|
|
||||||
warnings.warn("APIMasterView2 class is deprecated; please use "
|
|
||||||
"APIMasterView instead",
|
|
||||||
DeprecationWarning, stacklevel=2)
|
|
||||||
super(APIMasterView2, self).__init__(request, context=context)
|
|
|
@ -1,59 +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 Web API - Person Views
|
|
||||||
"""
|
|
||||||
|
|
||||||
from rattail.db import model
|
|
||||||
|
|
||||||
from tailbone.api import APIMasterView
|
|
||||||
|
|
||||||
|
|
||||||
class PersonView(APIMasterView):
|
|
||||||
"""
|
|
||||||
API views for Person data
|
|
||||||
"""
|
|
||||||
model_class = model.Person
|
|
||||||
permission_prefix = 'people'
|
|
||||||
collection_url_prefix = '/people'
|
|
||||||
object_url_prefix = '/person'
|
|
||||||
|
|
||||||
def normalize(self, person):
|
|
||||||
return {
|
|
||||||
'uuid': person.uuid,
|
|
||||||
'_str': str(person),
|
|
||||||
'first_name': person.first_name,
|
|
||||||
'last_name': person.last_name,
|
|
||||||
'display_name': person.display_name,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def defaults(config, **kwargs):
|
|
||||||
base = globals()
|
|
||||||
|
|
||||||
PersonView = kwargs.get('PersonView', base['PersonView'])
|
|
||||||
PersonView.defaults(config)
|
|
||||||
|
|
||||||
|
|
||||||
def includeme(config):
|
|
||||||
defaults(config)
|
|
|
@ -1,220 +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/>.
|
|
||||||
#
|
|
||||||
################################################################################
|
|
||||||
"""
|
|
||||||
Tailbone Web API - Product Views
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from sqlalchemy import orm
|
|
||||||
|
|
||||||
from cornice import Service
|
|
||||||
|
|
||||||
from rattail.db import model
|
|
||||||
|
|
||||||
from tailbone.api import APIMasterView
|
|
||||||
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class ProductView(APIMasterView):
|
|
||||||
"""
|
|
||||||
API views for Product data
|
|
||||||
"""
|
|
||||||
model_class = model.Product
|
|
||||||
collection_url_prefix = '/products'
|
|
||||||
object_url_prefix = '/product'
|
|
||||||
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):
|
|
||||||
|
|
||||||
# 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
|
|
||||||
data.update({
|
|
||||||
'upc': str(product.upc),
|
|
||||||
'scancode': product.scancode,
|
|
||||||
'item_id': product.item_id,
|
|
||||||
'item_type': product.item_type,
|
|
||||||
'status_code': product.status_code,
|
|
||||||
'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,
|
|
||||||
})
|
|
||||||
|
|
||||||
return data
|
|
||||||
|
|
||||||
def make_autocomplete_query(self, term):
|
|
||||||
query = self.Session.query(model.Product)\
|
|
||||||
.outerjoin(model.Brand)\
|
|
||||||
.filter(sa.or_(
|
|
||||||
model.Brand.name.ilike('%{}%'.format(term)),
|
|
||||||
model.Product.description.ilike('%{}%'.format(term))))
|
|
||||||
|
|
||||||
if not self.request.has_perm('products.view_deleted'):
|
|
||||||
query = query.filter(model.Product.deleted == False)
|
|
||||||
|
|
||||||
query = query.order_by(model.Brand.name,
|
|
||||||
model.Product.description)\
|
|
||||||
.options(orm.joinedload(model.Product.brand))
|
|
||||||
return query
|
|
||||||
|
|
||||||
def autocomplete_display(self, product):
|
|
||||||
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):
|
|
||||||
base = globals()
|
|
||||||
|
|
||||||
ProductView = kwargs.get('ProductView', base['ProductView'])
|
|
||||||
ProductView.defaults(config)
|
|
||||||
|
|
||||||
|
|
||||||
def includeme(config):
|
|
||||||
defaults(config)
|
|
|
@ -1,64 +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 Web API - Upgrade Views
|
|
||||||
"""
|
|
||||||
|
|
||||||
from rattail.db import model
|
|
||||||
|
|
||||||
from tailbone.api import APIMasterView
|
|
||||||
|
|
||||||
|
|
||||||
class UpgradeView(APIMasterView):
|
|
||||||
"""
|
|
||||||
REST API views for Upgrade model.
|
|
||||||
"""
|
|
||||||
model_class = model.Upgrade
|
|
||||||
collection_url_prefix = '/upgrades'
|
|
||||||
object_url_prefix = '/upgrades'
|
|
||||||
|
|
||||||
def normalize(self, upgrade):
|
|
||||||
data = {
|
|
||||||
'created': upgrade.created.isoformat(),
|
|
||||||
'description': upgrade.description,
|
|
||||||
'enabled': upgrade.enabled,
|
|
||||||
'executed': upgrade.executed.isoformat() if upgrade.executed else None,
|
|
||||||
# 'executed_by':
|
|
||||||
}
|
|
||||||
if upgrade.status_code is None:
|
|
||||||
data['status_code'] = None
|
|
||||||
else:
|
|
||||||
data['status_code'] = self.enum.UPGRADE_STATUS.get(upgrade.status_code,
|
|
||||||
str(upgrade.status_code))
|
|
||||||
return data
|
|
||||||
|
|
||||||
|
|
||||||
def defaults(config, **kwargs):
|
|
||||||
base = globals()
|
|
||||||
|
|
||||||
UpgradeView = kwargs.get('UpgradeView', base['UpgradeView'])
|
|
||||||
UpgradeView.defaults(config)
|
|
||||||
|
|
||||||
|
|
||||||
def includeme(config):
|
|
||||||
defaults(config)
|
|
|
@ -1,71 +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/>.
|
|
||||||
#
|
|
||||||
################################################################################
|
|
||||||
"""
|
|
||||||
Tailbone Web API - User Views
|
|
||||||
"""
|
|
||||||
|
|
||||||
from rattail.db import model
|
|
||||||
|
|
||||||
from tailbone.api import APIMasterView
|
|
||||||
|
|
||||||
|
|
||||||
class UserView(APIMasterView):
|
|
||||||
"""
|
|
||||||
API views for User data
|
|
||||||
"""
|
|
||||||
model_class = model.User
|
|
||||||
collection_url_prefix = '/users'
|
|
||||||
object_url_prefix = '/user'
|
|
||||||
|
|
||||||
def normalize(self, user):
|
|
||||||
return {
|
|
||||||
'uuid': user.uuid,
|
|
||||||
'username': user.username,
|
|
||||||
'person_display_name': (user.person.display_name or '') if user.person else '',
|
|
||||||
'active': user.active,
|
|
||||||
}
|
|
||||||
|
|
||||||
def interpret_sortcol(self, order_by):
|
|
||||||
if order_by == 'person_display_name':
|
|
||||||
return self.sortcol('Person', 'display_name')
|
|
||||||
return self.sortcol(order_by)
|
|
||||||
|
|
||||||
def join_for_sort_model(self, query, model_name):
|
|
||||||
if model_name == 'Person':
|
|
||||||
query = query.outerjoin(model.Person)
|
|
||||||
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):
|
|
||||||
base = globals()
|
|
||||||
|
|
||||||
UserView = kwargs.get('UserView', base['UserView'])
|
|
||||||
UserView.defaults(config)
|
|
||||||
|
|
||||||
|
|
||||||
def includeme(config):
|
|
||||||
defaults(config)
|
|
|
@ -1,57 +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 Web API - Vendor Views
|
|
||||||
"""
|
|
||||||
|
|
||||||
from rattail.db import model
|
|
||||||
|
|
||||||
from tailbone.api import APIMasterView
|
|
||||||
|
|
||||||
|
|
||||||
class VendorView(APIMasterView):
|
|
||||||
|
|
||||||
model_class = model.Vendor
|
|
||||||
collection_url_prefix = '/vendors'
|
|
||||||
object_url_prefix = '/vendor'
|
|
||||||
supports_autocomplete = True
|
|
||||||
autocomplete_fieldname = 'name'
|
|
||||||
|
|
||||||
def normalize(self, vendor):
|
|
||||||
return {
|
|
||||||
'uuid': vendor.uuid,
|
|
||||||
'_str': str(vendor),
|
|
||||||
'id': vendor.id,
|
|
||||||
'name': vendor.name,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def defaults(config, **kwargs):
|
|
||||||
base = globals()
|
|
||||||
|
|
||||||
VendorView = kwargs.get('VendorView', base['VendorView'])
|
|
||||||
VendorView.defaults(config)
|
|
||||||
|
|
||||||
|
|
||||||
def includeme(config):
|
|
||||||
defaults(config)
|
|
|
@ -1,234 +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 Web API - Work Order Views
|
|
||||||
"""
|
|
||||||
|
|
||||||
import datetime
|
|
||||||
|
|
||||||
from rattail.db.model import WorkOrder
|
|
||||||
|
|
||||||
from cornice import Service
|
|
||||||
|
|
||||||
from tailbone.api import APIMasterView
|
|
||||||
|
|
||||||
|
|
||||||
class WorkOrderView(APIMasterView):
|
|
||||||
|
|
||||||
model_class = WorkOrder
|
|
||||||
collection_url_prefix = '/workorders'
|
|
||||||
object_url_prefix = '/workorder'
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
app = self.get_rattail_app()
|
|
||||||
self.workorder_handler = app.get_workorder_handler()
|
|
||||||
|
|
||||||
def normalize(self, workorder):
|
|
||||||
data = super().normalize(workorder)
|
|
||||||
data.update({
|
|
||||||
'customer_name': workorder.customer.name,
|
|
||||||
'status_label': self.enum.WORKORDER_STATUS[workorder.status_code],
|
|
||||||
'date_submitted': str(workorder.date_submitted or ''),
|
|
||||||
'date_received': str(workorder.date_received or ''),
|
|
||||||
'date_released': str(workorder.date_released or ''),
|
|
||||||
'date_delivered': str(workorder.date_delivered or ''),
|
|
||||||
})
|
|
||||||
return data
|
|
||||||
|
|
||||||
def create_object(self, data):
|
|
||||||
|
|
||||||
# invoke the handler instead of normal API CRUD logic
|
|
||||||
workorder = self.workorder_handler.make_workorder(self.Session(), **data)
|
|
||||||
return workorder
|
|
||||||
|
|
||||||
def update_object(self, workorder, data):
|
|
||||||
date_fields = [
|
|
||||||
'date_submitted',
|
|
||||||
'date_received',
|
|
||||||
'date_released',
|
|
||||||
'date_delivered',
|
|
||||||
]
|
|
||||||
|
|
||||||
# coerce date field values to proper datetime.date objects
|
|
||||||
for field in date_fields:
|
|
||||||
if field in data:
|
|
||||||
if data[field] == '':
|
|
||||||
data[field] = None
|
|
||||||
elif not isinstance(data[field], datetime.date):
|
|
||||||
date = datetime.datetime.strptime(data[field], '%Y-%m-%d').date()
|
|
||||||
data[field] = date
|
|
||||||
|
|
||||||
# coerce status code value to proper integer
|
|
||||||
if 'status_code' in data:
|
|
||||||
data['status_code'] = int(data['status_code'])
|
|
||||||
|
|
||||||
return super().update_object(workorder, data)
|
|
||||||
|
|
||||||
def status_codes(self):
|
|
||||||
"""
|
|
||||||
Retrieve all info about possible work order status codes.
|
|
||||||
"""
|
|
||||||
return self.workorder_handler.status_codes()
|
|
||||||
|
|
||||||
def receive(self):
|
|
||||||
"""
|
|
||||||
Sets work order status to "received".
|
|
||||||
"""
|
|
||||||
workorder = self.get_object()
|
|
||||||
self.workorder_handler.receive(workorder)
|
|
||||||
self.Session.flush()
|
|
||||||
return self.normalize(workorder)
|
|
||||||
|
|
||||||
def await_estimate(self):
|
|
||||||
"""
|
|
||||||
Sets work order status to "awaiting estimate confirmation".
|
|
||||||
"""
|
|
||||||
workorder = self.get_object()
|
|
||||||
self.workorder_handler.await_estimate(workorder)
|
|
||||||
self.Session.flush()
|
|
||||||
return self.normalize(workorder)
|
|
||||||
|
|
||||||
def await_parts(self):
|
|
||||||
"""
|
|
||||||
Sets work order status to "awaiting parts".
|
|
||||||
"""
|
|
||||||
workorder = self.get_object()
|
|
||||||
self.workorder_handler.await_parts(workorder)
|
|
||||||
self.Session.flush()
|
|
||||||
return self.normalize(workorder)
|
|
||||||
|
|
||||||
def work_on_it(self):
|
|
||||||
"""
|
|
||||||
Sets work order status to "working on it".
|
|
||||||
"""
|
|
||||||
workorder = self.get_object()
|
|
||||||
self.workorder_handler.work_on_it(workorder)
|
|
||||||
self.Session.flush()
|
|
||||||
return self.normalize(workorder)
|
|
||||||
|
|
||||||
def release(self):
|
|
||||||
"""
|
|
||||||
Sets work order status to "released".
|
|
||||||
"""
|
|
||||||
workorder = self.get_object()
|
|
||||||
self.workorder_handler.release(workorder)
|
|
||||||
self.Session.flush()
|
|
||||||
return self.normalize(workorder)
|
|
||||||
|
|
||||||
def deliver(self):
|
|
||||||
"""
|
|
||||||
Sets work order status to "delivered".
|
|
||||||
"""
|
|
||||||
workorder = self.get_object()
|
|
||||||
self.workorder_handler.deliver(workorder)
|
|
||||||
self.Session.flush()
|
|
||||||
return self.normalize(workorder)
|
|
||||||
|
|
||||||
def cancel(self):
|
|
||||||
"""
|
|
||||||
Sets work order status to "canceled".
|
|
||||||
"""
|
|
||||||
workorder = self.get_object()
|
|
||||||
self.workorder_handler.cancel(workorder)
|
|
||||||
self.Session.flush()
|
|
||||||
return self.normalize(workorder)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def defaults(cls, config):
|
|
||||||
cls._defaults(config)
|
|
||||||
cls._workorder_defaults(config)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _workorder_defaults(cls, config):
|
|
||||||
route_prefix = cls.get_route_prefix()
|
|
||||||
permission_prefix = cls.get_permission_prefix()
|
|
||||||
collection_url_prefix = cls.get_collection_url_prefix()
|
|
||||||
object_url_prefix = cls.get_object_url_prefix()
|
|
||||||
|
|
||||||
# status codes
|
|
||||||
status_codes = Service(name='{}.status_codes'.format(route_prefix),
|
|
||||||
path='{}/status-codes'.format(collection_url_prefix))
|
|
||||||
status_codes.add_view('GET', 'status_codes', klass=cls,
|
|
||||||
permission='{}.list'.format(permission_prefix))
|
|
||||||
config.add_cornice_service(status_codes)
|
|
||||||
|
|
||||||
# receive
|
|
||||||
receive = Service(name='{}.receive'.format(route_prefix),
|
|
||||||
path='{}/{{uuid}}/receive'.format(object_url_prefix))
|
|
||||||
receive.add_view('POST', 'receive', klass=cls,
|
|
||||||
permission='{}.edit'.format(permission_prefix))
|
|
||||||
config.add_cornice_service(receive)
|
|
||||||
|
|
||||||
# await estimate confirmation
|
|
||||||
await_estimate = Service(name='{}.await_estimate'.format(route_prefix),
|
|
||||||
path='{}/{{uuid}}/await-estimate'.format(object_url_prefix))
|
|
||||||
await_estimate.add_view('POST', 'await_estimate', klass=cls,
|
|
||||||
permission='{}.edit'.format(permission_prefix))
|
|
||||||
config.add_cornice_service(await_estimate)
|
|
||||||
|
|
||||||
# await parts
|
|
||||||
await_parts = Service(name='{}.await_parts'.format(route_prefix),
|
|
||||||
path='{}/{{uuid}}/await-parts'.format(object_url_prefix))
|
|
||||||
await_parts.add_view('POST', 'await_parts', klass=cls,
|
|
||||||
permission='{}.edit'.format(permission_prefix))
|
|
||||||
config.add_cornice_service(await_parts)
|
|
||||||
|
|
||||||
# work on it
|
|
||||||
work_on_it = Service(name='{}.work_on_it'.format(route_prefix),
|
|
||||||
path='{}/{{uuid}}/work-on-it'.format(object_url_prefix))
|
|
||||||
work_on_it.add_view('POST', 'work_on_it', klass=cls,
|
|
||||||
permission='{}.edit'.format(permission_prefix))
|
|
||||||
config.add_cornice_service(work_on_it)
|
|
||||||
|
|
||||||
# release
|
|
||||||
release = Service(name='{}.release'.format(route_prefix),
|
|
||||||
path='{}/{{uuid}}/release'.format(object_url_prefix))
|
|
||||||
release.add_view('POST', 'release', klass=cls,
|
|
||||||
permission='{}.edit'.format(permission_prefix))
|
|
||||||
config.add_cornice_service(release)
|
|
||||||
|
|
||||||
# deliver
|
|
||||||
deliver = Service(name='{}.deliver'.format(route_prefix),
|
|
||||||
path='{}/{{uuid}}/deliver'.format(object_url_prefix))
|
|
||||||
deliver.add_view('POST', 'deliver', klass=cls,
|
|
||||||
permission='{}.edit'.format(permission_prefix))
|
|
||||||
config.add_cornice_service(deliver)
|
|
||||||
|
|
||||||
# cancel
|
|
||||||
cancel = Service(name='{}.cancel'.format(route_prefix),
|
|
||||||
path='{}/{{uuid}}/cancel'.format(object_url_prefix))
|
|
||||||
cancel.add_view('POST', 'cancel', klass=cls,
|
|
||||||
permission='{}.edit'.format(permission_prefix))
|
|
||||||
config.add_cornice_service(cancel)
|
|
||||||
|
|
||||||
|
|
||||||
def defaults(config, **kwargs):
|
|
||||||
base = globals()
|
|
||||||
|
|
||||||
WorkOrderView = kwargs.get('WorkOrderView', base['WorkOrderView'])
|
|
||||||
WorkOrderView.defaults(config)
|
|
||||||
|
|
||||||
|
|
||||||
def includeme(config):
|
|
||||||
defaults(config)
|
|
297
tailbone/app.py
297
tailbone/app.py
|
@ -1,46 +1,51 @@
|
||||||
# -*- 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.
|
||||||
#
|
#
|
||||||
# Rattail is free software: you can redistribute it and/or modify it under the
|
# 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
|
# terms of the GNU Affero General Public License as published by the Free
|
||||||
# Foundation, either version 3 of the License, or (at your option) any later
|
# Software Foundation, either version 3 of the License, or (at your option)
|
||||||
# version.
|
# any later version.
|
||||||
#
|
#
|
||||||
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
|
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||||
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
||||||
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
|
# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for
|
||||||
# details.
|
# more details.
|
||||||
#
|
#
|
||||||
# You should have received a copy of the GNU General Public License along with
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
# Rattail. If not, see <http://www.gnu.org/licenses/>.
|
# along with Rattail. If not, see <http://www.gnu.org/licenses/>.
|
||||||
#
|
#
|
||||||
################################################################################
|
################################################################################
|
||||||
"""
|
"""
|
||||||
Application Entry Point
|
Application Entry Point
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from __future__ import unicode_literals, absolute_import
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import warnings
|
||||||
|
|
||||||
from sqlalchemy.orm import sessionmaker, scoped_session
|
import sqlalchemy as sa
|
||||||
|
|
||||||
from wuttjamaican.util import parse_list
|
|
||||||
|
|
||||||
|
import rattail.db
|
||||||
from rattail.config import make_config
|
from rattail.config import make_config
|
||||||
from rattail.exceptions import ConfigurationError
|
from rattail.exceptions import ConfigurationError
|
||||||
|
from rattail.db.util import get_engines
|
||||||
|
from rattail.db.continuum import configure_versioning
|
||||||
|
from rattail.db.types import GPCType
|
||||||
|
|
||||||
|
import formalchemy as fa
|
||||||
from pyramid.config import Configurator
|
from pyramid.config import Configurator
|
||||||
from zope.sqlalchemy import register
|
from pyramid.authentication import SessionAuthenticationPolicy
|
||||||
|
|
||||||
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.forms import renderers
|
||||||
from tailbone.util import get_effective_theme, get_theme_template_path
|
from tailbone.forms.alchemy import TemplateEngine
|
||||||
from tailbone.providers import get_all_providers
|
|
||||||
|
|
||||||
|
|
||||||
def make_rattail_config(settings):
|
def make_rattail_config(settings):
|
||||||
|
@ -54,37 +59,43 @@ def make_rattail_config(settings):
|
||||||
# available for web requests later
|
# available for web requests later
|
||||||
path = settings.get('rattail.config')
|
path = settings.get('rattail.config')
|
||||||
if not path or not os.path.exists(path):
|
if not path or not os.path.exists(path):
|
||||||
raise ConfigurationError("Please set 'rattail.config' in [app:main] section of config "
|
path = settings.get('edbob.config')
|
||||||
"to the path of your config file. Lame, but necessary.")
|
if not path or not os.path.exists(path):
|
||||||
|
raise ConfigurationError("Please set 'rattail.config' in [app:main] section of config "
|
||||||
|
"to the path of your config file. Lame, but necessary.")
|
||||||
|
warnings.warn("[app:main] setting 'edbob.config' is deprecated; "
|
||||||
|
"please use 'rattail.config' setting instead",
|
||||||
|
DeprecationWarning)
|
||||||
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
|
rattail_engines = settings.get('rattail_engines')
|
||||||
settings['wutta_config'] = rattail_config
|
if not rattail_engines:
|
||||||
|
|
||||||
# configure database sessions
|
# Load all Rattail database engines from config, and store in settings
|
||||||
if hasattr(rattail_config, 'appdb_engine'):
|
# dict. This is necessary e.g. in the case of a host server, to have
|
||||||
tailbone.db.Session.configure(bind=rattail_config.appdb_engine)
|
# access to its subordinate store servers.
|
||||||
if hasattr(rattail_config, 'trainwreck_engine'):
|
rattail_engines = get_engines(rattail_config)
|
||||||
tailbone.db.TrainwreckSession.configure(bind=rattail_config.trainwreck_engine)
|
settings['rattail_engines'] = rattail_engines
|
||||||
|
|
||||||
|
# Configure the database session classes. Note that most of the time we'll
|
||||||
|
# be using the Tailbone Session, but occasionally (e.g. within batch
|
||||||
|
# processing threads) we want the Rattail Session. The reason is that
|
||||||
|
# during normal request processing, the Tailbone Session is preferable as
|
||||||
|
# it includes Zope Transaction magic. Within an explicitly-spawned thread
|
||||||
|
# however, this is *not* desirable.
|
||||||
|
rattail.db.Session.configure(bind=rattail_engines['default'])
|
||||||
|
tailbone.db.Session.configure(bind=rattail_engines['default'])
|
||||||
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
|
|
||||||
for key, engine in rattail_config.trainwreck_engines.items():
|
|
||||||
if key != 'default':
|
|
||||||
Session = scoped_session(sessionmaker(bind=engine))
|
|
||||||
register(Session)
|
|
||||||
tailbone.db.ExtraTrainwreckSessions[key] = Session
|
|
||||||
|
|
||||||
# Make sure rattail config object uses our scoped session, to avoid
|
# Make sure rattail config object uses our scoped session, to avoid
|
||||||
# unnecessary connections (and pooling limits).
|
# unnecessary connections (and pooling limits).
|
||||||
rattail_config._session_factory = lambda: (tailbone.db.Session(), False)
|
rattail_config._session_factory = lambda: (tailbone.db.Session(), False)
|
||||||
|
|
||||||
|
# Configure (or not) Continuum versioning.
|
||||||
|
configure_versioning(rattail_config)
|
||||||
return rattail_config
|
return rattail_config
|
||||||
|
|
||||||
|
|
||||||
|
@ -94,12 +105,7 @@ def provide_postgresql_settings(settings):
|
||||||
this enables retrying transactions a second time, in an attempt to
|
this enables retrying transactions a second time, in an attempt to
|
||||||
gracefully handle database restarts.
|
gracefully handle database restarts.
|
||||||
"""
|
"""
|
||||||
try:
|
settings.setdefault('tm.attempts', 2)
|
||||||
import pyramid_retry
|
|
||||||
except ImportError:
|
|
||||||
settings.setdefault('tm.attempts', 2)
|
|
||||||
else:
|
|
||||||
settings.setdefault('retry.attempts', 2)
|
|
||||||
|
|
||||||
|
|
||||||
class Root(dict):
|
class Root(dict):
|
||||||
|
@ -113,201 +119,47 @@ class Root(dict):
|
||||||
self.request = request
|
self.request = request
|
||||||
|
|
||||||
|
|
||||||
def make_pyramid_config(settings, configure_csrf=True):
|
def make_pyramid_config(settings):
|
||||||
"""
|
"""
|
||||||
Make a Pyramid config object from the given settings.
|
Make a Pyramid config object from the given settings.
|
||||||
"""
|
"""
|
||||||
rattail_config = settings['rattail_config']
|
config = Configurator(settings=settings, root_factory=Root)
|
||||||
|
|
||||||
config = settings.pop('pyramid_config', None)
|
# Configure user authentication / authorization.
|
||||||
if config:
|
config.set_authentication_policy(SessionAuthenticationPolicy())
|
||||||
config.set_root_factory(Root)
|
config.set_authorization_policy(TailboneAuthorizationPolicy())
|
||||||
else:
|
|
||||||
|
|
||||||
# declare this web app of the "classic" variety
|
# always require CSRF token protection
|
||||||
settings.setdefault('tailbone.classic', 'true')
|
config.set_default_csrf_options(require_csrf=True, token='_csrf')
|
||||||
|
|
||||||
# we want the new themes feature!
|
|
||||||
establish_theme(settings)
|
|
||||||
|
|
||||||
settings.setdefault('fanstatic.versioning', 'true')
|
|
||||||
settings.setdefault('pyramid_deform.template_search_path', 'tailbone:templates/deform')
|
|
||||||
config = Configurator(settings=settings, root_factory=Root)
|
|
||||||
|
|
||||||
# add rattail config directly to registry, for access throughout the app
|
|
||||||
config.registry['rattail_config'] = rattail_config
|
|
||||||
|
|
||||||
# configure user authorization / authentication
|
|
||||||
config.set_security_policy(TailboneSecurityPolicy())
|
|
||||||
|
|
||||||
# maybe require CSRF token protection
|
|
||||||
if configure_csrf:
|
|
||||||
config.set_default_csrf_options(require_csrf=True,
|
|
||||||
token=csrf_token_name(rattail_config),
|
|
||||||
header=csrf_header_name(rattail_config))
|
|
||||||
|
|
||||||
# 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_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
|
# Add some permissions magic.
|
||||||
# deform resources for component JS? cf. also base.mako template
|
config.add_directive('add_tailbone_permission_group', 'tailbone.auth.add_permission_group')
|
||||||
# # override default script mapping for deform
|
config.add_directive('add_tailbone_permission', 'tailbone.auth.add_permission')
|
||||||
# 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
|
# TODO: This can finally be removed once all CRUD/index views have been
|
||||||
# TODO: pretty soon we can require this package, hopefully..
|
# converted to use the new master view etc.
|
||||||
try:
|
for label, perms in settings.get('edbob.permissions', []):
|
||||||
import pyramid_retry
|
groupkey = label.lower().replace(' ', '_')
|
||||||
except ImportError:
|
config.add_tailbone_permission_group(groupkey, label)
|
||||||
pass
|
for key, label in perms:
|
||||||
else:
|
config.add_tailbone_permission(groupkey, key, label)
|
||||||
config.include('pyramid_retry')
|
|
||||||
|
|
||||||
# fetch all tailbone providers
|
# Configure FormAlchemy.
|
||||||
providers = get_all_providers(rattail_config)
|
fa.config.engine = TemplateEngine()
|
||||||
for provider in providers.values():
|
fa.FieldSet.default_renderers[sa.Boolean] = renderers.YesNoFieldRenderer
|
||||||
|
fa.FieldSet.default_renderers[sa.Date] = renderers.DateFieldRenderer
|
||||||
# configure DB sessions associated with transaction manager
|
fa.FieldSet.default_renderers[sa.DateTime] = renderers.DateTimeFieldRenderer
|
||||||
provider.configure_db_sessions(rattail_config, config)
|
fa.FieldSet.default_renderers[sa.Time] = renderers.TimeFieldRenderer
|
||||||
|
fa.FieldSet.default_renderers[GPCType] = renderers.GPCFieldRenderer
|
||||||
# add any static includes
|
|
||||||
includes = provider.get_static_includes()
|
|
||||||
if includes:
|
|
||||||
for spec in includes:
|
|
||||||
config.include(spec)
|
|
||||||
|
|
||||||
# add some permissions magic
|
|
||||||
config.add_directive('add_wutta_permission_group',
|
|
||||||
'wuttaweb.auth.add_permission_group')
|
|
||||||
config.add_directive('add_wutta_permission',
|
|
||||||
'wuttaweb.auth.add_permission')
|
|
||||||
# TODO: deprecate / remove these
|
|
||||||
config.add_directive('add_tailbone_permission_group',
|
|
||||||
'wuttaweb.auth.add_permission_group')
|
|
||||||
config.add_directive('add_tailbone_permission',
|
|
||||||
'wuttaweb.auth.add_permission')
|
|
||||||
|
|
||||||
# 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_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')
|
|
||||||
|
|
||||||
return config
|
return config
|
||||||
|
|
||||||
|
|
||||||
def add_websocket(config, name, view, attr=None):
|
|
||||||
"""
|
|
||||||
Register a websocket entry point for the app.
|
|
||||||
"""
|
|
||||||
def action():
|
|
||||||
rattail_config = config.registry.settings['rattail_config']
|
|
||||||
rattail_app = rattail_config.get_app()
|
|
||||||
|
|
||||||
if isinstance(view, str):
|
|
||||||
view_callable = rattail_app.load_object(view)
|
|
||||||
else:
|
|
||||||
view_callable = view
|
|
||||||
view_callable = view_callable(config)
|
|
||||||
if attr:
|
|
||||||
view_callable = getattr(view_callable, attr)
|
|
||||||
|
|
||||||
# register route
|
|
||||||
path = '/ws/{}'.format(name)
|
|
||||||
route_name = 'ws.{}'.format(name)
|
|
||||||
config.add_route(route_name, path, static=True)
|
|
||||||
|
|
||||||
# register view callable
|
|
||||||
websockets = config.registry.setdefault('tailbone_websockets', {})
|
|
||||||
websockets[path] = view_callable
|
|
||||||
|
|
||||||
config.action('tailbone-add-websocket-{}'.format(name), action,
|
|
||||||
# nb. since this action adds routes, it must happen
|
|
||||||
# sooner in the order than it normally would, hence
|
|
||||||
# we declare that
|
|
||||||
order=-20)
|
|
||||||
|
|
||||||
|
|
||||||
def add_index_page(config, route_name, label, permission):
|
|
||||||
"""
|
|
||||||
Register a config page for the app.
|
|
||||||
"""
|
|
||||||
def action():
|
|
||||||
pages = config.get_settings().get('tailbone_index_pages', [])
|
|
||||||
pages.append({'label': label, 'route': route_name,
|
|
||||||
'permission': permission})
|
|
||||||
config.add_settings({'tailbone_index_pages': pages})
|
|
||||||
config.action(None, action)
|
|
||||||
|
|
||||||
|
|
||||||
def add_config_page(config, route_name, label, permission):
|
|
||||||
"""
|
|
||||||
Register a config page for the app.
|
|
||||||
"""
|
|
||||||
def action():
|
|
||||||
pages = config.get_settings().get('tailbone_config_pages', [])
|
|
||||||
pages.append({'label': label, 'route': route_name,
|
|
||||||
'permission': permission})
|
|
||||||
config.add_settings({'tailbone_config_pages': pages})
|
|
||||||
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):
|
|
||||||
rattail_config = settings['rattail_config']
|
|
||||||
|
|
||||||
theme = get_effective_theme(rattail_config)
|
|
||||||
settings['tailbone.theme'] = theme
|
|
||||||
|
|
||||||
directories = settings['mako.directories']
|
|
||||||
if isinstance(directories, str):
|
|
||||||
directories = parse_list(directories)
|
|
||||||
|
|
||||||
path = get_theme_template_path(rattail_config)
|
|
||||||
directories.insert(0, path)
|
|
||||||
settings['mako.directories'] = directories
|
|
||||||
|
|
||||||
|
|
||||||
def configure_postgresql(pyramid_config):
|
def configure_postgresql(pyramid_config):
|
||||||
"""
|
"""
|
||||||
Add some PostgreSQL-specific tweaks to the final app config. Specifically,
|
Add some PostgreSQL-specific tweaks to the final app config. Specifically,
|
||||||
|
@ -321,8 +173,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')
|
||||||
|
|
110
tailbone/asgi.py
110
tailbone/asgi.py
|
@ -1,110 +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/>.
|
|
||||||
#
|
|
||||||
################################################################################
|
|
||||||
"""
|
|
||||||
ASGI App Utilities
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import configparser
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from rattail.util import load_object
|
|
||||||
|
|
||||||
from asgiref.wsgi import WsgiToAsgi
|
|
||||||
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class TailboneWsgiToAsgi(WsgiToAsgi):
|
|
||||||
"""
|
|
||||||
Custom WSGI -> ASGI wrapper, to add routing for websockets.
|
|
||||||
"""
|
|
||||||
|
|
||||||
async def __call__(self, scope, *args, **kwargs):
|
|
||||||
protocol = scope['type']
|
|
||||||
path = scope['path']
|
|
||||||
|
|
||||||
# strip off the root path, if non-empty. needed for serving
|
|
||||||
# under /poser or anything other than true site root
|
|
||||||
root_path = scope['root_path']
|
|
||||||
if root_path and path.startswith(root_path):
|
|
||||||
path = path[len(root_path):]
|
|
||||||
|
|
||||||
if protocol == 'websocket':
|
|
||||||
websockets = self.wsgi_application.registry.get(
|
|
||||||
'tailbone_websockets', {})
|
|
||||||
if path in websockets:
|
|
||||||
await websockets[path](scope, *args, **kwargs)
|
|
||||||
|
|
||||||
try:
|
|
||||||
await super().__call__(scope, *args, **kwargs)
|
|
||||||
except ValueError as e:
|
|
||||||
# The developer may wish to improve handling of this exception.
|
|
||||||
# See https://github.com/Pylons/pyramid_cookbook/issues/225 and
|
|
||||||
# https://asgi.readthedocs.io/en/latest/specs/www.html#websocket
|
|
||||||
pass
|
|
||||||
except Exception as e:
|
|
||||||
raise e
|
|
||||||
|
|
||||||
|
|
||||||
def make_asgi_app(main_app=None):
|
|
||||||
"""
|
|
||||||
This function returns an ASGI application.
|
|
||||||
"""
|
|
||||||
path = os.environ.get('TAILBONE_ASGI_CONFIG')
|
|
||||||
if not path:
|
|
||||||
raise RuntimeError("You must define TAILBONE_ASGI_CONFIG env variable.")
|
|
||||||
|
|
||||||
# make a config parser good enough to load pyramid settings
|
|
||||||
configdir = os.path.dirname(path)
|
|
||||||
parser = configparser.ConfigParser(defaults={'__file__': path,
|
|
||||||
'here': configdir})
|
|
||||||
|
|
||||||
# read the config file
|
|
||||||
parser.read(path)
|
|
||||||
|
|
||||||
# parse the settings needed for pyramid app
|
|
||||||
settings = dict(parser.items('app:main'))
|
|
||||||
|
|
||||||
if isinstance(main_app, str):
|
|
||||||
make_wsgi_app = load_object(main_app)
|
|
||||||
elif callable(main_app):
|
|
||||||
make_wsgi_app = main_app
|
|
||||||
else:
|
|
||||||
if main_app:
|
|
||||||
log.warning("specified main app of unknown type: %s", main_app)
|
|
||||||
make_wsgi_app = load_object('tailbone.app:main')
|
|
||||||
|
|
||||||
# construct a pyramid app "per usual"
|
|
||||||
app = make_wsgi_app({}, **settings)
|
|
||||||
|
|
||||||
# then wrap it with ASGI
|
|
||||||
return TailboneWsgiToAsgi(app)
|
|
||||||
|
|
||||||
|
|
||||||
def asgi_main():
|
|
||||||
"""
|
|
||||||
This function returns an ASGI application.
|
|
||||||
"""
|
|
||||||
return make_asgi_app()
|
|
159
tailbone/auth.py
159
tailbone/auth.py
|
@ -1,88 +1,85 @@
|
||||||
# -*- 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.
|
||||||
#
|
#
|
||||||
# Rattail is free software: you can redistribute it and/or modify it under the
|
# 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
|
# terms of the GNU Affero General Public License as published by the Free
|
||||||
# Foundation, either version 3 of the License, or (at your option) any later
|
# Software Foundation, either version 3 of the License, or (at your option)
|
||||||
# version.
|
# any later version.
|
||||||
#
|
#
|
||||||
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
|
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||||
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
||||||
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
|
# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for
|
||||||
# details.
|
# more details.
|
||||||
#
|
#
|
||||||
# You should have received a copy of the GNU General Public License along with
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
# Rattail. If not, see <http://www.gnu.org/licenses/>.
|
# along with Rattail. If not, see <http://www.gnu.org/licenses/>.
|
||||||
#
|
#
|
||||||
################################################################################
|
################################################################################
|
||||||
"""
|
"""
|
||||||
Authentication & Authorization
|
Authentication & Authorization
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from __future__ import unicode_literals, absolute_import
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import re
|
|
||||||
|
|
||||||
from wuttjamaican.util import UNSPECIFIED
|
from rattail.db import model
|
||||||
|
from rattail.db.auth import has_permission
|
||||||
|
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, 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, type_='default', 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
|
|
||||||
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 = get_session_timeout_for_user(request.rattail_config, user, type_) or None
|
||||||
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
|
||||||
|
|
||||||
|
|
||||||
def logout_user(request):
|
def get_session_timeout_for_user(config, user, type_='default'):
|
||||||
"""
|
"""
|
||||||
Perform the logout action for the given request. Note that this returns a
|
Must return a value to be used to set the session timeout for the given
|
||||||
``headers`` dict which you should pass to the redirect.
|
user. By default this will return ``None`` if the user has the
|
||||||
|
"forever session" permission, otherwise will try to read a default
|
||||||
|
value from config:
|
||||||
|
|
||||||
|
.. code-block:: ini
|
||||||
|
|
||||||
|
[tailbone]
|
||||||
|
|
||||||
|
# set session timeout to 10 minutes:
|
||||||
|
session.timeout.default = 600
|
||||||
|
|
||||||
|
# or, set to 0 to disable:
|
||||||
|
#session.timeout.default = 0
|
||||||
"""
|
"""
|
||||||
app = request.rattail_config.get_app()
|
if not has_permission(Session(), user, 'general.forever_session'):
|
||||||
user = request.user
|
timeout = config.getint('tailbone', 'session.timeout.{}'.format(type_))
|
||||||
if user:
|
|
||||||
user.record_event(app.enum.USER_EVENT_LOGOUT)
|
|
||||||
request.session.delete()
|
|
||||||
request.session.invalidate()
|
|
||||||
headers = forget(request)
|
|
||||||
return headers
|
|
||||||
|
|
||||||
|
# TODO: remove this hack after no longer needed
|
||||||
|
if timeout is None and type_ == 'default':
|
||||||
|
timeout = config.getint('tailbone', 'session.default_timeout')
|
||||||
|
|
||||||
def session_timeout_for_user(config, user):
|
return timeout if timeout is not None else 300 # 5 minutes
|
||||||
"""
|
|
||||||
Returns the "max" session timeout for the user, according to roles
|
|
||||||
"""
|
|
||||||
app = config.get_app()
|
|
||||||
auth = app.get_auth_handler()
|
|
||||||
|
|
||||||
authenticated = auth.get_role_authenticated(Session())
|
|
||||||
roles = user.roles + [authenticated]
|
|
||||||
timeouts = [role.session_timeout for role in roles
|
|
||||||
if role.session_timeout is not None]
|
|
||||||
|
|
||||||
if timeouts and 0 not in timeouts:
|
|
||||||
return max(timeouts)
|
|
||||||
|
|
||||||
|
|
||||||
def set_session_timeout(request, timeout):
|
def set_session_timeout(request, timeout):
|
||||||
|
@ -92,42 +89,50 @@ 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()
|
for userid in principals:
|
||||||
super().__init__(**kwargs)
|
if userid not in (Everyone, Authenticated):
|
||||||
self.api_mode = api_mode
|
if context.request.user and context.request.user.uuid == userid:
|
||||||
|
return context.request.has_perm(permission)
|
||||||
|
else:
|
||||||
|
assert False # should no longer happen..right?
|
||||||
|
user = Session.query(model.User).get(userid)
|
||||||
|
if user:
|
||||||
|
if has_permission(Session(), user, permission):
|
||||||
|
return True
|
||||||
|
if Everyone in principals:
|
||||||
|
return has_permission(Session(), None, permission)
|
||||||
|
return False
|
||||||
|
|
||||||
def load_identity(self, request):
|
def principals_allowed_by_permission(self, context, permission):
|
||||||
config = request.registry.settings.get('rattail_config')
|
raise NotImplementedError
|
||||||
app = config.get_app()
|
|
||||||
user = None
|
|
||||||
|
|
||||||
if self.api_mode:
|
|
||||||
|
|
||||||
# determine/load user from header token if present
|
def add_permission_group(config, key, label=None, overwrite=True):
|
||||||
credentials = request.headers.get('Authorization')
|
"""
|
||||||
if credentials:
|
Add a permission group to the app configuration.
|
||||||
match = re.match(r'^Bearer (\S+)$', credentials)
|
"""
|
||||||
if match:
|
def action():
|
||||||
token = match.group(1)
|
perms = config.get_settings().get('tailbone_permissions', {})
|
||||||
auth = app.get_auth_handler()
|
if key not in perms or overwrite:
|
||||||
user = auth.authenticate_user_token(self.db_session, token)
|
group = perms.setdefault(key, {'key': key})
|
||||||
|
group['label'] = label or prettify(key)
|
||||||
|
config.add_settings({'tailbone_permissions': perms})
|
||||||
|
config.action(None, action)
|
||||||
|
|
||||||
if not user:
|
|
||||||
|
|
||||||
# fetch user uuid from current session
|
def add_permission(config, groupkey, key, label=None):
|
||||||
uuid = self.session_helper.authenticated_userid(request)
|
"""
|
||||||
if not uuid:
|
Add a permission to the app configuration.
|
||||||
return
|
"""
|
||||||
|
def action():
|
||||||
# fetch user object from db
|
perms = config.get_settings().get('tailbone_permissions', {})
|
||||||
model = app.model
|
group = perms.setdefault(groupkey, {'key': groupkey})
|
||||||
user = self.db_session.get(model.User, uuid)
|
group.setdefault('label', prettify(groupkey))
|
||||||
if not user:
|
perm = group.setdefault('perms', {}).setdefault(key, {'key': key})
|
||||||
return
|
perm['label'] = label or prettify(key)
|
||||||
|
config.add_settings({'tailbone_permissions': perms})
|
||||||
# this user is responsible for data changes in current request
|
config.action(None, action)
|
||||||
self.db_session.set_continuum_user(user)
|
|
||||||
return user
|
|
||||||
|
|
|
@ -1,23 +1,23 @@
|
||||||
# -*- 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.
|
||||||
#
|
#
|
||||||
# Rattail is free software: you can redistribute it and/or modify it under the
|
# 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
|
# terms of the GNU Affero General Public License as published by the Free
|
||||||
# Foundation, either version 3 of the License, or (at your option) any later
|
# Software Foundation, either version 3 of the License, or (at your option)
|
||||||
# version.
|
# any later version.
|
||||||
#
|
#
|
||||||
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
|
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||||
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
||||||
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
|
# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for
|
||||||
# details.
|
# more details.
|
||||||
#
|
#
|
||||||
# You should have received a copy of the GNU General Public License along with
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
# Rattail. If not, see <http://www.gnu.org/licenses/>.
|
# along with Rattail. If not, see <http://www.gnu.org/licenses/>.
|
||||||
#
|
#
|
||||||
################################################################################
|
################################################################################
|
||||||
"""
|
"""
|
||||||
|
@ -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)
|
|
|
@ -1,39 +1,38 @@
|
||||||
# -*- coding: utf-8; -*-
|
# -*- coding: utf-8 -*-
|
||||||
################################################################################
|
################################################################################
|
||||||
#
|
#
|
||||||
# Rattail -- Retail Software Framework
|
# Rattail -- Retail Software Framework
|
||||||
# Copyright © 2010-2024 Lance Edgar
|
# Copyright © 2010-2016 Lance Edgar
|
||||||
#
|
#
|
||||||
# This file is part of Rattail.
|
# This file is part of Rattail.
|
||||||
#
|
#
|
||||||
# Rattail is free software: you can redistribute it and/or modify it under the
|
# 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
|
# terms of the GNU Affero General Public License as published by the Free
|
||||||
# Foundation, either version 3 of the License, or (at your option) any later
|
# Software Foundation, either version 3 of the License, or (at your option)
|
||||||
# version.
|
# any later version.
|
||||||
#
|
#
|
||||||
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
|
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||||
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
||||||
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
|
# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for
|
||||||
# details.
|
# more details.
|
||||||
#
|
#
|
||||||
# You should have received a copy of the GNU General Public License along with
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
# Rattail. If not, see <http://www.gnu.org/licenses/>.
|
# along with Rattail. If not, see <http://www.gnu.org/licenses/>.
|
||||||
#
|
#
|
||||||
################################################################################
|
################################################################################
|
||||||
"""
|
"""
|
||||||
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:
|
||||||
|
|
||||||
|
@ -48,31 +47,3 @@ class ConfigExtension(WuttaConfigExtension):
|
||||||
def configure(self, config):
|
def configure(self, config):
|
||||||
Session.configure(rattail_config=config)
|
Session.configure(rattail_config=config)
|
||||||
configure_session(config, Session)
|
configure_session(config, Session)
|
||||||
|
|
||||||
# provide default theme selection
|
|
||||||
config.setdefault('tailbone', 'themes.keys', 'default, butterball')
|
|
||||||
config.setdefault('tailbone', 'themes.expose_picker', 'true')
|
|
||||||
|
|
||||||
# override oruga detection
|
|
||||||
config.setdefault('wuttaweb.oruga_detector.spec', 'tailbone.util:should_use_oruga')
|
|
||||||
|
|
||||||
|
|
||||||
def csrf_token_name(config):
|
|
||||||
return config.get('tailbone', 'csrf_token_name', default='_csrf')
|
|
||||||
|
|
||||||
|
|
||||||
def csrf_header_name(config):
|
|
||||||
return config.get('tailbone', 'csrf_header_name', default='X-CSRF-TOKEN')
|
|
||||||
|
|
||||||
|
|
||||||
def global_help_url(config):
|
|
||||||
return config.get('tailbone', 'global_help_url')
|
|
||||||
|
|
||||||
|
|
||||||
def protected_usernames(config):
|
|
||||||
return config.getlist('tailbone', 'protected_usernames')
|
|
||||||
|
|
||||||
|
|
||||||
def should_expose_websockets(config):
|
|
||||||
return config.getbool('tailbone', 'expose_websockets',
|
|
||||||
usedb=False, default=False)
|
|
||||||
|
|
175
tailbone/db.py
175
tailbone/db.py
|
@ -1,29 +1,31 @@
|
||||||
# -*- coding: utf-8; -*-
|
# -*- coding: utf-8 -*-
|
||||||
################################################################################
|
################################################################################
|
||||||
#
|
#
|
||||||
# Rattail -- Retail Software Framework
|
# Rattail -- Retail Software Framework
|
||||||
# Copyright © 2010-2024 Lance Edgar
|
# Copyright © 2010-2016 Lance Edgar
|
||||||
#
|
#
|
||||||
# This file is part of Rattail.
|
# This file is part of Rattail.
|
||||||
#
|
#
|
||||||
# Rattail is free software: you can redistribute it and/or modify it under the
|
# 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
|
# terms of the GNU Affero General Public License as published by the Free
|
||||||
# Foundation, either version 3 of the License, or (at your option) any later
|
# Software Foundation, either version 3 of the License, or (at your option)
|
||||||
# version.
|
# any later version.
|
||||||
#
|
#
|
||||||
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
|
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||||
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
||||||
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
|
# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for
|
||||||
# details.
|
# more details.
|
||||||
#
|
#
|
||||||
# You should have received a copy of the GNU General Public License along with
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
# Rattail. If not, see <http://www.gnu.org/licenses/>.
|
# along with Rattail. If not, see <http://www.gnu.org/licenses/>.
|
||||||
#
|
#
|
||||||
################################################################################
|
################################################################################
|
||||||
"""
|
"""
|
||||||
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
|
||||||
|
@ -33,37 +35,23 @@ from rattail.db import SessionBase
|
||||||
from rattail.db.continuum import versioning_manager
|
from rattail.db.continuum import versioning_manager
|
||||||
|
|
||||||
|
|
||||||
Session = scoped_session(sessionmaker(class_=SessionBase, rattail_config=None, expire_on_commit=False))
|
Session = scoped_session(sessionmaker(class_=SessionBase, rattail_config=None, rattail_record_changes=False))
|
||||||
|
|
||||||
# not necessarily used, but here if you need it
|
# not necessarily used, but here if you need it
|
||||||
TempmonSession = scoped_session(sessionmaker())
|
TempmonSession = scoped_session(sessionmaker())
|
||||||
TrainwreckSession = scoped_session(sessionmaker())
|
|
||||||
|
|
||||||
# empty dict for now, this must populated on app startup (if needed)
|
|
||||||
ExtraTrainwreckSessions = {}
|
|
||||||
|
|
||||||
|
|
||||||
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,42 +63,25 @@ 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.
|
if datamanager._SESSION_STATE.get(id(session), None) is None:
|
||||||
# unfortunately for us, that means we must include each variant here.
|
|
||||||
|
|
||||||
if datamanager._SESSION_STATE.get(session, None) is None:
|
|
||||||
if session.twophase:
|
if session.twophase:
|
||||||
DataManager = datamanager.TwoPhaseSessionDataManager
|
DataManager = datamanager.TwoPhaseSessionDataManager
|
||||||
else:
|
else:
|
||||||
|
@ -118,74 +89,49 @@ def join_transaction(
|
||||||
DataManager(session, initial_state, transaction_manager, keep_session=keep_session)
|
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 __version__
|
||||||
|
assert tuple(int(x) for x in __version__.split(".")) >= (0, 7), \
|
||||||
|
"SQLAlchemy version 0.7 or greater required to use register()"
|
||||||
|
|
||||||
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,10 +143,11 @@ 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)
|
|
||||||
|
|
||||||
|
# TODO: We can probably assume a new SA version since we use Continuum now.
|
||||||
register(Session)
|
if tuple(int(x) for x in sa.__version__.split('.')) >= (0, 7):
|
||||||
register(TempmonSession)
|
register(Session)
|
||||||
register(TrainwreckSession)
|
register(TempmonSession)
|
||||||
|
else:
|
||||||
|
Session.configure(extension=ZopeTransactionExtension())
|
||||||
|
TempmonSession.configure(extension=ZopeTransactionExtension())
|
||||||
|
|
|
@ -1,291 +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/>.
|
|
||||||
#
|
|
||||||
################################################################################
|
|
||||||
"""
|
|
||||||
Tools for displaying data diffs
|
|
||||||
"""
|
|
||||||
|
|
||||||
import sqlalchemy as sa
|
|
||||||
import sqlalchemy_continuum as continuum
|
|
||||||
|
|
||||||
from pyramid.renderers import render
|
|
||||||
from webhelpers2.html import HTML
|
|
||||||
|
|
||||||
|
|
||||||
class Diff(object):
|
|
||||||
"""
|
|
||||||
Core diff class. In sore need of documentation.
|
|
||||||
|
|
||||||
You must provide the old and new data sets, and the set of
|
|
||||||
relevant fields as well, if they cannot be easily introspected.
|
|
||||||
|
|
||||||
:param old_data: Dict of "old" data values.
|
|
||||||
|
|
||||||
:param new_data: Dict of "old" data values.
|
|
||||||
|
|
||||||
:param fields: Sequence of relevant field names. Note that
|
|
||||||
both data dicts are expected to have keys which match these
|
|
||||||
field names. If you do not specify the fields then they
|
|
||||||
will (hopefully) be introspected from the old or new data
|
|
||||||
sets; however this will not work if they are both empty.
|
|
||||||
|
|
||||||
:param monospace: If true, this flag will cause the value
|
|
||||||
columns to be rendered in monospace font. This is assumed
|
|
||||||
to be helpful when comparing "raw" data values which are
|
|
||||||
shown as e.g. ``repr(val)``.
|
|
||||||
|
|
||||||
:param enums: Optional dict of enums for use when displaying field
|
|
||||||
values. If specified, keys should be field names and values
|
|
||||||
should be enum dicts.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, old_data, new_data, columns=None, fields=None, enums=None,
|
|
||||||
render_field=None, render_value=None, nature='dirty',
|
|
||||||
monospace=False, extra_row_attrs=None):
|
|
||||||
self.old_data = old_data
|
|
||||||
self.new_data = new_data
|
|
||||||
self.columns = columns or ["field name", "old value", "new value"]
|
|
||||||
self.fields = fields or self.make_fields()
|
|
||||||
self.enums = enums or {}
|
|
||||||
self._render_field = render_field or self.render_field_default
|
|
||||||
self.render_value = render_value or self.render_value_default
|
|
||||||
self.nature = nature
|
|
||||||
self.monospace = monospace
|
|
||||||
self.extra_row_attrs = extra_row_attrs
|
|
||||||
|
|
||||||
def make_fields(self):
|
|
||||||
return sorted(set(self.old_data) | set(self.new_data), key=lambda x: x.lower())
|
|
||||||
|
|
||||||
def old_value(self, field):
|
|
||||||
return self.old_data.get(field)
|
|
||||||
|
|
||||||
def new_value(self, field):
|
|
||||||
return self.new_data.get(field)
|
|
||||||
|
|
||||||
def values_differ(self, field):
|
|
||||||
return self.new_value(field) != self.old_value(field)
|
|
||||||
|
|
||||||
def render_html(self, template='/diff.mako', **kwargs):
|
|
||||||
context = kwargs
|
|
||||||
context['diff'] = self
|
|
||||||
return HTML.literal(render(template, context))
|
|
||||||
|
|
||||||
def get_row_attrs(self, field):
|
|
||||||
"""
|
|
||||||
Returns a *rendered* set of extra attributes for the ``<tr>`` element
|
|
||||||
for the given field. May be an empty string, or a snippet of HTML
|
|
||||||
attribute syntax, e.g.:
|
|
||||||
|
|
||||||
.. code-block:: none
|
|
||||||
|
|
||||||
class="diff" foo="bar"
|
|
||||||
|
|
||||||
If you wish to supply additional attributes, please define
|
|
||||||
:attr:`extra_row_attrs`, which can be either a static dict, or a
|
|
||||||
callable returning a dict.
|
|
||||||
"""
|
|
||||||
attrs = {}
|
|
||||||
if self.values_differ(field):
|
|
||||||
attrs['class'] = 'diff'
|
|
||||||
|
|
||||||
if self.extra_row_attrs:
|
|
||||||
if callable(self.extra_row_attrs):
|
|
||||||
attrs.update(self.extra_row_attrs(field, attrs))
|
|
||||||
else:
|
|
||||||
attrs.update(self.extra_row_attrs)
|
|
||||||
|
|
||||||
return HTML.render_attrs(attrs)
|
|
||||||
|
|
||||||
def render_field(self, field):
|
|
||||||
return self._render_field(field, self)
|
|
||||||
|
|
||||||
def render_field_default(self, field, diff):
|
|
||||||
return field
|
|
||||||
|
|
||||||
def render_value_default(self, field, value):
|
|
||||||
return repr(value)
|
|
||||||
|
|
||||||
def render_old_value(self, field):
|
|
||||||
value = self.old_value(field)
|
|
||||||
return self.render_value(field, value)
|
|
||||||
|
|
||||||
def render_new_value(self, field):
|
|
||||||
value = self.new_value(field)
|
|
||||||
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,
|
|
||||||
}
|
|
|
@ -1,49 +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 Exceptions
|
|
||||||
"""
|
|
||||||
|
|
||||||
from rattail.exceptions import RattailError
|
|
||||||
|
|
||||||
|
|
||||||
class TailboneError(RattailError):
|
|
||||||
"""
|
|
||||||
Base class for all Tailbone exceptions.
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
class TailboneJSONFieldError(TailboneError):
|
|
||||||
"""
|
|
||||||
Error raised when JSON serialization of a form field results in an error.
|
|
||||||
This is just a simple wrapper, to make the error message more helpful for
|
|
||||||
the developer.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, field, error):
|
|
||||||
self.field = field
|
|
||||||
self.error = error
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return ("Failed to serialize field '{}' as JSON! "
|
|
||||||
"Original error was: {}".format(self.field, self.error))
|
|
|
@ -1,30 +1,38 @@
|
||||||
# -*- coding: utf-8; -*-
|
# -*- coding: utf-8 -*-
|
||||||
################################################################################
|
################################################################################
|
||||||
#
|
#
|
||||||
# Rattail -- Retail Software Framework
|
# Rattail -- Retail Software Framework
|
||||||
# Copyright © 2010-2023 Lance Edgar
|
# Copyright © 2010-2016 Lance Edgar
|
||||||
#
|
#
|
||||||
# This file is part of Rattail.
|
# This file is part of Rattail.
|
||||||
#
|
#
|
||||||
# Rattail is free software: you can redistribute it and/or modify it under the
|
# 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
|
# terms of the GNU Affero General Public License as published by the Free
|
||||||
# Foundation, either version 3 of the License, or (at your option) any later
|
# Software Foundation, either version 3 of the License, or (at your option)
|
||||||
# version.
|
# any later version.
|
||||||
#
|
#
|
||||||
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
|
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||||
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
||||||
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
|
# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for
|
||||||
# details.
|
# more details.
|
||||||
#
|
#
|
||||||
# You should have received a copy of the GNU General Public License along with
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
# Rattail. If not, see <http://www.gnu.org/licenses/>.
|
# along with Rattail. If not, see <http://www.gnu.org/licenses/>.
|
||||||
#
|
#
|
||||||
################################################################################
|
################################################################################
|
||||||
"""
|
"""
|
||||||
Forms Library
|
Forms
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# 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 formencode import Schema
|
||||||
from .core import Form, SimpleFileImport
|
|
||||||
|
from .core import Form, Field, FieldSet, GenericFieldSet
|
||||||
|
from .simpleform import SimpleForm, FormRenderer
|
||||||
|
from .alchemy import AlchemyForm
|
||||||
|
from .fields import AssociationProxyField
|
||||||
|
from .renderers import *
|
||||||
|
|
||||||
|
from . import renderers
|
||||||
|
from . import validators
|
||||||
|
|
117
tailbone/forms/alchemy.py
Normal file
117
tailbone/forms/alchemy.py
Normal file
|
@ -0,0 +1,117 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
################################################################################
|
||||||
|
#
|
||||||
|
# Rattail -- Retail Software Framework
|
||||||
|
# Copyright © 2010-2016 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 Affero 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 Affero General Public License for
|
||||||
|
# more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with Rattail. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
################################################################################
|
||||||
|
"""
|
||||||
|
FormAlchemy Forms
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import unicode_literals, absolute_import
|
||||||
|
|
||||||
|
from rattail.core import Object
|
||||||
|
|
||||||
|
import formalchemy as fa
|
||||||
|
from pyramid.renderers import render
|
||||||
|
from webhelpers.html import HTML, tags
|
||||||
|
|
||||||
|
from tailbone.db import Session
|
||||||
|
|
||||||
|
|
||||||
|
class TemplateEngine(fa.templates.TemplateEngine):
|
||||||
|
"""
|
||||||
|
Mako template engine for FormAlchemy.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def render(self, template, prefix='/forms/', suffix='.mako', **kwargs):
|
||||||
|
template = ''.join((prefix, template, suffix))
|
||||||
|
return render(template, kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class AlchemyForm(Object):
|
||||||
|
"""
|
||||||
|
Form to contain a :class:`formalchemy.FieldSet` instance.
|
||||||
|
"""
|
||||||
|
id = None
|
||||||
|
create_label = "Create"
|
||||||
|
update_label = "Save"
|
||||||
|
|
||||||
|
allow_successive_creates = False
|
||||||
|
|
||||||
|
def __init__(self, request, fieldset, session=None, csrf_field='_csrf', **kwargs):
|
||||||
|
super(AlchemyForm, self).__init__(**kwargs)
|
||||||
|
self.request = request
|
||||||
|
self.fieldset = fieldset
|
||||||
|
self.session = session
|
||||||
|
self.csrf_field = csrf_field
|
||||||
|
|
||||||
|
def _get_readonly(self):
|
||||||
|
return self.fieldset.readonly
|
||||||
|
def _set_readonly(self, val):
|
||||||
|
self.fieldset.readonly = val
|
||||||
|
readonly = property(_get_readonly, _set_readonly)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def successive_create_label(self):
|
||||||
|
return "%s and continue" % self.create_label
|
||||||
|
|
||||||
|
def csrf(self, name=None):
|
||||||
|
"""
|
||||||
|
NOTE: this method was copied from `pyramid_simpleform.FormRenderer`
|
||||||
|
|
||||||
|
Returns the CSRF hidden input. Creates new CSRF token
|
||||||
|
if none has been assigned yet.
|
||||||
|
|
||||||
|
The name of the hidden field is **_csrf** by default.
|
||||||
|
"""
|
||||||
|
name = name or self.csrf_field
|
||||||
|
|
||||||
|
token = self.request.session.get_csrf_token()
|
||||||
|
if token is None:
|
||||||
|
token = self.request.session.new_csrf_token()
|
||||||
|
|
||||||
|
return tags.hidden(name, value=token)
|
||||||
|
|
||||||
|
def csrf_token(self, name=None):
|
||||||
|
"""
|
||||||
|
NOTE: this method was copied from `pyramid_simpleform.FormRenderer`
|
||||||
|
|
||||||
|
Convenience function. Returns CSRF hidden tag inside hidden DIV.
|
||||||
|
"""
|
||||||
|
return HTML.tag("div", self.csrf(name), style="display:none;")
|
||||||
|
|
||||||
|
def render(self, **kwargs):
|
||||||
|
kwargs['form'] = self
|
||||||
|
if self.readonly:
|
||||||
|
template = '/forms/form_readonly.mako'
|
||||||
|
else:
|
||||||
|
template = '/forms/form.mako'
|
||||||
|
return render(template, kwargs)
|
||||||
|
|
||||||
|
def render_fields(self):
|
||||||
|
return self.fieldset.render()
|
||||||
|
|
||||||
|
def save(self):
|
||||||
|
self.fieldset.sync()
|
||||||
|
self.session.flush()
|
||||||
|
|
||||||
|
def validate(self):
|
||||||
|
self.fieldset.rebind(data=self.request.params)
|
||||||
|
return self.fieldset.validate()
|
|
@ -1,62 +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/>.
|
|
||||||
#
|
|
||||||
################################################################################
|
|
||||||
"""
|
|
||||||
Common Forms
|
|
||||||
"""
|
|
||||||
|
|
||||||
from rattail.db import model
|
|
||||||
|
|
||||||
import colander
|
|
||||||
|
|
||||||
|
|
||||||
@colander.deferred
|
|
||||||
def validate_user(node, kw):
|
|
||||||
session = kw['session']
|
|
||||||
def validate(node, value):
|
|
||||||
user = session.get(model.User, value)
|
|
||||||
if not user:
|
|
||||||
raise colander.Invalid(node, "User not found")
|
|
||||||
return user.uuid
|
|
||||||
return validate
|
|
||||||
|
|
||||||
|
|
||||||
class Feedback(colander.Schema):
|
|
||||||
"""
|
|
||||||
Form schema for user feedback.
|
|
||||||
"""
|
|
||||||
email_key = colander.SchemaNode(colander.String(),
|
|
||||||
missing=colander.null)
|
|
||||||
|
|
||||||
referrer = colander.SchemaNode(colander.String())
|
|
||||||
|
|
||||||
user = colander.SchemaNode(colander.String(),
|
|
||||||
missing=colander.null,
|
|
||||||
validator=validate_user)
|
|
||||||
|
|
||||||
user_name = colander.SchemaNode(colander.String(),
|
|
||||||
missing=colander.null)
|
|
||||||
|
|
||||||
please_reply_to = colander.SchemaNode(colander.String(),
|
|
||||||
missing=colander.null)
|
|
||||||
|
|
||||||
message = colander.SchemaNode(colander.String())
|
|
File diff suppressed because it is too large
Load diff
82
tailbone/forms/custorders.py
Normal file
82
tailbone/forms/custorders.py
Normal file
|
@ -0,0 +1,82 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
################################################################################
|
||||||
|
#
|
||||||
|
# Rattail -- Retail Software Framework
|
||||||
|
# Copyright © 2010-2015 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 Affero 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 Affero General Public License for
|
||||||
|
# more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with Rattail. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
################################################################################
|
||||||
|
"""
|
||||||
|
Form Objects for Customer Orders
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
from rattail import enum
|
||||||
|
from rattail.db import model
|
||||||
|
|
||||||
|
import formencode
|
||||||
|
from formencode import validators
|
||||||
|
|
||||||
|
from tailbone.forms.validators import ValidCustomer, ValidProduct, ValidUser
|
||||||
|
|
||||||
|
|
||||||
|
class ValidCustomerInfo(validators.FormValidator):
|
||||||
|
"""
|
||||||
|
Custom validator to ensure we have either a proper customer reference, or
|
||||||
|
at least a customer name, when creating a new customer order.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def validate_python(self, field_dict, state):
|
||||||
|
if not field_dict['customer'] and not field_dict['customer_name']:
|
||||||
|
raise formencode.Invalid("Customer name is required", field_dict, state)
|
||||||
|
|
||||||
|
|
||||||
|
class NewCustomerOrderItem(formencode.Schema):
|
||||||
|
"""
|
||||||
|
Form schema to which individual items on a new customer order must adhere.
|
||||||
|
"""
|
||||||
|
allow_extra_fields = True
|
||||||
|
|
||||||
|
product = ValidProduct()
|
||||||
|
product_description = validators.NotEmpty()
|
||||||
|
|
||||||
|
quantity = validators.Int()
|
||||||
|
unit_of_measure = validators.OneOf(enum.UNIT_OF_MEASURE)
|
||||||
|
discount = validators.Int()
|
||||||
|
|
||||||
|
notes = validators.String()
|
||||||
|
|
||||||
|
|
||||||
|
class NewCustomerOrder(formencode.Schema):
|
||||||
|
"""
|
||||||
|
Form schema for creating a new customer order.
|
||||||
|
"""
|
||||||
|
allow_extra_fields = True
|
||||||
|
pre_validators = [formencode.NestedVariables()]
|
||||||
|
|
||||||
|
user = formencode.Pipe(validators=[
|
||||||
|
validators.NotEmpty(),
|
||||||
|
ValidUser()])
|
||||||
|
|
||||||
|
customer = ValidCustomer()
|
||||||
|
customer_name = validators.String()
|
||||||
|
customer_phone = validators.NotEmpty()
|
||||||
|
|
||||||
|
products = formencode.ForEach(NewCustomerOrderItem())
|
||||||
|
|
||||||
|
chained_validators = [ValidCustomerInfo()]
|
49
tailbone/forms/fields.py
Normal file
49
tailbone/forms/fields.py
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
################################################################################
|
||||||
|
#
|
||||||
|
# Rattail -- Retail Software Framework
|
||||||
|
# Copyright © 2010-2015 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 Affero 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 Affero General Public License for
|
||||||
|
# more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with Rattail. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
################################################################################
|
||||||
|
"""
|
||||||
|
FormAlchemy Fields
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import unicode_literals, absolute_import
|
||||||
|
|
||||||
|
from formalchemy import Field
|
||||||
|
|
||||||
|
|
||||||
|
def AssociationProxyField(name, **kwargs):
|
||||||
|
"""
|
||||||
|
Returns a FormAlchemy ``Field`` class which is aware of association
|
||||||
|
proxies.
|
||||||
|
"""
|
||||||
|
|
||||||
|
class ProxyField(Field):
|
||||||
|
|
||||||
|
def sync(self):
|
||||||
|
if not self.is_readonly():
|
||||||
|
setattr(self.parent.model, self.name,
|
||||||
|
self.renderer.deserialize())
|
||||||
|
|
||||||
|
def value(model):
|
||||||
|
return getattr(model, name, None)
|
||||||
|
|
||||||
|
kwargs.setdefault('value', value)
|
||||||
|
return ProxyField(name, **kwargs)
|
|
@ -1,68 +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/>.
|
|
||||||
#
|
|
||||||
################################################################################
|
|
||||||
"""
|
|
||||||
Forms for Receiving
|
|
||||||
"""
|
|
||||||
|
|
||||||
from rattail.db import model
|
|
||||||
|
|
||||||
import colander
|
|
||||||
|
|
||||||
|
|
||||||
@colander.deferred
|
|
||||||
def valid_purchase_batch_row(node, kw):
|
|
||||||
session = kw['session']
|
|
||||||
def validate(node, value):
|
|
||||||
row = session.get(model.PurchaseBatchRow, value)
|
|
||||||
if not row:
|
|
||||||
raise colander.Invalid(node, "Batch row not found")
|
|
||||||
if row.batch.executed:
|
|
||||||
raise colander.Invalid(node, "Batch has already been executed")
|
|
||||||
return row.uuid
|
|
||||||
return validate
|
|
||||||
|
|
||||||
|
|
||||||
class ReceiveRow(colander.MappingSchema):
|
|
||||||
|
|
||||||
row = colander.SchemaNode(colander.String(),
|
|
||||||
validator=valid_purchase_batch_row)
|
|
||||||
|
|
||||||
mode = colander.SchemaNode(colander.String(),
|
|
||||||
validator=colander.OneOf([
|
|
||||||
'received',
|
|
||||||
'damaged',
|
|
||||||
'expired',
|
|
||||||
'missing',
|
|
||||||
# 'mispick',
|
|
||||||
]))
|
|
||||||
|
|
||||||
cases = colander.SchemaNode(colander.Decimal(),
|
|
||||||
missing=colander.null)
|
|
||||||
|
|
||||||
units = colander.SchemaNode(colander.Decimal(),
|
|
||||||
missing=colander.null)
|
|
||||||
|
|
||||||
expiration_date = colander.SchemaNode(colander.Date(),
|
|
||||||
missing=colander.null)
|
|
||||||
|
|
||||||
quick_receive = colander.SchemaNode(colander.Boolean())
|
|
51
tailbone/forms/renderers/__init__.py
Normal file
51
tailbone/forms/renderers/__init__.py
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
################################################################################
|
||||||
|
#
|
||||||
|
# Rattail -- Retail Software Framework
|
||||||
|
# Copyright © 2010-2016 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 Affero 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 Affero General Public License for
|
||||||
|
# more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with Rattail. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
################################################################################
|
||||||
|
"""
|
||||||
|
FormAlchemy Field Renderers
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import unicode_literals, absolute_import
|
||||||
|
|
||||||
|
from .core import CustomFieldRenderer, DateFieldRenderer
|
||||||
|
|
||||||
|
from .common import (StrippedTextFieldRenderer, CodeTextAreaFieldRenderer, AutocompleteFieldRenderer,
|
||||||
|
DecimalFieldRenderer, CurrencyFieldRenderer, QuantityFieldRenderer,
|
||||||
|
DateTimeFieldRenderer, DateTimePrettyFieldRenderer, TimeFieldRenderer,
|
||||||
|
EnumFieldRenderer, YesNoFieldRenderer)
|
||||||
|
|
||||||
|
from .files import FileFieldRenderer
|
||||||
|
|
||||||
|
from .people import PersonFieldRenderer, CustomerFieldRenderer
|
||||||
|
from .users import UserFieldRenderer, PermissionsFieldRenderer
|
||||||
|
from .employees import EmployeeFieldRenderer
|
||||||
|
|
||||||
|
from .stores import StoreFieldRenderer
|
||||||
|
from .vendors import VendorFieldRenderer, PurchaseFieldRenderer
|
||||||
|
from .products import (GPCFieldRenderer, ScancodeFieldRenderer,
|
||||||
|
DepartmentFieldRenderer, SubdepartmentFieldRenderer, CategoryFieldRenderer,
|
||||||
|
BrandFieldRenderer, ProductFieldRenderer,
|
||||||
|
PriceFieldRenderer, PriceWithExpirationFieldRenderer)
|
||||||
|
|
||||||
|
from .custorders import CustomerOrderFieldRenderer
|
||||||
|
|
||||||
|
from .batch import BatchIDFieldRenderer, HandheldBatchFieldRenderer
|
103
tailbone/forms/renderers/batch.py
Normal file
103
tailbone/forms/renderers/batch.py
Normal file
|
@ -0,0 +1,103 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
################################################################################
|
||||||
|
#
|
||||||
|
# Rattail -- Retail Software Framework
|
||||||
|
# Copyright © 2010-2016 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 Affero 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 Affero General Public License for
|
||||||
|
# more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with Rattail. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
################################################################################
|
||||||
|
"""
|
||||||
|
Batch Field Renderers
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import unicode_literals, absolute_import
|
||||||
|
|
||||||
|
import os
|
||||||
|
import stat
|
||||||
|
import random
|
||||||
|
|
||||||
|
import formalchemy as fa
|
||||||
|
from formalchemy.ext import fsblob
|
||||||
|
from formalchemy.fields import FileFieldRenderer as Base
|
||||||
|
from webhelpers.html import tags
|
||||||
|
|
||||||
|
|
||||||
|
class BatchIDFieldRenderer(fa.FieldRenderer):
|
||||||
|
"""
|
||||||
|
Renderer for batch ID fields.
|
||||||
|
"""
|
||||||
|
def render_readonly(self, **kwargs):
|
||||||
|
try:
|
||||||
|
batch_id = self.raw_value
|
||||||
|
except AttributeError:
|
||||||
|
# this can happen when creating a new batch, b/c the default value
|
||||||
|
# comes from a sequence
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
if batch_id:
|
||||||
|
return '{:08d}'.format(batch_id)
|
||||||
|
return ''
|
||||||
|
|
||||||
|
|
||||||
|
# TODO: make this inherit from `tailbone.forms.renderers.files.FileFieldRenderer`
|
||||||
|
class FileFieldRenderer(fsblob.FileFieldRenderer):
|
||||||
|
"""
|
||||||
|
Custom file field renderer for batches based on a single source data file.
|
||||||
|
In edit mode, shows a file upload field. In readonly mode, shows the
|
||||||
|
filename and its size.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def new(cls, view):
|
||||||
|
name = 'Configured%s_%s' % (cls.__name__, str(random.random())[2:])
|
||||||
|
return type(str(name), (cls,), dict(view=view))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def storage_path(self):
|
||||||
|
return self.view.upload_dir
|
||||||
|
|
||||||
|
def get_size(self):
|
||||||
|
size = super(FileFieldRenderer, self).get_size()
|
||||||
|
if size:
|
||||||
|
return size
|
||||||
|
batch = self.field.parent.model
|
||||||
|
path = os.path.join(self.view.handler.datadir(batch), self.field.value)
|
||||||
|
if os.path.isfile(path):
|
||||||
|
return os.stat(path)[stat.ST_SIZE]
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def get_url(self, filename):
|
||||||
|
batch = self.field.parent.model
|
||||||
|
return self.view.request.route_url('{}.download'.format(self.view.get_route_prefix()),
|
||||||
|
uuid=batch.uuid)
|
||||||
|
|
||||||
|
def render(self, **kwargs):
|
||||||
|
return Base.render(self, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class HandheldBatchFieldRenderer(fa.FieldRenderer):
|
||||||
|
"""
|
||||||
|
Renderer for inventory batch's "handheld batch" field.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def render_readonly(self, **kwargs):
|
||||||
|
batch = self.raw_value
|
||||||
|
if batch:
|
||||||
|
return tags.link_to(
|
||||||
|
batch.id_str,
|
||||||
|
self.request.route_url('batch.handheld.view', uuid=batch.uuid))
|
||||||
|
return ''
|
63
tailbone/forms/renderers/bouncer.py
Normal file
63
tailbone/forms/renderers/bouncer.py
Normal file
|
@ -0,0 +1,63 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
################################################################################
|
||||||
|
#
|
||||||
|
# Rattail -- Retail Software Framework
|
||||||
|
# Copyright © 2010-2015 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 Affero 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 Affero General Public License for
|
||||||
|
# more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with Rattail. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
################################################################################
|
||||||
|
"""
|
||||||
|
Batch Field Renderers
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
import os
|
||||||
|
import stat
|
||||||
|
import random
|
||||||
|
|
||||||
|
from formalchemy.ext import fsblob
|
||||||
|
|
||||||
|
|
||||||
|
class BounceMessageFieldRenderer(fsblob.FileFieldRenderer):
|
||||||
|
"""
|
||||||
|
Custom file field renderer for email bounce messages. In readonly mode,
|
||||||
|
shows the filename and size.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def new(cls, request, handler):
|
||||||
|
name = 'Configured%s_%s' % (cls.__name__, unicode(random.random())[2:])
|
||||||
|
return type(str(name), (cls,), dict(request=request, handler=handler))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def storage_path(self):
|
||||||
|
return self.handler.root_msgdir
|
||||||
|
|
||||||
|
def get_size(self):
|
||||||
|
size = super(BounceMessageFieldRenderer, self).get_size()
|
||||||
|
if size:
|
||||||
|
return size
|
||||||
|
bounce = self.field.parent.model
|
||||||
|
path = os.path.join(self.handler.msgpath(bounce))
|
||||||
|
if os.path.isfile(path):
|
||||||
|
return os.stat(path)[stat.ST_SIZE]
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def get_url(self, filename):
|
||||||
|
bounce = self.field.parent.model
|
||||||
|
return self.request.route_url('emailbounces.download', uuid=bounce.uuid)
|
267
tailbone/forms/renderers/common.py
Normal file
267
tailbone/forms/renderers/common.py
Normal file
|
@ -0,0 +1,267 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
################################################################################
|
||||||
|
#
|
||||||
|
# Rattail -- Retail Software Framework
|
||||||
|
# Copyright © 2010-2016 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 Affero 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 Affero General Public License for
|
||||||
|
# more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with Rattail. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
################################################################################
|
||||||
|
"""
|
||||||
|
Common Field Renderers
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import unicode_literals, absolute_import
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
from rattail.time import localtime, make_utc
|
||||||
|
from rattail.util import pretty_quantity
|
||||||
|
|
||||||
|
import formalchemy as fa
|
||||||
|
from formalchemy import fields as fa_fields, helpers as fa_helpers
|
||||||
|
from pyramid.renderers import render
|
||||||
|
from webhelpers.html import HTML
|
||||||
|
|
||||||
|
from tailbone.util import pretty_datetime, raw_datetime
|
||||||
|
|
||||||
|
|
||||||
|
class StrippedTextFieldRenderer(fa.TextFieldRenderer):
|
||||||
|
"""
|
||||||
|
Standard text field renderer, which strips whitespace from either end of
|
||||||
|
the input value on deserialization.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def deserialize(self):
|
||||||
|
value = super(StrippedTextFieldRenderer, self).deserialize()
|
||||||
|
if value is not None:
|
||||||
|
return value.strip()
|
||||||
|
|
||||||
|
|
||||||
|
class CodeTextAreaFieldRenderer(fa.TextAreaFieldRenderer):
|
||||||
|
|
||||||
|
def render_readonly(self, **kwargs):
|
||||||
|
value = self.raw_value
|
||||||
|
if not value:
|
||||||
|
return ''
|
||||||
|
return HTML.tag('pre', c=value)
|
||||||
|
|
||||||
|
def render(self, **kwargs):
|
||||||
|
kwargs.setdefault('size', (80, 8))
|
||||||
|
return super(CodeTextAreaFieldRenderer, self).render(**kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class AutocompleteFieldRenderer(fa.FieldRenderer):
|
||||||
|
"""
|
||||||
|
Custom renderer for an autocomplete field.
|
||||||
|
"""
|
||||||
|
|
||||||
|
service_route = None
|
||||||
|
width = '300px'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def focus_name(self):
|
||||||
|
return self.name + '-textbox'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def needs_focus(self):
|
||||||
|
return not bool(self.value or self.field_value)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def field_display(self):
|
||||||
|
return self.raw_value
|
||||||
|
|
||||||
|
@property
|
||||||
|
def field_value(self):
|
||||||
|
return self.value
|
||||||
|
|
||||||
|
@property
|
||||||
|
def service_url(self):
|
||||||
|
return self.request.route_url(self.service_route)
|
||||||
|
|
||||||
|
def render(self, **kwargs):
|
||||||
|
kwargs.setdefault('field_name', self.name)
|
||||||
|
kwargs.setdefault('field_value', self.field_value)
|
||||||
|
kwargs.setdefault('field_display', self.field_display)
|
||||||
|
kwargs.setdefault('service_url', self.service_url)
|
||||||
|
kwargs.setdefault('width', self.width)
|
||||||
|
return render('/forms/field_autocomplete.mako', kwargs)
|
||||||
|
|
||||||
|
def render_readonly(self, **kwargs):
|
||||||
|
value = self.field_display
|
||||||
|
if value is None:
|
||||||
|
return u''
|
||||||
|
return unicode(value)
|
||||||
|
|
||||||
|
|
||||||
|
class DateTimeFieldRenderer(fa.DateTimeFieldRenderer):
|
||||||
|
"""
|
||||||
|
This renderer assumes the datetime field value is in UTC, and will convert
|
||||||
|
it to the local time zone before rendering it in the standard "raw" format.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def render_readonly(self, **kwargs):
|
||||||
|
value = self.raw_value
|
||||||
|
if not value:
|
||||||
|
return ''
|
||||||
|
return raw_datetime(self.request.rattail_config, value)
|
||||||
|
|
||||||
|
|
||||||
|
class DateTimePrettyFieldRenderer(fa.DateTimeFieldRenderer):
|
||||||
|
"""
|
||||||
|
Custom date/time field renderer, which displays a "pretty" value in
|
||||||
|
read-only mode, leveraging config to show the correct timezone.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def render_readonly(self, **kwargs):
|
||||||
|
value = self.raw_value
|
||||||
|
if not value:
|
||||||
|
return ''
|
||||||
|
return pretty_datetime(self.request.rattail_config, value)
|
||||||
|
|
||||||
|
|
||||||
|
class TimeFieldRenderer(fa.TimeFieldRenderer):
|
||||||
|
"""
|
||||||
|
Custom renderer for time fields. In edit mode, renders a simple text
|
||||||
|
input, which is expected to become a 'timepicker' widget in the UI.
|
||||||
|
However the particular magic required for that lives in 'tailbone.js'.
|
||||||
|
"""
|
||||||
|
format = '%I:%M %p'
|
||||||
|
|
||||||
|
def render(self, **kwargs):
|
||||||
|
kwargs.setdefault('class_', 'timepicker')
|
||||||
|
return fa_helpers.text_field(self.name, value=self.value, **kwargs)
|
||||||
|
|
||||||
|
def render_readonly(self, **kwargs):
|
||||||
|
return self.render_value(self.raw_value)
|
||||||
|
|
||||||
|
def render_value(self, value):
|
||||||
|
value = self.convert_value(value)
|
||||||
|
if isinstance(value, datetime.time):
|
||||||
|
return value.strftime(self.format)
|
||||||
|
return ''
|
||||||
|
|
||||||
|
def convert_value(self, value):
|
||||||
|
if isinstance(value, datetime.datetime):
|
||||||
|
if not value.tzinfo:
|
||||||
|
value = make_utc(value, tzinfo=True)
|
||||||
|
return localtime(self.request.rattail_config, value).time()
|
||||||
|
return value
|
||||||
|
|
||||||
|
def stringify_value(self, value, as_html=False):
|
||||||
|
if not as_html:
|
||||||
|
return self.render_value(value)
|
||||||
|
return super(TimeFieldRenderer, self).stringify_value(value, as_html=as_html)
|
||||||
|
|
||||||
|
def _serialized_value(self):
|
||||||
|
return self.params.getone(self.name)
|
||||||
|
|
||||||
|
def deserialize(self):
|
||||||
|
value = self._serialized_value()
|
||||||
|
if value:
|
||||||
|
try:
|
||||||
|
return datetime.datetime.strptime(value, self.format).time()
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class EnumFieldRenderer(fa_fields.SelectFieldRenderer):
|
||||||
|
"""
|
||||||
|
Renderer for simple enumeration fields.
|
||||||
|
"""
|
||||||
|
enumeration = {}
|
||||||
|
render_key = False
|
||||||
|
|
||||||
|
def __init__(self, arg, render_key=False):
|
||||||
|
if isinstance(arg, dict):
|
||||||
|
self.enumeration = arg
|
||||||
|
self.render_key = render_key
|
||||||
|
else:
|
||||||
|
self(arg)
|
||||||
|
|
||||||
|
def __call__(self, field):
|
||||||
|
super(EnumFieldRenderer, self).__init__(field)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def render_readonly(self, **kwargs):
|
||||||
|
value = self.raw_value
|
||||||
|
if value is None:
|
||||||
|
return ''
|
||||||
|
rendered = self.enumeration.get(value, unicode(value))
|
||||||
|
if self.render_key:
|
||||||
|
rendered = '{} - {}'.format(value, rendered)
|
||||||
|
return rendered
|
||||||
|
|
||||||
|
def render(self, **kwargs):
|
||||||
|
opts = [(self.enumeration[x], x) for x in self.enumeration]
|
||||||
|
if not self.field.is_required():
|
||||||
|
opts.insert(0, self.field._null_option)
|
||||||
|
return fa_fields.SelectFieldRenderer.render(self, opts, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class DecimalFieldRenderer(fa.FieldRenderer):
|
||||||
|
"""
|
||||||
|
Sort of generic field renderer for decimal values. You must provide the
|
||||||
|
number of places after the decimal (scale). Note that this in turn relies
|
||||||
|
on simple string formatting; the renderer does not attempt any mathematics
|
||||||
|
of its own.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, scale):
|
||||||
|
self.scale = scale
|
||||||
|
|
||||||
|
def __call__(self, field):
|
||||||
|
super(DecimalFieldRenderer, self).__init__(field)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def render_readonly(self, **kwargs):
|
||||||
|
value = self.raw_value
|
||||||
|
if value is None:
|
||||||
|
return ''
|
||||||
|
fmt = '{{0:0.{0}f}}'.format(self.scale)
|
||||||
|
return fmt.format(value)
|
||||||
|
|
||||||
|
|
||||||
|
class CurrencyFieldRenderer(fa_fields.FloatFieldRenderer):
|
||||||
|
"""
|
||||||
|
Sort of generic field renderer for currency values.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def render_readonly(self, **kwargs):
|
||||||
|
value = self.raw_value
|
||||||
|
if value is None:
|
||||||
|
return ''
|
||||||
|
if value < 0:
|
||||||
|
return "(${:0,.2f})".format(0 - value)
|
||||||
|
return "${:0,.2f}".format(value)
|
||||||
|
|
||||||
|
|
||||||
|
class QuantityFieldRenderer(fa_fields.FloatFieldRenderer):
|
||||||
|
"""
|
||||||
|
Sort of generic field renderer for quantity values.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def render_readonly(self, **kwargs):
|
||||||
|
return pretty_quantity(self.raw_value)
|
||||||
|
|
||||||
|
|
||||||
|
class YesNoFieldRenderer(fa.CheckBoxFieldRenderer):
|
||||||
|
|
||||||
|
def render_readonly(self, **kwargs):
|
||||||
|
value = self.raw_value
|
||||||
|
if value is None:
|
||||||
|
return u''
|
||||||
|
return u'Yes' if value else u'No'
|
99
tailbone/forms/renderers/core.py
Normal file
99
tailbone/forms/renderers/core.py
Normal file
|
@ -0,0 +1,99 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
################################################################################
|
||||||
|
#
|
||||||
|
# Rattail -- Retail Software Framework
|
||||||
|
# Copyright © 2010-2016 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 Affero 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 Affero General Public License for
|
||||||
|
# more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with Rattail. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
################################################################################
|
||||||
|
"""
|
||||||
|
Core Field Renderers
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import unicode_literals, absolute_import
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
import formalchemy as fa
|
||||||
|
from formalchemy.fields import AbstractField
|
||||||
|
from pyramid.renderers import render
|
||||||
|
|
||||||
|
|
||||||
|
class CustomFieldRenderer(fa.FieldRenderer):
|
||||||
|
"""
|
||||||
|
Base class for renderers which accept customization args, and "fake out"
|
||||||
|
FormAlchemy by pretending to still be a renderer factory when in fact it's
|
||||||
|
already dealing with a renderer instance.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
if len(args) == 1 and isinstance(args[0], AbstractField):
|
||||||
|
super(CustomFieldRenderer, self).__init__(args[0])
|
||||||
|
self.init(**kwargs)
|
||||||
|
else:
|
||||||
|
assert len(args) == 0
|
||||||
|
self.init(**kwargs)
|
||||||
|
|
||||||
|
def __call__(self, field):
|
||||||
|
super(CustomFieldRenderer, self).__init__(field)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def init(self, **kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
@property
|
||||||
|
def rattail_config(self):
|
||||||
|
return self.request.rattail_config
|
||||||
|
|
||||||
|
|
||||||
|
class DateFieldRenderer(CustomFieldRenderer):
|
||||||
|
"""
|
||||||
|
Date field renderer which uses jQuery UI datepicker widget when rendering
|
||||||
|
in edit mode.
|
||||||
|
"""
|
||||||
|
date_format = None
|
||||||
|
change_year = False
|
||||||
|
|
||||||
|
def init(self, date_format=None, change_year=False):
|
||||||
|
self.date_format = date_format
|
||||||
|
self.change_year = change_year
|
||||||
|
|
||||||
|
def render_readonly(self, **kwargs):
|
||||||
|
value = self.raw_value
|
||||||
|
if value is None:
|
||||||
|
return ''
|
||||||
|
return value.strftime(self.date_format)
|
||||||
|
|
||||||
|
def render(self, **kwargs):
|
||||||
|
kwargs['name'] = self.name
|
||||||
|
kwargs['value'] = self.value
|
||||||
|
kwargs['change_year'] = self.change_year
|
||||||
|
return render('/forms/fields/date.mako', kwargs)
|
||||||
|
|
||||||
|
def deserialize(self):
|
||||||
|
value = self._serialized_value()
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return datetime.datetime.strptime(value, '%Y-%m-%d')
|
||||||
|
except ValueError:
|
||||||
|
raise fa.ValidationError("Date value must be in YYYY-MM-DD format")
|
||||||
|
except Exception as error:
|
||||||
|
raise fa.ValidationError(unicode(error))
|
||||||
|
|
||||||
|
def _serialized_value(self):
|
||||||
|
return self.params.getone(self.name)
|
42
tailbone/forms/renderers/custorders.py
Normal file
42
tailbone/forms/renderers/custorders.py
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
################################################################################
|
||||||
|
#
|
||||||
|
# Rattail -- Retail Software Framework
|
||||||
|
# Copyright © 2010-2016 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 Affero 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 Affero General Public License for
|
||||||
|
# more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with Rattail. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
################################################################################
|
||||||
|
"""
|
||||||
|
Customer order field renderers
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import unicode_literals, absolute_import
|
||||||
|
|
||||||
|
import formalchemy as fa
|
||||||
|
from webhelpers.html import tags
|
||||||
|
|
||||||
|
|
||||||
|
class CustomerOrderFieldRenderer(fa.fields.SelectFieldRenderer):
|
||||||
|
"""
|
||||||
|
Renders a link to the customer order
|
||||||
|
"""
|
||||||
|
|
||||||
|
def render_readonly(self, **kwargs):
|
||||||
|
order = self.raw_value
|
||||||
|
if not order:
|
||||||
|
return ''
|
||||||
|
return tags.link_to(order, self.request.route_url('custorders.view', uuid=order.uuid))
|
47
tailbone/forms/renderers/employees.py
Normal file
47
tailbone/forms/renderers/employees.py
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
################################################################################
|
||||||
|
#
|
||||||
|
# Rattail -- Retail Software Framework
|
||||||
|
# Copyright © 2010-2017 Lance Edgar
|
||||||
|
#
|
||||||
|
# This file is part of Rattail.
|
||||||
|
#
|
||||||
|
# Rattail is free software: you can redistribute it and/or modify it under the
|
||||||
|
# terms of the GNU Affero 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 Affero General Public License for
|
||||||
|
# more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with Rattail. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
################################################################################
|
||||||
|
"""
|
||||||
|
Employee Field Renderers
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import unicode_literals, absolute_import
|
||||||
|
|
||||||
|
from webhelpers.html import tags
|
||||||
|
|
||||||
|
from tailbone.forms.renderers import AutocompleteFieldRenderer
|
||||||
|
|
||||||
|
|
||||||
|
class EmployeeFieldRenderer(AutocompleteFieldRenderer):
|
||||||
|
"""
|
||||||
|
Renderer for :class:`rattail.db.model.Employee` instance fields.
|
||||||
|
"""
|
||||||
|
service_route = 'employees.autocomplete'
|
||||||
|
|
||||||
|
def render_readonly(self, **kwargs):
|
||||||
|
employee = self.raw_value
|
||||||
|
if not employee:
|
||||||
|
return ''
|
||||||
|
title = unicode(employee.person)
|
||||||
|
if self.request.has_perm('employees.view'):
|
||||||
|
return tags.link_to(title, self.request.route_url('employees.view', uuid=employee.uuid))
|
||||||
|
return title
|
98
tailbone/forms/renderers/files.py
Normal file
98
tailbone/forms/renderers/files.py
Normal file
|
@ -0,0 +1,98 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
################################################################################
|
||||||
|
#
|
||||||
|
# Rattail -- Retail Software Framework
|
||||||
|
# Copyright © 2010-2016 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 Affero 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 Affero General Public License for
|
||||||
|
# more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with Rattail. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
################################################################################
|
||||||
|
"""
|
||||||
|
Batch Field Renderers
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import unicode_literals, absolute_import
|
||||||
|
|
||||||
|
import os
|
||||||
|
import stat
|
||||||
|
import random
|
||||||
|
|
||||||
|
from formalchemy.ext import fsblob
|
||||||
|
from formalchemy.fields import FileFieldRenderer as Base
|
||||||
|
|
||||||
|
|
||||||
|
class FileFieldRenderer(fsblob.FileFieldRenderer):
|
||||||
|
"""
|
||||||
|
Custom file field renderer. In readonly mode, shows a filename and its
|
||||||
|
size; in edit mode, supports a single file upload.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def new(cls, view, **kwargs):
|
||||||
|
name = b'Configured{}_{}'.format(cls.__name__, str(random.random())[2:])
|
||||||
|
return type(name, (cls,), dict(view=view, **kwargs))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def request(self):
|
||||||
|
return self.view.request
|
||||||
|
|
||||||
|
@property
|
||||||
|
def storage_path(self):
|
||||||
|
return self.view.upload_dir
|
||||||
|
|
||||||
|
def get_file_path(self):
|
||||||
|
"""
|
||||||
|
Returns the absolute path to the data file.
|
||||||
|
"""
|
||||||
|
if hasattr(self, 'file_path'):
|
||||||
|
return self.file_path
|
||||||
|
return self.field.value
|
||||||
|
|
||||||
|
def get_size(self):
|
||||||
|
"""
|
||||||
|
Returns the size of the data file, in bytes.
|
||||||
|
"""
|
||||||
|
path = self.get_file_path()
|
||||||
|
if path and os.path.isfile(path):
|
||||||
|
return os.stat(path)[stat.ST_SIZE]
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def get_url(self, filename):
|
||||||
|
url = self.get_download_url()
|
||||||
|
if url:
|
||||||
|
if callable(url):
|
||||||
|
return url(filename)
|
||||||
|
return url
|
||||||
|
|
||||||
|
def get_download_url(self):
|
||||||
|
if hasattr(self, 'download_url'):
|
||||||
|
return self.download_url
|
||||||
|
|
||||||
|
def render(self, **kwargs):
|
||||||
|
return Base.render(self, **kwargs)
|
||||||
|
|
||||||
|
def render_readonly(self, **kwargs):
|
||||||
|
"""
|
||||||
|
Render the filename and the binary size in a human readable with a link
|
||||||
|
to the file itself.
|
||||||
|
"""
|
||||||
|
value = self.get_file_path()
|
||||||
|
if value:
|
||||||
|
content = '{} ({})'.format(fsblob.normalized_basename(value),
|
||||||
|
self.readable_size())
|
||||||
|
return fsblob.h.content_tag('a', content,
|
||||||
|
href=self.get_url(value), **kwargs)
|
||||||
|
return ''
|
58
tailbone/forms/renderers/people.py
Normal file
58
tailbone/forms/renderers/people.py
Normal file
|
@ -0,0 +1,58 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
################################################################################
|
||||||
|
#
|
||||||
|
# Rattail -- Retail Software Framework
|
||||||
|
# Copyright © 2010-2016 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 Affero 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 Affero General Public License for
|
||||||
|
# more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with Rattail. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
################################################################################
|
||||||
|
"""
|
||||||
|
People Field Renderers
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import unicode_literals, absolute_import
|
||||||
|
|
||||||
|
from webhelpers.html import tags
|
||||||
|
|
||||||
|
from tailbone.forms.renderers.common import AutocompleteFieldRenderer
|
||||||
|
|
||||||
|
|
||||||
|
class PersonFieldRenderer(AutocompleteFieldRenderer):
|
||||||
|
"""
|
||||||
|
Renderer for :class:`rattail.db.model.Person` instance fields.
|
||||||
|
"""
|
||||||
|
service_route = 'people.autocomplete'
|
||||||
|
|
||||||
|
def render_readonly(self, **kwargs):
|
||||||
|
person = self.raw_value
|
||||||
|
if not person:
|
||||||
|
return ''
|
||||||
|
return tags.link_to(person, self.request.route_url('people.view', uuid=person.uuid))
|
||||||
|
|
||||||
|
|
||||||
|
class CustomerFieldRenderer(AutocompleteFieldRenderer):
|
||||||
|
"""
|
||||||
|
Renderer for :class:`rattail.db.model.Customer` instance fields.
|
||||||
|
"""
|
||||||
|
|
||||||
|
service_route = 'customers.autocomplete'
|
||||||
|
|
||||||
|
def render_readonly(self, **kwargs):
|
||||||
|
customer = self.raw_value
|
||||||
|
if not customer:
|
||||||
|
return ''
|
||||||
|
return tags.link_to(customer, self.request.route_url('customers.view', uuid=customer.uuid))
|
198
tailbone/forms/renderers/products.py
Normal file
198
tailbone/forms/renderers/products.py
Normal file
|
@ -0,0 +1,198 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
################################################################################
|
||||||
|
#
|
||||||
|
# Rattail -- Retail Software Framework
|
||||||
|
# Copyright © 2010-2016 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 Affero 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 Affero General Public License for
|
||||||
|
# more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with Rattail. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
################################################################################
|
||||||
|
"""
|
||||||
|
Product Field Renderers
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import unicode_literals, absolute_import
|
||||||
|
|
||||||
|
from rattail.gpc import GPC
|
||||||
|
from rattail.db import model
|
||||||
|
from rattail.db.util import maxlen
|
||||||
|
|
||||||
|
from formalchemy import TextFieldRenderer
|
||||||
|
from formalchemy.fields import SelectFieldRenderer
|
||||||
|
from webhelpers.html import tags, literal
|
||||||
|
|
||||||
|
from tailbone.forms.renderers.common import AutocompleteFieldRenderer
|
||||||
|
from tailbone.util import pretty_datetime
|
||||||
|
|
||||||
|
|
||||||
|
class ProductFieldRenderer(AutocompleteFieldRenderer):
|
||||||
|
"""
|
||||||
|
Renderer for :class:`rattail.db.model.Product` instance fields.
|
||||||
|
"""
|
||||||
|
|
||||||
|
service_route = 'products.autocomplete'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def field_display(self):
|
||||||
|
product = self.raw_value
|
||||||
|
if product:
|
||||||
|
return product.full_description
|
||||||
|
return ''
|
||||||
|
|
||||||
|
def render_readonly(self, **kwargs):
|
||||||
|
product = self.raw_value
|
||||||
|
if not product:
|
||||||
|
return ''
|
||||||
|
return tags.link_to(product, self.request.route_url('products.view', uuid=product.uuid))
|
||||||
|
|
||||||
|
|
||||||
|
class ProductKeyFieldRenderer(TextFieldRenderer):
|
||||||
|
"""
|
||||||
|
Base class for product key field renderers.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def render_readonly(self, **kwargs):
|
||||||
|
value = self.raw_value
|
||||||
|
if value is None:
|
||||||
|
return ''
|
||||||
|
value = self.render_value(value)
|
||||||
|
if kwargs.get('link'):
|
||||||
|
product = self.field.parent.model
|
||||||
|
value = tags.link_to(value, kwargs['link'](product))
|
||||||
|
return value
|
||||||
|
|
||||||
|
def render_value(self, value):
|
||||||
|
return unicode(value)
|
||||||
|
|
||||||
|
|
||||||
|
class GPCFieldRenderer(ProductKeyFieldRenderer):
|
||||||
|
"""
|
||||||
|
Renderer for :class:`rattail.gpc.GPC` fields.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def length(self):
|
||||||
|
# Hm, should maybe consider hard-coding this...?
|
||||||
|
return len(unicode(GPC(0)))
|
||||||
|
|
||||||
|
def render_value(self, gpc):
|
||||||
|
return gpc.pretty()
|
||||||
|
|
||||||
|
|
||||||
|
class ScancodeFieldRenderer(ProductKeyFieldRenderer):
|
||||||
|
"""
|
||||||
|
Renderer for :class:`rattail.db.model.Product.scancode` field
|
||||||
|
"""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def length(self):
|
||||||
|
return maxlen(model.Product.scancode)
|
||||||
|
|
||||||
|
|
||||||
|
class DepartmentFieldRenderer(SelectFieldRenderer):
|
||||||
|
"""
|
||||||
|
Shows the department number as well as the name.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def render_readonly(self, **kwargs):
|
||||||
|
department = self.raw_value
|
||||||
|
if not department:
|
||||||
|
return ''
|
||||||
|
if department.number:
|
||||||
|
text = '({}) {}'.format(department.number, department.name)
|
||||||
|
else:
|
||||||
|
text = department.name
|
||||||
|
return tags.link_to(text, self.request.route_url('departments.view', uuid=department.uuid))
|
||||||
|
|
||||||
|
|
||||||
|
class SubdepartmentFieldRenderer(SelectFieldRenderer):
|
||||||
|
"""
|
||||||
|
Shows a link to the subdepartment.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def render_readonly(self, **kwargs):
|
||||||
|
subdept = self.raw_value
|
||||||
|
if not subdept:
|
||||||
|
return ""
|
||||||
|
if subdept.number:
|
||||||
|
text = "({}) {}".format(subdept.number, subdept.name)
|
||||||
|
else:
|
||||||
|
text = subdept.name
|
||||||
|
return tags.link_to(text, self.request.route_url('subdepartments.view', uuid=subdept.uuid))
|
||||||
|
|
||||||
|
|
||||||
|
class CategoryFieldRenderer(SelectFieldRenderer):
|
||||||
|
"""
|
||||||
|
Shows a link to the category.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def render_readonly(self, **kwargs):
|
||||||
|
category = self.raw_value
|
||||||
|
if not category:
|
||||||
|
return ""
|
||||||
|
if category.code:
|
||||||
|
text = "({}) {}".format(category.code, category.name)
|
||||||
|
else:
|
||||||
|
text = category.name
|
||||||
|
return tags.link_to(text, self.request.route_url('categories.view', uuid=category.uuid))
|
||||||
|
|
||||||
|
|
||||||
|
class BrandFieldRenderer(AutocompleteFieldRenderer):
|
||||||
|
"""
|
||||||
|
Renderer for :class:`rattail.db.model.Brand` instance fields.
|
||||||
|
"""
|
||||||
|
|
||||||
|
service_route = 'brands.autocomplete'
|
||||||
|
|
||||||
|
|
||||||
|
class PriceFieldRenderer(TextFieldRenderer):
|
||||||
|
"""
|
||||||
|
Renderer for fields which reference a :class:`ProductPrice` instance.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def render_readonly(self, **kwargs):
|
||||||
|
price = self.field.raw_value
|
||||||
|
if price:
|
||||||
|
if not price.product.not_for_sale:
|
||||||
|
if price.price is not None and price.pack_price is not None:
|
||||||
|
if price.multiple > 1:
|
||||||
|
return literal('$ %0.2f / %u ($ %0.2f / %u)' % (
|
||||||
|
price.price, price.multiple,
|
||||||
|
price.pack_price, price.pack_multiple))
|
||||||
|
return literal('$ %0.2f ($ %0.2f / %u)' % (
|
||||||
|
price.price, price.pack_price, price.pack_multiple))
|
||||||
|
if price.price is not None:
|
||||||
|
if price.multiple > 1:
|
||||||
|
return '$ %0.2f / %u' % (price.price, price.multiple)
|
||||||
|
return '$ %0.2f' % price.price
|
||||||
|
if price.pack_price is not None:
|
||||||
|
return '$ %0.2f / %u' % (price.pack_price, price.pack_multiple)
|
||||||
|
return ''
|
||||||
|
|
||||||
|
|
||||||
|
class PriceWithExpirationFieldRenderer(PriceFieldRenderer):
|
||||||
|
"""
|
||||||
|
Price field renderer which also displays the expiration date, if present.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def render_readonly(self, **kwargs):
|
||||||
|
result = super(PriceWithExpirationFieldRenderer, self).render_readonly(**kwargs)
|
||||||
|
if result:
|
||||||
|
price = self.field.raw_value
|
||||||
|
if price.ends:
|
||||||
|
result = '{0} ({1})'.format(
|
||||||
|
result, pretty_datetime(self.request.rattail_config, price.ends))
|
||||||
|
return result
|
41
tailbone/forms/renderers/stores.py
Normal file
41
tailbone/forms/renderers/stores.py
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
################################################################################
|
||||||
|
#
|
||||||
|
# Rattail -- Retail Software Framework
|
||||||
|
# Copyright © 2010-2015 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 Affero 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 Affero General Public License for
|
||||||
|
# more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with Rattail. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
################################################################################
|
||||||
|
"""
|
||||||
|
Store Field Renderers
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
from formalchemy.fields import SelectFieldRenderer
|
||||||
|
|
||||||
|
|
||||||
|
class StoreFieldRenderer(SelectFieldRenderer):
|
||||||
|
"""
|
||||||
|
Renderer for :class:`rattail.db.model.Store` instance fields.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def render_readonly(self, **kwargs):
|
||||||
|
store = self.raw_value
|
||||||
|
if not store:
|
||||||
|
return ''
|
||||||
|
return '{0} - {1}'.format(store.id, store.name)
|
95
tailbone/forms/renderers/users.py
Normal file
95
tailbone/forms/renderers/users.py
Normal file
|
@ -0,0 +1,95 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
################################################################################
|
||||||
|
#
|
||||||
|
# Rattail -- Retail Software Framework
|
||||||
|
# Copyright © 2010-2017 Lance Edgar
|
||||||
|
#
|
||||||
|
# This file is part of Rattail.
|
||||||
|
#
|
||||||
|
# Rattail is free software: you can redistribute it and/or modify it under the
|
||||||
|
# terms of the GNU Affero 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 Affero General Public License for
|
||||||
|
# more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with Rattail. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
################################################################################
|
||||||
|
"""
|
||||||
|
User Field Renderers
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import unicode_literals, absolute_import
|
||||||
|
|
||||||
|
from rattail.db import model
|
||||||
|
from rattail.db.auth import has_permission, administrator_role
|
||||||
|
|
||||||
|
import formalchemy
|
||||||
|
from webhelpers.html import HTML, tags
|
||||||
|
|
||||||
|
from tailbone.db import Session
|
||||||
|
|
||||||
|
|
||||||
|
class UserFieldRenderer(formalchemy.TextFieldRenderer):
|
||||||
|
"""
|
||||||
|
Renderer for :class:`rattail:rattail.db.model.User` instance fields.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def render_readonly(self, **kwargs):
|
||||||
|
user = self.raw_value
|
||||||
|
if not user:
|
||||||
|
return ''
|
||||||
|
title = user.display_name
|
||||||
|
if kwargs.get('hyperlink') and self.request.has_perm('users.view'):
|
||||||
|
return tags.link_to(title, self.request.route_url('users.view', uuid=user.uuid))
|
||||||
|
return title
|
||||||
|
|
||||||
|
|
||||||
|
def PermissionsFieldRenderer(permissions, include_guest=False, include_authenticated=False):
|
||||||
|
|
||||||
|
class PermissionsFieldRenderer(formalchemy.FieldRenderer):
|
||||||
|
|
||||||
|
def deserialize(self):
|
||||||
|
perms = []
|
||||||
|
i = len(self.name) + 1
|
||||||
|
for key in self.params:
|
||||||
|
if key.startswith(self.name):
|
||||||
|
perms.append(key[i:])
|
||||||
|
return perms
|
||||||
|
|
||||||
|
def _render(self, readonly=False, **kwargs):
|
||||||
|
principal = self.field.model
|
||||||
|
html = ''
|
||||||
|
for groupkey in sorted(permissions, key=lambda k: permissions[k]['label'].lower()):
|
||||||
|
inner = HTML.tag('p', c=permissions[groupkey]['label'])
|
||||||
|
perms = permissions[groupkey]['perms']
|
||||||
|
rendered = False
|
||||||
|
for key in sorted(perms, key=lambda p: perms[p]['label'].lower()):
|
||||||
|
checked = has_permission(Session(), principal, key,
|
||||||
|
include_guest=include_guest,
|
||||||
|
include_authenticated=include_authenticated)
|
||||||
|
if checked or not readonly:
|
||||||
|
label = perms[key]['label']
|
||||||
|
if readonly:
|
||||||
|
span = HTML.tag('span', c="[X]" if checked else "[ ]")
|
||||||
|
inner += HTML.tag('p', class_='perm', c=span + ' ' + label)
|
||||||
|
else:
|
||||||
|
inner += tags.checkbox(self.name + '-' + key,
|
||||||
|
checked=checked, label=label)
|
||||||
|
rendered = True
|
||||||
|
if rendered:
|
||||||
|
html += HTML.tag('div', class_='group', c=inner)
|
||||||
|
return html or "(none granted)"
|
||||||
|
|
||||||
|
def render(self, **kwargs):
|
||||||
|
return self._render(**kwargs)
|
||||||
|
|
||||||
|
def render_readonly(self, **kwargs):
|
||||||
|
return self._render(readonly=True, **kwargs)
|
||||||
|
|
||||||
|
return PermissionsFieldRenderer
|
57
tailbone/forms/renderers/vendors.py
Normal file
57
tailbone/forms/renderers/vendors.py
Normal file
|
@ -0,0 +1,57 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
################################################################################
|
||||||
|
#
|
||||||
|
# Rattail -- Retail Software Framework
|
||||||
|
# Copyright © 2010-2016 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 Affero 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 Affero General Public License for
|
||||||
|
# more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with Rattail. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
################################################################################
|
||||||
|
"""
|
||||||
|
Vendor Field Renderers
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import unicode_literals, absolute_import
|
||||||
|
|
||||||
|
from formalchemy.fields import SelectFieldRenderer
|
||||||
|
from webhelpers.html import tags
|
||||||
|
|
||||||
|
from tailbone.forms.renderers.common import AutocompleteFieldRenderer
|
||||||
|
|
||||||
|
|
||||||
|
class VendorFieldRenderer(AutocompleteFieldRenderer):
|
||||||
|
"""
|
||||||
|
Renderer for :class:`rattail.db.model.Vendor` instance fields.
|
||||||
|
"""
|
||||||
|
service_route = 'vendors.autocomplete'
|
||||||
|
|
||||||
|
def render_readonly(self, **kwargs):
|
||||||
|
vendor = self.raw_value
|
||||||
|
if not vendor:
|
||||||
|
return ''
|
||||||
|
return tags.link_to(vendor, self.request.route_url('vendors.view', uuid=vendor.uuid))
|
||||||
|
|
||||||
|
|
||||||
|
class PurchaseFieldRenderer(SelectFieldRenderer):
|
||||||
|
"""
|
||||||
|
Renderer for :class:`rattail.db.model.Purchase` relation fields.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def render_readonly(self, **kwargs):
|
||||||
|
purchase = self.raw_value
|
||||||
|
if not purchase:
|
||||||
|
return ''
|
||||||
|
return tags.link_to(purchase, self.request.route_url('purchases.view', uuid=purchase.uuid))
|
83
tailbone/forms/simpleform.py
Normal file
83
tailbone/forms/simpleform.py
Normal file
|
@ -0,0 +1,83 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
################################################################################
|
||||||
|
#
|
||||||
|
# Rattail -- Retail Software Framework
|
||||||
|
# Copyright © 2010-2016 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 Affero 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 Affero General Public License for
|
||||||
|
# more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with Rattail. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
################################################################################
|
||||||
|
"""
|
||||||
|
Simple Forms
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import unicode_literals, absolute_import
|
||||||
|
|
||||||
|
from rattail.util import prettify
|
||||||
|
|
||||||
|
import pyramid_simpleform
|
||||||
|
from pyramid_simpleform import renderers
|
||||||
|
from webhelpers.html import tags
|
||||||
|
from webhelpers.html import HTML
|
||||||
|
|
||||||
|
from tailbone.forms import Form
|
||||||
|
|
||||||
|
|
||||||
|
class SimpleForm(Form):
|
||||||
|
"""
|
||||||
|
Customized simple form.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, request, schema, obj=None, **kwargs):
|
||||||
|
super(SimpleForm, self).__init__(request, **kwargs)
|
||||||
|
self._form = pyramid_simpleform.Form(request, schema=schema, obj=obj)
|
||||||
|
|
||||||
|
def __getattr__(self, attr):
|
||||||
|
return getattr(self._form, attr)
|
||||||
|
|
||||||
|
def render(self, **kwargs):
|
||||||
|
kwargs['form'] = FormRenderer(self)
|
||||||
|
return super(SimpleForm, self).render(**kwargs)
|
||||||
|
|
||||||
|
def validate(self):
|
||||||
|
return self._form.validate()
|
||||||
|
|
||||||
|
|
||||||
|
class FormRenderer(renderers.FormRenderer):
|
||||||
|
"""
|
||||||
|
Customized form renderer. Provides some extra methods for convenience.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __getattr__(self, attr):
|
||||||
|
return getattr(self.form, attr)
|
||||||
|
|
||||||
|
def field_div(self, name, field, label=None):
|
||||||
|
errors = self.errors_for(name)
|
||||||
|
if errors:
|
||||||
|
errors = [HTML.tag('div', class_='field-error', c=x) for x in errors]
|
||||||
|
errors = tags.literal('').join(errors)
|
||||||
|
|
||||||
|
label = HTML.tag('label', for_=name, c=label or prettify(name))
|
||||||
|
inner = HTML.tag('div', class_='field', c=field)
|
||||||
|
|
||||||
|
outer_class = 'field-wrapper'
|
||||||
|
if errors:
|
||||||
|
outer_class += ' error'
|
||||||
|
outer = HTML.tag('div', class_=outer_class, c=(errors or '') + label + inner)
|
||||||
|
return outer
|
||||||
|
|
||||||
|
def referrer_field(self):
|
||||||
|
return self.hidden('referrer', value=self.form.request.get_referrer())
|
|
@ -1,265 +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/>.
|
|
||||||
#
|
|
||||||
################################################################################
|
|
||||||
"""
|
|
||||||
Form Schema Types
|
|
||||||
"""
|
|
||||||
|
|
||||||
import re
|
|
||||||
import datetime
|
|
||||||
import json
|
|
||||||
|
|
||||||
from rattail.db import model
|
|
||||||
from rattail.gpc import GPC
|
|
||||||
|
|
||||||
import colander
|
|
||||||
|
|
||||||
from tailbone.db import Session
|
|
||||||
from tailbone.forms import widgets
|
|
||||||
|
|
||||||
|
|
||||||
class JQueryTime(colander.Time):
|
|
||||||
"""
|
|
||||||
Custom type for jQuery widget Time data.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def deserialize(self, node, cstruct):
|
|
||||||
if not cstruct:
|
|
||||||
return colander.null
|
|
||||||
|
|
||||||
formats = [
|
|
||||||
'%I:%M %p',
|
|
||||||
'%I:%M%p',
|
|
||||||
'%I %p',
|
|
||||||
'%I%p',
|
|
||||||
]
|
|
||||||
for fmt in formats:
|
|
||||||
try:
|
|
||||||
return datetime.datetime.strptime(cstruct, fmt).time()
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# re-try first format, for "better" error message
|
|
||||||
return datetime.datetime.strptime(cstruct, formats[0]).time()
|
|
||||||
|
|
||||||
|
|
||||||
class DateTimeBoolean(colander.Boolean):
|
|
||||||
"""
|
|
||||||
Schema type which presents the user with a "boolean" whereas the underlying
|
|
||||||
node is really a datetime (assumed to be "naive" UTC, and allow nulls).
|
|
||||||
"""
|
|
||||||
|
|
||||||
def deserialize(self, node, cstruct):
|
|
||||||
value = super(DateTimeBoolean, self).deserialize(node, cstruct)
|
|
||||||
if value: # else return None
|
|
||||||
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):
|
|
||||||
"""
|
|
||||||
Schema type for product GPC data.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def serialize(self, node, appstruct):
|
|
||||||
if appstruct is colander.null:
|
|
||||||
return colander.null
|
|
||||||
return str(appstruct)
|
|
||||||
|
|
||||||
def deserialize(self, node, cstruct):
|
|
||||||
if not cstruct:
|
|
||||||
return None
|
|
||||||
digits = re.sub(r'\D', '', cstruct)
|
|
||||||
if not digits:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
return GPC(digits)
|
|
||||||
except Exception as err:
|
|
||||||
raise colander.Invalid(node, str(err))
|
|
||||||
|
|
||||||
|
|
||||||
class ProductQuantity(colander.MappingSchema):
|
|
||||||
"""
|
|
||||||
Combo schema type for product cases and units; useful for inventory,
|
|
||||||
ordering, receiving etc. Meant to be used with the ``CasesUnitsWidget``.
|
|
||||||
"""
|
|
||||||
cases = colander.SchemaNode(colander.Decimal(), missing=colander.null)
|
|
||||||
|
|
||||||
units = colander.SchemaNode(colander.Decimal(), missing=colander.null)
|
|
||||||
|
|
||||||
|
|
||||||
class ModelType(colander.SchemaType):
|
|
||||||
"""
|
|
||||||
Custom schema type for scalar ORM relationship fields.
|
|
||||||
"""
|
|
||||||
model_class = None
|
|
||||||
session = None
|
|
||||||
|
|
||||||
def __init__(self, model_class=None, session=None):
|
|
||||||
if model_class:
|
|
||||||
self.model_class = model_class
|
|
||||||
if session:
|
|
||||||
self.session = session
|
|
||||||
else:
|
|
||||||
self.session = self.make_session()
|
|
||||||
|
|
||||||
def make_session(self):
|
|
||||||
return Session()
|
|
||||||
|
|
||||||
@property
|
|
||||||
def model_title(self):
|
|
||||||
self.model_class.get_model_title()
|
|
||||||
|
|
||||||
def serialize(self, node, appstruct):
|
|
||||||
if appstruct is colander.null:
|
|
||||||
return colander.null
|
|
||||||
return str(appstruct)
|
|
||||||
|
|
||||||
def deserialize(self, node, cstruct):
|
|
||||||
if not cstruct:
|
|
||||||
return None
|
|
||||||
obj = self.session.get(self.model_class, cstruct)
|
|
||||||
if not obj:
|
|
||||||
raise colander.Invalid(node, "{} not found".format(self.model_title))
|
|
||||||
return obj
|
|
||||||
|
|
||||||
|
|
||||||
# TODO: deprecate / remove this
|
|
||||||
ObjectType = ModelType
|
|
||||||
|
|
||||||
|
|
||||||
class StoreType(ModelType):
|
|
||||||
"""
|
|
||||||
Custom schema type for store field.
|
|
||||||
"""
|
|
||||||
model_class = model.Store
|
|
||||||
|
|
||||||
|
|
||||||
class CustomerType(ModelType):
|
|
||||||
"""
|
|
||||||
Custom schema type for customer field.
|
|
||||||
"""
|
|
||||||
model_class = model.Customer
|
|
||||||
|
|
||||||
|
|
||||||
class DepartmentType(ModelType):
|
|
||||||
"""
|
|
||||||
Custom schema type for department field.
|
|
||||||
"""
|
|
||||||
model_class = model.Department
|
|
||||||
|
|
||||||
|
|
||||||
class EmployeeType(ModelType):
|
|
||||||
"""
|
|
||||||
Custom schema type for employee field.
|
|
||||||
"""
|
|
||||||
model_class = model.Employee
|
|
||||||
|
|
||||||
|
|
||||||
class VendorType(ModelType):
|
|
||||||
"""
|
|
||||||
Custom schema type for vendor relationship field.
|
|
||||||
"""
|
|
||||||
model_class = model.Vendor
|
|
||||||
|
|
||||||
|
|
||||||
class ProductType(ModelType):
|
|
||||||
"""
|
|
||||||
Custom schema type for product relationship field.
|
|
||||||
"""
|
|
||||||
model_class = model.Product
|
|
||||||
|
|
||||||
|
|
||||||
class UserType(ModelType):
|
|
||||||
"""
|
|
||||||
Custom schema type for user field.
|
|
||||||
"""
|
|
||||||
model_class = model.User
|
|
153
tailbone/forms/validators.py
Normal file
153
tailbone/forms/validators.py
Normal file
|
@ -0,0 +1,153 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
################################################################################
|
||||||
|
#
|
||||||
|
# Rattail -- Retail Software Framework
|
||||||
|
# Copyright © 2010-2016 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 Affero 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 Affero General Public License for
|
||||||
|
# more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with Rattail. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
################################################################################
|
||||||
|
"""
|
||||||
|
Custom Form Validators
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import unicode_literals, absolute_import
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
from rattail.db import model
|
||||||
|
from rattail.db.util import validate_email_address, validate_phone_number
|
||||||
|
from rattail.gpc import GPC
|
||||||
|
|
||||||
|
import formencode as fe
|
||||||
|
import formalchemy as fa
|
||||||
|
|
||||||
|
from tailbone.db import Session
|
||||||
|
|
||||||
|
|
||||||
|
class ValidGPC(fe.validators.FancyValidator):
|
||||||
|
"""
|
||||||
|
Validator for fields which should contain GPC value.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _to_python(self, value, state):
|
||||||
|
if value is not None:
|
||||||
|
digits = re.sub(r'\D', '', value)
|
||||||
|
if digits:
|
||||||
|
try:
|
||||||
|
return GPC(digits)
|
||||||
|
except ValueError as error:
|
||||||
|
raise fe.Invalid("Invalid UPC: {}".format(error), value, state)
|
||||||
|
|
||||||
|
def _from_python(self, upc, state):
|
||||||
|
if upc is None:
|
||||||
|
return ''
|
||||||
|
return upc.pretty()
|
||||||
|
|
||||||
|
|
||||||
|
class ModelValidator(fe.validators.FancyValidator):
|
||||||
|
"""
|
||||||
|
Generic validator for data model reference fields.
|
||||||
|
"""
|
||||||
|
model_class = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def model_name(self):
|
||||||
|
self.model_class.__name__
|
||||||
|
|
||||||
|
def _to_python(self, value, state):
|
||||||
|
if value:
|
||||||
|
obj = Session.query(self.model_class).get(value)
|
||||||
|
if obj:
|
||||||
|
return obj
|
||||||
|
raise fe.Invalid("{} not found".format(self.model_name), value, state)
|
||||||
|
|
||||||
|
def _from_python(self, value, state):
|
||||||
|
obj = value
|
||||||
|
if not obj:
|
||||||
|
return ''
|
||||||
|
return obj.uuid
|
||||||
|
|
||||||
|
def validate_python(self, value, state):
|
||||||
|
obj = value
|
||||||
|
if obj is not None and not isinstance(obj, self.model_class):
|
||||||
|
raise fe.Invalid("Value must be a valid {} object".format(self.model_name), value, state)
|
||||||
|
|
||||||
|
|
||||||
|
class ValidStore(ModelValidator):
|
||||||
|
"""
|
||||||
|
Validator for store field.
|
||||||
|
"""
|
||||||
|
model_class = model.Store
|
||||||
|
|
||||||
|
|
||||||
|
class ValidCustomer(ModelValidator):
|
||||||
|
"""
|
||||||
|
Validator for customer field.
|
||||||
|
"""
|
||||||
|
model_class = model.Customer
|
||||||
|
|
||||||
|
|
||||||
|
class ValidDepartment(ModelValidator):
|
||||||
|
"""
|
||||||
|
Validator for department field.
|
||||||
|
"""
|
||||||
|
model_class = model.Department
|
||||||
|
|
||||||
|
|
||||||
|
class ValidEmployee(ModelValidator):
|
||||||
|
"""
|
||||||
|
Validator for employee field.
|
||||||
|
"""
|
||||||
|
model_class = model.Employee
|
||||||
|
|
||||||
|
|
||||||
|
class ValidProduct(ModelValidator):
|
||||||
|
"""
|
||||||
|
Validator for product field.
|
||||||
|
"""
|
||||||
|
model_class = model.Product
|
||||||
|
|
||||||
|
|
||||||
|
class ValidUser(ModelValidator):
|
||||||
|
"""
|
||||||
|
Validator for user field.
|
||||||
|
"""
|
||||||
|
model_class = model.User
|
||||||
|
|
||||||
|
|
||||||
|
def valid_email_address(value, field=None):
|
||||||
|
"""
|
||||||
|
FormAlchemy-compatible validation function, which leverages FormEncode
|
||||||
|
under the hood.
|
||||||
|
"""
|
||||||
|
if value:
|
||||||
|
try:
|
||||||
|
return validate_email_address(value, error=True)
|
||||||
|
except Exception as error:
|
||||||
|
raise fa.ValidationError(unicode(error))
|
||||||
|
|
||||||
|
|
||||||
|
def valid_phone_number(value, field=None):
|
||||||
|
"""
|
||||||
|
FormAlchemy-compatible validation function, which leverages FormEncode
|
||||||
|
under the hood.
|
||||||
|
"""
|
||||||
|
if value:
|
||||||
|
try:
|
||||||
|
return validate_phone_number(value, error=True)
|
||||||
|
except Exception as error:
|
||||||
|
raise fa.ValidationError(unicode(error))
|
|
@ -1,659 +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/>.
|
|
||||||
#
|
|
||||||
################################################################################
|
|
||||||
"""
|
|
||||||
Form Widgets
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import datetime
|
|
||||||
import decimal
|
|
||||||
import re
|
|
||||||
|
|
||||||
import colander
|
|
||||||
from deform import widget as dfwidget
|
|
||||||
from webhelpers2.html import tags, HTML
|
|
||||||
|
|
||||||
from tailbone.db import Session
|
|
||||||
|
|
||||||
|
|
||||||
class ReadonlyWidget(dfwidget.HiddenWidget):
|
|
||||||
|
|
||||||
readonly = True
|
|
||||||
|
|
||||||
def serialize(self, field, cstruct, **kw):
|
|
||||||
""" """
|
|
||||||
if cstruct in (colander.null, None):
|
|
||||||
cstruct = ''
|
|
||||||
# TODO: is this hacky?
|
|
||||||
text = kw.get('text')
|
|
||||||
if not text:
|
|
||||||
text = field.parent.tailbone_form.render_field_value(field.name)
|
|
||||||
return HTML.tag('span', text) + tags.hidden(field.name, value=cstruct, id=field.oid)
|
|
||||||
|
|
||||||
|
|
||||||
class NumberInputWidget(dfwidget.TextInputWidget):
|
|
||||||
template = 'numberinput'
|
|
||||||
autocomplete = 'off'
|
|
||||||
|
|
||||||
|
|
||||||
class NumericInputWidget(NumberInputWidget):
|
|
||||||
"""
|
|
||||||
This widget uses a ``<numeric-input>`` component, which will
|
|
||||||
leverage the ``numeric.js`` functions to ensure user doesn't enter
|
|
||||||
any non-numeric values. Note that this still uses a normal "text"
|
|
||||||
input on the HTML side, as opposed to a "number" input, since the
|
|
||||||
latter is a bit ugly IMHO.
|
|
||||||
"""
|
|
||||||
template = 'numericinput'
|
|
||||||
allow_enter = True
|
|
||||||
|
|
||||||
|
|
||||||
class PercentInputWidget(dfwidget.TextInputWidget):
|
|
||||||
"""
|
|
||||||
Custom text input widget, used for "percent" type fields. This widget
|
|
||||||
assumes that the underlying storage for the value is a "traditional"
|
|
||||||
percent value, e.g. ``0.36135`` - but the UI should represent this as a
|
|
||||||
"human-friendly" value, e.g. ``36.135 %``.
|
|
||||||
"""
|
|
||||||
template = 'percentinput'
|
|
||||||
autocomplete = 'off'
|
|
||||||
|
|
||||||
def serialize(self, field, cstruct, **kw):
|
|
||||||
""" """
|
|
||||||
if cstruct not in (colander.null, None):
|
|
||||||
# convert "traditional" value to "human-friendly"
|
|
||||||
value = decimal.Decimal(cstruct) * 100
|
|
||||||
value = value.quantize(decimal.Decimal('0.001'))
|
|
||||||
cstruct = str(value)
|
|
||||||
return super().serialize(field, cstruct, **kw)
|
|
||||||
|
|
||||||
def deserialize(self, field, pstruct):
|
|
||||||
""" """
|
|
||||||
pstruct = super().deserialize(field, pstruct)
|
|
||||||
if pstruct is colander.null:
|
|
||||||
return colander.null
|
|
||||||
# convert "human-friendly" value to "traditional"
|
|
||||||
try:
|
|
||||||
value = decimal.Decimal(pstruct)
|
|
||||||
except decimal.InvalidOperation:
|
|
||||||
raise colander.Invalid(field.schema, "Invalid decimal string: {}".format(pstruct))
|
|
||||||
value = value.quantize(decimal.Decimal('0.00001'))
|
|
||||||
value /= 100
|
|
||||||
return str(value)
|
|
||||||
|
|
||||||
|
|
||||||
class CasesUnitsWidget(dfwidget.Widget):
|
|
||||||
"""
|
|
||||||
Widget for collecting case and/or unit quantities. Most useful when you
|
|
||||||
need to ensure user provides cases *or* units but not both.
|
|
||||||
"""
|
|
||||||
template = 'cases_units'
|
|
||||||
amount_required = False
|
|
||||||
one_amount_only = False
|
|
||||||
|
|
||||||
def serialize(self, field, cstruct, **kw):
|
|
||||||
""" """
|
|
||||||
if cstruct in (colander.null, None):
|
|
||||||
cstruct = ''
|
|
||||||
readonly = kw.get('readonly', self.readonly)
|
|
||||||
kw['cases'] = cstruct['cases'] or ''
|
|
||||||
kw['units'] = cstruct['units'] or ''
|
|
||||||
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):
|
|
||||||
""" """
|
|
||||||
from tailbone.forms.types import ProductQuantity
|
|
||||||
|
|
||||||
if pstruct is colander.null:
|
|
||||||
return colander.null
|
|
||||||
|
|
||||||
schema = ProductQuantity()
|
|
||||||
try:
|
|
||||||
validated = schema.deserialize(pstruct)
|
|
||||||
except colander.Invalid as exc:
|
|
||||||
raise colander.Invalid(field.schema, "Invalid pstruct: %s" % exc)
|
|
||||||
|
|
||||||
if self.amount_required and not (validated['cases'] or validated['units']):
|
|
||||||
raise colander.Invalid(field.schema, "Must provide case or unit amount",
|
|
||||||
value=validated)
|
|
||||||
|
|
||||||
if self.amount_required and self.one_amount_only and validated['cases'] and validated['units']:
|
|
||||||
raise colander.Invalid(field.schema, "Must provide case *or* unit amount, "
|
|
||||||
"but *not* both", value=validated)
|
|
||||||
|
|
||||||
return validated
|
|
||||||
|
|
||||||
|
|
||||||
class DynamicCheckboxWidget(dfwidget.CheckboxWidget):
|
|
||||||
"""
|
|
||||||
This checkbox widget can be "dynamic" in the sense that form logic can
|
|
||||||
control its value and state.
|
|
||||||
"""
|
|
||||||
template = 'checkbox_dynamic'
|
|
||||||
|
|
||||||
|
|
||||||
# TODO: deprecate / remove this
|
|
||||||
class PlainSelectWidget(dfwidget.SelectWidget):
|
|
||||||
template = 'select_plain'
|
|
||||||
|
|
||||||
|
|
||||||
class CustomSelectWidget(dfwidget.SelectWidget):
|
|
||||||
"""
|
|
||||||
This widget is mostly for convenience. You can set extra kwargs for the
|
|
||||||
:meth:`serialize()` method, e.g.::
|
|
||||||
|
|
||||||
widget.set_template_values(foo='bar')
|
|
||||||
"""
|
|
||||||
|
|
||||||
def set_template_values(self, **kw):
|
|
||||||
if not hasattr(self, 'extra_template_values'):
|
|
||||||
self.extra_template_values = {}
|
|
||||||
self.extra_template_values.update(kw)
|
|
||||||
|
|
||||||
def get_template_values(self, field, cstruct, kw):
|
|
||||||
values = super().get_template_values(field, cstruct, kw)
|
|
||||||
if hasattr(self, 'extra_template_values'):
|
|
||||||
values.update(self.extra_template_values)
|
|
||||||
return values
|
|
||||||
|
|
||||||
|
|
||||||
class DynamicSelectWidget(CustomSelectWidget):
|
|
||||||
"""
|
|
||||||
This is a "normal" select widget, but instead of (or in addition to) its
|
|
||||||
values being set when constructed, they must be assigned dynamically in
|
|
||||||
real-time, e.g. based on other user selections.
|
|
||||||
|
|
||||||
Really all this widget "does" is render some Vue.js-compatible HTML, but
|
|
||||||
the page which contains the widget is ultimately responsible for wiring up
|
|
||||||
the logic for things to work right.
|
|
||||||
"""
|
|
||||||
template = 'select_dynamic'
|
|
||||||
|
|
||||||
|
|
||||||
class JQuerySelectWidget(dfwidget.SelectWidget):
|
|
||||||
template = 'select_jquery'
|
|
||||||
|
|
||||||
|
|
||||||
class PlainDateWidget(dfwidget.DateInputWidget):
|
|
||||||
template = 'date_plain'
|
|
||||||
|
|
||||||
|
|
||||||
class JQueryDateWidget(dfwidget.DateInputWidget):
|
|
||||||
"""
|
|
||||||
Uses the jQuery datepicker UI widget, instead of whatever it is deform uses
|
|
||||||
by default.
|
|
||||||
"""
|
|
||||||
template = 'date_jquery'
|
|
||||||
type_name = 'text'
|
|
||||||
requirements = None
|
|
||||||
|
|
||||||
default_options = (
|
|
||||||
('changeMonth', True),
|
|
||||||
('changeYear', True),
|
|
||||||
('dateFormat', 'yy-mm-dd'),
|
|
||||||
)
|
|
||||||
|
|
||||||
def serialize(self, field, cstruct, **kw):
|
|
||||||
""" """
|
|
||||||
if cstruct in (colander.null, None):
|
|
||||||
cstruct = ''
|
|
||||||
readonly = kw.get('readonly', self.readonly)
|
|
||||||
template = readonly and self.readonly_template or self.template
|
|
||||||
options = dict(
|
|
||||||
kw.get('options') or self.options or self.default_options
|
|
||||||
)
|
|
||||||
options.update(kw.get('extra_options', {}))
|
|
||||||
kw.setdefault('options_json', json.dumps(options))
|
|
||||||
kw.setdefault('selected_callback', None)
|
|
||||||
values = self.get_template_values(field, cstruct, kw)
|
|
||||||
return field.renderer(template, **values)
|
|
||||||
|
|
||||||
|
|
||||||
class JQueryTimeWidget(dfwidget.TimeInputWidget):
|
|
||||||
"""
|
|
||||||
Uses the jQuery datepicker UI widget, instead of whatever it is deform uses
|
|
||||||
by default.
|
|
||||||
"""
|
|
||||||
template = 'time_jquery'
|
|
||||||
type_name = 'text'
|
|
||||||
requirements = None
|
|
||||||
default_options = (
|
|
||||||
('showPeriod', True),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
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):
|
|
||||||
"""
|
|
||||||
Uses the jQuery autocomplete plugin, instead of whatever it is deform uses
|
|
||||||
by default.
|
|
||||||
"""
|
|
||||||
template = 'autocomplete_jquery'
|
|
||||||
requirements = None
|
|
||||||
field_display = ""
|
|
||||||
assigned_label = None
|
|
||||||
service_url = None
|
|
||||||
cleared_callback = None
|
|
||||||
selected_callback = None
|
|
||||||
input_callback = None
|
|
||||||
new_label_callback = None
|
|
||||||
ref = None
|
|
||||||
|
|
||||||
default_options = (
|
|
||||||
('autoFocus', True),
|
|
||||||
)
|
|
||||||
options = None
|
|
||||||
|
|
||||||
def serialize(self, field, cstruct, **kw):
|
|
||||||
""" """
|
|
||||||
if 'delay' in kw or getattr(self, 'delay', None):
|
|
||||||
raise ValueError(
|
|
||||||
'AutocompleteWidget does not support *delay* parameter '
|
|
||||||
'any longer.'
|
|
||||||
)
|
|
||||||
if cstruct in (colander.null, None):
|
|
||||||
cstruct = ''
|
|
||||||
self.values = self.values or []
|
|
||||||
readonly = kw.get('readonly', self.readonly)
|
|
||||||
|
|
||||||
options = dict(
|
|
||||||
kw.get('options') or self.options or self.default_options
|
|
||||||
)
|
|
||||||
options['source'] = self.service_url
|
|
||||||
|
|
||||||
kw['options'] = json.dumps(options)
|
|
||||||
kw['field_display'] = self.field_display
|
|
||||||
kw['cleared_callback'] = self.cleared_callback
|
|
||||||
kw['assigned_label'] = self.assigned_label
|
|
||||||
kw['input_callback'] = self.input_callback
|
|
||||||
kw['new_label_callback'] = self.new_label_callback
|
|
||||||
kw['ref'] = self.ref
|
|
||||||
kw.setdefault('selected_callback', self.selected_callback)
|
|
||||||
tmpl_values = self.get_template_values(field, cstruct, kw)
|
|
||||||
template = readonly and self.readonly_template or self.template
|
|
||||||
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):
|
|
||||||
"""
|
|
||||||
Make a customer widget; will be either autocomplete or dropdown
|
|
||||||
depending on config.
|
|
||||||
"""
|
|
||||||
# use autocomplete widget by default
|
|
||||||
factory = CustomerAutocompleteWidget
|
|
||||||
|
|
||||||
# caller may request dropdown widget
|
|
||||||
if kwargs.pop('dropdown', False):
|
|
||||||
factory = CustomerDropdownWidget
|
|
||||||
|
|
||||||
else: # or, config may say to use dropdown
|
|
||||||
if request.rattail_config.getbool(
|
|
||||||
'rattail', 'customers.choice_uses_dropdown',
|
|
||||||
default=False):
|
|
||||||
factory = CustomerDropdownWidget
|
|
||||||
|
|
||||||
# instantiate whichever
|
|
||||||
return factory(request, **kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
class CustomerAutocompleteWidget(JQueryAutocompleteWidget):
|
|
||||||
"""
|
|
||||||
Autocomplete widget for a
|
|
||||||
:class:`~rattail:rattail.db.model.customers.Customer` 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('customers.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 customer to provide button label, if we have a value
|
|
||||||
if cstruct:
|
|
||||||
app = self.request.rattail_config.get_app()
|
|
||||||
model = app.model
|
|
||||||
customer = Session.get(model.Customer, cstruct)
|
|
||||||
if customer:
|
|
||||||
self.field_display = str(customer)
|
|
||||||
|
|
||||||
return super().serialize(
|
|
||||||
field, cstruct, **kw)
|
|
||||||
|
|
||||||
|
|
||||||
class CustomerDropdownWidget(dfwidget.SelectWidget):
|
|
||||||
"""
|
|
||||||
Dropdown widget for a
|
|
||||||
:class:`~rattail:rattail.db.model.customers.Customer` reference
|
|
||||||
field.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, request, *args, **kwargs):
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
self.request = request
|
|
||||||
app = self.request.rattail_config.get_app()
|
|
||||||
|
|
||||||
# must figure out dropdown values, if they weren't given
|
|
||||||
if 'values' not in kwargs:
|
|
||||||
|
|
||||||
# use what caller gave us, if they did
|
|
||||||
if 'customers' in kwargs:
|
|
||||||
customers = kwargs['customers']
|
|
||||||
if callable(customers):
|
|
||||||
customers = customers()
|
|
||||||
|
|
||||||
else: # default customer list
|
|
||||||
customers = app.get_clientele_handler()\
|
|
||||||
.get_all_customers(Session())
|
|
||||||
|
|
||||||
# convert customer list to option values
|
|
||||||
self.values = [(c.uuid, c.name)
|
|
||||||
for c in customers]
|
|
||||||
|
|
||||||
|
|
||||||
class DepartmentWidget(dfwidget.SelectWidget):
|
|
||||||
"""
|
|
||||||
Custom select widget for a Department reference field.
|
|
||||||
|
|
||||||
Constructor accepts the normal ``values`` kwarg but if not
|
|
||||||
provided then the widget will fetch department list from Rattail
|
|
||||||
DB.
|
|
||||||
|
|
||||||
Constructor also accepts ``required`` kwarg, which defaults to
|
|
||||||
true unless specified.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, request, **kwargs):
|
|
||||||
|
|
||||||
if 'values' not in kwargs:
|
|
||||||
app = request.rattail_config.get_app()
|
|
||||||
model = app.model
|
|
||||||
departments = Session.query(model.Department)\
|
|
||||||
.order_by(model.Department.number)
|
|
||||||
values = [(dept.uuid, str(dept))
|
|
||||||
for dept in departments]
|
|
||||||
if not kwargs.pop('required', True):
|
|
||||||
values.insert(0, ('', "(none)"))
|
|
||||||
kwargs['values'] = values
|
|
||||||
|
|
||||||
super().__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]
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue