Compare commits

..

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

389 changed files with 25506 additions and 29784 deletions

3
.gitignore vendored
View file

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

View file

@ -1,689 +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.8 (2025-05-20)
### Fix
- add startup hack for tempmon DB model
## v0.22.7 (2025-02-19)
### Fix
- stop using old config for logo image url on login page
- fix warning msg for deprecated Grid param
## v0.22.6 (2025-02-01)
### Fix
- register vue3 form component for products -> make batch
## v0.22.5 (2024-12-16)
### Fix
- whoops this is latest rattail
- require newer rattail lib
- require newer wuttaweb
- let caller request safe HTML literal for rendered grid table
## v0.22.4 (2024-11-22)
### Fix
- avoid error in product search for duplicated key
- use vmodel for confirm password widget input
## v0.22.3 (2024-11-19)
### Fix
- avoid error for trainwreck query when not a customer
## v0.22.2 (2024-11-18)
### Fix
- use local/custom enum for continuum operations
- add basic master view for Product Costs
- show continuum operation type when viewing version history
- always define `app` attr for ViewSupplement
- avoid deprecated import
## v0.22.1 (2024-11-02)
### Fix
- fix submit button for running problem report
- avoid deprecated grid method
## v0.22.0 (2024-10-22)
### Feat
- add support for new ordering batch from parsed file
### Fix
- avoid deprecated method to suggest username
## v0.21.11 (2024-10-03)
### Fix
- custom method for adding grid action
- become/stop root should redirect to previous url
## v0.21.10 (2024-09-15)
### Fix
- update project repo links, kallithea -> forgejo
- use better icon for submit button on login page
- wrap notes text for batch view
- expose datasync consumer batch size via configure page
## v0.21.9 (2024-08-28)
### Fix
- render custom attrs in form component tag
## v0.21.8 (2024-08-28)
### Fix
- ignore session kwarg for `MasterView.make_row_grid()`
## v0.21.7 (2024-08-28)
### Fix
- avoid error when form value cannot be obtained
## v0.21.6 (2024-08-28)
### Fix
- avoid error when grid value cannot be obtained
## v0.21.5 (2024-08-28)
### Fix
- set empty string for "-new-" file configure option
## v0.21.4 (2024-08-26)
### Fix
- handle differing email profile keys for appinfo/configure
## v0.21.3 (2024-08-26)
### Fix
- show non-standard config values for app info configure email
## v0.21.2 (2024-08-26)
### Fix
- refactor waterpark base template to use wutta feedback component
- fix input/output file upload feature for configure pages, per oruga
- tweak how grid data translates to Vue template context
- merge filters into main grid template
- add basic wutta view for users
- some fixes for wutta people view
- various fixes for waterpark theme
- avoid deprecated `component` form kwarg
## v0.21.1 (2024-08-22)
### Fix
- misc. bugfixes per recent changes
## v0.21.0 (2024-08-22)
### Feat
- move "most" filtering logic for grid class to wuttaweb
- inherit from wuttaweb templates for home, login pages
- inherit from wuttaweb for AppInfoView, appinfo/configure template
- add "has output file templates" config option for master view
### Fix
- change grid reset-view param name to match wuttaweb
- move "searchable columns" grid feature to wuttaweb
- use wuttaweb to get/render csrf token
- inherit from wuttaweb for appinfo/index template
- prefer wuttaweb config for "home redirect to login" feature
- fix master/index template rendering for waterpark theme
- fix spacing for navbar logo/title in waterpark theme
## v0.20.1 (2024-08-20)
### Fix
- fix default filter verbs logic for workorder status
## v0.20.0 (2024-08-20)
### Feat
- add new 'waterpark' theme, based on wuttaweb w/ vue2 + buefy
- refactor templates to simplify base/page/form structure
### Fix
- avoid deprecated reference to app db engine
## v0.19.3 (2024-08-19)
### Fix
- add pager stats to all grid vue data (fixes view history)
## v0.19.2 (2024-08-19)
### Fix
- sort on frontend for appinfo package listing grid
- prefer attr over key lookup when getting model values
- replace all occurrences of `component_studly` => `vue_component`
## v0.19.1 (2024-08-19)
### Fix
- fix broken user auth for web API app
## v0.19.0 (2024-08-18)
### Feat
- move multi-column grid sorting logic to wuttaweb
- move single-column grid sorting logic to wuttaweb
### Fix
- fix misc. errors in grid template per wuttaweb
- fix broken permission directives in web api startup
## v0.18.0 (2024-08-16)
### Feat
- move "basic" grid pagination logic to wuttaweb
- inherit from wutta base class for Grid
- inherit most logic from wuttaweb, for GridAction
### Fix
- avoid route error in user view, when using wutta people view
- fix some more wutta compat for base template
## v0.17.0 (2024-08-15)
### Feat
- use wuttaweb for `get_liburl()` logic
## v0.16.1 (2024-08-15)
### Fix
- improve wutta People view a bit
- update references to `get_class_hierarchy()`
- tweak template for `people/view_profile` per wutta compat
## v0.16.0 (2024-08-15)
### Feat
- add first wutta-based master, for PersonView
- refactor forms/grids/views/templates per wuttaweb compat
## v0.15.6 (2024-08-13)
### Fix
- avoid `before_render` subscriber hook for web API
- simplify verbiage for batch execution panel
## v0.15.5 (2024-08-09)
### Fix
- assign convenience attrs for all views (config, app, enum, model)
## v0.15.4 (2024-08-09)
### Fix
- avoid bug when checking current theme
## v0.15.3 (2024-08-08)
### Fix
- fix timepicker `parseTime()` when value is null
## v0.15.2 (2024-08-06)
### Fix
- use auth handler, avoid legacy calls for role/perm checks
## v0.15.1 (2024-08-05)
### Fix
- move magic `b` template context var to wuttaweb
## v0.15.0 (2024-08-05)
### Feat
- move more subscriber logic to wuttaweb
### Fix
- use wuttaweb logic for `util.get_form_data()`
## v0.14.5 (2024-08-03)
### Fix
- use auth handler instead of deprecated auth functions
- avoid duplicate `partial` param when grid reloads data
## v0.14.4 (2024-07-18)
### Fix
- fix more settings persistence bug(s) for datasync/configure
- fix modals for luigi tasks page, per oruga
## v0.14.3 (2024-07-17)
### Fix
- fix auto-collapse title for viewing trainwreck txn
- allow auto-collapse of header when viewing trainwreck txn
## v0.14.2 (2024-07-15)
### Fix
- add null menu handler, for use with API apps
## v0.14.1 (2024-07-14)
### Fix
- update usage of auth handler, per rattail changes
- fix model reference in menu handler
- fix bug when making "integration" menus
## v0.14.0 (2024-07-14)
### Feat
- move core menu logic to wuttaweb
## v0.13.2 (2024-07-13)
### Fix
- fix logic bug for datasync/config settings save
## v0.13.1 (2024-07-13)
### Fix
- fix settings persistence bug(s) for datasync/configure page
## v0.13.0 (2024-07-12)
### Feat
- begin integrating WuttaWeb as upstream dependency
### Fix
- cast enum as list to satisfy deform widget
## v0.12.1 (2024-07-11)
### Fix
- refactor `config.get_model()` => `app.model`
## v0.12.0 (2024-07-09)
### Feat
- drop python 3.6 support, use pyproject.toml (again)
## v0.11.10 (2024-07-05)
### Fix
- make the Members tab optional, for profile view
## v0.11.9 (2024-07-05)
### Fix
- do not show flash message when changing app theme
- improve collapse panels for butterball theme
- expand input for butterball theme
- add xref button to customer profile, for trainwreck txn view
- add optional Transactions tab for profile view
## v0.11.8 (2024-07-04)
### Fix
- fix grid action icons for datasync/configure, per oruga
- allow view supplements to add extra links for profile employee tab
- leverage import handler method to determine command/subcommand
- add tool to make user account from profile view
## v0.11.7 (2024-07-04)
### Fix
- add stacklevel to deprecation warnings
- require zope.sqlalchemy >= 1.5
- include edit profile email/phone dialogs only if user has perms
- allow view supplements to add to profile member context
- cast enum as list to satisfy deform widget
- expand POD image URL setting input
## v0.11.6 (2024-07-01)
### Fix
- set explicit referrer when changing dbkey
- remove references, dependency for `six` package
## v0.11.5 (2024-06-30)
### Fix
- allow comma in numeric filter input
- add custom url prefix if needed, for fanstatic
- use vue 3.4.31 and oruga 0.8.12 by default
## v0.11.4 (2024-06-30)
### Fix
- start/stop being root should submit POST instead of GET
- require vendor when making new ordering batch via api
- don't escape each address for email attempts grid
## v0.11.3 (2024-06-28)
### Fix
- add link to "resolved by" user for pending products
- handle error when merging 2 records fails
## v0.11.2 (2024-06-18)
### Fix
- hide certain custorder settings if not applicable
- use different logic for buefy/oruga for product lookup keydown
- product records should be touchable
- show flash error message if resolve pending product fails
## v0.11.1 (2024-06-14)
### Fix
- revert back to setup.py + setup.cfg
## v0.11.0 (2024-06-10)
### Feat
- switch from setup.cfg to pyproject.toml + hatchling
## v0.10.16 (2024-06-10)
### Feat
- standardize how app, package versions are determined
### Fix
- avoid deprecated config methods for app/node title
## v0.10.15 (2024-06-07)
### Fix
- do *not* Use `pkg_resources` to determine package versions
## v0.10.14 (2024-06-06)
### Fix
- use `pkg_resources` to determine package versions
## v0.10.13 (2024-06-06)
### Feat
- remove old/unused scaffold for use with `pcreate`
- add 'fanstatic' support for sake of libcache assets
## v0.10.12 (2024-06-04)
### Feat
- require pyramid 2.x; remove 1.x-style auth policies
- remove version cap for deform
- set explicit referrer when changing app theme
- add `<b-tooltip>` component shim
- include extra styles from `base_meta` template for butterball
- include butterball theme by default for new apps
### Fix
- fix product lookup component, per butterball
## v0.10.11 (2024-06-03)
### Feat
- fix vue3 refresh bugs for various views
- fix grid bug for tempmon appliance view, per oruga
- fix ordering worksheet generator, per butterball
- fix inventory worksheet generator, per butterball
## v0.10.10 (2024-06-03)
### Feat
- more butterball fixes for "view profile" template
### Fix
- fix focus for `<b-select>` shim component
## v0.10.9 (2024-06-03)
### Feat
- let master view control context menu items for page
- fix the "new custorder" page for butterball
### Fix
- fix panel style for PO vs. Invoice breakdown in receiving batch
## v0.10.8 (2024-06-02)
### Feat
- add styling for checked grid rows, per oruga/butterball
- fix product view template for oruga/butterball
- allow per-user custom styles for butterball
- use oruga 0.8.9 by default
## v0.10.7 (2024-06-01)
### Feat
- add setting to allow decimal quantities for receiving
- log error if registry has no rattail config
- add column filters for import/export main grid
- escape all unsafe html for grid data
- add speedbumps for delete, set preferred email/phone in profile view
- fix file upload widget for oruga
### Fix
- fix overflow when instance header title is too long (butterball)
## v0.10.6 (2024-05-29)
### Feat
- add way to flag organic products within lookup dialog
- expose db picker for butterball theme
- expose quickie lookup for butterball theme
- fix basic problems with people profile view, per butterball
## v0.10.5 (2024-05-29)
### Feat
- add `<tailbone-timepicker>` component for oruga
## v0.10.4 (2024-05-12)
### Fix
- fix styles for grid actions, per butterball
## v0.10.3 (2024-05-10)
### Fix
- fix bug with grid date filters
## v0.10.2 (2024-05-08)
### Feat
- remove version restriction for pyramid_beaker dependency
- rename some attrs etc. for buefy components used with oruga
- fix "tools" helper for receiving batch view, per oruga
- more data type fixes for ``<tailbone-datepicker>``
- fix "view receiving row" page, per oruga
- tweak styles for grid action links, per butterball
### Fix
- fix employees grid when viewing department (per oruga)
- fix login "enter" key behavior, per oruga
- fix button text for autocomplete
## v0.10.1 (2024-04-28)
### Feat
- sort list of available themes
- update various icon names for oruga compatibility
- show "View This" button when cloning a record
- stop including 'falafel' as available theme
### Fix
- fix vertical alignment in main menu bar, for butterball
- fix upgrade execution logic/UI per oruga
## v0.10.0 (2024-04-28)
This version bump is to reflect adding support for Vue 3 + Oruga via
the 'butterball' theme. There is likely more work to be done for that
yet, but it mostly works at this point.
### Feat
- misc. template and view logic tweaks (applicable to all themes) for
better patterns, consistency etc.
- add initial support for Vue 3 + Oruga, via "butterball" theme
## Older Releases
Please see `docs/OLDCHANGES.rst` for older release notes.

View file

@ -2,911 +2,6 @@
CHANGELOG
=========
NB. this file contains "old" release notes only. for newer releases
see the `CHANGELOG.md` file in the source root folder.
0.9.96 (2024-04-25)
-------------------
* Remove unused code for ``webhelpers2_grid``.
* Rename setting for custom user css (remove "buefy").
* Fix permission checks for root user with pyramid 2.x.
* Cleanup grid/filters logic a bit.
* Use normal (not checkbox) button for grid filters.
* Tweak icon for Download Results button.
* Use v-model to track selection etc. for download results fields.
* Allow deleting rows from executed batches.
0.9.95 (2024-04-19)
-------------------
* Fix ASGI websockets when serving on sub-path under site root.
* Fix raw query to avoid SQLAlchemy 2.x warnings.
* Remove config "style" from appinfo page.
0.9.94 (2024-04-16)
-------------------
* Fix master template bug when no form in context.
0.9.93 (2024-04-16)
-------------------
* Improve form support for view supplements.
* Prevent multi-click for grid filters "Save Defaults" button.
* Fix typo when getting app instance.
0.9.92 (2024-04-16)
-------------------
* Escape underscore char for "contains" query filter.
* Rename custom ``user_css`` context.
* Add support for Pyramid 2.x; new security policy.
0.9.91 (2024-04-15)
-------------------
* Avoid uncaught error when updating order batch row quantities.
* Try to return JSON error when receiving API call fails.
* Avoid error for tax field when creating new department.
* Show toast msg instead of silent error, when grid fetch fails.
* Remove most references to "buefy" name in class methods, template
filenames etc.
0.9.90 (2024-04-01)
-------------------
* Add basic CRUD for Person "preferred first name".
0.9.89 (2024-03-27)
-------------------
* Fix bulk-delete rows for import/export batch.
0.9.88 (2024-03-26)
-------------------
* Update some SQLAlchemy logic per upcoming 2.0 changes.
0.9.87 (2023-12-26)
-------------------
* Auto-disable submit button for login form.
* Hide single invoice file field for multi-invoice receiving batch.
* Use common logic to render invoice total for receiving.
* Expose default custorder discount for Departments.
0.9.86 (2023-12-12)
-------------------
* Use ``ltrim(rtrim())`` instead of just ``trim()`` in grid filters.
0.9.85 (2023-12-01)
-------------------
* Use clientele handler to populate customer dropdown widget.
0.9.84 (2023-11-30)
-------------------
* Provide a way to show enum display text for some version diff fields.
0.9.83 (2023-11-30)
-------------------
* Avoid error when editing a department.
0.9.82 (2023-11-19)
-------------------
* Fix DB picker, theme picker per Buefy conventions.
0.9.81 (2023-11-15)
-------------------
* Log warning instead of error for batch population error.
* Remove reference to ``pytz`` library.
* Avoid outright error if user scans barcode for inventory count.
0.9.80 (2023-11-05)
-------------------
* Expose status code for equity payments.
0.9.79 (2023-11-01)
-------------------
* Add button to confirm all costs for receiving.
0.9.78 (2023-11-01)
-------------------
* Use shared logic to get batch handler.
* Fix config key for default themes list.
0.9.77 (2023-11-01)
-------------------
* Encode values for "between" query filter.
* Avoid error when rendering version diff.
0.9.76 (2023-11-01)
-------------------
* Fix missing import.
0.9.75 (2023-11-01)
-------------------
* Add deprecation warnings for ambgiguous config keys.
0.9.74 (2023-10-30)
-------------------
* Log warning / avoid error if email profile can't be normalized.
0.9.73 (2023-10-29)
-------------------
* Add way to "ignore" a pending product.
* Tweak param docs for ``Form.set_validator()``.
* Remove unused "simple menus" module approach.
0.9.72 (2023-10-26)
-------------------
* Use product lookup component for "resolve pending product" tool.
0.9.71 (2023-10-25)
-------------------
* Fix bug when editing vendor.
* Show user warning if "add item to custorder" fails.
* Allow pending product fields to be required, for new custorder.
* Add price confirm prompt when adding unknown item to custorder.
* Use ``<b-select>`` for theme picker.
* Add ``column_only`` kwarg for ``Grid.set_label()`` method.
* Do not show profile buttons for inactive customer shoppers.
* Add separate perm for making new custorder for unknown product.
* Expand the "product lookup" component to include autocomplete.
0.9.70 (2023-10-24)
-------------------
* Fix config file priority for display, and batch subprocess commands.
0.9.69 (2023-10-24)
-------------------
* Allow override of version diff for master views.
* No need to configure logging.
0.9.68 (2023-10-23)
-------------------
* Expose more permissions for POS.
* Fix order xlsx download if missing order date.
* Replace dropdowns with autocomplete, for "find principals by perm".
* Use ``Grid.make_sorter()`` instead of legacy code.
* Avoid "None" when rendering product UOM field.
* Fix default grid filter when "local" date times are involved.
* Expose new fields for POS batch/row.
* Remove sorter for "Credits?" column in purchasing batch row grid.
* Add validation to prevent duplicate files for multi-invoice receiving.
* Include invoice number for receiving batch row API.
* Show food stamp tender info for POS batch.
* Stop using sa-filters for basic grid sorting.
0.9.67 (2023-10-12)
-------------------
* Fix grid sorting when column key/name differ.
* Expose department tax, FS flag.
* Add permission for testing error handling at POS.
* Add some awareness of suspend/resume for POS batch.
* Fix version child classes for Customers view.
0.9.66 (2023-10-11)
-------------------
* Make grid JS ``loadAsyncData()`` method truly async.
* Add support for multi-column grid sorting.
* Add smarts to show display text for some version diff fields.
* Allow null for FalafelDateTime form fields.
* Show full version history within the "view" page.
* Use autocomplete instead of dropdown for grid "add filter".
0.9.65 (2023-10-07)
-------------------
* Avoid deprecated logic for fetching vendor contact email/phone.
* Add "mark complete" button for inventory batch row entry page.
* Expose tender ref in POS batch rows; new tender flags.
* Improve views for taxes, esp. in POS batches.
0.9.64 (2023-10-06)
-------------------
* Fix bug for param helptext in New Report page.
0.9.63 (2023-10-06)
-------------------
* Fix CRUD pages for tempmon clients, probes.
* Fix bug in POS batch view.
* Expose permissions for POS, if so configured.
0.9.62 (2023-10-04)
-------------------
* Avoid deprecated ``pretty_hours()`` function.
* Improve master view ``oneoff_import()`` method.
0.9.61 (2023-10-04)
-------------------
* Use enum to display ``POS_ROW_TYPE``.
* Expose cash-back flags for tenders.
* Re-work FalafelDateTime logic a bit.
0.9.60 (2023-10-01)
-------------------
* Do not allow executing custorder if no customer is set.
* Add clone support for POS batches.
* Expose views for tenders, more columns for POS batch/rows.
* Tidy up logic for vendor filtering in products grid.
* Add support for void rows in POS batch.
0.9.59 (2023-09-25)
-------------------
* Add custom form type/widget for time fields.
0.9.58 (2023-09-25)
-------------------
* Expose POS batch views as "typical".
0.9.57 (2023-09-24)
-------------------
* Show yesterday by default for Trainwreck if so configured.
* Add ``remove_sorter()`` method for grids.
* Show "true" (calculated) equity total in members grid.
* Add basic views for POS batches.
* Show customer for POS batches.
* Use header button instead of link for "touch" instance.
0.9.56 (2023-09-19)
-------------------
* Add link to vendor name for receiving batches grid.
* Prevent catalog/invoice cost edits if receiving batch is complete.
* Use small text input for receiving cost editor fields.
* Show catalog/invoice costs as 2-decimal currency in receiving.
0.9.55 (2023-09-18)
-------------------
* Show user warning if receive quick lookup fails.
* Fix bug for new receiving from scratch via API.
0.9.54 (2023-09-17)
-------------------
* Add "falafel" custom date/time field type and widget.
* Avoid error when history has blanks for ordering worksheet.
* Include PO number for receiving batch details via API.
* Tweaks to improve handling of "missing" items for receiving.
0.9.53 (2023-09-16)
-------------------
* Make member key field readonly when viewing equity payment.
0.9.52 (2023-09-15)
-------------------
* Add basic feature for "grid totals".
0.9.51 (2023-09-15)
-------------------
* Tweak default field list for batch views.
* Add ``get_rattail_app()`` method for view supplements.
0.9.50 (2023-09-12)
-------------------
* Avoid legacy logic for ``Customer.people`` schema.
* Show events instead of notes, in field subgrid for custorder item.
0.9.49 (2023-09-11)
-------------------
* Add custom hook for grid "apply filters".
* Use common POST logic for submitting new customer order.
* Optionally configure SQLAlchemy Session with ``future=True``.
* Show related customer orders for Pending Product view.
* Set stacklevel for all deprecation warnings.
* Add support for toggling custorder item "flagged".
* Add support for "mark received" when viewing custorder item.
* Misc. improvements for custorder views.
0.9.48 (2023-09-08)
-------------------
* Add grid link for equity payment description.
* Fix msg body display, download link for email bounces.
* Fix member key display for equity payment form.
0.9.47 (2023-09-07)
-------------------
* Fallback to None when getting values for merge preview.
0.9.46 (2023-09-07)
-------------------
* Improve display for member equity payments.
0.9.45 (2023-09-02)
-------------------
* Add grid filter type for BigInteger columns.
* Add products API route to fetch label profiles for use w/ printing.
* Tweaks for cost editing within a receiving batch.
0.9.44 (2023-08-31)
-------------------
* Avoid deprecated ``User.email_address`` property.
* Preserve URL hash when redirecting in grid "reset to defaults".
0.9.43 (2023-08-30)
-------------------
* Let "new product" batch override type-2 UPC lookup behavior.
0.9.42 (2023-08-29)
-------------------
* When bulk-deleting, skip objects which are not "deletable".
* Declare "from PO" receiving workflow if applicable, in API.
* Auto-select text when editing costs for receiving.
* Include shopper history from parent customer account perspective.
* Link to product record, for New Product batch row.
* Fix profile history to show when a CustomerShopperHistory is deleted.
* Fairly massive overhaul of the Profile view; standardize tabs etc..
* Add support for "missing" credit in mobile receiving.
0.9.41 (2023-08-08)
-------------------
* Add common logic to validate employee reference field.
* Fix HTML rendering for UOM choice options.
* Fix custom cell click handlers in main buefy grid tables.
0.9.40 (2023-08-03)
-------------------
* Make system key searchable for problem report grid.
0.9.39 (2023-07-15)
-------------------
* Show invoice number for each row in receiving.
* Tweak display options for tempmon probe readings graph.
0.9.38 (2023-07-07)
-------------------
* Optimize "auto-receive" batch process.
0.9.37 (2023-07-03)
-------------------
* Avoid deprecated product key field getter.
* Allow "arbitrary" PO attachment to purchase batch.
0.9.36 (2023-06-20)
-------------------
* Include user "active" flag in profile view context.
0.9.35 (2023-06-20)
-------------------
* Add views etc. for member equity payments.
* Improve merge support for records with no uuid.
* Turn on quickie person search for CustomerShopper views.
0.9.34 (2023-06-17)
-------------------
* Add basic Shopper tab for profile view.
* Cleanup some wording in profile view template.
* Tweak ``SimpleRequestMixin`` to not rely on ``response.data.ok``.
* Add support for Notes tab in profile view.
* Add basic support for Person quickie lookup.
* Hide unwanted revisions for CustomerPerson etc.
* Fix some things for viewing a member.
0.9.33 (2023-06-16)
-------------------
* Update usage of app handler per upstream changes.
0.9.32 (2023-06-16)
-------------------
* Fix grid filter bug when switching from 'equal' to 'between' verbs.
* Add users context data for profile view.
* Join the Person model for Customers grid differently based on config.
0.9.31 (2023-06-15)
-------------------
* Prefer account holder, shoppers over legacy ``Customers.people``.
0.9.30 (2023-06-12)
-------------------
* Add basic support for exposing ``Customer.shoppers``.
* Move "view history" and related buttons, for person profile view.
* Consider vendor catalog batch views "typical".
* Let external customer link buttons be more dynamic, for profile view.
* Add options for grid results to link straight to Profile view.
* Change label for Member.person to "Account Holder".
0.9.29 (2023-06-06)
-------------------
* Add "typical" view config, for e.g. Theo and the like.
* Add customer number filter for People grid.
* Tweak logic for ``MasterView.get_action_route_kwargs()``.
* Add "touch" support for Members.
* Add support for "configured customer/member key".
* Use *actual* current URL for user feedback msg.
* Remove old/unused feedback templates.
* Add basic support for membership types.
* Add support for version history in person profile view.
0.9.28 (2023-06-02)
-------------------
* Expose mail handler and template paths in email config page.
0.9.27 (2023-06-01)
-------------------
* Share some code for validating vendor field.
* Save datasync config with new keys, per RattailConfiguration.
0.9.26 (2023-05-25)
-------------------
* Prevent bug in upgrade diff for empty new version.
* Expose basic way to send test email.
* Avoid error when filter params not valid.
* Tweak byjove project generator form.
* Define essential views for API.
0.9.25 (2023-05-18)
-------------------
* Add initial swagger.json endpoint for API.
* Add workaround for "share grid link" on insecure sites.
0.9.24 (2023-05-16)
-------------------
* Replace ``setup.py`` contents with ``setup.cfg``.
* Prevent error in old product search logic.
0.9.23 (2023-05-15)
-------------------
* Get rid of ``newstyle`` flag for ``Form.validate()`` method.
* Add basic support for managing, and accepting API tokens.
0.9.22 (2023-05-13)
-------------------
* Tweak button wording in "find role by perm" form.
* Warn user if DB not up to date, in new table wizard.
0.9.21 (2023-05-10)
-------------------
* Move row delete check logic for receiving to batch handler.
0.9.20 (2023-05-09)
-------------------
* Add form config for generating 'shopfoo' projects.
* Misc. tweaks for "run import job" form.
0.9.19 (2023-05-05)
-------------------
* Massive overhaul of "generate project" feature.
* Include project views by default, in "essential" views.
0.9.18 (2023-05-03)
-------------------
* Avoid error if tempmon probe has invalid status.
* Expose, honor the ``prevent_password_change`` flag for Users.
0.9.17 (2023-04-17)
-------------------
* Allow bulk-delete for products grid.
* Improve global menu search behavior for multiple terms.
0.9.16 (2023-03-27)
-------------------
* Avoid accidental auto-submit of new msg form, for subject field.
* Add ``has_perm()`` etc. to request during the NewRequest event.
* Fix table sorting for FK reference column in new table wizard.
* Overhaul the "find by perm" feature a bit.
0.9.15 (2023-03-15)
-------------------
* Remove version workaround for sphinx.
* Let providers do DB connection setup for web API.
0.9.14 (2023-03-09)
-------------------
* Fix JSON rendering for Cornice API views.
0.9.13 (2023-03-08)
-------------------
* Remove version cap for cornice, now that we require python3.
0.9.12 (2023-03-02)
-------------------
* Add "equal to any of" verb for string-type grid filters.
* Allow download results for Trainwreck.
0.9.11 (2023-02-24)
-------------------
* Allow sort/filter by vendor for sample files grid.
0.9.10 (2023-02-22)
-------------------
* Add views for sample vendor files.
0.9.9 (2023-02-21)
------------------
* Validate vendor for catalog batch upload.
0.9.8 (2023-02-20)
------------------
* Make ``config`` param more explicit, for GridFilter constructor.
0.9.7 (2023-02-14)
------------------
* Add dedicated view config methods for "view" and "edit help".
0.9.6 (2023-02-12)
------------------
* Refactor ``Query.get()`` => ``Session.get()`` per SQLAlchemy 1.4.
0.9.5 (2023-02-11)
------------------
* Use sa-filters instead of sqlalchemy-filters for API queries.
0.9.4 (2023-02-11)
------------------
* Remove legacy grid for alt codes in product view.
0.9.3 (2023-02-10)
------------------
* Add dependency for pyramid_retry.
* Use latest zope.sqlalchemy package.
* Fix auto-advance on ENTER for login form.
* Use label handler to avoid deprecated logic.
* Remove legacy vendor sources grid for product view.
* Expose setting for POD image URL.
* Fix multi-file upload widget bug.
0.9.2 (2023-02-03)
------------------
* Fix auto-focus username for login form.
0.9.1 (2023-02-03)
------------------
* Stop including deform JS static files.
0.9.0 (2023-02-03)
------------------
* Officially drop support for python2.
* Remove all deprecated jquery and ``use_buefy`` logic.
* Add new Buefy-specific upgrade template.
* Replace 'default' theme to match 'falafel'.
* Allow editing the Department field for a Subdepartment.
* Refactor the Ordering Worksheet generator, per Buefy.
0.8.292 (2023-02-02)
--------------------
* Always assume ``use_buefy=True`` within main page template.
0.8.291 (2023-02-02)
--------------------
@ -4992,7 +4087,7 @@ and related technologies.
0.6.47 (2017-11-08)
-------------------
* Fix manifest to include ``*.pt`` deform templates
* Fix manifest to include *.pt deform templates
0.6.46 (2017-11-08)
@ -5325,13 +4420,13 @@ and related technologies.
0.6.13 (2017-07-26)
-------------------
------------------
* Allow master view to decide whether each grid checkbox is checked
0.6.12 (2017-07-26)
-------------------
------------------
* Add basic support for product inventory and status
@ -5339,7 +4434,7 @@ and related technologies.
0.6.11 (2017-07-18)
-------------------
------------------
* Tweak some basic styles for forms/grids
@ -5347,7 +4442,7 @@ and related technologies.
0.6.10 (2017-07-18)
-------------------
------------------
* Fix grid bug if "current page" becomes invalid

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -81,12 +81,6 @@ override when defining your subclass.
override this for certain views, if so that should be done within
:meth:`get_help_url()`.
.. attribute:: MasterView.version_diff_factory
Optional factory to use for version diff objects. By default
this is *not set* but a subclass is free to set it. See also
:meth:`get_version_diff_factory()`.
Methods to Override
-------------------
@ -94,8 +88,6 @@ 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
@ -103,24 +95,3 @@ subclass.
.. 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

View file

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

View file

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

View file

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

View file

@ -44,32 +44,19 @@ Package API:
api/api/batch/core
api/api/batch/ordering
api/db
api/diffs
api/forms
api/forms.widgets
api/grids
api/grids.core
api/progress
api/subscribers
api/util
api/views/batch
api/views/batch.vendorcatalog
api/views/core
api/views/master
api/views/members
api/views/purchasing.batch
api/views/purchasing.ordering
Changelog:
.. toctree::
:maxdepth: 1
changelog
Documentation To-Do
===================

View file

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

6
setup.cfg Normal file
View file

@ -0,0 +1,6 @@
[nosetests]
nocapture = 1
cover-package = tailbone
cover-erase = 1
cover-html = 1
cover-html-dir = htmlcov

194
setup.py Normal file
View file

@ -0,0 +1,194 @@
# -*- coding: utf-8; -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2022 Lance Edgar
#
# This file is part of Rattail.
#
# Rattail is free software: you can redistribute it and/or modify it under the
# terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Setup script for Tailbone
"""
from __future__ import unicode_literals, absolute_import
import os.path
from setuptools import setup, find_packages
here = os.path.abspath(os.path.dirname(__file__))
exec(open(os.path.join(here, 'tailbone', '_version.py')).read())
README = open(os.path.join(here, 'README.rst')).read()
requires = [
#
# Version numbers within comments below have specific meanings.
# Basically the 'low' value is a "soft low," and 'high' a "soft high."
# In other words:
#
# If either a 'low' or 'high' value exists, the primary point to be
# made about the value is that it represents the most current (stable)
# version available for the package (assuming typical public access
# methods) whenever this project was started and/or documented.
# Therefore:
#
# If a 'low' version is present, you should know that attempts to use
# versions of the package significantly older than the 'low' version
# may not yield happy results. (A "hard" high limit may or may not be
# indicated by a true version requirement.)
#
# Similarly, if a 'high' version is present, and especially if this
# project has laid dormant for a while, you may need to refactor a bit
# when attempting to support a more recent version of the package. (A
# "hard" low limit should be indicated by a true version requirement
# when a 'high' version is present.)
#
# In any case, developers and other users are encouraged to play
# outside the lines with regard to these soft limits. If bugs are
# encountered then they should be filed as such.
#
# package # low high
# TODO: previously was capping this to pre-1.0 although i'm not sure why.
# however the 1.2 release has some breaking changes which require refactor.
# cf. https://pypi.org/project/zope.sqlalchemy/#id3
'zope.sqlalchemy<1.2', # 0.7 1.1
# TODO: apparently they jumped from 0.1 to 0.9 and that broke us...
# (0.1 was released on 2014-09-14 and then 0.9 came out on 2018-09-27)
# (i've cached 0.1 at pypi.rattailproject.org just in case it disappears)
# (still, probably a better idea is to refactor so we can use 0.9)
'webhelpers2_grid==0.1', # 0.1
# TODO: remove version cap once we can drop support for python 2.x
'cornice<5.0', # 3.4.2 4.0.1
# TODO: remove once their bug is fixed? idk what this is about yet...
'deform<2.0.15', # 2.0.14
# TODO: cornice<5 requires pyramid<2 (see above)
'pyramid<2', # 1.3b2 1.10.8
'asgiref', # 3.2.3
'colander', # 1.7.0
'ColanderAlchemy', # 0.3.3
'humanize', # 0.5.1
'Mako', # 0.6.2
'markdown', # 3.3.3
'openpyxl', # 2.4.7
'paginate', # 0.5.6
'paginate_sqlalchemy', # 0.2.0
'passlib', # 1.7.1
'Pillow', # 5.3.0
'pyramid_beaker>=0.6', # 0.6.1
'pyramid_deform', # 0.2
'pyramid_exclog', # 0.6
'pyramid_mako', # 1.0.2
'pyramid_tm', # 0.3
'rattail[db,bouncer]', # 0.5.0
'six', # 1.10.0
'sqlalchemy-filters', # 0.8.0
'transaction', # 1.2.0
'waitress', # 0.8.1
'WebHelpers2', # 2.0
'WTForms', # 2.1
]
extras = {
'docs': [
#
# package # low high
# TODO: remove version workaround after next sphinx[-rtd-theme] release
# cf. https://github.com/readthedocs/sphinx_rtd_theme/issues/1343
'Sphinx!=5.2.0.post0', # 1.2
'sphinx-rtd-theme', # 0.2.4
],
'tests': [
#
# package # low high
'coverage', # 3.6
'fixture', # 1.5
'mock', # 1.0.1
'nose', # 1.3.0
'pytest', # 4.6.11
'pytest-cov', # 2.12.1
],
}
setup(
name = "Tailbone",
version = __version__,
author = "Lance Edgar",
author_email = "lance@edbob.org",
url = "http://rattailproject.org/",
license = "GNU GPL v3",
description = "Backoffice Web Application for Rattail",
long_description = README,
classifiers = [
'Development Status :: 4 - Beta',
'Environment :: Web Environment',
'Framework :: Pyramid',
'Intended Audience :: Developers',
'License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)',
'Natural Language :: English',
'Operating System :: OS Independent',
'Programming Language :: Python',
'Programming Language :: Python :: 2.7',
'Programming Language :: Python :: 3',
'Programming Language :: Python :: 3.5',
'Topic :: Internet :: WWW/HTTP',
'Topic :: Office/Business',
'Topic :: Software Development :: Libraries :: Python Modules',
],
install_requires = requires,
extras_require = extras,
tests_require = ['Tailbone[tests]'],
test_suite = 'nose.collector',
packages = find_packages(exclude=['tests.*', 'tests']),
include_package_data = True,
zip_safe = False,
entry_points = {
'paste.app_factory': [
'main = tailbone.app:main',
'webapi = tailbone.webapi:main',
],
'rattail.cleaners': [
'beaker = tailbone.cleanup:BeakerCleaner',
],
'rattail.config.extensions': [
'tailbone = tailbone.config:ConfigExtension',
],
'pyramid.scaffold': [
'rattail = tailbone.scaffolds:RattailTemplate',
],
},
)

View file

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

View file

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

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2023 Lance Edgar
# Copyright © 2010-2022 Lance Edgar
#
# This file is part of Rattail.
#
@ -24,9 +24,13 @@
Tailbone Web API - Batch Views
"""
from __future__ import unicode_literals, absolute_import
import logging
import warnings
import six
from cornice import Service
from tailbone.api import APIMasterView
@ -66,7 +70,9 @@ class APIBatchMixin(object):
"""
app = self.get_rattail_app()
key = self.get_batch_class().batch_key
return app.get_batch_handler(key, default=self.default_handler_spec)
spec = self.rattail_config.get('rattail.batch', '{}.handler'.format(key),
default=self.default_handler_spec)
return app.load_object(spec)(self.rattail_config)
class APIBatchView(APIBatchMixin, APIMasterView):
@ -98,25 +104,25 @@ class APIBatchView(APIBatchMixin, APIMasterView):
return {
'uuid': batch.uuid,
'_str': str(batch),
'_str': six.text_type(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': six.text_type(created),
'created_display': self.pretty_datetime(created),
'created_by_uuid': batch.created_by.uuid,
'created_by_display': str(batch.created_by),
'created_by_display': six.text_type(batch.created_by),
'complete': batch.complete,
'status_code': batch.status_code,
'status_display': batch.STATUS.get(batch.status_code,
str(batch.status_code)),
'executed': str(executed) if executed else None,
six.text_type(batch.status_code)),
'executed': six.text_type(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 ''),
'executed_by_display': six.text_type(batch.executed_by or ''),
'mutable': self.batch_handler.is_mutable(batch),
}
@ -267,8 +273,8 @@ class APIBatchRowView(APIBatchMixin, APIMasterView):
batch = row.batch
return {
'uuid': row.uuid,
'_str': str(row),
'_parent_str': str(batch),
'_str': six.text_type(row),
'_parent_str': six.text_type(batch),
'_parent_uuid': batch.uuid,
'batch_uuid': batch.uuid,
'batch_id': batch.id,
@ -279,7 +285,7 @@ class APIBatchRowView(APIBatchMixin, APIMasterView):
'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)),
'status_display': row.STATUS.get(row.status_code, six.text_type(row.status_code)),
}
def update_object(self, row, data):
@ -314,7 +320,7 @@ class APIBatchRowView(APIBatchMixin, APIMasterView):
data = self.request.json_body
uuid = data['batch_uuid']
batch = self.Session.get(self.get_batch_class(), uuid)
batch = self.Session.query(self.get_batch_class()).get(uuid)
if not batch:
raise self.notfound()
@ -326,14 +332,11 @@ class APIBatchRowView(APIBatchMixin, APIMasterView):
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)
msg = six.text_type(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
@ -349,12 +352,13 @@ class APIBatchRowView(APIBatchMixin, APIMasterView):
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_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)
config.add_route('{}.quick_entry'.format(route_prefix), '{}/quick-entry'.format(collection_url_prefix),
request_method=('OPTIONS', 'POST'))
config.add_view(cls, attr='quick_entry', route_name='{}.quick_entry'.format(route_prefix),
permission='{}.edit'.format(permission_prefix),
renderer='json')

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2023 Lance Edgar
# Copyright © 2010-2022 Lance Edgar
#
# This file is part of Rattail.
#
@ -24,12 +24,15 @@
Tailbone Web API - Inventory Batches
"""
from __future__ import unicode_literals, absolute_import
import decimal
import sqlalchemy as sa
import six
from rattail import pod
from rattail.db.model import InventoryBatch, InventoryBatchRow
from rattail.db import model
from rattail.util import pretty_quantity
from cornice import Service
@ -38,7 +41,7 @@ from tailbone.api.batch import APIBatchView, APIBatchRowView
class InventoryBatchViews(APIBatchView):
model_class = InventoryBatch
model_class = model.InventoryBatch
default_handler_spec = 'rattail.batch.inventory:InventoryBatchHandler'
route_prefix = 'inventory'
permission_prefix = 'batch.inventory'
@ -47,12 +50,12 @@ class InventoryBatchViews(APIBatchView):
supports_toggle_complete = True
def normalize(self, batch):
data = super().normalize(batch)
data = super(InventoryBatchViews, self).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['mode_display'] = six.text_type(batch.mode)
data['reason_code'] = batch.reason_code
@ -116,7 +119,7 @@ class InventoryBatchViews(APIBatchView):
class InventoryBatchRowViews(APIBatchRowView):
model_class = InventoryBatchRow
model_class = model.InventoryBatchRow
default_handler_spec = 'rattail.batch.inventory:InventoryBatchHandler'
route_prefix = 'inventory.rows'
permission_prefix = 'batch.inventory'
@ -127,24 +130,23 @@ class InventoryBatchRowViews(APIBatchRowView):
def normalize(self, row):
batch = row.batch
data = super().normalize(row)
app = self.get_rattail_app()
data = super(InventoryBatchRowViews, self).normalize(row)
data['item_id'] = row.item_id
data['upc'] = str(row.upc)
data['upc'] = six.text_type(row.upc)
data['upc_pretty'] = row.upc.pretty() if row.upc else None
data['brand_name'] = row.brand_name
data['description'] = row.description
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['case_quantity'] = pretty_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),
pretty_quantity(row.cases or row.units),
'CS' if row.cases else data['unit_uom'])
data['allow_cases'] = self.batch_handler.allow_cases(batch)
@ -172,17 +174,7 @@ class InventoryBatchRowViews(APIBatchRowView):
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
row = super(InventoryBatchRowViews, self).update_object(row, data)
return row

View file

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

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2024 Lance Edgar
# Copyright © 2010-2022 Lance Edgar
#
# This file is part of Rattail.
#
@ -27,24 +27,23 @@ These views expose the basic CRUD interface to "ordering" batches, for the web
API.
"""
from __future__ import unicode_literals, absolute_import
import datetime
import logging
import sqlalchemy as sa
import six
from rattail.db.model import PurchaseBatch, PurchaseBatchRow
from rattail.db import model
from rattail.util import pretty_quantity
from cornice import Service
from tailbone.api.batch import APIBatchView, APIBatchRowView
log = logging.getLogger(__name__)
class OrderingBatchViews(APIBatchView):
model_class = PurchaseBatch
model_class = model.PurchaseBatch
default_handler_spec = 'rattail.batch.purchase:PurchaseBatchHandler'
route_prefix = 'orderingbatchviews'
permission_prefix = 'ordering'
@ -60,19 +59,18 @@ class OrderingBatchViews(APIBatchView):
Adds a condition to the query, to ensure only purchase batches with
"ordering" mode are returned.
"""
model = self.model
query = super().base_query()
query = super(OrderingBatchViews, self).base_query()
query = query.filter(model.PurchaseBatch.mode == self.enum.PURCHASE_BATCH_MODE_ORDERING)
return query
def normalize(self, batch):
data = super().normalize(batch)
data = super(OrderingBatchViews, self).normalize(batch)
data['vendor_uuid'] = batch.vendor.uuid
data['vendor_display'] = str(batch.vendor)
data['vendor_display'] = six.text_type(batch.vendor)
data['department_uuid'] = batch.department_uuid
data['department_display'] = str(batch.department) if batch.department else None
data['department_display'] = six.text_type(batch.department) if batch.department else None
data['po_total_calculated_display'] = "${:0.2f}".format(batch.po_total_calculated or 0)
data['ship_method'] = batch.ship_method
@ -86,10 +84,8 @@ class OrderingBatchViews(APIBatchView):
Sets the mode to "ordering" for the new batch.
"""
data = dict(data)
if not data.get('vendor_uuid'):
raise ValueError("You must specify the vendor")
data['mode'] = self.enum.PURCHASE_BATCH_MODE_ORDERING
batch = super().create_object(data)
batch = super(OrderingBatchViews, self).create_object(data)
return batch
def worksheet(self):
@ -156,7 +152,7 @@ class OrderingBatchViews(APIBatchView):
product = cost.product
subdept_costs.append({
'uuid': cost.uuid,
'upc': str(product.upc),
'upc': six.text_type(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,
@ -177,8 +173,8 @@ class OrderingBatchViews(APIBatchView):
# sort the (sub)department groupings
sorted_departments = []
for dept in sorted(departments.values(), key=lambda d: d['name']):
dept['subdepartments'] = sorted(dept['subdepartments'].values(),
for dept in sorted(six.itervalues(departments), key=lambda d: d['name']):
dept['subdepartments'] = sorted(six.itervalues(dept['subdepartments']),
key=lambda s: s['name'])
sorted_departments.append(dept)
@ -189,8 +185,6 @@ class OrderingBatchViews(APIBatchView):
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')
@ -229,7 +223,7 @@ class OrderingBatchViews(APIBatchView):
class OrderingBatchRowViews(APIBatchRowView):
model_class = PurchaseBatchRow
model_class = model.PurchaseBatchRow
default_handler_spec = 'rattail.batch.purchase:PurchaseBatchHandler'
route_prefix = 'ordering.rows'
permission_prefix = 'ordering'
@ -239,12 +233,11 @@ class OrderingBatchRowViews(APIBatchRowView):
editable = True
def normalize(self, row):
data = super().normalize(row)
app = self.get_rattail_app()
batch = row.batch
data = super(OrderingBatchRowViews, self).normalize(row)
data['item_id'] = row.item_id
data['upc'] = str(row.upc)
data['upc'] = six.text_type(row.upc)
data['upc_pretty'] = row.upc.pretty() if row.upc else None
data['brand_name'] = row.brand_name
data['description'] = row.description
@ -261,15 +254,15 @@ class OrderingBatchRowViews(APIBatchRowView):
data['case_quantity'] = row.case_quantity
data['cases_ordered'] = row.cases_ordered
data['units_ordered'] = row.units_ordered
data['cases_ordered_display'] = app.render_quantity(row.cases_ordered or 0, empty_zero=False)
data['units_ordered_display'] = app.render_quantity(row.units_ordered or 0, empty_zero=False)
data['cases_ordered_display'] = pretty_quantity(row.cases_ordered or 0, empty_zero=False)
data['units_ordered_display'] = pretty_quantity(row.units_ordered or 0, empty_zero=False)
data['po_unit_cost'] = row.po_unit_cost
data['po_unit_cost_display'] = "${:0.2f}".format(row.po_unit_cost) if row.po_unit_cost is not None else None
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))
data['status_display'] = row.STATUS.get(row.status_code, six.text_type(row.status_code))
return data
@ -290,17 +283,7 @@ class OrderingBatchRowViews(APIBatchRowView):
if not self.batch_handler.is_mutable(row.batch):
return {'error': "Batch is not mutable"}
try:
self.batch_handler.update_row_quantity(row, **data)
self.Session.flush()
except Exception as error:
log.warning("update_row_quantity failed", exc_info=True)
if isinstance(error, sa.exc.DataError) and hasattr(error, 'orig'):
error = str(error.orig)
else:
error = str(error)
return {'error': error}
self.batch_handler.update_row_quantity(row, **data)
return row

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2024 Lance Edgar
# Copyright © 2010-2022 Lance Edgar
#
# This file is part of Rattail.
#
@ -24,14 +24,16 @@
Tailbone Web API - Receiving Batches
"""
from __future__ import unicode_literals, absolute_import
import logging
import six
import humanize
import sqlalchemy as sa
from rattail.db.model import PurchaseBatch, PurchaseBatchRow
from rattail.db import model
from rattail.util import pretty_quantity
from cornice import Service
from deform import widget as dfwidget
from tailbone import forms
@ -44,7 +46,7 @@ log = logging.getLogger(__name__)
class ReceivingBatchViews(APIBatchView):
model_class = PurchaseBatch
model_class = model.PurchaseBatch
default_handler_spec = 'rattail.batch.purchase:PurchaseBatchHandler'
route_prefix = 'receivingbatchviews'
permission_prefix = 'receiving'
@ -54,21 +56,19 @@ class ReceivingBatchViews(APIBatchView):
supports_execute = True
def base_query(self):
model = self.app.model
query = super().base_query()
query = super(ReceivingBatchViews, self).base_query()
query = query.filter(model.PurchaseBatch.mode == self.enum.PURCHASE_BATCH_MODE_RECEIVING)
return query
def normalize(self, batch):
data = super().normalize(batch)
data = super(ReceivingBatchViews, self).normalize(batch)
data['vendor_uuid'] = batch.vendor.uuid
data['vendor_display'] = str(batch.vendor)
data['vendor_display'] = six.text_type(batch.vendor)
data['department_uuid'] = batch.department_uuid
data['department_display'] = str(batch.department) if batch.department else None
data['department_display'] = six.text_type(batch.department) if batch.department else None
data['po_number'] = batch.po_number
data['po_total'] = batch.po_total
data['invoice_total'] = batch.invoice_total
data['invoice_total_calculated'] = batch.invoice_total_calculated
@ -79,15 +79,9 @@ class ReceivingBatchViews(APIBatchView):
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)
batch = super(ReceivingBatchViews, self).create_object(data)
return batch
def auto_receive(self):
"""
@ -120,9 +114,8 @@ class ReceivingBatchViews(APIBatchView):
return self._get(obj=batch)
def eligible_purchases(self):
model = self.app.model
uuid = self.request.params.get('vendor_uuid')
vendor = self.Session.get(model.Vendor, uuid) if uuid else None
vendor = self.Session.query(model.Vendor).get(uuid) if uuid else None
if not vendor:
return {'error': "Vendor not found"}
@ -153,31 +146,31 @@ class ReceivingBatchViews(APIBatchView):
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)
# auto-receive
config.add_route('{}.auto_receive'.format(route_prefix),
'{}/{{uuid}}/auto-receive'.format(object_url_prefix))
config.add_view(cls, attr='auto_receive',
route_name='{}.auto_receive'.format(route_prefix),
permission='{}.auto_receive'.format(permission_prefix),
renderer='json')
# 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)
# mark receiving complete
config.add_route('{}.mark_receiving_complete'.format(route_prefix), '{}/{{uuid}}/mark-receiving-complete'.format(object_url_prefix))
config.add_view(cls, attr='mark_receiving_complete', route_name='{}.mark_receiving_complete'.format(route_prefix),
permission='{}.edit'.format(permission_prefix),
renderer='json')
# 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)
config.add_route('{}.eligible_purchases'.format(route_prefix), '{}/eligible-purchases'.format(collection_url_prefix),
request_method='GET')
config.add_view(cls, attr='eligible_purchases', route_name='{}.eligible_purchases'.format(route_prefix),
permission='{}.create'.format(permission_prefix),
renderer='json')
class ReceivingBatchRowViews(APIBatchRowView):
model_class = PurchaseBatchRow
model_class = model.PurchaseBatchRow
default_handler_spec = 'rattail.batch.purchase:PurchaseBatchHandler'
route_prefix = 'receiving.rows'
permission_prefix = 'receiving'
@ -186,8 +179,7 @@ class ReceivingBatchRowViews(APIBatchRowView):
supports_quick_entry = True
def make_filter_spec(self):
model = self.app.model
filters = super().make_filter_spec()
filters = super(ReceivingBatchRowViews, self).make_filter_spec()
if filters:
# must translate certain convenience filters
@ -283,30 +275,21 @@ class ReceivingBatchRowViews(APIBatchRowView):
]},
])
# is_missing
elif filtr['field'] == 'is_missing' and filtr['op'] == 'eq' and filtr['value'] is True:
filters.extend([
{'or': [
{'field': 'cases_missing', 'op': '!=', 'value': 0},
{'field': 'units_missing', 'op': '!=', 'value': 0},
]},
])
else: # just some filter, use as-is
filters.append(filtr)
return filters
def normalize(self, row):
data = super().normalize(row)
model = self.app.model
data = super(ReceivingBatchRowViews, self).normalize(row)
batch = row.batch
prodder = self.app.get_products_handler()
app = self.get_rattail_app()
prodder = app.get_products_handler()
data['product_uuid'] = row.product_uuid
data['item_id'] = row.item_id
data['upc'] = str(row.upc)
data['upc'] = six.text_type(row.upc)
data['upc_pretty'] = row.upc.pretty() if row.upc else None
data['brand_name'] = row.brand_name
data['description'] = row.description
@ -338,9 +321,6 @@ class ReceivingBatchRowViews(APIBatchRowView):
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
@ -348,7 +328,6 @@ class ReceivingBatchRowViews(APIBatchRowView):
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
@ -377,7 +356,7 @@ class ReceivingBatchRowViews(APIBatchRowView):
if accounted_for:
# some product accounted for; button should receive "remainder" only
if remainder:
remainder = self.app.render_quantity(remainder)
remainder = pretty_quantity(remainder)
data['quick_receive_quantity'] = remainder
data['quick_receive_text'] = "Receive Remainder ({} {})".format(
remainder, data['unit_uom'])
@ -388,7 +367,7 @@ class ReceivingBatchRowViews(APIBatchRowView):
else: # nothing yet accounted for, button should receive "all"
if not remainder:
log.warning("quick receive remainder is empty for row %s", row.uuid)
remainder = self.app.render_quantity(remainder)
remainder = pretty_quantity(remainder)
data['quick_receive_quantity'] = remainder
data['quick_receive_text'] = "Receive ALL ({} {})".format(
remainder, data['unit_uom'])
@ -416,7 +395,7 @@ class ReceivingBatchRowViews(APIBatchRowView):
data['received_alert'] = None
if self.batch_handler.get_units_confirmed(row):
msg = "You have already received some of this product; last update was {}.".format(
humanize.naturaltime(self.app.make_utc() - row.modified))
humanize.naturaltime(app.make_utc() - row.modified))
data['received_alert'] = msg
return data
@ -425,37 +404,27 @@ class ReceivingBatchRowViews(APIBatchRowView):
"""
View which handles "receiving" against a particular batch row.
"""
model = self.app.model
# first do basic input validation
schema = ReceiveRow().bind(session=self.Session())
form = forms.Form(schema=schema, request=self.request)
# 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)
if not form.validate(newstyle=True):
log.debug("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'])
row = self.Session.query(model.PurchaseBatchRow).get(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}
self.batch_handler.receive_row(row, **kwargs)
self.Session.flush()
return self._get(obj=row)
@classmethod
@ -471,11 +440,11 @@ class ReceivingBatchRowViews(APIBatchRowView):
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)
config.add_route('{}.receive'.format(route_prefix), '{}/{{uuid}}/receive'.format(object_url_prefix),
request_method=('OPTIONS', 'POST'))
config.add_view(cls, attr='receive', route_name='{}.receive'.format(route_prefix),
permission='{}.edit_row'.format(permission_prefix),
renderer='json')
def defaults(config, **kwargs):

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2024 Lance Edgar
# Copyright © 2010-2022 Lance Edgar
#
# This file is part of Rattail.
#
@ -24,14 +24,16 @@
Tailbone Web API - "Common" Views
"""
from collections import OrderedDict
from __future__ import unicode_literals, absolute_import
from rattail.util import get_pkg_version
import rattail
from rattail.db import model
from rattail.mail import send_email
from rattail.util import OrderedDict
from cornice import Service
from cornice.service import get_services
from cornice_swagger import CorniceSwagger
import tailbone
from tailbone import forms
from tailbone.forms.common import Feedback
from tailbone.api import APIView, api
@ -63,12 +65,11 @@ class CommonView(APIView):
}
def get_project_title(self):
app = self.get_rattail_app()
return app.get_title()
return self.rattail_config.app_title(default="Tailbone")
def get_project_version(self):
app = self.get_rattail_app()
return app.get_version()
import tailbone
return tailbone.__version__
def get_packages(self):
"""
@ -76,8 +77,8 @@ class CommonView(APIView):
'about' page.
"""
return OrderedDict([
('rattail', get_pkg_version('rattail')),
('Tailbone', get_pkg_version('Tailbone')),
('rattail', rattail.__version__),
('Tailbone', tailbone.__version__),
])
@api
@ -85,20 +86,18 @@ class CommonView(APIView):
"""
View to handle user feedback form submits.
"""
app = self.get_rattail_app()
model = self.model
# TODO: this logic was copied from tailbone.views.common and is largely
# identical; perhaps should merge somehow?
schema = Feedback().bind(session=Session())
form = forms.Form(schema=schema, request=self.request)
if form.validate():
if form.validate(newstyle=True):
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'])
data['user'] = Session.query(model.User).get(data['user'])
# TODO: should provide URL to view user
if data['user']:
@ -106,27 +105,17 @@ class CommonView(APIView):
data['client_ip'] = self.request.client_addr
email_key = data['email_key'] or self.feedback_email_key
app.send_email(email_key, data=data)
send_email(self.rattail_config, email_key, data=data)
return {'ok': True}
return {'error': "Form did not validate!"}
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')
@ -139,14 +128,6 @@ class CommonView(APIView):
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()

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2024 Lance Edgar
# Copyright © 2010-2022 Lance Edgar
#
# This file is part of Rattail.
#
@ -24,6 +24,8 @@
Tailbone Web API - Core Views
"""
from __future__ import unicode_literals, absolute_import
from tailbone.views import View
@ -99,20 +101,20 @@ class APIView(View):
return info
"""
app = self.get_rattail_app()
auth = app.get_auth_handler()
auth_handler = app.get_auth_handler()
# basic / default info
is_admin = auth.user_is_admin(user)
employee = app.get_employee(user)
is_admin = user.is_admin()
employee = user.employee
info = {
'uuid': user.uuid,
'username': user.username,
'display_name': user.display_name,
'short_name': auth.get_short_display_name(user),
'short_name': user.get_short_name(),
'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),
'email_address': auth_handler.get_email_address(user),
}
# maybe get/use "extra" info

View file

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

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2024 Lance Edgar
# Copyright © 2010-2023 Lance Edgar
#
# This file is part of Rattail.
#
@ -24,15 +24,26 @@
Tailbone Web API - Master View
"""
from __future__ import unicode_literals, absolute_import
import json
import six
from rattail.config import parse_bool
from rattail.db.util import get_fieldnames
from cornice import resource, Service
from tailbone.api import APIView
from tailbone.api import APIView, api
from tailbone.db import Session
from tailbone.util import SortColumn
class SortColumn(object):
def __init__(self, field_name, model_name=None):
self.field_name = field_name
self.model_name = model_name
class APIMasterView(APIView):
@ -184,7 +195,7 @@ class APIMasterView(APIView):
if sortcol:
spec = {
'field': sortcol.field_name,
'direction': 'asc' if self.config.parse_bool(self.request.params['ascending']) else 'desc',
'direction': 'asc' if parse_bool(self.request.params['ascending']) else 'desc',
}
if sortcol.model_name:
spec['model'] = sortcol.model_name
@ -268,7 +279,7 @@ class APIMasterView(APIView):
return self._fieldnames
def normalize(self, obj):
data = {'_str': str(obj)}
data = {'_str': six.text_type(obj)}
for field in self.get_fieldnames():
data[field] = getattr(obj, field)
@ -276,7 +287,7 @@ class APIMasterView(APIView):
return data
def _collection_get(self):
from sa_filters import apply_filters, apply_sort, apply_pagination
from sqlalchemy_filters import apply_filters, apply_sort, apply_pagination
query = self.base_query()
context = {}
@ -332,7 +343,7 @@ class APIMasterView(APIView):
if not uuid:
uuid = self.request.matchdict['uuid']
obj = self.Session.get(self.get_model_class(), uuid)
obj = self.Session.query(self.get_model_class()).get(uuid)
if obj:
return obj
@ -354,13 +365,9 @@ class APIMasterView(APIView):
data = self.request.json_body
# add instance to session, and return data for it
try:
obj = self.create_object(data)
except Exception as error:
return self.json_response({'error': str(error)})
else:
self.Session.flush()
return self._get(obj)
obj = self.create_object(data)
self.Session.flush()
return self._get(obj)
def create_object(self, data):
"""
@ -387,7 +394,7 @@ class APIMasterView(APIView):
"""
if not uuid:
uuid = self.request.matchdict['uuid']
obj = self.Session.get(self.get_model_class(), uuid)
obj = self.Session.query(self.get_model_class()).get(uuid)
if not obj:
raise self.notfound()

View file

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

View file

@ -24,8 +24,11 @@
Tailbone Web API - Product Views
"""
from __future__ import unicode_literals, absolute_import
import logging
import six
import sqlalchemy as sa
from sqlalchemy import orm
@ -81,7 +84,7 @@ class ProductView(APIMasterView):
# but must supplement
cost = product.cost
data.update({
'upc': str(product.upc),
'upc': six.text_type(product.upc),
'scancode': product.scancode,
'item_id': product.item_id,
'item_type': product.item_type,
@ -125,24 +128,6 @@ class ProductView(APIMasterView):
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()
@ -150,12 +135,12 @@ class ProductView(APIMasterView):
data = self.request.json_body
uuid = data.get('label_profile_uuid')
profile = self.Session.get(model.LabelProfile, uuid) if uuid else None
profile = self.Session.query(model.LabelProfile).get(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
product = self.Session.query(model.Product).get(uuid) if uuid else None
if not product:
return {'error': "Product not found"}
@ -172,7 +157,7 @@ class ProductView(APIMasterView):
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 {'error': six.text_type(error)}
return {'ok': True}
@ -194,13 +179,6 @@ class ProductView(APIMasterView):
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))

View file

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

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2023 Lance Edgar
# Copyright © 2010-2022 Lance Edgar
#
# This file is part of Rattail.
#
@ -24,6 +24,8 @@
Tailbone Web API - User Views
"""
from __future__ import unicode_literals, absolute_import
from rattail.db import model
from tailbone.api import APIMasterView
@ -55,10 +57,6 @@ class UserView(APIMasterView):
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()

View file

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

View file

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

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2024 Lance Edgar
# Copyright © 2010-2023 Lance Edgar
#
# This file is part of Rattail.
#
@ -24,20 +24,25 @@
Application Entry Point
"""
import os
from __future__ import unicode_literals, absolute_import
import os
import warnings
import six
import sqlalchemy as sa
from sqlalchemy.orm import sessionmaker, scoped_session
from wuttjamaican.util import parse_list
from rattail.config import make_config
from rattail.config import make_config, parse_list
from rattail.exceptions import ConfigurationError
from rattail.db.types import GPCType
from pyramid.config import Configurator
from pyramid.authentication import SessionAuthenticationPolicy
from zope.sqlalchemy import register
import tailbone.db
from tailbone.auth import TailboneSecurityPolicy
from tailbone.auth import TailboneAuthorizationPolicy
from tailbone.config import csrf_token_name, csrf_header_name
from tailbone.util import get_effective_theme, get_theme_template_path
from tailbone.providers import get_all_providers
@ -58,33 +63,16 @@ def make_rattail_config(settings):
"to the path of your config file. Lame, but necessary.")
rattail_config = make_config(path)
settings['rattail_config'] = rattail_config
# nb. this is for compaibility with wuttaweb
settings['wutta_config'] = rattail_config
# must import all sqlalchemy models before things get rolling,
# otherwise can have errors about continuum TransactionMeta class
# not yet mapped, when relevant pages are first requested...
# cf. https://docs.pylonsproject.org/projects/pyramid_cookbook/en/latest/database/sqlalchemy.html#importing-all-sqlalchemy-models
# hat tip to https://stackoverflow.com/a/59241485
if getattr(rattail_config, 'tempmon_engine', None):
from rattail_tempmon.db import model as tempmon_model, Session as TempmonSession
tempmon_session = TempmonSession()
tempmon_session.query(tempmon_model.Appliance).first()
tempmon_session.close()
rattail_config.configure_logging()
# configure database sessions
if hasattr(rattail_config, 'appdb_engine'):
tailbone.db.Session.configure(bind=rattail_config.appdb_engine)
if hasattr(rattail_config, 'rattail_engine'):
tailbone.db.Session.configure(bind=rattail_config.rattail_engine)
if hasattr(rattail_config, 'trainwreck_engine'):
tailbone.db.TrainwreckSession.configure(bind=rattail_config.trainwreck_engine)
if hasattr(rattail_config, 'tempmon_engine'):
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':
@ -135,21 +123,18 @@ def make_pyramid_config(settings, configure_csrf=True):
config.set_root_factory(Root)
else:
# declare this web app of the "classic" variety
settings.setdefault('tailbone.classic', 'true')
# we want the new themes feature!
establish_theme(settings)
settings.setdefault('fanstatic.versioning', 'true')
settings.setdefault('pyramid_deform.template_search_path', 'tailbone:templates/deform')
config = Configurator(settings=settings, root_factory=Root)
# add rattail config directly to registry, for access throughout the app
# add rattail config directly to registry
config.registry['rattail_config'] = rattail_config
# configure user authorization / authentication
config.set_security_policy(TailboneSecurityPolicy())
config.set_authorization_policy(TailboneAuthorizationPolicy())
config.set_authentication_policy(SessionAuthenticationPolicy())
# maybe require CSRF token protection
if configure_csrf:
@ -160,20 +145,9 @@ def make_pyramid_config(settings, configure_csrf=True):
# Bring in some Pyramid goodies.
config.include('tailbone.beaker')
config.include('pyramid_deform')
config.include('pyramid_fanstatic')
config.include('pyramid_mako')
config.include('pyramid_tm')
# TODO: this may be a good idea some day, if wanting to leverage
# deform resources for component JS? cf. also base.mako template
# # override default script mapping for deform
# from deform import Field
# from deform.widget import ResourceRegistry, default_resources
# registry = ResourceRegistry(use_defaults=False)
# for key in default_resources:
# registry.set_js_resources(key, None, {'js': []})
# Field.set_default_resource_registry(registry)
# bring in the pyramid_retry logic, if available
# TODO: pretty soon we can require this package, hopefully..
try:
@ -185,7 +159,7 @@ def make_pyramid_config(settings, configure_csrf=True):
# fetch all tailbone providers
providers = get_all_providers(rattail_config)
for provider in providers.values():
for provider in six.itervalues(providers):
# configure DB sessions associated with transaction manager
provider.configure_db_sessions(rattail_config, config)
@ -196,16 +170,9 @@ def make_pyramid_config(settings, configure_csrf=True):
for spec in includes:
config.include(spec)
# add some permissions magic
config.add_directive('add_wutta_permission_group',
'wuttaweb.auth.add_permission_group')
config.add_directive('add_wutta_permission',
'wuttaweb.auth.add_permission')
# TODO: deprecate / remove these
config.add_directive('add_tailbone_permission_group',
'wuttaweb.auth.add_permission_group')
config.add_directive('add_tailbone_permission',
'wuttaweb.auth.add_permission')
# Add some permissions magic.
config.add_directive('add_tailbone_permission_group', 'tailbone.auth.add_permission_group')
config.add_directive('add_tailbone_permission', 'tailbone.auth.add_permission')
# and some similar magic for certain master views
config.add_directive('add_tailbone_index_page', 'tailbone.app.add_index_page')
@ -226,7 +193,7 @@ def add_websocket(config, name, view, attr=None):
rattail_config = config.registry.settings['rattail_config']
rattail_app = rattail_config.get_app()
if isinstance(view, str):
if isinstance(view, six.string_types):
view_callable = rattail_app.load_object(view)
else:
view_callable = view
@ -311,7 +278,7 @@ def establish_theme(settings):
settings['tailbone.theme'] = theme
directories = settings['mako.directories']
if isinstance(directories, str):
if isinstance(directories, six.string_types):
directories = parse_list(directories)
path = get_theme_template_path(rattail_config)
@ -332,8 +299,7 @@ def main(global_config, **settings):
"""
This function returns a Pyramid WSGI application.
"""
settings.setdefault('mako.directories', ['tailbone:templates',
'wuttaweb:templates'])
settings.setdefault('mako.directories', ['tailbone:templates'])
rattail_config = make_rattail_config(settings)
pyramid_config = make_pyramid_config(settings)
pyramid_config.include('tailbone')

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2024 Lance Edgar
# Copyright © 2010-2022 Lance Edgar
#
# This file is part of Rattail.
#
@ -24,10 +24,14 @@
ASGI App Utilities
"""
from __future__ import unicode_literals, absolute_import
import os
import configparser
import logging
import six
from six.moves import configparser
from rattail.util import load_object
from asgiref.wsgi import WsgiToAsgi
@ -45,12 +49,6 @@ class TailboneWsgiToAsgi(WsgiToAsgi):
protocol = scope['type']
path = scope['path']
# strip off the root path, if non-empty. needed for serving
# under /poser or anything other than true site root
root_path = scope['root_path']
if root_path and path.startswith(root_path):
path = path[len(root_path):]
if protocol == 'websocket':
websockets = self.wsgi_application.registry.get(
'tailbone_websockets', {})
@ -87,7 +85,7 @@ def make_asgi_app(main_app=None):
# parse the settings needed for pyramid app
settings = dict(parser.items('app:main'))
if isinstance(main_app, str):
if isinstance(main_app, six.string_types):
make_wsgi_app = load_object(main_app)
elif callable(main_app):
make_wsgi_app = main_app

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2024 Lance Edgar
# Copyright © 2010-2021 Lance Edgar
#
# This file is part of Rattail.
#
@ -24,31 +24,32 @@
Authentication & Authorization
"""
from __future__ import unicode_literals, absolute_import
import logging
import re
from wuttjamaican.util import UNSPECIFIED
from rattail import enum
from rattail.util import prettify, NOTSET
from pyramid.security import remember, forget
from zope.interface import implementer
from pyramid.interfaces import IAuthorizationPolicy
from pyramid.security import remember, forget, Everyone, Authenticated
from wuttaweb.auth import WuttaSecurityPolicy
from tailbone.db import Session
log = logging.getLogger(__name__)
def login_user(request, user, timeout=UNSPECIFIED):
def login_user(request, user, timeout=NOTSET):
"""
Perform the steps necessary to login the given user. Note that this
returns a ``headers`` dict which you should pass to the redirect.
"""
config = request.rattail_config
app = config.get_app()
user.record_event(app.enum.USER_EVENT_LOGIN)
user.record_event(enum.USER_EVENT_LOGIN)
headers = remember(request, user.uuid)
if timeout is UNSPECIFIED:
timeout = session_timeout_for_user(config, user)
if timeout is NOTSET:
timeout = session_timeout_for_user(user)
log.debug("setting session timeout for '{}' to {}".format(user.username, timeout))
set_session_timeout(request, timeout)
return headers
@ -59,28 +60,24 @@ def logout_user(request):
Perform the logout action for the given request. Note that this returns a
``headers`` dict which you should pass to the redirect.
"""
app = request.rattail_config.get_app()
user = request.user
if user:
user.record_event(app.enum.USER_EVENT_LOGOUT)
user.record_event(enum.USER_EVENT_LOGOUT)
request.session.delete()
request.session.invalidate()
headers = forget(request)
return headers
def session_timeout_for_user(config, user):
def session_timeout_for_user(user):
"""
Returns the "max" session timeout for the user, according to roles
"""
app = config.get_app()
auth = app.get_auth_handler()
from rattail.db.auth import authenticated_role
authenticated = auth.get_role_authenticated(Session())
roles = user.roles + [authenticated]
roles = user.roles + [authenticated_role(Session())]
timeouts = [role.session_timeout for role in roles
if role.session_timeout is not None]
if timeouts and 0 not in timeouts:
return max(timeouts)
@ -92,42 +89,58 @@ def set_session_timeout(request, timeout):
request.session['_timeout'] = timeout or None
class TailboneSecurityPolicy(WuttaSecurityPolicy):
@implementer(IAuthorizationPolicy)
class TailboneAuthorizationPolicy(object):
def __init__(self, db_session=None, api_mode=False, **kwargs):
kwargs['db_session'] = db_session or Session()
super().__init__(**kwargs)
self.api_mode = api_mode
def load_identity(self, request):
config = request.registry.settings.get('rattail_config')
def permits(self, context, principals, permission):
config = context.request.rattail_config
model = config.get_model()
app = config.get_app()
user = None
auth = app.get_auth_handler()
if self.api_mode:
for userid in principals:
if userid not in (Everyone, Authenticated):
if context.request.user and context.request.user.uuid == userid:
return context.request.has_perm(permission)
else:
# this is pretty rare, but can happen in dev after
# re-creating the database, which means new user uuids.
# TODO: the odds of this query returning a user in that
# case, are probably nil, and we should just skip this bit?
user = Session.query(model.User).get(userid)
if user:
if auth.has_permission(Session(), user, permission):
return True
if Everyone in principals:
return auth.has_permission(Session(), None, permission)
return False
# determine/load user from header token if present
credentials = request.headers.get('Authorization')
if credentials:
match = re.match(r'^Bearer (\S+)$', credentials)
if match:
token = match.group(1)
auth = app.get_auth_handler()
user = auth.authenticate_user_token(self.db_session, token)
def principals_allowed_by_permission(self, context, permission):
raise NotImplementedError
if not user:
# fetch user uuid from current session
uuid = self.session_helper.authenticated_userid(request)
if not uuid:
return
def add_permission_group(config, key, label=None, overwrite=True):
"""
Add a permission group to the app configuration.
"""
def action():
perms = config.get_settings().get('tailbone_permissions', {})
if key not in perms or overwrite:
group = perms.setdefault(key, {'key': key})
group['label'] = label or prettify(key)
config.add_settings({'tailbone_permissions': perms})
config.action(None, action)
# fetch user object from db
model = app.model
user = self.db_session.get(model.User, uuid)
if not user:
return
# this user is responsible for data changes in current request
self.db_session.set_continuum_user(user)
return user
def add_permission(config, groupkey, key, label=None):
"""
Add a permission to the app configuration.
"""
def action():
perms = config.get_settings().get('tailbone_permissions', {})
group = perms.setdefault(groupkey, {'key': groupkey})
group.setdefault('label', prettify(groupkey))
perm = group.setdefault('perms', {}).setdefault(key, {'key': key})
perm['label'] = label or prettify(key)
config.add_settings({'tailbone_permissions': perms})
config.action(None, action)

View file

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

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2024 Lance Edgar
# Copyright © 2010-2023 Lance Edgar
#
# This file is part of Rattail.
#
@ -24,16 +24,17 @@
Rattail config extension for Tailbone
"""
from __future__ import unicode_literals, absolute_import
import warnings
from wuttjamaican.conf import WuttaConfigExtension
from rattail.config import ConfigExtension as BaseExtension
from rattail.db.config import configure_session
from tailbone.db import Session
class ConfigExtension(WuttaConfigExtension):
class ConfigExtension(BaseExtension):
"""
Rattail config extension for Tailbone. Does the following:
@ -50,12 +51,9 @@ class ConfigExtension(WuttaConfigExtension):
configure_session(config, Session)
# provide default theme selection
config.setdefault('tailbone', 'themes.keys', 'default, butterball')
config.setdefault('tailbone', 'themes', 'default, falafel')
config.setdefault('tailbone', 'themes.expose_picker', 'true')
# override oruga detection
config.setdefault('wuttaweb.oruga_detector.spec', 'tailbone.util:should_use_oruga')
def csrf_token_name(config):
return config.get('tailbone', 'csrf_token_name', default='_csrf')
@ -65,6 +63,25 @@ def csrf_header_name(config):
return config.get('tailbone', 'csrf_header_name', default='X-CSRF-TOKEN')
def get_buefy_version(config):
warnings.warn("get_buefy_version() is deprecated; please use "
"tailbone.util.get_libver() instead",
DeprecationWarning, stacklevel=2)
version = config.get('tailbone', 'libver.buefy')
if version:
return version
return config.get('tailbone', 'buefy_version',
default='latest')
def get_buefy_0_8(config, version=None):
warnings.warn("get_buefy_0_8() is no longer supported",
DeprecationWarning, stacklevel=2)
return False
def global_help_url(config):
return config.get('tailbone', 'global_help_url')

View file

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

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2024 Lance Edgar
# Copyright © 2010-2019 Lance Edgar
#
# This file is part of Rattail.
#
@ -24,8 +24,7 @@
Tools for displaying data diffs
"""
import sqlalchemy as sa
import sqlalchemy_continuum as continuum
from __future__ import unicode_literals, absolute_import
from pyramid.renderers import render
from webhelpers2.html import HTML
@ -34,41 +33,37 @@ 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',
def __init__(self, old_data, new_data, columns=None, fields=None,
render_field=None, render_value=None,
monospace=False, extra_row_attrs=None):
"""
Constructor. You must provide the old and new data sets, and
the set of relevant fields as well, if they cannot be easily
introspected.
:param old_data: Dict of "old" data values.
:param new_data: Dict of "old" data values.
:param fields: Sequence of relevant field names. Note that
both data dicts are expected to have keys which match these
field names. If you do not specify the fields then they
will (hopefully) be introspected from the old or new data
sets; however this will not work if they are both empty.
:param monospace: If true, this flag will cause the value
columns to be rendered in monospace font. This is assumed
to be helpful when comparing "raw" data values which are
shown as e.g. ``repr(val)``.
"""
self.old_data = old_data
self.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
@ -95,7 +90,7 @@ class Diff(object):
for the given field. May be an empty string, or a snippet of HTML
attribute syntax, e.g.:
.. code-block:: none
.. code-highlight:: none
class="diff" foo="bar"
@ -131,161 +126,3 @@ class Diff(object):
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,
}

View file

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

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2023 Lance Edgar
# Copyright © 2010-2018 Lance Edgar
#
# This file is part of Rattail.
#
@ -24,7 +24,8 @@
Forms Library
"""
# nb. import widgets before types, b/c types may refer to widgets
from . import widgets
from __future__ import unicode_literals, absolute_import
from . import types
from . import widgets
from .core import Form, SimpleFileImport

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2023 Lance Edgar
# Copyright © 2010-2022 Lance Edgar
#
# This file is part of Rattail.
#
@ -24,6 +24,8 @@
Common Forms
"""
from __future__ import unicode_literals, absolute_import
from rattail.db import model
import colander
@ -33,7 +35,7 @@ import colander
def validate_user(node, kw):
session = kw['session']
def validate(node, value):
user = session.get(model.User, value)
user = session.query(model.User).get(value)
if not user:
raise colander.Invalid(node, "User not found")
return user.uuid

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2024 Lance Edgar
# Copyright © 2010-2023 Lance Edgar
#
# This file is part of Rattail.
#
@ -24,18 +24,19 @@
Forms Core
"""
import hashlib
from __future__ import unicode_literals, absolute_import
import json
import logging
import warnings
from collections import OrderedDict
import six
import sqlalchemy as sa
from sqlalchemy import orm
from sqlalchemy.ext.associationproxy import AssociationProxy, ASSOCIATION_PROXY
from wuttjamaican.util import UNSPECIFIED
from rattail.util import pretty_boolean
from rattail.time import localtime
from rattail.util import prettify, pretty_boolean, pretty_quantity
from rattail.core import UNSPECIFIED
from rattail.db.util import get_fieldnames
import colander
@ -47,14 +48,12 @@ from pyramid_deform import SessionFileUploadTempStore
from pyramid.renderers import render
from webhelpers2.html import tags, HTML
from wuttaweb.util import FieldList, get_form_data, make_json_safe
from tailbone.db import Session
from tailbone.util import raw_datetime, render_markdown
from tailbone.forms import types
from tailbone.forms.widgets import (ReadonlyWidget, PlainDateWidget,
JQueryDateWidget, JQueryTimeWidget,
FileUploadWidget, MultiFileUploadWidget)
from tailbone.util import raw_datetime, get_form_data, render_markdown
from . import types
from .widgets import (ReadonlyWidget, PlainDateWidget,
JQueryDateWidget, JQueryTimeWidget,
MultiFileUploadWidget)
from tailbone.exceptions import TailboneJSONFieldError
@ -226,7 +225,7 @@ class CustomSchemaNode(SQLAlchemySchemaNode):
if excludes:
overrides['excludes'] = excludes
return super().get_schema_from_relationship(prop, overrides)
return super(CustomSchemaNode, self).get_schema_from_relationship(prop, overrides)
def dictify(self, obj):
""" Return a dictified version of `obj` using schema information.
@ -235,7 +234,7 @@ class CustomSchemaNode(SQLAlchemySchemaNode):
This method was copied from upstream and modified to add automatic
handling of "association proxy" fields.
"""
dict_ = super().dictify(obj)
dict_ = super(CustomSchemaNode, self).dictify(obj)
for node in self:
name = node.name
@ -328,7 +327,7 @@ class Form(object):
"""
Base class for all forms.
"""
save_label = "Submit"
save_label = "Save"
update_label = "Save"
show_cancel = True
auto_disable = True
@ -339,12 +338,10 @@ class Form(object):
model_instance=None, model_class=None, appstruct=UNSPECIFIED, nodes={}, enums={}, labels={},
assume_local_times=False, renderers=None, renderer_kwargs={},
hidden={}, widgets={}, defaults={}, validators={}, required={}, helptext={}, focus_spec=None,
action_url=None, cancel_url=None,
vue_tagname=None,
vuejs_component_kwargs=None, vuejs_field_converters={}, json_data={}, included_templates={},
action_url=None, cancel_url=None, use_buefy=True, component='tailbone-form',
vuejs_field_converters={},
# TODO: ugh this is getting out hand!
can_edit_help=False, edit_help_url=None, route_prefix=None,
**kwargs
):
self.fields = None
if fields is not None:
@ -352,7 +349,6 @@ class Form(object):
self.schema = schema
if self.fields is None and self.schema:
self.set_fields([f.name for f in self.schema])
self.grouping = None
self.request = request
self.readonly = readonly
self.readonly_fields = set(readonly_fields or [])
@ -378,82 +374,23 @@ class Form(object):
self.validators = validators or {}
self.required = required or {}
self.helptext = helptext or {}
self.dynamic_helptext = {}
self.focus_spec = focus_spec
self.action_url = action_url
self.cancel_url = cancel_url
# vue_tagname
self.vue_tagname = vue_tagname
if not self.vue_tagname and kwargs.get('component'):
warnings.warn("component kwarg is deprecated for Form(); "
"please use vue_tagname param instead",
DeprecationWarning, stacklevel=2)
self.vue_tagname = kwargs['component']
if not self.vue_tagname:
self.vue_tagname = 'tailbone-form'
self.vuejs_component_kwargs = vuejs_component_kwargs or {}
self.use_buefy = use_buefy
self.component = component
self.vuejs_field_converters = vuejs_field_converters or {}
self.json_data = json_data or {}
self.included_templates = included_templates or {}
self.can_edit_help = can_edit_help
self.edit_help_url = edit_help_url
self.route_prefix = route_prefix
self.button_icon_submit = kwargs.get('button_icon_submit', 'save')
def __iter__(self):
return iter(self.fields)
@property
def vue_component(self):
"""
String name for the Vue component, e.g. ``'TailboneGrid'``.
This is a generated value based on :attr:`vue_tagname`.
"""
words = self.vue_tagname.split('-')
return ''.join([word.capitalize() for word in words])
@property
def component(self):
"""
DEPRECATED - use :attr:`vue_tagname` instead.
"""
warnings.warn("Form.component is deprecated; "
"please use vue_tagname instead",
DeprecationWarning, stacklevel=2)
return self.vue_tagname
@property
def component_studly(self):
"""
DEPRECATED - use :attr:`vue_component` instead.
"""
warnings.warn("Form.component_studly is deprecated; "
"please use vue_component instead",
DeprecationWarning, stacklevel=2)
return self.vue_component
def get_button_label_submit(self):
""" """
if hasattr(self, '_button_label_submit'):
return self._button_label_submit
label = getattr(self, 'submit_label', None)
if label:
return label
return self.save_label
def set_button_label_submit(self, value):
""" """
self._button_label_submit = value
# wutta compat
button_label_submit = property(get_button_label_submit,
set_button_label_submit)
words = self.component.split('-')
return ''.join([word.capitalize() for word in words])
def __contains__(self, item):
return item in self.fields
@ -471,9 +408,6 @@ class Form(object):
return get_fieldnames(self.request.rattail_config, self.model_class,
columns=True, proxies=True, relations=True)
def set_grouping(self, items):
self.grouping = OrderedDict(items)
def make_renderers(self):
"""
Return a default set of field renderers, based on :attr:`model_class`.
@ -630,9 +564,7 @@ class Form(object):
self.schema[key].title = label
def get_label(self, key):
config = self.request.rattail_config
app = config.get_app()
return self.labels.get(key, app.make_title(key))
return self.labels.get(key, prettify(key))
def set_readonly(self, key, readonly=True):
if readonly:
@ -654,18 +586,8 @@ class Form(object):
self.schema[key] = node
def set_type(self, key, type_, **kwargs):
if type_ == 'datetime':
self.set_renderer(key, self.render_datetime)
elif type_ == 'datetime_falafel':
self.set_renderer(key, self.render_datetime)
self.set_node(key, types.FalafelDateTime(request=self.request))
if kwargs.get('helptext'):
app = self.request.rattail_config.get_app()
timezone = app.get_timezone()
self.set_helptext(key, f"NOTE: all times are local to {timezone}")
elif type_ == 'datetime_local':
self.set_renderer(key, self.render_datetime_local)
elif type_ == 'date_plain':
@ -674,14 +596,9 @@ class Form(object):
# TODO: is this safe / a good idea?
# self.set_node(key, colander.Date())
self.set_widget(key, JQueryDateWidget())
elif type_ == 'time_jquery':
self.set_node(key, types.JQueryTime())
self.set_widget(key, JQueryTimeWidget())
elif type_ == 'time_falafel':
self.set_node(key, types.FalafelTime(request=self.request))
elif type_ == 'duration':
self.set_renderer(key, self.render_duration)
elif type_ == 'boolean':
@ -708,7 +625,7 @@ class Form(object):
self.set_widget(key, dfwidget.TextAreaWidget(cols=80, rows=8))
elif type_ == 'file':
tmpstore = SessionFileUploadTempStore(self.request)
kw = {'widget': FileUploadWidget(tmpstore, request=self.request),
kw = {'widget': dfwidget.FileUploadWidget(tmpstore),
'title': self.get_label(key)}
if 'required' in kwargs and not kwargs['required']:
kw['missing'] = colander.null
@ -723,25 +640,11 @@ class Form(object):
'widget': MultiFileUploadWidget(tmpstore)}
# if 'required' in kwargs and not kwargs['required']:
# kw['missing'] = colander.null
if kwargs.get('validate_unique'):
kw['validator'] = self.validate_multiple_files_unique
files_node = colander.SequenceSchema(file_node, **kw)
self.set_node(key, files_node)
else:
raise ValueError("unknown type for '{}' field: {}".format(key, type_))
def validate_multiple_files_unique(self, node, value):
# get SHA256 hash for each file; error if duplicates encountered
hashes = {}
for fileinfo in value:
fp = fileinfo['fp']
fp.seek(0)
filehash = hashlib.sha256(fp.read()).hexdigest()
if filehash in hashes:
node.raise_invalid(f"Duplicate file detected: {fileinfo['filename']}")
hashes[filehash] = fileinfo
def set_enum(self, key, enum, empty=None):
if enum:
self.enums[key] = enum
@ -805,8 +708,9 @@ class Form(object):
case the validator pertains to the form at large instead of
one of the fields.
:param validator: Callable which accepts ``(node, value)``
args.
TODO: what should the validator look like?
:param validator: Callable validator for the node.
"""
self.validators[key] = validator
@ -828,16 +732,11 @@ class Form(object):
"""
self.defaults[key] = value
def set_helptext(self, key, value, dynamic=False):
def set_helptext(self, key, value):
"""
Set the help text for a given field.
"""
# nb. must avoid newlines, they cause some weird "blank page" error?!
self.helptext[key] = value.replace('\n', ' ')
if value and dynamic:
self.dynamic_helptext[key] = True
else:
self.dynamic_helptext.pop(key, None)
self.helptext[key] = value
def has_helptext(self, key):
"""
@ -857,15 +756,15 @@ class Form(object):
def set_vuejs_field_converter(self, field, converter):
self.vuejs_field_converters[field] = converter
def render(self, **kwargs):
warnings.warn("Form.render() is deprecated (for now?); "
"please use Form.render_deform() instead",
DeprecationWarning, stacklevel=2)
return self.render_deform(**kwargs)
def get_deform(self):
""" """
return self.make_deform_form()
def render(self, template=None, **kwargs):
if not template:
if self.readonly and not self.use_buefy:
template = '/forms/form_readonly.mako'
else:
template = '/forms/form.mako'
context = kwargs
context['form'] = self
return render(template, context)
def make_deform_form(self):
if not hasattr(self, 'deform_form'):
@ -905,21 +804,19 @@ class Form(object):
return self.deform_form
def render_vue_template(self, template='/forms/deform.mako', **context):
""" """
output = self.render_deform(template=template, **context)
return HTML.literal(output)
def render_deform(self, dform=None, template=None, **kwargs):
if not template:
template = '/forms/deform.mako'
if self.use_buefy:
template = '/forms/deform_buefy.mako'
else:
template = '/forms/deform.mako'
if dform is None:
dform = self.make_deform_form()
# TODO: would perhaps be nice to leverage deform's default rendering
# someday..? i.e. using Chameleon *.pt templates
# return dform.render()
# return form.render()
context = kwargs
context['form'] = self
@ -932,8 +829,11 @@ class Form(object):
context.setdefault('form_kwargs', {})
# TODO: deprecate / remove the latter option here
if self.auto_disable_save or self.auto_disable:
context['form_kwargs'].setdefault('ref', self.vue_component)
context['form_kwargs']['@submit'] = 'submit{}'.format(self.vue_component)
if self.use_buefy:
context['form_kwargs'].setdefault('ref', self.component_studly)
context['form_kwargs']['@submit'] = 'submit{}'.format(self.component_studly)
else:
context['form_kwargs']['class_'] = 'autodisable'
if self.focus_spec:
context['form_kwargs']['data-focus'] = self.focus_spec
context['request'] = self.request
@ -945,13 +845,11 @@ class Form(object):
return dict([(field, self.get_label(field))
for field in self])
def get_field_markdowns(self, session=None):
app = self.request.rattail_config.get_app()
model = app.model
session = session or Session()
def get_field_markdowns(self):
model = self.request.rattail_config.get_model()
if not hasattr(self, 'field_markdowns'):
infos = session.query(model.TailboneFieldInfo)\
infos = Session.query(model.TailboneFieldInfo)\
.filter(model.TailboneFieldInfo.route_prefix == self.route_prefix)\
.all()
self.field_markdowns = dict([(info.field_name, info.markdown_text)
@ -959,18 +857,6 @@ class Form(object):
return self.field_markdowns
def get_vue_field_value(self, key):
""" """
if key not in self.fields:
return
dform = self.get_deform()
if key not in dform:
return
field = dform[key]
return make_json_safe(field.cstruct)
def get_vuejs_model_value(self, field):
"""
This method must return "raw" JS which will be assigned as the initial
@ -1005,11 +891,6 @@ class Form(object):
return json.dumps({'name': value['filename']})
return 'null'
elif isinstance(value, list) and all([isinstance(f, dfwidget.filedict)
for f in value]):
return json.dumps([{'name': f['filename']}
for f in value])
app = self.request.rattail_config.get_app()
value = app.json_friendly(value)
return json.dumps(value)
@ -1034,65 +915,12 @@ class Form(object):
return False
return True
def set_vuejs_component_kwargs(self, **kwargs):
self.vuejs_component_kwargs.update(kwargs)
def render_vue_tag(self, **kwargs):
""" """
return self.render_vuejs_component(**kwargs)
def render_vuejs_component(self, **kwargs):
def render_buefy_field(self, fieldname, bfield_attrs={}):
"""
Render the Vue.js component HTML for the form.
Most typically this is something like:
.. code-block:: html
<tailbone-form :configure-fields-help="configureFieldsHelp">
</tailbone-form>
"""
kw = dict(self.vuejs_component_kwargs)
kw.update(kwargs)
if self.can_edit_help:
kw.setdefault(':configure-fields-help', 'configureFieldsHelp')
return HTML.tag(self.vue_tagname, **kw)
def set_json_data(self, key, value):
"""
Establish a data value for use in client-side JS. This value
will be JSON-encoded and made available to the
`<tailbone-form>` component within the client page.
"""
self.json_data[key] = value
def include_template(self, template, context):
"""
Declare a JS template as required by the current form. This
template will then be included in the final page, so all
widgets behave correctly.
"""
self.included_templates[template] = context
def render_included_templates(self):
templates = []
for template, context in self.included_templates.items():
context = dict(context)
context['form'] = self
templates.append(HTML.literal(render(template, context)))
return HTML.literal('\n').join(templates)
def render_vue_field(self, fieldname, **kwargs):
""" """
return self.render_field_complete(fieldname, **kwargs)
def render_field_complete(self, fieldname, bfield_attrs={},
session=None):
"""
Render the given field completely, i.e. with ``<b-field>``
wrapper. Note that this is meant to render *editable* fields,
i.e. showing a widget, unless the field input is hidden. In
other words it's not for "readonly" fields.
Render the given field in a Buefy-compatible way. Note that
this is meant to render *editable* fields, i.e. showing a
widget, unless the field input is hidden. In other words it's
not for "readonly" fields.
"""
dform = self.make_deform_form()
field = dform[fieldname] if fieldname in dform else None
@ -1105,7 +933,7 @@ class Form(object):
if self.field_visible(fieldname):
label = self.get_label(fieldname)
markdowns = self.get_field_markdowns(session=session)
markdowns = self.get_field_markdowns()
# these attrs will be for the <b-field> (*not* the widget)
attrs = {
@ -1116,39 +944,29 @@ class Form(object):
if field and isinstance(field.schema.typ, deform.FileData):
attrs['class_'] = 'file'
# next we will build array of messages to display..some
# fields always show a "helptext" msg, and some may have
# validation errors..
field_type = None
messages = []
# show errors if present
error_messages = self.get_error_messages(field) if field else None
if error_messages:
field_type = 'is-danger'
messages.extend(error_messages)
# show helptext if present
# TODO: older logic did this only if field was *not*
# readonly, perhaps should add that back..
if self.has_helptext(fieldname):
messages.append(self.render_helptext(fieldname))
attrs['message'] = self.render_helptext(fieldname)
# ..okay now we can declare the field messages and type
if field_type:
attrs['type'] = field_type
if messages:
if len(messages) == 1:
msg = messages[0]
if msg.startswith('`') and msg.endswith('`'):
attrs[':message'] = msg
else:
attrs['message'] = msg
else:
# nb. must pass an array as JSON string
attrs[':message'] = '[{}]'.format(', '.join([
"'{}'".format(msg.replace("'", r"\'"))
for msg in messages]))
# show errors if present
error_messages = self.get_error_messages(field) if field else None
if error_messages:
# TODO: this surely can't be what we ought to do
# here..? seems like we must pass JS but not JSON,
# sort of, so we custom-write the JS code to ensure
# single instead of double quotes delimit strings
# within the code.
message = '[{}]'.format(', '.join([
"'{}'".format(msg.replace("'", r"\'"))
for msg in error_messages]))
attrs.update({
'type': 'is-danger',
':message': message,
})
# merge anything caller provided
attrs.update(bfield_attrs)
@ -1156,10 +974,9 @@ class Form(object):
# render the field widget or whatever
if self.readonly or fieldname in self.readonly_fields:
html = self.render_field_value(fieldname) or HTML.tag('span')
if type(html) is str:
html = HTML.tag('span', c=[html])
elif field:
html = field.serialize(**self.get_renderer_kwargs(fieldname))
html = field.serialize(use_buefy=True,
**self.get_renderer_kwargs(fieldname))
html = HTML.literal(html)
# may need a complex label
@ -1196,27 +1013,15 @@ class Form(object):
label_contents.append(HTML.literal('&nbsp; &nbsp;'))
label_contents.append(icon)
# only declare label template if it's complex
html = [html]
# TODO: figure out why complex label does not work for oruga
if self.request.use_oruga:
attrs['label'] = label
else:
if len(label_contents) > 1:
# nb. must apply hack to get <template #label> as final result
label_template = HTML.tag('template', c=label_contents,
**{'#label': 1})
label_template = label_template.replace(
HTML.literal('<template #label="1"'),
HTML.literal('<template #label'))
html.insert(0, label_template)
else: # simple label
attrs['label'] = label
# nb. must apply hack to get <template #label> as final result
label_template = HTML.tag('template', c=label_contents,
**{'#label': 1})
label_template = label_template.replace(
HTML.literal('<template #label="1"'),
HTML.literal('<template #label'))
# and finally wrap it all in a <b-field>
return HTML.tag('b-field', c=html, **attrs)
return HTML.tag('b-field', c=[label_template, html], **attrs)
elif field: # hidden field
@ -1224,18 +1029,6 @@ class Form(object):
# TODO: again, why does serialize() not return literal?
return HTML.literal(field.serialize())
# TODO: this was copied from wuttaweb; can remove when we align
# Form class structure
def render_vue_finalize(self):
""" """
set_data = f"{self.vue_component}.data = function() {{ return {self.vue_component}Data }}"
make_component = f"Vue.component('{self.vue_tagname}', {self.vue_component})"
return HTML.tag('script', c=['\n',
HTML.literal(set_data),
'\n',
HTML.literal(make_component),
'\n'])
def render_field_readonly(self, field_name, **kwargs):
"""
Render the given field completely, but in read-only fashion.
@ -1246,30 +1039,20 @@ class Form(object):
if field_name not in self.fields:
return ''
# TODO: fair bit of duplication here, should merge with deform.mako
label = kwargs.get('label')
if not label:
label = self.get_label(field_name)
label = HTML.tag('label', label, for_=field_name)
field = self.render_field_value(field_name) or ''
field_div = HTML.tag('div', class_='field', c=[field])
contents = [label, field_div]
value = self.render_field_value(field_name) or ''
if self.has_helptext(field_name):
contents.append(HTML.tag('span', class_='instructions',
c=[self.render_helptext(field_name)]))
if not self.request.use_oruga:
label = HTML.tag('label', label, for_=field_name)
field_div = HTML.tag('div', class_='field', c=[value])
contents = [label, field_div]
if self.has_helptext(field_name):
contents.append(HTML.tag('span', class_='instructions',
c=[self.render_helptext(field_name)]))
return HTML.tag('div', class_='field-wrapper {}'.format(field_name), c=contents)
# nb. for some reason we must wrap once more for oruga,
# otherwise it splits up the field?!
value = HTML.tag('span', c=[value])
# oruga uses <o-field>
return HTML.tag('o-field', label=label, c=[value], **{':horizontal': 'true'})
return HTML.tag('div', class_='field-wrapper {}'.format(field_name), c=contents)
def render_field_value(self, field_name):
record = self.model_instance
@ -1281,7 +1064,7 @@ class Form(object):
value = self.obtain_value(record, field_name)
if value is None:
return ""
return str(value)
return six.text_type(value)
def render_datetime(self, record, field_name):
value = self.obtain_value(record, field_name)
@ -1293,8 +1076,7 @@ class Form(object):
value = self.obtain_value(record, field_name)
if value is None:
return ""
app = self.request.rattail_config.get_app()
value = app.localtime(value)
value = localtime(self.request.rattail_config, value)
return raw_datetime(self.request.rattail_config, value)
def render_duration(self, record, field_name):
@ -1317,14 +1099,13 @@ class Form(object):
return "(${:0,.2f})".format(0 - value)
return "${:0,.2f}".format(value)
except ValueError:
return str(value)
return six.text_type(value)
def render_quantity(self, obj, field):
value = self.obtain_value(obj, field)
if value is None:
return ""
app = self.request.rattail_config.get_app()
return app.render_quantity(value)
return pretty_quantity(value)
def render_percent(self, obj, field):
app = self.request.rattail_config.get_app()
@ -1343,8 +1124,8 @@ class Form(object):
return ""
enum = self.enums.get(field_name)
if enum and value in enum:
return str(enum[value])
return str(value)
return six.text_type(enum[value])
return six.text_type(value)
def render_codeblock(self, record, field_name):
value = self.obtain_value(record, field_name)
@ -1375,70 +1156,85 @@ class Form(object):
def obtain_value(self, record, field_name):
if record:
if isinstance(record, dict):
return record[field_name]
try:
return getattr(record, field_name)
except AttributeError:
pass
try:
return record[field_name]
except KeyError:
return None
except TypeError:
pass
return getattr(record, field_name, None)
# TODO: is this always safe to do?
elif self.defaults and field_name in self.defaults:
return self.defaults[field_name]
def validate(self, *args, **kwargs):
"""
Try to validate the form.
if kwargs.pop('newstyle', False):
# yay, new behavior!
if hasattr(self, 'validated'):
del self.validated
if self.request.method != 'POST':
return False
This should work whether data was submitted as classic POST
data, or as JSON body.
controls = get_form_data(self.request).items()
:returns: ``True`` if form data is valid, otherwise ``False``.
"""
if 'newstyle' in kwargs:
warnings.warn("the `newstyle` kwarg is no longer used "
"for Form.validate()",
DeprecationWarning, stacklevel=2)
# unfortunately the normal form logic (i.e. peppercorn) is
# expecting all values to be strings, whereas if our data
# came from JSON body, may have given us some Pythonic
# objects. so here we must convert them *back* to strings
# TODO: this seems like a hack, i must be missing something
# TODO: also this uses same "JSON" check as get_form_data()
if self.request.is_xhr and not self.request.POST:
controls = [[key, val] for key, val in controls]
for i in range(len(controls)):
key, value = controls[i]
if value is None:
controls[i][1] = ''
elif value is True:
controls[i][1] = 'true'
elif value is False:
controls[i][1] = 'false'
elif not isinstance(value, six.string_types):
controls[i][1] = six.text_type(value)
if hasattr(self, 'validated'):
del self.validated
if self.request.method != 'POST':
return False
dform = self.make_deform_form()
try:
self.validated = dform.validate(controls)
return True
except deform.ValidationFailure:
return False
controls = get_form_data(self.request).items()
else: # legacy behavior
raise_error = kwargs.pop('raise_error', True)
dform = self.make_deform_form()
try:
return dform.validate(*args, **kwargs)
except deform.ValidationFailure:
if raise_error:
raise
# unfortunately the normal form logic (i.e. peppercorn) is
# expecting all values to be strings, whereas if our data
# came from JSON body, may have given us some Pythonic
# objects. so here we must convert them *back* to strings
# TODO: this seems like a hack, i must be missing something
# TODO: also this uses same "JSON" check as get_form_data()
if self.request.is_xhr and not self.request.POST:
controls = [[key, val] for key, val in controls]
for i in range(len(controls)):
key, value = controls[i]
if value is None:
controls[i][1] = ''
elif value is True:
controls[i][1] = 'true'
elif value is False:
controls[i][1] = 'false'
elif not isinstance(value, str):
controls[i][1] = str(value)
dform = self.make_deform_form()
try:
self.validated = dform.validate(controls)
return True
except deform.ValidationFailure:
return False
class FieldList(list):
"""
Convenience wrapper for a form's field list.
"""
def insert_before(self, field, newfield):
if field in self:
i = self.index(field)
self.insert(i, newfield)
else:
log.warning("field '%s' not found, will append new field: %s",
field, newfield)
self.append(newfield)
def insert_after(self, field, newfield):
if field in self:
i = self.index(field)
self.insert(i + 1, newfield)
else:
log.warning("field '%s' not found, will append new field: %s",
field, newfield)
self.append(newfield)
@colander.deferred

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2023 Lance Edgar
# Copyright © 2010-2019 Lance Edgar
#
# This file is part of Rattail.
#
@ -24,6 +24,8 @@
Forms for Receiving
"""
from __future__ import unicode_literals, absolute_import
from rattail.db import model
import colander
@ -33,7 +35,7 @@ import colander
def valid_purchase_batch_row(node, kw):
session = kw['session']
def validate(node, value):
row = session.get(model.PurchaseBatchRow, value)
row = session.query(model.PurchaseBatchRow).get(value)
if not row:
raise colander.Invalid(node, "Batch row not found")
if row.batch.executed:
@ -52,7 +54,6 @@ class ReceiveRow(colander.MappingSchema):
'received',
'damaged',
'expired',
'missing',
# 'mispick',
]))

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2023 Lance Edgar
# Copyright © 2010-2019 Lance Edgar
#
# This file is part of Rattail.
#
@ -24,9 +24,12 @@
Form Schema Types
"""
from __future__ import unicode_literals, absolute_import
import re
import datetime
import json
import six
from rattail.db import model
from rattail.gpc import GPC
@ -34,7 +37,6 @@ from rattail.gpc import GPC
import colander
from tailbone.db import Session
from tailbone.forms import widgets
class JQueryTime(colander.Time):
@ -74,76 +76,6 @@ class DateTimeBoolean(colander.Boolean):
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.
@ -152,7 +84,7 @@ class GPCType(colander.SchemaType):
def serialize(self, node, appstruct):
if appstruct is colander.null:
return colander.null
return str(appstruct)
return six.text_type(appstruct)
def deserialize(self, node, cstruct):
if not cstruct:
@ -163,7 +95,7 @@ class GPCType(colander.SchemaType):
try:
return GPC(digits)
except Exception as err:
raise colander.Invalid(node, str(err))
raise colander.Invalid(node, six.text_type(err))
class ProductQuantity(colander.MappingSchema):
@ -201,12 +133,12 @@ class ModelType(colander.SchemaType):
def serialize(self, node, appstruct):
if appstruct is colander.null:
return colander.null
return str(appstruct)
return six.text_type(appstruct)
def deserialize(self, node, cstruct):
if not cstruct:
return None
obj = self.session.get(self.model_class, cstruct)
obj = self.session.query(self.model_class).get(cstruct)
if not obj:
raise colander.Invalid(node, "{} not found".format(self.model_title))
return obj

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2024 Lance Edgar
# Copyright © 2010-2023 Lance Edgar
#
# This file is part of Rattail.
#
@ -24,16 +24,20 @@
Form Widgets
"""
from __future__ import unicode_literals, absolute_import, division
import json
import datetime
import decimal
import re
import six
import colander
from deform import widget as dfwidget
from webhelpers2.html import tags, HTML
from tailbone.db import Session
from tailbone.forms.types import ProductQuantity
class ReadonlyWidget(dfwidget.HiddenWidget):
@ -41,7 +45,6 @@ class ReadonlyWidget(dfwidget.HiddenWidget):
readonly = True
def serialize(self, field, cstruct, **kw):
""" """
if cstruct in (colander.null, None):
cstruct = ''
# TODO: is this hacky?
@ -58,11 +61,11 @@ class NumberInputWidget(dfwidget.TextInputWidget):
class NumericInputWidget(NumberInputWidget):
"""
This widget uses a ``<numeric-input>`` component, which will
leverage the ``numeric.js`` functions to ensure user doesn't enter
any non-numeric values. Note that this still uses a normal "text"
input on the HTML side, as opposed to a "number" input, since the
latter is a bit ugly IMHO.
This widget only supports Buefy themes for now. It uses a
``<numeric-input>`` component, which will leverage the ``numeric.js``
functions to ensure user doesn't enter any non-numeric values. Note that
this still uses a normal "text" input on the HTML side, as opposed to a
"number" input, since the latter is a bit ugly IMHO.
"""
template = 'numericinput'
allow_enter = True
@ -79,17 +82,15 @@ class PercentInputWidget(dfwidget.TextInputWidget):
autocomplete = 'off'
def serialize(self, field, cstruct, **kw):
""" """
if cstruct not in (colander.null, None):
# convert "traditional" value to "human-friendly"
value = decimal.Decimal(cstruct) * 100
value = value.quantize(decimal.Decimal('0.001'))
cstruct = str(value)
return super().serialize(field, cstruct, **kw)
cstruct = six.text_type(value)
return super(PercentInputWidget, self).serialize(field, cstruct, **kw)
def deserialize(self, field, pstruct):
""" """
pstruct = super().deserialize(field, pstruct)
pstruct = super(PercentInputWidget, self).deserialize(field, pstruct)
if pstruct is colander.null:
return colander.null
# convert "human-friendly" value to "traditional"
@ -99,7 +100,7 @@ class PercentInputWidget(dfwidget.TextInputWidget):
raise colander.Invalid(field.schema, "Invalid decimal string: {}".format(pstruct))
value = value.quantize(decimal.Decimal('0.00001'))
value /= 100
return str(value)
return six.text_type(value)
class CasesUnitsWidget(dfwidget.Widget):
@ -112,7 +113,6 @@ class CasesUnitsWidget(dfwidget.Widget):
one_amount_only = False
def serialize(self, field, cstruct, **kw):
""" """
if cstruct in (colander.null, None):
cstruct = ''
readonly = kw.get('readonly', self.readonly)
@ -123,9 +123,6 @@ class CasesUnitsWidget(dfwidget.Widget):
return field.renderer(template, **values)
def deserialize(self, field, pstruct):
""" """
from tailbone.forms.types import ProductQuantity
if pstruct is colander.null:
return colander.null
@ -154,7 +151,6 @@ class DynamicCheckboxWidget(dfwidget.CheckboxWidget):
template = 'checkbox_dynamic'
# TODO: deprecate / remove this
class PlainSelectWidget(dfwidget.SelectWidget):
template = 'select_plain'
@ -173,7 +169,7 @@ class CustomSelectWidget(dfwidget.SelectWidget):
self.extra_template_values.update(kw)
def get_template_values(self, field, cstruct, kw):
values = super().get_template_values(field, cstruct, kw)
values = super(CustomSelectWidget, self).get_template_values(field, cstruct, kw)
if hasattr(self, 'extra_template_values'):
values.update(self.extra_template_values)
return values
@ -216,7 +212,6 @@ class JQueryDateWidget(dfwidget.DateInputWidget):
)
def serialize(self, field, cstruct, **kw):
""" """
if cstruct in (colander.null, None):
cstruct = ''
readonly = kw.get('readonly', self.readonly)
@ -244,48 +239,6 @@ class JQueryTimeWidget(dfwidget.TimeInputWidget):
)
class FalafelDateTimeWidget(dfwidget.DateTimeInputWidget):
"""
Custom widget for rattail UTC datetimes
"""
template = 'datetime_falafel'
new_pattern = re.compile(r'^\d\d?:\d\d:\d\d [AP]M$')
def serialize(self, field, cstruct, **kw):
""" """
readonly = kw.get('readonly', self.readonly)
values = self.get_template_values(field, cstruct, kw)
template = self.readonly_template if readonly else self.template
return field.renderer(template, **values)
def deserialize(self, field, pstruct):
""" """
if pstruct == '':
return colander.null
# nb. we now allow '4:20:00 PM' on the widget side, but the
# true node needs it to be '16:20:00' instead
if self.new_pattern.match(pstruct['time']):
time = datetime.datetime.strptime(pstruct['time'], '%I:%M:%S %p')
pstruct['time'] = time.strftime('%H:%M:%S')
return pstruct
class FalafelTimeWidget(dfwidget.TimeInputWidget):
"""
Custom widget for simple time fields
"""
template = 'time_falafel'
def deserialize(self, field, pstruct):
""" """
if pstruct == '':
return colander.null
return pstruct
class JQueryAutocompleteWidget(dfwidget.AutocompleteInputWidget):
"""
Uses the jQuery autocomplete plugin, instead of whatever it is deform uses
@ -308,7 +261,6 @@ class JQueryAutocompleteWidget(dfwidget.AutocompleteInputWidget):
options = None
def serialize(self, field, cstruct, **kw):
""" """
if 'delay' in kw or getattr(self, 'delay', None):
raise ValueError(
'AutocompleteWidget does not support *delay* parameter '
@ -337,23 +289,6 @@ class JQueryAutocompleteWidget(dfwidget.AutocompleteInputWidget):
return field.renderer(template, **tmpl_values)
class FileUploadWidget(dfwidget.FileUploadWidget):
"""
Widget to handle file upload. Must override to add ``use_oruga``
to field template context.
"""
def __init__(self, *args, **kwargs):
self.request = kwargs.pop('request')
super().__init__(*args, **kwargs)
def get_template_values(self, field, cstruct, kw):
values = super().get_template_values(field, cstruct, kw)
if self.request:
values['use_oruga'] = self.request.use_oruga
return values
class MultiFileUploadWidget(dfwidget.FileUploadWidget):
"""
Widget to handle multiple (arbitrary number) of file uploads.
@ -361,29 +296,12 @@ class MultiFileUploadWidget(dfwidget.FileUploadWidget):
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'':
if len(pstruct) == 1 and not pstruct[0]:
return colander.null
files_data = []
@ -399,7 +317,6 @@ class MultiFileUploadWidget(dfwidget.FileUploadWidget):
return files_data
def deserialize_upload(self, upload):
""" """
# nb. this logic was copied from parent class and adapted
# to allow for multiple files. needs some more love.
@ -469,16 +386,13 @@ def make_customer_widget(request, **kwargs):
class CustomerAutocompleteWidget(JQueryAutocompleteWidget):
"""
Autocomplete widget for a
:class:`~rattail:rattail.db.model.customers.Customer` reference
field.
Autocomplete widget for a Customer reference field.
"""
def __init__(self, request, *args, **kwargs):
super().__init__(*args, **kwargs)
super(CustomerAutocompleteWidget, self).__init__(*args, **kwargs)
self.request = request
app = self.request.rattail_config.get_app()
model = app.model
model = self.request.rattail_config.get_model()
# must figure out URL providing autocomplete service
if 'service_url' not in kwargs:
@ -496,30 +410,26 @@ class CustomerAutocompleteWidget(JQueryAutocompleteWidget):
self.input_callback = input_handler
def serialize(self, field, cstruct, **kw):
""" """
# fetch customer to provide button label, if we have a value
if cstruct:
app = self.request.rattail_config.get_app()
model = app.model
customer = Session.get(model.Customer, cstruct)
model = self.request.rattail_config.get_model()
customer = Session.query(model.Customer).get(cstruct)
if customer:
self.field_display = str(customer)
self.field_display = six.text_type(customer)
return super().serialize(
return super(CustomerAutocompleteWidget, self).serialize(
field, cstruct, **kw)
class CustomerDropdownWidget(dfwidget.SelectWidget):
"""
Dropdown widget for a
:class:`~rattail:rattail.db.model.customers.Customer` reference
field.
Dropdown widget for a Customer reference field.
"""
def __init__(self, request, *args, **kwargs):
super().__init__(*args, **kwargs)
super(CustomerDropdownWidget, self).__init__(*args, **kwargs)
self.request = request
app = self.request.rattail_config.get_app()
# must figure out dropdown values, if they weren't given
if 'values' not in kwargs:
@ -531,8 +441,10 @@ class CustomerDropdownWidget(dfwidget.SelectWidget):
customers = customers()
else: # default customer list
customers = app.get_clientele_handler()\
.get_all_customers(Session())
model = self.request.rattail_config.get_model()
customers = Session.query(model.Customer)\
.order_by(model.Customer.name)\
.all()
# convert customer list to option values
self.values = [(c.uuid, c.name)
@ -554,106 +466,13 @@ class DepartmentWidget(dfwidget.SelectWidget):
def __init__(self, request, **kwargs):
if 'values' not in kwargs:
app = request.rattail_config.get_app()
model = app.model
model = request.rattail_config.get_model()
departments = Session.query(model.Department)\
.order_by(model.Department.number)
values = [(dept.uuid, str(dept))
values = [(dept.uuid, six.text_type(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]
super(DepartmentWidget, self).__init__(**kwargs)

File diff suppressed because it is too large Load diff

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2024 Lance Edgar
# Copyright © 2010-2022 Lance Edgar
#
# This file is part of Rattail.
#
@ -24,15 +24,17 @@
Grid Filters
"""
from __future__ import unicode_literals, absolute_import
import re
import datetime
import decimal
import logging
from collections import OrderedDict
import six
import sqlalchemy as sa
from rattail.gpc import GPC
from rattail.util import OrderedDict
from rattail.core import UNSPECIFIED
from rattail.time import localtime, make_utc
from rattail.util import prettify
@ -115,7 +117,7 @@ class EnumValueRenderer(ChoiceValueRenderer):
sorted_keys = list(enum.keys())
else:
sorted_keys = sorted(enum, key=lambda k: enum[k].lower())
self.options = [tags.Option(enum[k], str(k)) for k in sorted_keys]
self.options = [tags.Option(enum[k], six.text_type(k)) for k in sorted_keys]
class GridFilter(object):
@ -171,25 +173,18 @@ class GridFilter(object):
data_type = 'string' # default, but will be set from value renderer
choices = {}
def __init__(self, key, config=None, label=None, verbs=None,
value_enum=None, value_renderer=None,
def __init__(self, key, label=None, verbs=None, value_enum=None, value_renderer=None,
default_active=False, default_verb=None, default_value=None,
encode_values=False, value_encoding='utf-8', **kwargs):
self.key = key
self.config = config
self.label = label or prettify(key)
self.verbs = verbs or self.get_default_verbs()
if value_renderer:
self.set_value_renderer(value_renderer)
elif value_enum:
self.set_choices(value_enum)
else:
self.set_value_renderer(self.value_renderer_factory)
# nb. do this after setting choices, if applicable, since that
# could change default verbs
self.verbs = verbs or self.get_default_verbs()
self.default_active = default_active
self.default_verb = default_verb
self.default_value = default_value
@ -277,15 +272,14 @@ class GridFilter(object):
value = self.get_value(value)
filtr = getattr(self, 'filter_{0}'.format(verb), None)
if not filtr:
log.warning("unknown filter verb: %s", verb)
return data
raise ValueError("Unknown filter verb: {0}".format(repr(verb)))
return filtr(data, value)
def get_value(self, value=UNSPECIFIED):
return value if value is not UNSPECIFIED else self.value
def encode_value(self, value):
if self.encode_values and isinstance(value, str):
if self.encode_values and isinstance(value, six.string_types):
return value.encode('utf-8')
return value
@ -314,7 +308,7 @@ class AlchemyGridFilter(GridFilter):
def __init__(self, *args, **kwargs):
self.column = kwargs.pop('column')
super().__init__(*args, **kwargs)
super(AlchemyGridFilter, self).__init__(*args, **kwargs)
def filter_equal(self, query, value):
"""
@ -338,38 +332,6 @@ class AlchemyGridFilter(GridFilter):
self.column != self.encode_value(value),
))
def filter_equal_any_of(self, query, value):
"""
This filter expects "multiple values" separated by newline
character, and will add an "OR" condition with each value
being checked separately. For instance if the user submits a
"value" like this:
.. code-block:: none
foo bar
baz
This will result in SQL condition like this:
.. code-block:: sql
name = 'foo bar' OR name = 'baz'
"""
if not value:
return query
values = value.split('\n')
values = [value for value in values if value]
if not values:
return query
conditions = []
for value in values:
conditions.append(self.column == self.encode_value(value))
return query.filter(sa.or_(*conditions))
def filter_is_null(self, query, value):
"""
Filter data with an 'IS NULL' query. Note that this filter does not
@ -448,12 +410,12 @@ class AlchemyGridFilter(GridFilter):
if start_value:
if self.value_invalid(start_value):
return query
query = query.filter(self.column >= self.encode_value(start_value))
query = query.filter(self.column >= start_value)
if end_value:
if self.value_invalid(end_value):
return query
query = query.filter(self.column <= self.encode_value(end_value))
query = query.filter(self.column <= end_value)
return query
@ -467,13 +429,9 @@ class AlchemyStringFilter(AlchemyGridFilter):
"""
Expose contains / does-not-contain verbs in addition to core.
"""
if self.choices:
return ['equal', 'not_equal', 'is_null', 'is_not_null', 'is_any']
return ['contains', 'does_not_contain',
'contains_any_of',
'equal', 'not_equal', 'equal_any_of',
'equal', 'not_equal',
'is_empty', 'is_not_empty',
'is_null', 'is_not_null',
'is_empty_or_null',
@ -485,13 +443,9 @@ class AlchemyStringFilter(AlchemyGridFilter):
"""
if value is None or value == '':
return query
criteria = []
for val in value.split():
val = val.replace('_', r'\_')
val = self.encode_value(f'%{val}%')
criteria.append(self.column.ilike(val))
return query.filter(sa.and_(*criteria))
return query.filter(sa.and_(
*[self.column.ilike(self.encode_value('%{}%'.format(v)))
for v in value.split()]))
def filter_does_not_contain(self, query, value):
"""
@ -500,17 +454,14 @@ class AlchemyStringFilter(AlchemyGridFilter):
if value is None or value == '':
return query
criteria = []
for val in value.split():
val = val.replace('_', r'\_')
val = self.encode_value(f'%{val}%')
criteria.append(~self.column.ilike(val))
# When saying something is 'not like' something else, we must also
# include things which are nothing at all, in our result set.
return query.filter(sa.or_(
self.column == None,
sa.and_(*criteria)))
sa.and_(
*[~self.column.ilike(self.encode_value('%{}%'.format(v)))
for v in value.split()]),
))
def filter_contains_any_of(self, query, value):
"""
@ -539,28 +490,24 @@ class AlchemyStringFilter(AlchemyGridFilter):
conditions = []
for value in values:
criteria = []
for val in value.split():
val = val.replace('_', r'\_')
val = self.encode_value(f'%{val}%')
criteria.append(self.column.ilike(val))
conditions.append(sa.and_(*criteria))
conditions.append(sa.and_(
*[self.column.ilike(self.encode_value('%{}%'.format(v)))
for v in value.split()]))
return query.filter(sa.or_(*conditions))
def filter_is_empty(self, query, value):
return query.filter(sa.func.ltrim(sa.func.rtrim(self.column)) == self.encode_value(''))
return query.filter(sa.func.trim(self.column) == self.encode_value(''))
def filter_is_not_empty(self, query, value):
return query.filter(sa.func.ltrim(sa.func.rtrim(self.column)) != self.encode_value(''))
return query.filter(sa.func.trim(self.column) != self.encode_value(''))
def filter_is_empty_or_null(self, query, value):
return query.filter(
sa.or_(
sa.func.ltrim(sa.func.rtrim(self.column)) == self.encode_value(''),
sa.func.trim(self.column) == self.encode_value(''),
self.column == None))
class AlchemyEmptyStringFilter(AlchemyStringFilter):
"""
String filter with special logic to treat empty string values as NULL
@ -570,13 +517,13 @@ class AlchemyEmptyStringFilter(AlchemyStringFilter):
return query.filter(
sa.or_(
self.column == None,
sa.func.ltrim(sa.func.rtrim(self.column)) == self.encode_value('')))
sa.func.trim(self.column) == self.encode_value('')))
def filter_is_not_null(self, query, value):
return query.filter(
sa.and_(
self.column != None,
sa.func.ltrim(sa.func.rtrim(self.column)) != self.encode_value('')))
sa.func.trim(self.column) != self.encode_value('')))
class AlchemyByteStringFilter(AlchemyStringFilter):
@ -588,8 +535,8 @@ class AlchemyByteStringFilter(AlchemyStringFilter):
value_encoding = 'utf-8'
def get_value(self, value=UNSPECIFIED):
value = super().get_value(value)
if isinstance(value, str):
value = super(AlchemyByteStringFilter, self).get_value(value)
if isinstance(value, six.text_type):
value = value.encode(self.value_encoding)
return value
@ -599,13 +546,8 @@ class AlchemyByteStringFilter(AlchemyStringFilter):
"""
if value is None or value == '':
return query
criteria = []
for val in value.split():
val = val.replace('_', r'\_')
val = b'%{}%'.format(val)
criteria.append(self.column.ilike(val))
return query.filters(sa.and_(*criteria))
return query.filter(sa.and_(
*[self.column.ilike(b'%{}%'.format(v)) for v in value.split()]))
def filter_does_not_contain(self, query, value):
"""
@ -614,16 +556,13 @@ class AlchemyByteStringFilter(AlchemyStringFilter):
if value is None or value == '':
return query
for val in value.split():
val = val.replace('_', '\_')
val = b'%{}%'.format(val)
criteria.append(~self.column.ilike(val))
# When saying something is 'not like' something else, we must also
# include things which are nothing at all, in our result set.
return query.filter(sa.or_(
self.column == None,
sa.and_(*criteria)))
sa.and_(
*[~self.column.ilike(b'%{}%'.format(v)) for v in value.split()]),
))
class AlchemyNumericFilter(AlchemyGridFilter):
@ -632,11 +571,10 @@ class AlchemyNumericFilter(AlchemyGridFilter):
"""
value_renderer_factory = NumericValueRenderer
def default_verbs(self):
# expose greater-than / less-than verbs in addition to core
return ['equal', 'not_equal', 'greater_than', 'greater_equal',
'less_than', 'less_equal', 'between',
'is_null', 'is_not_null', 'is_any']
# expose greater-than / less-than verbs in addition to core
default_verbs = ['equal', 'not_equal', 'greater_than', 'greater_equal',
'less_than', 'less_equal', 'between',
'is_null', 'is_not_null', 'is_any']
# TODO: what follows "works" in that it prevents an error...but from the
# user's perspective it still fails silently...need to improve on front-end
@ -648,66 +586,47 @@ class AlchemyNumericFilter(AlchemyGridFilter):
# first just make sure it's somewhat numeric
try:
self.parse_decimal(value)
except decimal.InvalidOperation:
float(value)
except ValueError:
return True
return bool(value and len(str(value)) > 8)
def parse_decimal(self, value):
if value:
value = value.replace(',', '')
return decimal.Decimal(value)
def encode_value(self, value):
if value:
value = str(self.parse_decimal(value))
return super().encode_value(value)
return bool(value and len(six.text_type(value)) > 8)
def filter_equal(self, query, value):
if self.value_invalid(value):
return query
return super().filter_equal(query, value)
return super(AlchemyNumericFilter, self).filter_equal(query, value)
def filter_not_equal(self, query, value):
if self.value_invalid(value):
return query
return super().filter_not_equal(query, value)
return super(AlchemyNumericFilter, self).filter_not_equal(query, value)
def filter_greater_than(self, query, value):
if self.value_invalid(value):
return query
return super().filter_greater_than(query, value)
return super(AlchemyNumericFilter, self).filter_greater_than(query, value)
def filter_greater_equal(self, query, value):
if self.value_invalid(value):
return query
return super().filter_greater_equal(query, value)
return super(AlchemyNumericFilter, self).filter_greater_equal(query, value)
def filter_less_than(self, query, value):
if self.value_invalid(value):
return query
return super().filter_less_than(query, value)
return super(AlchemyNumericFilter, self).filter_less_than(query, value)
def filter_less_equal(self, query, value):
if self.value_invalid(value):
return query
return super().filter_less_equal(query, value)
return super(AlchemyNumericFilter, self).filter_less_equal(query, value)
class AlchemyIntegerFilter(AlchemyNumericFilter):
"""
Integer filter for SQLAlchemy.
"""
bigint = False
def default_verbs(self):
# limited verbs if choices are defined
if self.choices:
return ['equal', 'not_equal', 'is_null', 'is_not_null', 'is_any']
return super().default_verbs()
def value_invalid(self, value):
if value:
@ -715,10 +634,9 @@ class AlchemyIntegerFilter(AlchemyNumericFilter):
return True
if not value.isdigit():
return True
# normal Integer columns have a max value, beyond which PG
# will throw an error if we try to query for larger values
# TODO: this seems hacky, how to better handle it?
if not self.bigint and int(value) > 2147483647:
# TODO: this one is to avoid DataError from PG, but perhaps that
# isn't a good enough reason to make this global logic?
if int(value) > 2147483647:
return True
return False
@ -728,13 +646,6 @@ class AlchemyIntegerFilter(AlchemyNumericFilter):
return int(value)
class AlchemyBigIntegerFilter(AlchemyIntegerFilter):
"""
BigInteger filter for SQLAlchemy.
"""
bigint = True
class AlchemyBooleanFilter(AlchemyGridFilter):
"""
Boolean filter for SQLAlchemy.
@ -1223,7 +1134,7 @@ class AlchemyPhoneNumberFilter(AlchemyStringFilter):
'ILIKE' query with those parts.
"""
value = self.parse_value(value)
return super().filter_contains(query, value)
return super(AlchemyPhoneNumberFilter, self).filter_contains(query, value)
def filter_does_not_contain(self, query, value):
"""
@ -1231,7 +1142,7 @@ class AlchemyPhoneNumberFilter(AlchemyStringFilter):
'NOT ILIKE' query with those parts.
"""
value = self.parse_value(value)
return super().filter_does_not_contain(query, value)
return super(AlchemyPhoneNumberFilter, self).filter_does_not_contain(query, value)
class GridFilterSet(OrderedDict):
@ -1275,7 +1186,7 @@ class GridFiltersForm(forms.Form):
node = colander.SchemaNode(colander.String(), name=key)
schema.add(node)
kwargs['schema'] = schema
super().__init__(**kwargs)
super(GridFiltersForm, self).__init__(**kwargs)
def iter_filters(self):
return self.filters.values()

View file

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

View file

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

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2024 Lance Edgar
# Copyright © 2010-2023 Lance Edgar
#
# This file is part of Rattail.
#
@ -24,48 +24,39 @@
App Menus
"""
from __future__ import unicode_literals, absolute_import
import re
import logging
import warnings
from rattail.util import prettify, simple_error
from rattail.app import GenericHandler
from rattail.util import import_module_path, prettify, simple_error
from webhelpers2.html import tags, HTML
from wuttaweb.menus import MenuHandler as WuttaMenuHandler
from tailbone.db import Session
log = logging.getLogger(__name__)
class TailboneMenuHandler(WuttaMenuHandler):
class MenuHandler(GenericHandler):
"""
Base class and default implementation for menu handler.
"""
##############################
# internal methods
##############################
def make_raw_menus(self, request, **kwargs):
"""
Generate a full set of "raw" menus for the app.
def _is_allowed(self, request, item):
"""
TODO: must override this until wuttaweb has proper user auth checks
"""
perm = item.get('perm')
if perm:
return request.has_perm(perm)
return True
def _make_raw_menus(self, request, **kwargs):
"""
We are overriding this to allow for making dynamic menus from
config/settings. Which may or may not be a good idea..
The "raw" menus are basically just a set of dicts to represent
the final menus.
"""
# first try to make menus from config, but this is highly
# susceptible to failure, so try to warn user of problems
try:
menus = self._make_menus_from_config(request)
menus = self.make_menus_from_config(request)
if menus:
return menus
except Exception as error:
@ -81,10 +72,22 @@ class TailboneMenuHandler(WuttaMenuHandler):
tags.link_to("Menu Config", request.route_url('configure_menus'))))
request.session.flash(msg, 'warning')
# okay, no config, so menus will be built from code
return self.make_menus(request, **kwargs)
# okay, no config, so menus must be built from code..
def _make_menus_from_config(self, request, **kwargs):
# first check for a "simple menus" module; use that if defined
menumod = self.config.get('tailbone', 'menus')
if menumod:
menumod = import_module_path(menumod)
if (not hasattr(menumod, 'simple_menus')
or not callable(menumod.simple_menus)):
raise RuntimeError("module does not have a simple_menus() "
"callable: {}".format(menumod))
return menumod.simple_menus(request)
# now we fallback to menu handler method
return self.make_menus(request)
def make_menus_from_config(self, request, **kwargs):
"""
Try to build a complete menu set from config/settings.
@ -96,7 +99,7 @@ class TailboneMenuHandler(WuttaMenuHandler):
if not main_keys:
return
model = self.app.model
model = self.model
menus = []
# menu definition can come either from config file or db
@ -112,15 +115,16 @@ class TailboneMenuHandler(WuttaMenuHandler):
query=query, key='name',
normalizer=lambda s: s.value)
for key in main_keys:
menus.append(self._make_single_menu_from_settings(request, key, settings))
menus.append(self.make_single_menu_from_settings(request, key,
settings))
else: # read from config file only
for key in main_keys:
menus.append(self._make_single_menu_from_config(request, key))
menus.append(self.make_single_menu_from_config(request, key))
return menus
def _make_single_menu_from_config(self, request, key, **kwargs):
def make_single_menu_from_config(self, request, key, **kwargs):
"""
Makes a single top-level menu dict from config file. Note
that this will read from config file(s) *only* and avoids
@ -188,7 +192,7 @@ class TailboneMenuHandler(WuttaMenuHandler):
return menu
def _make_single_menu_from_settings(self, request, key, settings, **kwargs):
def make_single_menu_from_settings(self, request, key, settings, **kwargs):
"""
Makes a single top-level menu dict from DB settings.
"""
@ -247,10 +251,6 @@ class TailboneMenuHandler(WuttaMenuHandler):
return menu
##############################
# menu defaults
##############################
def make_menus(self, request, **kwargs):
"""
Make the full set of menus for the app.
@ -281,9 +281,8 @@ class TailboneMenuHandler(WuttaMenuHandler):
"""
Make a set of menus for all registered system integrations.
"""
tb = self.app.get_tailbone_handler()
menus = []
for provider in tb.iter_providers():
for provider in self.tb.iter_providers():
menu = provider.make_integration_menu(request)
if menu:
menus.append(menu)
@ -335,49 +334,32 @@ class TailboneMenuHandler(WuttaMenuHandler):
'route': 'members',
'perm': 'members.list',
},
{
'title': "Member Equity Payments",
'route': 'member_equity_payments',
'perm': 'member_equity_payments.list',
},
{
'title': "Membership Types",
'route': 'membership_types',
'perm': 'membership_types.list',
},
{'type': 'sep'},
{
'title': "Customers",
'route': 'customers',
'perm': 'customers.list',
},
{
'title': "Customer Shoppers",
'route': 'customer_shoppers',
'perm': 'customer_shoppers.list',
},
{
'title': "Customer Groups",
'route': 'customergroups',
'perm': 'customergroups.list',
},
{
'title': "Pending Customers",
'route': 'pending_customers',
'perm': 'pending_customers.list',
},
{'type': 'sep'},
{
'title': "Employees",
'route': 'employees',
'perm': 'employees.list',
},
{'type': 'sep'},
{
'title': "All People",
'route': 'people',
'perm': 'people.list',
},
{'type': 'sep'},
{
'title': "Pending Customers",
'route': 'pending_customers',
'perm': 'pending_customers.list',
},
],
}
@ -394,11 +376,6 @@ class TailboneMenuHandler(WuttaMenuHandler):
'route': 'products',
'perm': 'products.list',
},
{
'title': "Product Costs",
'route': 'product_costs',
'perm': 'product_costs.list',
},
{
'title': "Departments",
'route': 'departments',
@ -456,11 +433,6 @@ class TailboneMenuHandler(WuttaMenuHandler):
'route': 'vendors',
'perm': 'vendors.list',
},
{
'title': "Product Costs",
'route': 'product_costs',
'perm': 'product_costs.list',
},
{'type': 'sep'},
{
'title': "Ordering",
@ -494,12 +466,6 @@ class TailboneMenuHandler(WuttaMenuHandler):
'route': 'vendorcatalogs',
'perm': 'vendorcatalogs.list',
},
{'type': 'sep'},
{
'title': "Sample Files",
'route': 'vendorsamplefiles',
'perm': 'vendorsamplefiles.list',
},
],
}
@ -526,11 +492,6 @@ class TailboneMenuHandler(WuttaMenuHandler):
'route': 'batch.importer',
'perm': 'batch.importer.list',
},
{
'title': "POS",
'route': 'batch.pos',
'perm': 'batch.pos.list',
},
],
}
@ -566,19 +527,6 @@ class TailboneMenuHandler(WuttaMenuHandler):
},
])
if kwargs.get('include_worksheets', False):
items.extend([
{'type': 'sep'},
{
'title': "Ordering Worksheet",
'route': 'reports.ordering',
},
{
'title': "Inventory Worksheet",
'route': 'reports.inventory',
},
])
if kwargs.get('include_trainwreck', False):
items.extend([
{'type': 'sep'},
@ -638,30 +586,15 @@ class TailboneMenuHandler(WuttaMenuHandler):
"""
items = []
include_stores = kwargs.get('include_stores', True)
include_tenders = kwargs.get('include_tenders', True)
if include_stores or include_tenders:
if include_stores:
items.extend([
{
'title': "Stores",
'route': 'stores',
'perm': 'stores.list',
},
])
if include_tenders:
items.extend([
{
'title': "Tenders",
'route': 'tenders',
'perm': 'tenders.list',
},
])
items.append({'type': 'sep'})
if kwargs.get('include_stores', True):
items.extend([
{
'title': "Stores",
'route': 'stores',
'perm': 'stores.list',
},
{'type': 'sep'},
])
items.extend([
{
@ -713,22 +646,15 @@ class TailboneMenuHandler(WuttaMenuHandler):
},
{'type': 'sep'},
{
'title': "App Info",
'title': "App Details",
'route': 'appinfo',
'perm': 'appinfo.list',
},
])
if kwargs.get('include_label_settings', False):
items.extend([
{
'title': "Label Settings",
'route': 'labelprofiles',
'perm': 'labelprofiles.list',
},
])
items.extend([
{
'title': "Label Settings",
'route': 'labelprofiles',
'perm': 'labelprofiles.list',
},
{
'title': "Raw Settings",
'route': 'settings',
@ -748,25 +674,182 @@ class TailboneMenuHandler(WuttaMenuHandler):
}
class MenuHandler(TailboneMenuHandler):
def __init__(self, *args, **kwargs):
warnings.warn("tailbone.menus.MenuHandler is deprecated; "
"please use tailbone.menus.TailboneMenuHandler instead",
DeprecationWarning, stacklevel=2)
super().__init__(*args, **kwargs)
class NullMenuHandler(WuttaMenuHandler):
def make_simple_menus(request):
"""
Null menu handler which uses an empty menu set.
.. note:
This class shouldn't even exist, but for the moment, it is
useful to configure non-traditional (e.g. API) web apps to use
this, in order to avoid most of the overhead.
Build the main menu list for the app.
"""
app = request.rattail_config.get_app()
tailbone_handler = app.get_tailbone_handler()
menu_handler = tailbone_handler.get_menu_handler()
def make_menus(self, request, **kwargs):
return []
raw_menus = menu_handler.make_raw_menus(request)
# now we have "simple" (raw) menus definition, but must refine
# that somewhat to produce our final menus
mark_allowed(request, raw_menus)
final_menus = []
for topitem in raw_menus:
if topitem['allowed']:
if topitem.get('type') == 'link':
final_menus.append(make_menu_entry(request, topitem))
else: # assuming 'menu' type
menu_items = []
for item in topitem['items']:
if not item['allowed']:
continue
# nested submenu
if item.get('type') == 'menu':
submenu_items = []
for subitem in item['items']:
if subitem['allowed']:
submenu_items.append(make_menu_entry(request, subitem))
menu_items.append({
'type': 'submenu',
'title': item['title'],
'items': submenu_items,
'is_menu': True,
'is_sep': False,
})
elif item.get('type') == 'sep':
# we only want to add a sep, *if* we already have some
# menu items (i.e. there is something to separate)
# *and* the last menu item is not a sep (avoid doubles)
if menu_items and not menu_items[-1]['is_sep']:
menu_items.append(make_menu_entry(request, item))
else: # standard menu item
menu_items.append(make_menu_entry(request, item))
# remove final separator if present
if menu_items and menu_items[-1]['is_sep']:
menu_items.pop()
# only add if we wound up with something
assert menu_items
if menu_items:
group = {
'type': 'menu',
'key': topitem.get('key'),
'title': topitem['title'],
'items': menu_items,
'is_menu': True,
'is_link': False,
}
# topitem w/ no key likely means it did not come
# from config but rather explicit definition in
# code. so we are free to "invent" a (safe) key
# for it, since that is only for editing config
if not group['key']:
group['key'] = make_menu_key(request.rattail_config,
topitem['title'])
final_menus.append(group)
return final_menus
def make_menu_key(config, value):
"""
Generate a normalized menu key for the given value.
"""
return re.sub(r'\W', '', value.lower())
def make_menu_entry(request, item):
"""
Convert a simple menu entry dict, into a proper menu-related object, for
use in constructing final menu.
"""
# separator
if item.get('type') == 'sep':
return {
'type': 'sep',
'is_menu': False,
'is_sep': True,
}
# standard menu item
entry = {
'type': 'item',
'title': item['title'],
'perm': item.get('perm'),
'target': item.get('target'),
'is_link': True,
'is_menu': False,
'is_sep': False,
}
if item.get('route'):
entry['route'] = item['route']
try:
entry['url'] = request.route_url(entry['route'])
except KeyError: # happens if no such route
log.debug("invalid route name for menu entry: %s", entry)
entry['url'] = entry['route']
entry['key'] = entry['route']
else:
if item.get('url'):
entry['url'] = item['url']
entry['key'] = make_menu_key(request.rattail_config, entry['title'])
return entry
def is_allowed(request, item):
"""
Logic to determine if a given menu item is "allowed" for current user.
"""
perm = item.get('perm')
if perm:
return request.has_perm(perm)
return True
def mark_allowed(request, menus):
"""
Traverse the menu set, and mark each item as "allowed" (or not) based on
current user permissions.
"""
for topitem in menus:
if topitem.get('type', 'menu') == 'menu':
topitem['allowed'] = False
for item in topitem['items']:
if item.get('type') == 'menu':
for subitem in item['items']:
subitem['allowed'] = is_allowed(request, subitem)
item['allowed'] = False
for subitem in item['items']:
if subitem['allowed'] and subitem.get('type') != 'sep':
item['allowed'] = True
break
else:
item['allowed'] = is_allowed(request, item)
for item in topitem['items']:
if item['allowed'] and item.get('type') != 'sep':
topitem['allowed'] = True
break
def make_admin_menu(request, **kwargs):
"""
Generate a typical Admin menu
"""
warnings.warn("make_admin_menu() function is deprecated; please use "
"MenuHandler.make_admin_menu() instead",
DeprecationWarning, stacklevel=2)
app = request.rattail_config.get_app()
tailbone_handler = app.get_tailbone_handler()
menu_handler = tailbone_handler.get_menu_handler()
return menu_handler.make_admin_menu(request, **kwargs)

View file

@ -111,7 +111,7 @@
<td class="brand">${cost.product.brand or ''}</td>
<td class="desc">${cost.product.description}</td>
<td class="size">${cost.product.size or ''}</td>
<td class="case-qty">${app.render_quantity(cost.case_size)} ${"LB" if cost.product.weighed else "EA"}</td>
<td class="case-qty">${cost.case_size} ${"LB" if cost.product.weighed else "EA"}</td>
<td class="code">${cost.code or ''}</td>
<td class="preferred">${'X' if cost.preference == 1 else ''}</td>
% for i in range(14):

View file

@ -1,8 +1,8 @@
# -*- coding: utf-8; -*-
# -*- coding: utf-8 -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2023 Lance Edgar
# Copyright © 2010-2017 Lance Edgar
#
# This file is part of Rattail.
#
@ -21,16 +21,25 @@
#
################################################################################
"""
Essential views for convenient includes
Pyramid scaffold templates
"""
from __future__ import unicode_literals, absolute_import
def defaults(config, **kwargs):
mod = lambda spec: kwargs.get(spec, spec)
from rattail.files import resource_path
from rattail.util import prettify
config.include(mod('tailbone.api.auth'))
config.include(mod('tailbone.api.common'))
from pyramid.scaffolds import PyramidTemplate
def includeme(config):
defaults(config)
class RattailTemplate(PyramidTemplate):
_template_dir = resource_path('rattail:data/project')
summary = "Starter project based on Rattail / Tailbone"
def pre(self, command, output_dir, vars):
"""
Adds some more variables to the template context.
"""
vars['project_title'] = prettify(vars['project'])
vars['package_title'] = vars['package'].capitalize()
return super(RattailTemplate, self).pre(command, output_dir, vars)

View file

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

View file

@ -1,14 +1,122 @@
/******************************
* General
******************************/
* {
margin: 0px;
}
body {
font-family: Verdana, Arial, sans-serif;
font-size: 11pt;
}
a {
color: #0972a5;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
h1 {
margin-bottom: 15px;
}
h2 {
font-size: 12pt;
margin: 20px auto 10px auto;
}
li {
line-height: 2em;
}
p {
margin-bottom: 5px;
}
.left {
float: left;
text-align: left;
}
.right {
text-align: right;
}
.wrapper {
overflow: auto;
}
div.buttons {
clear: both;
margin-top: 10px;
}
div.dialog {
display: none;
}
div.flash-message {
background-color: #dddddd;
margin-bottom: 8px;
padding: 3px;
}
div.flash-messages div.ui-state-highlight {
padding: .3em;
margin-bottom: 8px;
}
div.error-messages div.ui-state-error {
padding: .3em;
margin-bottom: 8px;
}
.flash-messages,
.error-messages {
margin: 0.5em 0 0 0;
}
ul.error {
color: #dd6666;
font-weight: bold;
padding: 0px;
}
ul.error li {
list-style-type: none;
}
pre.is-family-sans-serif {
background-color: white;
font-family: Verdana, Arial, sans-serif;
font-size: 11pt;
padding: 1em;
}
/******************************
* jQuery UI tweaks
******************************/
ul.ui-menu {
max-height: 30em;
}
/******************************
* tweaks for root user
******************************/
.navbar .navbar-end .navbar-link.root-user,
.navbar .navbar-end .navbar-link.root-user:hover,
.navbar .navbar-end .navbar-link.root-user.is_active,
.navbar .navbar-end .navbar-item.root-user,
.navbar .navbar-end .navbar-item.root-user:hover,
.navbar .navbar-end .navbar-item.root-user.is_active {
.menubar .root-user .ui-button-text,
.menubar .root-user.ui-menu-item a {
background-color: red;
color: black;
font-weight: bold;
}
.menubar .root-user.ui-menu-item a {
padding-left: 1em;
}

View file

@ -1,18 +1,28 @@
/******************************
* Grid Filters
* Filters
******************************/
.filters .filter-fieldname .field,
.filters .filter-fieldname .field label {
width: 100%;
div.filters form {
margin-bottom: 10px;
}
.filters .filter-fieldname .field label {
justify-content: left;
div.filters div.filter {
margin-bottom: 10px;
}
.filters .filter-verb .select,
.filters .filter-verb .select select {
width: 100%;
div.filters div.filter label {
margin-right: 8px;
}
div.filters div.filter select.filter-type {
margin-right: 8px;
}
div.filters div.filter div.value {
display: inline;
}
div.filters div.buttons * {
margin-right: 8px;
}

View file

@ -1,37 +1,34 @@
/******************************
* forms
* Form Wrapper
******************************/
/* note that this should only apply to "normal" primary forms */
/* TODO: replace this with bulma equivalent */
div.form-wrapper {
overflow: auto;
}
/******************************
* Forms
******************************/
div.form,
div.fieldset-form,
div.fieldset {
clear: left;
float: left;
margin-top: 10px;
}
.form {
padding-left: 5em;
}
/* note that this should only apply to "normal" primary forms */
.form-wrapper .form .field.is-horizontal .field-label .label {
text-align: left;
white-space: nowrap;
width: 18em;
}
/* note that this should only apply to "normal" primary forms */
.form-wrapper .form .field.is-horizontal .field-body {
min-width: 30em;
}
/* note that this should only apply to "normal" primary forms */
.form-wrapper .form .field.is-horizontal .field-body .select,
.form-wrapper .form .field.is-horizontal .field-body .select select {
width: 100%;
}
/******************************
* field-wrappers
* Fieldsets
******************************/
/* TODO: replace this with bulma equivalent */
.field-wrapper {
clear: both;
min-height: 30px;
@ -39,12 +36,16 @@
margin: 15px;
}
/* TODO: replace this with bulma equivalent */
.field-wrapper.with-error {
background-color: #ddcccc;
border: 2px solid #dd6666;
padding-bottom: 1em;
}
.field-wrapper .field-row {
display: table-row;
}
/* TODO: replace this with bulma equivalent */
.field-wrapper label {
display: table-cell;
vertical-align: top;
@ -54,8 +55,47 @@
white-space: nowrap;
}
/* TODO: replace this with bulma equivalent */
.field-wrapper.with-error label {
padding-left: 1em;
}
.field-wrapper .field-error {
padding: 1em 0 0.5em 1em;
}
.field-wrapper .field-error .error-msg {
color: #dd6666;
font-weight: bold;
}
.field-wrapper .field {
display: table-cell;
line-height: 25px;
}
.field-wrapper .field input[type=text],
.field-wrapper .field input[type=password],
.field-wrapper .field select,
.field-wrapper .field textarea {
width: 320px;
}
label input[type="checkbox"],
label input[type="radio"] {
margin-right: 0.5em;
}
.field ul {
margin: 0px;
padding-left: 15px;
}
/******************************
* Buttons
******************************/
div.buttons {
clear: both;
margin: 10px 0px;
}

View file

@ -25,11 +25,6 @@
margin: 0;
}
.grid-tools {
display: flex;
gap: 0.5rem;
}
.grid-wrapper .grid-header td.tools {
margin: 0;
padding: 0;
@ -266,10 +261,6 @@
* main actions
******************************/
a.grid-action {
white-space: nowrap;
}
.grid .actions {
width: 1px;
}

View file

@ -0,0 +1,40 @@
.loadmask {
z-index: 100;
position: absolute;
top:0;
left:0;
-moz-opacity: 0.5;
opacity: .50;
filter: alpha(opacity=50);
background-color: #CCC;
width: 100%;
height: 100%;
zoom: 1;
}
.loadmask-msg {
z-index: 20001;
position: absolute;
top: 0;
left: 0;
border:1px solid #6593cf;
background: #c3daf9;
padding:2px;
}
.loadmask-msg div {
padding:5px 10px 5px 25px;
background: #fbfbfb url('../img/loading.gif') no-repeat 5px 5px;
line-height: 16px;
border:1px solid #a3bad9;
color:#222;
font:normal 11px tahoma, arial, helvetica, sans-serif;
cursor:wait;
}
.masked {
overflow: hidden !important;
}
.masked-relative {
position: relative !important;
}
.masked-hidden {
visibility: hidden !important;
}

View file

@ -0,0 +1,69 @@
ul.tagit {
padding: 1px 5px;
overflow: auto;
margin-left: inherit; /* usually we don't want the regular ul margins. */
margin-right: inherit;
}
ul.tagit li {
display: block;
float: left;
margin: 2px 5px 2px 0;
}
ul.tagit li.tagit-choice {
position: relative;
line-height: inherit;
}
input.tagit-hidden-field {
display: none;
}
ul.tagit li.tagit-choice-read-only {
padding: .2em .5em .2em .5em;
}
ul.tagit li.tagit-choice-editable {
padding: .2em 18px .2em .5em;
}
ul.tagit li.tagit-new {
padding: .25em 4px .25em 0;
}
ul.tagit li.tagit-choice a.tagit-label {
cursor: pointer;
text-decoration: none;
}
ul.tagit li.tagit-choice .tagit-close {
cursor: pointer;
position: absolute;
right: .1em;
top: 50%;
margin-top: -8px;
line-height: 17px;
}
/* used for some custom themes that don't need image icons */
ul.tagit li.tagit-choice .tagit-close .text-icon {
display: none;
}
ul.tagit li.tagit-choice input {
display: block;
float: left;
margin: 2px 5px 2px 0;
}
ul.tagit input[type="text"] {
-moz-box-sizing: border-box;
-webkit-box-sizing: border-box;
box-sizing: border-box;
-moz-box-shadow: none;
-webkit-box-shadow: none;
box-shadow: none;
border: none;
margin: 0;
padding: 0;
width: inherit;
background-color: inherit;
outline: none;
}

View file

@ -0,0 +1,15 @@
/*
* jQuery UI Menubar @VERSION
*
* Copyright 2011, AUTHORS.txt (http://jqueryui.com/about)
* Dual licensed under the MIT or GPL Version 2 licenses.
* http://jquery.org/license
*/
.ui-menubar { list-style: none; margin: 0; padding-left: 0; }
.ui-menubar-item { float: left; }
.ui-menubar .ui-button { float: left; font-weight: normal; border-top-width: 0 !important; border-bottom-width: 0 !important; margin: 0; outline: none; }
.ui-menubar .ui-menubar-link { border-right: 1px dashed transparent; border-left: 1px dashed transparent; }
.ui-menubar .ui-menu { width: 200px; position: absolute; z-index: 9999; font-weight: normal; }

View file

@ -0,0 +1,14 @@
/**********************************************************************
* jquery.ui.tailbone.css
*
* jQuery UI tweaks for Tailbone
**********************************************************************/
.ui-widget {
font-size: 1em;
}
.ui-menu-item a {
display: block;
}

View file

@ -0,0 +1,57 @@
/*
* Timepicker stylesheet
* Highly inspired from datepicker
* FG - Nov 2010 - Web3R
*
* version 0.0.3 : Fixed some settings, more dynamic
* version 0.0.4 : Removed width:100% on tables
* version 0.1.1 : set width 0 on tables to fix an ie6 bug
*/
.ui-timepicker-inline { display: inline; }
#ui-timepicker-div { padding: 0.2em; }
.ui-timepicker-table { display: inline-table; width: 0; }
.ui-timepicker-table table { margin:0.15em 0 0 0; border-collapse: collapse; }
.ui-timepicker-hours, .ui-timepicker-minutes { padding: 0.2em; }
.ui-timepicker-table .ui-timepicker-title { line-height: 1.8em; text-align: center; }
.ui-timepicker-table td { padding: 0.1em; width: 2.2em; }
.ui-timepicker-table th.periods { padding: 0.1em; width: 2.2em; }
/* span for disabled cells */
.ui-timepicker-table td span {
display:block;
padding:0.2em 0.3em 0.2em 0.5em;
width: 1.2em;
text-align:right;
text-decoration:none;
}
/* anchors for clickable cells */
.ui-timepicker-table td a {
display:block;
padding:0.2em 0.3em 0.2em 0.5em;
width: 1.2em;
cursor: pointer;
text-align:right;
text-decoration:none;
}
/* buttons and button pane styling */
.ui-timepicker .ui-timepicker-buttonpane {
background-image: none; margin: .7em 0 0 0; padding:0 .2em; border-left: 0; border-right: 0; border-bottom: 0;
}
.ui-timepicker .ui-timepicker-buttonpane button { margin: .5em .2em .4em; cursor: pointer; padding: .2em .6em .3em .6em; width:auto; overflow:visible; }
/* The close button */
.ui-timepicker .ui-timepicker-close { float: right }
/* the now button */
.ui-timepicker .ui-timepicker-now { float: left; }
/* the deselect button */
.ui-timepicker .ui-timepicker-deselect { float: left; }

View file

@ -1,87 +1,152 @@
/******************************
* main layout
* Main Layout
******************************/
body {
display: flex;
flex-direction: column;
min-height: 100vh;
html, body, #body-wrapper {
height: 100%;
}
body > #body-wrapper {
height: auto;
min-height: 100%;
}
#body-wrapper {
margin: 0 1em;
width: auto;
}
#header {
height: 50px;
line-height: 50px;
}
#body {
padding-top: 10px;
padding-bottom: 5em;
}
#footer {
clear: both;
margin-top: -4em;
text-align: center;
}
/******************************
* Header
******************************/
#header h1 {
float: left;
font-size: 25px;
margin: 0px;
}
#header div.login {
float: right;
}
/* new stuff from 'better' theme begins here */
header .global {
background-color: #eaeaea;
height: 60px;
}
header .global a.home,
header .global a.global,
header .global span.global {
display: block;
float: left;
font-size: 2em;
font-weight: bold;
line-height: 60px;
margin-left: 10px;
}
header .global a.home img {
display: block;
float: left;
padding: 5px 5px 5px 30px;
}
header .global .grid-nav {
display: inline-block;
font-size: 16px;
font-weight: bold;
line-height: 60px;
margin-left: 5em;
}
header .global .grid-nav .ui-button,
header .global .grid-nav span.viewing {
margin-left: 1em;
}
header .global .feedback {
float: right;
line-height: 60px;
margin-right: 1em;
}
header .global .after-feedback {
float: right;
line-height: 60px;
margin-right: 1em;
}
header .page {
border-bottom: 1px solid lightgrey;
padding: 0.5em;
}
header .page h1 {
margin: 0;
padding: 0 0 0 0.5em;
}
/******************************
* Logo
******************************/
#logo {
display: block;
margin: 40px auto;
}
/****************************************
* content
****************************************/
body > #body-wrapper {
margin: 0px;
position: relative;
}
.content-wrapper {
display: flex;
flex: 1;
flex-direction: column;
justify-content: space-between;
height: 100%;
padding-bottom: 30px;
}
/******************************
* header
******************************/
/* this is the one in the very top left of screen, next to logo and linked to
the home page */
#global-header-title {
margin-left: 0.3rem;
#scrollpane {
height: 100%;
}
header .level {
/* TODO: not sure what this 60px was supposed to do? but it broke the */
/* styles for the feedback dialog, so disabled it is.
/* height: 60px; */
/* line-height: 60px; */
padding-left: 0.5em;
padding-right: 0.5em;
#scrollpane .inner-content {
padding: 0 0.5em 0.5em 0.5em;
}
header .level #header-logo {
display: inline-block;
}
header .level .global-title,
header .level-left .global-title {
font-size: 2em;
font-weight: bold;
}
/* indent nested menu items a bit */
header .navbar-item.nested {
padding-left: 2.5rem;
}
header span.header-text {
font-size: 2em;
font-weight: bold;
margin-right: 10px;
}
#content-title h1 {
margin-bottom: 0;
margin-right: 1rem;
max-width: 50%;
overflow: hidden;
padding: 0 0.3rem;
text-overflow: ellipsis;
white-space: nowrap;
}
/******************************
* content
******************************/
#page-body {
padding: 0.4em;
}
/******************************
* context menu
******************************/
#context-menu {
margin-bottom: 1em;
margin-left: 1em;
list-style-type: none;
margin: 0.5em;
text-align: right;
white-space: nowrap;
}
@ -90,24 +155,11 @@ header span.header-text {
* "object helper" panel
******************************/
.object-helpers .panel {
margin: 1rem;
margin-bottom: 1.5rem;
}
.object-helpers .panel-heading {
white-space: nowrap;
}
.object-helpers a {
white-space: nowrap;
}
.object-helper {
border: 1px solid black;
margin: 1em;
padding: 1em;
width: 20em;
min-width: 20em;
}
.object-helper-content {
@ -115,44 +167,87 @@ header span.header-text {
}
/******************************
* markdown
* Panels
******************************/
.rendered-markdown p,
.rendered-markdown ul {
margin-bottom: 1rem;
.panel,
.panel-grid {
border-left: 1px solid Black;
margin-bottom: 1em;
}
.rendered-markdown .codehilite {
margin-bottom: 2rem;
.panel {
border-bottom: 1px solid Black;
border-right: 1px solid Black;
padding: 0px;
}
/******************************
* fix datepicker within modals
* TODO: someday this may not be necessary? cf.
* https://github.com/buefy/buefy/issues/292#issuecomment-347365637
******************************/
.modal .animation-content .modal-card {
overflow: visible !important;
.panel h2,
.panel-grid h2 {
border-bottom: 1px solid Black;
border-top: 1px solid Black;
padding: 5px;
margin: 0px;
}
.modal-card-body {
overflow: visible !important;
.panel-grid h2 {
border-right: 1px solid Black;
}
/* TODO: a simpler option we might try sometime instead? */
/* cf. https://github.com/buefy/buefy/issues/292#issuecomment-1073851313 */
.panel-body {
overflow: auto;
padding: 5px;
}
/* .dropdown-content{ */
/* position: fixed; */
/* } */
/****************************************
* footer
****************************************/
#footer {
border-top: 1px solid lightgray;
bottom: 0;
font-size: 9pt;
height: 20px;
left: 0;
line-height: 20px;
margin: 0;
position: absolute;
width: 100%;
}
/******************************
* feedback
******************************/
.feedback-dialog .red {
#feedback-dialog {
display: none;
}
#feedback-dialog p {
margin-top: 1em;
}
#feedback-dialog .red {
color: red;
font-weight: bold;
}
#feedback-dialog .field-wrapper {
margin-top: 1em;
padding: 0;
}
#feedback-dialog .field {
margin-bottom: 0;
margin-top: 0.5em;
}
#feedback-dialog .referrer .field {
clear: both;
float: none;
margin-top: 1em;
}
#feedback-dialog textarea {
width: auto;
}

View file

@ -0,0 +1,48 @@
/******************************
* login.css
******************************/
.logo img,
#logo {
display: block;
margin: 40px auto;
max-height: 350px;
max-width: 800px;
}
div.form {
margin: auto;
float: none;
text-align: center;
}
div.field-wrapper {
margin: 10px auto;
width: 300px;
}
div.field-wrapper label {
text-align: right;
width: auto;
}
div.field-wrapper div.field input[type="text"],
div.field-wrapper div.field input[type="password"] {
margin-left: 1em;
width: 150px;
}
div.buttons {
display: block;
}
div.buttons input {
margin: auto 5px;
}
/* this is for "login as chuck" tip in demo mode */
.tips {
margin-top: 2em;
text-align: center;
}

452
tailbone/static/js/jquery.ui.tailbone.js vendored Normal file
View file

@ -0,0 +1,452 @@
/**********************************************************************
* jQuery UI plugins for Tailbone
**********************************************************************/
/**********************************************************************
* gridcore plugin
**********************************************************************/
(function($) {
$.widget('tailbone.gridcore', {
_create: function() {
var that = this;
// Add hover highlight effect to grid rows during mouse-over.
// this.element.on('mouseenter', 'tbody tr:not(.header)', function() {
this.element.on('mouseenter', 'tr:not(.header)', function() {
$(this).addClass('hovering');
});
// this.element.on('mouseleave', 'tbody tr:not(.header)', function() {
this.element.on('mouseleave', 'tr:not(.header)', function() {
$(this).removeClass('hovering');
});
// do some extra stuff for grids with checkboxes
// mark rows selected on page load, as needed
this.element.find('tr:not(.header) td.checkbox :checkbox:checked').each(function() {
$(this).parents('tr:first').addClass('selected');
});
// (un-)check all rows when clicking check-all box in header
if (this.element.find('tr.header td.checkbox :checkbox').length) {
this.element.on('click', 'tr.header td.checkbox :checkbox', function() {
var checked = $(this).prop('checked');
var rows = that.element.find('tr:not(.header)');
rows.find('td.checkbox :checkbox').prop('checked', checked);
if (checked) {
rows.addClass('selected');
} else {
rows.removeClass('selected');
}
that.element.trigger('gridchecked', that.count_selected());
});
}
// when row with checkbox is clicked, toggle selected status,
// unless clicking checkbox (since that already toggles it) or a
// link (since that does something completely different)
this.element.on('click', 'tr:not(.header)', function(event) {
var el = $(event.target);
if (!el.is('a') && !el.is(':checkbox')) {
$(this).find('td.checkbox :checkbox').click();
}
});
this.element.on('change', 'tr:not(.header) td.checkbox :checkbox', function() {
if (this.checked) {
$(this).parents('tr:first').addClass('selected');
} else {
$(this).parents('tr:first').removeClass('selected');
}
that.element.trigger('gridchecked', that.count_selected());
});
// Show 'more' actions when user hovers over 'more' link.
this.element.on('mouseenter', '.actions a.more', function() {
that.element.find('.actions div.more').hide();
$(this).siblings('div.more')
.show()
.position({my: 'left-5 top-4', at: 'left top', of: $(this)});
});
this.element.on('mouseleave', '.actions div.more', function() {
$(this).hide();
});
// Add speed bump for "Delete Row" action, if grid is so configured.
if (this.element.data('delete-speedbump')) {
this.element.on('click', 'tr:not(.header) .actions a.delete', function() {
return confirm("Are you sure you wish to delete this object?");
});
}
},
count_selected: function() {
return this.element.find('tr:not(.header) td.checkbox :checkbox:checked').length;
},
// TODO: deprecate / remove this?
count_checked: function() {
return this.count_selected();
},
selected_rows: function() {
return this.element.find('tr:not(.header) td.checkbox :checkbox:checked').parents('tr:first');
},
all_uuids: function() {
var uuids = [];
this.element.find('tr:not(.header)').each(function() {
uuids.push($(this).data('uuid'));
});
return uuids;
},
selected_uuids: function() {
var uuids = [];
this.element.find('tr:not(.header) td.checkbox :checkbox:checked').each(function() {
uuids.push($(this).parents('tr:first').data('uuid'));
});
return uuids;
}
});
})( jQuery );
/**********************************************************************
* gridwrapper plugin
**********************************************************************/
(function($) {
$.widget('tailbone.gridwrapper', {
_create: function() {
var that = this;
// Snag some element references.
this.filters = this.element.find('.newfilters');
this.filters_form = this.filters.find('form');
this.add_filter = this.filters.find('#add-filter');
this.apply_filters = this.filters.find('#apply-filters');
this.default_filters = this.filters.find('#default-filters');
this.clear_filters = this.filters.find('#clear-filters');
this.save_defaults = this.filters.find('#save-defaults');
this.grid = this.element.find('.grid');
// add standard grid behavior
this.grid.gridcore();
// Enhance filters etc.
this.filters.find('.filter').gridfilter();
this.apply_filters.button('option', 'icons', {primary: 'ui-icon-search'});
this.default_filters.button('option', 'icons', {primary: 'ui-icon-home'});
this.clear_filters.button('option', 'icons', {primary: 'ui-icon-trash'});
this.save_defaults.button('option', 'icons', {primary: 'ui-icon-disk'});
if (! this.filters.find('.active:checked').length) {
this.apply_filters.button('disable');
}
this.add_filter.selectmenu({
width: '15em',
// Initially disabled if contains no enabled filter options.
disabled: this.add_filter.find('option:enabled').length == 1,
// When add-filter choice is made, show/focus new filter value input,
// and maybe hide the add-filter selection or show the apply button.
change: function (event, ui) {
var filter = that.filters.find('#filter-' + ui.item.value);
var select = $(this);
var option = ui.item.element;
filter.gridfilter('active', true);
filter.gridfilter('focus');
select.val('');
option.attr('disabled', 'disabled');
select.selectmenu('refresh');
if (select.find('option:enabled').length == 1) { // prompt is always enabled
select.selectmenu('disable');
}
that.apply_filters.button('enable');
}
});
this.add_filter.on('selectmenuopen', function(event, ui) {
show_all_options($(this));
});
// Intercept filters form submittal, and submit via AJAX instead.
this.filters_form.on('submit', function() {
var settings = {filter: true, partial: true};
if (that.filters_form.find('input[name="save-current-filters-as-defaults"]').val() == 'true') {
settings['save-current-filters-as-defaults'] = true;
}
that.filters.find('.filter').each(function() {
// currently active filters will be included in form data
if ($(this).gridfilter('active')) {
settings[$(this).data('key')] = $(this).gridfilter('value');
settings[$(this).data('key') + '.verb'] = $(this).gridfilter('verb');
// others will be hidden from view
} else {
$(this).gridfilter('hide');
}
});
// if no filters are visible, disable submit button
if (! that.filters.find('.filter:visible').length) {
that.apply_filters.button('disable');
}
// okay, submit filters to server and refresh grid
that.refresh(settings);
return false;
});
// When user clicks Default Filters button, refresh page with
// instructions for the server to reset filters to default settings.
this.default_filters.click(function() {
that.filters_form.off('submit');
that.filters_form.find('input[name="reset-to-default-filters"]').val('true');
that.element.mask("Refreshing data...");
that.filters_form.get(0).submit();
});
// When user clicks Save Defaults button, refresh the grid as with
// Apply Filters, but add an instruction for the server to save
// current settings as defaults for the user.
this.save_defaults.click(function() {
that.filters_form.find('input[name="save-current-filters-as-defaults"]').val('true');
that.filters_form.submit();
that.filters_form.find('input[name="save-current-filters-as-defaults"]').val('false');
});
// When user clicks Clear Filters button, deactivate all filters
// and refresh the grid.
this.clear_filters.click(function() {
that.filters.find('.filter').each(function() {
if ($(this).gridfilter('active')) {
$(this).gridfilter('active', false);
}
});
that.filters_form.submit();
});
// Refresh data when user clicks a sortable column header.
this.element.on('click', 'tr.header a', function() {
var td = $(this).parent();
var data = {
sortkey: $(this).data('sortkey'),
sortdir: (td.hasClass('asc')) ? 'desc' : 'asc',
page: 1,
partial: true
};
that.refresh(data);
return false;
});
// Refresh data when user chooses a new page size setting.
this.element.on('change', '.pager #pagesize', function() {
var settings = {
partial: true,
pagesize: $(this).val()
};
that.refresh(settings);
});
// Refresh data when user clicks a pager link.
this.element.on('click', '.pager a', function() {
that.refresh(this.search.substring(1)); // remove leading '?'
return false;
});
},
// Refreshes the visible data within the grid, according to the given settings.
refresh: function(settings) {
var that = this;
this.element.mask("Refreshing data...");
$.get(this.grid.data('url'), settings, function(data) {
that.grid.replaceWith(data);
that.grid = that.element.find('.grid');
that.grid.gridcore();
that.element.unmask();
});
},
results_count: function(as_text) {
var count = null;
var match = /showing \d+ thru \d+ of (\S+)/.exec(this.element.find('.pager .showing').text());
if (match) {
count = match[1];
if (!as_text) {
count = parseInt(count, 10);
}
}
return count;
},
all_uuids: function() {
return this.grid.gridcore('all_uuids');
},
selected_uuids: function() {
return this.grid.gridcore('selected_uuids');
}
});
})( jQuery );
/**********************************************************************
* gridfilter plugin
**********************************************************************/
(function($) {
$.widget('tailbone.gridfilter', {
_create: function() {
var that = this;
// Track down some important elements.
this.checkbox = this.element.find('input[name$="-active"]');
this.label = this.element.find('label');
this.inputs = this.element.find('.inputs');
this.add_filter = this.element.parents('.grid-wrapper').find('#add-filter');
// Hide the checkbox and label, and add button for toggling active status.
this.checkbox.addClass('ui-helper-hidden-accessible');
this.label.hide();
this.activebutton = $('<button type="button" class="toggle" />')
.insertAfter(this.label)
.text(this.label.text())
.button({
icons: {primary: 'ui-icon-blank'}
});
// Enhance verb dropdown as selectmenu.
this.verb_select = this.inputs.find('.verb');
this.valueless_verbs = {};
$.each(this.verb_select.data('hide-value-for').split(' '), function(index, value) {
that.valueless_verbs[value] = true;
});
this.verb_select.selectmenu({
width: '15em',
change: function(event, ui) {
if (ui.item.value in that.valueless_verbs) {
that.inputs.find('.value').hide();
} else {
that.inputs.find('.value').show();
that.focus();
that.select();
}
}
});
this.verb_select.on('selectmenuopen', function(event, ui) {
show_all_options($(this));
});
// Enhance any date values with datepicker widget.
this.inputs.find('.value input[data-datepicker="true"]').datepicker({
dateFormat: 'yy-mm-dd',
changeYear: true,
changeMonth: true
});
// Enhance any choice/dropdown values with selectmenu.
this.inputs.find('.value select').selectmenu({
// provide sane width for value dropdown
width: '15em'
});
this.inputs.find('.value select').on('selectmenuopen', function(event, ui) {
show_all_options($(this));
});
// Listen for button click, to keep checkbox in sync.
this._on(this.activebutton, {
click: function(e) {
var checked = !this.checkbox.is(':checked');
this.checkbox.prop('checked', checked);
this.refresh();
if (checked) {
this.focus();
}
}
});
// Update the initial state of the button according to checkbox.
this.refresh();
},
refresh: function() {
if (this.checkbox.is(':checked')) {
this.activebutton.button('option', 'icons', {primary: 'ui-icon-check'});
if (this.verb() in this.valueless_verbs) {
this.inputs.find('.value').hide();
} else {
this.inputs.find('.value').show();
}
this.inputs.show();
} else {
this.activebutton.button('option', 'icons', {primary: 'ui-icon-blank'});
this.inputs.hide();
}
},
active: function(value) {
if (value === undefined) {
return this.checkbox.is(':checked');
}
if (value) {
if (!this.checkbox.is(':checked')) {
this.checkbox.prop('checked', true);
this.refresh();
this.element.show();
}
} else if (this.checkbox.is(':checked')) {
this.checkbox.prop('checked', false);
this.refresh();
}
},
hide: function() {
this.active(false);
this.element.hide();
var option = this.add_filter.find('option[value="' + this.element.data('key') + '"]');
option.attr('disabled', false);
if (this.add_filter.selectmenu('option', 'disabled')) {
this.add_filter.selectmenu('enable');
}
this.add_filter.selectmenu('refresh');
},
focus: function() {
this.inputs.find('.value input').focus();
},
select: function() {
this.inputs.find('.value input').select();
},
value: function() {
return this.inputs.find('.value input, .value select').val();
},
verb: function() {
return this.inputs.find('.verb').val();
}
});
})( jQuery );

View file

@ -0,0 +1,10 @@
/**
* Copyright (c) 2009 Sergiy Kovalchuk (serg472@gmail.com)
*
* Dual licensed under the MIT (http://www.opensource.org/licenses/mit-license.php)
* and GPL (http://www.opensource.org/licenses/gpl-license.php) licenses.
*
* Following code is based on Element.mask() implementation from ExtJS framework (http://extjs.com/)
*
*/
(function(a){a.fn.mask=function(c,b){a(this).each(function(){if(b!==undefined&&b>0){var d=a(this);d.data("_mask_timeout",setTimeout(function(){a.maskElement(d,c)},b))}else{a.maskElement(a(this),c)}})};a.fn.unmask=function(){a(this).each(function(){a.unmaskElement(a(this))})};a.fn.isMasked=function(){return this.hasClass("masked")};a.maskElement=function(d,c){if(d.data("_mask_timeout")!==undefined){clearTimeout(d.data("_mask_timeout"));d.removeData("_mask_timeout")}if(d.isMasked()){a.unmaskElement(d)}if(d.css("position")=="static"){d.addClass("masked-relative")}d.addClass("masked");var e=a('<div class="loadmask"></div>');if(navigator.userAgent.toLowerCase().indexOf("msie")>-1){e.height(d.height()+parseInt(d.css("padding-top"))+parseInt(d.css("padding-bottom")));e.width(d.width()+parseInt(d.css("padding-left"))+parseInt(d.css("padding-right")))}if(navigator.userAgent.toLowerCase().indexOf("msie 6")>-1){d.find("select").addClass("masked-hidden")}d.append(e);if(c!==undefined){var b=a('<div class="loadmask-msg" style="display:none;"></div>');b.append("<div>"+c+"</div>");d.append(b);b.css("top",Math.round(d.height()/2-(b.height()-parseInt(b.css("padding-top"))-parseInt(b.css("padding-bottom")))/2)+"px");b.css("left",Math.round(d.width()/2-(b.width()-parseInt(b.css("padding-left"))-parseInt(b.css("padding-right")))/2)+"px");b.show()}};a.unmaskElement=function(b){if(b.data("_mask_timeout")!==undefined){clearTimeout(b.data("_mask_timeout"));b.removeData("_mask_timeout")}b.find(".loadmask-msg,.loadmask").remove();b.removeClass("masked");b.removeClass("masked-relative");b.find("select").removeClass("masked-hidden")}})(jQuery);

View file

@ -0,0 +1,331 @@
/*
* jQuery UI Menubar @VERSION
*
* Copyright 2011, AUTHORS.txt (http://jqueryui.com/about)
* Dual licensed under the MIT or GPL Version 2 licenses.
* http://jquery.org/license
*
* http://docs.jquery.com/UI/Menubar
*
* Depends:
* jquery.ui.core.js
* jquery.ui.widget.js
* jquery.ui.position.js
* jquery.ui.menu.js
*/
(function( $ ) {
// TODO when mixing clicking menus and keyboard navigation, focus handling is broken
// there has to be just one item that has tabindex
$.widget( "ui.menubar", {
version: "@VERSION",
options: {
autoExpand: false,
buttons: false,
items: "li",
menuElement: "ul",
menuIcon: false,
position: {
my: "left top",
at: "left bottom"
}
},
_create: function() {
var that = this;
this.menuItems = this.element.children( this.options.items );
this.items = this.menuItems.children( "button, a" );
this.menuItems
.addClass( "ui-menubar-item" )
.attr( "role", "presentation" );
// let only the first item receive focus
this.items.slice(1).attr( "tabIndex", -1 );
this.element
.addClass( "ui-menubar ui-widget-header ui-helper-clearfix" )
.attr( "role", "menubar" );
this._focusable( this.items );
this._hoverable( this.items );
this.items.siblings( this.options.menuElement )
.menu({
position: {
within: this.options.position.within
},
select: function( event, ui ) {
ui.item.parents( "ul.ui-menu:last" ).hide();
that._close();
// TODO what is this targetting? there's probably a better way to access it
$(event.target).prev().focus();
that._trigger( "select", event, ui );
},
menus: that.options.menuElement
})
.hide()
.attr({
"aria-hidden": "true",
"aria-expanded": "false"
})
// TODO use _on
.bind( "keydown.menubar", function( event ) {
var menu = $( this );
if ( menu.is( ":hidden" ) ) {
return;
}
switch ( event.keyCode ) {
case $.ui.keyCode.LEFT:
that.previous( event );
event.preventDefault();
break;
case $.ui.keyCode.RIGHT:
that.next( event );
event.preventDefault();
break;
}
});
this.items.each(function() {
var input = $(this),
// TODO menu var is only used on two places, doesn't quite justify the .each
menu = input.next( that.options.menuElement );
// might be a non-menu button
if ( menu.length ) {
// TODO use _on
input.bind( "click.menubar focus.menubar mouseenter.menubar", function( event ) {
// ignore triggered focus event
if ( event.type === "focus" && !event.originalEvent ) {
return;
}
event.preventDefault();
// TODO can we simplify or extractthis check? especially the last two expressions
// there's a similar active[0] == menu[0] check in _open
if ( event.type === "click" && menu.is( ":visible" ) && that.active && that.active[0] === menu[0] ) {
that._close();
return;
}
if ( ( that.open && event.type === "mouseenter" ) || event.type === "click" || that.options.autoExpand ) {
if( that.options.autoExpand ) {
clearTimeout( that.closeTimer );
}
that._open( event, menu );
}
})
// TODO use _on
.bind( "keydown", function( event ) {
switch ( event.keyCode ) {
case $.ui.keyCode.SPACE:
case $.ui.keyCode.UP:
case $.ui.keyCode.DOWN:
that._open( event, $( this ).next() );
event.preventDefault();
break;
case $.ui.keyCode.LEFT:
that.previous( event );
event.preventDefault();
break;
case $.ui.keyCode.RIGHT:
that.next( event );
event.preventDefault();
break;
}
})
.attr( "aria-haspopup", "true" );
// TODO review if these options (menuIcon and buttons) are a good choice, maybe they can be merged
if ( that.options.menuIcon ) {
input.addClass( "ui-state-default" ).append( "<span class='ui-button-icon-secondary ui-icon ui-icon-triangle-1-s'></span>" );
input.removeClass( "ui-button-text-only" ).addClass( "ui-button-text-icon-secondary" );
}
} else {
// TODO use _on
input.bind( "click.menubar mouseenter.menubar", function( event ) {
if ( ( that.open && event.type === "mouseenter" ) || event.type === "click" ) {
that._close();
}
});
}
input
.addClass( "ui-button ui-widget ui-button-text-only ui-menubar-link" )
.attr( "role", "menuitem" )
.wrapInner( "<span class='ui-button-text'></span>" );
if ( that.options.buttons ) {
input.removeClass( "ui-menubar-link" ).addClass( "ui-state-default" );
}
});
that._on( {
keydown: function( event ) {
if ( event.keyCode === $.ui.keyCode.ESCAPE && that.active && that.active.menu( "collapse", event ) !== true ) {
var active = that.active;
that.active.blur();
that._close( event );
active.prev().focus();
}
},
focusin: function( event ) {
clearTimeout( that.closeTimer );
},
focusout: function( event ) {
that.closeTimer = setTimeout( function() {
that._close( event );
}, 150);
},
"mouseleave .ui-menubar-item": function( event ) {
if ( that.options.autoExpand ) {
that.closeTimer = setTimeout( function() {
that._close( event );
}, 150);
}
},
"mouseenter .ui-menubar-item": function( event ) {
clearTimeout( that.closeTimer );
}
});
// Keep track of open submenus
this.openSubmenus = 0;
},
_destroy : function() {
this.menuItems
.removeClass( "ui-menubar-item" )
.removeAttr( "role" );
this.element
.removeClass( "ui-menubar ui-widget-header ui-helper-clearfix" )
.removeAttr( "role" )
.unbind( ".menubar" );
this.items
.unbind( ".menubar" )
.removeClass( "ui-button ui-widget ui-button-text-only ui-menubar-link ui-state-default" )
.removeAttr( "role" )
.removeAttr( "aria-haspopup" )
// TODO unwrap?
.children( "span.ui-button-text" ).each(function( i, e ) {
var item = $( this );
item.parent().html( item.html() );
})
.end()
.children( ".ui-icon" ).remove();
this.element.find( ":ui-menu" )
.menu( "destroy" )
.show()
.removeAttr( "aria-hidden" )
.removeAttr( "aria-expanded" )
.removeAttr( "tabindex" )
.unbind( ".menubar" );
},
_close: function() {
if ( !this.active || !this.active.length ) {
return;
}
this.active
.menu( "collapseAll" )
.hide()
.attr({
"aria-hidden": "true",
"aria-expanded": "false"
});
this.active
.prev()
.removeClass( "ui-state-active" )
.removeAttr( "tabIndex" );
this.active = null;
this.open = false;
this.openSubmenus = 0;
},
_open: function( event, menu ) {
// on a single-button menubar, ignore reopening the same menu
if ( this.active && this.active[0] === menu[0] ) {
return;
}
// TODO refactor, almost the same as _close above, but don't remove tabIndex
if ( this.active ) {
this.active
.menu( "collapseAll" )
.hide()
.attr({
"aria-hidden": "true",
"aria-expanded": "false"
});
this.active
.prev()
.removeClass( "ui-state-active" );
}
// set tabIndex -1 to have the button skipped on shift-tab when menu is open (it gets focus)
var button = menu.prev().addClass( "ui-state-active" ).attr( "tabIndex", -1 );
this.active = menu
.show()
.position( $.extend({
of: button
}, this.options.position ) )
.removeAttr( "aria-hidden" )
.attr( "aria-expanded", "true" )
.menu("focus", event, menu.children( ".ui-menu-item" ).first() )
// TODO need a comment here why both events are triggered
// TODO: heh well given the above comment i'm not sure what the
// implications might be for disabling the focus() call..but it
// messes with text input focus in undesirable ways..so disable it
// we will..until we know why we shouldn't
// .focus()
.focusin();
this.open = true;
},
next: function( event ) {
if ( this.open && this.active.data( "menu" ).active.has( ".ui-menu" ).length ) {
// Track number of open submenus and prevent moving to next menubar item
this.openSubmenus++;
return;
}
this.openSubmenus = 0;
this._move( "next", "first", event );
},
previous: function( event ) {
if ( this.open && this.openSubmenus ) {
// Track number of open submenus and prevent moving to previous menubar item
this.openSubmenus--;
return;
}
this.openSubmenus = 0;
this._move( "prev", "last", event );
},
_move: function( direction, filter, event ) {
var next,
wrapItem;
if ( this.open ) {
next = this.active.closest( ".ui-menubar-item" )[ direction + "All" ]( this.options.items ).first().children( ".ui-menu" ).eq( 0 );
wrapItem = this.menuItems[ filter ]().children( ".ui-menu" ).eq( 0 );
} else {
if ( event ) {
next = $( event.target ).closest( ".ui-menubar-item" )[ direction + "All" ]( this.options.items ).children( ".ui-menubar-link" ).eq( 0 );
wrapItem = this.menuItems[ filter ]().children( ".ui-menubar-link" ).eq( 0 );
} else {
next = wrapItem = this.menuItems.children( "a" ).eq( 0 );
}
}
if ( next.length ) {
if ( this.open ) {
this._open( event, next );
} else {
next.removeAttr( "tabIndex")[0].focus();
}
} else {
if ( this.open ) {
this._open( event, wrapItem );
} else {
wrapItem.removeAttr( "tabIndex")[0].focus();
}
}
}
});
}( jQuery ));

File diff suppressed because it is too large Load diff

17
tailbone/static/js/lib/tag-it.min.js vendored Normal file
View file

@ -0,0 +1,17 @@
(function(b){b.widget("ui.tagit",{options:{allowDuplicates:!1,caseSensitive:!0,fieldName:"tags",placeholderText:null,readOnly:!1,removeConfirmation:!1,tagLimit:null,availableTags:[],autocomplete:{},showAutocompleteOnFocus:!1,allowSpaces:!1,singleField:!1,singleFieldDelimiter:",",singleFieldNode:null,animate:!0,tabIndex:null,beforeTagAdded:null,afterTagAdded:null,beforeTagRemoved:null,afterTagRemoved:null,onTagClicked:null,onTagLimitExceeded:null,onTagAdded:null,onTagRemoved:null,tagSource:null},_create:function(){var a=
this;this.element.is("input")?(this.tagList=b("<ul></ul>").insertAfter(this.element),this.options.singleField=!0,this.options.singleFieldNode=this.element,this.element.addClass("tagit-hidden-field")):this.tagList=this.element.find("ul, ol").andSelf().last();this.tagInput=b('<input type="text" />').addClass("ui-widget-content");this.options.readOnly&&this.tagInput.attr("disabled","disabled");this.options.tabIndex&&this.tagInput.attr("tabindex",this.options.tabIndex);this.options.placeholderText&&this.tagInput.attr("placeholder",
this.options.placeholderText);this.options.autocomplete.source||(this.options.autocomplete.source=function(a,e){var d=a.term.toLowerCase(),c=b.grep(this.options.availableTags,function(a){return 0===a.toLowerCase().indexOf(d)});this.options.allowDuplicates||(c=this._subtractArray(c,this.assignedTags()));e(c)});this.options.showAutocompleteOnFocus&&(this.tagInput.focus(function(b,d){a._showAutocomplete()}),"undefined"===typeof this.options.autocomplete.minLength&&(this.options.autocomplete.minLength=
0));b.isFunction(this.options.autocomplete.source)&&(this.options.autocomplete.source=b.proxy(this.options.autocomplete.source,this));b.isFunction(this.options.tagSource)&&(this.options.tagSource=b.proxy(this.options.tagSource,this));this.tagList.addClass("tagit").addClass("ui-widget ui-widget-content ui-corner-all").append(b('<li class="tagit-new"></li>').append(this.tagInput)).click(function(d){var c=b(d.target);c.hasClass("tagit-label")?(c=c.closest(".tagit-choice"),c.hasClass("removed")||a._trigger("onTagClicked",
d,{tag:c,tagLabel:a.tagLabel(c)})):a.tagInput.focus()});var c=!1;if(this.options.singleField)if(this.options.singleFieldNode){var d=b(this.options.singleFieldNode),f=d.val().split(this.options.singleFieldDelimiter);d.val("");b.each(f,function(b,d){a.createTag(d,null,!0);c=!0})}else this.options.singleFieldNode=b('<input type="hidden" style="display:none;" value="" name="'+this.options.fieldName+'" />'),this.tagList.after(this.options.singleFieldNode);c||this.tagList.children("li").each(function(){b(this).hasClass("tagit-new")||
(a.createTag(b(this).text(),b(this).attr("class"),!0),b(this).remove())});this.tagInput.keydown(function(c){if(c.which==b.ui.keyCode.BACKSPACE&&""===a.tagInput.val()){var d=a._lastTag();!a.options.removeConfirmation||d.hasClass("remove")?a.removeTag(d):a.options.removeConfirmation&&d.addClass("remove ui-state-highlight")}else a.options.removeConfirmation&&a._lastTag().removeClass("remove ui-state-highlight");if(c.which===b.ui.keyCode.COMMA&&!1===c.shiftKey||c.which===b.ui.keyCode.ENTER||c.which==
b.ui.keyCode.TAB&&""!==a.tagInput.val()||c.which==b.ui.keyCode.SPACE&&!0!==a.options.allowSpaces&&('"'!=b.trim(a.tagInput.val()).replace(/^s*/,"").charAt(0)||'"'==b.trim(a.tagInput.val()).charAt(0)&&'"'==b.trim(a.tagInput.val()).charAt(b.trim(a.tagInput.val()).length-1)&&0!==b.trim(a.tagInput.val()).length-1))c.which===b.ui.keyCode.ENTER&&""===a.tagInput.val()||c.preventDefault(),a.options.autocomplete.autoFocus&&a.tagInput.data("autocomplete-open")||(a.tagInput.autocomplete("close"),a.createTag(a._cleanedInput()))}).blur(function(b){a.tagInput.data("autocomplete-open")||
a.createTag(a._cleanedInput())});if(this.options.availableTags||this.options.tagSource||this.options.autocomplete.source)d={select:function(b,c){a.createTag(c.item.value);return!1}},b.extend(d,this.options.autocomplete),d.source=this.options.tagSource||d.source,this.tagInput.autocomplete(d).bind("autocompleteopen.tagit",function(b,c){a.tagInput.data("autocomplete-open",!0)}).bind("autocompleteclose.tagit",function(b,c){a.tagInput.data("autocomplete-open",!1)}),this.tagInput.autocomplete("widget").addClass("tagit-autocomplete")},
destroy:function(){b.Widget.prototype.destroy.call(this);this.element.unbind(".tagit");this.tagList.unbind(".tagit");this.tagInput.removeData("autocomplete-open");this.tagList.removeClass("tagit ui-widget ui-widget-content ui-corner-all tagit-hidden-field");this.element.is("input")?(this.element.removeClass("tagit-hidden-field"),this.tagList.remove()):(this.element.children("li").each(function(){b(this).hasClass("tagit-new")?b(this).remove():(b(this).removeClass("tagit-choice ui-widget-content ui-state-default ui-state-highlight ui-corner-all remove tagit-choice-editable tagit-choice-read-only"),
b(this).text(b(this).children(".tagit-label").text()))}),this.singleFieldNode&&this.singleFieldNode.remove());return this},_cleanedInput:function(){return b.trim(this.tagInput.val().replace(/^"(.*)"$/,"$1"))},_lastTag:function(){return this.tagList.find(".tagit-choice:last:not(.removed)")},_tags:function(){return this.tagList.find(".tagit-choice:not(.removed)")},assignedTags:function(){var a=this,c=[];this.options.singleField?(c=b(this.options.singleFieldNode).val().split(this.options.singleFieldDelimiter),
""===c[0]&&(c=[])):this._tags().each(function(){c.push(a.tagLabel(this))});return c},_updateSingleTagsField:function(a){b(this.options.singleFieldNode).val(a.join(this.options.singleFieldDelimiter)).trigger("change")},_subtractArray:function(a,c){for(var d=[],f=0;f<a.length;f++)-1==b.inArray(a[f],c)&&d.push(a[f]);return d},tagLabel:function(a){return this.options.singleField?b(a).find(".tagit-label:first").text():b(a).find("input:first").val()},_showAutocomplete:function(){this.tagInput.autocomplete("search",
"")},_findTagByLabel:function(a){var c=this,d=null;this._tags().each(function(f){if(c._formatStr(a)==c._formatStr(c.tagLabel(this)))return d=b(this),!1});return d},_isNew:function(a){return!this._findTagByLabel(a)},_formatStr:function(a){return this.options.caseSensitive?a:b.trim(a.toLowerCase())},_effectExists:function(a){return Boolean(b.effects&&(b.effects[a]||b.effects.effect&&b.effects.effect[a]))},createTag:function(a,c,d){var f=this;a=b.trim(a);this.options.preprocessTag&&(a=this.options.preprocessTag(a));
if(""===a)return!1;if(!this.options.allowDuplicates&&!this._isNew(a))return a=this._findTagByLabel(a),!1!==this._trigger("onTagExists",null,{existingTag:a,duringInitialization:d})&&this._effectExists("highlight")&&a.effect("highlight"),!1;if(this.options.tagLimit&&this._tags().length>=this.options.tagLimit)return this._trigger("onTagLimitExceeded",null,{duringInitialization:d}),!1;var g=b(this.options.onTagClicked?'<a class="tagit-label"></a>':'<span class="tagit-label"></span>').text(a),e=b("<li></li>").addClass("tagit-choice ui-widget-content ui-state-default ui-corner-all").addClass(c).append(g);
this.options.readOnly?e.addClass("tagit-choice-read-only"):(e.addClass("tagit-choice-editable"),c=b("<span></span>").addClass("ui-icon ui-icon-close"),c=b('<a><span class="text-icon">\u00d7</span></a>').addClass("tagit-close").append(c).click(function(a){f.removeTag(e)}),e.append(c));this.options.singleField||(g=g.html(),e.append('<input type="hidden" value="'+g+'" name="'+this.options.fieldName+'" class="tagit-hidden-field" />'));!1!==this._trigger("beforeTagAdded",null,{tag:e,tagLabel:this.tagLabel(e),
duringInitialization:d})&&(this.options.singleField&&(g=this.assignedTags(),g.push(a),this._updateSingleTagsField(g)),this._trigger("onTagAdded",null,e),this.tagInput.val(""),this.tagInput.parent().before(e),this._trigger("afterTagAdded",null,{tag:e,tagLabel:this.tagLabel(e),duringInitialization:d}),this.options.showAutocompleteOnFocus&&!d&&setTimeout(function(){f._showAutocomplete()},0))},removeTag:function(a,c){c="undefined"===typeof c?this.options.animate:c;a=b(a);this._trigger("onTagRemoved",
null,a);if(!1!==this._trigger("beforeTagRemoved",null,{tag:a,tagLabel:this.tagLabel(a)})){if(this.options.singleField){var d=this.assignedTags(),f=this.tagLabel(a),d=b.grep(d,function(a){return a!=f});this._updateSingleTagsField(d)}if(c){a.addClass("removed");var d=this._effectExists("blind")?["blind",{direction:"horizontal"},"fast"]:["fast"],g=this;d.push(function(){a.remove();g._trigger("afterTagRemoved",null,{tag:a,tagLabel:g.tagLabel(a)})});a.fadeOut("fast").hide.apply(a,d).dequeue()}else a.remove(),
this._trigger("afterTagRemoved",null,{tag:a,tagLabel:this.tagLabel(a)})}},removeTagByLabel:function(a,b){var d=this._findTagByLabel(a);if(!d)throw"No such tag exists with the name '"+a+"'";this.removeTag(d,b)},removeAll:function(){var a=this;this._tags().each(function(b,d){a.removeTag(d,!1)})}})})(jQuery);

View file

@ -0,0 +1,32 @@
$(function() {
$('input[name="username"]').keydown(function(event) {
if (event.which == 13) {
$('input[name="password"]').focus().select();
return false;
}
return true;
});
$('form').submit(function() {
if (! $('input[name="username"]').val()) {
with ($('input[name="username"]').get(0)) {
select();
focus();
}
return false;
}
if (! $('input[name="password"]').val()) {
with ($('input[name="password"]').get(0)) {
select();
focus();
}
return false;
}
return true;
});
$('input[name="username"]').focus();
});

View file

@ -0,0 +1,29 @@
/************************************************************
*
* tailbone.appsettings.js
*
* Logic for App Settings page.
*
************************************************************/
function show_group(group) {
if (group == "(All)") {
$('.panel').show();
} else {
$('.panel').hide();
$('.panel[data-groupname="' + group + '"]').show();
}
}
$(function() {
$('#settings-group').on('selectmenuchange', function(event, ui) {
show_group(ui.item.value);
});
show_group($('#settings-group').val());
});

View file

@ -0,0 +1,41 @@
/************************************************************
*
* tailbone.batch.js
*
* Common logic for view/edit batch pages
*
************************************************************/
$(function() {
$('#execute-batch').click(function() {
if (has_execution_options) {
$('#execution-options-dialog').dialog({
title: "Execution Options",
width: 600,
modal: true,
buttons: [
{
text: "Execute",
click: function(event) {
dialog_button(event).button('option', 'label', "Executing, please wait...").button('disable');
$('form[name="batch-execution"]').submit();
}
},
{
text: "Cancel",
click: function() {
$(this).dialog('close');
}
}
]
});
} else {
$(this).button('option', 'label', "Executing, please wait...").button('disable');
$('form[name="batch-execution"]').submit();
}
});
});

View file

@ -11,7 +11,7 @@ const TailboneDatepicker = {
'icon="calendar-alt"',
':date-formatter="formatDate"',
':date-parser="parseDate"',
':value="buefyValue"',
':value="value ? parseDate(value) : null"',
'@input="dateChanged"',
':disabled="disabled"',
'ref="trueDatePicker"',
@ -26,18 +26,6 @@ const TailboneDatepicker = {
disabled: Boolean,
},
data() {
return {
buefyValue: this.parseDate(this.value),
}
},
watch: {
value(to, from) {
this.buefyValue = this.parseDate(to)
},
},
methods: {
formatDate(date) {
@ -55,12 +43,9 @@ const TailboneDatepicker = {
},
parseDate(date) {
if (typeof(date) == 'string') {
// note, this assumes classic YYYY-MM-DD (i.e. ISO?) format
var parts = date.split('-')
return new Date(parts[0], parseInt(parts[1]) - 1, parts[2])
}
return date
// note, this assumes classic YYYY-MM-DD (i.e. ISO?) format
var parts = date.split('-')
return new Date(parts[0], parseInt(parts[1]) - 1, parts[2])
},
dateChanged(date) {

View file

@ -0,0 +1,156 @@
const GridFilterNumericValue = {
template: '#grid-filter-numeric-value-template',
props: {
value: String,
wantsRange: Boolean,
},
data() {
return {
startValue: null,
endValue: null,
}
},
mounted() {
if (this.wantsRange) {
if (this.value.includes('|')) {
let values = this.value.split('|')
if (values.length == 2) {
this.startValue = values[0]
this.endValue = values[1]
} else {
this.startValue = this.value
}
} else {
this.startValue = this.value
}
} else {
this.startValue = this.value
}
},
methods: {
focus() {
this.$refs.startValue.focus()
},
startValueChanged(value) {
if (this.wantsRange) {
value += '|' + this.endValue
}
this.$emit('input', value)
},
endValueChanged(value) {
value = this.startValue + '|' + value
this.$emit('input', value)
},
},
}
Vue.component('grid-filter-numeric-value', GridFilterNumericValue)
const GridFilterDateValue = {
template: '#grid-filter-date-value-template',
props: {
value: String,
dateRange: Boolean,
},
data() {
return {
startDate: null,
endDate: null,
}
},
mounted() {
if (this.dateRange) {
if (this.value.includes('|')) {
let values = this.value.split('|')
if (values.length == 2) {
this.startDate = values[0]
this.endDate = values[1]
} else {
this.startDate = this.value
}
} else {
this.startDate = this.value
}
} else {
this.startDate = this.value
}
},
methods: {
focus() {
this.$refs.startDate.focus()
},
startDateChanged(value) {
if (this.dateRange) {
value += '|' + this.endDate
}
this.$emit('input', value)
},
endDateChanged(value) {
value = this.startDate + '|' + value
this.$emit('input', value)
},
},
}
Vue.component('grid-filter-date-value', GridFilterDateValue)
const GridFilter = {
template: '#grid-filter-template',
props: {
filter: Object
},
methods: {
changeVerb() {
// set focus to value input, "as quickly as we can"
this.$nextTick(function() {
this.focusValue()
})
},
valuedVerb() {
/* this returns true if the filter's current verb should expose value input(s) */
// if filter has no "valueless" verbs, then all verbs should expose value inputs
if (!this.filter.valueless_verbs) {
return true
}
// if filter *does* have valueless verbs, check if "current" verb is valueless
if (this.filter.valueless_verbs.includes(this.filter.verb)) {
return false
}
// current verb is *not* valueless
return true
},
multiValuedVerb() {
/* this returns true if the filter's current verb should expose a multi-value input */
// if filter has no "multi-value" verbs then we safely assume false
if (!this.filter.multiple_value_verbs) {
return false
}
// if filter *does* have multi-value verbs, see if "current" is one
if (this.filter.multiple_value_verbs.includes(this.filter.verb)) {
return true
}
// current verb is not multi-value
return false
},
focusValue: function() {
this.$refs.valueInput.focus()
// this.$refs.valueInput.select()
}
}
}
Vue.component('grid-filter', GridFilter)

View file

@ -9,55 +9,15 @@ const TailboneTimepicker = {
'placeholder="Click to select ..."',
'icon-pack="fas"',
'icon="clock"',
':value="value ? parseTime(value) : null"',
'hour-format="12"',
'@input="timeChanged"',
':time-formatter="formatTime"',
'>',
'</b-timepicker>'
].join(' '),
props: {
name: String,
id: String,
value: String,
},
methods: {
formatTime(time) {
if (time === null) {
return null
}
let h = time.getHours()
let m = time.getMinutes()
let s = time.getSeconds()
h = h < 10 ? '0' + h : h
m = m < 10 ? '0' + m : m
s = s < 10 ? '0' + s : s
return h + ':' + m + ':' + s
},
parseTime(time) {
if (time.getHours) {
return time
}
let found = time.match(/^(\d\d):(\d\d):\d\d$/)
if (found) {
return new Date(null, null, null,
parseInt(found[1]), parseInt(found[2]))
}
},
timeChanged(time) {
this.$emit('input', time)
},
},
id: String
}
}
Vue.component('tailbone-timepicker', TailboneTimepicker)

View file

@ -0,0 +1,193 @@
/************************************************************
*
* tailbone.edit-shifts.js
*
* Common logic for editing time sheet / schedule data.
*
************************************************************/
var editing_day = null;
var new_shift_id = 1;
function add_shift(focus, uuid, start_time, end_time) {
var shift = $('#snippets .shift').clone();
if (! uuid) {
uuid = 'new-' + (new_shift_id++).toString();
}
shift.attr('data-uuid', uuid);
shift.children('input').each(function() {
var name = $(this).attr('name') + '-' + uuid;
$(this).attr('name', name);
$(this).attr('id', name);
});
shift.children('input[name|="edit_start_time"]').val(start_time || '');
shift.children('input[name|="edit_end_time"]').val(end_time || '');
$('#day-editor .shifts').append(shift);
shift.children('input').timepicker({showPeriod: true});
if (focus) {
shift.children('input:first').focus();
}
}
function calc_minutes(start_time, end_time) {
var start = parseTime(start_time);
start = new Date(2000, 0, 1, start.hh, start.mm);
var end = parseTime(end_time);
end = new Date(2000, 0, 1, end.hh, end.mm);
return Math.floor((end - start) / 1000 / 60);
}
function format_minutes(minutes) {
var hours = Math.floor(minutes / 60);
if (hours) {
minutes -= hours * 60;
}
return hours.toString() + ':' + (minutes < 10 ? '0' : '') + minutes.toString();
}
// stolen from http://stackoverflow.com/a/1788084
function parseTime(s) {
var part = s.match(/(\d+):(\d+)(?: )?(am|pm)?/i);
var hh = parseInt(part[1], 10);
var mm = parseInt(part[2], 10);
var ap = part[3] ? part[3].toUpperCase() : null;
if (ap == 'AM') {
if (hh == 12) {
hh = 0;
}
} else if (ap == 'PM') {
if (hh != 12) {
hh += 12;
}
}
return { hh: hh, mm: mm };
}
function time_input(shift, type) {
var input = shift.children('input[name|="' + type + '_time"]');
if (! input.length) {
input = $('<input type="hidden" name="' + type + '_time-' + shift.data('uuid') + '" />');
shift.append(input);
}
return input;
}
function update_row_hours(row) {
var minutes = 0;
row.find('.day .shift:not(.deleted)').each(function() {
var time_range = $.trim($(this).children('span').text()).split(' - ');
minutes += calc_minutes(time_range[0], time_range[1]);
});
row.children('.total').text(minutes ? format_minutes(minutes) : '0');
}
$(function() {
$('.timesheet').on('click', '.day', function() {
editing_day = $(this);
var editor = $('#day-editor');
var employee = editing_day.siblings('.employee').text();
var date = weekdays[editing_day.get(0).cellIndex - 1];
var shifts = editor.children('.shifts');
shifts.empty();
editing_day.children('.shift:not(.deleted)').each(function() {
var uuid = $(this).data('uuid');
var time_range = $.trim($(this).children('span').text()).split(' - ');
add_shift(false, uuid, time_range[0], time_range[1]);
});
if (! shifts.children('.shift').length) {
add_shift();
}
editor.dialog({
modal: true,
title: employee + ' - ' + date,
position: {my: 'center', at: 'center', of: editing_day},
width: 'auto',
autoResize: true,
buttons: [
{
text: "Update",
click: function() {
// TODO: is this hacky? invoking timepicker to format the time values
// in all cases, to avoid "invalid format" from user input
editor.find('.shifts .shift').each(function() {
var start_time = $(this).children('input[name|="edit_start_time"]');
var end_time = $(this).children('input[name|="edit_end_time"]');
$.timepicker._setTime(start_time.data('timepicker'), start_time.val());
$.timepicker._setTime(end_time.data('timepicker'), end_time.val());
});
// create / update shifts in time table, as needed
editor.find('.shifts .shift').each(function() {
var uuid = $(this).data('uuid');
var start_time = $(this).children('input[name|="edit_start_time"]').val();
var end_time = $(this).children('input[name|="edit_end_time"]').val();
var shift = editing_day.children('.shift[data-uuid="' + uuid + '"]');
if (! shift.length) {
shift = $('<p class="shift" data-uuid="' + uuid + '"><span></span></p>');
shift.append($('<input type="hidden" name="employee_uuid-' + uuid + '" value="'
+ editing_day.parents('tr:first').data('employee-uuid') + '" />'));
editing_day.append(shift);
}
shift.children('span').text(start_time + ' - ' + end_time);
time_input(shift, 'start').val(date + ' ' + start_time);
time_input(shift, 'end').val(date + ' ' + end_time);
});
// remove shifts from time table, as needed
editing_day.children('.shift').each(function() {
var uuid = $(this).data('uuid');
if (! editor.find('.shifts .shift[data-uuid="' + uuid + '"]').length) {
if (uuid.match(/^new-/)) {
$(this).remove();
} else {
$(this).addClass('deleted');
$(this).append($('<input type="hidden" name="delete-' + uuid + '" value="delete" />'));
}
}
});
// mark day as modified, close dialog
editing_day.addClass('modified');
$('.save-changes').button('enable');
$('.undo-changes').button('enable');
update_row_hours(editing_day.parents('tr:first'));
editor.dialog('close');
data_modified = true;
okay_to_leave = false;
}
},
{
text: "Cancel",
click: function() {
editor.dialog('close');
}
}
]
});
});
$('#day-editor #add-shift').click(function() {
add_shift(true);
});
$('#day-editor').on('click', '.shifts button', function() {
$(this).parents('.shift:first').remove();
});
$('.save-changes').click(function() {
$(this).button('disable').button('option', 'label', "Saving Changes...");
okay_to_leave = true;
$('#timetable-form').submit();
});
$('.undo-changes').click(function() {
$(this).button('disable').button('option', 'label', "Refreshing...");
okay_to_leave = true;
location.href = location.href;
});
});

View file

@ -1,55 +1,58 @@
let FeedbackForm = {
props: ['action', 'message'],
template: '#feedback-template',
mixins: [FormPosterMixin],
methods: {
$(function() {
pleaseReplyChanged(value) {
this.$nextTick(() => {
this.$refs.userEmail.focus()
})
},
$('#feedback').click(function() {
var dialog = $('#feedback-dialog');
var form = dialog.find('form');
var textarea = form.find('textarea');
dialog.find('.referrer .field').html(location.href);
textarea.val('');
dialog.dialog({
title: "User Feedback",
width: 600,
modal: true,
buttons: [
{
text: "Send",
click: function(event) {
showFeedback() {
this.referrer = location.href
this.showDialog = true
this.$nextTick(function() {
this.$refs.textarea.focus()
})
},
var msg = $.trim(textarea.val());
if (! msg) {
alert("Please enter a message.");
textarea.select();
textarea.focus();
return;
}
sendFeedback() {
disable_button(dialog_button(event));
let params = {
referrer: this.referrer,
user: this.userUUID,
user_name: this.userName,
please_reply_to: this.pleaseReply ? this.userEmail : null,
message: this.message.trim(),
}
var data = {
_csrf: form.find('input[name="_csrf"]').val(),
referrer: location.href,
user: form.find('input[name="user"]').val(),
user_name: form.find('input[name="user_name"]').val(),
message: msg
};
this.submitForm(this.action, params, response => {
$.ajax(form.attr('action'), {
method: 'POST',
data: data,
success: function(data) {
dialog.dialog('close');
alert("Message successfully sent.\n\nThank you for your feedback.");
}
});
this.$buefy.toast.open({
message: "Message sent! Thank you for your feedback.",
type: 'is-info',
duration: 4000, // 4 seconds
})
this.showDialog = false
// clear out message, in case they need to send another
this.message = ""
})
},
}
}
let FeedbackFormData = {
referrer: null,
userUUID: null,
userName: null,
pleaseReply: false,
userEmail: null,
showDialog: false,
}
}
},
{
text: "Cancel",
click: function() {
dialog.dialog('close');
}
}
]
});
});
});

View file

@ -0,0 +1,386 @@
/************************************************************
*
* tailbone.js
*
************************************************************/
/*
* Initialize the disabled filters array. This is populated from within the
* /grids/search.mako template.
*/
var filters_to_disable = [];
/*
* Disables options within the "add filter" dropdown which correspond to those
* filters already being displayed. Called from /grids/search.mako template.
*/
function disable_filter_options() {
while (filters_to_disable.length) {
var filter = filters_to_disable.shift();
var option = $('#add-filter option[value="' + filter + '"]');
option.attr('disabled', 'disabled');
}
}
/*
* Convenience function to disable a UI button.
*/
function disable_button(button, label) {
$(button).button('disable');
if (label === undefined) {
label = $(button).data('working-label') || "Working, please wait...";
}
if (label) {
if (label.slice(-3) != '...') {
label += '...';
}
$(button).button('option', 'label', label);
}
}
function disable_submit_button(form, label) {
// for some reason chrome requires us to do things this way...
// https://stackoverflow.com/questions/16867080/onclick-javascript-stops-form-submit-in-chrome
// https://stackoverflow.com/questions/5691054/disable-submit-button-on-form-submit
var submit = $(form).find('input[type="submit"]');
if (! submit.length) {
submit = $(form).find('button[type="submit"]');
}
if (submit.length) {
disable_button(submit, label);
}
}
/*
* Load next / previous page of results to grid. This function is called on
* the click event from the pager links, via inline script code.
*/
function grid_navigate_page(link, url) {
var wrapper = $(link).parents('div.grid-wrapper');
var grid = wrapper.find('div.grid');
wrapper.mask("Loading...");
$.get(url, function(data) {
wrapper.unmask();
grid.replaceWith(data);
});
}
/*
* Fetch the UUID value associated with a table row.
*/
function get_uuid(obj) {
obj = $(obj);
if (obj.attr('uuid')) {
return obj.attr('uuid');
}
var tr = obj.parents('tr:first');
if (tr.attr('uuid')) {
return tr.attr('uuid');
}
return undefined;
}
/*
* Return a jQuery object containing a button from a dialog. This is a
* convenience function to help with browser differences. It is assumed
* that it is being called from within the relevant button click handler.
* @param {event} event - Click event object.
*/
function dialog_button(event) {
var button = $(event.target);
// TODO: not sure why this workaround is needed for Chrome..?
if (! button.hasClass('ui-button')) {
button = button.parents('.ui-button:first');
}
return button;
}
/**
* Scroll screen as needed to ensure all options are visible, for the given
* select menu widget.
*/
function show_all_options(select) {
if (! select.is(':visible')) {
/*
* Note that the following code was largely stolen from
* http://brianseekford.com/2013/06/03/how-to-scroll-a-container-or-element-into-view-using-jquery-javascript-in-your-html/
*/
var docViewTop = $(window).scrollTop();
var docViewBottom = docViewTop + $(window).height();
var widget = select.selectmenu('menuWidget');
var elemTop = widget.offset().top;
var elemBottom = elemTop + widget.height();
var isScrolled = ((elemBottom <= docViewBottom) && (elemTop >= docViewTop));
if (!isScrolled) {
if (widget.height() > $(window).height()) { //then just bring to top of the container
$(window).scrollTop(elemTop)
} else { //try and and bring bottom of container to bottom of screen
$(window).scrollTop(elemTop - ($(window).height() - widget.height()));
}
}
}
}
/*
* reference to existing timeout warning dialog, if any
*/
var session_timeout_warning = null;
/**
* Warn user of impending session timeout.
*/
function timeout_warning() {
if (! session_timeout_warning) {
session_timeout_warning = $('<div id="session-timeout-warning">' +
'You will be logged out in <span class="seconds"></span> ' +
'seconds...</div>');
}
session_timeout_warning.find('.seconds').text('60');
session_timeout_warning.dialog({
title: "Session Timeout Warning",
modal: true,
buttons: {
"Stay Logged In": function() {
session_timeout_warning.dialog('close');
$.get(noop_url, set_timeout_warning_timer);
},
"Logout Now": function() {
location.href = logout_url;
}
}
});
window.setTimeout(timeout_warning_update, 1000);
}
/**
* Decrement the 'seconds' counter for the current timeout warning
*/
function timeout_warning_update() {
if (session_timeout_warning.is(':visible')) {
var span = session_timeout_warning.find('.seconds');
var seconds = parseInt(span.text()) - 1;
if (seconds) {
span.text(seconds.toString());
window.setTimeout(timeout_warning_update, 1000);
} else {
location.href = logout_url;
}
}
}
/**
* Warn user of impending session timeout.
*/
function set_timeout_warning_timer() {
// timout dialog says we're 60 seconds away, but we actually trigger when
// 70 seconds away from supposed timeout, in case of timer drift?
window.setTimeout(timeout_warning, session_timeout * 1000 - 70000);
}
/*
* set initial timer for timeout warning, if applicable
*/
if (session_timeout) {
set_timeout_warning_timer();
}
$(function() {
/*
* enhance buttons
*/
$('button, a.button').button();
$('input[type=submit]').button();
$('input[type=reset]').button();
$('a.button.autodisable').click(function() {
disable_button(this);
});
$('form.autodisable').submit(function() {
disable_submit_button(this);
});
// quickie button
$('#submit-quickie').button('option', 'icons', {primary: 'ui-icon-zoomin'});
/*
* enhance dropdowns
*/
$('select[auto-enhance="true"]').selectmenu();
$('select[auto-enhance="true"]').on('selectmenuopen', function(event, ui) {
show_all_options($(this));
});
/* Also automatically disable any buttons marked for that. */
$('a.button[disabled=disabled]').button('option', 'disabled', true);
/*
* Apply timepicker behavior to text inputs which are marked for it.
*/
$('input[type=text].timepicker').timepicker({
showPeriod: true
});
/*
* When filter labels are clicked, (un)check the associated checkbox.
*/
$('body').on('click', '.grid-wrapper .filter label', function() {
var checkbox = $(this).prev('input[type="checkbox"]');
if (checkbox.prop('checked')) {
checkbox.prop('checked', false);
return false;
}
checkbox.prop('checked', true);
});
/*
* When a new filter is selected in the "add filter" dropdown, show it in
* the UI. This selects the filter's checkbox and puts focus to its input
* element. If all available filters have been displayed, the "add filter"
* dropdown will be hidden.
*/
$('body').on('change', '#add-filter', function() {
var select = $(this);
var filters = select.parents('div.filters:first');
var filter = filters.find('#filter-' + select.val());
var checkbox = filter.find('input[type="checkbox"]:first');
var input = filter.find(':last-child');
checkbox.prop('checked', true);
filter.show();
input.select();
input.focus();
filters.find('input[type="submit"]').show();
filters.find('button[type="reset"]').show();
select.find('option:selected').attr('disabled', true);
select.val('add a filter');
if (select.find('option:enabled').length == 1) {
select.hide();
}
});
/*
* When user clicks the grid filters search button, perform the search in
* the background and reload the grid in-place.
*/
$('body').on('submit', '.filters form', function() {
var form = $(this);
var wrapper = form.parents('div.grid-wrapper');
var grid = wrapper.find('div.grid');
var data = form.serializeArray();
data.push({name: 'partial', value: true});
wrapper.mask("Loading...");
$.get(grid.attr('url'), data, function(data) {
wrapper.unmask();
grid.replaceWith(data);
});
return false;
});
/*
* When user clicks the grid filters reset button, manually clear all
* filter input elements, and submit a new search.
*/
$('body').on('click', '.filters form button[type="reset"]', function() {
var form = $(this).parents('form');
form.find('div.filter').each(function() {
$(this).find('div.value input').val('');
});
form.submit();
return false;
});
$('body').on('click', '.grid thead th.sortable a', function() {
var th = $(this).parent();
var wrapper = th.parents('div.grid-wrapper');
var grid = wrapper.find('div.grid');
var data = {
sort: th.attr('field'),
dir: (th.hasClass('sorted') && th.hasClass('asc')) ? 'desc' : 'asc',
page: 1,
partial: true
};
wrapper.mask("Loading...");
$.get(grid.attr('url'), data, function(data) {
wrapper.unmask();
grid.replaceWith(data);
});
return false;
});
$('body').on('mouseenter', '.grid.hoverable tbody tr', function() {
$(this).addClass('hovering');
});
$('body').on('mouseleave', '.grid.hoverable tbody tr', function() {
$(this).removeClass('hovering');
});
$('body').on('click', '.grid tbody td.view', function() {
var url = $(this).attr('url');
if (url) {
location.href = url;
}
});
$('body').on('click', '.grid tbody td.edit', function() {
var url = $(this).attr('url');
if (url) {
location.href = url;
}
});
$('body').on('click', '.grid tbody td.delete', function() {
var url = $(this).attr('url');
if (url) {
if (confirm("Do you really wish to delete this object?")) {
location.href = url;
}
}
});
// $('div.grid-wrapper').on('change', 'div.grid div.pager select#grid-page-count', function() {
$('body').on('change', '.grid .pager #grid-page-count', function() {
var select = $(this);
var wrapper = select.parents('div.grid-wrapper');
var grid = wrapper.find('div.grid');
var data = {
per_page: select.val(),
partial: true
};
wrapper.mask("Loading...");
$.get(grid.attr('url'), data, function(data) {
wrapper.unmask();
grid.replaceWith(data);
});
});
$('body').on('click', 'div.dialog button.close', function() {
var dialog = $(this).parents('div.dialog:first');
dialog.dialog('close');
});
});

View file

@ -0,0 +1,267 @@
/************************************************************
*
* tailbone.timesheet.edit.js
*
* Common logic for editing time sheet / schedule data.
*
************************************************************/
var editing_day = null;
var new_shift_id = 1;
var show_timepicker = true;
/*
* Add a new shift entry to the editor dialog.
* @param {boolean} focus - Whether to set focus to the start_time input
* element after adding the shift.
* @param {string} uuid - UUID value for the shift, if applicable.
* @param {string} start_time - Value for start_time input element.
* @param {string} end_time - Value for end_time input element.
*/
function add_shift(focus, uuid, start_time, end_time) {
var shift = $('#snippets .shift').clone();
if (! uuid) {
uuid = 'new-' + (new_shift_id++).toString();
}
shift.attr('data-uuid', uuid);
shift.children('input').each(function() {
var name = $(this).attr('name') + '-' + uuid;
$(this).attr('name', name);
$(this).attr('id', name);
});
shift.children('input[name|="edit_start_time"]').val(start_time);
shift.children('input[name|="edit_end_time"]').val(end_time);
$('#day-editor .shifts').append(shift);
// maybe trick timepicker into never showing itself
var args = {showPeriod: true};
if (! show_timepicker) {
args.showOn = 'button';
args.button = '#nevershow';
}
shift.children('input').timepicker(args);
if (focus) {
shift.children('input:first').focus();
}
}
/**
* Calculate the number of minutes between given the times.
* @param {string} start_time - Value from start_time input element.
* @param {string} end_time - Value from end_time input element.
*/
function calc_minutes(start_time, end_time) {
var start = parseTime(start_time);
var end = parseTime(end_time);
if (start && end) {
start = new Date(2000, 0, 1, start.hh, start.mm);
end = new Date(2000, 0, 1, end.hh, end.mm);
return Math.floor((end - start) / 1000 / 60);
}
}
/**
* Converts a number of minutes into string of HH:MM format.
* @param {number} minutes - Number of minutes to be converted.
*/
function format_minutes(minutes) {
var hours = Math.floor(minutes / 60);
if (hours) {
minutes -= hours * 60;
}
return hours.toString() + ':' + (minutes < 10 ? '0' : '') + minutes.toString();
}
/**
* NOTE: most of this logic was stolen from http://stackoverflow.com/a/1788084
*
* Parse a time string and convert to simple object with hh and mm keys.
* @param {string} time - Time value in 'HH:MM PP' format, or close enough.
*/
function parseTime(time) {
if (time) {
var part = time.match(/(\d+):(\d+)(?: )?(am|pm)?/i);
if (part) {
var hh = parseInt(part[1], 10);
var mm = parseInt(part[2], 10);
var ap = part[3] ? part[3].toUpperCase() : null;
if (ap == 'AM') {
if (hh == 12) {
hh = 0;
}
} else if (ap == 'PM') {
if (hh != 12) {
hh += 12;
}
}
return { hh: hh, mm: mm };
}
}
}
/**
* Return a jQuery object containing the hidden start or end time input element
* for the shift (i.e. within the *main* timesheet form). This will create the
* input if necessary.
* @param {jQuery} shift - A jQuery object for the shift itself.
* @param {string} type - Should be 'start' or 'end' only.
*/
function time_input(shift, type) {
var input = shift.children('input[name|="' + type + '_time"]');
if (! input.length) {
input = $('<input type="hidden" name="' + type + '_time-' + shift.data('uuid') + '" />');
shift.append(input);
}
return input;
}
/**
* Update the weekly hour total for a given row (employee).
* @param {jQuery} row - A jQuery object for the row to be updated.
*/
function update_row_hours(row) {
var minutes = 0;
row.find('.day .shift:not(.deleted)').each(function() {
var time_range = $.trim($(this).children('span').text()).split(' - ');
minutes += calc_minutes(time_range[0], time_range[1]);
});
row.children('.total').text(minutes ? format_minutes(minutes) : '0');
}
/**
* Clean up user input within the editor dialog, e.g. '8:30am' => '08:30 AM'.
* This also should ensure invalid input will become empty string.
*/
function cleanup_editor_input() {
// TODO: is this hacky? invoking timepicker to format the time values
// in all cases, to avoid "invalid format" from user input
var backward = false;
$('#day-editor .shifts .shift').each(function() {
var start_time = $(this).children('input[name|="edit_start_time"]');
var end_time = $(this).children('input[name|="edit_end_time"]');
$.timepicker._setTime(start_time.data('timepicker'), start_time.val() || '??');
$.timepicker._setTime(end_time.data('timepicker'), end_time.val() || '??');
var t_start = parseTime(start_time.val());
var t_end = parseTime(end_time.val());
if (t_start && t_end) {
if ((t_start.hh > t_end.hh) || ((t_start.hh == t_end.hh) && (t_start.mm > t_end.mm))) {
alert("Start time falls *after* end time! Please fix...");
start_time.focus().select();
backward = true;
return false;
}
}
});
return !backward;
}
/**
* Update the main timesheet table based on editor dialog input. This updates
* both the displayed timesheet, as well as any hidden input elements on the
* main form.
*/
function update_timetable() {
var date = weekdays[editing_day.get(0).cellIndex - 1];
// add or update
$('#day-editor .shifts .shift').each(function() {
var uuid = $(this).data('uuid');
var start_time = $(this).children('input[name|="edit_start_time"]').val();
var end_time = $(this).children('input[name|="edit_end_time"]').val();
var shift = editing_day.children('.shift[data-uuid="' + uuid + '"]');
if (! shift.length) {
if (! (start_time || end_time)) {
return;
}
shift = $('<p class="shift" data-uuid="' + uuid + '"><span></span></p>');
shift.append($('<input type="hidden" name="employee_uuid-' + uuid + '" value="'
+ editing_day.parents('tr:first').data('employee-uuid') + '" />'));
editing_day.append(shift);
}
shift.children('span').text((start_time || '??') + ' - ' + (end_time || '??'));
start_time = start_time ? (date + ' ' + start_time) : '';
end_time = end_time ? (date + ' ' + end_time) : '';
time_input(shift, 'start').val(start_time);
time_input(shift, 'end').val(end_time);
});
// remove / mark for deletion
editing_day.children('.shift').each(function() {
var uuid = $(this).data('uuid');
if (! $('#day-editor .shifts .shift[data-uuid="' + uuid + '"]').length) {
if (uuid.match(/^new-/)) {
$(this).remove();
} else {
$(this).addClass('deleted');
$(this).append($('<input type="hidden" name="delete-' + uuid + '" value="delete" />'));
}
}
});
}
/**
* Perform full "save" action for time sheet form, direct from day editor dialog.
*/
function save_dialog() {
if (! cleanup_editor_input()) {
return false;
}
var save = $('#day-editor').parents('.ui-dialog').find('.ui-dialog-buttonpane button:first');
save.button('disable').button('option', 'label', "Saving...");
update_timetable();
$('#timetable-form').submit();
return true;
}
/*
* on document load...
*/
$(function() {
/*
* Within editor dialog, clicking Add Shift button will create a new/empty
* shift and set focus to its start_time input.
*/
$('#day-editor #add-shift').click(function() {
add_shift(true);
});
/*
* Within editor dialog, clicking a shift's "trash can" button will remove
* the shift.
*/
$('#day-editor').on('click', '.shifts button', function() {
$(this).parents('.shift:first').remove();
});
/*
* Within editor dialog, Enter press within time field "might" trigger
* save. Note that this is only done for timesheet editing, not schedule.
*/
$('#day-editor').on('keydown', '.shifts input[type="text"]', function(event) {
if (!show_timepicker) { // TODO: this implies too much, should be cleaner
if (event.which == 13) {
save_dialog();
return false;
}
}
});
});

View file

@ -0,0 +1,14 @@
/******************************
* tweaks for root user
******************************/
.navbar .navbar-end .navbar-link.root-user,
.navbar .navbar-end .navbar-link.root-user:hover,
.navbar .navbar-end .navbar-link.root-user.is_active,
.navbar .navbar-end .navbar-item.root-user,
.navbar .navbar-end .navbar-item.root-user:hover,
.navbar .navbar-end .navbar-item.root-user.is_active {
background-color: red;
font-weight: bold;
}

View file

@ -0,0 +1,22 @@
/******************************
* Grid Filters
******************************/
.filters .filter {
margin-bottom: 0.5rem;
}
.filters .filter-fieldname .field,
.filters .filter-fieldname .field label {
width: 100%;
}
.filters .filter-fieldname .field label {
justify-content: left;
}
.filters .filter-verb .select,
.filters .filter-verb .select select {
width: 100%;
}

View file

@ -0,0 +1,61 @@
/******************************
* forms
******************************/
/* note that this should only apply to "normal" primary forms */
/* TODO: replace this with bulma equivalent */
.form {
padding-left: 5em;
}
/* note that this should only apply to "normal" primary forms */
.form-wrapper .form .field.is-horizontal .field-label .label {
text-align: left;
white-space: nowrap;
width: 18em;
}
/* note that this should only apply to "normal" primary forms */
.form-wrapper .form .field.is-horizontal .field-body {
min-width: 30em;
}
/* note that this should only apply to "normal" primary forms */
.form-wrapper .form .field.is-horizontal .field-body .select,
.form-wrapper .form .field.is-horizontal .field-body .select select {
width: 100%;
}
/******************************
* field-wrappers
******************************/
/* TODO: replace this with bulma equivalent */
.field-wrapper {
clear: both;
min-height: 30px;
overflow: auto;
margin: 15px;
}
/* TODO: replace this with bulma equivalent */
.field-wrapper .field-row {
display: table-row;
}
/* TODO: replace this with bulma equivalent */
.field-wrapper label {
display: table-cell;
vertical-align: top;
width: 18em;
font-weight: bold;
padding-top: 2px;
white-space: nowrap;
}
/* TODO: replace this with bulma equivalent */
.field-wrapper .field {
display: table-cell;
line-height: 25px;
}

View file

@ -0,0 +1,15 @@
/********************************************************************************
* grids.css
*
* Style tweaks for the Buefy grids.
********************************************************************************/
/******************************
* actions column
******************************/
a.grid-action {
white-space: nowrap;
}

View file

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

View file

@ -0,0 +1,150 @@
/******************************
* main layout
******************************/
body {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.content-wrapper {
display: flex;
flex: 1;
flex-direction: column;
justify-content: space-between;
}
/******************************
* header
******************************/
/* this is the one in the very top left of screen, next to logo and linked to
the home page */
#global-header-title {
margin-left: 0.3rem;
}
header .level {
/* TODO: not sure what this 60px was supposed to do? but it broke the */
/* styles for the feedback dialog, so disabled it is.
/* height: 60px; */
/* line-height: 60px; */
padding-left: 0.5em;
padding-right: 0.5em;
}
header .level #header-logo {
display: inline-block;
}
header .level .global-title,
header .level-left .global-title {
font-size: 2em;
font-weight: bold;
}
/* indent nested menu items a bit */
header .navbar-item.nested {
padding-left: 2.5rem;
}
header span.header-text {
font-size: 2em;
font-weight: bold;
margin-right: 10px;
}
header .level .theme-picker {
display: inline-flex;
}
#content-title {
padding: 0.3rem;
}
#content-title h1 {
font-size: 2rem;
margin-left: 1rem;
}
/******************************
* content
******************************/
#page-body {
padding: 0.4em;
}
/******************************
* context menu
******************************/
#context-menu {
margin-bottom: 1em;
margin-left: 1em;
text-align: right;
white-space: nowrap;
}
/******************************
* "object helper" panel
******************************/
.object-helpers .panel-heading {
white-space: nowrap;
}
.object-helpers a {
white-space: nowrap;
}
.object-helper {
border: 1px solid black;
margin: 1em;
padding: 1em;
width: 20em;
}
.object-helper-content {
margin-top: 1em;
}
/******************************
* markdown
******************************/
.rendered-markdown p,
.rendered-markdown ul {
margin-bottom: 1rem;
}
.rendered-markdown .codehilite {
margin-bottom: 2rem;
}
/******************************
* fix datepicker within modals
* TODO: someday this may not be necessary? cf.
* https://github.com/buefy/buefy/issues/292#issuecomment-347365637
******************************/
.modal .animation-content .modal-card {
overflow: visible !important;
}
.modal-card-body {
overflow: visible !important;
}
/******************************
* feedback
******************************/
.feedback-dialog .red {
color: red;
font-weight: bold;
}

View file

@ -0,0 +1,54 @@
let FeedbackForm = {
props: ['action', 'message'],
template: '#feedback-template',
mixins: [FormPosterMixin],
methods: {
pleaseReplyChanged(value) {
this.$nextTick(() => {
this.$refs.userEmail.focus()
})
},
showFeedback() {
this.showDialog = true
this.$nextTick(function() {
this.$refs.textarea.focus()
})
},
sendFeedback() {
let params = {
referrer: this.referrer,
user: this.userUUID,
user_name: this.userName,
please_reply_to: this.pleaseReply ? this.userEmail : null,
message: this.message.trim(),
}
this.submitForm(this.action, params, response => {
this.$buefy.toast.open({
message: "Message sent! Thank you for your feedback.",
type: 'is-info',
duration: 4000, // 4 seconds
})
this.showDialog = false
// clear out message, in case they need to send another
this.message = ""
})
},
}
}
let FeedbackFormData = {
referrer: null,
userUUID: null,
userName: null,
pleaseReply: false,
userEmail: null,
showDialog: false,
}

View file

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

View file

@ -1,2 +1,241 @@
## -*- coding: utf-8; -*-
<%inherit file="wuttaweb:templates/appinfo/configure.mako" />
<%inherit file="/configure.mako" />
<%def name="form_content()">
<h3 class="block is-size-3">Basics</h3>
<div class="block" style="padding-left: 2rem;">
<b-field grouped>
<b-field label="App Title">
<b-input name="rattail.app_title"
v-model="simpleSettings['rattail.app_title']"
@input="settingsNeedSaved = true">
</b-input>
</b-field>
<b-field label="Node Type">
## TODO: should be a dropdown, app handler defines choices
<b-input name="rattail.node_type"
v-model="simpleSettings['rattail.node_type']"
@input="settingsNeedSaved = true">
</b-input>
</b-field>
<b-field label="Node Title">
<b-input name="rattail.node_title"
v-model="simpleSettings['rattail.node_title']"
@input="settingsNeedSaved = true">
</b-input>
</b-field>
</b-field>
<b-field>
<b-checkbox name="rattail.production"
v-model="simpleSettings['rattail.production']"
native-value="true"
@input="settingsNeedSaved = true">
Production Mode
</b-checkbox>
</b-field>
<div class="level-left">
<div class="level-item">
<b-field>
<b-checkbox name="rattail.running_from_source"
v-model="simpleSettings['rattail.running_from_source']"
native-value="true"
@input="settingsNeedSaved = true">
Running from Source
</b-checkbox>
</b-field>
</div>
<div class="level-item">
<b-field label="Top-Level Package" horizontal
v-if="simpleSettings['rattail.running_from_source']">
<b-input name="rattail.running_from_source.rootpkg"
v-model="simpleSettings['rattail.running_from_source.rootpkg']"
@input="settingsNeedSaved = true">
</b-input>
</b-field>
</div>
</div>
</div>
<h3 class="block is-size-3">Display</h3>
<div class="block" style="padding-left: 2rem;">
<b-field grouped>
<b-field label="Background Color">
<b-input name="tailbone.background_color"
v-model="simpleSettings['tailbone.background_color']"
@input="settingsNeedSaved = true">
</b-input>
</b-field>
</b-field>
</div>
<h3 class="block is-size-3">Grids</h3>
<div class="block" style="padding-left: 2rem;">
<b-field grouped>
<b-field label="Default Page Size">
<b-input name="tailbone.grid.default_pagesize"
v-model="simpleSettings['tailbone.grid.default_pagesize']"
@input="settingsNeedSaved = true">
</b-input>
</b-field>
</b-field>
</div>
<h3 class="block is-size-3">Web Libraries</h3>
<div class="block" style="padding-left: 2rem;">
<b-table :data="weblibs">
<b-table-column field="title"
label="Name"
v-slot="props">
{{ props.row.title }}
</b-table-column>
<b-table-column field="configured_version"
label="Version"
v-slot="props">
{{ props.row.configured_version || props.row.default_version }}
</b-table-column>
<b-table-column field="configured_url"
label="URL Override"
v-slot="props">
{{ props.row.configured_url }}
</b-table-column>
<b-table-column field="live_url"
label="Effective (Live) URL"
v-slot="props">
<span v-if="props.row.modified"
class="has-text-warning">
save settings and refresh page to see new URL
</span>
<span v-if="!props.row.modified">
{{ props.row.live_url }}
</span>
</b-table-column>
<b-table-column field="actions"
label="Actions"
v-slot="props">
<a href="#"
@click.prevent="editWebLibraryInit(props.row)">
<i class="fas fa-edit"></i>
Edit
</a>
</b-table-column>
</b-table>
% for weblib in weblibs:
${h.hidden('tailbone.libver.{}'.format(weblib['key']), **{':value': "simpleSettings['tailbone.libver.{}']".format(weblib['key'])})}
${h.hidden('tailbone.liburl.{}'.format(weblib['key']), **{':value': "simpleSettings['tailbone.liburl.{}']".format(weblib['key'])})}
% endfor
<b-modal has-modal-card
:active.sync="editWebLibraryShowDialog">
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">Web Library: {{ editWebLibraryRecord.title }}</p>
</header>
<section class="modal-card-body">
<b-field grouped>
<b-field label="Default Version">
<b-input v-model="editWebLibraryRecord.default_version"
disabled>
</b-input>
</b-field>
<b-field label="Override Version">
<b-input v-model="editWebLibraryVersion">
</b-input>
</b-field>
</b-field>
<b-field label="Override URL">
<b-input v-model="editWebLibraryURL">
</b-input>
</b-field>
<b-field label="Effective URL (as of last page load)">
<b-input v-model="editWebLibraryRecord.live_url"
disabled>
</b-input>
</b-field>
</section>
<footer class="modal-card-foot">
<b-button type="is-primary"
@click="editWebLibrarySave()"
icon-pack="fas"
icon-left="save">
Save
</b-button>
<b-button @click="editWebLibraryShowDialog = false">
Cancel
</b-button>
</footer>
</div>
</b-modal>
</div>
</%def>
<%def name="modify_this_page_vars()">
${parent.modify_this_page_vars()}
<script type="text/javascript">
ThisPageData.weblibs = ${json.dumps(weblibs)|n}
ThisPageData.editWebLibraryShowDialog = false
ThisPageData.editWebLibraryRecord = {}
ThisPageData.editWebLibraryVersion = null
ThisPageData.editWebLibraryURL = null
ThisPage.methods.editWebLibraryInit = function(row) {
this.editWebLibraryRecord = row
this.editWebLibraryVersion = row.configured_version
this.editWebLibraryURL = row.configured_url
this.editWebLibraryShowDialog = true
}
ThisPage.methods.editWebLibrarySave = function() {
this.editWebLibraryRecord.configured_version = this.editWebLibraryVersion
this.editWebLibraryRecord.configured_url = this.editWebLibraryURL
this.editWebLibraryRecord.modified = true
this.simpleSettings[`tailbone.libver.${'$'}{this.editWebLibraryRecord.key}`] = this.editWebLibraryVersion
this.simpleSettings[`tailbone.liburl.${'$'}{this.editWebLibraryRecord.key}`] = this.editWebLibraryURL
this.settingsNeedSaved = true
this.editWebLibraryShowDialog = false
}
</script>
</%def>
${parent.body()}

View file

@ -1,7 +1,8 @@
## -*- coding: utf-8; -*-
<%inherit file="wuttaweb:templates/appinfo/index.mako" />
<%inherit file="/master/index.mako" />
<%def name="render_grid_component()">
<%def name="page_content()">
<div class="buttons">
<once-button type="is-primary"
@ -27,5 +28,98 @@
</div>
${parent.page_content()}
<b-collapse class="panel" open>
<template #trigger="props">
<div class="panel-heading"
role="button">
## TODO: for some reason buefy will "reuse" the icon
## element in such a way that its display does not
## refresh. so to work around that, we use different
## structure for the two icons, so buefy is forced to
## re-draw
<b-icon v-if="props.open"
pack="fas"
icon="angle-down">
</b-icon>
<span v-if="!props.open">
<b-icon pack="fas"
icon="angle-right">
</b-icon>
</span>
<strong>Configuration Files</strong>
</div>
</template>
<div class="panel-block">
<div style="width: 100%;">
<b-table :data="configFiles">
<b-table-column field="priority"
label="Priority"
v-slot="props">
{{ props.row.priority }}
</b-table-column>
<b-table-column field="path"
label="File Path"
v-slot="props">
{{ props.row.path }}
</b-table-column>
</b-table>
</div>
</div>
</b-collapse>
<b-collapse class="panel"
:open="false">
<template #trigger="props">
<div class="panel-heading"
role="button">
## TODO: for some reason buefy will "reuse" the icon
## element in such a way that its display does not
## refresh. so to work around that, we use different
## structure for the two icons, so buefy is forced to
## re-draw
<b-icon v-if="props.open"
pack="fas"
icon="angle-down">
</b-icon>
<span v-if="!props.open">
<b-icon pack="fas"
icon="angle-right">
</b-icon>
</span>
<strong>Installed Packages</strong>
</div>
</template>
<div class="panel-block">
<div style="width: 100%;">
${parent.render_grid_component()}
</div>
</div>
</b-collapse>
</%def>
<%def name="modify_this_page_vars()">
${parent.modify_this_page_vars()}
<script type="text/javascript">
ThisPageData.configFiles = ${json.dumps([dict(path=p, priority=i) for i, p in enumerate(reversed(request.rattail_config.files_read), 1)])|n}
</script>
</%def>
${parent.body()}

View file

@ -5,6 +5,34 @@
<%def name="content_title()"></%def>
<%def name="extra_javascript()">
${parent.extra_javascript()}
% if not use_buefy:
${h.javascript_link(request.static_url('tailbone:static/js/tailbone.appsettings.js') + '?ver={}'.format(tailbone.__version__))}
% endif
</%def>
<%def name="extra_styles()">
${parent.extra_styles()}
% if not use_buefy:
<style type="text/css">
div.form {
float: none;
}
div.panel {
width: 85%;
}
.field-wrapper {
margin-bottom: 2em;
}
.panel .field-wrapper label {
font-family: monospace;
width: 50em;
}
</style>
% endif
</%def>
<%def name="context_menu_items()">
% if request.has_perm('settings.list'):
<li>${h.link_to("View Raw Settings", url('settings'))}</li>
@ -15,8 +43,8 @@
<app-settings :groups="groups" :showing-group="showingGroup"></app-settings>
</%def>
<%def name="render_vue_templates()">
${parent.render_vue_templates()}
<%def name="render_this_page_template()">
${parent.render_this_page_template()}
<script type="text/x-template" id="app-settings-template">
<div class="form">
@ -150,18 +178,19 @@
</script>
</%def>
<%def name="modify_this_page_vars()">
${parent.modify_this_page_vars()}
<script type="text/javascript">
<%def name="modify_vue_vars()">
${parent.modify_vue_vars()}
<script>
ThisPageData.groups = ${json.dumps(settings_data)|n}
ThisPageData.groups = ${json.dumps(buefy_data)|n}
ThisPageData.showingGroup = ${json.dumps(current_group or '')|n}
</script>
</%def>
<%def name="make_vue_components()">
${parent.make_vue_components()}
<script>
<%def name="make_this_page_component()">
${parent.make_this_page_component()}
<script type="text/javascript">
Vue.component('app-settings', {
template: '#app-settings-template',
@ -192,3 +221,78 @@
</script>
</%def>
% if use_buefy:
${parent.body()}
% else:
## legacy / not buefy
<div class="form">
${h.form(form.action_url, id=dform.formid, method='post', class_='autodisable')}
${h.csrf_token(request)}
% if dform.error:
<div class="error-messages">
<div class="ui-state-error ui-corner-all">
<span style="float: left; margin-right: .3em;" class="ui-icon ui-icon-alert"></span>
Please see errors below.
</div>
<div class="ui-state-error ui-corner-all">
<span style="float: left; margin-right: .3em;" class="ui-icon ui-icon-alert"></span>
${dform.error}
</div>
</div>
% endif
<div class="group-picker">
<div class="field-wrapper">
<label for="settings-group">Showing Group</label>
<div class="field select">
${h.select('settings-group', current_group, group_options, **{'auto-enhance': 'true'})}
</div>
</div>
</div>
% for group in groups:
<div class="panel" data-groupname="${group}">
<h2>${group}</h2>
<div class="panel-body">
% for setting in settings:
% if setting.group == group:
<% field = dform[setting.node_name] %>
<div class="field-wrapper ${field.name} ${'with-error' if field.error else ''}">
% if field.error:
<div class="field-error">
% for msg in field.error.messages():
<span class="error-msg">${msg}</span>
% endfor
</div>
% endif
<div class="field-row">
<label for="${field.oid}">${form.get_label(field.name)}</label>
<div class="field">
${field.serialize()|n}
</div>
</div>
% if form.has_helptext(field.name):
<span class="instructions">${form.render_helptext(field.name)}</span>
% endif
</div>
% endif
% endfor
</div><!-- panel-body -->
</div><! -- panel -->
% endfor
<div class="buttons">
${h.submit('save', getattr(form, 'submit_label', getattr(form, 'save_label', "Submit")), class_='button is-primary')}
${h.link_to("Cancel", form.cancel_url, class_='cancel button{}'.format(' autodisable' if form.auto_disable_cancel else ''))}
</div>
${h.end_form()}
</div>
% endif

View file

@ -1,5 +1,63 @@
## -*- coding: utf-8; -*-
## TODO: This function signature is getting out of hand...
<%def name="autocomplete(field_name, service_url, field_value=None, field_display=None, width='300px', select=None, selected=None, cleared=None, change_clicked=None, options={})">
<div id="${field_name}-container" class="autocomplete-container">
${h.hidden(field_name, id=field_name, value=field_value)}
${h.text(field_name+'-textbox', id=field_name+'-textbox', value=field_display,
class_='autocomplete-textbox', style='display: none;' if field_value else '')}
<div id="${field_name}-display" class="autocomplete-display"${'' if field_value else ' style="display: none;"'|n}>
<span>${field_display or ''}</span>
<button type="button" id="${field_name}-change" class="autocomplete-change">Change</button>
</div>
</div>
<script type="text/javascript">
$(function() {
$('#${field_name}-textbox').autocomplete({
source: '${service_url}',
autoFocus: true,
% for key, value in options.items():
${key}: ${value},
% endfor
focus: function(event, ui) {
return false;
},
% if select:
select: ${select}
% else:
select: function(event, ui) {
$('#${field_name}').val(ui.item.value);
$('#${field_name}-display span:first').text(ui.item.label);
$('#${field_name}-textbox').hide();
$('#${field_name}-display').show();
% if selected:
${selected}(ui.item.value, ui.item.label);
% endif
return false;
}
% endif
});
$('#${field_name}-change').click(function() {
% if change_clicked:
if (! ${change_clicked}()) {
return false;
}
% endif
$('#${field_name}').val('');
$('#${field_name}-display').hide();
with ($('#${field_name}-textbox')) {
val('');
show();
focus();
}
% if cleared:
${cleared}();
% endif
});
});
</script>
</%def>
<%def name="tailbone_autocomplete_template()">
<script type="text/x-template" id="tailbone-autocomplete-template">
<div>

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,8 @@
## -*- coding: utf-8; -*-
<%inherit file="wuttaweb:templates/base_meta.mako" />
<%def name="app_title()">${app.get_node_title()}</%def>
<%def name="app_title()">${request.rattail_config.node_title(default="Rattail")}</%def>
<%def name="global_title()">${"[STAGE] " if not request.rattail_config.production() else ''}${self.app_title()}</%def>
<%def name="favicon()">
<link rel="icon" type="image/x-icon" href="${request.rattail_config.get('tailbone', 'favicon_url', default=request.static_url('tailbone:static/img/rattail.ico'))}" />
@ -10,3 +11,9 @@
<%def name="header_logo()">
${h.image(request.rattail_config.get('tailbone', 'header_image_url', default=request.static_url('tailbone:static/img/rattail.ico')), "Header Logo", style="height: 49px;")}
</%def>
<%def name="footer()">
<p class="has-text-centered">
powered by ${h.link_to("Rattail", url('about'))}
</p>
</%def>

View file

@ -68,7 +68,7 @@
% endif
</%def>
<%def name="render_form()">
<%def name="render_buefy_form()">
<div class="form">
<tailbone-form></tailbone-form>
<br />
@ -76,5 +76,12 @@
</div>
</%def>
<%def name="render_form()">
${parent.render_form()}
% if not use_buefy:
${self.field_diff_table()}
% endif
</%def>
${parent.body()}

View file

@ -1,80 +1,157 @@
## -*- coding: utf-8; -*-
<%inherit file="/master/index.mako" />
<%def name="extra_javascript()">
${parent.extra_javascript()}
% if not use_buefy:
% if master.results_executable and master.has_perm('execute_multiple'):
<script type="text/javascript">
var has_execution_options = ${'true' if master.has_execution_options(batch) else 'false'};
var dialog_opened = false;
$(function() {
$('#refresh-results-button').click(function() {
var count = $('.grid-wrapper').gridwrapper('results_count');
if (!count) {
alert("There are no batch results to refresh.");
return;
}
var form = $('form[name="refresh-results"]');
$(this).button('option', 'label', "Refreshing, please wait...").button('disable');
form.submit();
});
$('#execute-results-button').click(function() {
var count = $('.grid-wrapper').gridwrapper('results_count');
if (!count) {
alert("There are no batch results to execute.");
return;
}
var form = $('form[name="execute-results"]');
if (has_execution_options) {
$('#execution-options-dialog').dialog({
title: "Execution Options",
width: 550,
height: 300,
modal: true,
buttons: [
{
text: "Execute",
click: function(event) {
dialog_button(event).button('option', 'label', "Executing, please wait...").button('disable');
form.submit();
}
},
{
text: "Cancel",
click: function() {
$(this).dialog('close');
}
}
],
open: function() {
if (! dialog_opened) {
$('#execution-options-dialog select[auto-enhance="true"]').selectmenu();
$('#execution-options-dialog select[auto-enhance="true"]').on('selectmenuopen', function(event, ui) {
show_all_options($(this));
});
dialog_opened = true;
}
}
});
} else {
$(this).button('option', 'label', "Executing, please wait...").button('disable');
form.submit();
}
});
});
</script>
% endif
% endif
</%def>
<%def name="grid_tools()">
${parent.grid_tools()}
## Refresh Results
% if master.results_refreshable and master.has_perm('refresh'):
<b-button type="is-primary"
:disabled="refreshResultsButtonDisabled"
icon-pack="fas"
icon-left="redo"
@click="refreshResults()">
{{ refreshResultsButtonText }}
</b-button>
${h.form(url('{}.refresh_results'.format(route_prefix)), ref='refreshResultsForm')}
${h.csrf_token(request)}
${h.end_form()}
% if use_buefy:
<b-button type="is-primary"
:disabled="refreshResultsButtonDisabled"
icon-pack="fas"
icon-left="fas fa-redo"
@click="refreshResults()">
{{ refreshResultsButtonText }}
</b-button>
${h.form(url('{}.refresh_results'.format(route_prefix)), ref='refreshResultsForm')}
${h.csrf_token(request)}
${h.end_form()}
% else:
<button type="button" id="refresh-results-button">
Refresh Results
</button>
% endif
% endif
## Execute Results
% if master.results_executable and master.has_perm('execute_multiple'):
<b-button type="is-primary"
@click="executeResults()"
icon-pack="fas"
icon-left="arrow-circle-right"
:disabled="!total">
Execute Results
</b-button>
% if use_buefy:
<b-button type="is-primary"
@click="executeResults()"
icon-pack="fas"
icon-left="arrow-circle-right"
:disabled="!total">
Execute Results
</b-button>
<b-modal has-modal-card
:active.sync="showExecutionOptions">
<div class="modal-card">
<b-modal has-modal-card
:active.sync="showExecutionOptions">
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">Execution Options</p>
</header>
<header class="modal-card-head">
<p class="modal-card-title">Execution Options</p>
</header>
<section class="modal-card-body">
<p>
Please be advised, you are about to execute {{ total }} batches!
</p>
<br />
<div class="form-wrapper">
<div class="form">
<${execute_form.component} ref="executeResultsForm"></${execute_form.component}>
</div>
</div>
</section>
<footer class="modal-card-foot">
<b-button @click="showExecutionOptions = false">
Cancel
</b-button>
<once-button type="is-primary"
@click="submitExecuteResults()"
icon-left="arrow-circle-right"
:text="'Execute ' + total + ' Batches'">
</once-button>
</footer>
<section class="modal-card-body">
<p>
Please be advised, you are about to execute {{ total }} batches!
</p>
<br />
<div class="form-wrapper">
<div class="form">
${execute_form.render_vue_tag(ref='executeResultsForm')}
</div>
</div>
</section>
</b-modal>
<footer class="modal-card-foot">
<b-button @click="showExecutionOptions = false">
Cancel
</b-button>
<once-button type="is-primary"
@click="submitExecuteResults()"
icon-left="arrow-circle-right"
:text="'Execute ' + total + ' Batches'">
</once-button>
</footer>
</div>
</b-modal>
% else:
<button type="button" id="execute-results-button">Execute Results</button>
% endif
% endif
</%def>
<%def name="render_vue_templates()">
${parent.render_vue_templates()}
% if master.results_executable and master.has_perm('execute_multiple'):
${execute_form.render_vue_template(form_kwargs={'ref': 'actualExecuteForm'}, buttons=False)}
% endif
</%def>
<%def name="modify_vue_vars()">
${parent.modify_vue_vars()}
<%def name="modify_this_page_vars()">
${parent.modify_this_page_vars()}
% if master.results_refreshable and master.has_perm('refresh'):
<script>
<script type="text/javascript">
TailboneGridData.refreshResultsButtonText = "Refresh Results"
TailboneGridData.refreshResultsButtonDisabled = false
@ -88,9 +165,9 @@
</script>
% endif
% if master.results_executable and master.has_perm('execute_multiple'):
<script>
<script type="text/javascript">
${execute_form.vue_component}.methods.submit = function() {
${execute_form.component_studly}.methods.submit = function() {
this.$refs.actualExecuteForm.submit()
}
@ -125,9 +202,46 @@
% endif
</%def>
<%def name="make_vue_components()">
${parent.make_vue_components()}
<%def name="make_this_page_component()">
${parent.make_this_page_component()}
% if master.results_executable and master.has_perm('execute_multiple'):
${execute_form.render_vue_finalize()}
<script type="text/javascript">
${execute_form.component_studly}.data = function() { return ${execute_form.component_studly}Data }
Vue.component('${execute_form.component}', ${execute_form.component_studly})
</script>
% endif
</%def>
<%def name="render_this_page_template()">
${parent.render_this_page_template()}
% if master.results_executable and master.has_perm('execute_multiple'):
${execute_form.render_deform(form_kwargs={'ref': 'actualExecuteForm'}, buttons=False)|n}
% endif
</%def>
${parent.body()}
% if not use_buefy:
## Refresh Results
% if master.results_refreshable and master.has_perm('refresh'):
${h.form(url('{}.refresh_results'.format(route_prefix)), name='refresh-results')}
${h.csrf_token(request)}
${h.end_form()}
% endif
% if master.results_executable and master.has_perm('execute_multiple'):
<div id="execution-options-dialog" style="display: none;">
<br />
<p>
Please be advised, you are about to execute multiple batches!
</p>
<br />
${execute_form.render_deform(form_kwargs={'name': 'execute-results'}, buttons=False)|n}
</div>
% endif
% endif

View file

@ -3,93 +3,298 @@
<%def name="title()">Inventory Form</%def>
<%def name="object_helpers()">
<nav class="panel">
<p class="panel-heading">Batch</p>
<div class="panel-block buttons">
<div style="display: flex; flex-direction: column;">
<%def name="extra_javascript()">
${parent.extra_javascript()}
% if not use_buefy:
${h.javascript_link(request.static_url('tailbone:static/js/numeric.js'))}
<script type="text/javascript">
<once-button type="is-primary"
icon-left="eye"
tag="a" href="${url('batch.inventory.view', uuid=batch.uuid)}"
text="View Batch">
</once-button>
% if not batch.executed and master.has_perm('edit'):
${h.form(master.get_action_url('toggle_complete', batch), **{'@submit': 'toggleCompleteSubmitting = true'})}
${h.csrf_token(request)}
${h.hidden('complete', value='true')}
<b-button type="is-primary"
native-type="submit"
icon-pack="fas"
icon-left="check"
:disabled="toggleCompleteSubmitting">
{{ toggleCompleteSubmitting ? "Working, please wait..." : "Mark Complete" }}
</b-button>
${h.end_form()}
function assert_quantity() {
% if allow_cases:
var cases = parseFloat($('#cases').val());
if (!isNaN(cases)) {
if (cases > 999999) {
alert("Case amount is invalid!");
$('#cases').select().focus();
return false;
}
return true;
}
% endif
var units = parseFloat($('#units').val());
if (!isNaN(units)) {
if (units > 999999) {
alert("Unit amount is invalid!");
$('#units').select().focus();
return false;
}
return true;
}
alert("Please provide case and/or unit quantity");
% if allow_cases:
$('#cases').select().focus();
% else:
$('#units').select().focus();
% endif
return false;
}
</div>
</div>
</nav>
function invalid_product(msg) {
$('#product-info p').text(msg);
$('#product-info img').hide();
$('#upc').focus().select();
% if allow_cases:
$('.field-wrapper.cases input').prop('disabled', true);
% endif
$('.field-wrapper.units input').prop('disabled', true);
$('.buttons button').button('disable');
}
function pretty_quantity(cases, units) {
if (cases && units) {
return cases + " cases, " + units + " units";
} else if (cases) {
return cases + " cases";
} else if (units) {
return units + " units";
}
return '';
}
function show_quantity(name, cases, units) {
var quantity = pretty_quantity(cases, units);
var field = $('.field-wrapper.quantity_' + name);
field.find('.field').text(quantity);
if (quantity || name == 'ordered') {
field.show();
} else {
field.hide();
}
}
$(function() {
$('#upc').keydown(function(event) {
if (key_allowed(event)) {
return true;
}
if (key_modifies(event)) {
$('#product').val('');
$('#product-info p').html("please ENTER a scancode");
$('#product-info img').hide();
$('#product-info .warning').hide();
$('.product-fields').hide();
// $('.receiving-fields').hide();
% if allow_cases:
$('.field-wrapper.cases input').prop('disabled', true);
% endif
$('.field-wrapper.units input').prop('disabled', true);
$('.buttons button').button('disable');
return true;
}
// when user presses ENTER, do product lookup
if (event.which == 13) {
var upc = $(this).val();
var data = {'upc': upc};
$.get('${url('batch.inventory.desktop_lookup', uuid=batch.uuid)}', data, function(data) {
if (data.error) {
alert(data.error);
if (data.redirect) {
$('#inventory-form').mask("Redirecting...");
location.href = data.redirect;
}
} else if (data.product) {
$('#upc').val(data.product.upc_pretty);
$('#product').val(data.product.uuid);
$('#brand_name').val(data.product.brand_name);
$('#description').val(data.product.description);
$('#size').val(data.product.size);
$('#case_quantity').val(data.product.case_quantity);
if (data.force_unit_item) {
$('#product-info .warning.force-unit').show();
}
if (data.already_present_in_batch) {
$('#product-info .warning.present').show();
$('#cases').val(data.cases);
$('#units').val(data.units);
} else if (data.product.type2) {
$('#units').val(data.product.units);
}
$('#product-info p').text(data.product.full_description);
$('#product-info img').attr('src', data.product.image_url).show();
if (! data.product.uuid) {
// $('#product-info .warning.notfound').show();
$('.product-fields').show();
}
$('#product-info .warning.notordered').show();
% if allow_cases:
$('.field-wrapper.cases input').prop('disabled', false);
% endif
$('.field-wrapper.units input').prop('disabled', false);
$('.buttons button').button('enable');
if (data.product.type2) {
$('#units').focus().select();
} else {
% if allow_cases and prefer_cases:
if ($('#cases').val()) {
$('#cases').focus().select();
} else if ($('#units').val()) {
$('#units').focus().select();
} else {
$('#cases').focus().select();
}
% else:
$('#units').focus().select();
% endif
}
// TODO: this is maybe useful if "new products" may be added via inventory batch
// } else if (data.upc) {
// $('#upc').val(data.upc_pretty);
// $('#product-info p').text("product not found in our system");
// $('#product-info img').attr('src', data.image_url).show();
// $('#product').val('');
// $('#brand_name').val('');
// $('#description').val('');
// $('#size').val('');
// $('#case_quantity').val('');
// $('#product-info .warning.notfound').show();
// $('.product-fields').show();
// $('#brand_name').focus();
// $('.field-wrapper.cases input').prop('disabled', false);
// $('.field-wrapper.units input').prop('disabled', false);
// $('.buttons button').button('enable');
} else {
invalid_product('product not found');
}
});
}
return false;
});
$('#inventory-form').submit(function() {
if (! assert_quantity()) {
return false;
}
disable_submit_button(this);
$(this).mask("Working...");
});
$('#upc').focus();
% if allow_cases:
$('.field-wrapper.cases input').prop('disabled', true);
% endif
$('.field-wrapper.units input').prop('disabled', true);
$('.buttons button').button('disable');
});
</script>
% endif
</%def>
<%def name="render_form_template()">
<script type="text/x-template" id="${form.component}-template">
<div class="product-info">
<%def name="extra_styles()">
${parent.extra_styles()}
% if not use_buefy:
<style type="text/css">
${h.form(form.action_url, **{'@submit': 'handleSubmit'})}
${h.csrf_token(request)}
#product-info {
margin-top: 0.5em;
text-align: center;
}
${h.hidden('product', **{':value': 'productInfo.uuid'})}
${h.hidden('upc', **{':value': 'productInfo.upc'})}
${h.hidden('brand_name', **{':value': 'productInfo.brand_name'})}
${h.hidden('description', **{':value': 'productInfo.description'})}
${h.hidden('size', **{':value': 'productInfo.size'})}
${h.hidden('case_quantity', **{':value': 'productInfo.case_quantity'})}
#product-info p {
margin-left: 0.5em;
}
<b-field label="Product UPC" horizontal>
<div style="display: flex; flex-direction: column;">
<b-input v-model="productUPC"
ref="productUPC"
@input="productChanged"
@keydown.native="productKeydown">
</b-input>
<div class="has-text-centered block">
#product-info .img-wrapper {
height: 150px;
margin: 0.5em 0;
}
<p v-if="!productInfo.uuid"
class="block">
please ENTER a scancode
</p>
#product-info .warning {
background: #f66;
display: none;
}
<p v-if="productInfo.uuid"
class="block">
{{ productInfo.full_description }}
</p>
</style>
% endif
</%def>
<div style="min-height: 150px; margin: 0.5rem 0;">
<img v-if="productInfo.uuid"
:src="productInfo.image_url" />
</div>
<%def name="context_menu_items()">
${parent.context_menu_items()}
<li>${h.link_to("Back to Inventory Batch", url('batch.inventory.view', uuid=batch.uuid))}</li>
</%def>
<div v-if="alreadyPresentInBatch"
class="has-background-danger">
product already exists in batch, please confirm count
</div>
<%def name="render_form()">
% if use_buefy:
<div v-if="forceUnitItem"
class="has-background-danger">
pack item scanned, but must count units instead
</div>
<script type="text/x-template" id="${form.component}-template">
<div class="product-info">
${h.form(form.action_url, **{'@submit': 'handleSubmit'})}
${h.csrf_token(request)}
${h.hidden('product', **{':value': 'productInfo.uuid'})}
${h.hidden('upc', **{':value': 'productInfo.upc'})}
${h.hidden('brand_name', **{':value': 'productInfo.brand_name'})}
${h.hidden('description', **{':value': 'productInfo.description'})}
${h.hidden('size', **{':value': 'productInfo.size'})}
${h.hidden('case_quantity', **{':value': 'productInfo.case_quantity'})}
<b-field label="Product UPC" horizontal>
<div style="display: flex; flex-direction: column;">
<b-input v-model="productUPC"
ref="productUPC"
@input="productChanged"
@keydown.native="productKeydown">
</b-input>
<div class="has-text-centered block">
<p v-if="!productInfo.uuid"
class="block">
please ENTER a scancode
</p>
<p v-if="productInfo.uuid"
class="block">
{{ productInfo.full_description }}
</p>
<div style="min-height: 150px; margin: 0.5rem 0;">
<img v-if="productInfo.uuid"
:src="productInfo.image_url" />
</div>
<div v-if="alreadyPresentInBatch"
class="has-background-danger">
product already exists in batch, please confirm count
</div>
<div v-if="forceUnitItem"
class="has-background-danger">
pack item scanned, but must count units instead
</div>
## <div v-if="productNotFound"
## class="has-background-danger">
## please confirm UPC and provide more details
## </div>
</div>
</div>
</b-field>
</div>
</div>
</b-field>
## <div v-if="productNotFound"
## ## class="product-fields"
@ -117,189 +322,244 @@
##
## </div>
% if allow_cases:
<b-field label="Cases" horizontal>
<b-input name="cases"
v-model="productCases"
ref="productCases"
% if allow_cases:
<b-field label="Cases" horizontal>
<b-input name="cases"
v-model="productCases"
ref="productCases"
:disabled="!productInfo.uuid">
</b-input>
</b-field>
% endif
<b-field label="Units" horizontal>
<b-input name="units"
v-model="productUnits"
ref="productUnits"
:disabled="!productInfo.uuid">
</b-input>
</b-field>
% endif
<b-field label="Units" horizontal>
<b-input name="units"
v-model="productUnits"
ref="productUnits"
:disabled="!productInfo.uuid">
</b-input>
</b-field>
<b-button type="is-primary"
native-type="submit"
:disabled="submitting">
{{ submitting ? "Working, please wait..." : "Submit" }}
</b-button>
<b-button type="is-primary"
native-type="submit"
:disabled="submitting">
{{ submitting ? "Working, please wait..." : "Submit" }}
</b-button>
${h.end_form()}
</div>
</script>
${h.end_form()}
</div>
</script>
<script type="text/javascript">
<script type="text/javascript">
let ${form.component_studly} = {
template: '#${form.component}-template',
let ${form.vue_component} = {
template: '#${form.component}-template',
mixins: [SimpleRequestMixin],
mounted() {
this.$refs.productUPC.focus()
},
methods: {
clearProduct() {
this.productInfo = {}
## this.productNotFound = false
this.alreadyPresentInBatch = false
this.forceUnitItem = false
this.productCases = null
this.productUnits = null
mounted() {
this.$refs.productUPC.focus()
},
assertQuantity() {
methods: {
% if allow_cases:
let cases = parseFloat(this.productCases)
if (!isNaN(cases)) {
if (cases > 999999) {
alert("Case amount is invalid!")
this.$refs.productCases.focus()
clearProduct() {
this.productInfo = {}
## this.productNotFound = false
this.alreadyPresentInBatch = false
this.forceUnitItem = false
this.productCases = null
this.productUnits = null
},
assertQuantity() {
% if allow_cases:
let cases = parseFloat(this.productCases)
if (!isNaN(cases)) {
if (cases > 999999) {
alert("Case amount is invalid!")
this.$refs.productCases.focus()
return false
}
return true
}
% endif
let units = parseFloat(this.productUnits)
if (!isNaN(units)) {
if (units > 999999) {
alert("Unit amount is invalid!")
this.$refs.productUnits.focus()
return false
}
return true
}
% endif
let units = parseFloat(this.productUnits)
if (!isNaN(units)) {
if (units > 999999) {
alert("Unit amount is invalid!")
alert("Please provide case and/or unit quantity")
% if allow_cases:
this.$refs.productCases.focus()
% else:
this.$refs.productUnits.focus()
return false
% endif
},
handleSubmit(event) {
if (!this.assertQuantity()) {
event.preventDefault()
return
}
return true
}
this.submitting = true
},
alert("Please provide case and/or unit quantity")
% if allow_cases:
this.$refs.productCases.focus()
% else:
this.$refs.productUnits.focus()
% endif
},
productChanged() {
this.clearProduct()
},
handleSubmit(event) {
if (!this.assertQuantity()) {
event.preventDefault()
return
}
this.submitting = true
},
productKeydown(event) {
if (event.which == 13) { // ENTER
this.productLookup()
event.preventDefault()
}
},
productChanged() {
this.clearProduct()
},
productLookup() {
let url = '${url('batch.inventory.desktop_lookup', uuid=batch.uuid)}'
let params = {
upc: this.productUPC,
}
this.$http.get(url, {params: params}).then(response => {
productKeydown(event) {
if (event.which == 13) { // ENTER
this.productLookup()
event.preventDefault()
}
},
productLookup() {
let url = '${url('batch.inventory.desktop_lookup', uuid=batch.uuid)}'
let params = {
upc: this.productUPC,
}
this.simpleGET(url, params, response => {
if (response.data.product.uuid) {
this.productUPC = response.data.product.upc_pretty
this.productInfo = response.data.product
this.forceUnitItem = response.data.force_unit_item
this.alreadyPresentInBatch = response.data.already_present_in_batch
if (this.alreadyPresentInBatch) {
this.productCases = response.data.cases
this.productUnits = response.data.units
} else if (this.productInfo.type2) {
this.productUnits = this.productInfo.units
}
this.$nextTick(() => {
if (this.productInfo.type2) {
this.$refs.productUnits.focus()
} else {
% if allow_cases and prefer_cases:
if (this.productCases) {
this.$refs.productCases.focus()
} else if (this.productUnits) {
this.$refs.productUnits.focus()
} else {
this.$refs.productCases.focus()
}
% else:
this.$refs.productUnits.focus()
% endif
if (response.data.error) {
alert(response.data.error)
if (response.data.redirect) {
location.href = response.data.redirect
}
})
} else {
## this.productNotFound = true
alert("Product not found!")
} else if (response.data.product.uuid) {
// focus/select UPC entry
this.$refs.productUPC.focus()
// nb. must traverse into the <b-input> element
this.$refs.productUPC.$el.firstChild.select()
}
this.productUPC = response.data.product.upc_pretty
this.productInfo = response.data.product
this.forceUnitItem = response.data.force_unit_item
this.alreadyPresentInBatch = response.data.already_present_in_batch
}, response => {
if (response.data.error) {
alert(response.data.error)
if (response.data.redirect) {
location.href = response.data.redirect
if (this.alreadyPresentInBatch) {
this.productCases = response.data.cases
this.productUnits = response.data.units
} else if (this.productInfo.type2) {
this.productUnits = this.productInfo.units
}
this.$nextTick(() => {
if (this.productInfo.type2) {
this.$refs.productUnits.focus()
} else {
% if allow_cases and prefer_cases:
if (this.productCases) {
this.$refs.productCases.focus()
} else if (this.productUnits) {
this.$refs.productUnits.focus()
} else {
this.$refs.productCases.focus()
}
% else:
this.$refs.productUnits.focus()
% endif
}
})
} else {
## this.productNotFound = true
alert("Product not found!")
}
}
})
})
},
},
},
}
}
let ${form.vue_component}Data = {
submitting: false,
let ${form.component_studly}Data = {
submitting: false,
productUPC: null,
## productNotFound: false,
productInfo: {},
productUPC: null,
## productNotFound: false,
productInfo: {},
% if allow_cases:
productCases: null,
% endif
productUnits: null,
alreadyPresentInBatch: false,
forceUnitItem: false,
}
</script>
% else:
## not buefy
<div class="form-wrapper">
${h.form(form.action_url, id='inventory-form')}
${h.csrf_token(request)}
<div class="field-wrapper">
<label for="upc">Product UPC</label>
<div class="field">
${h.hidden('product')}
<div>${h.text('upc', autocomplete='off')}</div>
<div id="product-info">
<p>please ENTER a scancode</p>
<div class="img-wrapper"><img /></div>
<div class="warning notfound">please confirm UPC and provide more details</div>
<div class="warning present">product already exists in batch, please confirm count</div>
<div class="warning force-unit">pack item scanned, but must count units instead</div>
</div>
</div>
</div>
<div class="product-fields" style="display: none;">
<div class="field-wrapper brand_name">
<label for="brand_name">Brand Name</label>
<div class="field">${h.text('brand_name')}</div>
</div>
<div class="field-wrapper description">
<label for="description">Description</label>
<div class="field">${h.text('description')}</div>
</div>
<div class="field-wrapper size">
<label for="size">Size</label>
<div class="field">${h.text('size')}</div>
</div>
<div class="field-wrapper case_quantity">
<label for="case_quantity">Units in Case</label>
<div class="field">${h.text('case_quantity')}</div>
</div>
</div>
% if allow_cases:
productCases: null,
<div class="field-wrapper cases">
<label for="cases">Cases</label>
<div class="field">${h.text('cases', autocomplete='off')}</div>
</div>
% endif
productUnits: null,
alreadyPresentInBatch: false,
forceUnitItem: false,
}
<div class="field-wrapper units">
<label for="units">Units</label>
<div class="field">${h.text('units', autocomplete='off')}</div>
</div>
</script>
<div class="buttons">
${h.submit('submit', "Submit")}
</div>
${h.end_form()}
</div>
% endif
</%def>
<%def name="modify_vue_vars()">
${parent.modify_vue_vars()}
<script>
ThisPageData.toggleCompleteSubmitting = false
</script>
</%def>
${parent.body()}

View file

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

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