diff --git a/.gitignore b/.gitignore index 906dc226..b3006f90 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,8 @@ +*~ +*.pyc .coverage .tox/ +dist/ docs/_build/ htmlcov/ Tailbone.egg-info/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..c974b3a6 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,683 @@ + +# Changelog +All notable changes to Tailbone will be documented in this file. + +The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) +and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). + +## v0.22.7 (2025-02-19) + +### Fix + +- stop using old config for logo image url on login page +- fix warning msg for deprecated Grid param + +## v0.22.6 (2025-02-01) + +### Fix + +- register vue3 form component for products -> make batch + +## v0.22.5 (2024-12-16) + +### Fix + +- whoops this is latest rattail +- require newer rattail lib +- require newer wuttaweb +- let caller request safe HTML literal for rendered grid table + +## v0.22.4 (2024-11-22) + +### Fix + +- avoid error in product search for duplicated key +- use vmodel for confirm password widget input + +## v0.22.3 (2024-11-19) + +### Fix + +- avoid error for trainwreck query when not a customer + +## v0.22.2 (2024-11-18) + +### Fix + +- use local/custom enum for continuum operations +- add basic master view for Product Costs +- show continuum operation type when viewing version history +- always define `app` attr for ViewSupplement +- avoid deprecated import + +## v0.22.1 (2024-11-02) + +### Fix + +- fix submit button for running problem report +- avoid deprecated grid method + +## v0.22.0 (2024-10-22) + +### Feat + +- add support for new ordering batch from parsed file + +### Fix + +- avoid deprecated method to suggest username + +## v0.21.11 (2024-10-03) + +### Fix + +- custom method for adding grid action +- become/stop root should redirect to previous url + +## v0.21.10 (2024-09-15) + +### Fix + +- update project repo links, kallithea -> forgejo +- use better icon for submit button on login page +- wrap notes text for batch view +- expose datasync consumer batch size via configure page + +## v0.21.9 (2024-08-28) + +### Fix + +- render custom attrs in form component tag + +## v0.21.8 (2024-08-28) + +### Fix + +- ignore session kwarg for `MasterView.make_row_grid()` + +## v0.21.7 (2024-08-28) + +### Fix + +- avoid error when form value cannot be obtained + +## v0.21.6 (2024-08-28) + +### Fix + +- avoid error when grid value cannot be obtained + +## v0.21.5 (2024-08-28) + +### Fix + +- set empty string for "-new-" file configure option + +## v0.21.4 (2024-08-26) + +### Fix + +- handle differing email profile keys for appinfo/configure + +## v0.21.3 (2024-08-26) + +### Fix + +- show non-standard config values for app info configure email + +## v0.21.2 (2024-08-26) + +### Fix + +- refactor waterpark base template to use wutta feedback component +- fix input/output file upload feature for configure pages, per oruga +- tweak how grid data translates to Vue template context +- merge filters into main grid template +- add basic wutta view for users +- some fixes for wutta people view +- various fixes for waterpark theme +- avoid deprecated `component` form kwarg + +## v0.21.1 (2024-08-22) + +### Fix + +- misc. bugfixes per recent changes + +## v0.21.0 (2024-08-22) + +### Feat + +- move "most" filtering logic for grid class to wuttaweb +- inherit from wuttaweb templates for home, login pages +- inherit from wuttaweb for AppInfoView, appinfo/configure template +- add "has output file templates" config option for master view + +### Fix + +- change grid reset-view param name to match wuttaweb +- move "searchable columns" grid feature to wuttaweb +- use wuttaweb to get/render csrf token +- inherit from wuttaweb for appinfo/index template +- prefer wuttaweb config for "home redirect to login" feature +- fix master/index template rendering for waterpark theme +- fix spacing for navbar logo/title in waterpark theme + +## v0.20.1 (2024-08-20) + +### Fix + +- fix default filter verbs logic for workorder status + +## v0.20.0 (2024-08-20) + +### Feat + +- add new 'waterpark' theme, based on wuttaweb w/ vue2 + buefy +- refactor templates to simplify base/page/form structure + +### Fix + +- avoid deprecated reference to app db engine + +## v0.19.3 (2024-08-19) + +### Fix + +- add pager stats to all grid vue data (fixes view history) + +## v0.19.2 (2024-08-19) + +### Fix + +- sort on frontend for appinfo package listing grid +- prefer attr over key lookup when getting model values +- replace all occurrences of `component_studly` => `vue_component` + +## v0.19.1 (2024-08-19) + +### Fix + +- fix broken user auth for web API app + +## v0.19.0 (2024-08-18) + +### Feat + +- move multi-column grid sorting logic to wuttaweb +- move single-column grid sorting logic to wuttaweb + +### Fix + +- fix misc. errors in grid template per wuttaweb +- fix broken permission directives in web api startup + +## v0.18.0 (2024-08-16) + +### Feat + +- move "basic" grid pagination logic to wuttaweb +- inherit from wutta base class for Grid +- inherit most logic from wuttaweb, for GridAction + +### Fix + +- avoid route error in user view, when using wutta people view +- fix some more wutta compat for base template + +## v0.17.0 (2024-08-15) + +### Feat + +- use wuttaweb for `get_liburl()` logic + +## v0.16.1 (2024-08-15) + +### Fix + +- improve wutta People view a bit +- update references to `get_class_hierarchy()` +- tweak template for `people/view_profile` per wutta compat + +## v0.16.0 (2024-08-15) + +### Feat + +- add first wutta-based master, for PersonView +- refactor forms/grids/views/templates per wuttaweb compat + +## v0.15.6 (2024-08-13) + +### Fix + +- avoid `before_render` subscriber hook for web API +- simplify verbiage for batch execution panel + +## v0.15.5 (2024-08-09) + +### Fix + +- assign convenience attrs for all views (config, app, enum, model) + +## v0.15.4 (2024-08-09) + +### Fix + +- avoid bug when checking current theme + +## v0.15.3 (2024-08-08) + +### Fix + +- fix timepicker `parseTime()` when value is null + +## v0.15.2 (2024-08-06) + +### Fix + +- use auth handler, avoid legacy calls for role/perm checks + +## v0.15.1 (2024-08-05) + +### Fix + +- move magic `b` template context var to wuttaweb + +## v0.15.0 (2024-08-05) + +### Feat + +- move more subscriber logic to wuttaweb + +### Fix + +- use wuttaweb logic for `util.get_form_data()` + +## v0.14.5 (2024-08-03) + +### Fix + +- use auth handler instead of deprecated auth functions +- avoid duplicate `partial` param when grid reloads data + +## v0.14.4 (2024-07-18) + +### Fix + +- fix more settings persistence bug(s) for datasync/configure +- fix modals for luigi tasks page, per oruga + +## v0.14.3 (2024-07-17) + +### Fix + +- fix auto-collapse title for viewing trainwreck txn +- allow auto-collapse of header when viewing trainwreck txn + +## v0.14.2 (2024-07-15) + +### Fix + +- add null menu handler, for use with API apps + +## v0.14.1 (2024-07-14) + +### Fix + +- update usage of auth handler, per rattail changes +- fix model reference in menu handler +- fix bug when making "integration" menus + +## v0.14.0 (2024-07-14) + +### Feat + +- move core menu logic to wuttaweb + +## v0.13.2 (2024-07-13) + +### Fix + +- fix logic bug for datasync/config settings save + +## v0.13.1 (2024-07-13) + +### Fix + +- fix settings persistence bug(s) for datasync/configure page + +## v0.13.0 (2024-07-12) + +### Feat + +- begin integrating WuttaWeb as upstream dependency + +### Fix + +- cast enum as list to satisfy deform widget + +## v0.12.1 (2024-07-11) + +### Fix + +- refactor `config.get_model()` => `app.model` + +## v0.12.0 (2024-07-09) + +### Feat + +- drop python 3.6 support, use pyproject.toml (again) + +## v0.11.10 (2024-07-05) + +### Fix + +- make the Members tab optional, for profile view + +## v0.11.9 (2024-07-05) + +### Fix + +- do not show flash message when changing app theme + +- improve collapse panels for butterball theme + +- expand input for butterball theme + +- add xref button to customer profile, for trainwreck txn view + +- add optional Transactions tab for profile view + +## v0.11.8 (2024-07-04) + +### Fix + +- fix grid action icons for datasync/configure, per oruga + +- allow view supplements to add extra links for profile employee tab + +- leverage import handler method to determine command/subcommand + +- add tool to make user account from profile view + +## v0.11.7 (2024-07-04) + +### Fix + +- add stacklevel to deprecation warnings + +- require zope.sqlalchemy >= 1.5 + +- include edit profile email/phone dialogs only if user has perms + +- allow view supplements to add to profile member context + +- cast enum as list to satisfy deform widget + +- expand POD image URL setting input + +## v0.11.6 (2024-07-01) + +### Fix + +- set explicit referrer when changing dbkey + +- remove references, dependency for `six` package + +## v0.11.5 (2024-06-30) + +### Fix + +- allow comma in numeric filter input + +- add custom url prefix if needed, for fanstatic + +- use vue 3.4.31 and oruga 0.8.12 by default + +## v0.11.4 (2024-06-30) + +### Fix + +- start/stop being root should submit POST instead of GET + +- require vendor when making new ordering batch via api + +- don't escape each address for email attempts grid + +## v0.11.3 (2024-06-28) + +### Fix + +- add link to "resolved by" user for pending products + +- handle error when merging 2 records fails + +## v0.11.2 (2024-06-18) + +### Fix + +- hide certain custorder settings if not applicable + +- use different logic for buefy/oruga for product lookup keydown + +- product records should be touchable + +- show flash error message if resolve pending product fails + +## v0.11.1 (2024-06-14) + +### Fix + +- revert back to setup.py + setup.cfg + +## v0.11.0 (2024-06-10) + +### Feat + +- switch from setup.cfg to pyproject.toml + hatchling + +## v0.10.16 (2024-06-10) + +### Feat + +- standardize how app, package versions are determined + +### Fix + +- avoid deprecated config methods for app/node title + +## v0.10.15 (2024-06-07) + +### Fix + +- do *not* Use `pkg_resources` to determine package versions + +## v0.10.14 (2024-06-06) + +### Fix + +- use `pkg_resources` to determine package versions + +## v0.10.13 (2024-06-06) + +### Feat + +- remove old/unused scaffold for use with `pcreate` + +- add 'fanstatic' support for sake of libcache assets + +## v0.10.12 (2024-06-04) + +### Feat + +- require pyramid 2.x; remove 1.x-style auth policies + +- remove version cap for deform + +- set explicit referrer when changing app theme + +- add `<b-tooltip>` component shim + +- include extra styles from `base_meta` template for butterball + +- include butterball theme by default for new apps + +### Fix + +- fix product lookup component, per butterball + +## v0.10.11 (2024-06-03) + +### Feat + +- fix vue3 refresh bugs for various views + +- fix grid bug for tempmon appliance view, per oruga + +- fix ordering worksheet generator, per butterball + +- fix inventory worksheet generator, per butterball + +## v0.10.10 (2024-06-03) + +### Feat + +- more butterball fixes for "view profile" template + +### Fix + +- fix focus for `<b-select>` shim component + +## v0.10.9 (2024-06-03) + +### Feat + +- let master view control context menu items for page + +- fix the "new custorder" page for butterball + +### Fix + +- fix panel style for PO vs. Invoice breakdown in receiving batch + +## v0.10.8 (2024-06-02) + +### Feat + +- add styling for checked grid rows, per oruga/butterball + +- fix product view template for oruga/butterball + +- allow per-user custom styles for butterball + +- use oruga 0.8.9 by default + +## v0.10.7 (2024-06-01) + +### Feat + +- add setting to allow decimal quantities for receiving + +- log error if registry has no rattail config + +- add column filters for import/export main grid + +- escape all unsafe html for grid data + +- add speedbumps for delete, set preferred email/phone in profile view + +- fix file upload widget for oruga + +### Fix + +- fix overflow when instance header title is too long (butterball) + +## v0.10.6 (2024-05-29) + +### Feat + +- add way to flag organic products within lookup dialog + +- expose db picker for butterball theme + +- expose quickie lookup for butterball theme + +- fix basic problems with people profile view, per butterball + +## v0.10.5 (2024-05-29) + +### Feat + +- add `<tailbone-timepicker>` component for oruga + +## v0.10.4 (2024-05-12) + +### Fix + +- fix styles for grid actions, per butterball + +## v0.10.3 (2024-05-10) + +### Fix + +- fix bug with grid date filters + +## v0.10.2 (2024-05-08) + +### Feat + +- remove version restriction for pyramid_beaker dependency + +- rename some attrs etc. for buefy components used with oruga + +- fix "tools" helper for receiving batch view, per oruga + +- more data type fixes for ``<tailbone-datepicker>`` + +- fix "view receiving row" page, per oruga + +- tweak styles for grid action links, per butterball + +### Fix + +- fix employees grid when viewing department (per oruga) + +- fix login "enter" key behavior, per oruga + +- fix button text for autocomplete + +## v0.10.1 (2024-04-28) + +### Feat + +- sort list of available themes + +- update various icon names for oruga compatibility + +- show "View This" button when cloning a record + +- stop including 'falafel' as available theme + +### Fix + +- fix vertical alignment in main menu bar, for butterball + +- fix upgrade execution logic/UI per oruga + +## v0.10.0 (2024-04-28) + +This version bump is to reflect adding support for Vue 3 + Oruga via +the 'butterball' theme. There is likely more work to be done for that +yet, but it mostly works at this point. + +### Feat + +- misc. template and view logic tweaks (applicable to all themes) for + better patterns, consistency etc. + +- add initial support for Vue 3 + Oruga, via "butterball" theme + + +## Older Releases + +Please see `docs/OLDCHANGES.rst` for older release notes. diff --git a/README.rst b/README.md similarity index 56% rename from README.rst rename to README.md index 0cffc62d..74c007f6 100644 --- a/README.rst +++ b/README.md @@ -1,10 +1,8 @@ -Tailbone -======== +# Tailbone Tailbone is an extensible web application based on Rattail. It provides a "back-office network environment" (BONE) for use in managing retail data. -Please see Rattail's `home page`_ for more information. - -.. _home page: http://rattailproject.org/ +Please see Rattail's [home page](http://rattailproject.org/) for more +information. diff --git a/CHANGES.rst b/docs/OLDCHANGES.rst similarity index 85% rename from CHANGES.rst rename to docs/OLDCHANGES.rst index ebba8d69..0a802f40 100644 --- a/CHANGES.rst +++ b/docs/OLDCHANGES.rst @@ -2,6 +2,1167 @@ 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) +-------------------- + +* Fix checkbox behavior for Inventory Worksheet. + +* Form constructor assumes ``use_buefy=True`` by default. + + +0.8.290 (2023-02-02) +-------------------- + +* Remove support for Buefy 0.8. + +* Add progress bar page for Buefy theme. + + +0.8.289 (2023-01-30) +-------------------- + +* Fix icon for multi-file upload widget. + +* Tweak customer panel header style for new custorder. + +* Add basic API support for printing product labels. + +* Tweak the Ordering Worksheet generator, per Buefy. + +* Refactor the Inventory Worksheet generator, per Buefy. + + +0.8.288 (2023-01-28) +-------------------- + +* Tweak import handler form, some fields not required. + +* Tweak styles for Quantity panel when viewing Receiving row. + + +0.8.287 (2023-01-26) +-------------------- + +* Fix click event for right-aligned buttons on profile view. + + +0.8.286 (2023-01-18) +-------------------- + +* Add some more menu items to default set. + +* Add default view config for Trainwreck. + +* Rename frontend request handler logic to ``SimpleRequestMixin``. + + +0.8.285 (2023-01-18) +-------------------- + +* Misc. tweaks for App Details / Configure Menus. + +* Add specific data type options for new table entry form. + +* Add more views, menus to default set. + +* Add way to override particular 'essential' views. + + +0.8.284 (2023-01-15) +-------------------- + +* Let the API "rawbytes" response be just that, w/ no file. + +* Fix bug when adding new profile via datasync configure. + +* Add default logic to get merge data for object. + +* Add new handlers, TailboneHandler and MenuHandler. + +* Add full set of default menus. + +* Wrap up steps for new table wizard. + +* Add basic "new model view" wizard. + + +0.8.283 (2023-01-14) +-------------------- + +* Tweak how backfill task is launched. + + +0.8.282 (2023-01-13) +-------------------- + +* Show basic column info as row grid when viewing Table. + +* Semi-finish logic for writing new table model class to file. + +* Fix "toggle batch complete" for Chrome browser. + +* Revert logic that assumes all themes use buefy. + +* Refactor tempmon dashboard view, for buefy themes. + +* Prevent listing for top-level Messages view. + + +0.8.281 (2023-01-12) +-------------------- + +* Add new views for App Info, and Configure App. + + +0.8.280 (2023-01-11) +-------------------- + +* Allow all external dependency URLs to be set in config. + + +0.8.279 (2023-01-11) +-------------------- + +* Add basic support for receiving from multiple invoice files. + +* Add support for per-item default discount, for new custorder. + +* Fix panel header icon behavior for new custorder. + +* Refactor inventory batch "add row" page, per new theme. + + +0.8.278 (2023-01-08) +-------------------- + +* Improve "download rows as XLSX" for importer batch. + + +0.8.277 (2023-01-07) +-------------------- + +* Expose, start to honor "units only" setting for products. + + +0.8.276 (2023-01-05) +-------------------- + +* Keep aspect ratio for product images in new custorder. + +* Fix template bug for generating report. + +* Show help link when generating or viewing report, if applicable. + +* Use product handler to normalize data for products API. + + +0.8.275 (2023-01-04) +-------------------- + +* Allow xref buttons to have "internal" links. + + +0.8.274 (2023-01-02) +-------------------- + +* Show only "core" app settings by default. + +* Allow buefy version to be 'latest'. + +* Add beginnings of "New Table" feature. + +* Make invalid email more obvious, in profile view. + +* Expose some settings for Trainwreck DB rotation. + + +0.8.273 (2022-12-28) +-------------------- + +* Add support for Buefy 0.9.x. + +* Warn user when luigi is not installed, for relevant view. + +* Fix HUD display when toggling employee status in profile view. + +* Fix checkbox values when re-running a report. + +* Make static files optional, for new tailbone-integration project. + +* Preserve current tab for page reload in profile view. + +* Add cleanup logic for old Beaker session data. + +* Add basic support for editing help info for page, fields. + +* Override document title when upgrading. + +* Filter by person instead of user, for Generated Reports "Created by". + +* Add "direct link" support for master grids. + +* Add support for websockets over HTTP. + +* Fix product image view for python3. + +* Add "global searchbox" for quicker access to main views. + +* Use minified version of vue.js by default, in falafel theme. + + +0.8.272 (2022-12-21) +-------------------- + +* Add support for "is row checkable" in grids. + +* Add ``make_status_renderer()`` to MasterView. + +* Expose the ``terms`` field for Vendor CRUD. + + +0.8.271 (2022-12-15) +-------------------- + +* Add ``configure_execute_form()`` hook for batch views. + + +0.8.270 (2022-12-10) +-------------------- + +* Fix error if no view supplements defined. + + +0.8.269 (2022-12-10) +-------------------- + +* Show simple error string, when subprocess batch actions fail. + +* Fix ordering worksheet API for date objects. + +* Add the ViewSupplement concept. + +* Cleanup employees view per new supplements. + +* Add common logic for xref buttons, links when viewing object. + +* Add common logic to determine panel fields for product view. + +* Add xref buttons for Customer, Member tabs in profile view. + +* Suppress error if menu entry has bad route name. + + +0.8.268 (2022-12-07) +-------------------- + +* Add support for Beaker >= 1.12.0. + + 0.8.267 (2022-12-06) -------------------- @@ -3831,7 +4992,7 @@ and related technologies. 0.6.47 (2017-11-08) ------------------- -* Fix manifest to include *.pt deform templates +* Fix manifest to include ``*.pt`` deform templates 0.6.46 (2017-11-08) @@ -4164,13 +5325,13 @@ and related technologies. 0.6.13 (2017-07-26) ------------------- +------------------- * Allow master view to decide whether each grid checkbox is checked 0.6.12 (2017-07-26) ------------------- +------------------- * Add basic support for product inventory and status @@ -4178,7 +5339,7 @@ and related technologies. 0.6.11 (2017-07-18) ------------------- +------------------- * Tweak some basic styles for forms/grids @@ -4186,7 +5347,7 @@ and related technologies. 0.6.10 (2017-07-18) ------------------- +------------------- * Fix grid bug if "current page" becomes invalid diff --git a/docs/api/db.rst b/docs/api/db.rst new file mode 100644 index 00000000..ace21b68 --- /dev/null +++ b/docs/api/db.rst @@ -0,0 +1,6 @@ + +``tailbone.db`` +=============== + +.. automodule:: tailbone.db + :members: diff --git a/docs/api/diffs.rst b/docs/api/diffs.rst new file mode 100644 index 00000000..fb1bba71 --- /dev/null +++ b/docs/api/diffs.rst @@ -0,0 +1,6 @@ + +``tailbone.diffs`` +================== + +.. automodule:: tailbone.diffs + :members: diff --git a/docs/api/forms.widgets.rst b/docs/api/forms.widgets.rst new file mode 100644 index 00000000..33316903 --- /dev/null +++ b/docs/api/forms.widgets.rst @@ -0,0 +1,6 @@ + +``tailbone.forms.widgets`` +========================== + +.. automodule:: tailbone.forms.widgets + :members: diff --git a/docs/api/subscribers.rst b/docs/api/subscribers.rst index 8b25c994..d28a1b15 100644 --- a/docs/api/subscribers.rst +++ b/docs/api/subscribers.rst @@ -3,5 +3,4 @@ ======================== .. automodule:: tailbone.subscribers - -.. autofunction:: new_request + :members: diff --git a/docs/api/util.rst b/docs/api/util.rst new file mode 100644 index 00000000..35e66ed3 --- /dev/null +++ b/docs/api/util.rst @@ -0,0 +1,6 @@ + +``tailbone.util`` +================= + +.. automodule:: tailbone.util + :members: diff --git a/docs/api/views/master.rst b/docs/api/views/master.rst index bf505b6c..e7de7170 100644 --- a/docs/api/views/master.rst +++ b/docs/api/views/master.rst @@ -81,6 +81,12 @@ override when defining your subclass. override this for certain views, if so that should be done within :meth:`get_help_url()`. + .. attribute:: MasterView.version_diff_factory + + Optional factory to use for version diff objects. By default + this is *not set* but a subclass is free to set it. See also + :meth:`get_version_diff_factory()`. + Methods to Override ------------------- @@ -88,6 +94,8 @@ 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 @@ -95,3 +103,24 @@ 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 diff --git a/docs/api/views/members.rst b/docs/api/views/members.rst new file mode 100644 index 00000000..6a9e9168 --- /dev/null +++ b/docs/api/views/members.rst @@ -0,0 +1,6 @@ + +``tailbone.views.members`` +========================== + +.. automodule:: tailbone.views.members + :members: diff --git a/docs/changelog.rst b/docs/changelog.rst new file mode 100644 index 00000000..bbf94f4b --- /dev/null +++ b/docs/changelog.rst @@ -0,0 +1,8 @@ + +Changelog Archive +================= + +.. toctree:: + :maxdepth: 1 + + OLDCHANGES diff --git a/docs/conf.py b/docs/conf.py index 505396ed..ade4c92a 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,38 +1,21 @@ -# -*- coding: utf-8; -*- +# Configuration file for the Sphinx documentation builder. # -# Tailbone documentation build configuration file, created by -# sphinx-quickstart on Sat Feb 15 23:15:27 2014. -# -# This file is exec()d with the current directory set to its -# containing dir. -# -# Note that not all possible configuration values are present in this -# autogenerated file. -# -# All configuration values have a default; values that are commented out -# serve to show the default. +# For the full list of built-in configuration values, see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html -import sys -import os +# -- Project information ----------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information -import sphinx_rtd_theme +from importlib.metadata import version as get_version -exec(open(os.path.join(os.pardir, 'tailbone', '_version.py')).read()) +project = 'Tailbone' +copyright = '2010 - 2024, Lance Edgar' +author = 'Lance Edgar' +release = get_version('Tailbone') +# -- General configuration --------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration -# If extensions (or modules to document with autodoc) are in another directory, -# add these directories to sys.path here. If the directory is relative to the -# documentation root, use os.path.abspath to make it absolute, like shown here. -#sys.path.insert(0, os.path.abspath('.')) - -# -- General configuration ------------------------------------------------ - -# If your documentation needs a minimal Sphinx version, state it here. -#needs_sphinx = '1.0' - -# Add any Sphinx extension module names here, as strings. They can be -# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom -# ones. extensions = [ 'sphinx.ext.autodoc', 'sphinx.ext.todo', @@ -40,241 +23,30 @@ extensions = [ 'sphinx.ext.viewcode', ] +templates_path = ['_templates'] +exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] + intersphinx_mapping = { - 'rattail': ('https://rattailproject.org/docs/rattail/', None), + 'rattail': ('https://docs.wuttaproject.org/rattail/', None), 'webhelpers2': ('https://webhelpers2.readthedocs.io/en/latest/', None), + 'wuttaweb': ('https://docs.wuttaproject.org/wuttaweb/', None), + 'wuttjamaican': ('https://docs.wuttaproject.org/wuttjamaican/', None), } -# Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] - -# The suffix of source filenames. -source_suffix = '.rst' - -# The encoding of source files. -#source_encoding = 'utf-8-sig' - -# The master toctree document. -master_doc = 'index' - -# General information about the project. -project = u'Tailbone' -copyright = u'2010 - 2020, Lance Edgar' - -# The version info for the project you're documenting, acts as replacement for -# |version| and |release|, also used in various other places throughout the -# built documents. -# -# The short X.Y version. -# version = '0.3' -version = '.'.join(__version__.split('.')[:2]) -# The full version, including alpha/beta/rc tags. -release = __version__ - -# The language for content autogenerated by Sphinx. Refer to documentation -# for a list of supported languages. -#language = None - -# There are two options for replacing |today|: either, you set today to some -# non-false value, then it is used: -#today = '' -# Else, today_fmt is used as the format for a strftime call. -#today_fmt = '%B %d, %Y' - -# List of patterns, relative to source directory, that match files and -# directories to ignore when looking for source files. -exclude_patterns = ['_build'] - -# The reST default role (used for this markup: `text`) to use for all -# documents. -#default_role = None - -# If true, '()' will be appended to :func: etc. cross-reference text. -#add_function_parentheses = True - -# If true, the current module name will be prepended to all description -# unit titles (such as .. function::). -#add_module_names = True - -# If true, sectionauthor and moduleauthor directives will be shown in the -# output. They are ignored by default. -#show_authors = False - -# The name of the Pygments (syntax highlighting) style to use. -pygments_style = 'sphinx' - -# A list of ignored prefixes for module index sorting. -#modindex_common_prefix = [] - -# If true, keep warnings as "system message" paragraphs in the built documents. -#keep_warnings = False - -# Allow todo entries to show up. +# allow todo entries to show up todo_include_todos = True -# -- Options for HTML output ---------------------------------------------- +# -- Options for HTML output ------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output -# The theme to use for HTML and HTML Help pages. See the documentation for -# a list of builtin themes. -# html_theme = 'classic' -html_theme = 'sphinx_rtd_theme' - -# Theme options are theme-specific and customize the look and feel of a theme -# further. For a list of options available for each theme, see the -# documentation. -#html_theme_options = {} - -# Add any paths that contain custom themes here, relative to this directory. -#html_theme_path = [] -html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] - -# The name for this set of Sphinx documents. If None, it defaults to -# "<project> v<release> documentation". -#html_title = None - -# A shorter title for the navigation bar. Default is the same as html_title. -#html_short_title = None +html_theme = 'furo' +html_static_path = ['_static'] # The name of an image file (relative to this directory) to place at the top # of the sidebar. #html_logo = None -html_logo = 'images/rattail_avatar.png' - -# The name of an image file (within the static path) to use as favicon of the -# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 -# pixels large. -#html_favicon = None - -# Add any paths that contain custom static files (such as style sheets) here, -# relative to this directory. They are copied after the builtin static files, -# so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['_static'] - -# Add any extra paths that contain custom files (such as robots.txt or -# .htaccess) here, relative to this directory. These files are copied -# directly to the root of the documentation. -#html_extra_path = [] - -# If not '', a 'Last updated on:' timestamp is inserted at every page bottom, -# using the given strftime format. -#html_last_updated_fmt = '%b %d, %Y' - -# If true, SmartyPants will be used to convert quotes and dashes to -# typographically correct entities. -#html_use_smartypants = True - -# Custom sidebar templates, maps document names to template names. -#html_sidebars = {} - -# Additional templates that should be rendered to pages, maps page names to -# template names. -#html_additional_pages = {} - -# If false, no module index is generated. -#html_domain_indices = True - -# If false, no index is generated. -#html_use_index = True - -# If true, the index is split into individual pages for each letter. -#html_split_index = False - -# If true, links to the reST sources are added to the pages. -#html_show_sourcelink = True - -# If true, "Created using Sphinx" is shown in the HTML footer. Default is True. -#html_show_sphinx = True - -# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. -#html_show_copyright = True - -# If true, an OpenSearch description file will be output, and all pages will -# contain a <link> tag referring to it. The value of this option must be the -# base URL from which the finished HTML is served. -#html_use_opensearch = '' - -# This is the file name suffix for HTML files (e.g. ".xhtml"). -#html_file_suffix = None +#html_logo = 'images/rattail_avatar.png' # Output file base name for HTML help builder. -htmlhelp_basename = 'Tailbonedoc' - - -# -- Options for LaTeX output --------------------------------------------- - -latex_elements = { -# The paper size ('letterpaper' or 'a4paper'). -#'papersize': 'letterpaper', - -# The font size ('10pt', '11pt' or '12pt'). -#'pointsize': '10pt', - -# Additional stuff for the LaTeX preamble. -#'preamble': '', -} - -# Grouping the document tree into LaTeX files. List of tuples -# (source start file, target name, title, -# author, documentclass [howto, manual, or own class]). -latex_documents = [ - ('index', 'Tailbone.tex', u'Tailbone Documentation', - u'Lance Edgar', 'manual'), -] - -# The name of an image file (relative to this directory) to place at the top of -# the title page. -#latex_logo = None - -# For "manual" documents, if this is true, then toplevel headings are parts, -# not chapters. -#latex_use_parts = False - -# If true, show page references after internal links. -#latex_show_pagerefs = False - -# If true, show URL addresses after external links. -#latex_show_urls = False - -# Documents to append as an appendix to all manuals. -#latex_appendices = [] - -# If false, no module index is generated. -#latex_domain_indices = True - - -# -- Options for manual page output --------------------------------------- - -# One entry per manual page. List of tuples -# (source start file, name, description, authors, manual section). -man_pages = [ - ('index', 'tailbone', u'Tailbone Documentation', - [u'Lance Edgar'], 1) -] - -# If true, show URL addresses after external links. -#man_show_urls = False - - -# -- Options for Texinfo output ------------------------------------------- - -# Grouping the document tree into Texinfo files. List of tuples -# (source start file, target name, title, author, -# dir menu entry, description, category) -texinfo_documents = [ - ('index', 'Tailbone', u'Tailbone Documentation', - u'Lance Edgar', 'Tailbone', 'One line description of project.', - 'Miscellaneous'), -] - -# Documents to append as an appendix to all manuals. -#texinfo_appendices = [] - -# If false, no module index is generated. -#texinfo_domain_indices = True - -# How to display URL addresses: 'footnote', 'no', or 'inline'. -#texinfo_show_urls = 'footnote' - -# If true, do not generate a @detailmenu in the "Top" node's menu. -#texinfo_no_detailmenu = False +#htmlhelp_basename = 'Tailbonedoc' diff --git a/docs/index.rst b/docs/index.rst index b19d859f..d964086f 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -44,19 +44,32 @@ Package API: api/api/batch/core api/api/batch/ordering + api/db + api/diffs api/forms + api/forms.widgets api/grids api/grids.core api/progress api/subscribers + api/util api/views/batch api/views/batch.vendorcatalog api/views/core api/views/master + api/views/members api/views/purchasing.batch api/views/purchasing.ordering +Changelog: + +.. toctree:: + :maxdepth: 1 + + changelog + + Documentation To-Do =================== diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..a7214a8e --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,103 @@ + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + + +[project] +name = "Tailbone" +version = "0.22.7" +description = "Backoffice Web Application for Rattail" +readme = "README.md" +authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] +license = {text = "GNU GPL v3+"} +classifiers = [ + "Development Status :: 4 - Beta", + "Environment :: Web Environment", + "Framework :: Pyramid", + "Intended Audience :: Developers", + "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)", + "Natural Language :: English", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Topic :: Internet :: WWW/HTTP", + "Topic :: Office/Business", + "Topic :: Software Development :: Libraries :: Python Modules", +] +requires-python = ">= 3.8" +dependencies = [ + "asgiref", + "colander", + "ColanderAlchemy", + "cornice", + "cornice-swagger", + "deform", + "humanize", + "Mako", + "markdown", + "openpyxl", + "paginate", + "paginate_sqlalchemy", + "passlib", + "Pillow", + "pyramid>=2", + "pyramid_beaker", + "pyramid_deform", + "pyramid_exclog", + "pyramid_fanstatic", + "pyramid_mako", + "pyramid_retry", + "pyramid_tm", + "rattail[db,bouncer]>=0.20.1", + "sa-filters", + "simplejson", + "transaction", + "waitress", + "WebHelpers2", + "WuttaWeb>=0.21.0", + "zope.sqlalchemy>=1.5", +] + + +[project.optional-dependencies] +docs = ["Sphinx", "furo"] +tests = ["coverage", "mock", "pytest", "pytest-cov"] + + +[project.entry-points."paste.app_factory"] +main = "tailbone.app:main" +webapi = "tailbone.webapi:main" + + +[project.entry-points."rattail.cleaners"] +beaker = "tailbone.cleanup:BeakerCleaner" + + +[project.entry-points."rattail.config.extensions"] +tailbone = "tailbone.config:ConfigExtension" + + +[project.urls] +Homepage = "https://rattailproject.org" +Repository = "https://forgejo.wuttaproject.org/rattail/tailbone" +Issues = "https://forgejo.wuttaproject.org/rattail/tailbone/issues" +Changelog = "https://forgejo.wuttaproject.org/rattail/tailbone/src/branch/master/CHANGELOG.md" + + +[tool.commitizen] +version_provider = "pep621" +tag_format = "v$version" +update_changelog_on_bump = true + + +[tool.nosetests] +nocapture = 1 +cover-package = "tailbone" +cover-erase = 1 +cover-html = 1 +cover-html-dir = "htmlcov" diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 7712ec72..00000000 --- a/setup.cfg +++ /dev/null @@ -1,6 +0,0 @@ -[nosetests] -nocapture = 1 -cover-package = tailbone -cover-erase = 1 -cover-html = 1 -cover-html-dir = htmlcov diff --git a/setup.py b/setup.py deleted file mode 100644 index 3328785e..00000000 --- a/setup.py +++ /dev/null @@ -1,190 +0,0 @@ -# -*- coding: utf-8; -*- -################################################################################ -# -# Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar -# -# This file is part of Rattail. -# -# Rattail is free software: you can redistribute it and/or modify it under the -# terms of the GNU General Public License as published by the Free Software -# Foundation, either version 3 of the License, or (at your option) any later -# version. -# -# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY -# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more -# details. -# -# You should have received a copy of the GNU General Public License along with -# Rattail. If not, see <http://www.gnu.org/licenses/>. -# -################################################################################ -""" -Setup script for Tailbone -""" - -from __future__ import unicode_literals, absolute_import - -import os.path -from setuptools import setup, find_packages - - -here = os.path.abspath(os.path.dirname(__file__)) -exec(open(os.path.join(here, 'tailbone', '_version.py')).read()) -README = open(os.path.join(here, 'README.rst')).read() - - -requires = [ - # - # Version numbers within comments below have specific meanings. - # Basically the 'low' value is a "soft low," and 'high' a "soft high." - # In other words: - # - # If either a 'low' or 'high' value exists, the primary point to be - # made about the value is that it represents the most current (stable) - # version available for the package (assuming typical public access - # methods) whenever this project was started and/or documented. - # Therefore: - # - # If a 'low' version is present, you should know that attempts to use - # versions of the package significantly older than the 'low' version - # may not yield happy results. (A "hard" high limit may or may not be - # indicated by a true version requirement.) - # - # Similarly, if a 'high' version is present, and especially if this - # project has laid dormant for a while, you may need to refactor a bit - # when attempting to support a more recent version of the package. (A - # "hard" low limit should be indicated by a true version requirement - # when a 'high' version is present.) - # - # In any case, developers and other users are encouraged to play - # outside the lines with regard to these soft limits. If bugs are - # encountered then they should be filed as such. - # - # package # low high - - # TODO: previously was capping this to pre-1.0 although i'm not sure why. - # however the 1.2 release has some breaking changes which require refactor. - # cf. https://pypi.org/project/zope.sqlalchemy/#id3 - 'zope.sqlalchemy<1.2', # 0.7 1.1 - - # TODO: apparently they jumped from 0.1 to 0.9 and that broke us... - # (0.1 was released on 2014-09-14 and then 0.9 came out on 2018-09-27) - # (i've cached 0.1 at pypi.rattailproject.org just in case it disappears) - # (still, probably a better idea is to refactor so we can use 0.9) - 'webhelpers2_grid==0.1', # 0.1 - - # TODO: remove version cap once we can drop support for python 2.x - 'cornice<5.0', # 3.4.2 4.0.1 - - # TODO: remove once their bug is fixed? idk what this is about yet... - 'deform<2.0.15', # 2.0.14 - - # TODO: cornice<5 requires pyramid<2 (see above) - 'pyramid<2', # 1.3b2 1.10.8 - - 'asgiref', # 3.2.3 - 'colander', # 1.7.0 - 'ColanderAlchemy', # 0.3.3 - 'humanize', # 0.5.1 - 'Mako', # 0.6.2 - 'markdown', # 3.3.3 - 'openpyxl', # 2.4.7 - 'paginate', # 0.5.6 - 'paginate_sqlalchemy', # 0.2.0 - 'passlib', # 1.7.1 - 'Pillow', # 5.3.0 - 'pyramid_beaker>=0.6', # 0.6.1 - 'pyramid_deform', # 0.2 - 'pyramid_exclog', # 0.6 - 'pyramid_mako', # 1.0.2 - 'pyramid_tm', # 0.3 - 'rattail[db,bouncer]', # 0.5.0 - 'six', # 1.10.0 - 'sqlalchemy-filters', # 0.8.0 - 'transaction', # 1.2.0 - 'waitress', # 0.8.1 - 'WebHelpers2', # 2.0 - 'WTForms', # 2.1 -] - - -extras = { - - 'docs': [ - # - # package # low high - - # TODO: remove version workaround after next sphinx[-rtd-theme] release - # cf. https://github.com/readthedocs/sphinx_rtd_theme/issues/1343 - 'Sphinx!=5.2.0.post0', # 1.2 - 'sphinx-rtd-theme', # 0.2.4 - ], - - 'tests': [ - # - # package # low high - - 'coverage', # 3.6 - 'fixture', # 1.5 - 'mock', # 1.0.1 - 'nose', # 1.3.0 - 'pytest', # 4.6.11 - 'pytest-cov', # 2.12.1 - ], -} - - -setup( - name = "Tailbone", - version = __version__, - author = "Lance Edgar", - author_email = "lance@edbob.org", - url = "http://rattailproject.org/", - license = "GNU GPL v3", - description = "Backoffice Web Application for Rattail", - long_description = README, - - classifiers = [ - 'Development Status :: 4 - Beta', - 'Environment :: Web Environment', - 'Framework :: Pyramid', - 'Intended Audience :: Developers', - 'License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)', - 'Natural Language :: English', - 'Operating System :: OS Independent', - 'Programming Language :: Python', - 'Programming Language :: Python :: 2.7', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.5', - 'Topic :: Internet :: WWW/HTTP', - 'Topic :: Office/Business', - 'Topic :: Software Development :: Libraries :: Python Modules', - ], - - install_requires = requires, - extras_require = extras, - tests_require = ['Tailbone[tests]'], - test_suite = 'nose.collector', - - packages = find_packages(exclude=['tests.*', 'tests']), - include_package_data = True, - zip_safe = False, - - entry_points = { - - 'paste.app_factory': [ - 'main = tailbone.app:main', - 'webapi = tailbone.webapi:main', - ], - - 'rattail.config.extensions': [ - 'tailbone = tailbone.config:ConfigExtension', - ], - - 'pyramid.scaffold': [ - 'rattail = tailbone.scaffolds:RattailTemplate', - ], - }, -) diff --git a/tailbone/_version.py b/tailbone/_version.py index 321a1037..7095f6c8 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,9 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.267' +try: + from importlib.metadata import version +except ImportError: + from importlib_metadata import version + + +__version__ = version('Tailbone') diff --git a/tailbone/api/auth.py b/tailbone/api/auth.py index 867c15a8..a710e30d 100644 --- a/tailbone/api/auth.py +++ b/tailbone/api/auth.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,10 +24,6 @@ Tailbone Web API - 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 @@ -44,11 +40,10 @@ class AuthenticationView(APIView): This will establish a server-side web session for the user if none exists. Note that this also resets the user's session timer. """ - data = {'ok': True} + data = {'ok': True, 'permissions': []} if self.request.user: data['user'] = self.get_user_info(self.request.user) - - data['permissions'] = list(self.request.tailbone_cached_permissions) + data['permissions'] = list(self.request.user_permissions) # background color may be set per-request, by some apps if hasattr(self.request, 'background_color') and self.request.background_color: @@ -168,6 +163,9 @@ 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 @@ -175,7 +173,8 @@ class AuthenticationView(APIView): return {'error': "The current/old password you provided is incorrect"} # okay then, set new password - set_user_password(self.request.user, data['new_password']) + auth = self.app.get_auth_handler() + auth.set_user_password(self.request.user, data['new_password']) return { 'ok': True, 'user': self.get_user_info(self.request.user), diff --git a/tailbone/api/batch/core.py b/tailbone/api/batch/core.py index 5b6102ed..f7bc9333 100644 --- a/tailbone/api/batch/core.py +++ b/tailbone/api/batch/core.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,13 +24,9 @@ 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 @@ -70,9 +66,7 @@ class APIBatchMixin(object): """ app = self.get_rattail_app() key = self.get_batch_class().batch_key - spec = self.rattail_config.get('rattail.batch', '{}.handler'.format(key), - default=self.default_handler_spec) - return app.load_object(spec)(self.rattail_config) + return app.get_batch_handler(key, default=self.default_handler_spec) class APIBatchView(APIBatchMixin, APIMasterView): @@ -104,25 +98,25 @@ class APIBatchView(APIBatchMixin, APIMasterView): return { 'uuid': batch.uuid, - '_str': six.text_type(batch), + '_str': str(batch), 'id': batch.id, 'id_str': batch.id_str, 'description': batch.description, 'notes': batch.notes, 'params': batch.params or {}, 'rowcount': batch.rowcount, - 'created': six.text_type(created), + 'created': str(created), 'created_display': self.pretty_datetime(created), 'created_by_uuid': batch.created_by.uuid, - 'created_by_display': six.text_type(batch.created_by), + 'created_by_display': str(batch.created_by), 'complete': batch.complete, 'status_code': batch.status_code, 'status_display': batch.STATUS.get(batch.status_code, - six.text_type(batch.status_code)), - 'executed': six.text_type(executed) if executed else None, + str(batch.status_code)), + 'executed': str(executed) if executed else None, 'executed_display': self.pretty_datetime(executed) if executed else None, 'executed_by_uuid': batch.executed_by_uuid, - 'executed_by_display': six.text_type(batch.executed_by or ''), + 'executed_by_display': str(batch.executed_by or ''), 'mutable': self.batch_handler.is_mutable(batch), } @@ -273,8 +267,8 @@ class APIBatchRowView(APIBatchMixin, APIMasterView): batch = row.batch return { 'uuid': row.uuid, - '_str': six.text_type(row), - '_parent_str': six.text_type(batch), + '_str': str(row), + '_parent_str': str(batch), '_parent_uuid': batch.uuid, 'batch_uuid': batch.uuid, 'batch_id': batch.id, @@ -285,7 +279,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, six.text_type(row.status_code)), + 'status_display': row.STATUS.get(row.status_code, str(row.status_code)), } def update_object(self, row, data): @@ -320,7 +314,7 @@ class APIBatchRowView(APIBatchMixin, APIMasterView): data = self.request.json_body uuid = data['batch_uuid'] - batch = self.Session.query(self.get_batch_class()).get(uuid) + batch = self.Session.get(self.get_batch_class(), uuid) if not batch: raise self.notfound() @@ -332,11 +326,14 @@ 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 = six.text_type(error) + msg = str(error) if not msg and isinstance(error, NotImplementedError): msg = "Feature is not implemented" return {'error': msg} + if not row: + return {'error': "Could not identify product"} + self.Session.flush() result = self._get(obj=row) result['ok'] = True @@ -352,13 +349,12 @@ 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 - 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') + 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) diff --git a/tailbone/api/batch/inventory.py b/tailbone/api/batch/inventory.py index 5e56fe46..22b67e54 100644 --- a/tailbone/api/batch/inventory.py +++ b/tailbone/api/batch/inventory.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,15 +24,12 @@ Tailbone Web API - Inventory Batches """ -from __future__ import unicode_literals, absolute_import - import decimal -import six +import sqlalchemy as sa from rattail import pod -from rattail.db import model -from rattail.util import pretty_quantity +from rattail.db.model import InventoryBatch, InventoryBatchRow from cornice import Service @@ -41,7 +38,7 @@ from tailbone.api.batch import APIBatchView, APIBatchRowView class InventoryBatchViews(APIBatchView): - model_class = model.InventoryBatch + model_class = InventoryBatch default_handler_spec = 'rattail.batch.inventory:InventoryBatchHandler' route_prefix = 'inventory' permission_prefix = 'batch.inventory' @@ -50,12 +47,12 @@ class InventoryBatchViews(APIBatchView): supports_toggle_complete = True def normalize(self, batch): - data = super(InventoryBatchViews, self).normalize(batch) + data = super().normalize(batch) data['mode'] = batch.mode data['mode_display'] = self.enum.INVENTORY_MODE.get(batch.mode) if data['mode_display'] is None and batch.mode is not None: - data['mode_display'] = six.text_type(batch.mode) + data['mode_display'] = str(batch.mode) data['reason_code'] = batch.reason_code @@ -119,7 +116,7 @@ class InventoryBatchViews(APIBatchView): class InventoryBatchRowViews(APIBatchRowView): - model_class = model.InventoryBatchRow + model_class = InventoryBatchRow default_handler_spec = 'rattail.batch.inventory:InventoryBatchHandler' route_prefix = 'inventory.rows' permission_prefix = 'batch.inventory' @@ -130,23 +127,24 @@ class InventoryBatchRowViews(APIBatchRowView): def normalize(self, row): batch = row.batch - data = super(InventoryBatchRowViews, self).normalize(row) + data = super().normalize(row) + app = self.get_rattail_app() data['item_id'] = row.item_id - data['upc'] = six.text_type(row.upc) + data['upc'] = str(row.upc) data['upc_pretty'] = row.upc.pretty() if row.upc else None data['brand_name'] = row.brand_name data['description'] = row.description data['size'] = row.size data['full_description'] = row.product.full_description if row.product else row.description data['image_url'] = pod.get_image_url(self.rattail_config, row.upc) if row.upc else None - data['case_quantity'] = pretty_quantity(row.case_quantity or 1) + data['case_quantity'] = app.render_quantity(row.case_quantity or 1) data['cases'] = row.cases data['units'] = row.units data['unit_uom'] = 'LB' if row.product and row.product.weighed else 'EA' data['quantity_display'] = "{} {}".format( - pretty_quantity(row.cases or row.units), + app.render_quantity(row.cases or row.units), 'CS' if row.cases else data['unit_uom']) data['allow_cases'] = self.batch_handler.allow_cases(batch) @@ -174,7 +172,17 @@ class InventoryBatchRowViews(APIBatchRowView): data['units'] = decimal.Decimal(data['units']) # update row per usual - row = super(InventoryBatchRowViews, self).update_object(row, data) + try: + row = super().update_object(row, data) + except sa.exc.DataError as error: + # detect when user scans barcode for cases/units field + if hasattr(error, 'orig'): + orig = type(error.orig) + if hasattr(orig, '__name__'): + # nb. this particular error is from psycopg2 + if orig.__name__ == 'NumericValueOutOfRange': + return {'error': "Numeric value out of range"} + raise return row diff --git a/tailbone/api/batch/labels.py b/tailbone/api/batch/labels.py index 4787aeb9..4f154b21 100644 --- a/tailbone/api/batch/labels.py +++ b/tailbone/api/batch/labels.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,10 +24,6 @@ Tailbone Web API - Label Batches """ -from __future__ import unicode_literals, absolute_import - -import six - from rattail.db import model from tailbone.api.batch import APIBatchView, APIBatchRowView @@ -56,10 +52,10 @@ class LabelBatchRowViews(APIBatchRowView): def normalize(self, row): batch = row.batch - data = super(LabelBatchRowViews, self).normalize(row) + data = super().normalize(row) data['item_id'] = row.item_id - data['upc'] = six.text_type(row.upc) + data['upc'] = str(row.upc) data['upc_pretty'] = row.upc.pretty() if row.upc else None data['brand_name'] = row.brand_name data['description'] = row.description diff --git a/tailbone/api/batch/ordering.py b/tailbone/api/batch/ordering.py index 9ab9617c..204be8ad 100644 --- a/tailbone/api/batch/ordering.py +++ b/tailbone/api/batch/ordering.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -27,21 +27,24 @@ 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 six +import sqlalchemy as sa -from rattail.db import model -from rattail.util import pretty_quantity +from rattail.db.model import PurchaseBatch, PurchaseBatchRow from cornice import Service from tailbone.api.batch import APIBatchView, APIBatchRowView +log = logging.getLogger(__name__) + + class OrderingBatchViews(APIBatchView): - model_class = model.PurchaseBatch + model_class = PurchaseBatch default_handler_spec = 'rattail.batch.purchase:PurchaseBatchHandler' route_prefix = 'orderingbatchviews' permission_prefix = 'ordering' @@ -57,18 +60,19 @@ class OrderingBatchViews(APIBatchView): Adds a condition to the query, to ensure only purchase batches with "ordering" mode are returned. """ - query = super(OrderingBatchViews, self).base_query() + model = self.model + query = super().base_query() query = query.filter(model.PurchaseBatch.mode == self.enum.PURCHASE_BATCH_MODE_ORDERING) return query def normalize(self, batch): - data = super(OrderingBatchViews, self).normalize(batch) + data = super().normalize(batch) data['vendor_uuid'] = batch.vendor.uuid - data['vendor_display'] = six.text_type(batch.vendor) + data['vendor_display'] = str(batch.vendor) data['department_uuid'] = batch.department_uuid - data['department_display'] = six.text_type(batch.department) if batch.department else None + data['department_display'] = str(batch.department) if batch.department else None data['po_total_calculated_display'] = "${:0.2f}".format(batch.po_total_calculated or 0) data['ship_method'] = batch.ship_method @@ -82,8 +86,10 @@ class OrderingBatchViews(APIBatchView): Sets the mode to "ordering" for the new batch. """ data = dict(data) + if not data.get('vendor_uuid'): + raise ValueError("You must specify the vendor") data['mode'] = self.enum.PURCHASE_BATCH_MODE_ORDERING - batch = super(OrderingBatchViews, self).create_object(data) + batch = super().create_object(data) return batch def worksheet(self): @@ -94,6 +100,8 @@ class OrderingBatchViews(APIBatchView): if batch.executed: raise self.forbidden() + app = self.get_rattail_app() + # TODO: much of the logic below was copied from the traditional master # view for ordering batches. should maybe let them share it somehow? @@ -148,7 +156,7 @@ class OrderingBatchViews(APIBatchView): product = cost.product subdept_costs.append({ 'uuid': cost.uuid, - 'upc': six.text_type(product.upc), + 'upc': str(product.upc), 'upc_pretty': product.upc.pretty() if product.upc else None, 'brand_name': product.brand.name if product.brand else None, 'description': product.description, @@ -169,8 +177,8 @@ class OrderingBatchViews(APIBatchView): # sort the (sub)department groupings sorted_departments = [] - for dept in sorted(six.itervalues(departments), key=lambda d: d['name']): - dept['subdepartments'] = sorted(six.itervalues(dept['subdepartments']), + for dept in sorted(departments.values(), key=lambda d: d['name']): + dept['subdepartments'] = sorted(dept['subdepartments'].values(), key=lambda s: s['name']) sorted_departments.append(dept) @@ -179,6 +187,18 @@ class OrderingBatchViews(APIBatchView): for i in range(6 - len(history)): history.append(None) history = list(reversed(history)) + # must convert some date objects to string, for JSON sake + for h in history: + if not h: + continue + purchase = h.get('purchase') + if purchase: + dt = purchase.get('date_ordered') + if dt and isinstance(dt, datetime.date): + purchase['date_ordered'] = app.render_date(dt) + dt = purchase.get('date_received') + if dt and isinstance(dt, datetime.date): + purchase['date_received'] = app.render_date(dt) return { 'batch': self.normalize(batch), @@ -209,7 +229,7 @@ class OrderingBatchViews(APIBatchView): class OrderingBatchRowViews(APIBatchRowView): - model_class = model.PurchaseBatchRow + model_class = PurchaseBatchRow default_handler_spec = 'rattail.batch.purchase:PurchaseBatchHandler' route_prefix = 'ordering.rows' permission_prefix = 'ordering' @@ -219,11 +239,12 @@ 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'] = six.text_type(row.upc) + data['upc'] = str(row.upc) data['upc_pretty'] = row.upc.pretty() if row.upc else None data['brand_name'] = row.brand_name data['description'] = row.description @@ -240,15 +261,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'] = pretty_quantity(row.cases_ordered or 0, empty_zero=False) - data['units_ordered_display'] = pretty_quantity(row.units_ordered or 0, empty_zero=False) + data['cases_ordered_display'] = app.render_quantity(row.cases_ordered or 0, empty_zero=False) + data['units_ordered_display'] = app.render_quantity(row.units_ordered or 0, empty_zero=False) data['po_unit_cost'] = row.po_unit_cost data['po_unit_cost_display'] = "${:0.2f}".format(row.po_unit_cost) if row.po_unit_cost is not None else None 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, six.text_type(row.status_code)) + data['status_display'] = row.STATUS.get(row.status_code, str(row.status_code)) return data @@ -269,7 +290,17 @@ class OrderingBatchRowViews(APIBatchRowView): if not self.batch_handler.is_mutable(row.batch): return {'error': "Batch is not mutable"} - self.batch_handler.update_row_quantity(row, **data) + try: + self.batch_handler.update_row_quantity(row, **data) + self.Session.flush() + except Exception as error: + log.warning("update_row_quantity failed", exc_info=True) + if isinstance(error, sa.exc.DataError) and hasattr(error, 'orig'): + error = str(error.orig) + else: + error = str(error) + return {'error': error} + return row diff --git a/tailbone/api/batch/receiving.py b/tailbone/api/batch/receiving.py index c755de65..b23bff55 100644 --- a/tailbone/api/batch/receiving.py +++ b/tailbone/api/batch/receiving.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,16 +24,14 @@ 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 import model -from rattail.util import pretty_quantity +from rattail.db.model import PurchaseBatch, PurchaseBatchRow +from cornice import Service from deform import widget as dfwidget from tailbone import forms @@ -46,7 +44,7 @@ log = logging.getLogger(__name__) class ReceivingBatchViews(APIBatchView): - model_class = model.PurchaseBatch + model_class = PurchaseBatch default_handler_spec = 'rattail.batch.purchase:PurchaseBatchHandler' route_prefix = 'receivingbatchviews' permission_prefix = 'receiving' @@ -56,19 +54,21 @@ class ReceivingBatchViews(APIBatchView): supports_execute = True def base_query(self): - query = super(ReceivingBatchViews, self).base_query() + model = self.app.model + query = super().base_query() query = query.filter(model.PurchaseBatch.mode == self.enum.PURCHASE_BATCH_MODE_RECEIVING) return query def normalize(self, batch): - data = super(ReceivingBatchViews, self).normalize(batch) + data = super().normalize(batch) data['vendor_uuid'] = batch.vendor.uuid - data['vendor_display'] = six.text_type(batch.vendor) + data['vendor_display'] = str(batch.vendor) data['department_uuid'] = batch.department_uuid - data['department_display'] = six.text_type(batch.department) if batch.department else None + data['department_display'] = str(batch.department) if batch.department else None + data['po_number'] = batch.po_number data['po_total'] = batch.po_total data['invoice_total'] = batch.invoice_total data['invoice_total_calculated'] = batch.invoice_total_calculated @@ -79,9 +79,15 @@ class ReceivingBatchViews(APIBatchView): def create_object(self, data): data = dict(data) + + # all about receiving mode here data['mode'] = self.enum.PURCHASE_BATCH_MODE_RECEIVING - batch = super(ReceivingBatchViews, self).create_object(data) - return batch + + # assume "receive from PO" if given a PO key + if data.get('purchase_key'): + data['workflow'] = 'from_po' + + return super().create_object(data) def auto_receive(self): """ @@ -114,8 +120,9 @@ 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.query(model.Vendor).get(uuid) if uuid else None + vendor = self.Session.get(model.Vendor, uuid) if uuid else None if not vendor: return {'error': "Vendor not found"} @@ -146,31 +153,31 @@ class ReceivingBatchViews(APIBatchView): collection_url_prefix = cls.get_collection_url_prefix() object_url_prefix = cls.get_object_url_prefix() - # 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') + # auto_receive + auto_receive = Service(name='{}.auto_receive'.format(route_prefix), + path='{}/{{uuid}}/auto-receive'.format(object_url_prefix)) + auto_receive.add_view('GET', 'auto_receive', klass=cls, + permission='{}.auto_receive'.format(permission_prefix)) + config.add_cornice_service(auto_receive) - # mark receiving complete - 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') + # mark_receiving_complete + mark_receiving_complete = Service(name='{}.mark_receiving_complete'.format(route_prefix), + path='{}/{{uuid}}/mark-receiving-complete'.format(object_url_prefix)) + mark_receiving_complete.add_view('POST', 'mark_receiving_complete', klass=cls, + permission='{}.edit'.format(permission_prefix)) + config.add_cornice_service(mark_receiving_complete) # eligible purchases - 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') + eligible_purchases = Service(name='{}.eligible_purchases'.format(route_prefix), + path='{}/eligible-purchases'.format(collection_url_prefix)) + eligible_purchases.add_view('GET', 'eligible_purchases', klass=cls, + permission='{}.create'.format(permission_prefix)) + config.add_cornice_service(eligible_purchases) class ReceivingBatchRowViews(APIBatchRowView): - model_class = model.PurchaseBatchRow + model_class = PurchaseBatchRow default_handler_spec = 'rattail.batch.purchase:PurchaseBatchHandler' route_prefix = 'receiving.rows' permission_prefix = 'receiving' @@ -179,7 +186,8 @@ class ReceivingBatchRowViews(APIBatchRowView): supports_quick_entry = True def make_filter_spec(self): - filters = super(ReceivingBatchRowViews, self).make_filter_spec() + model = self.app.model + filters = super().make_filter_spec() if filters: # must translate certain convenience filters @@ -275,21 +283,30 @@ 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(ReceivingBatchRowViews, self).normalize(row) + data = super().normalize(row) + model = self.app.model batch = row.batch - app = self.get_rattail_app() - prodder = app.get_products_handler() + prodder = self.app.get_products_handler() data['product_uuid'] = row.product_uuid data['item_id'] = row.item_id - data['upc'] = six.text_type(row.upc) + data['upc'] = str(row.upc) data['upc_pretty'] = row.upc.pretty() if row.upc else None data['brand_name'] = row.brand_name data['description'] = row.description @@ -321,6 +338,9 @@ 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 @@ -328,6 +348,7 @@ 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 @@ -356,7 +377,7 @@ class ReceivingBatchRowViews(APIBatchRowView): if accounted_for: # some product accounted for; button should receive "remainder" only if remainder: - remainder = pretty_quantity(remainder) + remainder = self.app.render_quantity(remainder) data['quick_receive_quantity'] = remainder data['quick_receive_text'] = "Receive Remainder ({} {})".format( remainder, data['unit_uom']) @@ -367,7 +388,7 @@ class ReceivingBatchRowViews(APIBatchRowView): else: # nothing yet accounted for, button should receive "all" if not remainder: log.warning("quick receive remainder is empty for row %s", row.uuid) - remainder = pretty_quantity(remainder) + remainder = self.app.render_quantity(remainder) data['quick_receive_quantity'] = remainder data['quick_receive_text'] = "Receive ALL ({} {})".format( remainder, data['unit_uom']) @@ -395,7 +416,7 @@ class ReceivingBatchRowViews(APIBatchRowView): data['received_alert'] = None if self.batch_handler.get_units_confirmed(row): msg = "You have already received some of this product; last update was {}.".format( - humanize.naturaltime(app.make_utc() - row.modified)) + humanize.naturaltime(self.app.make_utc() - row.modified)) data['received_alert'] = msg return data @@ -404,27 +425,37 @@ 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(newstyle=True): - log.debug("form did not validate: %s", - form.make_deform_form().error) + if not form.validate(): + log.warning("form did not validate: %s", + form.make_deform_form().error) return {'error': "Form did not validate"} # fetch / validate row object - row = self.Session.query(model.PurchaseBatchRow).get(form.validated['row']) + row = self.Session.get(model.PurchaseBatchRow, form.validated['row']) if row is not self.get_object(): return {'error': "Specified row does not match the route!"} # handler takes care of the row receiving logic for us kwargs = dict(form.validated) del kwargs['row'] - self.batch_handler.receive_row(row, **kwargs) + try: + self.batch_handler.receive_row(row, **kwargs) + self.Session.flush() + except Exception as error: + log.warning("receive() failed", exc_info=True) + if isinstance(error, sa.exc.DataError) and hasattr(error, 'orig'): + error = str(error.orig) + else: + error = str(error) + return {'error': error} - self.Session.flush() return self._get(obj=row) @classmethod @@ -440,11 +471,11 @@ class ReceivingBatchRowViews(APIBatchRowView): object_url_prefix = cls.get_object_url_prefix() # receive (row) - 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') + receive = Service(name='{}.receive'.format(route_prefix), + path='{}/{{uuid}}/receive'.format(object_url_prefix)) + receive.add_view('POST', 'receive', klass=cls, + permission='{}.edit_row'.format(permission_prefix)) + config.add_cornice_service(receive) def defaults(config, **kwargs): diff --git a/tailbone/api/common.py b/tailbone/api/common.py index 3e96609a..6cacfb06 100644 --- a/tailbone/api/common.py +++ b/tailbone/api/common.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,16 +24,14 @@ Tailbone Web API - "Common" Views """ -from __future__ import unicode_literals, absolute_import +from collections import OrderedDict -import rattail -from rattail.db import model -from rattail.mail import send_email -from rattail.util import OrderedDict +from rattail.util import get_pkg_version from cornice import Service +from cornice.service import get_services +from cornice_swagger import CorniceSwagger -import tailbone from tailbone import forms from tailbone.forms.common import Feedback from tailbone.api import APIView, api @@ -65,11 +63,12 @@ class CommonView(APIView): } def get_project_title(self): - return self.rattail_config.app_title(default="Tailbone") + app = self.get_rattail_app() + return app.get_title() def get_project_version(self): - import tailbone - return tailbone.__version__ + app = self.get_rattail_app() + return app.get_version() def get_packages(self): """ @@ -77,8 +76,8 @@ class CommonView(APIView): 'about' page. """ return OrderedDict([ - ('rattail', rattail.__version__), - ('Tailbone', tailbone.__version__), + ('rattail', get_pkg_version('rattail')), + ('Tailbone', get_pkg_version('Tailbone')), ]) @api @@ -86,18 +85,20 @@ 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(newstyle=True): + if form.validate(): data = dict(form.validated) # figure out who the sending user is, if any if self.request.user: data['user'] = self.request.user elif data['user']: - data['user'] = Session.query(model.User).get(data['user']) + data['user'] = Session.get(model.User, data['user']) # TODO: should provide URL to view user if data['user']: @@ -105,17 +106,27 @@ class CommonView(APIView): data['client_ip'] = self.request.client_addr email_key = data['email_key'] or self.feedback_email_key - send_email(self.rattail_config, email_key, data=data) + app.send_email(email_key, data=data) return {'ok': True} return {'error': "Form did not validate!"} + 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') @@ -128,6 +139,14 @@ 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() diff --git a/tailbone/api/core.py b/tailbone/api/core.py index 613b1566..0d8eec32 100644 --- a/tailbone/api/core.py +++ b/tailbone/api/core.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,8 +24,6 @@ Tailbone Web API - Core Views """ -from __future__ import unicode_literals, absolute_import - from tailbone.views import View @@ -101,20 +99,20 @@ class APIView(View): return info """ app = self.get_rattail_app() - auth_handler = app.get_auth_handler() + auth = app.get_auth_handler() # basic / default info - is_admin = user.is_admin() - employee = user.employee + is_admin = auth.user_is_admin(user) + employee = app.get_employee(user) info = { 'uuid': user.uuid, 'username': user.username, 'display_name': user.display_name, - 'short_name': user.get_short_name(), + 'short_name': auth.get_short_display_name(user), 'is_admin': is_admin, 'is_root': is_admin and self.request.session.get('is_root', False), 'employee_uuid': employee.uuid if employee else None, - 'email_address': auth_handler.get_email_address(user), + 'email_address': app.get_contact_email_address(user), } # maybe get/use "extra" info diff --git a/tailbone/api/customers.py b/tailbone/api/customers.py index e9953572..85d28c24 100644 --- a/tailbone/api/customers.py +++ b/tailbone/api/customers.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,10 +24,6 @@ Tailbone Web API - Customer Views """ -from __future__ import unicode_literals, absolute_import - -import six - from rattail.db import model from tailbone.api import APIMasterView @@ -46,7 +42,7 @@ class CustomerView(APIMasterView): def normalize(self, customer): return { 'uuid': customer.uuid, - '_str': six.text_type(customer), + '_str': str(customer), 'id': customer.id, 'number': customer.number, 'name': customer.name, diff --git a/tailbone/api/essentials.py b/tailbone/api/essentials.py new file mode 100644 index 00000000..7b151578 --- /dev/null +++ b/tailbone/api/essentials.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2023 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. +# +################################################################################ +""" +Essential views for convenient includes +""" + + +def defaults(config, **kwargs): + mod = lambda spec: kwargs.get(spec, spec) + + config.include(mod('tailbone.api.auth')) + config.include(mod('tailbone.api.common')) + + +def includeme(config): + defaults(config) diff --git a/tailbone/scaffolds.py b/tailbone/api/labels.py similarity index 57% rename from tailbone/scaffolds.py rename to tailbone/api/labels.py index 10bf9640..8bc11f8f 100644 --- a/tailbone/scaffolds.py +++ b/tailbone/api/labels.py @@ -1,8 +1,8 @@ -# -*- coding: utf-8 -*- +# -*- coding: utf-8; -*- ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -21,25 +21,31 @@ # ################################################################################ """ -Pyramid scaffold templates +Tailbone Web API - Label Views """ from __future__ import unicode_literals, absolute_import -from rattail.files import resource_path -from rattail.util import prettify +from rattail.db.model import LabelProfile -from pyramid.scaffolds import PyramidTemplate +from tailbone.api import APIMasterView -class RattailTemplate(PyramidTemplate): - _template_dir = resource_path('rattail:data/project') - summary = "Starter project based on Rattail / Tailbone" +class LabelProfileView(APIMasterView): + """ + API views for Label Profile data + """ + model_class = LabelProfile + collection_url_prefix = '/label-profiles' + object_url_prefix = '/label-profile' - 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) + +def defaults(config, **kwargs): + base = globals() + + LabelProfileView = kwargs.get('LabelProfileView', base['LabelProfileView']) + LabelProfileView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/api/master.py b/tailbone/api/master.py index 97426214..551d6428 100644 --- a/tailbone/api/master.py +++ b/tailbone/api/master.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,26 +24,15 @@ 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, api +from tailbone.api import APIView from tailbone.db import Session - - -class SortColumn(object): - - def __init__(self, field_name, model_name=None): - self.field_name = field_name - self.model_name = model_name +from tailbone.util import SortColumn class APIMasterView(APIView): @@ -195,7 +184,7 @@ class APIMasterView(APIView): if sortcol: spec = { 'field': sortcol.field_name, - 'direction': 'asc' if parse_bool(self.request.params['ascending']) else 'desc', + 'direction': 'asc' if self.config.parse_bool(self.request.params['ascending']) else 'desc', } if sortcol.model_name: spec['model'] = sortcol.model_name @@ -279,7 +268,7 @@ class APIMasterView(APIView): return self._fieldnames def normalize(self, obj): - data = {'_str': six.text_type(obj)} + data = {'_str': str(obj)} for field in self.get_fieldnames(): data[field] = getattr(obj, field) @@ -287,7 +276,7 @@ class APIMasterView(APIView): return data def _collection_get(self): - from sqlalchemy_filters import apply_filters, apply_sort, apply_pagination + from sa_filters import apply_filters, apply_sort, apply_pagination query = self.base_query() context = {} @@ -343,7 +332,7 @@ class APIMasterView(APIView): if not uuid: uuid = self.request.matchdict['uuid'] - obj = self.Session.query(self.get_model_class()).get(uuid) + obj = self.Session.get(self.get_model_class(), uuid) if obj: return obj @@ -365,9 +354,13 @@ class APIMasterView(APIView): data = self.request.json_body # add instance to session, and return data for it - obj = self.create_object(data) - self.Session.flush() - return self._get(obj) + try: + obj = self.create_object(data) + except Exception as error: + return self.json_response({'error': str(error)}) + else: + self.Session.flush() + return self._get(obj) def create_object(self, data): """ @@ -394,7 +387,7 @@ class APIMasterView(APIView): """ if not uuid: uuid = self.request.matchdict['uuid'] - obj = self.Session.query(self.get_model_class()).get(uuid) + obj = self.Session.get(self.get_model_class(), uuid) if not obj: raise self.notfound() @@ -479,13 +472,16 @@ class APIMasterView(APIView): """ obj = self.get_object() - filename = self.request.GET.get('filename', None) - if not filename: - raise self.notfound() - path = self.download_path(obj, filename) + # TODO: is this really needed? + # filename = self.request.GET.get('filename', None) + # if filename: + # path = self.download_path(obj, filename) + # return self.file_response(path, attachment=False) - response = self.file_response(path, attachment=False) - return response + return self.rawbytes_response(obj) + + def rawbytes_response(self, obj): + raise NotImplementedError ############################## # autocomplete diff --git a/tailbone/api/people.py b/tailbone/api/people.py index 7e06e969..f7c08dfa 100644 --- a/tailbone/api/people.py +++ b/tailbone/api/people.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,10 +24,6 @@ Tailbone Web API - Person Views """ -from __future__ import unicode_literals, absolute_import - -import six - from rattail.db import model from tailbone.api import APIMasterView @@ -45,7 +41,7 @@ class PersonView(APIMasterView): def normalize(self, person): return { 'uuid': person.uuid, - '_str': six.text_type(person), + '_str': str(person), 'first_name': person.first_name, 'last_name': person.last_name, 'display_name': person.display_name, diff --git a/tailbone/api/products.py b/tailbone/api/products.py index 48a6e4aa..3f29ff54 100644 --- a/tailbone/api/products.py +++ b/tailbone/api/products.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,17 +24,21 @@ Tailbone Web API - Product Views """ -from __future__ import unicode_literals, absolute_import +import logging -import six import sqlalchemy as sa from sqlalchemy import orm +from cornice import Service + from rattail.db import model from tailbone.api import APIMasterView +log = logging.getLogger(__name__) + + class ProductView(APIMasterView): """ API views for Product data @@ -44,20 +48,49 @@ class ProductView(APIMasterView): object_url_prefix = '/product' supports_autocomplete = True + def __init__(self, request, context=None): + super(ProductView, self).__init__(request, context=context) + app = self.get_rattail_app() + self.products_handler = app.get_products_handler() + def normalize(self, product): + + # get what we can from handler + data = self.products_handler.normalize_product(product, fields=[ + 'brand_name', + 'full_description', + 'department_name', + 'unit_price_display', + 'sale_price', + 'sale_price_display', + 'sale_ends', + 'sale_ends_display', + 'tpr_price', + 'tpr_price_display', + 'tpr_ends', + 'tpr_ends_display', + 'current_price', + 'current_price_display', + 'current_ends', + 'current_ends_display', + 'vendor_name', + 'costs', + 'image_url', + ]) + + # but must supplement cost = product.cost - return { - 'uuid': product.uuid, - '_str': six.text_type(product), - 'upc': six.text_type(product.upc), + data.update({ + 'upc': str(product.upc), 'scancode': product.scancode, 'item_id': product.item_id, 'item_type': product.item_type, - 'description': product.description, 'status_code': product.status_code, 'default_unit_cost': cost.unit_cost if cost else None, 'default_unit_cost_display': "${:0.2f}".format(cost.unit_cost) if cost and cost.unit_cost is not None else None, - } + }) + + return data def make_autocomplete_query(self, term): query = self.Session.query(model.Product)\ @@ -77,6 +110,104 @@ class ProductView(APIMasterView): def autocomplete_display(self, product): return product.full_description + def quick_lookup(self): + """ + View for handling "quick lookup" user input, for index page. + """ + data = self.request.GET + entry = data['entry'] + + product = self.products_handler.locate_product_for_entry(self.Session(), + entry) + if not product: + return {'error': "Product not found"} + + return {'ok': True, + 'product': self.normalize(product)} + + def label_profiles(self): + """ + Returns the set of label profiles available for use with + printing label for product. + """ + app = self.get_rattail_app() + label_handler = app.get_label_handler() + model = self.model + + profiles = [] + for profile in label_handler.get_label_profiles(self.Session()): + profiles.append({ + 'uuid': profile.uuid, + 'description': profile.description, + }) + + return {'label_profiles': profiles} + + def print_labels(self): + app = self.get_rattail_app() + label_handler = app.get_label_handler() + model = self.model + data = self.request.json_body + + uuid = data.get('label_profile_uuid') + profile = self.Session.get(model.LabelProfile, uuid) if uuid else None + if not profile: + return {'error': "Label profile not found"} + + uuid = data.get('product_uuid') + product = self.Session.get(model.Product, uuid) if uuid else None + if not product: + return {'error': "Product not found"} + + try: + quantity = int(data.get('quantity')) + except: + return {'error': "Quantity must be integer"} + + printer = label_handler.get_printer(profile) + if not printer: + return {'error': "Couldn't get printer from label profile"} + + try: + printer.print_labels([({'product': product}, quantity)]) + except Exception as error: + log.warning("error occurred while printing labels", exc_info=True) + return {'error': str(error)} + + return {'ok': True} + + @classmethod + def defaults(cls, config): + cls._defaults(config) + cls._product_defaults(config) + + @classmethod + def _product_defaults(cls, config): + route_prefix = cls.get_route_prefix() + permission_prefix = cls.get_permission_prefix() + collection_url_prefix = cls.get_collection_url_prefix() + + # quick lookup + quick_lookup = Service(name='{}.quick_lookup'.format(route_prefix), + path='{}/quick-lookup'.format(collection_url_prefix)) + quick_lookup.add_view('GET', 'quick_lookup', klass=cls, + permission='{}.list'.format(permission_prefix)) + config.add_cornice_service(quick_lookup) + + # label profiles + label_profiles = Service(name=f'{route_prefix}.label_profiles', + path=f'{collection_url_prefix}/label-profiles') + label_profiles.add_view('GET', 'label_profiles', klass=cls, + permission=f'{permission_prefix}.print_labels') + config.add_cornice_service(label_profiles) + + # print labels + print_labels = Service(name='{}.print_labels'.format(route_prefix), + path='{}/print-labels'.format(collection_url_prefix)) + print_labels.add_view('POST', 'print_labels', klass=cls, + permission='{}.print_labels'.format(permission_prefix)) + config.add_cornice_service(print_labels) + def defaults(config, **kwargs): base = globals() diff --git a/tailbone/api/upgrades.py b/tailbone/api/upgrades.py index 6ce5f778..467c8a0d 100644 --- a/tailbone/api/upgrades.py +++ b/tailbone/api/upgrades.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,10 +24,6 @@ Tailbone Web API - Upgrade Views """ -from __future__ import unicode_literals, absolute_import - -import six - from rattail.db import model from tailbone.api import APIMasterView @@ -53,7 +49,7 @@ class UpgradeView(APIMasterView): data['status_code'] = None else: data['status_code'] = self.enum.UPGRADE_STATUS.get(upgrade.status_code, - six.text_type(upgrade.status_code)) + str(upgrade.status_code)) return data diff --git a/tailbone/api/users.py b/tailbone/api/users.py index 2b6476a2..a6bcad57 100644 --- a/tailbone/api/users.py +++ b/tailbone/api/users.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,8 +24,6 @@ Tailbone Web API - User Views """ -from __future__ import unicode_literals, absolute_import - from rattail.db import model from tailbone.api import APIMasterView @@ -57,6 +55,10 @@ 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() diff --git a/tailbone/api/vendors.py b/tailbone/api/vendors.py index 7fa61590..64311b1b 100644 --- a/tailbone/api/vendors.py +++ b/tailbone/api/vendors.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,10 +24,6 @@ Tailbone Web API - Vendor Views """ -from __future__ import unicode_literals, absolute_import - -import six - from rattail.db import model from tailbone.api import APIMasterView @@ -44,7 +40,7 @@ class VendorView(APIMasterView): def normalize(self, vendor): return { 'uuid': vendor.uuid, - '_str': six.text_type(vendor), + '_str': str(vendor), 'id': vendor.id, 'name': vendor.name, } diff --git a/tailbone/api/workorders.py b/tailbone/api/workorders.py index eabe4cdb..19def6c4 100644 --- a/tailbone/api/workorders.py +++ b/tailbone/api/workorders.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,12 +24,8 @@ Tailbone Web API - Work Order Views """ -from __future__ import unicode_literals, absolute_import - import datetime -import six - from rattail.db.model import WorkOrder from cornice import Service @@ -44,19 +40,19 @@ class WorkOrderView(APIMasterView): object_url_prefix = '/workorder' def __init__(self, *args, **kwargs): - super(WorkOrderView, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) app = self.get_rattail_app() self.workorder_handler = app.get_workorder_handler() def normalize(self, workorder): - data = super(WorkOrderView, self).normalize(workorder) + data = super().normalize(workorder) data.update({ 'customer_name': workorder.customer.name, 'status_label': self.enum.WORKORDER_STATUS[workorder.status_code], - 'date_submitted': six.text_type(workorder.date_submitted or ''), - 'date_received': six.text_type(workorder.date_received or ''), - 'date_released': six.text_type(workorder.date_released or ''), - 'date_delivered': six.text_type(workorder.date_delivered or ''), + 'date_submitted': str(workorder.date_submitted or ''), + 'date_received': str(workorder.date_received or ''), + 'date_released': str(workorder.date_released or ''), + 'date_delivered': str(workorder.date_delivered or ''), }) return data @@ -87,7 +83,7 @@ class WorkOrderView(APIMasterView): if 'status_code' in data: data['status_code'] = int(data['status_code']) - return super(WorkOrderView, self).update_object(workorder, data) + return super().update_object(workorder, data) def status_codes(self): """ diff --git a/tailbone/app.py b/tailbone/app.py index d7155829..d2d0c5ef 100644 --- a/tailbone/app.py +++ b/tailbone/app.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,25 +24,20 @@ Application Entry Point """ -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 rattail.config import make_config, parse_list +from wuttjamaican.util import parse_list + +from rattail.config import make_config from rattail.exceptions import ConfigurationError -from rattail.db.types import GPCType from pyramid.config import Configurator -from pyramid.authentication import SessionAuthenticationPolicy from zope.sqlalchemy import register import tailbone.db -from tailbone.auth import TailboneAuthorizationPolicy +from tailbone.auth import TailboneSecurityPolicy from tailbone.config import csrf_token_name, csrf_header_name from tailbone.util import get_effective_theme, get_theme_template_path from tailbone.providers import get_all_providers @@ -63,16 +58,33 @@ 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 - rattail_config.configure_logging() + + # nb. this is for compaibility with wuttaweb + settings['wutta_config'] = rattail_config + + # must import all sqlalchemy models before things get rolling, + # otherwise can have errors about continuum TransactionMeta class + # not yet mapped, when relevant pages are first requested... + # cf. https://docs.pylonsproject.org/projects/pyramid_cookbook/en/latest/database/sqlalchemy.html#importing-all-sqlalchemy-models + # hat tip to https://stackoverflow.com/a/59241485 + if getattr(rattail_config, 'tempmon_engine', None): + from rattail_tempmon.db import model as tempmon_model, Session as TempmonSession + tempmon_session = TempmonSession() + tempmon_session.query(tempmon_model.Appliance).first() + tempmon_session.close() # configure database sessions - if hasattr(rattail_config, 'rattail_engine'): - tailbone.db.Session.configure(bind=rattail_config.rattail_engine) + if hasattr(rattail_config, 'appdb_engine'): + tailbone.db.Session.configure(bind=rattail_config.appdb_engine) if hasattr(rattail_config, 'trainwreck_engine'): tailbone.db.TrainwreckSession.configure(bind=rattail_config.trainwreck_engine) if hasattr(rattail_config, 'tempmon_engine'): 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': @@ -123,18 +135,21 @@ def make_pyramid_config(settings, configure_csrf=True): config.set_root_factory(Root) else: + # declare this web app of the "classic" variety + settings.setdefault('tailbone.classic', 'true') + # we want the new themes feature! establish_theme(settings) + settings.setdefault('fanstatic.versioning', 'true') settings.setdefault('pyramid_deform.template_search_path', 'tailbone:templates/deform') config = Configurator(settings=settings, root_factory=Root) - # add rattail config directly to registry + # add rattail config directly to registry, for access throughout the app config.registry['rattail_config'] = rattail_config # configure user authorization / authentication - config.set_authorization_policy(TailboneAuthorizationPolicy()) - config.set_authentication_policy(SessionAuthenticationPolicy()) + config.set_security_policy(TailboneSecurityPolicy()) # maybe require CSRF token protection if configure_csrf: @@ -145,9 +160,20 @@ 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: @@ -159,7 +185,7 @@ def make_pyramid_config(settings, configure_csrf=True): # fetch all tailbone providers providers = get_all_providers(rattail_config) - for provider in six.itervalues(providers): + for provider in providers.values(): # configure DB sessions associated with transaction manager provider.configure_db_sessions(rattail_config, config) @@ -170,13 +196,22 @@ def make_pyramid_config(settings, configure_csrf=True): for spec in includes: config.include(spec) - # Add some permissions magic. - config.add_directive('add_tailbone_permission_group', 'tailbone.auth.add_permission_group') - config.add_directive('add_tailbone_permission', 'tailbone.auth.add_permission') + # add some permissions magic + config.add_directive('add_wutta_permission_group', + 'wuttaweb.auth.add_permission_group') + config.add_directive('add_wutta_permission', + 'wuttaweb.auth.add_permission') + # TODO: deprecate / remove these + config.add_directive('add_tailbone_permission_group', + 'wuttaweb.auth.add_permission_group') + config.add_directive('add_tailbone_permission', + 'wuttaweb.auth.add_permission') # and some similar magic for certain master views config.add_directive('add_tailbone_index_page', 'tailbone.app.add_index_page') config.add_directive('add_tailbone_config_page', 'tailbone.app.add_config_page') + config.add_directive('add_tailbone_model_view', 'tailbone.app.add_model_view') + config.add_directive('add_tailbone_view_supplement', 'tailbone.app.add_view_supplement') config.add_directive('add_tailbone_websocket', 'tailbone.app.add_websocket') @@ -191,7 +226,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, six.string_types): + if isinstance(view, str): view_callable = rattail_app.load_object(view) else: view_callable = view @@ -239,6 +274,36 @@ def add_config_page(config, route_name, label, permission): config.action(None, action) +def add_model_view(config, model_name, label, route_prefix, permission_prefix): + """ + Register a model view for the app. + """ + def action(): + all_views = config.get_settings().get('tailbone_model_views', {}) + + model_views = all_views.setdefault(model_name, []) + model_views.append({ + 'label': label, + 'route_prefix': route_prefix, + 'permission_prefix': permission_prefix, + }) + + config.add_settings({'tailbone_model_views': all_views}) + + config.action(None, action) + + +def add_view_supplement(config, route_prefix, cls): + """ + Register a master view supplement for the app. + """ + def action(): + supplements = config.get_settings().get('tailbone_view_supplements', {}) + supplements.setdefault(route_prefix, []).append(cls) + config.add_settings({'tailbone_view_supplements': supplements}) + config.action(None, action) + + def establish_theme(settings): rattail_config = settings['rattail_config'] @@ -246,7 +311,7 @@ def establish_theme(settings): settings['tailbone.theme'] = theme directories = settings['mako.directories'] - if isinstance(directories, six.string_types): + if isinstance(directories, str): directories = parse_list(directories) path = get_theme_template_path(rattail_config) @@ -267,7 +332,8 @@ def main(global_config, **settings): """ This function returns a Pyramid WSGI application. """ - settings.setdefault('mako.directories', ['tailbone:templates']) + settings.setdefault('mako.directories', ['tailbone:templates', + 'wuttaweb:templates']) rattail_config = make_rattail_config(settings) pyramid_config = make_pyramid_config(settings) pyramid_config.include('tailbone') diff --git a/tailbone/asgi.py b/tailbone/asgi.py index f2146577..1afbe12a 100644 --- a/tailbone/asgi.py +++ b/tailbone/asgi.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,14 +24,10 @@ ASGI App Utilities """ -from __future__ import unicode_literals, absolute_import - import os +import configparser import logging -import six -from six.moves import configparser - from rattail.util import load_object from asgiref.wsgi import WsgiToAsgi @@ -49,6 +45,12 @@ class TailboneWsgiToAsgi(WsgiToAsgi): protocol = scope['type'] path = scope['path'] + # strip off the root path, if non-empty. needed for serving + # under /poser or anything other than true site root + root_path = scope['root_path'] + if root_path and path.startswith(root_path): + path = path[len(root_path):] + if protocol == 'websocket': websockets = self.wsgi_application.registry.get( 'tailbone_websockets', {}) @@ -85,7 +87,7 @@ def make_asgi_app(main_app=None): # parse the settings needed for pyramid app settings = dict(parser.items('app:main')) - if isinstance(main_app, six.string_types): + if isinstance(main_app, str): make_wsgi_app = load_object(main_app) elif callable(main_app): make_wsgi_app = main_app diff --git a/tailbone/auth.py b/tailbone/auth.py index 88fbab0b..95bf90ba 100644 --- a/tailbone/auth.py +++ b/tailbone/auth.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2021 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,32 +24,31 @@ Authentication & Authorization """ -from __future__ import unicode_literals, absolute_import - import logging +import re -from rattail import enum -from rattail.util import prettify, NOTSET +from wuttjamaican.util import UNSPECIFIED -from zope.interface import implementer -from pyramid.interfaces import IAuthorizationPolicy -from pyramid.security import remember, forget, Everyone, Authenticated +from pyramid.security import remember, forget +from wuttaweb.auth import WuttaSecurityPolicy from tailbone.db import Session log = logging.getLogger(__name__) -def login_user(request, user, timeout=NOTSET): +def login_user(request, user, timeout=UNSPECIFIED): """ Perform the steps necessary to login the given user. Note that this returns a ``headers`` dict which you should pass to the redirect. """ - user.record_event(enum.USER_EVENT_LOGIN) + config = request.rattail_config + app = config.get_app() + user.record_event(app.enum.USER_EVENT_LOGIN) headers = remember(request, user.uuid) - if timeout is NOTSET: - timeout = session_timeout_for_user(user) + if timeout is UNSPECIFIED: + timeout = session_timeout_for_user(config, user) log.debug("setting session timeout for '{}' to {}".format(user.username, timeout)) set_session_timeout(request, timeout) return headers @@ -60,24 +59,28 @@ def logout_user(request): Perform the logout action for the given request. Note that this returns a ``headers`` dict which you should pass to the redirect. """ + app = request.rattail_config.get_app() user = request.user if user: - user.record_event(enum.USER_EVENT_LOGOUT) + user.record_event(app.enum.USER_EVENT_LOGOUT) request.session.delete() request.session.invalidate() headers = forget(request) return headers -def session_timeout_for_user(user): +def session_timeout_for_user(config, user): """ Returns the "max" session timeout for the user, according to roles """ - from rattail.db.auth import authenticated_role + app = config.get_app() + auth = app.get_auth_handler() - roles = user.roles + [authenticated_role(Session())] + authenticated = auth.get_role_authenticated(Session()) + roles = user.roles + [authenticated] timeouts = [role.session_timeout for role in roles if role.session_timeout is not None] + if timeouts and 0 not in timeouts: return max(timeouts) @@ -89,58 +92,42 @@ def set_session_timeout(request, timeout): request.session['_timeout'] = timeout or None -@implementer(IAuthorizationPolicy) -class TailboneAuthorizationPolicy(object): +class TailboneSecurityPolicy(WuttaSecurityPolicy): - def permits(self, context, principals, permission): - config = context.request.rattail_config - model = config.get_model() + 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') app = config.get_app() - auth = app.get_auth_handler() + user = None - for userid in principals: - if userid not in (Everyone, Authenticated): - if context.request.user and context.request.user.uuid == userid: - return context.request.has_perm(permission) - else: - # this is pretty rare, but can happen in dev after - # re-creating the database, which means new user uuids. - # TODO: the odds of this query returning a user in that - # case, are probably nil, and we should just skip this bit? - user = Session.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 + if self.api_mode: - def principals_allowed_by_permission(self, context, permission): - raise NotImplementedError + # determine/load user from header token if present + credentials = request.headers.get('Authorization') + if credentials: + match = re.match(r'^Bearer (\S+)$', credentials) + if match: + token = match.group(1) + auth = app.get_auth_handler() + user = auth.authenticate_user_token(self.db_session, token) + if not user: -def add_permission_group(config, key, label=None, overwrite=True): - """ - Add a permission group to the app configuration. - """ - def action(): - perms = config.get_settings().get('tailbone_permissions', {}) - if key not in perms or overwrite: - group = perms.setdefault(key, {'key': key}) - group['label'] = label or prettify(key) - config.add_settings({'tailbone_permissions': perms}) - config.action(None, action) + # fetch user uuid from current session + uuid = self.session_helper.authenticated_userid(request) + if not uuid: + return + # fetch user object from db + model = app.model + user = self.db_session.get(model.User, uuid) + if not user: + return -def add_permission(config, groupkey, key, label=None): - """ - Add a permission to the app configuration. - """ - def action(): - perms = config.get_settings().get('tailbone_permissions', {}) - group = perms.setdefault(groupkey, {'key': groupkey}) - group.setdefault('label', prettify(groupkey)) - perm = group.setdefault('perms', {}).setdefault(key, {'key': key}) - perm['label'] = label or prettify(key) - config.add_settings({'tailbone_permissions': perms}) - config.action(None, action) + # this user is responsible for data changes in current request + self.db_session.set_continuum_user(user) + return user diff --git a/tailbone/beaker.py b/tailbone/beaker.py index 1f7f20c5..25a450df 100644 --- a/tailbone/beaker.py +++ b/tailbone/beaker.py @@ -1,8 +1,8 @@ -# -*- coding: utf-8 -*- +# -*- coding: utf-8; -*- ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -27,10 +27,12 @@ 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 from pyramid.settings import asbool @@ -45,6 +47,10 @@ class TailboneSession(Session): def load(self): "Loads the data from this session from persistent storage" + + # are we using older version of beaker? + old_beaker = parse_version(get_pkg_version('beaker')) < parse_version('1.12') + self.namespace = self.namespace_class(self.id, data_dir=self.data_dir, digest_filenames=False, @@ -60,8 +66,12 @@ class TailboneSession(Session): try: session_data = self.namespace['session'] - if (session_data is not None and self.encrypt_key): - session_data = self._decrypt_data(session_data) + if old_beaker: + if (session_data is not None and self.encrypt_key): + session_data = self._decrypt_data(session_data) + else: # beaker >= 1.12 + if session_data is not None: + session_data = self._decrypt_data(session_data) # Memcached always returns a key, its None when its not # present @@ -90,6 +100,7 @@ class TailboneSession(Session): # for this module entirely... timeout = session_data.get('_timeout', self.timeout) if timeout is not None and \ + '_accessed_time' in session_data and \ now - session_data['_accessed_time'] > timeout: timed_out = True else: @@ -103,9 +114,6 @@ class TailboneSession(Session): # Update the current _accessed_time session_data['_accessed_time'] = now - # Set the path if applicable - if '_path' in session_data: - self._path = session_data['_path'] self.update(session_data) self.accessed_dict = session_data.copy() finally: diff --git a/tailbone/cleanup.py b/tailbone/cleanup.py new file mode 100644 index 00000000..0ed5d026 --- /dev/null +++ b/tailbone/cleanup.py @@ -0,0 +1,80 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2022 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. +# +################################################################################ +""" +Cleanup logic +""" + +from __future__ import unicode_literals, absolute_import + +import os +import logging +import time + +from rattail.cleanup import Cleaner + + +log = logging.getLogger(__name__) + + +class BeakerCleaner(Cleaner): + """ + Cleanup logic for old Beaker session files. + """ + + def get_session_dir(self): + session_dir = self.config.get('rattail.cleanup', 'beaker.session_dir') + if session_dir and os.path.isdir(session_dir): + return session_dir + + session_dir = os.path.join(self.config.appdir(), 'sessions') + if os.path.isdir(session_dir): + return session_dir + + def cleanup(self, session, dry_run=False, progress=None, **kwargs): + session_dir = self.get_session_dir() + if not session_dir: + return + + data_dir = os.path.join(session_dir, 'data') + lock_dir = os.path.join(session_dir, 'lock') + + # looking for files older than X days + days = self.config.getint('rattail.cleanup', + 'beaker.session_cutoff_days', + default=30) + cutoff = time.time() - 3600 * 24 * days + + for topdir in (data_dir, lock_dir): + if not os.path.isdir(topdir): + continue + + for dirpath, dirnames, filenames in os.walk(topdir): + for fname in filenames: + path = os.path.join(dirpath, fname) + ts = os.path.getmtime(path) + if ts <= cutoff: + if dry_run: + log.debug("would delete file: %s", path) + else: + os.remove(path) + log.debug("deleted file: %s", path) diff --git a/tailbone/config.py b/tailbone/config.py index 4c393b49..8392ba0a 100644 --- a/tailbone/config.py +++ b/tailbone/config.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,15 +24,16 @@ 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(BaseExtension): +class ConfigExtension(WuttaConfigExtension): """ Rattail config extension for Tailbone. Does the following: @@ -49,9 +50,12 @@ class ConfigExtension(BaseExtension): configure_session(config, Session) # provide default theme selection - config.setdefault('tailbone', 'themes', 'default, falafel') + config.setdefault('tailbone', 'themes.keys', 'default, butterball') config.setdefault('tailbone', 'themes.expose_picker', 'true') + # override oruga detection + config.setdefault('wuttaweb.oruga_detector.spec', 'tailbone.util:should_use_oruga') + def csrf_token_name(config): return config.get('tailbone', 'csrf_token_name', default='_csrf') diff --git a/tailbone/db.py b/tailbone/db.py index ae919e49..8b37f399 100644 --- a/tailbone/db.py +++ b/tailbone/db.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2021 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -21,16 +21,13 @@ # ################################################################################ """ -Database Stuff +Database sessions etc. """ -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 @@ -45,23 +42,28 @@ TrainwreckSession = scoped_session(sessionmaker()) # empty dict for now, this must populated on app startup (if needed) ExtraTrainwreckSessions = {} -# some of the logic below may need to vary somewhat, based on which version of -# zope.sqlalchemy we have installed -zope_sqlalchemy_version = get_distribution('zope.sqlalchemy').version -zope_sqlalchemy_version_parsed = parse_version(zope_sqlalchemy_version) - class TailboneSessionDataManager(datamanager.SessionDataManager): - """Integrate a top level sqlalchemy session transaction into a zope transaction + """ + Integrate a top level sqlalchemy session transaction into a zope + transaction One phase variant. .. note:: - This class appears to be necessary in order for the Continuum - integration to work alongside the Zope transaction integration. + + This class appears to be necessary in order for the + SQLAlchemy-Continuum integration to work alongside the Zope + transaction integration. + + It subclasses + ``zope.sqlalchemy.datamanager.SessionDataManager`` but injects + some SQLAlchemy-Continuum logic within :meth:`tpc_vote()`, and + is sort of monkey-patched into the mix. """ def tpc_vote(self, trans): + """ """ # for a one phase data manager commit last in tpc_vote if self.tx is not None: # there may have been no work to do @@ -73,82 +75,117 @@ class TailboneSessionDataManager(datamanager.SessionDataManager): self._finish('committed') -def join_transaction(session, initial_state=datamanager.STATUS_ACTIVE, transaction_manager=datamanager.zope_transaction.manager, keep_session=False): - """Join a session to a transaction using the appropriate datamanager. +def join_transaction( + session, + initial_state=datamanager.STATUS_ACTIVE, + transaction_manager=datamanager.zope_transaction.manager, + keep_session=False, +): + """ + Join a session to a transaction using the appropriate datamanager. - It is safe to call this multiple times, if the session is already joined - then it just returns. + It is safe to call this multiple times, if the session is already + joined then it just returns. - `initial_state` is either STATUS_ACTIVE, STATUS_INVALIDATED or STATUS_READONLY + `initial_state` is either STATUS_ACTIVE, STATUS_INVALIDATED or + STATUS_READONLY - If using the default initial status of STATUS_ACTIVE, you must ensure that - mark_changed(session) is called when data is written to the database. + If using the default initial status of STATUS_ACTIVE, you must + ensure that mark_changed(session) is called when data is written + to the database. - The ZopeTransactionExtesion SessionExtension can be used to ensure that this is - called automatically after session write operations. + The ZopeTransactionExtesion SessionExtension can be used to ensure + that this is called automatically after session write operations. .. note:: - This function is copied from upstream, and tweaked so that our custom - :class:`TailboneSessionDataManager` will be used. + + This function appears to be necessary in order for the + SQLAlchemy-Continuum integration to work alongside the Zope + transaction integration. + + It overrides ``zope.sqlalchemy.datamanager.join_transaction()`` + to ensure the custom :class:`TailboneSessionDataManager` is + used, and is sort of monkey-patched into the mix. """ # the upstream internals of this function has changed a little over time. # unfortunately for us, that means we must include each variant here. - if zope_sqlalchemy_version_parsed >= parse_version('1.1'): # 1.1+ - if datamanager._SESSION_STATE.get(session, None) is None: - if session.twophase: - DataManager = datamanager.TwoPhaseSessionDataManager - else: - DataManager = TailboneSessionDataManager - DataManager(session, initial_state, transaction_manager, keep_session=keep_session) - - else: # pre-1.1 - if datamanager._SESSION_STATE.get(id(session), None) is None: - if session.twophase: - DataManager = datamanager.TwoPhaseSessionDataManager - else: - DataManager = TailboneSessionDataManager - DataManager(session, initial_state, transaction_manager, keep_session=keep_session) + if datamanager._SESSION_STATE.get(session, None) is None: + if session.twophase: + DataManager = datamanager.TwoPhaseSessionDataManager + else: + DataManager = TailboneSessionDataManager + DataManager(session, initial_state, transaction_manager, keep_session=keep_session) -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. +class ZopeTransactionEvents(datamanager.ZopeTransactionEvents): + """ + Record that a flush has occurred on a session's connection. This + allows the DataManager to rollback rather than commit on read only + transactions. .. note:: - This class is copied from upstream, and tweaked so that our custom - :func:`join_transaction()` will be used. + + This class appears to be necessary in order for the + SQLAlchemy-Continuum integration to work alongside the Zope + transaction integration. + + It subclasses + ``zope.sqlalchemy.datamanager.ZopeTransactionEvents`` but + overrides various methods to ensure the custom + :func:`join_transaction()` is called, and is sort of + monkey-patched into the mix. """ def after_begin(self, session, transaction, connection): - join_transaction(session, self.initial_state, self.transaction_manager, self.keep_session) + """ """ + 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) + """ """ + join_transaction(session, self.initial_state, + self.transaction_manager, self.keep_session) + + def join_transaction(self, session): + """ """ + join_transaction(session, self.initial_state, + self.transaction_manager, self.keep_session) -def register(session, initial_state=datamanager.STATUS_ACTIVE, - transaction_manager=datamanager.zope_transaction.manager, keep_session=False): - """Register ZopeTransaction listener events on the - given Session or Session factory/class. +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 is copied from upstream, and tweaked so that our custom - :class:`ZopeTransactionExtension` will be used. + + This function appears to be necessary in order for the + SQLAlchemy-Continuum integration to work alongside the Zope + transaction integration. + + It overrides ``zope.sqlalchemy.datamanager.regsiter()`` to + ensure the custom :class:`ZopeTransactionEvents` is used. """ from sqlalchemy import event - ext = ZopeTransactionExtension( - initial_state=initial_state, + ext = ZopeTransactionEvents( + initial_state=initial_state, transaction_manager=transaction_manager, keep_session=keep_session, ) @@ -160,6 +197,9 @@ def register(session, initial_state=datamanager.STATUS_ACTIVE, event.listen(session, "after_bulk_delete", ext.after_bulk_delete) event.listen(session, "before_commit", ext.before_commit) + if datamanager.SA_GE_14: + event.listen(session, "do_orm_execute", ext.do_orm_execute) + register(Session) register(TempmonSession) diff --git a/tailbone/diffs.py b/tailbone/diffs.py index d57aa9ac..2e582b15 100644 --- a/tailbone/diffs.py +++ b/tailbone/diffs.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2019 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,7 +24,8 @@ Tools for displaying data diffs """ -from __future__ import unicode_literals, absolute_import +import sqlalchemy as sa +import sqlalchemy_continuum as continuum from pyramid.renderers import render from webhelpers2.html import HTML @@ -33,37 +34,41 @@ 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, - render_field=None, render_value=None, + def __init__(self, old_data, new_data, columns=None, fields=None, enums=None, + render_field=None, render_value=None, nature='dirty', monospace=False, extra_row_attrs=None): - """ - Constructor. You must provide the old and new data sets, and - the set of relevant fields as well, if they cannot be easily - introspected. - - :param old_data: Dict of "old" data values. - - :param new_data: Dict of "old" data values. - - :param fields: Sequence of relevant field names. Note that - both data dicts are expected to have keys which match these - field names. If you do not specify the fields then they - will (hopefully) be introspected from the old or new data - sets; however this will not work if they are both empty. - - :param monospace: If true, this flag will cause the value - columns to be rendered in monospace font. This is assumed - to be helpful when comparing "raw" data values which are - shown as e.g. ``repr(val)``. - """ self.old_data = old_data self.new_data = new_data self.columns = columns or ["field name", "old value", "new value"] self.fields = fields or self.make_fields() + self.enums = enums or {} self._render_field = render_field or self.render_field_default self.render_value = render_value or self.render_value_default + self.nature = nature self.monospace = monospace self.extra_row_attrs = extra_row_attrs @@ -90,7 +95,7 @@ class Diff(object): for the given field. May be an empty string, or a snippet of HTML attribute syntax, e.g.: - .. code-highlight:: none + .. code-block:: none class="diff" foo="bar" @@ -126,3 +131,161 @@ 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, + } diff --git a/tailbone/exceptions.py b/tailbone/exceptions.py index beea1366..3468562a 100644 --- a/tailbone/exceptions.py +++ b/tailbone/exceptions.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2020 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,10 +24,6 @@ Tailbone Exceptions """ -from __future__ import unicode_literals, absolute_import - -import six - from rattail.exceptions import RattailError @@ -37,7 +33,6 @@ class TailboneError(RattailError): """ -@six.python_2_unicode_compatible class TailboneJSONFieldError(TailboneError): """ Error raised when JSON serialization of a form field results in an error. diff --git a/tailbone/forms/__init__.py b/tailbone/forms/__init__.py index a368f2d1..34b34a6c 100644 --- a/tailbone/forms/__init__.py +++ b/tailbone/forms/__init__.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2018 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,8 +24,7 @@ Forms Library """ -from __future__ import unicode_literals, absolute_import - -from . import types +# nb. import widgets before types, b/c types may refer to widgets from . import widgets +from . import types from .core import Form, SimpleFileImport diff --git a/tailbone/forms/common.py b/tailbone/forms/common.py index 4d58b943..6183d17f 100644 --- a/tailbone/forms/common.py +++ b/tailbone/forms/common.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,8 +24,6 @@ Common Forms """ -from __future__ import unicode_literals, absolute_import - from rattail.db import model import colander @@ -35,7 +33,7 @@ import colander def validate_user(node, kw): session = kw['session'] def validate(node, value): - user = session.query(model.User).get(value) + user = session.get(model.User, value) if not user: raise colander.Invalid(node, "User not found") return user.uuid diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index bf508a6f..4024557b 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,19 +24,18 @@ Forms Core """ -from __future__ import unicode_literals, absolute_import - +import hashlib 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.time import localtime -from rattail.util import prettify, pretty_boolean, pretty_quantity -from rattail.core import UNSPECIFIED +from rattail.util import pretty_boolean from rattail.db.util import get_fieldnames import colander @@ -48,9 +47,14 @@ from pyramid_deform import SessionFileUploadTempStore from pyramid.renderers import render from webhelpers2.html import tags, HTML -from tailbone.util import raw_datetime, get_form_data -from . import types -from .widgets import ReadonlyWidget, PlainDateWidget, JQueryDateWidget, JQueryTimeWidget +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.exceptions import TailboneJSONFieldError @@ -222,7 +226,7 @@ class CustomSchemaNode(SQLAlchemySchemaNode): if excludes: overrides['excludes'] = excludes - return super(CustomSchemaNode, self).get_schema_from_relationship(prop, overrides) + return super().get_schema_from_relationship(prop, overrides) def dictify(self, obj): """ Return a dictified version of `obj` using schema information. @@ -231,7 +235,7 @@ class CustomSchemaNode(SQLAlchemySchemaNode): This method was copied from upstream and modified to add automatic handling of "association proxy" fields. """ - dict_ = super(CustomSchemaNode, self).dictify(obj) + dict_ = super().dictify(obj) for node in self: name = node.name @@ -324,7 +328,7 @@ class Form(object): """ Base class for all forms. """ - save_label = "Save" + save_label = "Submit" update_label = "Save" show_cancel = True auto_disable = True @@ -335,8 +339,12 @@ class Form(object): model_instance=None, model_class=None, appstruct=UNSPECIFIED, nodes={}, enums={}, labels={}, assume_local_times=False, renderers=None, renderer_kwargs={}, hidden={}, widgets={}, defaults={}, validators={}, required={}, helptext={}, focus_spec=None, - action_url=None, cancel_url=None, use_buefy=None, component='tailbone-form', - vuejs_field_converters={}, + action_url=None, cancel_url=None, + vue_tagname=None, + vuejs_component_kwargs=None, vuejs_field_converters={}, json_data={}, included_templates={}, + # TODO: ugh this is getting out hand! + can_edit_help=False, edit_help_url=None, route_prefix=None, + **kwargs ): self.fields = None if fields is not None: @@ -344,6 +352,7 @@ 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 []) @@ -369,21 +378,83 @@ 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 - self.use_buefy = use_buefy - self.component = component + + # vue_tagname + self.vue_tagname = vue_tagname + if not self.vue_tagname and kwargs.get('component'): + warnings.warn("component kwarg is deprecated for Form(); " + "please use vue_tagname param instead", + DeprecationWarning, stacklevel=2) + self.vue_tagname = kwargs['component'] + if not self.vue_tagname: + self.vue_tagname = 'tailbone-form' + + self.vuejs_component_kwargs = vuejs_component_kwargs or {} self.vuejs_field_converters = vuejs_field_converters or {} + self.json_data = json_data or {} + self.included_templates = included_templates or {} + self.can_edit_help = can_edit_help + self.edit_help_url = edit_help_url + self.route_prefix = route_prefix + + self.button_icon_submit = kwargs.get('button_icon_submit', 'save') def __iter__(self): return iter(self.fields) @property - def component_studly(self): - words = self.component.split('-') + def vue_component(self): + """ + String name for the Vue component, e.g. ``'TailboneGrid'``. + + This is a generated value based on :attr:`vue_tagname`. + """ + words = self.vue_tagname.split('-') return ''.join([word.capitalize() for word in words]) + @property + def component(self): + """ + DEPRECATED - use :attr:`vue_tagname` instead. + """ + warnings.warn("Form.component is deprecated; " + "please use vue_tagname instead", + DeprecationWarning, stacklevel=2) + return self.vue_tagname + + @property + def component_studly(self): + """ + DEPRECATED - use :attr:`vue_component` instead. + """ + warnings.warn("Form.component_studly is deprecated; " + "please use vue_component instead", + DeprecationWarning, stacklevel=2) + return self.vue_component + + def get_button_label_submit(self): + """ """ + if hasattr(self, '_button_label_submit'): + return self._button_label_submit + + label = getattr(self, 'submit_label', None) + if label: + return label + + return self.save_label + + def set_button_label_submit(self, value): + """ """ + self._button_label_submit = value + + # wutta compat + button_label_submit = property(get_button_label_submit, + set_button_label_submit) + def __contains__(self, item): return item in self.fields @@ -400,6 +471,9 @@ 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`. @@ -556,7 +630,9 @@ class Form(object): self.schema[key].title = label def get_label(self, key): - return self.labels.get(key, prettify(key)) + config = self.request.rattail_config + app = config.get_app() + return self.labels.get(key, app.make_title(key)) def set_readonly(self, key, readonly=True): if readonly: @@ -573,9 +649,23 @@ class Form(object): node = colander.SchemaNode(nodeinfo, **kwargs) self.nodes[key] = node + # must explicitly replace node, if we already have a schema + if self.schema: + self.schema[key] = node + def set_type(self, key, type_, **kwargs): + 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': @@ -584,9 +674,14 @@ 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': @@ -613,17 +708,40 @@ class Form(object): self.set_widget(key, dfwidget.TextAreaWidget(cols=80, rows=8)) elif type_ == 'file': tmpstore = SessionFileUploadTempStore(self.request) - kw = {'widget': dfwidget.FileUploadWidget(tmpstore), + kw = {'widget': FileUploadWidget(tmpstore, request=self.request), 'title': self.get_label(key)} if 'required' in kwargs and not kwargs['required']: kw['missing'] = colander.null self.set_node(key, colander.SchemaNode(deform.FileData(), **kw)) - # must explicitly replace node, if we already have a schema - if self.schema: - self.schema[key] = self.nodes[key] + elif type_ == 'multi_file': + tmpstore = SessionFileUploadTempStore(self.request) + file_node = colander.SchemaNode(deform.FileData(), + name='upload') + + kw = {'name': key, + 'title': self.get_label(key), + 'widget': MultiFileUploadWidget(tmpstore)} + # if 'required' in kwargs and not kwargs['required']: + # kw['missing'] = colander.null + if kwargs.get('validate_unique'): + kw['validator'] = self.validate_multiple_files_unique + files_node = colander.SequenceSchema(file_node, **kw) + self.set_node(key, files_node) else: 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 @@ -687,9 +805,8 @@ class Form(object): case the validator pertains to the form at large instead of one of the fields. - TODO: what should the validator look like? - - :param validator: Callable validator for the node. + :param validator: Callable which accepts ``(node, value)`` + args. """ self.validators[key] = validator @@ -711,11 +828,16 @@ class Form(object): """ self.defaults[key] = value - def set_helptext(self, key, value): + def set_helptext(self, key, value, dynamic=False): """ Set the help text for a given field. """ - self.helptext[key] = value + # 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) def has_helptext(self, key): """ @@ -735,15 +857,15 @@ class Form(object): def set_vuejs_field_converter(self, field, converter): self.vuejs_field_converters[field] = converter - def render(self, template=None, **kwargs): - if not template: - 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 render(self, **kwargs): + warnings.warn("Form.render() is deprecated (for now?); " + "please use Form.render_deform() instead", + DeprecationWarning, stacklevel=2) + return self.render_deform(**kwargs) + + def get_deform(self): + """ """ + return self.make_deform_form() def make_deform_form(self): if not hasattr(self, 'deform_form'): @@ -783,31 +905,35 @@ 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: - if self.use_buefy: - template = '/forms/deform_buefy.mako' - else: - template = '/forms/deform.mako' + template = '/forms/deform.mako' if dform is None: dform = self.make_deform_form() # TODO: would perhaps be nice to leverage deform's default rendering # someday..? i.e. using Chameleon *.pt templates - # return form.render() + # return dform.render() context = kwargs context['form'] = self context['dform'] = dform + context.setdefault('can_edit_help', self.can_edit_help) + if context['can_edit_help']: + context.setdefault('edit_help_url', self.edit_help_url) + context['field_labels'] = self.get_field_labels() + context['field_markdowns'] = self.get_field_markdowns() context.setdefault('form_kwargs', {}) # TODO: deprecate / remove the latter option here if self.auto_disable_save or self.auto_disable: - 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' + context['form_kwargs'].setdefault('ref', self.vue_component) + context['form_kwargs']['@submit'] = 'submit{}'.format(self.vue_component) if self.focus_spec: context['form_kwargs']['data-focus'] = self.focus_spec context['request'] = self.request @@ -815,6 +941,36 @@ class Form(object): context['render_field_readonly'] = self.render_field_readonly return render(template, context) + def get_field_labels(self): + return dict([(field, self.get_label(field)) + for field in self]) + + def get_field_markdowns(self, session=None): + app = self.request.rattail_config.get_app() + model = app.model + session = session or Session() + + if not hasattr(self, 'field_markdowns'): + infos = session.query(model.TailboneFieldInfo)\ + .filter(model.TailboneFieldInfo.route_prefix == self.route_prefix)\ + .all() + self.field_markdowns = dict([(info.field_name, info.markdown_text) + for info in infos]) + + return self.field_markdowns + + def get_vue_field_value(self, key): + """ """ + if key not in self.fields: + return + + dform = self.get_deform() + if key not in dform: + return + + field = dform[key] + return make_json_safe(field.cstruct) + def get_vuejs_model_value(self, field): """ This method must return "raw" JS which will be assigned as the initial @@ -826,25 +982,38 @@ class Form(object): value = convert(field.cstruct) return json.dumps(value) - if isinstance(field.schema.typ, deform.FileData): - # TODO: we used to always/only return 'null' here but hopefully - # this also works, to show existing filename when present - if field.cstruct and field.cstruct['filename']: - return json.dumps({'name': field.cstruct['filename']}) - return 'null' - if isinstance(field.schema.typ, colander.Set): if field.cstruct is colander.null: return '[]' - if field.cstruct is colander.null: - return 'null' - try: - return json.dumps(field.cstruct) + return self.jsonify_value(field.cstruct) except Exception as error: raise TailboneJSONFieldError(field.name, error) + def jsonify_value(self, value): + """ + Take a Python value and convert to JSON + """ + if value is colander.null: + return 'null' + + if isinstance(value, dfwidget.filedict): + # TODO: we used to always/only return 'null' here but hopefully + # this also works, to show existing filename when present + if value and value['filename']: + return json.dumps({'name': value['filename']}) + return 'null' + + elif isinstance(value, list) and all([isinstance(f, dfwidget.filedict) + for f in value]): + return json.dumps([{'name': f['filename']} + for f in value]) + + app = self.request.rattail_config.get_app() + value = app.json_friendly(value) + return json.dumps(value) + def get_error_messages(self, field): if field.error: return field.error.messages() @@ -865,68 +1034,208 @@ class Form(object): return False return True - def render_buefy_field(self, fieldname, bfield_attrs={}): + 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): """ - 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. + 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. """ dform = self.make_deform_form() - field = dform[fieldname] + field = dform[fieldname] if fieldname in dform else None + + include = bool(field) + if self.readonly or (not field and fieldname in self.readonly_fields): + include = True + if not include: + return if self.field_visible(fieldname): + label = self.get_label(fieldname) + markdowns = self.get_field_markdowns(session=session) # these attrs will be for the <b-field> (*not* the widget) attrs = { ':horizontal': 'true', - 'label': self.get_label(fieldname), } # add some magic for file input fields - if isinstance(field.schema.typ, deform.FileData): + if field and isinstance(field.schema.typ, deform.FileData): attrs['class_'] = 'file' - # show helptext if present - if self.has_helptext(fieldname): - attrs['message'] = self.render_helptext(fieldname) + # 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) + error_messages = self.get_error_messages(field) if field else None if error_messages: + field_type = 'is-danger' + messages.extend(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])) + # 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.update({ - 'type': 'is-danger', - ':message': message, - }) + # ..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])) # merge anything caller provided attrs.update(bfield_attrs) # render the field widget or whatever - html = field.serialize(use_buefy=True, - **self.get_renderer_kwargs(fieldname)) - # TODO: why do we not get HTML literal from serialize() ? - html = HTML.literal(html) + 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 = HTML.literal(html) + + # may need a complex label + label_contents = [label] + + # add 'help' icon/tooltip if defined + if markdowns.get(fieldname): + icon = HTML.tag('b-icon', size='is-small', pack='fas', + icon='question-circle') + tooltip = render_markdown(markdowns[fieldname]) + + # nb. must apply hack to get <template #content> as final result + tooltip_template = HTML.tag('template', c=[tooltip], + **{'#content': 1}) + tooltip_template = tooltip_template.replace( + HTML.literal('<template #content="1"'), + HTML.literal('<template #content')) + + tooltip = HTML.tag('b-tooltip', + type='is-white', + size='is-large', + multilined='multilined', + c=[icon, tooltip_template]) + label_contents.append(HTML.literal(' ')) + label_contents.append(tooltip) + + # add 'configure' icon if allowed + if self.can_edit_help: + icon = HTML.tag('b-icon', size='is-small', pack='fas', + icon='cog') + icon = HTML.tag('a', title="Configure field", c=[icon], + **{'@click.prevent': "configureFieldInit('{}')".format(fieldname), + 'v-show': 'configureFieldsHelp'}) + label_contents.append(HTML.literal(' ')) + label_contents.append(icon) + + # only declare label template if it's complex + html = [html] + # TODO: figure out why complex label does not work for oruga + if self.request.use_oruga: + attrs['label'] = label + else: + if len(label_contents) > 1: + + # nb. must apply hack to get <template #label> as final result + label_template = HTML.tag('template', c=label_contents, + **{'#label': 1}) + label_template = label_template.replace( + HTML.literal('<template #label="1"'), + HTML.literal('<template #label')) + html.insert(0, label_template) + + else: # simple label + attrs['label'] = label # and finally wrap it all in a <b-field> - return HTML.tag('b-field', c=[html], **attrs) + return HTML.tag('b-field', c=html, **attrs) - else: # hidden field + elif field: # hidden field # can just do normal thing for these # 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. @@ -937,20 +1246,30 @@ class Form(object): if field_name not in self.fields: return '' - # TODO: fair bit of duplication here, should merge with deform.mako label = kwargs.get('label') if not label: label = self.get_label(field_name) - label = HTML.tag('label', label, for_=field_name) - field = self.render_field_value(field_name) or '' - field_div = HTML.tag('div', class_='field', c=[field]) - contents = [label, field_div] - if self.has_helptext(field_name): - contents.append(HTML.tag('span', class_='instructions', - c=[self.render_helptext(field_name)])) + value = self.render_field_value(field_name) or '' - return HTML.tag('div', class_='field-wrapper {}'.format(field_name), c=contents) + if not self.request.use_oruga: + + label = HTML.tag('label', label, for_=field_name) + field_div = HTML.tag('div', class_='field', c=[value]) + contents = [label, field_div] + + if self.has_helptext(field_name): + contents.append(HTML.tag('span', class_='instructions', + c=[self.render_helptext(field_name)])) + + return HTML.tag('div', class_='field-wrapper {}'.format(field_name), c=contents) + + # nb. for some reason we must wrap once more for oruga, + # otherwise it splits up the field?! + value = HTML.tag('span', c=[value]) + + # oruga uses <o-field> + return HTML.tag('o-field', label=label, c=[value], **{':horizontal': 'true'}) def render_field_value(self, field_name): record = self.model_instance @@ -962,7 +1281,7 @@ class Form(object): value = self.obtain_value(record, field_name) if value is None: return "" - return six.text_type(value) + return str(value) def render_datetime(self, record, field_name): value = self.obtain_value(record, field_name) @@ -974,7 +1293,8 @@ class Form(object): value = self.obtain_value(record, field_name) if value is None: return "" - value = localtime(self.request.rattail_config, value) + app = self.request.rattail_config.get_app() + value = app.localtime(value) return raw_datetime(self.request.rattail_config, value) def render_duration(self, record, field_name): @@ -997,13 +1317,14 @@ class Form(object): return "(${:0,.2f})".format(0 - value) return "${:0,.2f}".format(value) except ValueError: - return six.text_type(value) + return str(value) def render_quantity(self, obj, field): value = self.obtain_value(obj, field) if value is None: return "" - return pretty_quantity(value) + app = self.request.rattail_config.get_app() + return app.render_quantity(value) def render_percent(self, obj, field): app = self.request.rattail_config.get_app() @@ -1022,8 +1343,8 @@ class Form(object): return "" enum = self.enums.get(field_name) if enum and value in enum: - return six.text_type(enum[value]) - return six.text_type(value) + return str(enum[value]) + return str(value) def render_codeblock(self, record, field_name): value = self.obtain_value(record, field_name) @@ -1054,83 +1375,70 @@ 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 TypeError: - return getattr(record, field_name, None) + pass # 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): - if kwargs.pop('newstyle', False): - # yay, new behavior! - if hasattr(self, 'validated'): - del self.validated - if self.request.method != 'POST': - return False + """ + Try to validate the form. - controls = get_form_data(self.request).items() + This should work whether data was submitted as classic POST + data, or as JSON body. - # 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) + :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) - dform = self.make_deform_form() - try: - self.validated = dform.validate(controls) - return True - except deform.ValidationFailure: - return False + if hasattr(self, 'validated'): + del self.validated + if self.request.method != 'POST': + return False - 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 + controls = get_form_data(self.request).items() + # 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) -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) + dform = self.make_deform_form() + try: + self.validated = dform.validate(controls) + return True + except deform.ValidationFailure: + return False @colander.deferred diff --git a/tailbone/forms/receiving.py b/tailbone/forms/receiving.py index 40fa35fe..9f5706c7 100644 --- a/tailbone/forms/receiving.py +++ b/tailbone/forms/receiving.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2019 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,8 +24,6 @@ Forms for Receiving """ -from __future__ import unicode_literals, absolute_import - from rattail.db import model import colander @@ -35,7 +33,7 @@ import colander def valid_purchase_batch_row(node, kw): session = kw['session'] def validate(node, value): - row = session.query(model.PurchaseBatchRow).get(value) + row = session.get(model.PurchaseBatchRow, value) if not row: raise colander.Invalid(node, "Batch row not found") if row.batch.executed: @@ -54,6 +52,7 @@ class ReceiveRow(colander.MappingSchema): 'received', 'damaged', 'expired', + 'missing', # 'mispick', ])) diff --git a/tailbone/forms/types.py b/tailbone/forms/types.py index d9f7e828..ac7f2d43 100644 --- a/tailbone/forms/types.py +++ b/tailbone/forms/types.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2019 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,12 +24,9 @@ Form Schema Types """ -from __future__ import unicode_literals, absolute_import - import re import datetime - -import six +import json from rattail.db import model from rattail.gpc import GPC @@ -37,6 +34,7 @@ from rattail.gpc import GPC import colander from tailbone.db import Session +from tailbone.forms import widgets class JQueryTime(colander.Time): @@ -76,6 +74,76 @@ 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. @@ -84,7 +152,7 @@ class GPCType(colander.SchemaType): def serialize(self, node, appstruct): if appstruct is colander.null: return colander.null - return six.text_type(appstruct) + return str(appstruct) def deserialize(self, node, cstruct): if not cstruct: @@ -95,7 +163,7 @@ class GPCType(colander.SchemaType): try: return GPC(digits) except Exception as err: - raise colander.Invalid(node, six.text_type(err)) + raise colander.Invalid(node, str(err)) class ProductQuantity(colander.MappingSchema): @@ -133,12 +201,12 @@ class ModelType(colander.SchemaType): def serialize(self, node, appstruct): if appstruct is colander.null: return colander.null - return six.text_type(appstruct) + return str(appstruct) def deserialize(self, node, cstruct): if not cstruct: return None - obj = self.session.query(self.model_class).get(cstruct) + obj = self.session.get(self.model_class, cstruct) if not obj: raise colander.Invalid(node, "{} not found".format(self.model_title)) return obj diff --git a/tailbone/forms/widgets.py b/tailbone/forms/widgets.py index e72ab6b9..8c16726d 100644 --- a/tailbone/forms/widgets.py +++ b/tailbone/forms/widgets.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,20 +24,16 @@ Form Widgets """ -from __future__ import unicode_literals, absolute_import, division - import json import datetime import decimal - -import six +import re 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): @@ -45,6 +41,7 @@ class ReadonlyWidget(dfwidget.HiddenWidget): readonly = True def serialize(self, field, cstruct, **kw): + """ """ if cstruct in (colander.null, None): cstruct = '' # TODO: is this hacky? @@ -61,11 +58,11 @@ class NumberInputWidget(dfwidget.TextInputWidget): class NumericInputWidget(NumberInputWidget): """ - This widget only supports Buefy themes for now. It uses a - ``<numeric-input>`` component, which will leverage the ``numeric.js`` - functions to ensure user doesn't enter any non-numeric values. Note that - this still uses a normal "text" input on the HTML side, as opposed to a - "number" input, since the latter is a bit ugly IMHO. + This widget uses a ``<numeric-input>`` component, which will + leverage the ``numeric.js`` functions to ensure user doesn't enter + any non-numeric values. Note that this still uses a normal "text" + input on the HTML side, as opposed to a "number" input, since the + latter is a bit ugly IMHO. """ template = 'numericinput' allow_enter = True @@ -82,15 +79,17 @@ class PercentInputWidget(dfwidget.TextInputWidget): autocomplete = 'off' def serialize(self, field, cstruct, **kw): + """ """ if cstruct not in (colander.null, None): # convert "traditional" value to "human-friendly" value = decimal.Decimal(cstruct) * 100 value = value.quantize(decimal.Decimal('0.001')) - cstruct = six.text_type(value) - return super(PercentInputWidget, self).serialize(field, cstruct, **kw) + cstruct = str(value) + return super().serialize(field, cstruct, **kw) def deserialize(self, field, pstruct): - pstruct = super(PercentInputWidget, self).deserialize(field, pstruct) + """ """ + pstruct = super().deserialize(field, pstruct) if pstruct is colander.null: return colander.null # convert "human-friendly" value to "traditional" @@ -100,7 +99,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 six.text_type(value) + return str(value) class CasesUnitsWidget(dfwidget.Widget): @@ -113,6 +112,7 @@ class CasesUnitsWidget(dfwidget.Widget): one_amount_only = False def serialize(self, field, cstruct, **kw): + """ """ if cstruct in (colander.null, None): cstruct = '' readonly = kw.get('readonly', self.readonly) @@ -123,6 +123,9 @@ 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 @@ -151,6 +154,7 @@ class DynamicCheckboxWidget(dfwidget.CheckboxWidget): template = 'checkbox_dynamic' +# TODO: deprecate / remove this class PlainSelectWidget(dfwidget.SelectWidget): template = 'select_plain' @@ -169,7 +173,7 @@ class CustomSelectWidget(dfwidget.SelectWidget): self.extra_template_values.update(kw) def get_template_values(self, field, cstruct, kw): - values = super(CustomSelectWidget, self).get_template_values(field, cstruct, kw) + values = super().get_template_values(field, cstruct, kw) if hasattr(self, 'extra_template_values'): values.update(self.extra_template_values) return values @@ -212,6 +216,7 @@ class JQueryDateWidget(dfwidget.DateInputWidget): ) def serialize(self, field, cstruct, **kw): + """ """ if cstruct in (colander.null, None): cstruct = '' readonly = kw.get('readonly', self.readonly) @@ -239,6 +244,48 @@ 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 @@ -261,6 +308,7 @@ class JQueryAutocompleteWidget(dfwidget.AutocompleteInputWidget): options = None def serialize(self, field, cstruct, **kw): + """ """ if 'delay' in kw or getattr(self, 'delay', None): raise ValueError( 'AutocompleteWidget does not support *delay* parameter ' @@ -289,6 +337,114 @@ 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. + """ + template = 'multi_file_upload' + requirements = () + + def serialize(self, field, cstruct, **kw): + """ """ + if cstruct in (colander.null, None): + cstruct = [] + + if cstruct: + for fileinfo in cstruct: + uid = fileinfo['uid'] + if uid not in self.tmpstore: + self.tmpstore[uid] = fileinfo + + readonly = kw.get("readonly", self.readonly) + template = readonly and self.readonly_template or self.template + values = self.get_template_values(field, cstruct, kw) + return field.renderer(template, **values) + + def deserialize(self, field, pstruct): + """ """ + if pstruct is colander.null: + return colander.null + + # TODO: why is this a thing? pstruct == [b''] + if len(pstruct) == 1 and pstruct[0] == b'': + return colander.null + + files_data = [] + for upload in pstruct: + + data = self.deserialize_upload(upload) + if data: + files_data.append(data) + + if not files_data: + return colander.null + + return files_data + + def deserialize_upload(self, upload): + """ """ + # nb. this logic was copied from parent class and adapted + # to allow for multiple files. needs some more love. + + uid = None # TODO? + + if hasattr(upload, "file"): + # the upload control had a file selected + data = dfwidget.filedict() + data["fp"] = upload.file + filename = upload.filename + # sanitize IE whole-path filenames + filename = filename[filename.rfind("\\") + 1 :].strip() + data["filename"] = filename + data["mimetype"] = upload.type + data["size"] = upload.length + if uid is None: + # no previous file exists + while 1: + uid = self.random_id() + if self.tmpstore.get(uid) is None: + data["uid"] = uid + self.tmpstore[uid] = data + preview_url = self.tmpstore.preview_url(uid) + self.tmpstore[uid]["preview_url"] = preview_url + break + else: + # a previous file exists + data["uid"] = uid + self.tmpstore[uid] = data + preview_url = self.tmpstore.preview_url(uid) + self.tmpstore[uid]["preview_url"] = preview_url + else: + # the upload control had no file selected + if uid is None: + # no previous file exists + return colander.null + else: + # a previous file should exist + data = self.tmpstore.get(uid) + # but if it doesn't, don't blow up + if data is None: + return colander.null + return data + + def make_customer_widget(request, **kwargs): """ Make a customer widget; will be either autocomplete or dropdown @@ -313,13 +469,16 @@ def make_customer_widget(request, **kwargs): class CustomerAutocompleteWidget(JQueryAutocompleteWidget): """ - Autocomplete widget for a Customer reference field. + Autocomplete widget for a + :class:`~rattail:rattail.db.model.customers.Customer` reference + field. """ def __init__(self, request, *args, **kwargs): - super(CustomerAutocompleteWidget, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) self.request = request - model = self.request.rattail_config.get_model() + app = self.request.rattail_config.get_app() + model = app.model # must figure out URL providing autocomplete service if 'service_url' not in kwargs: @@ -337,26 +496,30 @@ class CustomerAutocompleteWidget(JQueryAutocompleteWidget): self.input_callback = input_handler def serialize(self, field, cstruct, **kw): - + """ """ # fetch customer to provide button label, if we have a value if cstruct: - model = self.request.rattail_config.get_model() - customer = Session.query(model.Customer).get(cstruct) + app = self.request.rattail_config.get_app() + model = app.model + customer = Session.get(model.Customer, cstruct) if customer: - self.field_display = six.text_type(customer) + self.field_display = str(customer) - return super(CustomerAutocompleteWidget, self).serialize( + return super().serialize( field, cstruct, **kw) class CustomerDropdownWidget(dfwidget.SelectWidget): """ - Dropdown widget for a Customer reference field. + Dropdown widget for a + :class:`~rattail:rattail.db.model.customers.Customer` reference + field. """ def __init__(self, request, *args, **kwargs): - super(CustomerDropdownWidget, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) self.request = request + app = self.request.rattail_config.get_app() # must figure out dropdown values, if they weren't given if 'values' not in kwargs: @@ -368,10 +531,8 @@ class CustomerDropdownWidget(dfwidget.SelectWidget): customers = customers() else: # default customer list - model = self.request.rattail_config.get_model() - customers = Session.query(model.Customer)\ - .order_by(model.Customer.name)\ - .all() + customers = app.get_clientele_handler()\ + .get_all_customers(Session()) # convert customer list to option values self.values = [(c.uuid, c.name) @@ -393,13 +554,106 @@ class DepartmentWidget(dfwidget.SelectWidget): def __init__(self, request, **kwargs): if 'values' not in kwargs: - model = request.rattail_config.get_model() + app = request.rattail_config.get_app() + model = app.model departments = Session.query(model.Department)\ .order_by(model.Department.number) - values = [(dept.uuid, six.text_type(dept)) + values = [(dept.uuid, str(dept)) for dept in departments] if not kwargs.pop('required', True): values.insert(0, ('', "(none)")) kwargs['values'] = values - super(DepartmentWidget, self).__init__(**kwargs) + 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] diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index 2f11f094..56b97b86 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,25 +24,24 @@ Core Grid Classes """ -from __future__ import unicode_literals, absolute_import - -import warnings +import inspect import logging +import warnings +from urllib.parse import urlencode -import six -from six.moves import urllib import sqlalchemy as sa from sqlalchemy import orm +from wuttjamaican.util import UNSPECIFIED from rattail.db.types import GPCType -from rattail.util import prettify, pretty_boolean, pretty_quantity, pretty_hours -from rattail.time import localtime +from rattail.util import prettify, pretty_boolean -import webhelpers2_grid from pyramid.renderers import render from webhelpers2.html import HTML, tags from paginate_sqlalchemy import SqlalchemyOrmPage +from wuttaweb.grids import Grid as WuttaGrid, GridAction as WuttaGridAction, SortInfo +from wuttaweb.util import FieldList from . import filters as gridfilters from tailbone.db import Session from tailbone.util import raw_datetime @@ -51,23 +50,85 @@ from tailbone.util import raw_datetime log = logging.getLogger(__name__) -class FieldList(list): - """ - Convenience wrapper for a field list. +class Grid(WuttaGrid): """ + Base class for all grids. - def insert_before(self, field, newfield): - i = self.index(field) - self.insert(i, newfield) + This is now a subclass of + :class:`wuttaweb:wuttaweb.grids.base.Grid`, and exists to add + customizations which have traditionally been part of Tailbone. - def insert_after(self, field, newfield): - i = self.index(field) - self.insert(i + 1, newfield) + Some of these customizations are still undocumented. Some will + eventually be moved to the upstream/parent class, and possibly + some will be removed outright. What docs we have, are shown here. + .. _Buefy docs: https://buefy.org/documentation/table/ -class Grid(object): - """ - Core grid class. In sore need of documentation. + .. attribute:: checkable + + Optional callback to determine if a given row is checkable, + i.e. this allows hiding checkbox for certain rows if needed. + + This may be either a Python callable, or string representing a + JS callable. If the latter, according to the `Buefy docs`_: + + .. code-block:: none + + Custom method to verify if a row is checkable, works when is + checkable. + + Function (row: Object) + + In other words this JS callback would be invoked for each data + row in the client-side grid. + + But if a Python callable is used, then it will be invoked for + each row object in the server-side grid. For instance:: + + def checkable(obj): + if obj.some_property == True: + return True + return False + + grid.checkable = checkable + + .. attribute:: check_handler + + Optional JS callback for the ``@check`` event of the underlying + Buefy table component. See the `Buefy docs`_ for more info, + but for convenience they say this (as of writing): + + .. code-block:: none + + Triggers when the checkbox in a row is clicked and/or when + the header checkbox is clicked + + For instance, you might set ``grid.check_handler = + 'rowChecked'`` and then define the handler within your template + (e.g. ``/widgets/index.mako``) like so: + + .. code-block:: none + + <%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> + + TailboneGrid.methods.rowChecked = function(checkedList, row) { + if (!row) { + console.log("no row, so header checkbox was clicked") + } else { + console.log(row) + if (checkedList.includes(row)) { + console.log("clicking row checkbox ON") + } else { + console.log("clicking row checkbox OFF") + } + } + console.log(checkedList) + } + + </script> + </%def> .. attribute:: raw_renderers @@ -105,31 +166,109 @@ class Grid(object): 'myfield': myrender, }, ) + + .. attribute row_uuid_getter:: + + Optional callable to obtain the "UUID" (sic) value for each + data row. The default assumption as that each row object has a + ``uuid`` attribute, but when that isn't the case, *and* the + grid needs to support checkboxes, we must "pretend" by + injecting some custom value to the ``uuid`` of the row data. + + If necssary, set this to a callable like so:: + + def fake_uuid(row): + return row.some_custom_key + + grid.row_uuid_getter = fake_uuid """ - def __init__(self, key, data, columns=None, width='auto', request=None, - model_class=None, model_title=None, model_title_plural=None, - enums={}, labels={}, assume_local_times=False, renderers={}, invisible=[], - raw_renderers={}, - extra_row_class=None, linked_columns=[], url='#', - joiners={}, filterable=False, filters={}, use_byte_string_filters=False, - searchable={}, - sortable=False, sorters={}, default_sortkey=None, default_sortdir='asc', - pageable=False, default_pagesize=None, default_page=1, - checkboxes=False, checked=None, check_handler=None, check_all_handler=None, - clicking_row_checks_box=False, click_handlers=None, - main_actions=[], more_actions=[], delete_speedbump=False, - ajax_data_url=None, component='tailbone-grid', - **kwargs): + def __init__( + self, + request, + key=None, + data=None, + width='auto', + model_title=None, + model_title_plural=None, + enums={}, + assume_local_times=False, + invisible=[], + raw_renderers={}, + extra_row_class=None, + url='#', + use_byte_string_filters=False, + checkboxes=False, + checked=None, + check_handler=None, + check_all_handler=None, + checkable=None, + row_uuid_getter=None, + clicking_row_checks_box=False, + click_handlers=None, + main_actions=[], + more_actions=[], + delete_speedbump=False, + ajax_data_url=None, + expose_direct_link=False, + **kwargs, + ): + if 'component' in kwargs: + warnings.warn("component param is deprecated for Grid(); " + "please use vue_tagname param instead", + DeprecationWarning, stacklevel=2) + kwargs.setdefault('vue_tagname', kwargs.pop('component')) - self.key = key - self.data = data - self.columns = FieldList(columns) if columns is not None else None - self.width = width - self.request = request - self.model_class = model_class - if self.model_class and self.columns is None: - self.columns = self.make_columns() + if 'default_sortkey' in kwargs: + warnings.warn("default_sortkey param is deprecated for Grid(); " + "please use sort_defaults param instead", + DeprecationWarning, stacklevel=2) + if 'default_sortdir' in kwargs: + warnings.warn("default_sortdir param is deprecated for Grid(); " + "please use sort_defaults param instead", + DeprecationWarning, stacklevel=2) + if 'default_sortkey' in kwargs or 'default_sortdir' in kwargs: + sortkey = kwargs.pop('default_sortkey', None) + sortdir = kwargs.pop('default_sortdir', 'asc') + if sortkey: + kwargs.setdefault('sort_defaults', [(sortkey, sortdir)]) + + if 'pageable' in kwargs: + warnings.warn("pageable param is deprecated for Grid(); " + "please use paginated param instead", + DeprecationWarning, stacklevel=2) + kwargs.setdefault('paginated', kwargs.pop('pageable')) + + if 'default_pagesize' in kwargs: + warnings.warn("default_pagesize param is deprecated for Grid(); " + "please use pagesize param instead", + DeprecationWarning, stacklevel=2) + kwargs.setdefault('pagesize', kwargs.pop('default_pagesize')) + + if 'default_page' in kwargs: + warnings.warn("default_page param is deprecated for Grid(); " + "please use page param instead", + DeprecationWarning, stacklevel=2) + kwargs.setdefault('page', kwargs.pop('default_page')) + + if 'searchable' in kwargs: + warnings.warn("searchable param is deprecated for Grid(); " + "please use searchable_columns param instead", + DeprecationWarning, stacklevel=2) + kwargs.setdefault('searchable_columns', kwargs.pop('searchable')) + + # TODO: this should not be needed once all templates correctly + # reference grid.vue_component etc. + kwargs.setdefault('vue_tagname', 'tailbone-grid') + + # nb. these must be set before super init, as they are + # referenced when constructing filters + self.assume_local_times = assume_local_times + self.use_byte_string_filters = use_byte_string_filters + + kwargs['key'] = key + kwargs['data'] = data + super().__init__(request, **kwargs) self.model_title = model_title if not self.model_title and self.model_class and hasattr(self.model_class, 'get_model_title'): @@ -142,32 +281,13 @@ class Grid(object): if not self.model_title_plural: self.model_title_plural = '{}s'.format(self.model_title) + self.width = width self.enums = enums or {} - - self.labels = labels or {} - self.assume_local_times = assume_local_times - self.renderers = self.make_default_renderers(renderers or {}) + self.renderers = self.make_default_renderers(self.renderers) self.raw_renderers = raw_renderers or {} self.invisible = invisible or [] self.extra_row_class = extra_row_class - self.linked_columns = linked_columns or [] self.url = url - self.joiners = joiners or {} - - self.filterable = filterable - self.use_byte_string_filters = use_byte_string_filters - self.filters = self.make_filters(filters) - - self.searchable = searchable or {} - - self.sortable = sortable - self.sorters = self.make_sorters(sorters) - self.default_sortkey = default_sortkey - self.default_sortdir = default_sortdir - - self.pageable = pageable - self.default_pagesize = default_pagesize - self.default_page = default_page self.checkboxes = checkboxes self.checked = checked @@ -175,46 +295,110 @@ class Grid(object): self.checked = lambda item: False self.check_handler = check_handler self.check_all_handler = check_all_handler + self.checkable = checkable + self.row_uuid_getter = row_uuid_getter self.clicking_row_checks_box = clicking_row_checks_box self.click_handlers = click_handlers or {} - self.main_actions = main_actions or [] - self.more_actions = more_actions or [] self.delete_speedbump = delete_speedbump if ajax_data_url: self.ajax_data_url = ajax_data_url elif self.request: - self.ajax_data_url = self.request.current_route_url() + self.ajax_data_url = self.request.path_url else: self.ajax_data_url = '' - self.component = component + self.main_actions = main_actions or [] + if self.main_actions: + warnings.warn("main_actions param is deprecated for Grdi(); " + "please use actions param instead", + DeprecationWarning, stacklevel=2) + self.actions.extend(self.main_actions) + self.more_actions = more_actions or [] + if self.more_actions: + warnings.warn("more_actions param is deprecated for Grdi(); " + "please use actions param instead", + DeprecationWarning, stacklevel=2) + self.actions.extend(self.more_actions) + + self.expose_direct_link = expose_direct_link self._whgrid_kwargs = kwargs + @property + def component(self): + """ """ + warnings.warn("Grid.component is deprecated; " + "please use vue_tagname instead", + DeprecationWarning, stacklevel=2) + return self.vue_tagname + @property def component_studly(self): - words = self.component.split('-') - return ''.join([word.capitalize() for word in words]) + """ """ + warnings.warn("Grid.component_studly is deprecated; " + "please use vue_component instead", + DeprecationWarning, stacklevel=2) + return self.vue_component - def make_columns(self): - """ - Return a default list of columns, based on :attr:`model_class`. - """ - if not self.model_class: - raise ValueError("Must define model_class to use make_columns()") + def get_default_sortkey(self): + """ """ + warnings.warn("Grid.default_sortkey is deprecated; " + "please use Grid.sort_defaults instead", + DeprecationWarning, stacklevel=2) + if self.sort_defaults: + return self.sort_defaults[0].sortkey - mapper = orm.class_mapper(self.model_class) - return [prop.key for prop in mapper.iterate_properties] + def set_default_sortkey(self, value): + """ """ + warnings.warn("Grid.default_sortkey is deprecated; " + "please use Grid.sort_defaults instead", + DeprecationWarning, stacklevel=2) + if self.sort_defaults: + info = self.sort_defaults[0] + self.sort_defaults[0] = SortInfo(value, info.sortdir) + else: + self.sort_defaults = [SortInfo(value, 'asc')] - def remove(self, *keys): - """ - This *removes* some column(s) from the grid, altogether. - """ - for key in keys: - if key in self.columns: - self.columns.remove(key) + default_sortkey = property(get_default_sortkey, set_default_sortkey) + + def get_default_sortdir(self): + """ """ + warnings.warn("Grid.default_sortdir is deprecated; " + "please use Grid.sort_defaults instead", + DeprecationWarning, stacklevel=2) + if self.sort_defaults: + return self.sort_defaults[0].sortdir + + def set_default_sortdir(self, value): + """ """ + warnings.warn("Grid.default_sortdir is deprecated; " + "please use Grid.sort_defaults instead", + DeprecationWarning, stacklevel=2) + if self.sort_defaults: + info = self.sort_defaults[0] + self.sort_defaults[0] = SortInfo(info.sortkey, value) + else: + raise ValueError("cannot set default_sortdir without default_sortkey") + + default_sortdir = property(get_default_sortdir, set_default_sortdir) + + def get_pageable(self): + """ """ + warnings.warn("Grid.pageable is deprecated; " + "please use Grid.paginated instead", + DeprecationWarning, stacklevel=2) + return self.paginated + + def set_pageable(self, value): + """ """ + warnings.warn("Grid.pageable is deprecated; " + "please use Grid.paginated instead", + DeprecationWarning, stacklevel=2) + self.paginated = value + + pageable = property(get_pageable, set_pageable) def hide_column(self, key): """ @@ -224,7 +408,7 @@ class Grid(object): """ warnings.warn("Grid.hide_column() is deprecated; please use " "Grid.remove() instead.", - DeprecationWarning) + DeprecationWarning, stacklevel=2) self.remove(key) def hide_columns(self, *keys): @@ -248,9 +432,6 @@ class Grid(object): if key in self.invisible: self.invisible.remove(key) - def append(self, field): - self.columns.append(field) - def insert_before(self, field, newfield): self.columns.insert_before(field, newfield) @@ -262,56 +443,54 @@ class Grid(object): self.remove(oldfield) def set_joiner(self, key, joiner): + """ """ if joiner is None: - self.joiners.pop(key, None) + warnings.warn("specifying None is deprecated for Grid.set_joiner(); " + "please use Grid.remove_joiner() instead", + DeprecationWarning, stacklevel=2) + self.remove_joiner(key) else: - self.joiners[key] = joiner + super().set_joiner(key, joiner) def set_sorter(self, key, *args, **kwargs): - self.sorters[key] = self.make_sorter(*args, **kwargs) + """ """ - def set_sort_defaults(self, sortkey, sortdir='asc'): - self.default_sortkey = sortkey - self.default_sortdir = sortdir + if len(args) == 1: + if kwargs: + warnings.warn("kwargs are ignored for Grid.set_sorter(); " + "please refactor your code accordingly", + DeprecationWarning, stacklevel=2) + if args[0] is None: + warnings.warn("specifying None is deprecated for Grid.set_sorter(); " + "please use Grid.remove_sorter() instead", + DeprecationWarning, stacklevel=2) + self.remove_sorter(key) + else: + super().set_sorter(key, args[0]) + + elif len(args) == 0: + super().set_sorter(key) + + else: + warnings.warn("multiple args are deprecated for Grid.set_sorter(); " + "please refactor your code accordingly", + DeprecationWarning, stacklevel=2) + self.sorters[key] = self.make_sorter(*args, **kwargs) def set_filter(self, key, *args, **kwargs): - if len(args) == 1 and args[0] is None: - self.remove_filter(key) - else: - if 'label' not in kwargs and key in self.labels: - kwargs['label'] = self.labels[key] - self.filters[key] = self.make_filter(key, *args, **kwargs) + """ """ + if len(args) == 1: + if args[0] is None: + warnings.warn("specifying None is deprecated for Grid.set_filter(); " + "please use Grid.remove_filter() instead", + DeprecationWarning, stacklevel=2) + self.remove_filter(key) + return - def set_searchable(self, key, searchable=True): - if searchable: - self.searchable[key] = True - else: - self.searchable.pop(key, None) - - def is_searchable(self, key): - return self.searchable.get(key, False) - - def remove_filter(self, key): - self.filters.pop(key, None) - - def set_label(self, key, label): - self.labels[key] = label - if key in self.filters: - self.filters[key].label = label - - def get_label(self, key): - """ - Returns the label text for given field key. - """ - return self.labels.get(key, prettify(key)) - - def set_link(self, key, link=True): - if link: - if key not in self.linked_columns: - self.linked_columns.append(key) - else: # unlink - if self.linked_columns and key in self.linked_columns: - self.linked_columns.remove(key) + # TODO: our make_filter() signature differs from upstream, + # so must call it explicitly instead of delegating to super + kwargs.setdefault('label', self.get_label(key)) + self.filters[key] = self.make_filter(key, *args, **kwargs) def set_click_handler(self, key, handler): if handler: @@ -322,9 +501,6 @@ class Grid(object): def has_click_handler(self, key): return key in self.click_handlers - def set_renderer(self, key, renderer): - self.renderers[key] = renderer - def set_raw_renderer(self, key, renderer): """ Set or remove the "raw" renderer for the given field. @@ -381,10 +557,29 @@ class Grid(object): return pretty_boolean(value) def obtain_value(self, obj, column_name): + """ + Try to obtain and return the value from the given object, for + the given column name. + + :returns: The value, or ``None`` if no value was found. + """ + # TODO: this seems a little hacky, is there a better way? + # nb. this may only be relevant for import/export batch view? + if isinstance(obj, sa.engine.Row): + return obj._mapping[column_name] + + if isinstance(obj, dict): + return obj[column_name] + + try: + return getattr(obj, column_name) + except AttributeError: + pass + try: return obj[column_name] except TypeError: - return getattr(obj, column_name) + pass def render_currency(self, obj, column_name): value = self.obtain_value(obj, column_name) @@ -404,7 +599,8 @@ class Grid(object): value = self.obtain_value(obj, column_name) if value is None: return "" - value = localtime(self.request.rattail_config, value) + app = self.request.rattail_config.get_app() + value = app.localtime(value) return raw_datetime(self.request.rattail_config, value) def render_enum(self, obj, column_name): @@ -413,8 +609,8 @@ class Grid(object): return "" enum = self.enums.get(column_name) if enum and value in enum: - return six.text_type(enum[value]) - return six.text_type(value) + return str(enum[value]) + return str(value) def render_gpc(self, obj, column_name): value = self.obtain_value(obj, column_name) @@ -429,7 +625,8 @@ class Grid(object): def render_quantity(self, obj, column_name): value = self.obtain_value(obj, column_name) - return pretty_quantity(value) + app = self.request.rattail_config.get_app() + return app.render_quantity(value) def render_duration(self, obj, column_name): seconds = self.obtain_value(obj, column_name) @@ -442,7 +639,8 @@ class Grid(object): value = self.obtain_value(obj, field) if value is None: return "" - return pretty_hours(hours=value) + app = self.request.rattail_config.get_app() + return app.render_duration(hours=value) def set_url(self, url): self.url = url @@ -452,48 +650,6 @@ class Grid(object): return self.url(obj) return self.url - def make_webhelpers_grid(self): - kwargs = dict(self._whgrid_kwargs) - kwargs['request'] = self.request - kwargs['url'] = self.make_url - - columns = list(self.columns) - column_labels = kwargs.setdefault('column_labels', {}) - column_formats = kwargs.setdefault('column_formats', {}) - - for key, value in self.labels.items(): - column_labels.setdefault(key, value) - - if self.checkboxes: - columns.insert(0, 'checkbox') - column_labels['checkbox'] = tags.checkbox('check-all') - column_formats['checkbox'] = self.checkbox_column_format - - if self.renderers: - kwargs['renderers'] = self.renderers - if self.extra_row_class: - kwargs['extra_record_class'] = self.extra_row_class - if self.linked_columns: - kwargs['linked_columns'] = list(self.linked_columns) - - if self.main_actions or self.more_actions: - columns.append('actions') - column_formats['actions'] = self.actions_column_format - - # TODO: pretty sure this factory doesn't serve all use cases yet? - factory = CustomWebhelpersGrid - # factory = webhelpers2_grid.Grid - if self.sortable: - # factory = CustomWebhelpersGrid - kwargs['order_column'] = self.sortkey - kwargs['order_direction'] = 'dsc' if self.sortdir == 'desc' else 'asc' - - grid = factory(self.make_visible_data(), columns, **kwargs) - if self.sortable: - grid.exclude_ordering = list([key for key in grid.exclude_ordering - if key not in self.sorters]) - return grid - def make_default_renderers(self, renderers): """ Make the default set of column renderers for the grid. @@ -538,18 +694,13 @@ class Grid(object): def actions_column_format(self, column_number, row_number, item): return HTML.td(self.render_actions(item, row_number), class_='actions') - def render_grid(self, template='/grids/grid.mako', **kwargs): - context = kwargs - context['grid'] = self - context['request'] = self.request - grid_class = '' - if self.width == 'full': - grid_class = 'full' - elif self.width == 'half': - grid_class = 'half' - context['grid_class'] = '{} {}'.format(grid_class, context.get('grid_class', '')) - context.setdefault('grid_attrs', {}) - return render(template, context) + # TODO: upstream should handle this.. + def make_backend_filters(self, filters=None): + """ """ + final = self.get_default_filters() + if filters: + final.update(filters) + return final def get_default_filters(self): """ @@ -575,16 +726,6 @@ class Grid(object): filters[prop.key] = self.make_filter(prop.key, column) return filters - def make_filters(self, filters=None): - """ - Returns an initial set of filters which will be available to the grid. - The grid itself may or may not provide some default filters, and the - ``filters`` kwarg may contain additions and/or overrides. - """ - if filters: - return filters - return self.get_default_filters() - def make_filter(self, key, column, **kwargs): """ Make a filter suitable for use with the given column. @@ -596,6 +737,8 @@ class Grid(object): factory = gridfilters.AlchemyStringFilter elif isinstance(column.type, sa.Numeric): factory = gridfilters.AlchemyNumericFilter + elif isinstance(column.type, sa.BigInteger): + factory = gridfilters.AlchemyBigIntegerFilter elif isinstance(column.type, sa.Integer): factory = gridfilters.AlchemyIntegerFilter elif isinstance(column.type, sa.Boolean): @@ -604,17 +747,22 @@ class Grid(object): elif isinstance(column.type, sa.Date): factory = gridfilters.AlchemyDateFilter elif isinstance(column.type, sa.DateTime): - factory = gridfilters.AlchemyDateTimeFilter + if self.assume_local_times: + factory = gridfilters.AlchemyLocalDateTimeFilter + else: + factory = gridfilters.AlchemyDateTimeFilter elif isinstance(column.type, GPCType): factory = gridfilters.AlchemyGPCFilter + kwargs['column'] = column + kwargs.setdefault('config', self.request.rattail_config) kwargs.setdefault('encode_values', self.use_byte_string_filters) - return factory(key, column=column, config=self.request.rattail_config, **kwargs) + return factory(key, **kwargs) def iter_filters(self): """ Iterate over all filters available to the grid. """ - return six.itervalues(self.filters) + return self.filters.values() def iter_active_filters(self): """ @@ -625,88 +773,103 @@ class Grid(object): if filtr.active: yield filtr - def make_sorters(self, sorters=None): - """ - Returns an initial set of sorters which will be available to the grid. - The grid itself may or may not provide some default sorters, and the - ``sorters`` kwarg may contain additions and/or overrides. - """ - sorters, updates = {}, sorters - if self.model_class: - mapper = orm.class_mapper(self.model_class) - for prop in mapper.iterate_properties: - if isinstance(prop, orm.ColumnProperty) and not prop.key.endswith('uuid'): - sorters[prop.key] = self.make_sorter(prop) - if updates: - sorters.update(updates) - return sorters - - def make_sorter(self, model_property): - """ - Returns a function suitable for a sort map callable, with typical logic - built in for sorting applied to ``field``. - """ - class_ = getattr(model_property, 'class_', self.model_class) - column = getattr(class_, model_property.key) - - def sorter(query, direction): - # TODO: this seems hacky..normally we expect a true query - # of course, but in some cases it may be a list instead. - # if so then we can't actually sort - if isinstance(query, list): - return query - return query.order_by(getattr(column, direction)()) - - return sorter - def make_simple_sorter(self, key, foldcase=False): - """ - Returns a function suitable for a sort map callable, with typical logic - built in for sorting a data set comprised of dicts, on the given key. - """ - if foldcase: - keyfunc = lambda v: v[key].lower() - else: - keyfunc = lambda v: v[key] - return lambda q, d: sorted(q, key=keyfunc, reverse=d == 'desc') + """ """ + warnings.warn("Grid.make_simple_sorter() is deprecated; " + "please use Grid.make_sorter() instead", + DeprecationWarning, stacklevel=2) + return self.make_sorter(key, foldcase=foldcase) + + def get_pagesize_options(self, default=None): + """ """ + # let upstream check config + options = super().get_pagesize_options(default=UNSPECIFIED) + if options is not UNSPECIFIED: + return options + + # fallback to legacy config + options = self.config.get_list('tailbone.grid.pagesize_options') + if options: + warnings.warn("tailbone.grid.pagesize_options setting is deprecated; " + "please set wuttaweb.grids.default_pagesize_options instead", + DeprecationWarning) + options = [int(size) for size in options + if size.isdigit()] + if options: + return options + + if default: + return default + + # use upstream default + return super().get_pagesize_options() + + def get_pagesize(self, default=None): + """ """ + # let upstream check config + pagesize = super().get_pagesize(default=UNSPECIFIED) + if pagesize is not UNSPECIFIED: + return pagesize + + # fallback to legacy config + pagesize = self.config.get_int('tailbone.grid.default_pagesize') + if pagesize: + warnings.warn("tailbone.grid.default_pagesize setting is deprecated; " + "please use wuttaweb.grids.default_pagesize instead", + DeprecationWarning) + return pagesize + + if default: + return default + + # use upstream default + return super().get_pagesize() + + def get_default_pagesize(self): # pragma: no cover + """ """ + warnings.warn("Grid.get_default_pagesize() method is deprecated; " + "please use Grid.get_pagesize() of Grid.page instead", + DeprecationWarning, stacklevel=2) - def get_default_pagesize(self): if self.default_pagesize: return self.default_pagesize - pagesize = self.request.rattail_config.getint('tailbone', - 'grid.default_pagesize', - default=0) - if pagesize: - return pagesize + return self.get_pagesize() - options = self.get_pagesize_options() - return options[0] + def load_settings(self, **kwargs): + """ """ + if 'store' in kwargs: + warnings.warn("the 'store' param is deprecated for load_settings(); " + "please use the 'persist' param instead", + DeprecationWarning, stacklevel=2) + kwargs.setdefault('persist', kwargs.pop('store')) - def load_settings(self, store=True): - """ - Load current/effective settings for the grid, from the request query - string and/or session storage. If ``store`` is true, then once - settings have been fully read, they are stored in current session for - next time. Finally, various instance attributes of the grid and its - filters are updated in-place to reflect the settings; this is so code - needn't access the settings dict directly, but the more Pythonic - instance attributes. - """ + persist = kwargs.get('persist', True) # initial default settings settings = {} if self.sortable: - settings['sortkey'] = self.default_sortkey - settings['sortdir'] = self.default_sortdir - if self.pageable: - settings['pagesize'] = self.get_default_pagesize() - settings['page'] = self.default_page + if self.sort_defaults: + # nb. as of writing neither Buefy nor Oruga support a + # multi-column *default* sort; so just use first sorter + sortinfo = self.sort_defaults[0] + settings['sorters.length'] = 1 + settings['sorters.1.key'] = sortinfo.sortkey + settings['sorters.1.dir'] = sortinfo.sortdir + else: + settings['sorters.length'] = 0 + if self.paginated: + settings['pagesize'] = self.pagesize + settings['page'] = self.page if self.filterable: for filtr in self.iter_filters(): - settings['filter.{}.active'.format(filtr.key)] = filtr.default_active - settings['filter.{}.verb'.format(filtr.key)] = filtr.default_verb - settings['filter.{}.value'.format(filtr.key)] = filtr.default_value + defaults = self.filter_defaults.get(filtr.key, {}) + settings[f'filter.{filtr.key}.active'] = defaults.get('active', + filtr.default_active) + settings[f'filter.{filtr.key}.verb'] = defaults.get('verb', + filtr.default_verb) + settings[f'filter.{filtr.key}.value'] = defaults.get('value', + filtr.default_value) # If user has default settings on file, apply those first. if self.user_has_defaults(): @@ -714,25 +877,25 @@ class Grid(object): # If request contains instruction to reset to default filters, then we # can skip the rest of the request/session checks. - if self.request.GET.get('reset-to-default-filters') == 'true': + if self.request.GET.get('reset-view'): pass # If request has filter settings, grab those, then grab sort/pager # settings from request or session. - elif self.filterable and self.request_has_settings('filter'): - self.update_filter_settings(settings, 'request') + elif self.request_has_settings('filter'): + self.update_filter_settings(settings, src='request') if self.request_has_settings('sort'): - self.update_sort_settings(settings, 'request') + self.update_sort_settings(settings, src='request') else: - self.update_sort_settings(settings, 'session') + self.update_sort_settings(settings, src='session') self.update_page_settings(settings) # If request has no filter settings but does have sort settings, grab # those, then grab filter settings from session, then grab pager # settings from request or session. elif self.request_has_settings('sort'): - self.update_sort_settings(settings, 'request') - self.update_filter_settings(settings, 'session') + self.update_sort_settings(settings, src='request') + self.update_filter_settings(settings, src='session') self.update_page_settings(settings) # NOTE: These next two are functionally equivalent, but are kept @@ -742,27 +905,27 @@ class Grid(object): # grab those, then grab filter/sort settings from session. elif self.request_has_settings('page'): self.update_page_settings(settings) - self.update_filter_settings(settings, 'session') - self.update_sort_settings(settings, 'session') + self.update_filter_settings(settings, src='session') + self.update_sort_settings(settings, src='session') # If request has no settings, grab all from session. elif self.session_has_settings(): - self.update_filter_settings(settings, 'session') - self.update_sort_settings(settings, 'session') + self.update_filter_settings(settings, src='session') + self.update_sort_settings(settings, src='session') self.update_page_settings(settings) # If no settings were found in request or session, don't store result. else: - store = False + persist = False # Maybe store settings for next time. - if store: - self.persist_settings(settings, 'session') + if persist: + self.persist_settings(settings, dest='session') # If request contained instruction to save current settings as defaults # for the current user, then do that. if self.request.GET.get('save-current-filters-as-defaults') == 'true': - self.persist_settings(settings, 'defaults') + self.persist_settings(settings, dest='defaults') # update ourself to reflect settings if self.filterable: @@ -771,9 +934,14 @@ class Grid(object): filtr.verb = settings['filter.{}.verb'.format(filtr.key)] filtr.value = settings['filter.{}.value'.format(filtr.key)] if self.sortable: - self.sortkey = settings['sortkey'] - self.sortdir = settings['sortdir'] - if self.pageable: + # and self.sort_on_backend: + self.active_sorters = [] + for i in range(1, settings['sorters.length'] + 1): + self.active_sorters.append({ + 'key': settings[f'sorters.{i}.key'], + 'dir': settings[f'sorters.{i}.dir'], + }) + if self.paginated: self.pagesize = settings['pagesize'] self.page = settings['page'] @@ -791,21 +959,36 @@ class Grid(object): # anything... session = Session() if user not in session: - user = session.merge(user) + # TODO: pretty sure there is no need to *merge* here.. + # but we shall see if any breakage happens maybe + #user = session.merge(user) + user = session.get(user.__class__, user.uuid) - # User defaults should have all or nothing, so just check one key. - key = 'tailbone.{}.grid.{}.sortkey'.format(user.uuid, self.key) app = self.request.rattail_config.get_app() - return app.get_setting(Session(), key) is not None + + # user defaults should be all or nothing, so just check one key + key = f'tailbone.{user.uuid}.grid.{self.key}.sorters.length' + if app.get_setting(session, key) is not None: + return True + + # TODO: this is deprecated but should work its way out of the + # system in a little while (?)..then can remove this entirely + key = f'tailbone.{user.uuid}.grid.{self.key}.sortkey' + if app.get_setting(session, key) is not None: + return True + + return False def apply_user_defaults(self, settings): """ Update the given settings dict with user defaults, if any exist. """ + app = self.request.rattail_config.get_app() + session = Session() + prefix = f'tailbone.{self.request.user.uuid}.grid.{self.key}' + def merge(key, normalize=lambda v: v): - skey = 'tailbone.{}.grid.{}.{}'.format(self.request.user.uuid, self.key, key) - app = self.request.rattail_config.get_app() - value = app.get_setting(Session(), skey) + value = app.get_setting(session, f'{prefix}.{key}') settings[key] = normalize(value) if self.filterable: @@ -815,35 +998,70 @@ class Grid(object): merge('filter.{}.value'.format(filtr.key)) if self.sortable: - merge('sortkey') - merge('sortdir') - if self.pageable: + # first clear existing settings for *sorting* only + # nb. this is because number of sort settings will vary + for key in list(settings): + if key.startswith('sorters.'): + del settings[key] + + # check for *deprecated* settings, and use those if present + # TODO: obviously should stop this, but must wait until + # all old settings have been flushed out. which in the + # case of user-persisted settings, could be a while... + sortkey = app.get_setting(session, f'{prefix}.sortkey') + if sortkey: + settings['sorters.length'] = 1 + settings['sorters.1.key'] = sortkey + settings['sorters.1.dir'] = app.get_setting(session, f'{prefix}.sortdir') + + # nb. re-persist these user settings per new + # convention, so deprecated settings go away and we + # can remove this logic after a while.. + app = self.request.rattail_config.get_app() + model = app.model + prefix = f'tailbone.{self.request.user.uuid}.grid.{self.key}' + query = Session.query(model.Setting)\ + .filter(sa.or_( + model.Setting.name.like(f'{prefix}.sorters.%'), + model.Setting.name == f'{prefix}.sortkey', + model.Setting.name == f'{prefix}.sortdir')) + for setting in query.all(): + Session.delete(setting) + Session.flush() + + def persist(key): + app.save_setting(Session(), + f'tailbone.{self.request.user.uuid}.grid.{self.key}.{key}', + settings[key]) + + persist('sorters.length') + persist('sorters.1.key') + persist('sorters.1.dir') + + else: # the future + merge('sorters.length', int) + for i in range(1, settings['sorters.length'] + 1): + merge(f'sorters.{i}.key') + merge(f'sorters.{i}.dir') + + if self.paginated: merge('pagesize', int) merge('page', int) def request_has_settings(self, type_): - """ - Determine if the current request (GET query string) contains any - filter/sort settings for the grid. - """ - if type_ == 'filter': - for filtr in self.iter_filters(): - if filtr.key in self.request.GET: - return True - if 'filter' in self.request.GET: # user may be applying empty filters - return True + """ """ + if super().request_has_settings(type_): + return True - elif type_ == 'sort': + if type_ == 'sort': + + # TODO: remove this eventually, but some links in the wild + # may still include these params, so leave it for now for key in ['sortkey', 'sortdir']: if key in self.request.GET: return True - elif type_ == 'page': - for key in ['pagesize', 'page']: - if key in self.request.GET: - return True - return False def session_has_settings(self): @@ -852,154 +1070,77 @@ class Grid(object): """ # session should have all or nothing, so just check a few keys which # should be guaranteed present if anything has been stashed - for key in ['page', 'sortkey']: - if 'grid.{}.{}'.format(self.key, key) in self.request.session: + prefix = f'grid.{self.key}' + for key in ['page', 'sorters.length']: + if f'{prefix}.{key}' in self.request.session: return True - return any([key.startswith('grid.{}.filter'.format(self.key)) for key in self.request.session]) + return any([key.startswith(f'{prefix}.filter') + for key in self.request.session]) - def get_setting(self, source, settings, key, normalize=lambda v: v, default=None): - """ - Get the effective value for a particular setting, preferring ``source`` - but falling back to existing ``settings`` and finally the ``default``. - """ - if source not in ('request', 'session'): - raise ValueError("Invalid source identifier: {}".format(source)) + def persist_settings(self, settings, dest='session'): + """ """ + if dest not in ('defaults', 'session'): + raise ValueError(f"invalid dest identifier: {dest}") - # If source is query string, try that first. - if source == 'request': - value = self.request.GET.get(key) - if value is not None: - try: - value = normalize(value) - except ValueError: - pass - else: - return value + app = self.request.rattail_config.get_app() + model = app.model - # Or, if source is session, try that first. - else: - value = self.request.session.get('grid.{}.{}'.format(self.key, key)) - if value is not None: - return normalize(value) - - # If source had nothing, try default/existing settings. - value = settings.get(key) - if value is not None: - try: - value = normalize(value) - except ValueError: - pass - else: - return value - - # Okay then, default it is. - return default - - def update_filter_settings(self, settings, source): - """ - Updates a settings dictionary according to filter settings data found - in either the GET query string, or session storage. - - :param settings: Dictionary of initial settings, which is to be updated. - - :param source: String identifying the source to consult for settings - data. Must be one of: ``('request', 'session')``. - """ - if not self.filterable: - return - - for filtr in self.iter_filters(): - prefix = 'filter.{}'.format(filtr.key) - - if source == 'request': - # consider filter active if query string contains a value for it - settings['{}.active'.format(prefix)] = filtr.key in self.request.GET - settings['{}.verb'.format(prefix)] = self.get_setting( - source, settings, '{}.verb'.format(filtr.key), default='') - settings['{}.value'.format(prefix)] = self.get_setting( - source, settings, filtr.key, default='') - - else: # source = session - settings['{}.active'.format(prefix)] = self.get_setting( - source, settings, '{}.active'.format(prefix), - normalize=lambda v: six.text_type(v).lower() == 'true', default=False) - settings['{}.verb'.format(prefix)] = self.get_setting( - source, settings, '{}.verb'.format(prefix), default='') - settings['{}.value'.format(prefix)] = self.get_setting( - source, settings, '{}.value'.format(prefix), default='') - - def update_sort_settings(self, settings, source): - """ - Updates a settings dictionary according to sort settings data found in - either the GET query string, or session storage. - - :param settings: Dictionary of initial settings, which is to be updated. - - :param source: String identifying the source to consult for settings - data. Must be one of: ``('request', 'session')``. - """ - if not self.sortable: - return - settings['sortkey'] = self.get_setting(source, settings, 'sortkey') - settings['sortdir'] = self.get_setting(source, settings, 'sortdir') - - def update_page_settings(self, settings): - """ - Updates a settings dictionary according to pager settings data found in - either the GET query string, or session storage. - - Note that due to how the actual pager functions, the effective settings - will often come from *both* the request and session. This is so that - e.g. the page size will remain constant (coming from the session) while - the user jumps between pages (which only provides the single setting). - - :param settings: Dictionary of initial settings, which is to be updated. - """ - if not self.pageable: - return - - pagesize = self.request.GET.get('pagesize') - if pagesize is not None: - if pagesize.isdigit(): - settings['pagesize'] = int(pagesize) - else: - pagesize = self.request.session.get('grid.{}.pagesize'.format(self.key)) - if pagesize is not None: - settings['pagesize'] = pagesize - - page = self.request.GET.get('page') - if page is not None: - if page.isdigit(): - settings['page'] = int(page) - else: - page = self.request.session.get('grid.{}.page'.format(self.key)) - if page is not None: - settings['page'] = int(page) - - def persist_settings(self, settings, to='session'): - """ - Persist the given settings in some way, as defined by ``func``. - """ - def persist(key, value=lambda k: settings[k]): - if to == 'defaults': + def persist(key, value=lambda k: settings.get(k)): + if dest == 'defaults': skey = 'tailbone.{}.grid.{}.{}'.format(self.request.user.uuid, self.key, key) - app = self.request.rattail_config.get_app() app.save_setting(Session(), skey, value(key)) - else: # to == session + else: # dest == session skey = 'grid.{}.{}'.format(self.key, key) self.request.session[skey] = value(key) if self.filterable: for filtr in self.iter_filters(): - persist('filter.{}.active'.format(filtr.key), value=lambda k: six.text_type(settings[k]).lower()) + persist('filter.{}.active'.format(filtr.key), value=lambda k: str(settings[k]).lower()) persist('filter.{}.verb'.format(filtr.key)) persist('filter.{}.value'.format(filtr.key)) if self.sortable: - persist('sortkey') - persist('sortdir') - if self.pageable: + # first must clear all sort settings from dest. this is + # because number of sort settings will vary, so we delete + # all and then write all + + if dest == 'defaults': + prefix = f'tailbone.{self.request.user.uuid}.grid.{self.key}' + query = Session.query(model.Setting)\ + .filter(sa.or_( + model.Setting.name.like(f'{prefix}.sorters.%'), + # TODO: remove these eventually, + # but probably should wait until + # all nodes have been upgraded for + # (quite) a while? + model.Setting.name == f'{prefix}.sortkey', + model.Setting.name == f'{prefix}.sortdir')) + for setting in query.all(): + Session.delete(setting) + Session.flush() + + else: # session + # remove sort settings from user session + prefix = f'grid.{self.key}' + for key in list(self.request.session): + if key.startswith(f'{prefix}.sorters.'): + del self.request.session[key] + # TODO: definitely will remove these, but leave for + # now so they don't monkey with current user sessions + # when next upgrade happens. so, remove after all are + # upgraded + self.request.session.pop(f'{prefix}.sortkey', None) + self.request.session.pop(f'{prefix}.sortdir', None) + + # now save sort settings to dest + if 'sorters.length' in settings: + persist('sorters.length') + for i in range(1, settings['sorters.length'] + 1): + persist(f'sorters.{i}.key') + persist(f'sorters.{i}.dir') + + if self.paginated: persist('pagesize') persist('page') @@ -1023,96 +1164,38 @@ class Grid(object): return data - def sort_data(self, data): - """ - Sort the given query according to current settings, and return the result. - """ - # Cannot sort unless we know which column to sort by. - if not self.sortkey: - return data - - # Cannot sort unless we have a sort function. - sortfunc = self.sorters.get(self.sortkey) - if not sortfunc: - return data - - # We can provide a default sort direction though. - sortdir = getattr(self, 'sortdir', 'asc') - if self.sortkey in self.joiners and self.sortkey not in self.joined: - data = self.joiners[self.sortkey](data) - self.joined.add(self.sortkey) - return sortfunc(data, sortdir) - - def paginate_data(self, data): - """ - Paginate the given data set according to current settings, and return - the result. - """ - # we of course assume our current page is correct, at first - pager = self.make_pager(data) - - # if pager has detected that our current page is outside the valid - # range, we must re-orient ourself around the "new" (valid) page - if pager.page != self.page: - self.page = pager.page - self.request.session['grid.{}.page'.format(self.key)] = self.page - pager = self.make_pager(data) - - return pager - - def make_pager(self, data): - - # TODO: this seems hacky..normally we expect `data` to be a - # query of course, but in some cases it may be a list instead. - # if so then we can't use ORM pager - if isinstance(data, list): - import paginate - return paginate.Page(data, - items_per_page=self.pagesize, - page=self.page) - - return SqlalchemyOrmPage(data, - items_per_page=self.pagesize, - page=self.page, - url_maker=URLMaker(self.request)) - def make_visible_data(self): - """ - Apply various settings to the raw data set, to produce a final data - set. This will page / sort / filter as necessary, according to the - grid's defaults and the current request etc. - """ - self.joined = set() - data = self.data - if self.filterable: - data = self.filter_data(data) - if self.sortable: - data = self.sort_data(data) - if self.pageable: - self.pager = self.paginate_data(data) - data = self.pager - return data + """ """ + warnings.warn("grid.make_visible_data() method is deprecated; " + "please use grid.get_visible_data() instead", + DeprecationWarning, stacklevel=2) + return self.get_visible_data() + + def render_vue_tag(self, master=None, **kwargs): + """ """ + kwargs.setdefault('ref', 'grid') + kwargs.setdefault(':csrftoken', 'csrftoken') + + if (master and master.deletable and master.has_perm('delete') + and master.delete_confirm == 'simple'): + kwargs.setdefault('@deleteActionClicked', 'deleteObject') + + return HTML.tag(self.vue_tagname, **kwargs) + + def render_vue_template(self, template='/grids/complete.mako', **context): + """ """ + return self.render_complete(template=template, **context) def render_complete(self, template='/grids/complete.mako', **kwargs): """ - Render the complete grid, including filters. - """ - context = kwargs - context['grid'] = self - context['request'] = self.request - context.setdefault('allow_save_defaults', True) - return render(template, context) - - def render_buefy(self, template='/grids/buefy.mako', **kwargs): - """ - Render the Buefy grid, complete with filters. Note that this also + Render the grid, complete with filters. Note that this also includes the context menu items and grid tools. """ if 'grid_columns' not in kwargs: - kwargs['grid_columns'] = self.get_buefy_columns() + kwargs['grid_columns'] = self.get_vue_columns() if 'grid_data' not in kwargs: - kwargs['grid_data'] = self.get_buefy_data() + kwargs['grid_data'] = self.get_table_data() if 'static_data' not in kwargs: kwargs['static_data'] = self.has_static_data() @@ -1123,43 +1206,53 @@ class Grid(object): if self.filterable and 'filters_sequence' not in kwargs: kwargs['filters_sequence'] = self.get_filters_sequence() - return self.render_complete(template=template, **kwargs) + context = kwargs + context['grid'] = self + context['request'] = self.request + context.setdefault('allow_save_defaults', True) + context.setdefault('view_click_handler', self.get_view_click_handler()) + html = render(template, context) + return HTML.literal(html) - def render_buefy_table_element(self, template='/grids/b-table.mako', - data_prop='gridData', empty_labels=False, - **kwargs): + def render_buefy(self, **kwargs): + """ """ + warnings.warn("Grid.render_buefy() is deprecated; " + "please use Grid.render_complete() instead", + DeprecationWarning, stacklevel=2) + return self.render_complete(**kwargs) + + def render_table_element(self, template='/grids/b-table.mako', + data_prop='gridData', empty_labels=False, + literal=False, + **kwargs): """ This is intended for ad-hoc "small" grids with static data. Renders just a ``<b-table>`` element instead of the typical "full" grid. """ context = dict(kwargs) context['grid'] = self + context['request'] = self.request context['data_prop'] = data_prop context['empty_labels'] = empty_labels if 'grid_columns' not in context: - context['grid_columns'] = self.get_buefy_columns() + context['grid_columns'] = self.get_vue_columns() context.setdefault('paginated', False) if context['paginated']: context.setdefault('per_page', 20) + context['view_click_handler'] = self.get_view_click_handler() + result = render(template, context) + if literal: + result = HTML.literal(result) + return result + def get_view_click_handler(self): + """ """ # locate the 'view' action # TODO: this should be easier, and/or moved elsewhere? view = None - for action in self.main_actions: + for action in self.actions: if action.key == 'view': - view = action - break - if not view: - for action in self.more_actions: - if action.key == 'view': - view = action - break - - context['view_click_handler'] = None - if view and view.click_handler: - context['view_click_handler'] = view.click_handler - - return render(template, context) + return getattr(action, 'click_handler', None) def set_filters_sequence(self, filters, only=False): """ @@ -1195,7 +1288,7 @@ class Grid(object): def get_filters_data(self): """ - Returns a dict of current filters data, for use with Buefy grid view. + Returns a dict of current filters data, for use with index view. """ data = {} for filtr in self.filters.values(): @@ -1225,7 +1318,7 @@ class Grid(object): 'multiple_value_verbs': multiple_values, 'verb_labels': filtr.verb_labels, 'verb': filtr.verb or filtr.default_verb or filtr.verbs[0], - 'value': six.text_type(filtr.value) if filtr.value is not None else "", + 'value': str(filtr.value) if filtr.value is not None else "", 'data_type': filtr.data_type, 'choices': choices, 'choice_labels': choice_labels, @@ -1233,48 +1326,21 @@ class Grid(object): return data - def render_filters(self, template='/grids/filters.mako', **kwargs): - """ - Render the filters to a Unicode string, using the specified template. - Additional kwargs are passed along as context to the template. - """ - # Provide default data to filters form, so renderer can do some of the - # work for us. - data = {} - for filtr in self.iter_active_filters(): - data['{}.active'.format(filtr.key)] = filtr.active - data['{}.verb'.format(filtr.key)] = filtr.verb - data[filtr.key] = filtr.value + def render_actions(self, row, i): # pragma: no cover + """ """ + warnings.warn("grid.render_actions() is deprecated!", + DeprecationWarning, stacklevel=2) - form = gridfilters.GridFiltersForm(self.filters, - request=self.request, - defaults=data) + actions = [self.render_action(a, row, i) + for a in self.actions] + actions = [a for a in actions if a] + return HTML.literal('').join(actions) - kwargs['request'] = self.request - kwargs['grid'] = self - kwargs['form'] = form - return render(template, kwargs) + def render_action(self, action, row, i): # pragma: no cover + """ """ + warnings.warn("grid.render_action() is deprecated!", + DeprecationWarning, stacklevel=2) - def render_actions(self, row, i): - """ - Returns the rendered contents of the 'actions' column for a given row. - """ - main_actions = [self.render_action(a, row, i) - for a in self.main_actions] - main_actions = [a for a in main_actions if a] - more_actions = [self.render_action(a, row, i) - for a in self.more_actions] - more_actions = [a for a in more_actions if a] - if more_actions: - icon = HTML.tag('span', class_='ui-icon ui-icon-carat-1-e') - link = tags.link_to("More" + icon, '#', class_='more') - main_actions.append(HTML.literal(' ') + link + HTML.tag('div', class_='more', c=more_actions)) - return HTML.literal('').join(main_actions) - - def render_action(self, action, row, i): - """ - Renders an action menu item (link) for the given row. - """ url = action.get_url(row, i) if url: kwargs = {'class_': action.key, 'target': action.target} @@ -1308,18 +1374,6 @@ class Grid(object): return tags.checkbox('checkbox-{}-{}'.format(self.key, self.get_row_key(item)), checked=self.checked(item)) - def get_pagesize_options(self): - - # use values from config, if defined - options = self.request.rattail_config.getlist('tailbone', 'grid.pagesize_options') - if options: - options = [int(size) for size in options - if size.isdigit()] - if options: - return options - - return [5, 10, 20, 50, 100, 200] - def has_static_data(self): """ Should return ``True`` if the grid data can be considered "static" @@ -1331,27 +1385,51 @@ class Grid(object): return True return False - def get_buefy_columns(self): - """ - Return a list of dicts representing all grid columns. Meant for use - with Buefy table. - """ - columns = [] - for name in self.columns: - columns.append({ - 'field': name, - 'label': self.get_label(name), - 'sortable': self.sortable and name in self.sorters, - 'visible': name not in self.invisible, - }) + def get_vue_columns(self): + """ """ + columns = super().get_vue_columns() + + for column in columns: + column['visible'] = column['field'] not in self.invisible + return columns - def get_buefy_data(self): + def get_table_columns(self): + """ """ + warnings.warn("grid.get_table_columns() method is deprecated; " + "please use grid.get_vue_columns() instead", + DeprecationWarning, stacklevel=2) + return self.get_vue_columns() + + def get_uuid_for_row(self, rowobj): + + # use custom getter if set + if self.row_uuid_getter: + return self.row_uuid_getter(rowobj) + + # otherwise fallback to normal uuid, if present + if hasattr(rowobj, 'uuid'): + return rowobj.uuid + + def get_vue_context(self): + """ """ + return self.get_table_data() + + def get_vue_data(self): + """ """ + table_data = self.get_table_data() + return table_data['data'] + + def get_table_data(self): """ - Returns a list of data rows for the grid, for use with Buefy table. + Returns a list of data rows for the grid, for use with + client-side JS table. """ + if hasattr(self, '_table_data'): + return self._table_data + # filter / sort / paginate to get "visible" data - raw_data = self.make_visible_data() + raw_data = self.get_visible_data() data = [] status_map = {} checked = [] @@ -1365,6 +1443,7 @@ class Grid(object): count = len(raw_data) # iterate over data rows + checkable = self.checkboxes and self.checkable and callable(self.checkable) for i in range(count): rowobj = raw_data[i] @@ -1372,32 +1451,54 @@ class Grid(object): # logic finds it useful row = {'_index': i} + # if grid allows checkboxes, and we have logic to see if + # any given row is checkable, add data for that here + if checkable: + row['_checkable'] = self.checkable(rowobj) + # sometimes we need to include some "raw" data columns in our # result set, even though the column is not displayed as part of # the grid. this can be used for front-end editing of row data for # instance, when the "display" version is different than raw data. # here is the hack we use for that. columns = list(self.columns) - if hasattr(self, 'buefy_data_columns'): - columns.extend(self.buefy_data_columns) + if hasattr(self, 'raw_data_columns'): + columns.extend(self.raw_data_columns) # iterate over data fields for name in columns: # leverage configured rendering logic where applicable; # otherwise use "raw" data value as string + value = self.obtain_value(rowobj, name) if self.renderers and name in self.renderers: - value = self.renderers[name](rowobj, name) - else: - value = self.obtain_value(rowobj, name) + renderer = self.renderers[name] + + # TODO: legacy renderer callables require 2 args, + # but wuttaweb callables require 3 args + sig = inspect.signature(renderer) + required = [param for param in sig.parameters.values() + if param.default == param.empty] + + if len(required) == 2: + # TODO: legacy renderer + value = renderer(rowobj, name) + else: # the future + value = renderer(rowobj, name, value) + if value is None: value = "" - row[name] = six.text_type(value) + + # this value will ultimately be inserted into table + # cell a la <td v-html="..."> so we must escape it + # here to be safe + row[name] = HTML.literal.escape(value) # maybe add UUID for convenience if 'uuid' not in self.columns: - if hasattr(rowobj, 'uuid'): - row['uuid'] = rowobj.uuid + uuid = self.get_uuid_for_row(rowobj) + if uuid: + row['uuid'] = uuid # set action URL(s) for row, as needed self.set_action_urls(row, rowobj, i) @@ -1417,6 +1518,8 @@ class Grid(object): results = { 'data': data, + 'row_classes': status_map, + # TODO: deprecate / remove this 'row_status_map': status_map, } @@ -1424,11 +1527,15 @@ class Grid(object): results['checked_rows'] = checked # TODO: this seems a bit hacky, but is required for now to # initialize things on the client side... - var = '{}CurrentData'.format(self.component_studly) + var = '{}CurrentData'.format(self.vue_component) results['checked_rows_code'] = '[{}]'.format( ', '.join(['{}[{}]'.format(var, i) for i in checked])) - if self.pageable and self.pager is not None: + if self.paginated and self.paginate_on_backend: + results['pager_stats'] = self.get_vue_pager_stats() + + # TODO: is this actually needed now that we have pager_stats? + if self.paginated and self.pager is not None: results['total_items'] = self.pager.item_count results['per_page'] = self.pager.items_per_page results['page'] = self.pager.page @@ -1438,133 +1545,66 @@ class Grid(object): else: results['total_items'] = count - return results + self._table_data = results + return self._table_data + + # TODO: remove this when we use upstream GridAction + def add_action(self, key, **kwargs): + """ """ + self.actions.append(GridAction(self.request, key, **kwargs)) def set_action_urls(self, row, rowobj, i): """ Pre-generate all action URLs for the given data row. Meant for use - with Buefy table, since we can't generate URLs from JS. + with client-side table, since we can't generate URLs from JS. """ - for action in (self.main_actions + self.more_actions): + for action in self.actions: url = action.get_url(rowobj, i) row['_action_url_{}'.format(action.key)] = url - def is_linked(self, name): - """ - Should return ``True`` if the given column name is configured to be - "linked" (i.e. table cell should contain a link to "view object"), - otherwise ``False``. - """ - if self.linked_columns: - if name in self.linked_columns: - return True - return False - -class CustomWebhelpersGrid(webhelpers2_grid.Grid): +class GridAction(WuttaGridAction): """ - Implement column sorting links etc. for webhelpers2_grid + Represents a "row action" hyperlink within a grid context. + + This is a subclass of + :class:`wuttaweb:wuttaweb.grids.base.GridAction`. + + .. warning:: + + This class remains for now, to retain compatibility with + existing code. But at some point the WuttaWeb class will + supersede this one entirely. + + :param target: HTML "target" attribute for the ``<a>`` tag. + + :param click_handler: Optional JS click handler for the action. + This value will be rendered as-is within the final grid + template, hence the JS string must be callable code. Note + that ``props.row`` will be available in the calling context, + so a couple of examples: + + * ``deleteThisThing(props.row)`` + * ``$emit('do-something', props.row)`` """ - def __init__(self, itemlist, columns, **kwargs): - self.renderers = kwargs.pop('renderers', {}) - self.linked_columns = kwargs.pop('linked_columns', []) - self.extra_record_class = kwargs.pop('extra_record_class', None) - super(CustomWebhelpersGrid, self).__init__(itemlist, columns, **kwargs) + def __init__( + self, + request, + key, + target=None, + click_handler=None, + **kwargs, + ): + # TODO: previously url default was '#' - but i don't think we + # need that anymore? guess we'll see.. + #kwargs.setdefault('url', '#') - def generate_header_link(self, column_number, column, label_text): + super().__init__(request, key, **kwargs) - # display column header as simple no-op link; client-side JS takes care - # of the rest for us - label_text = tags.link_to(label_text, '#', data_sortkey=column) - - # Is the current column the one we're ordering on? - if (column == self.order_column): - return self.default_header_ordered_column_format(column_number, - column, - label_text) - else: - return self.default_header_column_format(column_number, column, - label_text) - - def default_record_format(self, i, record, columns): - kwargs = { - 'class_': self.get_record_class(i, record, columns), - } - if hasattr(record, 'uuid'): - kwargs['data_uuid'] = record.uuid - return HTML.tag('tr', columns, **kwargs) - - def get_record_class(self, i, record, columns): - if i % 2 == 0: - cls = 'even r{}'.format(i) - else: - cls = 'odd r{}'.format(i) - if self.extra_record_class: - extra = self.extra_record_class(record, i) - if extra: - cls = '{} {}'.format(cls, extra) - return cls - - def get_column_value(self, column_number, i, record, column_name): - if self.renderers and column_name in self.renderers: - return self.renderers[column_name](record, column_name) - try: - return record[column_name] - except TypeError: - return getattr(record, column_name) - - def default_column_format(self, column_number, i, record, column_name): - value = self.get_column_value(column_number, i, record, column_name) - if self.linked_columns and column_name in self.linked_columns and ( - value is not None and value != ''): - url = self.url_generator(record, i) - value = tags.link_to(value, url) - class_name = 'c{} {}'.format(column_number, column_name) - return HTML.tag('td', value, class_=class_name) - - -class GridAction(object): - """ - Represents an action available to a grid. This is used to construct the - 'actions' column when rendering the grid. - """ - - def __init__(self, key, label=None, url='#', icon=None, target=None, - link_class=None, click_handler=None): - self.key = key - self.label = label or prettify(key) - self.icon = icon - self.url = url self.target = target - self.link_class = link_class self.click_handler = click_handler - def get_url(self, row, i): - """ - Returns an action URL for the given row. - """ - if callable(self.url): - return self.url(row, i) - return self.url - - def render_icon(self): - """ - Render the HTML snippet for the action link icon. - """ - return HTML.tag('i', class_='fas fa-{}'.format(self.icon)) - - def render_label(self): - """ - Render the label "text" within the actions column of a grid - row. Most actions have a static label that never varies, but - you can override this to add e.g. HTML content. Note that the - return value will be treated / rendered as HTML whether or not - it contains any, so perhaps be careful that it is trusted - content. - """ - return self.label - class URLMaker(object): """ @@ -1579,5 +1619,5 @@ class URLMaker(object): params = self.request.GET.copy() params["page"] = page params["partial"] = "1" - qs = urllib.parse.urlencode(params, True) + qs = urlencode(params, True) return '{}?{}'.format(self.request.path, qs) diff --git a/tailbone/grids/filters.py b/tailbone/grids/filters.py index 2818b78a..7e52bb8d 100644 --- a/tailbone/grids/filters.py +++ b/tailbone/grids/filters.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,17 +24,15 @@ 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 @@ -117,7 +115,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], six.text_type(k)) for k in sorted_keys] + self.options = [tags.Option(enum[k], str(k)) for k in sorted_keys] class GridFilter(object): @@ -173,18 +171,25 @@ class GridFilter(object): data_type = 'string' # default, but will be set from value renderer choices = {} - def __init__(self, key, label=None, verbs=None, value_enum=None, value_renderer=None, + def __init__(self, key, config=None, 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 @@ -272,14 +277,15 @@ class GridFilter(object): value = self.get_value(value) filtr = getattr(self, 'filter_{0}'.format(verb), None) if not filtr: - raise ValueError("Unknown filter verb: {0}".format(repr(verb))) + log.warning("unknown filter verb: %s", verb) + return data 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, six.string_types): + if self.encode_values and isinstance(value, str): return value.encode('utf-8') return value @@ -308,7 +314,7 @@ class AlchemyGridFilter(GridFilter): def __init__(self, *args, **kwargs): self.column = kwargs.pop('column') - super(AlchemyGridFilter, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) def filter_equal(self, query, value): """ @@ -332,6 +338,38 @@ 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 @@ -410,12 +448,12 @@ class AlchemyGridFilter(GridFilter): if start_value: if self.value_invalid(start_value): return query - query = query.filter(self.column >= start_value) + query = query.filter(self.column >= self.encode_value(start_value)) if end_value: if self.value_invalid(end_value): return query - query = query.filter(self.column <= end_value) + query = query.filter(self.column <= self.encode_value(end_value)) return query @@ -429,9 +467,13 @@ 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', 'not_equal', 'equal_any_of', 'is_empty', 'is_not_empty', 'is_null', 'is_not_null', 'is_empty_or_null', @@ -443,9 +485,13 @@ class AlchemyStringFilter(AlchemyGridFilter): """ if value is None or value == '': return query - return query.filter(sa.and_( - *[self.column.ilike(self.encode_value('%{}%'.format(v))) - for v in value.split()])) + + criteria = [] + for val in value.split(): + val = val.replace('_', r'\_') + val = self.encode_value(f'%{val}%') + criteria.append(self.column.ilike(val)) + return query.filter(sa.and_(*criteria)) def filter_does_not_contain(self, query, value): """ @@ -454,14 +500,17 @@ class AlchemyStringFilter(AlchemyGridFilter): if value is None or value == '': return query + criteria = [] + for val in value.split(): + val = val.replace('_', r'\_') + val = self.encode_value(f'%{val}%') + criteria.append(~self.column.ilike(val)) + # When saying something is 'not like' something else, we must also # include things which are nothing at all, in our result set. return query.filter(sa.or_( self.column == None, - sa.and_( - *[~self.column.ilike(self.encode_value('%{}%'.format(v))) - for v in value.split()]), - )) + sa.and_(*criteria))) def filter_contains_any_of(self, query, value): """ @@ -490,24 +539,28 @@ class AlchemyStringFilter(AlchemyGridFilter): conditions = [] for value in values: - conditions.append(sa.and_( - *[self.column.ilike(self.encode_value('%{}%'.format(v))) - for v in value.split()])) + criteria = [] + for val in value.split(): + val = val.replace('_', r'\_') + val = self.encode_value(f'%{val}%') + criteria.append(self.column.ilike(val)) + conditions.append(sa.and_(*criteria)) return query.filter(sa.or_(*conditions)) def filter_is_empty(self, query, value): - return query.filter(sa.func.trim(self.column) == self.encode_value('')) + return query.filter(sa.func.ltrim(sa.func.rtrim(self.column)) == self.encode_value('')) def filter_is_not_empty(self, query, value): - return query.filter(sa.func.trim(self.column) != self.encode_value('')) + return query.filter(sa.func.ltrim(sa.func.rtrim(self.column)) != self.encode_value('')) def filter_is_empty_or_null(self, query, value): return query.filter( sa.or_( - sa.func.trim(self.column) == self.encode_value(''), + sa.func.ltrim(sa.func.rtrim(self.column)) == self.encode_value(''), self.column == None)) + class AlchemyEmptyStringFilter(AlchemyStringFilter): """ String filter with special logic to treat empty string values as NULL @@ -517,13 +570,13 @@ class AlchemyEmptyStringFilter(AlchemyStringFilter): return query.filter( sa.or_( self.column == None, - sa.func.trim(self.column) == self.encode_value(''))) + sa.func.ltrim(sa.func.rtrim(self.column)) == self.encode_value(''))) def filter_is_not_null(self, query, value): return query.filter( sa.and_( self.column != None, - sa.func.trim(self.column) != self.encode_value(''))) + sa.func.ltrim(sa.func.rtrim(self.column)) != self.encode_value(''))) class AlchemyByteStringFilter(AlchemyStringFilter): @@ -535,8 +588,8 @@ class AlchemyByteStringFilter(AlchemyStringFilter): value_encoding = 'utf-8' def get_value(self, value=UNSPECIFIED): - value = super(AlchemyByteStringFilter, self).get_value(value) - if isinstance(value, six.text_type): + value = super().get_value(value) + if isinstance(value, str): value = value.encode(self.value_encoding) return value @@ -546,8 +599,13 @@ class AlchemyByteStringFilter(AlchemyStringFilter): """ if value is None or value == '': return query - return query.filter(sa.and_( - *[self.column.ilike(b'%{}%'.format(v)) for v in value.split()])) + + criteria = [] + for val in value.split(): + val = val.replace('_', r'\_') + val = b'%{}%'.format(val) + criteria.append(self.column.ilike(val)) + return query.filters(sa.and_(*criteria)) def filter_does_not_contain(self, query, value): """ @@ -556,13 +614,16 @@ class AlchemyByteStringFilter(AlchemyStringFilter): if value is None or value == '': return query + for val in value.split(): + val = val.replace('_', '\_') + val = b'%{}%'.format(val) + criteria.append(~self.column.ilike(val)) + # When saying something is 'not like' something else, we must also # include things which are nothing at all, in our result set. return query.filter(sa.or_( self.column == None, - sa.and_( - *[~self.column.ilike(b'%{}%'.format(v)) for v in value.split()]), - )) + sa.and_(*criteria))) class AlchemyNumericFilter(AlchemyGridFilter): @@ -571,10 +632,11 @@ class AlchemyNumericFilter(AlchemyGridFilter): """ value_renderer_factory = NumericValueRenderer - # 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'] + 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'] # 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 @@ -586,47 +648,66 @@ class AlchemyNumericFilter(AlchemyGridFilter): # first just make sure it's somewhat numeric try: - float(value) - except ValueError: + self.parse_decimal(value) + except decimal.InvalidOperation: return True - return bool(value and len(six.text_type(value)) > 8) + return bool(value and len(str(value)) > 8) + + def parse_decimal(self, value): + if value: + value = value.replace(',', '') + return decimal.Decimal(value) + + def encode_value(self, value): + if value: + value = str(self.parse_decimal(value)) + return super().encode_value(value) def filter_equal(self, query, value): if self.value_invalid(value): return query - return super(AlchemyNumericFilter, self).filter_equal(query, value) + return super().filter_equal(query, value) def filter_not_equal(self, query, value): if self.value_invalid(value): return query - return super(AlchemyNumericFilter, self).filter_not_equal(query, value) + return super().filter_not_equal(query, value) def filter_greater_than(self, query, value): if self.value_invalid(value): return query - return super(AlchemyNumericFilter, self).filter_greater_than(query, value) + return super().filter_greater_than(query, value) def filter_greater_equal(self, query, value): if self.value_invalid(value): return query - return super(AlchemyNumericFilter, self).filter_greater_equal(query, value) + return super().filter_greater_equal(query, value) def filter_less_than(self, query, value): if self.value_invalid(value): return query - return super(AlchemyNumericFilter, self).filter_less_than(query, value) + return super().filter_less_than(query, value) def filter_less_equal(self, query, value): if self.value_invalid(value): return query - return super(AlchemyNumericFilter, self).filter_less_equal(query, value) + return super().filter_less_equal(query, value) class AlchemyIntegerFilter(AlchemyNumericFilter): """ 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: @@ -634,9 +715,10 @@ class AlchemyIntegerFilter(AlchemyNumericFilter): return True if not value.isdigit(): return True - # 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: + # 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: return True return False @@ -646,6 +728,13 @@ class AlchemyIntegerFilter(AlchemyNumericFilter): return int(value) +class AlchemyBigIntegerFilter(AlchemyIntegerFilter): + """ + BigInteger filter for SQLAlchemy. + """ + bigint = True + + class AlchemyBooleanFilter(AlchemyGridFilter): """ Boolean filter for SQLAlchemy. @@ -1134,7 +1223,7 @@ class AlchemyPhoneNumberFilter(AlchemyStringFilter): 'ILIKE' query with those parts. """ value = self.parse_value(value) - return super(AlchemyPhoneNumberFilter, self).filter_contains(query, value) + return super().filter_contains(query, value) def filter_does_not_contain(self, query, value): """ @@ -1142,7 +1231,7 @@ class AlchemyPhoneNumberFilter(AlchemyStringFilter): 'NOT ILIKE' query with those parts. """ value = self.parse_value(value) - return super(AlchemyPhoneNumberFilter, self).filter_does_not_contain(query, value) + return super().filter_does_not_contain(query, value) class GridFilterSet(OrderedDict): @@ -1186,7 +1275,7 @@ class GridFiltersForm(forms.Form): node = colander.SchemaNode(colander.String(), name=key) schema.add(node) kwargs['schema'] = schema - super(GridFiltersForm, self).__init__(**kwargs) + super().__init__(**kwargs) def iter_filters(self): return self.filters.values() diff --git a/tailbone/handler.py b/tailbone/handler.py new file mode 100644 index 00000000..00f41bc9 --- /dev/null +++ b/tailbone/handler.py @@ -0,0 +1,82 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2024 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. +# +################################################################################ +""" +Tailbone Handler +""" + +import warnings + +from mako.lookup import TemplateLookup + +from rattail.app import GenericHandler +from rattail.files import resource_path + +from tailbone.providers import get_all_providers + + +class TailboneHandler(GenericHandler): + """ + Base class and default implementation for Tailbone handler. + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # TODO: make templates dir configurable? + templates = [resource_path('rattail:templates/web')] + self.templates = TemplateLookup(directories=templates) + + def get_menu_handler(self, **kwargs): + """ + DEPRECATED; use + :meth:`wuttaweb.handler.WebHandler.get_menu_handler()` + instead. + """ + warnings.warn("TailboneHandler.get_menu_handler() is deprecated; " + "please use WebHandler.get_menu_handler() instead", + DeprecationWarning, stacklevel=2) + + if not hasattr(self, 'menu_handler'): + spec = self.config.get('tailbone.menus', 'handler', + default='tailbone.menus:MenuHandler') + Handler = self.app.load_object(spec) + self.menu_handler = Handler(self.config) + self.menu_handler.tb = self + return self.menu_handler + + def iter_providers(self): + """ + Returns an iterator over all registered Tailbone providers. + """ + providers = get_all_providers(self.config) + return providers.values() + + def write_model_view(self, data, path, **kwargs): + """ + Write code for a new model view, based on the given data dict, + to the given path. + """ + template = self.templates.get_template('/new-model-view.mako') + content = template.render(**data) + with open(path, 'wt') as f: + f.write(content) diff --git a/tailbone/helpers.py b/tailbone/helpers.py index a3d07f79..50b38c30 100644 --- a/tailbone/helpers.py +++ b/tailbone/helpers.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2021 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,22 +24,20 @@ Template Context Helpers """ -from __future__ import unicode_literals, absolute_import +# start off with all from wuttaweb +from wuttaweb.helpers 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, - OrderedDict) +from rattail.util import pretty_quantity, pretty_hours, hours_as_decimal from rattail.db.util import maxlen -from webhelpers2.html import * -from webhelpers2.html.tags import * - -from tailbone.util import (csrf_token, get_csrf_token, - pretty_datetime, raw_datetime, +from tailbone.util import (pretty_datetime, raw_datetime, + render_markdown, route_exists) diff --git a/tailbone/menus.py b/tailbone/menus.py index 46f5c62a..09d6f3f0 100644 --- a/tailbone/menus.py +++ b/tailbone/menus.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,378 +24,749 @@ App Menus """ -from __future__ import unicode_literals, absolute_import - -import re import logging +import warnings -from rattail.util import import_module_path, prettify, simple_error +from rattail.util import prettify, simple_error from webhelpers2.html import tags, HTML +from wuttaweb.menus import MenuHandler as WuttaMenuHandler + from tailbone.db import Session log = logging.getLogger(__name__) -def make_simple_menus(request): +class TailboneMenuHandler(WuttaMenuHandler): """ - Build the main menu list for the app. + Base class and default implementation for menu handler. """ - # first try to make menus from config, but this is highly - # susceptible to failure, so try to warn user of problems - raw_menus = None - try: - raw_menus = make_menus_from_config(request) - except Exception as error: - # TODO: these messages show up multiple times on some pages?! - # that must mean the BeforeRender event is firing multiple - # times..but why?? seems like there is only 1 request... - log.warning("failed to make menus from config", exc_info=True) - request.session.flash(simple_error(error), 'error') - request.session.flash("Menu config is invalid! Reverting to menus " - "defined in code!", 'warning') - msg = HTML.literal('Please edit your {} ASAP.'.format( - tags.link_to("Menu Config", request.route_url('configure_menus')))) - request.session.flash(msg, 'warning') - if not raw_menus: + ############################## + # internal methods + ############################## - # no config, so import/invoke code function to build them - menus_module = import_module_path( - request.rattail_config.require('tailbone', 'menus')) - if not hasattr(menus_module, 'simple_menus') or not callable(menus_module.simple_menus): - raise RuntimeError("module does not have a simple_menus() callable: {}".format(menus_module)) - raw_menus = menus_module.simple_menus(request) + 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 - # 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: + def _make_raw_menus(self, request, **kwargs): + """ + We are overriding this to allow for making dynamic menus from + config/settings. Which may or may not be a good idea.. + """ + # first try to make menus from config, but this is highly + # susceptible to failure, so try to warn user of problems + try: + menus = self._make_menus_from_config(request) + if menus: + return menus + except Exception as error: - if topitem['allowed']: + # TODO: these messages show up multiple times on some pages?! + # that must mean the BeforeRender event is firing multiple + # times..but why?? seems like there is only 1 request... + log.warning("failed to make menus from config", exc_info=True) + request.session.flash(simple_error(error), 'error') + request.session.flash("Menu config is invalid! Reverting to menus " + "defined in code!", 'warning') + msg = HTML.literal('Please edit your {} ASAP.'.format( + tags.link_to("Menu Config", request.route_url('configure_menus')))) + request.session.flash(msg, 'warning') - if topitem.get('type') == 'link': - final_menus.append(make_menu_entry(request, topitem)) + # okay, no config, so menus will be built from code + return self.make_menus(request, **kwargs) - else: # assuming 'menu' type + def _make_menus_from_config(self, request, **kwargs): + """ + Try to build a complete menu set from config/settings. - menu_items = [] - for item in topitem['items']: - if not item['allowed']: - continue + This will look in the DB settings table, or config file, for + menu data. If found, it constructs menus from that data. + """ + # bail unless config defines top-level menu keys + main_keys = self.config.getlist('tailbone.menu', 'menus') + if not main_keys: + return - # 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, - }) + model = self.app.model + menus = [] - 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)) + # menu definition can come either from config file or db + # settings, but if the latter then we want to optimize with + # one big query + if self.config.getbool('tailbone.menu', 'from_settings', + default=False): - else: # standard menu item - menu_items.append(make_menu_entry(request, item)) + # fetch all menu-related settings at once + query = Session().query(model.Setting)\ + .filter(model.Setting.name.like('tailbone.menu.%')) + settings = self.app.cache_model(Session(), model.Setting, + 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)) - # remove final separator if present - if menu_items and menu_items[-1]['is_sep']: - menu_items.pop() + else: # read from config file only + for key in main_keys: + menus.append(self._make_single_menu_from_config(request, key)) - # 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, - } + return menus - # 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_menus_from_config(request): - """ - Try to build a complete menu set from config/settings. - - This essentially checks for the top-level menu list in config; if - found then it will build a full menu set from config. If this - top-level list is not present in config then menus will be built - purely from code instead. An example of this top-level list: - - .. code-hightlight:: ini - - [tailbone.menu] - menus = first, second, third, admin - - Obviously much more config would be needed to define those menus - etc. but that is the option that determines whether the rest of - menu config is even read, or not. - """ - config = request.rattail_config - main_keys = config.getlist('tailbone.menu', 'menus') - if not main_keys: - return - - menus = [] - - # menu definition can come either from config file or db settings, - # but if the latter then we want to optimize with one big query - if config.getbool('tailbone.menu', 'from_settings', - default=False): - app = config.get_app() - model = config.get_model() - - # fetch all menu-related settings at once - query = Session().query(model.Setting)\ - .filter(model.Setting.name.like('tailbone.menu.%')) - settings = app.cache_model(Session(), model.Setting, - query=query, key='name', - normalizer=lambda s: s.value) - for key in main_keys: - menus.append(make_single_menu_from_settings(request, key, settings)) - - else: # read from config file only - for key in main_keys: - menus.append(make_single_menu_from_config(request, key)) - - return menus - - -def make_single_menu_from_config(request, key): - """ - Makes a single top-level menu dict from config file. Note that - this will read from config file(s) *only* and avoids querying the - database, for efficiency. - """ - config = request.rattail_config - menu = { - 'key': key, - 'type': 'menu', - 'items': [], - } - - # title - title = config.get('tailbone.menu', - 'menu.{}.label'.format(key), - usedb=False) - menu['title'] = title or prettify(key) - - # items - item_keys = config.getlist('tailbone.menu', - 'menu.{}.items'.format(key), - usedb=False) - for item_key in item_keys: - item = {} - - if item_key == 'SEP': - item['type'] = 'sep' - - else: - item['type'] = 'item' - item['key'] = item_key - - # title - title = config.get('tailbone.menu', - 'menu.{}.item.{}.label'.format(key, item_key), - usedb=False) - item['title'] = title or prettify(item_key) - - # route - route = config.get('tailbone.menu', - 'menu.{}.item.{}.route'.format(key, item_key), - usedb=False) - if route: - item['route'] = route - item['url'] = request.route_url(route) - - else: - - # url - url = config.get('tailbone.menu', - 'menu.{}.item.{}.url'.format(key, item_key), - usedb=False) - if not url: - url = request.route_url(item_key) - elif url.startswith('route:'): - url = request.route_url(url[6:]) - item['url'] = url - - # perm - perm = config.get('tailbone.menu', - 'menu.{}.item.{}.perm'.format(key, item_key), - usedb=False) - item['perm'] = perm or '{}.list'.format(item_key) - - menu['items'].append(item) - - return menu - - -def make_single_menu_from_settings(request, key, settings): - """ - Makes a single top-level menu dict from DB settings. - """ - config = request.rattail_config - menu = { - 'key': key, - 'type': 'menu', - 'items': [], - } - - # title - title = settings.get('tailbone.menu.menu.{}.label'.format(key)) - menu['title'] = title or prettify(key) - - # items - item_keys = config.parse_list( - settings.get('tailbone.menu.menu.{}.items'.format(key))) - for item_key in item_keys: - item = {} - - if item_key == 'SEP': - item['type'] = 'sep' - - else: - item['type'] = 'item' - item['key'] = item_key - - # title - title = settings.get('tailbone.menu.menu.{}.item.{}.label'.format( - key, item_key)) - item['title'] = title or prettify(item_key) - - # route - route = settings.get('tailbone.menu.menu.{}.item.{}.route'.format( - key, item_key)) - if route: - item['route'] = route - item['url'] = request.route_url(route) - - else: - - # url - url = settings.get('tailbone.menu.menu.{}.item.{}.url'.format( - key, item_key)) - if not url: - url = request.route_url(item_key) - if url.startswith('route:'): - url = request.route_url(url[6:]) - item['url'] = url - - # perm - perm = settings.get('tailbone.menu.menu.{}.item.{}.perm'.format( - key, item_key)) - item['perm'] = perm or '{}.list'.format(item_key) - - menu['items'].append(item) - - return menu - - -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, + 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 + querying the database, for efficiency. + """ + menu = { + 'key': key, + 'type': 'menu', + 'items': [], } - # 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'] - entry['url'] = request.route_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 + # title + title = self.config.get('tailbone.menu', + 'menu.{}.label'.format(key), + usedb=False) + menu['title'] = title or prettify(key) + # items + item_keys = self.config.getlist('tailbone.menu', + 'menu.{}.items'.format(key), + usedb=False) + for item_key in item_keys: + item = {} -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 + if item_key == 'SEP': + item['type'] = 'sep' + else: + item['type'] = 'item' + item['key'] = item_key -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: + # title + title = self.config.get('tailbone.menu', + 'menu.{}.item.{}.label'.format(key, item_key), + usedb=False) + item['title'] = title or prettify(item_key) - 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 + # route + route = self.config.get('tailbone.menu', + 'menu.{}.item.{}.route'.format(key, item_key), + usedb=False) + if route: + item['route'] = route + item['url'] = request.route_url(route) else: - item['allowed'] = is_allowed(request, item) - for item in topitem['items']: - if item['allowed'] and item.get('type') != 'sep': - topitem['allowed'] = True - break + # url + url = self.config.get('tailbone.menu', + 'menu.{}.item.{}.url'.format(key, item_key), + usedb=False) + if not url: + url = request.route_url(item_key) + elif url.startswith('route:'): + url = request.route_url(url[6:]) + item['url'] = url + + # perm + perm = self.config.get('tailbone.menu', + 'menu.{}.item.{}.perm'.format(key, item_key), + usedb=False) + item['perm'] = perm or '{}.list'.format(item_key) + + menu['items'].append(item) + + return menu + + def _make_single_menu_from_settings(self, request, key, settings, **kwargs): + """ + Makes a single top-level menu dict from DB settings. + """ + menu = { + 'key': key, + 'type': 'menu', + 'items': [], + } + + # title + title = settings.get('tailbone.menu.menu.{}.label'.format(key)) + menu['title'] = title or prettify(key) + + # items + item_keys = self.config.parse_list( + settings.get('tailbone.menu.menu.{}.items'.format(key))) + for item_key in item_keys: + item = {} + + if item_key == 'SEP': + item['type'] = 'sep' + + else: + item['type'] = 'item' + item['key'] = item_key + + # title + title = settings.get('tailbone.menu.menu.{}.item.{}.label'.format( + key, item_key)) + item['title'] = title or prettify(item_key) + + # route + route = settings.get('tailbone.menu.menu.{}.item.{}.route'.format( + key, item_key)) + if route: + item['route'] = route + item['url'] = request.route_url(route) + + else: + + # url + url = settings.get('tailbone.menu.menu.{}.item.{}.url'.format( + key, item_key)) + if not url: + url = request.route_url(item_key) + if url.startswith('route:'): + url = request.route_url(url[6:]) + item['url'] = url + + # perm + perm = settings.get('tailbone.menu.menu.{}.item.{}.perm'.format( + key, item_key)) + item['perm'] = perm or '{}.list'.format(item_key) + + menu['items'].append(item) + + return menu + + ############################## + # menu defaults + ############################## + + def make_menus(self, request, **kwargs): + """ + Make the full set of menus for the app. + + This method provides a semi-sane menu set by default, but it + is expected for most apps to override it. + """ + menus = [ + self.make_custorders_menu(request), + self.make_people_menu(request), + self.make_products_menu(request), + self.make_vendors_menu(request), + ] + + integration_menus = self.make_integration_menus(request) + if integration_menus: + menus.extend(integration_menus) + + menus.extend([ + self.make_reports_menu(request, include_trainwreck=True), + self.make_batches_menu(request), + self.make_admin_menu(request, include_stores=True), + ]) + + return menus + + def make_integration_menus(self, request, **kwargs): + """ + Make a set of menus for all registered system integrations. + """ + tb = self.app.get_tailbone_handler() + menus = [] + for provider in tb.iter_providers(): + menu = provider.make_integration_menu(request) + if menu: + menus.append(menu) + menus.sort(key=lambda menu: menu['title'].lower()) + return menus + + def make_custorders_menu(self, request, **kwargs): + """ + Generate a typical Customer Orders menu + """ + return { + 'title': "Orders", + 'type': 'menu', + 'items': [ + { + 'title': "New Customer Order", + 'route': 'custorders.create', + 'perm': 'custorders.create', + }, + { + 'title': "All New Orders", + 'route': 'new_custorders', + 'perm': 'new_custorders.list', + }, + {'type': 'sep'}, + { + 'title': "All Customer Orders", + 'route': 'custorders', + 'perm': 'custorders.list', + }, + { + 'title': "All Order Items", + 'route': 'custorders.items', + 'perm': 'custorders.items.list', + }, + ], + } + + def make_people_menu(self, request, **kwargs): + """ + Generate a typical People menu + """ + return { + 'title': "People", + 'type': 'menu', + 'items': [ + { + 'title': "Members", + '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', + }, + ], + } + + def make_products_menu(self, request, **kwargs): + """ + Generate a typical Products menu + """ + return { + 'title': "Products", + 'type': 'menu', + 'items': [ + { + 'title': "Products", + 'route': 'products', + 'perm': 'products.list', + }, + { + 'title': "Product Costs", + 'route': 'product_costs', + 'perm': 'product_costs.list', + }, + { + 'title': "Departments", + 'route': 'departments', + 'perm': 'departments.list', + }, + { + 'title': "Subdepartments", + 'route': 'subdepartments', + 'perm': 'subdepartments.list', + }, + { + 'title': "Brands", + 'route': 'brands', + 'perm': 'brands.list', + }, + { + 'title': "Categories", + 'route': 'categories', + 'perm': 'categories.list', + }, + { + 'title': "Families", + 'route': 'families', + 'perm': 'families.list', + }, + { + 'title': "Report Codes", + 'route': 'reportcodes', + 'perm': 'reportcodes.list', + }, + { + 'title': "Units of Measure", + 'route': 'uoms', + 'perm': 'uoms.list', + }, + {'type': 'sep'}, + { + 'title': "Pending Products", + 'route': 'pending_products', + 'perm': 'pending_products.list', + }, + ], + } + + def make_vendors_menu(self, request, **kwargs): + """ + Generate a typical Vendors menu + """ + return { + 'title': "Vendors", + 'type': 'menu', + 'items': [ + { + 'title': "Vendors", + 'route': 'vendors', + 'perm': 'vendors.list', + }, + { + 'title': "Product Costs", + 'route': 'product_costs', + 'perm': 'product_costs.list', + }, + {'type': 'sep'}, + { + 'title': "Ordering", + 'route': 'ordering', + 'perm': 'ordering.list', + }, + { + 'title': "Receiving", + 'route': 'receiving', + 'perm': 'receiving.list', + }, + { + 'title': "Invoice Costing", + 'route': 'invoice_costing', + 'perm': 'invoice_costing.list', + }, + {'type': 'sep'}, + { + 'title': "Purchases", + 'route': 'purchases', + 'perm': 'purchases.list', + }, + { + 'title': "Credits", + 'route': 'purchases.credits', + 'perm': 'purchases.credits.list', + }, + {'type': 'sep'}, + { + 'title': "Catalog Batches", + 'route': 'vendorcatalogs', + 'perm': 'vendorcatalogs.list', + }, + {'type': 'sep'}, + { + 'title': "Sample Files", + 'route': 'vendorsamplefiles', + 'perm': 'vendorsamplefiles.list', + }, + ], + } + + def make_batches_menu(self, request, **kwargs): + """ + Generate a typical Batches menu + """ + return { + 'title': "Batches", + 'type': 'menu', + 'items': [ + { + 'title': "Handheld", + 'route': 'batch.handheld', + 'perm': 'batch.handheld.list', + }, + { + 'title': "Inventory", + 'route': 'batch.inventory', + 'perm': 'batch.inventory.list', + }, + { + 'title': "Import / Export", + 'route': 'batch.importer', + 'perm': 'batch.importer.list', + }, + { + 'title': "POS", + 'route': 'batch.pos', + 'perm': 'batch.pos.list', + }, + ], + } + + def make_reports_menu(self, request, **kwargs): + """ + Generate a typical Reports menu + """ + items = [ + { + 'title': "New Report", + 'route': 'report_output.create', + 'perm': 'report_output.create', + }, + { + 'title': "Generated Reports", + 'route': 'report_output', + 'perm': 'report_output.list', + }, + { + 'title': "Problem Reports", + 'route': 'problem_reports', + 'perm': 'problem_reports.list', + }, + ] + + if kwargs.get('include_poser', False): + items.extend([ + {'type': 'sep'}, + { + 'title': "Poser Reports", + 'route': 'poser_reports', + 'perm': 'poser_reports.list', + }, + ]) + + 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'}, + { + 'title': "Trainwreck", + 'route': 'trainwreck.transactions', + 'perm': 'trainwreck.transactions.list', + }, + ]) + + return { + 'title': "Reports", + 'type': 'menu', + 'items': items, + } + + def make_tempmon_menu(self, request, **kwargs): + """ + Generate a typical TempMon menu + """ + return { + 'title': "TempMon", + 'type': 'menu', + 'items': [ + { + 'title': "Dashboard", + 'route': 'tempmon.dashboard', + 'perm': 'tempmon.appliances.dashboard', + }, + {'type': 'sep'}, + { + 'title': "Appliances", + 'route': 'tempmon.appliances', + 'perm': 'tempmon.appliances.list', + }, + { + 'title': "Clients", + 'route': 'tempmon.clients', + 'perm': 'tempmon.clients.list', + }, + { + 'title': "Probes", + 'route': 'tempmon.probes', + 'perm': 'tempmon.probes.list', + }, + { + 'title': "Readings", + 'route': 'tempmon.readings', + 'perm': 'tempmon.readings.list', + }, + ], + } + + def make_admin_menu(self, request, **kwargs): + """ + Generate a typical Admin menu + """ + 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'}) + + items.extend([ + { + 'title': "Users", + 'route': 'users', + 'perm': 'users.list', + }, + { + 'title': "Roles", + 'route': 'roles', + 'perm': 'roles.list', + }, + { + 'title': "Raw Permissions", + 'route': 'permissions', + 'perm': 'permissions.list', + }, + {'type': 'sep'}, + { + 'title': "Email Settings", + 'route': 'emailprofiles', + 'perm': 'emailprofiles.list', + }, + { + 'title': "Email Attempts", + 'route': 'email_attempts', + 'perm': 'email_attempts.list', + }, + {'type': 'sep'}, + { + 'title': "DataSync Status", + 'route': 'datasync.status', + 'perm': 'datasync.status', + }, + { + 'title': "DataSync Changes", + 'route': 'datasyncchanges', + 'perm': 'datasync_changes.list', + }, + { + 'title': "Importing / Exporting", + 'route': 'importing', + 'perm': 'importing.list', + }, + { + 'title': "Luigi Tasks", + 'route': 'luigi', + 'perm': 'luigi.list', + }, + {'type': 'sep'}, + { + 'title': "App Info", + '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': "Raw Settings", + 'route': 'settings', + 'perm': 'settings.list', + }, + { + 'title': "Upgrades", + 'route': 'upgrades', + 'perm': 'upgrades.list', + }, + ]) + + return { + 'title': "Admin", + 'type': 'menu', + 'items': items, + } + + +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): + """ + 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. + """ + + def make_menus(self, request, **kwargs): + return [] diff --git a/tailbone/providers.py b/tailbone/providers.py index baa2a15d..a538fa73 100644 --- a/tailbone/providers.py +++ b/tailbone/providers.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -48,6 +48,9 @@ class TailboneProvider(object): def get_provided_views(self): return {} + def make_integration_menu(self, request, **kwargs): + pass + def get_all_providers(config): """ diff --git a/tailbone/reports/ordering_worksheet.mako b/tailbone/reports/ordering_worksheet.mako index f6a97dc6..fe3f53e8 100644 --- a/tailbone/reports/ordering_worksheet.mako +++ b/tailbone/reports/ordering_worksheet.mako @@ -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">${cost.case_size} ${"LB" if cost.product.weighed else "EA"}</td> + <td class="case-qty">${app.render_quantity(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): diff --git a/tailbone/static/__init__.py b/tailbone/static/__init__.py index 2ad5161a..57700b80 100644 --- a/tailbone/static/__init__.py +++ b/tailbone/static/__init__.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,9 +24,8 @@ Static Assets """ -from __future__ import unicode_literals, absolute_import - def includeme(config): + config.include('wuttaweb.static') config.add_static_view('tailbone', 'tailbone:static') config.add_static_view('deform', 'deform:static') diff --git a/tailbone/static/css/base.css b/tailbone/static/css/base.css index 689fb000..0fa02dbb 100644 --- a/tailbone/static/css/base.css +++ b/tailbone/static/css/base.css @@ -1,122 +1,14 @@ -/****************************** - * 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 ******************************/ -.menubar .root-user .ui-button-text, -.menubar .root-user.ui-menu-item a { +.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; - color: black; font-weight: bold; } - -.menubar .root-user.ui-menu-item a { - padding-left: 1em; -} diff --git a/tailbone/static/css/filters.css b/tailbone/static/css/filters.css index c4d59025..72506a06 100644 --- a/tailbone/static/css/filters.css +++ b/tailbone/static/css/filters.css @@ -1,28 +1,18 @@ /****************************** - * Filters + * Grid Filters ******************************/ -div.filters form { - margin-bottom: 10px; +.filters .filter-fieldname .field, +.filters .filter-fieldname .field label { + width: 100%; } -div.filters div.filter { - margin-bottom: 10px; +.filters .filter-fieldname .field label { + justify-content: left; } -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; +.filters .filter-verb .select, +.filters .filter-verb .select select { + width: 100%; } diff --git a/tailbone/static/css/forms.css b/tailbone/static/css/forms.css index 42364b14..de4b1ebe 100644 --- a/tailbone/static/css/forms.css +++ b/tailbone/static/css/forms.css @@ -1,34 +1,37 @@ /****************************** - * Form Wrapper + * forms ******************************/ -div.form-wrapper { - overflow: auto; -} - - -/****************************** - * Forms - ******************************/ - -div.form, -div.fieldset-form, -div.fieldset { - clear: left; - float: left; - margin-top: 10px; -} - +/* 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%; +} /****************************** - * Fieldsets + * field-wrappers ******************************/ +/* TODO: replace this with bulma equivalent */ .field-wrapper { clear: both; min-height: 30px; @@ -36,16 +39,12 @@ div.fieldset { margin: 15px; } -.field-wrapper.with-error { - background-color: #ddcccc; - border: 2px solid #dd6666; - padding-bottom: 1em; -} - +/* 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; @@ -55,47 +54,8 @@ div.fieldset { white-space: nowrap; } -.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; -} - +/* TODO: replace this with bulma equivalent */ .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; -} diff --git a/tailbone/static/css/grids.css b/tailbone/static/css/grids.css index 3725c8e3..42da832c 100644 --- a/tailbone/static/css/grids.css +++ b/tailbone/static/css/grids.css @@ -25,6 +25,11 @@ margin: 0; } +.grid-tools { + display: flex; + gap: 0.5rem; +} + .grid-wrapper .grid-header td.tools { margin: 0; padding: 0; @@ -261,6 +266,10 @@ * main actions ******************************/ +a.grid-action { + white-space: nowrap; +} + .grid .actions { width: 1px; } diff --git a/tailbone/static/themes/falafel/css/grids.rowstatus.css b/tailbone/static/css/grids.rowstatus.css similarity index 95% rename from tailbone/static/themes/falafel/css/grids.rowstatus.css rename to tailbone/static/css/grids.rowstatus.css index 9335b827..bfd73404 100644 --- a/tailbone/static/themes/falafel/css/grids.rowstatus.css +++ b/tailbone/static/css/grids.rowstatus.css @@ -2,7 +2,7 @@ /******************************************************************************** * grids.rowstatus.css * - * Add "row status" styles for Buefy grid tables. + * Add "row status" styles for grid tables. ********************************************************************************/ /************************************************** diff --git a/tailbone/static/css/jquery.loadmask.css b/tailbone/static/css/jquery.loadmask.css deleted file mode 100644 index 6aa1caa1..00000000 --- a/tailbone/static/css/jquery.loadmask.css +++ /dev/null @@ -1,40 +0,0 @@ -.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; -} \ No newline at end of file diff --git a/tailbone/static/css/jquery.tagit.css b/tailbone/static/css/jquery.tagit.css deleted file mode 100644 index f18650d9..00000000 --- a/tailbone/static/css/jquery.tagit.css +++ /dev/null @@ -1,69 +0,0 @@ -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; -} diff --git a/tailbone/static/css/jquery.ui.menubar.css b/tailbone/static/css/jquery.ui.menubar.css deleted file mode 100644 index 8b175f28..00000000 --- a/tailbone/static/css/jquery.ui.menubar.css +++ /dev/null @@ -1,15 +0,0 @@ -/* - * 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; } diff --git a/tailbone/static/css/jquery.ui.tailbone.css b/tailbone/static/css/jquery.ui.tailbone.css deleted file mode 100644 index b6ce1023..00000000 --- a/tailbone/static/css/jquery.ui.tailbone.css +++ /dev/null @@ -1,14 +0,0 @@ - -/********************************************************************** - * jquery.ui.tailbone.css - * - * jQuery UI tweaks for Tailbone - **********************************************************************/ - -.ui-widget { - font-size: 1em; -} - -.ui-menu-item a { - display: block; -} diff --git a/tailbone/static/css/jquery.ui.timepicker.css b/tailbone/static/css/jquery.ui.timepicker.css deleted file mode 100644 index b5930fb7..00000000 --- a/tailbone/static/css/jquery.ui.timepicker.css +++ /dev/null @@ -1,57 +0,0 @@ -/* - * 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; } - - diff --git a/tailbone/static/css/layout.css b/tailbone/static/css/layout.css index 6c1b926f..ef5c5352 100644 --- a/tailbone/static/css/layout.css +++ b/tailbone/static/css/layout.css @@ -1,152 +1,87 @@ /****************************** - * Main Layout + * main layout ******************************/ -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; +body { + display: flex; + flex-direction: column; + min-height: 100vh; } .content-wrapper { - height: 100%; - padding-bottom: 30px; + display: flex; + flex: 1; + flex-direction: column; + justify-content: space-between; } -#scrollpane { - height: 100%; + +/****************************** + * header + ******************************/ + +/* this is the one in the very top left of screen, next to logo and linked to +the home page */ +#global-header-title { + margin-left: 0.3rem; } -#scrollpane .inner-content { - padding: 0 0.5em 0.5em 0.5em; +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; +} + +#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 { - list-style-type: none; - margin: 0.5em; + margin-bottom: 1em; + margin-left: 1em; text-align: right; white-space: nowrap; } @@ -155,11 +90,24 @@ body > #body-wrapper { * "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; - min-width: 20em; + width: 20em; } .object-helper-content { @@ -167,87 +115,44 @@ body > #body-wrapper { } /****************************** - * Panels + * markdown ******************************/ -.panel, -.panel-grid { - border-left: 1px solid Black; - margin-bottom: 1em; +.rendered-markdown p, +.rendered-markdown ul { + margin-bottom: 1rem; } -.panel { - border-bottom: 1px solid Black; - border-right: 1px solid Black; - padding: 0px; +.rendered-markdown .codehilite { + margin-bottom: 2rem; } -.panel h2, -.panel-grid h2 { - border-bottom: 1px solid Black; - border-top: 1px solid Black; - padding: 5px; - margin: 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-grid h2 { - border-right: 1px solid Black; +.modal-card-body { + overflow: visible !important; } -.panel-body { - overflow: auto; - padding: 5px; -} +/* TODO: a simpler option we might try sometime instead? */ +/* cf. https://github.com/buefy/buefy/issues/292#issuecomment-1073851313 */ -/**************************************** - * 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%; -} +/* .dropdown-content{ */ +/* position: fixed; */ +/* } */ /****************************** * feedback ******************************/ -#feedback-dialog { - display: none; -} - -#feedback-dialog p { - margin-top: 1em; -} - -#feedback-dialog .red { +.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; -} diff --git a/tailbone/static/css/login.css b/tailbone/static/css/login.css deleted file mode 100644 index 448f9f70..00000000 --- a/tailbone/static/css/login.css +++ /dev/null @@ -1,48 +0,0 @@ - -/****************************** - * 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; -} diff --git a/tailbone/static/js/jquery.ui.tailbone.js b/tailbone/static/js/jquery.ui.tailbone.js deleted file mode 100644 index 06904e59..00000000 --- a/tailbone/static/js/jquery.ui.tailbone.js +++ /dev/null @@ -1,452 +0,0 @@ - -/********************************************************************** - * 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 ); diff --git a/tailbone/static/js/lib/jquery.loadmask.min.js b/tailbone/static/js/lib/jquery.loadmask.min.js deleted file mode 100644 index d77373c8..00000000 --- a/tailbone/static/js/lib/jquery.loadmask.min.js +++ /dev/null @@ -1,10 +0,0 @@ -/** - * 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); \ No newline at end of file diff --git a/tailbone/static/js/lib/jquery.ui.menubar.js b/tailbone/static/js/lib/jquery.ui.menubar.js deleted file mode 100644 index a1559091..00000000 --- a/tailbone/static/js/lib/jquery.ui.menubar.js +++ /dev/null @@ -1,331 +0,0 @@ -/* - * 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 )); diff --git a/tailbone/static/js/lib/jquery.ui.timepicker.js b/tailbone/static/js/lib/jquery.ui.timepicker.js deleted file mode 100644 index d8a0cfb7..00000000 --- a/tailbone/static/js/lib/jquery.ui.timepicker.js +++ /dev/null @@ -1,1496 +0,0 @@ -/* - * jQuery UI Timepicker - * - * Copyright 2010-2013, Francois Gelinas - * Dual licensed under the MIT or GPL Version 2 licenses. - * http://jquery.org/license - * - * http://fgelinas.com/code/timepicker - * - * Depends: - * jquery.ui.core.js - * jquery.ui.position.js (only if position settings are used) - * - * Change version 0.1.0 - moved the t-rex up here - * - ____ - ___ .-~. /_"-._ - `-._~-. / /_ "~o\ :Y - \ \ / : \~x. ` ') - ] Y / | Y< ~-.__j - / ! _.--~T : l l< /.-~ - / / ____.--~ . ` l /~\ \<|Y - / / .-~~" /| . ',-~\ \L| - / / / .^ \ Y~Y \.^>/l_ "--' - / Y .-"( . l__ j_j l_/ /~_.-~ . - Y l / \ ) ~~~." / `/"~ / \.__/l_ - | \ _.-" ~-{__ l : l._Z~-.___.--~ - | ~---~ / ~~"---\_ ' __[> - l . _.^ ___ _>-y~ - \ \ . .-~ .-~ ~>--" / - \ ~---" / ./ _.-' - "-.,_____.,_ _.--~\ _.-~ - ~~ ( _} -Row - `. ~( - ) \ - /,`--'~\--'~\ - ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - ->T-Rex<- -*/ - -(function ($) { - - $.extend($.ui, { timepicker: { version: "0.3.3"} }); - - var PROP_NAME = 'timepicker', - tpuuid = new Date().getTime(); - - /* Time picker manager. - Use the singleton instance of this class, $.timepicker, to interact with the time picker. - Settings for (groups of) time pickers are maintained in an instance object, - allowing multiple different settings on the same page. */ - - function Timepicker() { - this.debug = true; // Change this to true to start debugging - this._curInst = null; // The current instance in use - this._disabledInputs = []; // List of time picker inputs that have been disabled - this._timepickerShowing = false; // True if the popup picker is showing , false if not - this._inDialog = false; // True if showing within a "dialog", false if not - this._dialogClass = 'ui-timepicker-dialog'; // The name of the dialog marker class - this._mainDivId = 'ui-timepicker-div'; // The ID of the main timepicker division - this._inlineClass = 'ui-timepicker-inline'; // The name of the inline marker class - this._currentClass = 'ui-timepicker-current'; // The name of the current hour / minutes marker class - this._dayOverClass = 'ui-timepicker-days-cell-over'; // The name of the day hover marker class - - this.regional = []; // Available regional settings, indexed by language code - this.regional[''] = { // Default regional settings - hourText: 'Hour', // Display text for hours section - minuteText: 'Minute', // Display text for minutes link - amPmText: ['AM', 'PM'], // Display text for AM PM - closeButtonText: 'Done', // Text for the confirmation button (ok button) - nowButtonText: 'Now', // Text for the now button - deselectButtonText: 'Deselect' // Text for the deselect button - }; - this._defaults = { // Global defaults for all the time picker instances - showOn: 'focus', // 'focus' for popup on focus, - // 'button' for trigger button, or 'both' for either (not yet implemented) - button: null, // 'button' element that will trigger the timepicker - showAnim: 'fadeIn', // Name of jQuery animation for popup - showOptions: {}, // Options for enhanced animations - appendText: '', // Display text following the input box, e.g. showing the format - - beforeShow: null, // Define a callback function executed before the timepicker is shown - onSelect: null, // Define a callback function when a hour / minutes is selected - onClose: null, // Define a callback function when the timepicker is closed - - timeSeparator: ':', // The character to use to separate hours and minutes. - periodSeparator: ' ', // The character to use to separate the time from the time period. - showPeriod: false, // Define whether or not to show AM/PM with selected time - showPeriodLabels: true, // Show the AM/PM labels on the left of the time picker - showLeadingZero: true, // Define whether or not to show a leading zero for hours < 10. [true/false] - showMinutesLeadingZero: true, // Define whether or not to show a leading zero for minutes < 10. - altField: '', // Selector for an alternate field to store selected time into - defaultTime: 'now', // Used as default time when input field is empty or for inline timePicker - // (set to 'now' for the current time, '' for no highlighted time) - myPosition: 'left top', // Position of the dialog relative to the input. - // see the position utility for more info : http://jqueryui.com/demos/position/ - atPosition: 'left bottom', // Position of the input element to match - // Note : if the position utility is not loaded, the timepicker will attach left top to left bottom - //NEW: 2011-02-03 - onHourShow: null, // callback for enabling / disabling on selectable hours ex : function(hour) { return true; } - onMinuteShow: null, // callback for enabling / disabling on time selection ex : function(hour,minute) { return true; } - - hours: { - starts: 0, // first displayed hour - ends: 23 // last displayed hour - }, - minutes: { - starts: 0, // first displayed minute - ends: 55, // last displayed minute - interval: 5, // interval of displayed minutes - manual: [] // optional extra manual entries for minutes - }, - rows: 4, // number of rows for the input tables, minimum 2, makes more sense if you use multiple of 2 - // 2011-08-05 0.2.4 - showHours: true, // display the hours section of the dialog - showMinutes: true, // display the minute section of the dialog - optionalMinutes: false, // optionally parse inputs of whole hours with minutes omitted - - // buttons - showCloseButton: false, // shows an OK button to confirm the edit - showNowButton: false, // Shows the 'now' button - showDeselectButton: false, // Shows the deselect time button - - maxTime: { - hour: null, - minute: null - }, - minTime: { - hour: null, - minute: null - } - - }; - $.extend(this._defaults, this.regional['']); - - this.tpDiv = $('<div id="' + this._mainDivId + '" class="ui-timepicker ui-widget ui-helper-clearfix ui-corner-all " style="display: none"></div>'); - } - - $.extend(Timepicker.prototype, { - /* Class name added to elements to indicate already configured with a time picker. */ - markerClassName: 'hasTimepicker', - - /* Debug logging (if enabled). */ - log: function () { - if (this.debug) - console.log.apply('', arguments); - }, - - _widgetTimepicker: function () { - return this.tpDiv; - }, - - /* Override the default settings for all instances of the time picker. - @param settings object - the new settings to use as defaults (anonymous object) - @return the manager object */ - setDefaults: function (settings) { - extendRemove(this._defaults, settings || {}); - return this; - }, - - /* Attach the time picker to a jQuery selection. - @param target element - the target input field or division or span - @param settings object - the new settings to use for this time picker instance (anonymous) */ - _attachTimepicker: function (target, settings) { - // check for settings on the control itself - in namespace 'time:' - var inlineSettings = null; - for (var attrName in this._defaults) { - var attrValue = target.getAttribute('time:' + attrName); - if (attrValue) { - inlineSettings = inlineSettings || {}; - try { - inlineSettings[attrName] = eval(attrValue); - } catch (err) { - inlineSettings[attrName] = attrValue; - } - } - } - var nodeName = target.nodeName.toLowerCase(); - var inline = (nodeName == 'div' || nodeName == 'span'); - - if (!target.id) { - this.uuid += 1; - target.id = 'tp' + this.uuid; - } - var inst = this._newInst($(target), inline); - inst.settings = $.extend({}, settings || {}, inlineSettings || {}); - if (nodeName == 'input') { - this._connectTimepicker(target, inst); - // init inst.hours and inst.minutes from the input value - this._setTimeFromField(inst); - } else if (inline) { - this._inlineTimepicker(target, inst); - } - - - }, - - /* Create a new instance object. */ - _newInst: function (target, inline) { - var id = target[0].id.replace(/([^A-Za-z0-9_-])/g, '\\\\$1'); // escape jQuery meta chars - return { - id: id, input: target, // associated target - inline: inline, // is timepicker inline or not : - tpDiv: (!inline ? this.tpDiv : // presentation div - $('<div class="' + this._inlineClass + ' ui-timepicker ui-widget ui-helper-clearfix"></div>')) - }; - }, - - /* Attach the time picker to an input field. */ - _connectTimepicker: function (target, inst) { - var input = $(target); - inst.append = $([]); - inst.trigger = $([]); - if (input.hasClass(this.markerClassName)) { return; } - this._attachments(input, inst); - input.addClass(this.markerClassName). - keydown(this._doKeyDown). - keyup(this._doKeyUp). - bind("setData.timepicker", function (event, key, value) { - inst.settings[key] = value; - }). - bind("getData.timepicker", function (event, key) { - return this._get(inst, key); - }); - $.data(target, PROP_NAME, inst); - }, - - /* Handle keystrokes. */ - _doKeyDown: function (event) { - var inst = $.timepicker._getInst(event.target); - var handled = true; - inst._keyEvent = true; - if ($.timepicker._timepickerShowing) { - switch (event.keyCode) { - case 9: $.timepicker._hideTimepicker(); - handled = false; - break; // hide on tab out - case 13: - $.timepicker._updateSelectedValue(inst); - $.timepicker._hideTimepicker(); - - return false; // don't submit the form - break; // select the value on enter - case 27: $.timepicker._hideTimepicker(); - break; // hide on escape - default: handled = false; - } - } - else if (event.keyCode == 36 && event.ctrlKey) { // display the time picker on ctrl+home - $.timepicker._showTimepicker(this); - } - else { - handled = false; - } - if (handled) { - event.preventDefault(); - event.stopPropagation(); - } - }, - - /* Update selected time on keyUp */ - /* Added verion 0.0.5 */ - _doKeyUp: function (event) { - var inst = $.timepicker._getInst(event.target); - $.timepicker._setTimeFromField(inst); - $.timepicker._updateTimepicker(inst); - }, - - /* Make attachments based on settings. */ - _attachments: function (input, inst) { - var appendText = this._get(inst, 'appendText'); - var isRTL = this._get(inst, 'isRTL'); - if (inst.append) { inst.append.remove(); } - if (appendText) { - inst.append = $('<span class="' + this._appendClass + '">' + appendText + '</span>'); - input[isRTL ? 'before' : 'after'](inst.append); - } - input.unbind('focus.timepicker', this._showTimepicker); - input.unbind('click.timepicker', this._adjustZIndex); - - if (inst.trigger) { inst.trigger.remove(); } - - var showOn = this._get(inst, 'showOn'); - if (showOn == 'focus' || showOn == 'both') { // pop-up time picker when in the marked field - input.bind("focus.timepicker", this._showTimepicker); - input.bind("click.timepicker", this._adjustZIndex); - } - if (showOn == 'button' || showOn == 'both') { // pop-up time picker when 'button' element is clicked - var button = this._get(inst, 'button'); - - // Add button if button element is not set - if(button == null) { - button = $('<button class="ui-timepicker-trigger" type="button">...</button>'); - input.after(button); - } - - $(button).bind("click.timepicker", function () { - if ($.timepicker._timepickerShowing && $.timepicker._lastInput == input[0]) { - $.timepicker._hideTimepicker(); - } else if (!inst.input.is(':disabled')) { - $.timepicker._showTimepicker(input[0]); - } - return false; - }); - - } - }, - - - /* Attach an inline time picker to a div. */ - _inlineTimepicker: function(target, inst) { - var divSpan = $(target); - if (divSpan.hasClass(this.markerClassName)) - return; - divSpan.addClass(this.markerClassName).append(inst.tpDiv). - bind("setData.timepicker", function(event, key, value){ - inst.settings[key] = value; - }).bind("getData.timepicker", function(event, key){ - return this._get(inst, key); - }); - $.data(target, PROP_NAME, inst); - - this._setTimeFromField(inst); - this._updateTimepicker(inst); - inst.tpDiv.show(); - }, - - _adjustZIndex: function(input) { - input = input.target || input; - var inst = $.timepicker._getInst(input); - inst.tpDiv.css('zIndex', $.timepicker._getZIndex(input) +1); - }, - - /* Pop-up the time picker for a given input field. - @param input element - the input field attached to the time picker or - event - if triggered by focus */ - _showTimepicker: function (input) { - input = input.target || input; - if (input.nodeName.toLowerCase() != 'input') { input = $('input', input.parentNode)[0]; } // find from button/image trigger - - if ($.timepicker._isDisabledTimepicker(input) || $.timepicker._lastInput == input) { return; } // already here - - // fix v 0.0.8 - close current timepicker before showing another one - $.timepicker._hideTimepicker(); - - var inst = $.timepicker._getInst(input); - if ($.timepicker._curInst && $.timepicker._curInst != inst) { - $.timepicker._curInst.tpDiv.stop(true, true); - } - var beforeShow = $.timepicker._get(inst, 'beforeShow'); - extendRemove(inst.settings, (beforeShow ? beforeShow.apply(input, [input, inst]) : {})); - inst.lastVal = null; - $.timepicker._lastInput = input; - - $.timepicker._setTimeFromField(inst); - - // calculate default position - if ($.timepicker._inDialog) { input.value = ''; } // hide cursor - if (!$.timepicker._pos) { // position below input - $.timepicker._pos = $.timepicker._findPos(input); - $.timepicker._pos[1] += input.offsetHeight; // add the height - } - var isFixed = false; - $(input).parents().each(function () { - isFixed |= $(this).css('position') == 'fixed'; - return !isFixed; - }); - - var offset = { left: $.timepicker._pos[0], top: $.timepicker._pos[1] }; - - $.timepicker._pos = null; - // determine sizing offscreen - inst.tpDiv.css({ position: 'absolute', display: 'block', top: '-1000px' }); - $.timepicker._updateTimepicker(inst); - - - // position with the ui position utility, if loaded - if ( ( ! inst.inline ) && ( typeof $.ui.position == 'object' ) ) { - inst.tpDiv.position({ - of: inst.input, - my: $.timepicker._get( inst, 'myPosition' ), - at: $.timepicker._get( inst, 'atPosition' ), - // offset: $( "#offset" ).val(), - // using: using, - collision: 'flip' - }); - var offset = inst.tpDiv.offset(); - $.timepicker._pos = [offset.top, offset.left]; - } - - - // reset clicked state - inst._hoursClicked = false; - inst._minutesClicked = false; - - // fix width for dynamic number of time pickers - // and adjust position before showing - offset = $.timepicker._checkOffset(inst, offset, isFixed); - inst.tpDiv.css({ position: ($.timepicker._inDialog && $.blockUI ? - 'static' : (isFixed ? 'fixed' : 'absolute')), display: 'none', - left: offset.left + 'px', top: offset.top + 'px' - }); - if ( ! inst.inline ) { - var showAnim = $.timepicker._get(inst, 'showAnim'); - var duration = $.timepicker._get(inst, 'duration'); - - var postProcess = function () { - $.timepicker._timepickerShowing = true; - var borders = $.timepicker._getBorders(inst.tpDiv); - inst.tpDiv.find('iframe.ui-timepicker-cover'). // IE6- only - css({ left: -borders[0], top: -borders[1], - width: inst.tpDiv.outerWidth(), height: inst.tpDiv.outerHeight() - }); - }; - - // Fixed the zIndex problem for real (I hope) - FG - v 0.2.9 - $.timepicker._adjustZIndex(input); - //inst.tpDiv.css('zIndex', $.timepicker._getZIndex(input) +1); - - if ($.effects && $.effects[showAnim]) { - inst.tpDiv.show(showAnim, $.timepicker._get(inst, 'showOptions'), duration, postProcess); - } - else { - inst.tpDiv.show((showAnim ? duration : null), postProcess); - } - if (!showAnim || !duration) { postProcess(); } - if (inst.input.is(':visible') && !inst.input.is(':disabled')) { inst.input.focus(); } - $.timepicker._curInst = inst; - } - }, - - // This is an enhanced copy of the zIndex function of UI core 1.8.?? For backward compatibility. - // Enhancement returns maximum zindex value discovered while traversing parent elements, - // rather than the first zindex value found. Ensures the timepicker popup will be in front, - // even in funky scenarios like non-jq dialog containers with large fixed zindex values and - // nested zindex-influenced elements of their own. - _getZIndex: function (target) { - var elem = $(target); - var maxValue = 0; - var position, value; - while (elem.length && elem[0] !== document) { - position = elem.css("position"); - if (position === "absolute" || position === "relative" || position === "fixed") { - value = parseInt(elem.css("zIndex"), 10); - if (!isNaN(value) && value !== 0) { - if (value > maxValue) { maxValue = value; } - } - } - elem = elem.parent(); - } - - return maxValue; - }, - - /* Refresh the time picker - @param target element - The target input field or inline container element. */ - _refreshTimepicker: function(target) { - var inst = this._getInst(target); - if (inst) { - this._updateTimepicker(inst); - } - }, - - - /* Generate the time picker content. */ - _updateTimepicker: function (inst) { - inst.tpDiv.empty().append(this._generateHTML(inst)); - this._rebindDialogEvents(inst); - - }, - - _rebindDialogEvents: function (inst) { - var borders = $.timepicker._getBorders(inst.tpDiv), - self = this; - inst.tpDiv - .find('iframe.ui-timepicker-cover') // IE6- only - .css({ left: -borders[0], top: -borders[1], - width: inst.tpDiv.outerWidth(), height: inst.tpDiv.outerHeight() - }) - .end() - // after the picker html is appended bind the click & double click events (faster in IE this way - // then letting the browser interpret the inline events) - // the binding for the minute cells also exists in _updateMinuteDisplay - .find('.ui-timepicker-minute-cell') - .unbind() - .bind("click", { fromDoubleClick:false }, $.proxy($.timepicker.selectMinutes, this)) - .bind("dblclick", { fromDoubleClick:true }, $.proxy($.timepicker.selectMinutes, this)) - .end() - .find('.ui-timepicker-hour-cell') - .unbind() - .bind("click", { fromDoubleClick:false }, $.proxy($.timepicker.selectHours, this)) - .bind("dblclick", { fromDoubleClick:true }, $.proxy($.timepicker.selectHours, this)) - .end() - .find('.ui-timepicker td a') - .unbind() - .bind('mouseout', function () { - $(this).removeClass('ui-state-hover'); - if (this.className.indexOf('ui-timepicker-prev') != -1) $(this).removeClass('ui-timepicker-prev-hover'); - if (this.className.indexOf('ui-timepicker-next') != -1) $(this).removeClass('ui-timepicker-next-hover'); - }) - .bind('mouseover', function () { - if ( ! self._isDisabledTimepicker(inst.inline ? inst.tpDiv.parent()[0] : inst.input[0])) { - $(this).parents('.ui-timepicker-calendar').find('a').removeClass('ui-state-hover'); - $(this).addClass('ui-state-hover'); - if (this.className.indexOf('ui-timepicker-prev') != -1) $(this).addClass('ui-timepicker-prev-hover'); - if (this.className.indexOf('ui-timepicker-next') != -1) $(this).addClass('ui-timepicker-next-hover'); - } - }) - .end() - .find('.' + this._dayOverClass + ' a') - .trigger('mouseover') - .end() - .find('.ui-timepicker-now').bind("click", function(e) { - $.timepicker.selectNow(e); - }).end() - .find('.ui-timepicker-deselect').bind("click",function(e) { - $.timepicker.deselectTime(e); - }).end() - .find('.ui-timepicker-close').bind("click",function(e) { - $.timepicker._hideTimepicker(); - }).end(); - }, - - /* Generate the HTML for the current state of the time picker. */ - _generateHTML: function (inst) { - - var h, m, row, col, html, hoursHtml, minutesHtml = '', - showPeriod = (this._get(inst, 'showPeriod') == true), - showPeriodLabels = (this._get(inst, 'showPeriodLabels') == true), - showLeadingZero = (this._get(inst, 'showLeadingZero') == true), - showHours = (this._get(inst, 'showHours') == true), - showMinutes = (this._get(inst, 'showMinutes') == true), - amPmText = this._get(inst, 'amPmText'), - rows = this._get(inst, 'rows'), - amRows = 0, - pmRows = 0, - amItems = 0, - pmItems = 0, - amFirstRow = 0, - pmFirstRow = 0, - hours = Array(), - hours_options = this._get(inst, 'hours'), - hoursPerRow = null, - hourCounter = 0, - hourLabel = this._get(inst, 'hourText'), - showCloseButton = this._get(inst, 'showCloseButton'), - closeButtonText = this._get(inst, 'closeButtonText'), - showNowButton = this._get(inst, 'showNowButton'), - nowButtonText = this._get(inst, 'nowButtonText'), - showDeselectButton = this._get(inst, 'showDeselectButton'), - deselectButtonText = this._get(inst, 'deselectButtonText'), - showButtonPanel = showCloseButton || showNowButton || showDeselectButton; - - - - // prepare all hours and minutes, makes it easier to distribute by rows - for (h = hours_options.starts; h <= hours_options.ends; h++) { - hours.push (h); - } - hoursPerRow = Math.ceil(hours.length / rows); // always round up - - if (showPeriodLabels) { - for (hourCounter = 0; hourCounter < hours.length; hourCounter++) { - if (hours[hourCounter] < 12) { - amItems++; - } - else { - pmItems++; - } - } - hourCounter = 0; - - amRows = Math.floor(amItems / hours.length * rows); - pmRows = Math.floor(pmItems / hours.length * rows); - - // assign the extra row to the period that is more densely populated - if (rows != amRows + pmRows) { - // Make sure: AM Has Items and either PM Does Not, AM has no rows yet, or AM is more dense - if (amItems && (!pmItems || !amRows || (pmRows && amItems / amRows >= pmItems / pmRows))) { - amRows++; - } else { - pmRows++; - } - } - amFirstRow = Math.min(amRows, 1); - pmFirstRow = amRows + 1; - - if (amRows == 0) { - hoursPerRow = Math.ceil(pmItems / pmRows); - } else if (pmRows == 0) { - hoursPerRow = Math.ceil(amItems / amRows); - } else { - hoursPerRow = Math.ceil(Math.max(amItems / amRows, pmItems / pmRows)); - } - } - - - html = '<table class="ui-timepicker-table ui-widget-content ui-corner-all"><tr>'; - - if (showHours) { - - html += '<td class="ui-timepicker-hours">' + - '<div class="ui-timepicker-title ui-widget-header ui-helper-clearfix ui-corner-all">' + - hourLabel + - '</div>' + - '<table class="ui-timepicker">'; - - for (row = 1; row <= rows; row++) { - html += '<tr>'; - // AM - if (row == amFirstRow && showPeriodLabels) { - html += '<th rowspan="' + amRows.toString() + '" class="periods" scope="row">' + amPmText[0] + '</th>'; - } - // PM - if (row == pmFirstRow && showPeriodLabels) { - html += '<th rowspan="' + pmRows.toString() + '" class="periods" scope="row">' + amPmText[1] + '</th>'; - } - for (col = 1; col <= hoursPerRow; col++) { - if (showPeriodLabels && row < pmFirstRow && hours[hourCounter] >= 12) { - html += this._generateHTMLHourCell(inst, undefined, showPeriod, showLeadingZero); - } else { - html += this._generateHTMLHourCell(inst, hours[hourCounter], showPeriod, showLeadingZero); - hourCounter++; - } - } - html += '</tr>'; - } - html += '</table>' + // Close the hours cells table - '</td>'; // Close the Hour td - } - - if (showMinutes) { - html += '<td class="ui-timepicker-minutes">'; - html += this._generateHTMLMinutes(inst); - html += '</td>'; - } - - html += '</tr>'; - - - if (showButtonPanel) { - var buttonPanel = '<tr><td colspan="3"><div class="ui-timepicker-buttonpane ui-widget-content">'; - if (showNowButton) { - buttonPanel += '<button type="button" class="ui-timepicker-now ui-state-default ui-corner-all" ' - + ' data-timepicker-instance-id="#' + inst.id.replace(/\\\\/g,"\\") + '" >' - + nowButtonText + '</button>'; - } - if (showDeselectButton) { - buttonPanel += '<button type="button" class="ui-timepicker-deselect ui-state-default ui-corner-all" ' - + ' data-timepicker-instance-id="#' + inst.id.replace(/\\\\/g,"\\") + '" >' - + deselectButtonText + '</button>'; - } - if (showCloseButton) { - buttonPanel += '<button type="button" class="ui-timepicker-close ui-state-default ui-corner-all" ' - + ' data-timepicker-instance-id="#' + inst.id.replace(/\\\\/g,"\\") + '" >' - + closeButtonText + '</button>'; - } - - html += buttonPanel + '</div></td></tr>'; - } - html += '</table>'; - - return html; - }, - - /* Special function that update the minutes selection in currently visible timepicker - * called on hour selection when onMinuteShow is defined */ - _updateMinuteDisplay: function (inst) { - var newHtml = this._generateHTMLMinutes(inst); - inst.tpDiv.find('td.ui-timepicker-minutes').html(newHtml); - this._rebindDialogEvents(inst); - // after the picker html is appended bind the click & double click events (faster in IE this way - // then letting the browser interpret the inline events) - // yes I know, duplicate code, sorry -/* .find('.ui-timepicker-minute-cell') - .bind("click", { fromDoubleClick:false }, $.proxy($.timepicker.selectMinutes, this)) - .bind("dblclick", { fromDoubleClick:true }, $.proxy($.timepicker.selectMinutes, this)); -*/ - - }, - - /* - * Generate the minutes table - * This is separated from the _generateHTML function because is can be called separately (when hours changes) - */ - _generateHTMLMinutes: function (inst) { - - var m, row, html = '', - rows = this._get(inst, 'rows'), - minutes = Array(), - minutes_options = this._get(inst, 'minutes'), - minutesPerRow = null, - minuteCounter = 0, - showMinutesLeadingZero = (this._get(inst, 'showMinutesLeadingZero') == true), - onMinuteShow = this._get(inst, 'onMinuteShow'), - minuteLabel = this._get(inst, 'minuteText'); - - if ( ! minutes_options.starts) { - minutes_options.starts = 0; - } - if ( ! minutes_options.ends) { - minutes_options.ends = 59; - } - if ( ! minutes_options.manual) { - minutes_options.manual = []; - } - for (m = minutes_options.starts; m <= minutes_options.ends; m += minutes_options.interval) { - minutes.push(m); - } - for (i = 0; i < minutes_options.manual.length;i++) { - var currMin = minutes_options.manual[i]; - - // Validate & filter duplicates of manual minute input - if (typeof currMin != 'number' || currMin < 0 || currMin > 59 || $.inArray(currMin, minutes) >= 0) { - continue; - } - minutes.push(currMin); - } - - // Sort to get correct order after adding manual minutes - // Use compare function to sort by number, instead of string (default) - minutes.sort(function(a, b) { - return a-b; - }); - - minutesPerRow = Math.round(minutes.length / rows + 0.49); // always round up - - /* - * The minutes table - */ - // if currently selected minute is not enabled, we have a problem and need to select a new minute. - if (onMinuteShow && - (onMinuteShow.apply((inst.input ? inst.input[0] : null), [inst.hours , inst.minutes]) == false) ) { - // loop minutes and select first available - for (minuteCounter = 0; minuteCounter < minutes.length; minuteCounter += 1) { - m = minutes[minuteCounter]; - if (onMinuteShow.apply((inst.input ? inst.input[0] : null), [inst.hours, m])) { - inst.minutes = m; - break; - } - } - } - - - - html += '<div class="ui-timepicker-title ui-widget-header ui-helper-clearfix ui-corner-all">' + - minuteLabel + - '</div>' + - '<table class="ui-timepicker">'; - - minuteCounter = 0; - for (row = 1; row <= rows; row++) { - html += '<tr>'; - while (minuteCounter < row * minutesPerRow) { - var m = minutes[minuteCounter]; - var displayText = ''; - if (m !== undefined ) { - displayText = (m < 10) && showMinutesLeadingZero ? "0" + m.toString() : m.toString(); - } - html += this._generateHTMLMinuteCell(inst, m, displayText); - minuteCounter++; - } - html += '</tr>'; - } - - html += '</table>'; - - return html; - }, - - /* Generate the content of a "Hour" cell */ - _generateHTMLHourCell: function (inst, hour, showPeriod, showLeadingZero) { - - var displayHour = hour; - if ((hour > 12) && showPeriod) { - displayHour = hour - 12; - } - if ((displayHour == 0) && showPeriod) { - displayHour = 12; - } - if ((displayHour < 10) && showLeadingZero) { - displayHour = '0' + displayHour; - } - - var html = ""; - var enabled = true; - var onHourShow = this._get(inst, 'onHourShow'); //custom callback - var maxTime = this._get(inst, 'maxTime'); - var minTime = this._get(inst, 'minTime'); - - if (hour == undefined) { - html = '<td><span class="ui-state-default ui-state-disabled"> </span></td>'; - return html; - } - - if (onHourShow) { - enabled = onHourShow.apply((inst.input ? inst.input[0] : null), [hour]); - } - - if (enabled) { - if ( !isNaN(parseInt(maxTime.hour)) && hour > maxTime.hour ) enabled = false; - if ( !isNaN(parseInt(minTime.hour)) && hour < minTime.hour ) enabled = false; - } - - if (enabled) { - html = '<td class="ui-timepicker-hour-cell" data-timepicker-instance-id="#' + inst.id.replace(/\\\\/g,"\\") + '" data-hour="' + hour.toString() + '">' + - '<a class="ui-state-default ' + - (hour == inst.hours ? 'ui-state-active' : '') + - '">' + - displayHour.toString() + - '</a></td>'; - } - else { - html = - '<td>' + - '<span class="ui-state-default ui-state-disabled ' + - (hour == inst.hours ? ' ui-state-active ' : ' ') + - '">' + - displayHour.toString() + - '</span>' + - '</td>'; - } - return html; - }, - - /* Generate the content of a "Hour" cell */ - _generateHTMLMinuteCell: function (inst, minute, displayText) { - var html = ""; - var enabled = true; - var hour = inst.hours; - var onMinuteShow = this._get(inst, 'onMinuteShow'); //custom callback - var maxTime = this._get(inst, 'maxTime'); - var minTime = this._get(inst, 'minTime'); - - if (onMinuteShow) { - //NEW: 2011-02-03 we should give the hour as a parameter as well! - enabled = onMinuteShow.apply((inst.input ? inst.input[0] : null), [inst.hours,minute]); //trigger callback - } - - if (minute == undefined) { - html = '<td><span class="ui-state-default ui-state-disabled"> </span></td>'; - return html; - } - - if (enabled && hour !== null) { - if ( !isNaN(parseInt(maxTime.hour)) && !isNaN(parseInt(maxTime.minute)) && hour >= maxTime.hour && minute > maxTime.minute ) enabled = false; - if ( !isNaN(parseInt(minTime.hour)) && !isNaN(parseInt(minTime.minute)) && hour <= minTime.hour && minute < minTime.minute ) enabled = false; - } - - if (enabled) { - html = '<td class="ui-timepicker-minute-cell" data-timepicker-instance-id="#' + inst.id.replace(/\\\\/g,"\\") + '" data-minute="' + minute.toString() + '" >' + - '<a class="ui-state-default ' + - (minute == inst.minutes ? 'ui-state-active' : '') + - '" >' + - displayText + - '</a></td>'; - } - else { - - html = '<td>' + - '<span class="ui-state-default ui-state-disabled" >' + - displayText + - '</span>' + - '</td>'; - } - return html; - }, - - - /* Detach a timepicker from its control. - @param target element - the target input field or division or span */ - _destroyTimepicker: function(target) { - var $target = $(target); - var inst = $.data(target, PROP_NAME); - if (!$target.hasClass(this.markerClassName)) { - return; - } - var nodeName = target.nodeName.toLowerCase(); - $.removeData(target, PROP_NAME); - if (nodeName == 'input') { - inst.append.remove(); - inst.trigger.remove(); - $target.removeClass(this.markerClassName) - .unbind('focus.timepicker', this._showTimepicker) - .unbind('click.timepicker', this._adjustZIndex); - } else if (nodeName == 'div' || nodeName == 'span') - $target.removeClass(this.markerClassName).empty(); - }, - - /* Enable the date picker to a jQuery selection. - @param target element - the target input field or division or span */ - _enableTimepicker: function(target) { - var $target = $(target), - target_id = $target.attr('id'), - inst = $.data(target, PROP_NAME); - - if (!$target.hasClass(this.markerClassName)) { - return; - } - var nodeName = target.nodeName.toLowerCase(); - if (nodeName == 'input') { - target.disabled = false; - var button = this._get(inst, 'button'); - $(button).removeClass('ui-state-disabled').disabled = false; - inst.trigger.filter('button'). - each(function() { this.disabled = false; }).end(); - } - else if (nodeName == 'div' || nodeName == 'span') { - var inline = $target.children('.' + this._inlineClass); - inline.children().removeClass('ui-state-disabled'); - inline.find('button').each( - function() { this.disabled = false } - ) - } - this._disabledInputs = $.map(this._disabledInputs, - function(value) { return (value == target_id ? null : value); }); // delete entry - }, - - /* Disable the time picker to a jQuery selection. - @param target element - the target input field or division or span */ - _disableTimepicker: function(target) { - var $target = $(target); - var inst = $.data(target, PROP_NAME); - if (!$target.hasClass(this.markerClassName)) { - return; - } - var nodeName = target.nodeName.toLowerCase(); - if (nodeName == 'input') { - var button = this._get(inst, 'button'); - - $(button).addClass('ui-state-disabled').disabled = true; - target.disabled = true; - - inst.trigger.filter('button'). - each(function() { this.disabled = true; }).end(); - - } - else if (nodeName == 'div' || nodeName == 'span') { - var inline = $target.children('.' + this._inlineClass); - inline.children().addClass('ui-state-disabled'); - inline.find('button').each( - function() { this.disabled = true } - ) - - } - this._disabledInputs = $.map(this._disabledInputs, - function(value) { return (value == target ? null : value); }); // delete entry - this._disabledInputs[this._disabledInputs.length] = $target.attr('id'); - }, - - /* Is the first field in a jQuery collection disabled as a timepicker? - @param target_id element - the target input field or division or span - @return boolean - true if disabled, false if enabled */ - _isDisabledTimepicker: function (target_id) { - if ( ! target_id) { return false; } - for (var i = 0; i < this._disabledInputs.length; i++) { - if (this._disabledInputs[i] == target_id) { return true; } - } - return false; - }, - - /* Check positioning to remain on screen. */ - _checkOffset: function (inst, offset, isFixed) { - var tpWidth = inst.tpDiv.outerWidth(); - var tpHeight = inst.tpDiv.outerHeight(); - var inputWidth = inst.input ? inst.input.outerWidth() : 0; - var inputHeight = inst.input ? inst.input.outerHeight() : 0; - var viewWidth = document.documentElement.clientWidth + $(document).scrollLeft(); - var viewHeight = document.documentElement.clientHeight + $(document).scrollTop(); - - offset.left -= (this._get(inst, 'isRTL') ? (tpWidth - inputWidth) : 0); - offset.left -= (isFixed && offset.left == inst.input.offset().left) ? $(document).scrollLeft() : 0; - offset.top -= (isFixed && offset.top == (inst.input.offset().top + inputHeight)) ? $(document).scrollTop() : 0; - - // now check if timepicker is showing outside window viewport - move to a better place if so. - offset.left -= Math.min(offset.left, (offset.left + tpWidth > viewWidth && viewWidth > tpWidth) ? - Math.abs(offset.left + tpWidth - viewWidth) : 0); - offset.top -= Math.min(offset.top, (offset.top + tpHeight > viewHeight && viewHeight > tpHeight) ? - Math.abs(tpHeight + inputHeight) : 0); - - return offset; - }, - - /* Find an object's position on the screen. */ - _findPos: function (obj) { - var inst = this._getInst(obj); - var isRTL = this._get(inst, 'isRTL'); - while (obj && (obj.type == 'hidden' || obj.nodeType != 1)) { - obj = obj[isRTL ? 'previousSibling' : 'nextSibling']; - } - var position = $(obj).offset(); - return [position.left, position.top]; - }, - - /* Retrieve the size of left and top borders for an element. - @param elem (jQuery object) the element of interest - @return (number[2]) the left and top borders */ - _getBorders: function (elem) { - var convert = function (value) { - return { thin: 1, medium: 2, thick: 3}[value] || value; - }; - return [parseFloat(convert(elem.css('border-left-width'))), - parseFloat(convert(elem.css('border-top-width')))]; - }, - - - /* Close time picker if clicked elsewhere. */ - _checkExternalClick: function (event) { - if (!$.timepicker._curInst) { return; } - var $target = $(event.target); - if ($target[0].id != $.timepicker._mainDivId && - $target.parents('#' + $.timepicker._mainDivId).length == 0 && - !$target.hasClass($.timepicker.markerClassName) && - !$target.hasClass($.timepicker._triggerClass) && - $.timepicker._timepickerShowing && !($.timepicker._inDialog && $.blockUI)) - $.timepicker._hideTimepicker(); - }, - - /* Hide the time picker from view. - @param input element - the input field attached to the time picker */ - _hideTimepicker: function (input) { - var inst = this._curInst; - if (!inst || (input && inst != $.data(input, PROP_NAME))) { return; } - if (this._timepickerShowing) { - var showAnim = this._get(inst, 'showAnim'); - var duration = this._get(inst, 'duration'); - var postProcess = function () { - $.timepicker._tidyDialog(inst); - this._curInst = null; - }; - if ($.effects && $.effects[showAnim]) { - inst.tpDiv.hide(showAnim, $.timepicker._get(inst, 'showOptions'), duration, postProcess); - } - else { - inst.tpDiv[(showAnim == 'slideDown' ? 'slideUp' : - (showAnim == 'fadeIn' ? 'fadeOut' : 'hide'))]((showAnim ? duration : null), postProcess); - } - if (!showAnim) { postProcess(); } - - this._timepickerShowing = false; - - this._lastInput = null; - if (this._inDialog) { - this._dialogInput.css({ position: 'absolute', left: '0', top: '-100px' }); - if ($.blockUI) { - $.unblockUI(); - $('body').append(this.tpDiv); - } - } - this._inDialog = false; - - var onClose = this._get(inst, 'onClose'); - if (onClose) { - onClose.apply( - (inst.input ? inst.input[0] : null), - [(inst.input ? inst.input.val() : ''), inst]); // trigger custom callback - } - - } - }, - - - - /* Tidy up after a dialog display. */ - _tidyDialog: function (inst) { - inst.tpDiv.removeClass(this._dialogClass).unbind('.ui-timepicker'); - }, - - /* Retrieve the instance data for the target control. - @param target element - the target input field or division or span - @return object - the associated instance data - @throws error if a jQuery problem getting data */ - _getInst: function (target) { - try { - return $.data(target, PROP_NAME); - } - catch (err) { - throw 'Missing instance data for this timepicker'; - } - }, - - /* Get a setting value, defaulting if necessary. */ - _get: function (inst, name) { - return inst.settings[name] !== undefined ? - inst.settings[name] : this._defaults[name]; - }, - - /* Parse existing time and initialise time picker. */ - _setTimeFromField: function (inst) { - if (inst.input.val() == inst.lastVal) { return; } - var defaultTime = this._get(inst, 'defaultTime'); - - var timeToParse = defaultTime == 'now' ? this._getCurrentTimeRounded(inst) : defaultTime; - if ((inst.inline == false) && (inst.input.val() != '')) { timeToParse = inst.input.val() } - - if (timeToParse instanceof Date) { - inst.hours = timeToParse.getHours(); - inst.minutes = timeToParse.getMinutes(); - } else { - var timeVal = inst.lastVal = timeToParse; - if (timeToParse == '') { - inst.hours = -1; - inst.minutes = -1; - } else { - var time = this.parseTime(inst, timeVal); - inst.hours = time.hours; - inst.minutes = time.minutes; - } - } - - - $.timepicker._updateTimepicker(inst); - }, - - /* Update or retrieve the settings for an existing time picker. - @param target element - the target input field or division or span - @param name object - the new settings to update or - string - the name of the setting to change or retrieve, - when retrieving also 'all' for all instance settings or - 'defaults' for all global defaults - @param value any - the new value for the setting - (omit if above is an object or to retrieve a value) */ - _optionTimepicker: function(target, name, value) { - var inst = this._getInst(target); - if (arguments.length == 2 && typeof name == 'string') { - return (name == 'defaults' ? $.extend({}, $.timepicker._defaults) : - (inst ? (name == 'all' ? $.extend({}, inst.settings) : - this._get(inst, name)) : null)); - } - var settings = name || {}; - if (typeof name == 'string') { - settings = {}; - settings[name] = value; - } - if (inst) { - extendRemove(inst.settings, settings); - if (this._curInst == inst) { - this._hideTimepicker(); - this._updateTimepicker(inst); - } - if (inst.inline) { - this._updateTimepicker(inst); - } - } - }, - - - /* Set the time for a jQuery selection. - @param target element - the target input field or division or span - @param time String - the new time */ - _setTimeTimepicker: function(target, time) { - var inst = this._getInst(target); - if (inst) { - this._setTime(inst, time); - this._updateTimepicker(inst); - this._updateAlternate(inst, time); - } - }, - - /* Set the time directly. */ - _setTime: function(inst, time, noChange) { - var origHours = inst.hours; - var origMinutes = inst.minutes; - if (time instanceof Date) { - inst.hours = time.getHours(); - inst.minutes = time.getMinutes(); - } else { - var time = this.parseTime(inst, time); - inst.hours = time.hours; - inst.minutes = time.minutes; - } - - if ((origHours != inst.hours || origMinutes != inst.minutes) && !noChange) { - inst.input.trigger('change'); - } - this._updateTimepicker(inst); - this._updateSelectedValue(inst); - }, - - /* Return the current time, ready to be parsed, rounded to the closest minute by interval */ - _getCurrentTimeRounded: function (inst) { - var currentTime = new Date(), - currentMinutes = currentTime.getMinutes(), - minutes_options = this._get(inst, 'minutes'), - // round to closest interval - adjustedMinutes = Math.round(currentMinutes / minutes_options.interval) * minutes_options.interval; - currentTime.setMinutes(adjustedMinutes); - return currentTime; - }, - - /* - * Parse a time string into hours and minutes - */ - parseTime: function (inst, timeVal) { - var retVal = new Object(); - retVal.hours = -1; - retVal.minutes = -1; - - if(!timeVal) - return ''; - - var timeSeparator = this._get(inst, 'timeSeparator'), - amPmText = this._get(inst, 'amPmText'), - showHours = this._get(inst, 'showHours'), - showMinutes = this._get(inst, 'showMinutes'), - optionalMinutes = this._get(inst, 'optionalMinutes'), - showPeriod = (this._get(inst, 'showPeriod') == true), - p = timeVal.indexOf(timeSeparator); - - // check if time separator found - if (p != -1) { - retVal.hours = parseInt(timeVal.substr(0, p), 10); - retVal.minutes = parseInt(timeVal.substr(p + 1), 10); - } - // check for hours only - else if ( (showHours) && ( !showMinutes || optionalMinutes ) ) { - retVal.hours = parseInt(timeVal, 10); - } - // check for minutes only - else if ( ( ! showHours) && (showMinutes) ) { - retVal.minutes = parseInt(timeVal, 10); - } - - if (showHours) { - var timeValUpper = timeVal.toUpperCase(); - if ((retVal.hours < 12) && (showPeriod) && (timeValUpper.indexOf(amPmText[1].toUpperCase()) != -1)) { - retVal.hours += 12; - } - // fix for 12 AM - if ((retVal.hours == 12) && (showPeriod) && (timeValUpper.indexOf(amPmText[0].toUpperCase()) != -1)) { - retVal.hours = 0; - } - } - - return retVal; - }, - - selectNow: function(event) { - var id = $(event.target).attr("data-timepicker-instance-id"), - $target = $(id), - inst = this._getInst($target[0]); - //if (!inst || (input && inst != $.data(input, PROP_NAME))) { return; } - var currentTime = new Date(); - inst.hours = currentTime.getHours(); - inst.minutes = currentTime.getMinutes(); - this._updateSelectedValue(inst); - this._updateTimepicker(inst); - this._hideTimepicker(); - }, - - deselectTime: function(event) { - var id = $(event.target).attr("data-timepicker-instance-id"), - $target = $(id), - inst = this._getInst($target[0]); - inst.hours = -1; - inst.minutes = -1; - this._updateSelectedValue(inst); - this._hideTimepicker(); - }, - - - selectHours: function (event) { - var $td = $(event.currentTarget), - id = $td.attr("data-timepicker-instance-id"), - newHours = parseInt($td.attr("data-hour")), - fromDoubleClick = event.data.fromDoubleClick, - $target = $(id), - inst = this._getInst($target[0]), - showMinutes = (this._get(inst, 'showMinutes') == true); - - // don't select if disabled - if ( $.timepicker._isDisabledTimepicker($target.attr('id')) ) { return false } - - $td.parents('.ui-timepicker-hours:first').find('a').removeClass('ui-state-active'); - $td.children('a').addClass('ui-state-active'); - inst.hours = newHours; - - // added for onMinuteShow callback - var onMinuteShow = this._get(inst, 'onMinuteShow'), - maxTime = this._get(inst, 'maxTime'), - minTime = this._get(inst, 'minTime'); - if (onMinuteShow || maxTime.minute || minTime.minute) { - // this will trigger a callback on selected hour to make sure selected minute is allowed. - this._updateMinuteDisplay(inst); - } - - this._updateSelectedValue(inst); - - inst._hoursClicked = true; - if ((inst._minutesClicked) || (fromDoubleClick) || (showMinutes == false)) { - $.timepicker._hideTimepicker(); - } - // return false because if used inline, prevent the url to change to a hashtag - return false; - }, - - selectMinutes: function (event) { - var $td = $(event.currentTarget), - id = $td.attr("data-timepicker-instance-id"), - newMinutes = parseInt($td.attr("data-minute")), - fromDoubleClick = event.data.fromDoubleClick, - $target = $(id), - inst = this._getInst($target[0]), - showHours = (this._get(inst, 'showHours') == true); - - // don't select if disabled - if ( $.timepicker._isDisabledTimepicker($target.attr('id')) ) { return false } - - $td.parents('.ui-timepicker-minutes:first').find('a').removeClass('ui-state-active'); - $td.children('a').addClass('ui-state-active'); - - inst.minutes = newMinutes; - this._updateSelectedValue(inst); - - inst._minutesClicked = true; - if ((inst._hoursClicked) || (fromDoubleClick) || (showHours == false)) { - $.timepicker._hideTimepicker(); - // return false because if used inline, prevent the url to change to a hashtag - return false; - } - - // return false because if used inline, prevent the url to change to a hashtag - return false; - }, - - _updateSelectedValue: function (inst) { - var newTime = this._getParsedTime(inst); - if (inst.input) { - inst.input.val(newTime); - inst.input.trigger('change'); - } - var onSelect = this._get(inst, 'onSelect'); - if (onSelect) { onSelect.apply((inst.input ? inst.input[0] : null), [newTime, inst]); } // trigger custom callback - this._updateAlternate(inst, newTime); - return newTime; - }, - - /* this function process selected time and return it parsed according to instance options */ - _getParsedTime: function(inst) { - - if (inst.hours == -1 && inst.minutes == -1) { - return ''; - } - - // default to 0 AM if hours is not valid - if ((inst.hours < inst.hours.starts) || (inst.hours > inst.hours.ends )) { inst.hours = 0; } - // default to 0 minutes if minute is not valid - if ((inst.minutes < inst.minutes.starts) || (inst.minutes > inst.minutes.ends)) { inst.minutes = 0; } - - var period = "", - showPeriod = (this._get(inst, 'showPeriod') == true), - showLeadingZero = (this._get(inst, 'showLeadingZero') == true), - showHours = (this._get(inst, 'showHours') == true), - showMinutes = (this._get(inst, 'showMinutes') == true), - optionalMinutes = (this._get(inst, 'optionalMinutes') == true), - amPmText = this._get(inst, 'amPmText'), - selectedHours = inst.hours ? inst.hours : 0, - selectedMinutes = inst.minutes ? inst.minutes : 0, - displayHours = selectedHours ? selectedHours : 0, - parsedTime = ''; - - // fix some display problem when hours or minutes are not selected yet - if (displayHours == -1) { displayHours = 0 } - if (selectedMinutes == -1) { selectedMinutes = 0 } - - if (showPeriod) { - if (inst.hours == 0) { - displayHours = 12; - } - if (inst.hours < 12) { - period = amPmText[0]; - } - else { - period = amPmText[1]; - if (displayHours > 12) { - displayHours -= 12; - } - } - } - - var h = displayHours.toString(); - if (showLeadingZero && (displayHours < 10)) { h = '0' + h; } - - var m = selectedMinutes.toString(); - if (selectedMinutes < 10) { m = '0' + m; } - - if (showHours) { - parsedTime += h; - } - if (showHours && showMinutes && (!optionalMinutes || m != 0)) { - parsedTime += this._get(inst, 'timeSeparator'); - } - if (showMinutes && (!optionalMinutes || m != 0)) { - parsedTime += m; - } - if (showHours) { - if (period.length > 0) { parsedTime += this._get(inst, 'periodSeparator') + period; } - } - - return parsedTime; - }, - - /* Update any alternate field to synchronise with the main field. */ - _updateAlternate: function(inst, newTime) { - var altField = this._get(inst, 'altField'); - if (altField) { // update alternate field too - $(altField).each(function(i,e) { - $(e).val(newTime); - }); - } - }, - - _getTimeAsDateTimepicker: function(input) { - var inst = this._getInst(input); - if (inst.hours == -1 && inst.minutes == -1) { - return ''; - } - - // default to 0 AM if hours is not valid - if ((inst.hours < inst.hours.starts) || (inst.hours > inst.hours.ends )) { inst.hours = 0; } - // default to 0 minutes if minute is not valid - if ((inst.minutes < inst.minutes.starts) || (inst.minutes > inst.minutes.ends)) { inst.minutes = 0; } - - return new Date(0, 0, 0, inst.hours, inst.minutes, 0); - }, - /* This might look unused but it's called by the $.fn.timepicker function with param getTime */ - /* added v 0.2.3 - gitHub issue #5 - Thanks edanuff */ - _getTimeTimepicker : function(input) { - var inst = this._getInst(input); - return this._getParsedTime(inst); - }, - _getHourTimepicker: function(input) { - var inst = this._getInst(input); - if ( inst == undefined) { return -1; } - return inst.hours; - }, - _getMinuteTimepicker: function(input) { - var inst= this._getInst(input); - if ( inst == undefined) { return -1; } - return inst.minutes; - } - - }); - - - - /* Invoke the timepicker functionality. - @param options string - a command, optionally followed by additional parameters or - Object - settings for attaching new timepicker functionality - @return jQuery object */ - $.fn.timepicker = function (options) { - /* Initialise the time picker. */ - if (!$.timepicker.initialized) { - $(document).mousedown($.timepicker._checkExternalClick); - $.timepicker.initialized = true; - } - - /* Append timepicker main container to body if not exist. */ - if ($("#"+$.timepicker._mainDivId).length === 0) { - $('body').append($.timepicker.tpDiv); - } - - var otherArgs = Array.prototype.slice.call(arguments, 1); - if (typeof options == 'string' && (options == 'getTime' || options == 'getTimeAsDate' || options == 'getHour' || options == 'getMinute' )) - return $.timepicker['_' + options + 'Timepicker']. - apply($.timepicker, [this[0]].concat(otherArgs)); - if (options == 'option' && arguments.length == 2 && typeof arguments[1] == 'string') - return $.timepicker['_' + options + 'Timepicker']. - apply($.timepicker, [this[0]].concat(otherArgs)); - return this.each(function () { - typeof options == 'string' ? - $.timepicker['_' + options + 'Timepicker']. - apply($.timepicker, [this].concat(otherArgs)) : - $.timepicker._attachTimepicker(this, options); - }); - }; - - /* jQuery extend now ignores nulls! */ - function extendRemove(target, props) { - $.extend(target, props); - for (var name in props) - if (props[name] == null || props[name] == undefined) - target[name] = props[name]; - return target; - }; - - $.timepicker = new Timepicker(); // singleton instance - $.timepicker.initialized = false; - $.timepicker.uuid = new Date().getTime(); - $.timepicker.version = "0.3.3"; - - // Workaround for #4055 - // Add another global to avoid noConflict issues with inline event handlers - window['TP_jQuery_' + tpuuid] = $; - -})(jQuery); diff --git a/tailbone/static/js/lib/tag-it.min.js b/tailbone/static/js/lib/tag-it.min.js deleted file mode 100644 index fd6140c8..00000000 --- a/tailbone/static/js/lib/tag-it.min.js +++ /dev/null @@ -1,17 +0,0 @@ -(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); diff --git a/tailbone/static/js/login.js b/tailbone/static/js/login.js deleted file mode 100644 index f2a072b8..00000000 --- a/tailbone/static/js/login.js +++ /dev/null @@ -1,32 +0,0 @@ - -$(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(); - -}); diff --git a/tailbone/static/js/tailbone.appsettings.js b/tailbone/static/js/tailbone.appsettings.js deleted file mode 100644 index ae378931..00000000 --- a/tailbone/static/js/tailbone.appsettings.js +++ /dev/null @@ -1,29 +0,0 @@ - -/************************************************************ - * - * 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()); - -}); diff --git a/tailbone/static/js/tailbone.batch.js b/tailbone/static/js/tailbone.batch.js deleted file mode 100644 index 2844c0b4..00000000 --- a/tailbone/static/js/tailbone.batch.js +++ /dev/null @@ -1,41 +0,0 @@ - -/************************************************************ - * - * 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(); - } - }); - -}); diff --git a/tailbone/static/js/tailbone.buefy.datepicker.js b/tailbone/static/js/tailbone.buefy.datepicker.js index fe649380..0b861fd6 100644 --- a/tailbone/static/js/tailbone.buefy.datepicker.js +++ b/tailbone/static/js/tailbone.buefy.datepicker.js @@ -11,7 +11,7 @@ const TailboneDatepicker = { 'icon="calendar-alt"', ':date-formatter="formatDate"', ':date-parser="parseDate"', - ':value="value ? parseDate(value) : null"', + ':value="buefyValue"', '@input="dateChanged"', ':disabled="disabled"', 'ref="trueDatePicker"', @@ -26,6 +26,18 @@ const TailboneDatepicker = { disabled: Boolean, }, + data() { + return { + buefyValue: this.parseDate(this.value), + } + }, + + watch: { + value(to, from) { + this.buefyValue = this.parseDate(to) + }, + }, + methods: { formatDate(date) { @@ -43,9 +55,12 @@ const TailboneDatepicker = { }, parseDate(date) { - // note, this assumes classic YYYY-MM-DD (i.e. ISO?) format - var parts = date.split('-') - return new Date(parts[0], parseInt(parts[1]) - 1, parts[2]) + if (typeof(date) == 'string') { + // note, this assumes classic YYYY-MM-DD (i.e. ISO?) format + var parts = date.split('-') + return new Date(parts[0], parseInt(parts[1]) - 1, parts[2]) + } + return date }, dateChanged(date) { diff --git a/tailbone/static/js/tailbone.buefy.grid.js b/tailbone/static/js/tailbone.buefy.grid.js deleted file mode 100644 index 75037448..00000000 --- a/tailbone/static/js/tailbone.buefy.grid.js +++ /dev/null @@ -1,156 +0,0 @@ - -const GridFilterNumericValue = { - template: '#grid-filter-numeric-value-template', - props: { - value: String, - wantsRange: Boolean, - }, - data() { - return { - startValue: null, - endValue: null, - } - }, - mounted() { - if (this.wantsRange) { - if (this.value.includes('|')) { - let values = this.value.split('|') - if (values.length == 2) { - this.startValue = values[0] - this.endValue = values[1] - } else { - this.startValue = this.value - } - } else { - this.startValue = this.value - } - } else { - this.startValue = this.value - } - }, - methods: { - focus() { - this.$refs.startValue.focus() - }, - startValueChanged(value) { - if (this.wantsRange) { - value += '|' + this.endValue - } - this.$emit('input', value) - }, - endValueChanged(value) { - value = this.startValue + '|' + value - this.$emit('input', value) - }, - }, -} - -Vue.component('grid-filter-numeric-value', GridFilterNumericValue) - - -const GridFilterDateValue = { - template: '#grid-filter-date-value-template', - props: { - value: String, - dateRange: Boolean, - }, - data() { - return { - startDate: null, - endDate: null, - } - }, - mounted() { - if (this.dateRange) { - if (this.value.includes('|')) { - let values = this.value.split('|') - if (values.length == 2) { - this.startDate = values[0] - this.endDate = values[1] - } else { - this.startDate = this.value - } - } else { - this.startDate = this.value - } - } else { - this.startDate = this.value - } - }, - methods: { - focus() { - this.$refs.startDate.focus() - }, - startDateChanged(value) { - if (this.dateRange) { - value += '|' + this.endDate - } - this.$emit('input', value) - }, - endDateChanged(value) { - value = this.startDate + '|' + value - this.$emit('input', value) - }, - }, -} - -Vue.component('grid-filter-date-value', GridFilterDateValue) - - -const GridFilter = { - template: '#grid-filter-template', - props: { - filter: Object - }, - - methods: { - - changeVerb() { - // set focus to value input, "as quickly as we can" - this.$nextTick(function() { - this.focusValue() - }) - }, - - valuedVerb() { - /* this returns true if the filter's current verb should expose value input(s) */ - - // if filter has no "valueless" verbs, then all verbs should expose value inputs - if (!this.filter.valueless_verbs) { - return true - } - - // if filter *does* have valueless verbs, check if "current" verb is valueless - if (this.filter.valueless_verbs.includes(this.filter.verb)) { - return false - } - - // current verb is *not* valueless - return true - }, - - multiValuedVerb() { - /* this returns true if the filter's current verb should expose a multi-value input */ - - // if filter has no "multi-value" verbs then we safely assume false - if (!this.filter.multiple_value_verbs) { - return false - } - - // if filter *does* have multi-value verbs, see if "current" is one - if (this.filter.multiple_value_verbs.includes(this.filter.verb)) { - return true - } - - // current verb is not multi-value - return false - }, - - focusValue: function() { - this.$refs.valueInput.focus() - // this.$refs.valueInput.select() - } - } -} - -Vue.component('grid-filter', GridFilter) diff --git a/tailbone/static/js/tailbone.buefy.timepicker.js b/tailbone/static/js/tailbone.buefy.timepicker.js index 6cca75f3..207a7940 100644 --- a/tailbone/static/js/tailbone.buefy.timepicker.js +++ b/tailbone/static/js/tailbone.buefy.timepicker.js @@ -9,15 +9,55 @@ 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 - } + id: String, + value: String, + }, + + methods: { + + formatTime(time) { + if (time === null) { + return null + } + + let h = time.getHours() + let m = time.getMinutes() + let s = time.getSeconds() + + h = h < 10 ? '0' + h : h + m = m < 10 ? '0' + m : m + s = s < 10 ? '0' + s : s + + return h + ':' + m + ':' + s + }, + + parseTime(time) { + + if (time.getHours) { + return time + } + + let found = time.match(/^(\d\d):(\d\d):\d\d$/) + if (found) { + return new Date(null, null, null, + parseInt(found[1]), parseInt(found[2])) + } + }, + + timeChanged(time) { + this.$emit('input', time) + }, + }, } Vue.component('tailbone-timepicker', TailboneTimepicker) diff --git a/tailbone/static/js/tailbone.edit-shifts.js b/tailbone/static/js/tailbone.edit-shifts.js deleted file mode 100644 index 87dc4a21..00000000 --- a/tailbone/static/js/tailbone.edit-shifts.js +++ /dev/null @@ -1,193 +0,0 @@ - -/************************************************************ - * - * 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; - }); - -}); diff --git a/tailbone/static/js/tailbone.feedback.js b/tailbone/static/js/tailbone.feedback.js index f6d44875..648c9695 100644 --- a/tailbone/static/js/tailbone.feedback.js +++ b/tailbone/static/js/tailbone.feedback.js @@ -1,58 +1,55 @@ -$(function() { +let FeedbackForm = { + props: ['action', 'message'], + template: '#feedback-template', + mixins: [FormPosterMixin], + methods: { - $('#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) { + pleaseReplyChanged(value) { + this.$nextTick(() => { + this.$refs.userEmail.focus() + }) + }, - var msg = $.trim(textarea.val()); - if (! msg) { - alert("Please enter a message."); - textarea.select(); - textarea.focus(); - return; - } + showFeedback() { + this.referrer = location.href + this.showDialog = true + this.$nextTick(function() { + this.$refs.textarea.focus() + }) + }, - disable_button(dialog_button(event)); + sendFeedback() { - 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 - }; + let params = { + referrer: this.referrer, + user: this.userUUID, + user_name: this.userName, + please_reply_to: this.pleaseReply ? this.userEmail : null, + message: this.message.trim(), + } - $.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.submitForm(this.action, params, response => { - } - }, - { - text: "Cancel", - click: function() { - dialog.dialog('close'); - } - } - ] - }); - }); - -}); + 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, +} diff --git a/tailbone/static/js/tailbone.js b/tailbone/static/js/tailbone.js deleted file mode 100644 index 4d3212df..00000000 --- a/tailbone/static/js/tailbone.js +++ /dev/null @@ -1,386 +0,0 @@ - -/************************************************************ - * - * 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'); - }); - -}); diff --git a/tailbone/static/js/tailbone.timesheet.edit.js b/tailbone/static/js/tailbone.timesheet.edit.js deleted file mode 100644 index f2fcb271..00000000 --- a/tailbone/static/js/tailbone.timesheet.edit.js +++ /dev/null @@ -1,267 +0,0 @@ - -/************************************************************ - * - * 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; - } - } - }); - -}); diff --git a/tailbone/static/themes/falafel/css/base.css b/tailbone/static/themes/falafel/css/base.css deleted file mode 100644 index 0fa02dbb..00000000 --- a/tailbone/static/themes/falafel/css/base.css +++ /dev/null @@ -1,14 +0,0 @@ - -/****************************** - * 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; -} diff --git a/tailbone/static/themes/falafel/css/filters.css b/tailbone/static/themes/falafel/css/filters.css deleted file mode 100644 index 6deff7b0..00000000 --- a/tailbone/static/themes/falafel/css/filters.css +++ /dev/null @@ -1,22 +0,0 @@ - -/****************************** - * 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%; -} diff --git a/tailbone/static/themes/falafel/css/forms.css b/tailbone/static/themes/falafel/css/forms.css deleted file mode 100644 index de4b1ebe..00000000 --- a/tailbone/static/themes/falafel/css/forms.css +++ /dev/null @@ -1,61 +0,0 @@ - -/****************************** - * 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; -} diff --git a/tailbone/static/themes/falafel/css/grids.css b/tailbone/static/themes/falafel/css/grids.css deleted file mode 100644 index b24e9cb0..00000000 --- a/tailbone/static/themes/falafel/css/grids.css +++ /dev/null @@ -1,15 +0,0 @@ - -/******************************************************************************** - * grids.css - * - * Style tweaks for the Buefy grids. - ********************************************************************************/ - - -/****************************** - * actions column - ******************************/ - -a.grid-action { - white-space: nowrap; -} diff --git a/tailbone/static/themes/falafel/css/layout.css b/tailbone/static/themes/falafel/css/layout.css deleted file mode 100644 index db3ebaf8..00000000 --- a/tailbone/static/themes/falafel/css/layout.css +++ /dev/null @@ -1,133 +0,0 @@ - -/****************************** - * main layout - ******************************/ - -body { - display: flex; - flex-direction: column; - min-height: 100vh; -} - -.content-wrapper { - display: flex; - flex: 1; - flex-direction: column; - justify-content: space-between; -} - - -/****************************** - * header - ******************************/ - -/* this is the one in the very top left of screen, next to logo and linked to -the home page */ -#global-header-title { - margin-left: 0.3rem; -} - -header .level { - /* TODO: not sure what this 60px was supposed to do? but it broke the */ - /* styles for the feedback dialog, so disabled it is. - /* height: 60px; */ - /* line-height: 60px; */ - padding-left: 0.5em; - padding-right: 0.5em; -} - -header .level #header-logo { - display: inline-block; -} - -header .level .global-title, -header .level-left .global-title { - font-size: 2em; - font-weight: bold; -} - -/* indent nested menu items a bit */ -header .navbar-item.nested { - padding-left: 2.5rem; -} - -header span.header-text { - font-size: 2em; - font-weight: bold; - margin-right: 10px; -} - -header .level .theme-picker { - display: inline-flex; -} - -#content-title { - padding: 0.3rem; -} - -#content-title h1 { - font-size: 2rem; - margin-left: 1rem; -} - -/****************************** - * content - ******************************/ - -#page-body { - padding: 0.4em; -} - -/****************************** - * context menu - ******************************/ - -#context-menu { - margin-bottom: 1em; - margin-left: 1em; - text-align: right; - white-space: nowrap; -} - -/****************************** - * "object helper" panel - ******************************/ - -.object-helpers a { - white-space: nowrap; -} - -.object-helper { - border: 1px solid black; - margin: 1em; - padding: 1em; - width: 20em; -} - -.object-helper-content { - margin-top: 1em; -} - -/****************************** - * fix datepicker within modals - * TODO: someday this may not be necessary? cf. - * https://github.com/buefy/buefy/issues/292#issuecomment-347365637 - ******************************/ - -.modal .animation-content .modal-card { - overflow: visible !important; -} - -.modal-card-body { - overflow: visible !important; -} - - -/****************************** - * feedback - ******************************/ - -.feedback-dialog .red { - color: red; - font-weight: bold; -} diff --git a/tailbone/static/themes/falafel/js/tailbone.feedback.js b/tailbone/static/themes/falafel/js/tailbone.feedback.js deleted file mode 100644 index 6f687b80..00000000 --- a/tailbone/static/themes/falafel/js/tailbone.feedback.js +++ /dev/null @@ -1,54 +0,0 @@ - -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, -} diff --git a/tailbone/subscribers.py b/tailbone/subscribers.py index 6e8e2d33..268d4818 100644 --- a/tailbone/subscribers.py +++ b/tailbone/subscribers.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,11 +24,10 @@ 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 @@ -37,147 +36,169 @@ import deform from pyramid import threadlocal from webhelpers2.html import tags +from wuttaweb import subscribers as base + import tailbone from tailbone import helpers from tailbone.db import Session from tailbone.config import csrf_header_name, should_expose_websockets -from tailbone.menus import make_simple_menus -from tailbone.util import should_use_buefy +from tailbone.util import get_available_themes, get_global_search_options -def new_request(event): +log = logging.getLogger(__name__) + + +def new_request(event, session=None): """ - Identify the current user, and cache their current permissions. Also adds - the ``rattail_config`` attribute to the request. + Event hook called when processing a new request. - A global Rattail ``config`` should already be present within the Pyramid - application registry's settings, which would normally be accessed via:: - - request.registry.settings['rattail_config'] + This first invokes the upstream hooks: - This function merely "promotes" that config object so that it is more - directly accessible, a la:: + * :func:`wuttaweb:wuttaweb.subscribers.new_request()` + * :func:`wuttaweb:wuttaweb.subscribers.new_request_set_user()` - request.rattail_config + It then adds more things to the request object; among them: - .. note:: - This of course assumes that a Rattail ``config`` object *has* in fact - already been placed in the application registry settings. If this is - not the case, this function will do nothing. + .. attribute:: request.rattail_config + + 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. """ request = event.request - rattail_config = request.registry.settings.get('rattail_config') - # TODO: why would this ever be null? - if rattail_config: - request.rattail_config = rattail_config - def user(request): - user = None - uuid = request.authenticated_userid - if uuid: - model = request.rattail_config.get_model() - user = Session.query(model.User).get(uuid) - if user: - Session().set_continuum_user(user) - return user + # invoke main upstream logic + # nb. this sets request.wutta_config + base.new_request(event) - request.set_property(user, reify=True) + config = request.wutta_config + app = config.get_app() + auth = app.get_auth_handler() + session = session or Session() + + # compatibility + rattail_config = config + request.rattail_config = rattail_config + + def user_getter(request, db_session=None): + user = base.default_user_getter(request, db_session=db_session) + if user: + # nb. we also assign continuum user to session + session = db_session or Session() + session.set_continuum_user(user) + return user + + # invoke upstream hook to set user + base.new_request_set_user(event, user_getter=user_getter, db_session=session) # assign client IP address to the session, for sake of versioning - Session().continuum_remote_addr = request.client_addr + if hasattr(request, 'client_addr'): + session.continuum_remote_addr = request.client_addr - request.is_admin = bool(request.user) and request.user.is_admin() - request.is_root = request.is_admin and request.session.get('is_root', False) + # request.register_component() + def register_component(tagname, classname): + """ + Register a Vue 3 component, so the base template knows to + declare it for use within the app (page). + """ + if not hasattr(request, '_tailbone_registered_components'): + request._tailbone_registered_components = OrderedDict() - if rattail_config: - app = rattail_config.get_app() - auth = app.get_auth_handler() - request.tailbone_cached_permissions = auth.get_permissions( - Session(), request.user) + 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 def before_render(event): """ Adds goodies to the global template renderer context. """ + # log.debug("before_render: %s", event) + + # invoke upstream logic + base.before_render(event) request = event.get('request') or threadlocal.get_current_request() - rattail_config = request.rattail_config + config = request.wutta_config + app = config.get_app() renderer_globals = event - renderer_globals['rattail_app'] = request.rattail_config.get_app() - renderer_globals['app_title'] = request.rattail_config.app_title() + + # overrides renderer_globals['h'] = helpers - renderer_globals['url'] = request.route_url - renderer_globals['rattail'] = rattail - renderer_globals['tailbone'] = tailbone - renderer_globals['model'] = request.rattail_config.get_model() - renderer_globals['enum'] = request.rattail_config.get_enum() - renderer_globals['six'] = six - renderer_globals['json'] = json + + # misc. renderer_globals['datetime'] = datetime renderer_globals['colander'] = colander renderer_globals['deform'] = deform - renderer_globals['csrf_header_name'] = csrf_header_name(request.rattail_config) + renderer_globals['csrf_header_name'] = csrf_header_name(config) + + # TODO: deprecate / remove these + renderer_globals['rattail_app'] = app + renderer_globals['app_title'] = app.get_title() + renderer_globals['app_version'] = app.get_version() + renderer_globals['rattail'] = rattail + renderer_globals['tailbone'] = tailbone + renderer_globals['model'] = app.model + renderer_globals['enum'] = app.enum # theme - we only want do this for classic web app, *not* API # TODO: so, clearly we need a better way to distinguish the two if 'tailbone.theme' in request.registry.settings: renderer_globals['theme'] = request.registry.settings['tailbone.theme'] # note, this is just a global flag; user still needs permission to see picker - expose_picker = request.rattail_config.getbool('tailbone', 'themes.expose_picker', - default=False) + expose_picker = config.get_bool('tailbone.themes.expose_picker', + default=False) renderer_globals['expose_theme_picker'] = expose_picker if expose_picker: - # tailbone's config extension provides a default theme selection, - # so the default we specify here *probably* should not matter - available = request.rattail_config.getlist('tailbone', 'themes', - default=['falafel']) - if 'default' not in available: - available.insert(0, 'default') - options = [tags.Option(theme) for theme in available] + + # TODO: should remove 'falafel' option altogether + available = get_available_themes(config) + + options = [tags.Option(theme, value=theme) for theme in available] renderer_globals['theme_picker_options'] = options - # heck while we're assuming the classic web app here... - # (we don't want this to happen for the API either!) - # TODO: just..awful *shrug* - # note that we assume "simple" menus nowadays - if request.rattail_config.getbool('tailbone', 'menus.simple', default=True): - renderer_globals['menus'] = make_simple_menus(request) - # TODO: ugh, same deal here - renderer_globals['messaging_enabled'] = request.rattail_config.getbool( - 'tailbone', 'messaging.enabled', default=False) + renderer_globals['messaging_enabled'] = config.get_bool('tailbone.messaging.enabled', + default=False) # background color may be set per-request, by some apps if hasattr(request, 'background_color') and request.background_color: renderer_globals['background_color'] = request.background_color else: # otherwise we use the one from config - renderer_globals['background_color'] = request.rattail_config.get( - 'tailbone', 'background_color') + renderer_globals['background_color'] = config.get('tailbone.background_color') - # buefy themes get some extra treatment - if should_use_buefy(request): - - # declare vue.js and buefy versions to use. the default - # values here are "quite conservative" as of this writing, - # perhaps too much so, but at least they should work fine. - renderer_globals['vue_version'] = request.rattail_config.get( - 'tailbone', 'vue_version') or '2.6.10' - renderer_globals['buefy_version'] = request.rattail_config.get( - 'tailbone', 'buefy_version') or '0.8.13' - - # maybe set custom stylesheet - css = None - if request.user: - css = request.rattail_config.get('tailbone.{}'.format(request.user.uuid), - 'buefy_css') + # maybe set custom stylesheet + css = None + if request.user: + css = config.get(f'tailbone.{request.user.uuid}', 'user_css') if not css: - css = request.rattail_config.get('tailbone', 'theme.falafel.buefy_css') - renderer_globals['buefy_css'] = css + css = config.get(f'tailbone.{request.user.uuid}', 'buefy_css') + if css: + warnings.warn(f"setting 'tailbone.{request.user.uuid}.buefy_css' should be" + f"changed to 'tailbone.{request.user.uuid}.user_css'", + DeprecationWarning) + renderer_globals['user_css'] = css + + # add global search data for quick access + renderer_globals['global_search_data'] = get_global_search_options(request) # here we globally declare widths for grid filter pseudo-columns - widths = request.rattail_config.get('tailbone', 'grids.filters.column_widths') + widths = config.get('tailbone.grids.filters.column_widths') if widths: widths = widths.split(';') if len(widths) < 2: @@ -188,7 +209,7 @@ def before_render(event): renderer_globals['filter_verb_width'] = widths[1] # declare global support for websockets, or lack thereof - renderer_globals['expose_websockets'] = should_expose_websockets(rattail_config) + renderer_globals['expose_websockets'] = should_expose_websockets(config) def add_inbox_count(event): @@ -202,8 +223,9 @@ def add_inbox_count(event): request = event.get('request') or threadlocal.get_current_request() if request.user: renderer_globals = event + app = request.rattail_config.get_app() + model = app.model enum = request.rattail_config.get_enum() - model = request.rattail_config.get_model() renderer_globals['inbox_count'] = Session.query(model.Message)\ .outerjoin(model.MessageRecipient)\ .filter(model.MessageRecipient.recipient == Session.merge(request.user))\ @@ -213,51 +235,14 @@ def add_inbox_count(event): def context_found(event): """ - Attach some goodies to the request object. + Attach some more goodies to the request object: The following is attached to the request: - * 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()``. + * ``get_session_timeout()`` function """ - 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 diff --git a/tailbone/templates/appinfo/configure.mako b/tailbone/templates/appinfo/configure.mako new file mode 100644 index 00000000..9d866cea --- /dev/null +++ b/tailbone/templates/appinfo/configure.mako @@ -0,0 +1,2 @@ +## -*- coding: utf-8; -*- +<%inherit file="wuttaweb:templates/appinfo/configure.mako" /> diff --git a/tailbone/templates/appinfo/index.mako b/tailbone/templates/appinfo/index.mako new file mode 100644 index 00000000..faaea935 --- /dev/null +++ b/tailbone/templates/appinfo/index.mako @@ -0,0 +1,31 @@ +## -*- coding: utf-8; -*- +<%inherit file="wuttaweb:templates/appinfo/index.mako" /> + +<%def name="page_content()"> + <div class="buttons"> + + <once-button type="is-primary" + tag="a" href="${url('tables')}" + icon-pack="fas" + icon-left="eye" + text="Tables"> + </once-button> + + <once-button type="is-primary" + tag="a" href="${url('model_views')}" + icon-pack="fas" + icon-left="eye" + text="Model Views"> + </once-button> + + <once-button type="is-primary" + tag="a" href="${url('configure_menus')}" + icon-pack="fas" + icon-left="cog" + text="Configure Menus"> + </once-button> + + </div> + + ${parent.page_content()} +</%def> diff --git a/tailbone/templates/appsettings.mako b/tailbone/templates/appsettings.mako index a80dafc2..ba667e0e 100644 --- a/tailbone/templates/appsettings.mako +++ b/tailbone/templates/appsettings.mako @@ -5,34 +5,6 @@ <%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> @@ -43,8 +15,8 @@ <app-settings :groups="groups" :showing-group="showingGroup"></app-settings> </%def> -<%def name="render_this_page_template()"> - ${parent.render_this_page_template()} +<%def name="render_vue_templates()"> + ${parent.render_vue_templates()} <script type="text/x-template" id="app-settings-template"> <div class="form"> @@ -178,19 +150,18 @@ </script> </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> - ThisPageData.groups = ${json.dumps(buefy_data)|n} +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> + ThisPageData.groups = ${json.dumps(settings_data)|n} ThisPageData.showingGroup = ${json.dumps(current_group or '')|n} - </script> </%def> -<%def name="make_this_page_component()"> - ${parent.make_this_page_component()} - <script type="text/javascript"> +<%def name="make_vue_components()"> + ${parent.make_vue_components()} + <script> Vue.component('app-settings', { template: '#app-settings-template', @@ -221,78 +192,3 @@ </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 diff --git a/tailbone/templates/autocomplete.mako b/tailbone/templates/autocomplete.mako index 8c84aedd..4c413757 100644 --- a/tailbone/templates/autocomplete.mako +++ b/tailbone/templates/autocomplete.mako @@ -1,63 +1,5 @@ ## -*- 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> diff --git a/tailbone/templates/base.mako b/tailbone/templates/base.mako index 43f3a1dd..8228f823 100644 --- a/tailbone/templates/base.mako +++ b/tailbone/templates/base.mako @@ -1,8 +1,12 @@ ## -*- coding: utf-8; -*- -<%namespace file="/menu.mako" import="main_menu_items" /> +<%namespace file="/wutta-components.mako" import="make_wutta_components" /> <%namespace file="/grids/nav.mako" import="grid_index_nav" /> -<%namespace file="/feedback_dialog.mako" import="feedback_dialog" /> +<%namespace file="/autocomplete.mako" import="tailbone_autocomplete_template" /> <%namespace name="base_meta" file="/base_meta.mako" /> +<%namespace file="/formposter.mako" import="declare_formposter_mixin" /> +<%namespace file="/grids/filter-components.mako" import="make_grid_filter_components" /> +<%namespace name="page_help" file="/page_help.mako" /> +<%namespace name="multi_file_upload" file="/multi_file_upload.mako" /> <!DOCTYPE html> <html lang="en"> <head> @@ -13,13 +17,17 @@ % if background_color: <style type="text/css"> - body { background-color: ${background_color}; } + body, .navbar, .footer { + background-color: ${background_color}; + } </style> % endif % if not request.rattail_config.production(): <style type="text/css"> - body { background-image: url(${request.static_url('tailbone:static/img/testing.png')}); } + body, .navbar, .footer { + background-image: url(${request.static_url('tailbone:static/img/testing.png')}); + } </style> % endif @@ -27,154 +35,21 @@ </head> <body> - <div id="body-wrapper"> + <div id="app" style="height: 100%;"> + <whole-page></whole-page> + </div> - <header> - <nav> - <ul class="menubar"> - ${main_menu_items()} - </ul> - </nav> + ## TODO: this must come before the self.body() call..but why? + ${declare_formposter_mixin()} - <div class="global"> - <a class="home" href="${url('home')}"> - ${base_meta.header_logo()} - <span class="global-title">${base_meta.global_title()}</span> - </a> - % if master: - <span class="global">»</span> - % if master.listing: - <span class="global">${index_title}</span> - % else: - ${h.link_to(index_title, index_url, class_='global')} - % if parent_url is not Undefined: - <span class="global">»</span> - ${h.link_to(parent_title, parent_url, class_='global')} - % elif instance_url is not Undefined: - <span class="global">»</span> - ${h.link_to(instance_title, instance_url, class_='global')} - % endif - % if master.viewing and grid_index: - ${grid_index_nav()} - % endif - % endif - % elif index_title: - <span class="global">»</span> - % if index_url: - ${h.link_to(index_title, index_url, class_='global')} - % else: - <span class="global">${index_title}</span> - % endif - % endif + ## content body from derived/child template + ${self.body()} - <div class="feedback"> - % if help_url is not Undefined and help_url: - ${h.link_to("Help", help_url, target='_blank', class_='button')} - % endif - % if request.has_perm('common.feedback'): - <button type="button" id="feedback">Feedback</button> - % endif - </div> - - % if expose_theme_picker and request.has_perm('common.change_app_theme'): - <div class="after-feedback"> - ${h.form(url('change_theme'), name="theme_changer", method="post")} - ${h.csrf_token(request)} - Theme: - ${h.select('theme', theme, options=theme_picker_options, id='theme-picker')} - ${h.end_form()} - </div> - % endif - - % if quickie is not Undefined and quickie and request.has_perm(quickie.perm): - <div class="after-feedback"> - ${h.form(quickie.url, name="quickie", method="get")} - ${h.text('entry', placeholder=quickie.placeholder, autocomplete='off')} - <button type="submit" id="submit-quickie">Lookup</button> - ${h.end_form()} - </div> - % endif - - </div><!-- global --> - - <div class="page"> - % if capture(self.content_title): - - % if show_prev_next is not Undefined and show_prev_next: - <div style="float: right;"> - ## NOTE: the u"" literals seem to be required for python2..not sure why - % if prev_url: - ${h.link_to(u"« Older", prev_url, class_='button autodisable')} - % else: - ${h.link_to(u"« Older", '#', class_='button', disabled='disabled')} - % endif - % if next_url: - ${h.link_to(u"Newer »", next_url, class_='button autodisable')} - % else: - ${h.link_to(u"Newer »", '#', class_='button', disabled='disabled')} - % endif - </div> - % endif - - <h1>${self.content_title()}</h1> - % endif - </div> - </header> - - <div class="content-wrapper"> - - <div id="scrollpane"> - <div id="content"> - <div class="inner-content"> - - % if request.session.peek_flash('error'): - <div class="error-messages"> - % for error in request.session.pop_flash('error'): - <div class="ui-state-error ui-corner-all"> - <span style="float: left; margin-right: .3em;" class="ui-icon ui-icon-alert"></span> - ${error} - </div> - % endfor - </div> - % endif - - % if request.session.peek_flash('warning'): - <div class="error-messages"> - % for msg in request.session.pop_flash('warning'): - <div class="ui-state-error ui-corner-all"> - <span style="float: left; margin-right: .3em;" class="ui-icon ui-icon-alert"></span> - ${msg} - </div> - % endfor - </div> - % endif - - % if request.session.peek_flash(): - <div class="flash-messages"> - % for msg in request.session.pop_flash(): - <div class="ui-state-highlight ui-corner-all"> - <span style="float: left; margin-right: .3em;" class="ui-icon ui-icon-info"></span> - ${msg|n} - </div> - % endfor - </div> - % endif - - ${self.body()} - - </div><!-- inner-content --> - </div><!-- content --> - </div><!-- scrollpane --> - - </div><!-- content-wrapper --> - - <div id="footer"> - ${base_meta.footer()} - </div> - - </div><!-- body-wrapper --> - - ${feedback_dialog()} + ## Vue app + ${self.render_vue_templates()} + ${self.modify_vue_vars()} + ${self.make_vue_components()} + ${self.make_vue_app()} </body> </html> @@ -185,82 +60,855 @@ </%def> <%def name="header_core()"> + ${self.core_javascript()} ${self.extra_javascript()} ${self.core_styles()} ${self.extra_styles()} - ## TODO: should this be elsewhere / more customizable? - % if dform is not Undefined: - <% resources = dform.get_widget_resources() %> - % for path in resources['js']: - ${h.javascript_link(request.static_url(path))} - % endfor - % for path in resources['css']: - ${h.stylesheet_link(request.static_url(path))} - % endfor - % endif + ## TODO: should leverage deform resources for component JS? + ## cf. also tailbone.app.make_pyramid_config() +## ## TODO: should this be elsewhere / more customizable? +## % if dform is not Undefined: +## <% resources = dform.get_widget_resources() %> +## % for path in resources['js']: +## ${h.javascript_link(request.static_url(path))} +## % endfor +## % for path in resources['css']: +## ${h.stylesheet_link(request.static_url(path))} +## % endfor +## % endif </%def> <%def name="core_javascript()"> - ${self.jquery()} - ${h.javascript_link(request.static_url('tailbone:static/js/lib/jquery.ui.menubar.js'))} - ${h.javascript_link(request.static_url('tailbone:static/js/lib/jquery.loadmask.min.js'))} - ${h.javascript_link(request.static_url('tailbone:static/js/lib/jquery.ui.timepicker.js'))} + ${self.vuejs()} + ${self.buefy()} + ${self.fontawesome()} + + ## some commonly-useful logic for detecting (non-)numeric input + ${h.javascript_link(request.static_url('tailbone:static/js/numeric.js') + '?ver={}'.format(tailbone.__version__))} + + ## debounce, for better autocomplete performance + ${h.javascript_link(request.static_url('tailbone:static/js/debounce.js') + '?ver={}'.format(tailbone.__version__))} + + ## Tailbone / Buefy stuff + ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.datepicker.js') + '?ver={}'.format(tailbone.__version__))} + ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.numericinput.js') + '?ver={}'.format(tailbone.__version__))} + ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.oncebutton.js') + '?ver={}'.format(tailbone.__version__))} + ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.timepicker.js') + '?ver={}'.format(tailbone.__version__))} + <script type="text/javascript"> - var session_timeout = ${request.get_session_timeout() or 'null'}; - var logout_url = '${request.route_url('logout')}'; - var noop_url = '${request.route_url('noop')}'; - $(function() { - $('ul.menubar').menubar({ - buttons: true, - menuIcon: true, - autoExpand: true - }); - }); - % if expose_theme_picker and request.has_perm('common.change_app_theme'): - $(function() { - $('#theme-picker').change(function() { - $(this).parents('form:first').submit(); - }); - }); - % endif + + ## NOTE: this code was copied from + ## https://bulma.io/documentation/components/navbar/#navbar-menu + + document.addEventListener('DOMContentLoaded', () => { + + // Get all "navbar-burger" elements + const $navbarBurgers = Array.prototype.slice.call(document.querySelectorAll('.navbar-burger'), 0) + + // Add a click event on each of them + $navbarBurgers.forEach( el => { + el.addEventListener('click', () => { + + // Get the target from the "data-target" attribute + const target = el.dataset.target + const $target = document.getElementById(target) + + // Toggle the "is-active" class on both the "navbar-burger" and the "navbar-menu" + el.classList.toggle('is-active') + $target.classList.toggle('is-active') + + }) + }) + }) + </script> - ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.js') + '?ver={}'.format(tailbone.__version__))} - ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.feedback.js') + '?ver={}'.format(tailbone.__version__))} - ${h.javascript_link(request.static_url('tailbone:static/js/jquery.ui.tailbone.js') + '?ver={}'.format(tailbone.__version__))} </%def> -<%def name="jquery()"> - ${h.javascript_link('https://code.jquery.com/jquery-1.12.4.min.js')} - ${h.javascript_link('https://code.jquery.com/ui/{}/jquery-ui.min.js'.format(request.rattail_config.get('tailbone', 'jquery_ui.version', default='1.11.4')))} +<%def name="vuejs()"> + ${h.javascript_link(h.get_liburl(request, 'vue', prefix='tailbone'))} + ${h.javascript_link(h.get_liburl(request, 'vue_resource', prefix='tailbone'))} +</%def> + +<%def name="buefy()"> + ${h.javascript_link(h.get_liburl(request, 'buefy', prefix='tailbone'))} +</%def> + +<%def name="fontawesome()"> + <script defer src="${h.get_liburl(request, 'fontawesome', prefix='tailbone')}"></script> </%def> <%def name="extra_javascript()"></%def> <%def name="core_styles()"> - ${h.stylesheet_link(request.static_url('tailbone:static/css/normalize.css'))} - ${self.jquery_theme()} - ${h.stylesheet_link(request.static_url('tailbone:static/css/jquery.ui.menubar.css'))} - ${h.stylesheet_link(request.static_url('tailbone:static/css/jquery.loadmask.css'))} - ${h.stylesheet_link(request.static_url('tailbone:static/css/jquery.ui.timepicker.css'))} - ${h.stylesheet_link(request.static_url('tailbone:static/css/jquery.ui.tailbone.css') + '?ver={}'.format(tailbone.__version__))} + + ${self.buefy_styles()} + ${h.stylesheet_link(request.static_url('tailbone:static/css/base.css') + '?ver={}'.format(tailbone.__version__))} ${h.stylesheet_link(request.static_url('tailbone:static/css/layout.css') + '?ver={}'.format(tailbone.__version__))} ${h.stylesheet_link(request.static_url('tailbone:static/css/grids.css') + '?ver={}'.format(tailbone.__version__))} + ${h.stylesheet_link(request.static_url('tailbone:static/css/grids.rowstatus.css') + '?ver={}'.format(tailbone.__version__))} ${h.stylesheet_link(request.static_url('tailbone:static/css/filters.css') + '?ver={}'.format(tailbone.__version__))} ${h.stylesheet_link(request.static_url('tailbone:static/css/forms.css') + '?ver={}'.format(tailbone.__version__))} ${h.stylesheet_link(request.static_url('tailbone:static/css/diffs.css') + '?ver={}'.format(tailbone.__version__))} + + ${h.stylesheet_link(request.static_url('tailbone:static/css/codehilite.css') + '?ver={}'.format(tailbone.__version__))} + + <style type="text/css"> + .filters .filter-fieldname, + .filters .filter-fieldname .button { + % if filter_fieldname_width is not Undefined: + min-width: ${filter_fieldname_width}; + % endif + justify-content: left; + } + % if filter_fieldname_width is not Undefined: + .filters .filter-verb { + min-width: ${filter_verb_width}; + } + % endif + </style> </%def> -<%def name="jquery_theme()"> - ${h.stylesheet_link('https://code.jquery.com/ui/1.11.4/themes/dark-hive/jquery-ui.css')} +<%def name="buefy_styles()"> + % if user_css: + ${h.stylesheet_link(user_css)} + % else: + ## upstream Buefy CSS + ${h.stylesheet_link(h.get_liburl(request, 'buefy.css', prefix='tailbone'))} + % endif </%def> -<%def name="extra_styles()"></%def> +<%def name="extra_styles()"> + ${base_meta.extra_styles()} +</%def> <%def name="head_tags()"></%def> +<%def name="render_vue_template_whole_page()"> + <script type="text/x-template" id="whole-page-template"> + <div> + <header> + + <nav class="navbar" role="navigation" aria-label="main navigation"> + + <div class="navbar-brand"> + <a class="navbar-item" href="${url('home')}" + v-show="!globalSearchActive"> + ${base_meta.header_logo()} + <div id="global-header-title"> + ${base_meta.global_title()} + </div> + </a> + <div v-show="globalSearchActive" + class="navbar-item"> + <b-autocomplete ref="globalSearchAutocomplete" + v-model="globalSearchTerm" + :data="globalSearchFilteredData" + field="label" + open-on-focus + keep-first + icon-pack="fas" + clearable + @keydown.native="globalSearchKeydown" + @select="globalSearchSelect"> + </b-autocomplete> + </div> + <a role="button" class="navbar-burger" data-target="navbar-menu" aria-label="menu" aria-expanded="false"> + <span aria-hidden="true"></span> + <span aria-hidden="true"></span> + <span aria-hidden="true"></span> + </a> + </div> + + <div class="navbar-menu" id="navbar-menu"> + <div class="navbar-start"> + + <div v-if="globalSearchData.length" + class="navbar-item"> + <b-button type="is-primary" + size="is-small" + @click="globalSearchInit()"> + <span><i class="fa fa-search"></i></span> + </b-button> + </div> + + % for topitem in menus: + % if topitem['is_link']: + ${h.link_to(topitem['title'], topitem['url'], target=topitem['target'], class_='navbar-item')} + % else: + <div class="navbar-item has-dropdown is-hoverable"> + <a class="navbar-link">${topitem['title']}</a> + <div class="navbar-dropdown"> + % for item in topitem['items']: + % if item['is_menu']: + <% item_hash = id(item) %> + <% toggle = 'menu_{}_shown'.format(item_hash) %> + <div> + <a class="navbar-link" @click.prevent="toggleNestedMenu('${item_hash}')"> + ${item['title']} + </a> + </div> + % for subitem in item['items']: + % if subitem['is_sep']: + <hr class="navbar-divider" v-show="${toggle}"> + % else: + ${h.link_to("{}".format(subitem['title']), subitem['url'], class_='navbar-item nested', target=subitem['target'], **{'v-show': toggle})} + % endif + % endfor + % else: + % if item['is_sep']: + <hr class="navbar-divider"> + % else: + ${h.link_to(item['title'], item['url'], class_='navbar-item', target=item['target'])} + % endif + % endif + % endfor + </div> + </div> + % endif + % endfor + + </div><!-- navbar-start --> + ${self.render_navbar_end()} + </div> + </nav> + + <nav class="level" style="margin: 0.5rem auto;"> + <div class="level-left"> + + ## Current Context + <div id="current-context" class="level-item"> + % if master: + % if master.listing: + <span class="header-text"> + ${index_title} + </span> + % if master.creatable and getattr(master, 'show_create_link', True) and master.has_perm('create'): + <once-button type="is-primary" + tag="a" href="${url('{}.create'.format(route_prefix))}" + icon-left="plus" + style="margin-left: 1rem;" + text="Create New"> + </once-button> + % endif + % elif index_url: + <span class="header-text"> + ${h.link_to(index_title, index_url)} + </span> + % if parent_url is not Undefined: + <span class="header-text"> + » + </span> + <span class="header-text"> + ${h.link_to(parent_title, parent_url)} + </span> + % elif instance_url is not Undefined: + <span class="header-text"> + » + </span> + <span class="header-text"> + ${h.link_to(instance_title, instance_url)} + </span> + % elif master.creatable and getattr(master, 'show_create_link', True) and master.has_perm('create'): + % if not request.matched_route.name.endswith('.create'): + <once-button type="is-primary" + tag="a" href="${url('{}.create'.format(route_prefix))}" + icon-left="plus" + style="margin-left: 1rem;" + text="Create New"> + </once-button> + % endif + % endif + % if master.viewing and grid_index: + ${grid_index_nav()} + % endif + % else: + <span class="header-text"> + ${index_title} + </span> + % endif + % elif index_title: + % if index_url: + <span class="header-text"> + ${h.link_to(index_title, index_url)} + </span> + % else: + <span class="header-text"> + ${index_title} + </span> + % endif + % endif + </div> + + % if expose_db_picker is not Undefined and expose_db_picker: + <div class="level-item"> + <p>DB:</p> + </div> + <div class="level-item"> + ${h.form(url('change_db_engine'), ref='dbPickerForm')} + ${h.csrf_token(request)} + ${h.hidden('engine_type', value=master.engine_type_key)} + <b-select name="dbkey" + value="${db_picker_selected}" + @input="changeDB()"> + % for option in db_picker_options: + <option value="${option.value}"> + ${option.label} + </option> + % endfor + </b-select> + ${h.end_form()} + </div> + % endif + + </div><!-- level-left --> + <div class="level-right"> + + ## Quickie Lookup + % if quickie is not Undefined and quickie and request.has_perm(quickie.perm): + <div class="level-item"> + ${h.form(quickie.url, method="get")} + <div class="level"> + <div class="level-right"> + <div class="level-item"> + <b-input name="entry" + placeholder="${quickie.placeholder}" + autocomplete="off"> + </b-input> + </div> + <div class="level-item"> + <button type="submit" class="button is-primary"> + <span class="icon is-small"> + <i class="fas fa-search"></i> + </span> + <span>Lookup</span> + </button> + </div> + </div> + </div> + ${h.end_form()} + </div> + % endif + + % if master and master.configurable and master.has_perm('configure'): + % if not request.matched_route.name.endswith('.configure'): + <div class="level-item"> + <once-button type="is-primary" + tag="a" + href="${url('{}.configure'.format(route_prefix))}" + icon-left="cog" + text="${(configure_button_title or "Configure") if configure_button_title is not Undefined else "Configure"}"> + </once-button> + </div> + % endif + % endif + + ## Theme Picker + % if expose_theme_picker and request.has_perm('common.change_app_theme'): + <div class="level-item"> + ${h.form(url('change_theme'), method="post", ref='themePickerForm')} + ${h.csrf_token(request)} + <input type="hidden" name="referrer" :value="referrer" /> + <div style="display: flex; align-items: center; gap: 0.5rem;"> + <span>Theme:</span> + <b-select name="theme" + v-model="globalTheme" + @input="changeTheme()"> + % for option in theme_picker_options: + <option value="${option.value}"> + ${option.label} + </option> + % endfor + </b-select> + </div> + ${h.end_form()} + </div> + % endif + + <div class="level-item"> + <page-help + % if can_edit_help: + @configure-fields-help="configureFieldsHelp = true" + % endif + > + </page-help> + </div> + + ## Feedback Button / Dialog + % if request.has_perm('common.feedback'): + <feedback-form + action="${url('feedback')}" + :message="feedbackMessage"> + </feedback-form> + % endif + + </div><!-- level-right --> + </nav><!-- level --> + </header> + + ## Page Title + % if capture(self.content_title): + <section id="content-title" + class="has-background-primary"> + <div style="display: flex; align-items: center; padding: 0.5rem;"> + + <h1 class="title has-text-white" + v-html="contentTitleHTML"> + </h1> + + <div style="flex-grow: 1; display: flex; gap: 0.5rem;"> + ${self.render_instance_header_title_extras()} + </div> + + <div style="display: flex; gap: 0.5rem;"> + ${self.render_instance_header_buttons()} + </div> + + </div> + </section> + % endif + + <div class="content-wrapper"> + + ## Page Body + <section id="page-body"> + + % if request.session.peek_flash('error'): + % for error in request.session.pop_flash('error'): + <b-notification type="is-warning"> + ${error} + </b-notification> + % endfor + % endif + + % if request.session.peek_flash('warning'): + % for msg in request.session.pop_flash('warning'): + <b-notification type="is-warning"> + ${msg} + </b-notification> + % endfor + % endif + + % if request.session.peek_flash(): + % for msg in request.session.pop_flash(): + <b-notification type="is-info"> + ${msg} + </b-notification> + % endfor + % endif + + ${self.render_this_page_component()} + </section> + + ## Footer + <footer class="footer"> + <div class="content"> + ${base_meta.footer()} + </div> + </footer> + + </div><!-- content-wrapper --> + </div> + </script> + + ${page_help.render_template()} + + % if request.has_perm('common.feedback'): + <script type="text/x-template" id="feedback-template"> + <div> + + <div class="level-item"> + <b-button type="is-primary" + @click="showFeedback()" + icon-pack="fas" + icon-left="comment"> + Feedback + </b-button> + </div> + + <b-modal has-modal-card + :active.sync="showDialog"> + <div class="modal-card"> + + <header class="modal-card-head"> + <p class="modal-card-title">User Feedback</p> + </header> + + <section class="modal-card-body"> + <p class="block"> + Questions, suggestions, comments, complaints, etc. + <span class="red">regarding this website</span> are + welcome and may be submitted below. + </p> + + <b-field label="User Name"> + <b-input v-model="userName" + % if request.user: + disabled + % endif + > + </b-input> + </b-field> + + <b-field label="Referring URL"> + <b-input + v-model="referrer" + disabled="true"> + </b-input> + </b-field> + + <b-field label="Message"> + <b-input type="textarea" + v-model="message" + ref="textarea"> + </b-input> + </b-field> + + % if request.rattail_config.getbool('tailbone', 'feedback_allows_reply'): + <div class="level"> + <div class="level-left"> + <div class="level-item"> + <b-checkbox v-model="pleaseReply" + @input="pleaseReplyChanged"> + Please email me back{{ pleaseReply ? " at: " : "" }} + </b-checkbox> + </div> + <div class="level-item" v-show="pleaseReply"> + <b-input v-model="userEmail" + ref="userEmail"> + </b-input> + </div> + </div> + </div> + % endif + + </section> + + <footer class="modal-card-foot"> + <b-button @click="showDialog = false"> + Cancel + </b-button> + <once-button type="is-primary" + @click="sendFeedback()" + :disabled="!message.trim()" + text="Send Message"> + </once-button> + </footer> + </div> + </b-modal> + + </div> + </script> + % endif + + ${tailbone_autocomplete_template()} + ${multi_file_upload.render_template()} +</%def> + +<%def name="render_this_page_component()"> + <this-page @change-content-title="changeContentTitle" + % if can_edit_help: + :configure-fields-help="configureFieldsHelp" + % endif + > + </this-page> +</%def> + +<%def name="render_navbar_end()"> + <div class="navbar-end"> + ${self.render_user_menu()} + </div> +</%def> + +<%def name="render_user_menu()"> + % if request.user: + <div class="navbar-item has-dropdown is-hoverable"> + % if messaging_enabled: + <a class="navbar-link ${'root-user' if request.is_root else ''}">${request.user}${" ({})".format(inbox_count) if inbox_count else ''}</a> + % else: + <a class="navbar-link ${'root-user' if request.is_root else ''}">${request.user}</a> + % endif + <div class="navbar-dropdown"> + % if request.is_root: + ${h.form(url('stop_root'), ref='stopBeingRootForm')} + ${h.csrf_token(request)} + <input type="hidden" name="referrer" value="${request.current_route_url()}" /> + <a @click="$refs.stopBeingRootForm.submit()" + class="navbar-item root-user"> + Stop being root + </a> + ${h.end_form()} + % elif request.is_admin: + ${h.form(url('become_root'), ref='startBeingRootForm')} + ${h.csrf_token(request)} + <input type="hidden" name="referrer" value="${request.current_route_url()}" /> + <a @click="$refs.startBeingRootForm.submit()" + class="navbar-item root-user"> + Become root + </a> + ${h.end_form()} + % endif + % if messaging_enabled: + ${h.link_to("Messages{}".format(" ({})".format(inbox_count) if inbox_count else ''), url('messages.inbox'), class_='navbar-item')} + % endif + % if request.is_root or not request.user.prevent_password_change: + ${h.link_to("Change Password", url('change_password'), class_='navbar-item')} + % endif + % try: + ## nb. does not exist yet for wuttaweb + ${h.link_to("Edit Preferences", url('my.preferences'), class_='navbar-item')} + % except: + % endtry + ${h.link_to("Logout", url('logout'), class_='navbar-item')} + </div> + </div> + % else: + ${h.link_to("Login", url('login'), class_='navbar-item')} + % endif +</%def> + +<%def name="render_instance_header_title_extras()"></%def> + +<%def name="render_instance_header_buttons()"> + ${self.render_crud_header_buttons()} + ${self.render_prevnext_header_buttons()} +</%def> + +<%def name="render_crud_header_buttons()"> + % if master and master.viewing: + ## TODO: is there a better way to check if viewing parent? + % if parent_instance is Undefined: + % if master.editable and instance_editable and master.has_perm('edit'): + <once-button tag="a" href="${master.get_action_url('edit', instance)}" + icon-left="edit" + text="Edit This"> + </once-button> + % endif + % if getattr(master, 'cloneable', False) and not master.cloning and master.has_perm('clone'): + <once-button tag="a" href="${master.get_action_url('clone', instance)}" + icon-left="object-ungroup" + text="Clone This"> + </once-button> + % endif + % if master.deletable and instance_deletable and master.has_perm('delete'): + <once-button tag="a" href="${master.get_action_url('delete', instance)}" + type="is-danger" + icon-left="trash" + text="Delete This"> + </once-button> + % endif + % else: + ## viewing row + % if instance_deletable and master.has_perm('delete_row'): + <once-button tag="a" href="${master.get_action_url('delete', instance)}" + type="is-danger" + icon-left="trash" + text="Delete This"> + </once-button> + % endif + % endif + % elif master and master.editing: + % if master.viewable and master.has_perm('view'): + <once-button tag="a" href="${master.get_action_url('view', instance)}" + icon-left="eye" + text="View This"> + </once-button> + % endif + % if master.deletable and instance_deletable and master.has_perm('delete'): + <once-button tag="a" href="${master.get_action_url('delete', instance)}" + type="is-danger" + icon-left="trash" + text="Delete This"> + </once-button> + % endif + % elif master and master.deleting: + % if master.viewable and master.has_perm('view'): + <once-button tag="a" href="${master.get_action_url('view', instance)}" + icon-left="eye" + text="View This"> + </once-button> + % endif + % if master.editable and instance_editable and master.has_perm('edit'): + <once-button tag="a" href="${master.get_action_url('edit', instance)}" + icon-left="edit" + text="Edit This"> + </once-button> + % endif + % endif +</%def> + +<%def name="render_prevnext_header_buttons()"> + % if show_prev_next is not Undefined and show_prev_next: + % if prev_url: + <b-button tag="a" href="${prev_url}" + icon-pack="fas" + icon-left="arrow-left"> + Older + </b-button> + % else: + <b-button tag="a" href="#" + disabled + icon-pack="fas" + icon-left="arrow-left"> + Older + </b-button> + % endif + % if next_url: + <b-button tag="a" href="${next_url}" + icon-pack="fas" + icon-left="arrow-right"> + Newer + </b-button> + % else: + <b-button tag="a" href="#" + disabled + icon-pack="fas" + icon-left="arrow-right"> + Newer + </b-button> + % endif + % endif +</%def> + +<%def name="render_vue_script_whole_page()"> + <script> + + let WholePage = { + template: '#whole-page-template', + mixins: [FormPosterMixin], + computed: { + + globalSearchFilteredData() { + if (!this.globalSearchTerm.length) { + return this.globalSearchData + } + + let terms = [] + for (let term of this.globalSearchTerm.toLowerCase().split(' ')) { + term = term.trim() + if (term) { + terms.push(term) + } + } + if (!terms.length) { + return this.globalSearchData + } + + // all terms must match + return this.globalSearchData.filter((option) => { + let label = option.label.toLowerCase() + for (let term of terms) { + if (label.indexOf(term) < 0) { + return false + } + } + return true + }) + }, + + }, + + mounted() { + window.addEventListener('keydown', this.globalKey) + for (let hook of this.mountedHooks) { + hook(this) + } + }, + beforeDestroy() { + window.removeEventListener('keydown', this.globalKey) + }, + + methods: { + + changeContentTitle(newTitle) { + this.contentTitleHTML = newTitle + }, + + % if expose_db_picker is not Undefined and expose_db_picker: + changeDB() { + this.$refs.dbPickerForm.submit() + }, + % endif + + % if expose_theme_picker and request.has_perm('common.change_app_theme'): + changeTheme() { + this.$refs.themePickerForm.submit() + }, + % endif + + globalKey(event) { + + // Ctrl+8 opens global search + if (event.target.tagName == 'BODY') { + if (event.ctrlKey && event.key == '8') { + this.globalSearchInit() + } + } + }, + + globalSearchInit() { + this.globalSearchTerm = '' + this.globalSearchActive = true + this.$nextTick(() => { + this.$refs.globalSearchAutocomplete.focus() + }) + }, + + globalSearchKeydown(event) { + + // ESC will dismiss searchbox + if (event.which == 27) { + this.globalSearchActive = false + } + }, + + globalSearchSelect(option) { + location.href = option.url + }, + + toggleNestedMenu(hash) { + const key = 'menu_' + hash + '_shown' + this[key] = !this[key] + }, + }, + } + + let WholePageData = { + contentTitleHTML: ${json.dumps(capture(self.content_title))|n}, + feedbackMessage: "", + + % if expose_theme_picker and request.has_perm('common.change_app_theme'): + globalTheme: ${json.dumps(theme or None)|n}, + referrer: location.href, + % endif + + % if can_edit_help: + configureFieldsHelp: false, + % endif + + globalSearchActive: false, + globalSearchTerm: '', + globalSearchData: ${json.dumps(global_search_data or [])|n}, + + mountedHooks: [], + } + + ## declare nested menu visibility toggle flags + % for topitem in menus: + % if topitem['is_menu']: + % for item in topitem['items']: + % if item['is_menu']: + WholePageData.menu_${id(item)}_shown = false + % endif + % endfor + % endif + % endfor + + </script> +</%def> + <%def name="wtfield(form, name, **kwargs)"> <div class="field-wrapper${' error' if form[name].errors else ''}"> <label for="${name}">${form[name].label}</label> @@ -269,3 +917,101 @@ </div> </div> </%def> + +<%def name="simple_field(label, value)"> + ## TODO: keep this? only used by personal profile view currently + ## (although could be useful for any readonly scenario) + <div class="field-wrapper"> + <div class="field-row"> + <label>${label}</label> + <div class="field"> + ${'' if value is None else value} + </div> + </div> + </div> +</%def> + +############################## +## vue components + app +############################## + +<%def name="render_vue_templates()"> + ${page_help.declare_vars()} + ${multi_file_upload.declare_vars()} + ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.feedback.js') + '?ver={}'.format(tailbone.__version__))} + ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.autocomplete.js') + '?ver={}'.format(tailbone.__version__))} + + ## DEPRECATED; called for back-compat + ${self.render_whole_page_template()} +</%def> + +## DEPRECATED; remains for back-compat +<%def name="render_whole_page_template()"> + ${self.render_vue_template_whole_page()} + ${self.declare_whole_page_vars()} +</%def> + +## DEPRECATED; remains for back-compat +<%def name="declare_whole_page_vars()"> + ${self.render_vue_script_whole_page()} +</%def> + +<%def name="modify_vue_vars()"> + ## DEPRECATED; called for back-compat + ${self.modify_whole_page_vars()} +</%def> + +## DEPRECATED; remains for back-compat +<%def name="modify_whole_page_vars()"> + <script> + + % if request.user: + FeedbackFormData.userUUID = ${json.dumps(request.user.uuid)|n} + FeedbackFormData.userName = ${json.dumps(str(request.user))|n} + % endif + + </script> +</%def> + +<%def name="make_vue_components()"> + ${make_wutta_components()} + ${make_grid_filter_components()} + ${page_help.make_component()} + ${multi_file_upload.make_component()} + <script> + FeedbackForm.data = function() { return FeedbackFormData } + Vue.component('feedback-form', FeedbackForm) + </script> + + ## DEPRECATED; called for back-compat + ${self.finalize_whole_page_vars()} + ${self.make_whole_page_component()} +</%def> + +## DEPRECATED; remains for back-compat +<%def name="make_whole_page_component()"> + <script> + WholePage.data = function() { return WholePageData } + Vue.component('whole-page', WholePage) + </script> +</%def> + +<%def name="make_vue_app()"> + ## DEPRECATED; called for back-compat + ${self.make_whole_page_app()} +</%def> + +## DEPRECATED; remains for back-compat +<%def name="make_whole_page_app()"> + <script> + new Vue({ + el: '#app' + }) + </script> +</%def> + +############################## +## DEPRECATED +############################## + +<%def name="finalize_whole_page_vars()"></%def> diff --git a/tailbone/templates/base_meta.mako b/tailbone/templates/base_meta.mako index 568782b7..b6376448 100644 --- a/tailbone/templates/base_meta.mako +++ b/tailbone/templates/base_meta.mako @@ -1,8 +1,7 @@ ## -*- coding: utf-8; -*- +<%inherit file="wuttaweb:templates/base_meta.mako" /> -<%def name="app_title()">${request.rattail_config.node_title(default="Rattail")}</%def> - -<%def name="global_title()">${"[STAGE] " if not request.rattail_config.production() else ''}${self.app_title()}</%def> +<%def name="app_title()">${app.get_node_title()}</%def> <%def name="favicon()"> <link rel="icon" type="image/x-icon" href="${request.rattail_config.get('tailbone', 'favicon_url', default=request.static_url('tailbone:static/img/rattail.ico'))}" /> @@ -11,9 +10,3 @@ <%def name="header_logo()"> ${h.image(request.rattail_config.get('tailbone', 'header_image_url', default=request.static_url('tailbone:static/img/rattail.ico')), "Header Logo", style="height: 49px;")} </%def> - -<%def name="footer()"> - <p class="has-text-centered"> - powered by ${h.link_to("Rattail", url('about'))} - </p> -</%def> diff --git a/tailbone/templates/batch/importer/view_row.mako b/tailbone/templates/batch/importer/view_row.mako index 24eb6456..7d6f121f 100644 --- a/tailbone/templates/batch/importer/view_row.mako +++ b/tailbone/templates/batch/importer/view_row.mako @@ -68,7 +68,7 @@ % endif </%def> -<%def name="render_buefy_form()"> +<%def name="render_form()"> <div class="form"> <tailbone-form></tailbone-form> <br /> @@ -76,12 +76,5 @@ </div> </%def> -<%def name="render_form()"> - ${parent.render_form()} - % if not use_buefy: - ${self.field_diff_table()} - % endif -</%def> - ${parent.body()} diff --git a/tailbone/templates/batch/index.mako b/tailbone/templates/batch/index.mako index 89358567..bea10a97 100644 --- a/tailbone/templates/batch/index.mako +++ b/tailbone/templates/batch/index.mako @@ -1,157 +1,80 @@ ## -*- 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'): - % 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 + <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()} % endif ## Execute Results % if master.results_executable and master.has_perm('execute_multiple'): - % 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-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> - - <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> + <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.render_vue_tag(ref='executeResultsForm')} + </div> </div> - </b-modal> + </section> - % else: - <button type="button" id="execute-results-button">Execute Results</button> - % endif + <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> % endif </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} +<%def name="render_vue_templates()"> + ${parent.render_vue_templates()} + % if master.results_executable and master.has_perm('execute_multiple'): + ${execute_form.render_vue_template(form_kwargs={'ref': 'actualExecuteForm'}, buttons=False)} + % endif +</%def> + +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} % if master.results_refreshable and master.has_perm('refresh'): - <script type="text/javascript"> + <script> TailboneGridData.refreshResultsButtonText = "Refresh Results" TailboneGridData.refreshResultsButtonDisabled = false @@ -165,9 +88,9 @@ </script> % endif % if master.results_executable and master.has_perm('execute_multiple'): - <script type="text/javascript"> + <script> - ${execute_form.component_studly}.methods.submit = function() { + ${execute_form.vue_component}.methods.submit = function() { this.$refs.actualExecuteForm.submit() } @@ -202,46 +125,9 @@ % endif </%def> -<%def name="make_this_page_component()"> - ${parent.make_this_page_component()} +<%def name="make_vue_components()"> + ${parent.make_vue_components()} % if master.results_executable and master.has_perm('execute_multiple'): - <script type="text/javascript"> - - ${execute_form.component_studly}.data = function() { return ${execute_form.component_studly}Data } - - Vue.component('${execute_form.component}', ${execute_form.component_studly}) - - </script> + ${execute_form.render_vue_finalize()} % endif </%def> - -<%def name="render_this_page_template()"> - ${parent.render_this_page_template()} - % if master.results_executable and master.has_perm('execute_multiple'): - ${execute_form.render_deform(form_kwargs={'ref': 'actualExecuteForm'}, buttons=False)|n} - % endif -</%def> - - -${parent.body()} - -% 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 diff --git a/tailbone/templates/batch/inventory/desktop_form.mako b/tailbone/templates/batch/inventory/desktop_form.mako index bc64d498..cddaa2c5 100644 --- a/tailbone/templates/batch/inventory/desktop_form.mako +++ b/tailbone/templates/batch/inventory/desktop_form.mako @@ -1,301 +1,305 @@ ## -*- coding: utf-8; -*- -<%inherit file="/base.mako" /> +<%inherit file="/form.mako" /> <%def name="title()">Inventory Form</%def> -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - ${h.javascript_link(request.static_url('tailbone:static/js/numeric.js'))} +<%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;"> + + <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()} + % endif + + </div> + </div> + </nav> +</%def> + +<%def name="render_form_template()"> + <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 v-if="productNotFound" +## ## class="product-fields" +## > +## +## <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: + <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> + + <b-button type="is-primary" + native-type="submit" + :disabled="submitting"> + {{ submitting ? "Working, please wait..." : "Submit" }} + </b-button> + + ${h.end_form()} + </div> + </script> + <script type="text/javascript"> - 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; - } + let ${form.vue_component} = { + template: '#${form.component}-template', + mixins: [SimpleRequestMixin], - 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'); - } + mounted() { + this.$refs.productUPC.focus() + }, - 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 ''; - } + methods: { - 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(); - } - } + clearProduct() { + this.productInfo = {} + ## this.productNotFound = false + this.alreadyPresentInBatch = false + this.forceUnitItem = false + this.productCases = null + this.productUnits = null + }, - $(function() { + assertQuantity() { - $('#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); + 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 - $('.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) { + let units = parseFloat(this.productUnits) + if (!isNaN(units)) { + if (units > 999999) { + alert("Unit amount is invalid!") + this.$refs.productUnits.focus() + return false + } + return true + } - if (data.error) { - alert(data.error); - if (data.redirect) { - $('#inventory-form').mask("Redirecting..."); - location.href = data.redirect; + alert("Please provide case and/or unit quantity") + % if allow_cases: + this.$refs.productCases.focus() + % else: + this.$refs.productUnits.focus() + % endif + }, + + handleSubmit(event) { + if (!this.assertQuantity()) { + event.preventDefault() + return + } + this.submitting = true + }, + + productChanged() { + this.clearProduct() + }, + + 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 } - } 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(); + this.$nextTick(() => { + if (this.productInfo.type2) { + this.$refs.productUnits.focus() } else { - $('#cases').focus().select(); + % 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: - $('#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'); + ## this.productNotFound = true + alert("Product not found!") + + // focus/select UPC entry + this.$refs.productUPC.focus() + // nb. must traverse into the <b-input> element + this.$refs.productUPC.$el.firstChild.select() } - }); - } - return false; - }); - $('#inventory-form').submit(function() { - if (! assert_quantity()) { - return false; - } - disable_submit_button(this); - $(this).mask("Working..."); - }); + }, response => { + if (response.data.error) { + alert(response.data.error) + if (response.data.redirect) { + location.href = response.data.redirect + } + } + }) + }, + }, + } + + let ${form.vue_component}Data = { + submitting: false, + + productUPC: null, + ## productNotFound: false, + productInfo: {}, - $('#upc').focus(); % if allow_cases: - $('.field-wrapper.cases input').prop('disabled', true); + productCases: null, % endif - $('.field-wrapper.units input').prop('disabled', true); - $('.buttons button').button('disable'); + productUnits: null, + + alreadyPresentInBatch: false, + forceUnitItem: false, + } - }); </script> </%def> -<%def name="extra_styles()"> - ${parent.extra_styles()} - <style type="text/css"> - - #product-info { - margin-top: 0.5em; - text-align: center; - } - - #product-info p { - margin-left: 0.5em; - } - - #product-info .img-wrapper { - height: 150px; - margin: 0.5em 0; - } - - #product-info .warning { - background: #f66; - display: none; - } - - </style> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> + ThisPageData.toggleCompleteSubmitting = false + </script> </%def> - - -<%def name="context_menu_items()"> - <li>${h.link_to("Back to Inventory Batch", url('batch.inventory.view', uuid=batch.uuid))}</li> -</%def> - - -<ul id="context-menu"> - ${self.context_menu_items()} -</ul> - -<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: - <div class="field-wrapper cases"> - <label for="cases">Cases</label> - <div class="field">${h.text('cases', autocomplete='off')}</div> - </div> - % endif - - <div class="field-wrapper units"> - <label for="units">Units</label> - <div class="field">${h.text('units', autocomplete='off')}</div> - </div> - - <div class="buttons"> - ${h.submit('submit', "Submit")} - </div> - - ${h.end_form()} -</div> diff --git a/tailbone/templates/batch/pos/view.mako b/tailbone/templates/batch/pos/view.mako new file mode 100644 index 00000000..5ecabd4d --- /dev/null +++ b/tailbone/templates/batch/pos/view.mako @@ -0,0 +1,9 @@ +## -*- 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> diff --git a/tailbone/templates/batch/vendorcatalog/configure.mako b/tailbone/templates/batch/vendorcatalog/configure.mako index 0d57053e..4f91cb02 100644 --- a/tailbone/templates/batch/vendorcatalog/configure.mako +++ b/tailbone/templates/batch/vendorcatalog/configure.mako @@ -39,14 +39,9 @@ </div> </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> - +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> ThisPageData.catalogParsers = ${json.dumps(catalog_parsers_data)|n} - </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/batch/vendorcatalog/create.mako b/tailbone/templates/batch/vendorcatalog/create.mako index 19e91dd0..d9d62bd1 100644 --- a/tailbone/templates/batch/vendorcatalog/create.mako +++ b/tailbone/templates/batch/vendorcatalog/create.mako @@ -1,69 +1,16 @@ ## -*- coding: utf-8; -*- <%inherit file="/batch/create.mako" /> -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - % if not use_buefy: - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> - var vendormap = { - % for i, parser in enumerate(parsers, 1): - '${parser.key}': ${parser.vendormap_value|n}${',' if i < len(parsers) else ''} - % endfor - }; + ${form.vue_component}Data.parsers = ${json.dumps(parsers_data)|n} - $(function() { + ${form.vue_component}Data.vendorName = null + ${form.vue_component}Data.vendorNameReplacement = null - if ($('select[name="parser_key"] option:first').is(':selected')) { - $('.vendor_uuid .autocomplete-container').hide(); - } else { - $('.vendor_uuid input[name="vendor_uuid"]').val(''); - $('.vendor_uuid .autocomplete-display').hide(); - $('.vendor_uuid .autocomplete-display button').show(); - $('.vendor_uuid .autocomplete-textbox').val(''); - $('.vendor_uuid .autocomplete-textbox').show(); - $('.vendor_uuid .autocomplete-container').show(); - } - - $('select[name="parser_key"]').on('selectmenuchange', function() { - if ($(this).find('option:first').is(':selected')) { - $('.vendor_uuid .autocomplete-container').hide(); - } else { - var vendor = vendormap[$(this).val()]; - if (vendor) { - $('.vendor_uuid input[name="vendor_uuid"]').val(vendor.uuid); - $('.vendor_uuid .autocomplete-textbox').hide(); - $('.vendor_uuid .autocomplete-display span:first').text(vendor.name); - $('.vendor_uuid .autocomplete-display button').hide(); - $('.vendor_uuid .autocomplete-display').show(); - $('.vendor_uuid .autocomplete-container').show(); - } else { - $('.vendor_uuid input[name="vendor_uuid"]').val(''); - $('.vendor_uuid .autocomplete-display').hide(); - $('.vendor_uuid .autocomplete-display button').show(); - $('.vendor_uuid .autocomplete-textbox').val(''); - $('.vendor_uuid .autocomplete-textbox').show(); - $('.vendor_uuid .autocomplete-container').show(); - $('.vendor_uuid .autocomplete-textbox').focus(); - } - } - }); - - }); - </script> - % endif -</%def> - -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> - - ${form.component_studly}Data.parsers = ${json.dumps(parsers_data)|n} - - ${form.component_studly}Data.vendorName = null - ${form.component_studly}Data.vendorNameReplacement = null - - ${form.component_studly}.watch.field_model_parser_key = function(val) { + ${form.vue_component}.watch.field_model_parser_key = function(val) { let parser = this.parsers[val] if (parser.vendor_uuid) { if (this.field_model_vendor_uuid != parser.vendor_uuid) { @@ -77,11 +24,11 @@ } } - ${form.component_studly}.methods.vendorLabelChanging = function(label) { + ${form.vue_component}.methods.vendorLabelChanging = function(label) { this.vendorNameReplacement = label } - ${form.component_studly}.methods.vendorChanged = function(uuid) { + ${form.vue_component}.methods.vendorChanged = function(uuid) { if (uuid) { this.vendorName = this.vendorNameReplacement this.vendorNameReplacement = null @@ -90,6 +37,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/batch/vendorcatalog/view_row.mako b/tailbone/templates/batch/vendorcatalog/view_row.mako index 6aaf9bf4..0128e3b3 100644 --- a/tailbone/templates/batch/vendorcatalog/view_row.mako +++ b/tailbone/templates/batch/vendorcatalog/view_row.mako @@ -1,7 +1,7 @@ ## -*- coding: utf-8; -*- <%inherit file="/master/view_row.mako" /> -<%def name="render_buefy_form()"> +<%def name="render_form()"> <div class="form"> <tailbone-form></tailbone-form> <br /> diff --git a/tailbone/templates/batch/view.mako b/tailbone/templates/batch/view.mako index 66a6881a..7c81ab0e 100644 --- a/tailbone/templates/batch/view.mako +++ b/tailbone/templates/batch/view.mako @@ -1,68 +1,8 @@ ## -*- coding: utf-8; -*- <%inherit file="/master/view.mako" /> -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - % if not use_buefy: - ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.batch.js') + '?ver={}'.format(tailbone.__version__))} - <script type="text/javascript"> - - var has_execution_options = ${'true' if master.has_execution_options(batch) else 'false'}; - - $(function() { - % if master.has_worksheet and master.allow_worksheet(batch) and master.has_perm('worksheet'): - $('.load-worksheet').click(function() { - disable_button(this); - location.href = '${url('{}.worksheet'.format(route_prefix), uuid=batch.uuid)}'; - }); - % endif - % if master.batch_refreshable(batch) and master.has_perm('refresh'): - $('#refresh-data').click(function() { - $(this) - .button('option', 'disabled', true) - .button('option', 'label', "Working, please wait..."); - location.href = '${url('{}.refresh'.format(route_prefix), uuid=batch.uuid)}'; - }); - % endif - % if master.has_worksheet_file and master.allow_worksheet(batch) and master.has_perm('worksheet'): - $('.upload-worksheet').click(function() { - $('#upload-worksheet-dialog').dialog({ - title: "Upload Worksheet", - width: 600, - modal: true, - buttons: [ - { - text: "Upload & Update Batch", - click: function(event) { - var form = $('form[name="upload-worksheet"]'); - var field = form.find('input[type="file"]').get(0); - if (!field.value) { - alert("Please choose a file to upload."); - return - } - disable_button(dialog_button(event)); - form.submit(); - } - }, - { - text: "Cancel", - click: function() { - $(this).dialog('close'); - } - } - ] - }); - }); - % endif - }); - - </script> - % endif -</%def> - <%def name="extra_styles()"> ${parent.extra_styles()} - % if use_buefy: <style type="text/css"> .modal-card-body label { @@ -74,19 +14,6 @@ } </style> - % else: - <style type="text/css"> - - .grid-wrapper { - margin-top: 10px; - } - - .complete form { - display: inline; - } - - </style> - % endif </%def> <%def name="buttons()"> @@ -99,52 +26,39 @@ <%def name="leading_buttons()"> % if master.has_worksheet and master.allow_worksheet(batch) and master.has_perm('worksheet'): - % if use_buefy: - <once-button type="is-primary" - tag="a" href="${url('{}.worksheet'.format(route_prefix), uuid=batch.uuid)}" - icon-left="edit" - text="Edit as Worksheet"> - </once-button> - % else: - <button type="button" class="load-worksheet">Edit as Worksheet</button> - % endif + <once-button type="is-primary" + tag="a" href="${url('{}.worksheet'.format(route_prefix), uuid=batch.uuid)}" + icon-left="edit" + text="Edit as Worksheet"> + </once-button> % endif </%def> <%def name="refresh_button()"> % if master.batch_refreshable(batch) and master.has_perm('refresh'): - % if use_buefy: - ## TODO: this should surely use a POST request? - <once-button type="is-primary" - tag="a" href="${url('{}.refresh'.format(route_prefix), uuid=batch.uuid)}" - text="Refresh Data" - icon-left="redo"> - </once-button> - % else: - <button type="button" class="button" id="refresh-data">Refresh Data</button> - % endif + ## TODO: this should surely use a POST request? + <once-button type="is-primary" + tag="a" href="${url('{}.refresh'.format(route_prefix), uuid=batch.uuid)}" + text="Refresh Data" + icon-left="redo"> + </once-button> % endif </%def> <%def name="trailing_buttons()"> % if master.has_worksheet_file and master.allow_worksheet(batch) and master.has_perm('worksheet'): - % if use_buefy: - <b-button tag="a" - href="${master.get_action_url('download_worksheet', batch)}" - icon-pack="fas" - icon-left="fas fa-download"> - Download Worksheet - </b-button> - <b-button type="is-primary" - icon-pack="fas" - icon-left="fas fa-upload" - @click="$emit('show-upload')"> - Upload Worksheet - </b-button> - % else: - ${h.link_to("Download Worksheet", master.get_action_url('download_worksheet', batch), class_='button')} - <button type="button" class="upload-worksheet">Upload Worksheet</button> - % endif + <b-button tag="a" + href="${master.get_action_url('download_worksheet', batch)}" + icon-pack="fas" + icon-left="download"> + Download Worksheet + </b-button> + <b-button type="is-primary" + icon-pack="fas" + icon-left="upload" + @click="$emit('show-upload')"> + Upload Worksheet + </b-button> % endif </%def> @@ -154,65 +68,42 @@ </%def> <%def name="render_status_breakdown()"> - % if use_buefy: - <div class="object-helper"> - <h3>Row Status Breakdown</h3> - <div class="object-helper-content"> - ${status_breakdown_grid} - </div> + <nav class="panel"> + <p class="panel-heading">Row Status</p> + <div class="panel-block"> + <div style="width: 100%;"> + ${status_breakdown_grid} </div> - % elif status_breakdown is not Undefined and status_breakdown is not None: - <div class="object-helper"> - <h3>Row Status Breakdown</h3> - <div class="object-helper-content"> - % if status_breakdown: - <div class="grid full"> - <table> - % for i, (status, count) in enumerate(status_breakdown): - <tr class="${'even' if i % 2 == 0 else 'odd'}"> - <td>${status}</td> - <td>${count}</td> - </tr> - % endfor - </table> - </div> - % else: - <p>Nothing to report yet.</p> - % endif - </div> - </div> - % endif + </div> + </nav> </%def> <%def name="render_execute_helper()"> - <div class="object-helper"> - <h3>Batch Execution</h3> - <div class="object-helper-content"> + <nav class="panel"> + <p class="panel-heading">Execution</p> + <div class="panel-block"> + <div style="display: flex; flex-direction: column; gap: 0.5rem;"> % if batch.executed: <p> - Batch was executed ${h.pretty_datetime(request.rattail_config, batch.executed)} by ${batch.executed_by} </p> % elif master.handler.executable(batch): % if master.has_perm('execute'): - <p>Batch has not yet been executed.</p> - % if use_buefy: - <br /> - <b-button type="is-primary" - % if not execute_enabled: - disabled - % if why_not_execute: - title="${why_not_execute}" - % endif - % endif - @click="showExecutionDialog = true" - icon-pack="fas" - icon-left="arrow-circle-right"> - ${execute_title} - </b-button> + <b-button type="is-primary" + % if not execute_enabled: + disabled + % if why_not_execute: + title="${why_not_execute}" + % endif + % endif + @click="showExecutionDialog = true" + icon-pack="fas" + icon-left="arrow-circle-right"> + ${execute_title} + </b-button> - % if execute_enabled: + % if execute_enabled: <b-modal has-modal-card :active.sync="showExecutionDialog"> <div class="modal-card"> @@ -228,8 +119,7 @@ <div class="markdown"> ${execution_described|n} </div> - <${execute_form.component} ref="executeBatchForm"> - </${execute_form.component}> + ${execute_form.render_vue_tag(ref='executeBatchForm')} </section> <footer class="modal-card-foot"> @@ -245,111 +135,61 @@ </div> </b-modal> - % endif - - % else: - ## no buefy, do legacy thing - <button type="button" - % if not execute_enabled: - disabled="disabled" - % endif - % if why_not_execute: - title="${why_not_execute}" - % endif - class="button is-primary" - id="execute-batch"> - ${execute_title} - </button> % endif + % else: <p>TODO: batch *may* be executed, but not by *you*</p> % endif % else: <p>TODO: batch cannot be executed..?</p> % endif + </div> </div> - </div> -</%def> - -<%def name="render_form()"> - ## TODO: should use self.render_form_buttons() - ## ${form.render(form_id='batch-form', buttons=capture(self.render_form_buttons))|n} - ${form.render(form_id='batch-form', buttons=capture(buttons))|n} + </nav> </%def> <%def name="render_this_page()"> ${parent.render_this_page()} % if master.has_worksheet_file and master.allow_worksheet(batch) and master.has_perm('worksheet'): - % if use_buefy: - <b-modal has-modal-card - :active.sync="showUploadDialog"> - <div class="modal-card"> + <b-modal has-modal-card + :active.sync="showUploadDialog"> + <div class="modal-card"> - <header class="modal-card-head"> - <p class="modal-card-title">Upload Worksheet</p> - </header> + <header class="modal-card-head"> + <p class="modal-card-title">Upload Worksheet</p> + </header> - <section class="modal-card-body"> - <p> - This will <span class="has-text-weight-bold">update</span> - the batch data with the worksheet file you provide. - Please be certain to use the right one! - </p> - <br /> - <${upload_worksheet_form.component} ref="uploadForm"> - </${upload_worksheet_form.component}> - </section> - - <footer class="modal-card-foot"> - <b-button @click="showUploadDialog = false"> - Cancel - </b-button> - <b-button type="is-primary" - @click="submitUpload()" - icon-pack="fas" - icon-left="fas fa-upload" - :disabled="uploadButtonDisabled"> - {{ uploadButtonText }} - </b-button> - </footer> - - </div> - </b-modal> - % else: - <div id="upload-worksheet-dialog" style="display: none;"> + <section class="modal-card-body"> <p> - This will <strong>update</strong> the batch data with the worksheet - file you provide. Please be certain to use the right one! + This will <span class="has-text-weight-bold">update</span> + the batch data with the worksheet file you provide. + Please be certain to use the right one! </p> - ${upload_worksheet_form.render_deform(buttons=False, form_kwargs={'name': 'upload-worksheet'})|n} - </div> - % endif - % endif + <br /> + ${upload_worksheet_form.render_vue_tag(ref='uploadForm')} + </section> - % if not use_buefy: - % if master.handler.executable(batch) and master.has_perm('execute'): - <div id="execution-options-dialog" style="display: none;"> - ${execute_form.render_deform(form_kwargs={'name': 'batch-execution'}, buttons=False)|n} - </div> - % endif + <footer class="modal-card-foot"> + <b-button @click="showUploadDialog = false"> + Cancel + </b-button> + <b-button type="is-primary" + @click="submitUpload()" + icon-pack="fas" + icon-left="upload" + :disabled="uploadButtonDisabled"> + {{ uploadButtonText }} + </b-button> + </footer> + + </div> + </b-modal> % endif </%def> -<%def name="render_this_page_template()"> - ${parent.render_this_page_template()} - % if use_buefy: - % if master.has_worksheet_file and master.allow_worksheet(batch) and master.has_perm('worksheet'): - ${upload_worksheet_form.render_deform(buttons=False, form_kwargs={'ref': 'actualUploadForm'})|n} - % endif - % if master.handler.executable(batch) and master.has_perm('execute'): - ${execute_form.render_deform(form_kwargs={'ref': 'actualExecuteForm'}, buttons=False)|n} - % endif - % endif -</%def> - -<%def name="render_buefy_form()"> +<%def name="render_form()"> <div class="form"> <${form.component} @show-upload="showUploadDialog = true"> </${form.component}> @@ -358,7 +198,7 @@ <%def name="render_row_grid_tools()"> ${parent.render_row_grid_tools()} - % if use_buefy and master.rows_bulk_deletable and not batch.executed and master.has_perm('delete_rows'): + % if master.rows_bulk_deletable and not batch.executed and master.has_perm('delete_rows'): <b-button type="is-danger" @click="deleteResultsInit()" :disabled="!total" @@ -409,9 +249,27 @@ % endif </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="render_vue_templates()"> + ${parent.render_vue_templates()} + % if master.has_worksheet_file and master.allow_worksheet(batch) and master.has_perm('worksheet'): + ${upload_worksheet_form.render_vue_template(buttons=False, form_kwargs={'ref': 'actualUploadForm'})} + % endif + % if master.handler.executable(batch) and master.has_perm('execute'): + ${execute_form.render_vue_template(form_kwargs={'ref': 'actualExecuteForm'}, buttons=False)} + % endif +</%def> + +## DEPRECATED; remains for back-compat +## nb. this is called by parent template, /form.mako +<%def name="render_form_template()"> + ## TODO: should use self.render_form_buttons() + ## ${form.render_deform(form_id='batch-form', buttons=capture(self.render_form_buttons))|n} + ${form.render_deform(form_id='batch-form', buttons=capture(buttons))|n} +</%def> + +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> ThisPageData.statusBreakdownData = ${json.dumps(status_breakdown_data)|n} @@ -426,6 +284,10 @@ }) } + % if not batch.executed and master.has_perm('edit'): + ${form.vue_component}Data.togglingBatchComplete = false + % endif + % if master.has_worksheet_file and master.allow_worksheet(batch) and master.has_perm('worksheet'): ThisPageData.showUploadDialog = false @@ -444,7 +306,7 @@ form.submit() } - ${upload_worksheet_form.component_studly}.methods.submit = function() { + ${upload_worksheet_form.vue_component}.methods.submit = function() { this.$refs.actualUploadForm.submit() } @@ -459,7 +321,7 @@ this.$refs.executeBatchForm.submit() } - ${execute_form.component_studly}.methods.submit = function() { + ${execute_form.vue_component}.methods.submit = function() { this.$refs.actualExecuteForm.submit() } @@ -467,9 +329,9 @@ % if master.rows_bulk_deletable and not batch.executed and master.has_perm('delete_rows'): - ${rows_grid.component_studly}Data.deleteResultsShowDialog = false + ${rows_grid.vue_component}Data.deleteResultsShowDialog = false - ${rows_grid.component_studly}.methods.deleteResultsInit = function() { + ${rows_grid.vue_component}.methods.deleteResultsInit = function() { this.deleteResultsShowDialog = true } @@ -478,28 +340,12 @@ </script> </%def> -<%def name="make_this_page_component()"> - ${parent.make_this_page_component()} +<%def name="make_vue_components()"> + ${parent.make_vue_components()} % if master.has_worksheet_file and master.allow_worksheet(batch) and master.has_perm('worksheet'): - <script type="text/javascript"> - - ## UploadForm - ${upload_worksheet_form.component_studly}.data = function() { return ${upload_worksheet_form.component_studly}Data } - Vue.component('${upload_worksheet_form.component}', ${upload_worksheet_form.component_studly}) - - </script> + ${upload_worksheet_form.render_vue_finalize()} % endif - % if execute_enabled and master.has_perm('execute'): - <script type="text/javascript"> - - ## ExecuteForm - ${execute_form.component_studly}.data = function() { return ${execute_form.component_studly}Data } - Vue.component('${execute_form.component}', ${execute_form.component_studly}) - - </script> + ${execute_form.render_vue_finalize()} % endif </%def> - - -${parent.body()} diff --git a/tailbone/templates/batch/worksheet.mako b/tailbone/templates/batch/worksheet.mako index cf19a0e0..a0dca748 100644 --- a/tailbone/templates/batch/worksheet.mako +++ b/tailbone/templates/batch/worksheet.mako @@ -1,26 +1,6 @@ ## -*- coding: utf-8; -*- <%inherit file="/page.mako" /> -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - % if not use_buefy: - <script type="text/javascript"> - - $(function() { - - $('.worksheet .current-entry input').focus(function(event) { - $(this).parents('tr:first').addClass('active'); - }); - - $('.worksheet .current-entry input').blur(function(event) { - $(this).parents('tr:first').removeClass('active'); - }); - - }); - </script> - % endif -</%def> - <%def name="extra_styles()"> ${parent.extra_styles()} <style type="text/css"> diff --git a/tailbone/templates/configure-menus.mako b/tailbone/templates/configure-menus.mako index 495b5c65..c7f46d21 100644 --- a/tailbone/templates/configure-menus.mako +++ b/tailbone/templates/configure-menus.mako @@ -14,6 +14,12 @@ </%def> <%def name="form_content()"> + + ## nb. must be root to configure menus! otherwise some of the + ## currently-defined menus may not appear on the page, so saving + ## would inadvertently remove them! + % if request.is_root: + ${h.hidden('menus', **{':value': 'JSON.stringify(allMenuData)'})} <h3 class="is-size-3">Top-Level Menus</h3> @@ -182,11 +188,29 @@ </div> + % else: + ## not root! + + <b-notification type="is-warning"> + You must become root to configure menus! + </b-notification> + + % endif + </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +## TODO: should probably make some global "editable" flag that the +## base configure template has knowledge of, and just set that to +## false for this view +<%def name="purge_button()"> + % if request.is_root: + ${parent.purge_button()} + % endif +</%def> + +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> ThisPageData.menuSequence = ${json.dumps([m['key'] for m in menus])|n} @@ -419,6 +443,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/configure.mako b/tailbone/templates/configure.mako index 2fe8ee72..e6b128fc 100644 --- a/tailbone/templates/configure.mako +++ b/tailbone/templates/configure.mako @@ -3,6 +3,15 @@ <%def name="title()">Configure ${config_title}</%def> +<%def name="extra_styles()"> + ${parent.extra_styles()} + <style type="text/css"> + .label { + white-space: nowrap; + } + </style> +</%def> + <%def name="save_undo_buttons()"> <div class="buttons" v-if="settingsNeedSaved"> @@ -83,7 +92,7 @@ <b-select name="${tmpl['setting_file']}" v-model="inputFileTemplateSettings['${tmpl['setting_file']}']" @input="settingsNeedSaved = true"> - <option :value="null">-new-</option> + <option value="">-new-</option> <option v-for="option in inputFileTemplateFileOptions['${tmpl['key']}']" :key="option" :value="option"> @@ -95,22 +104,40 @@ <b-field label="Upload" v-show="inputFileTemplateSettings['${tmpl['setting_mode']}'] == 'hosted' && !inputFileTemplateSettings['${tmpl['setting_file']}']"> - <b-field class="file is-primary" - :class="{'has-name': !!inputFileTemplateSettings['${tmpl['setting_file']}']}"> - <b-upload name="${tmpl['setting_file']}.upload" - v-model="inputFileTemplateUploads['${tmpl['key']}']" - class="file-label" - @input="settingsNeedSaved = true"> - <span class="file-cta"> - <b-icon class="file-icon" pack="fas" icon="upload"></b-icon> - <span class="file-label">Click to upload</span> - </span> - </b-upload> - <span v-if="inputFileTemplateUploads['${tmpl['key']}']" - class="file-name"> - {{ inputFileTemplateUploads['${tmpl['key']}'].name }} - </span> - </b-field> + % if request.use_oruga: + <o-field class="file"> + <o-upload name="${tmpl['setting_file']}.upload" + v-model="inputFileTemplateUploads['${tmpl['key']}']" + v-slot="{ onclick }" + @input="settingsNeedSaved = true"> + <o-button variant="primary" + @click="onclick"> + <o-icon icon="upload" /> + <span>Click to upload</span> + </o-button> + <span class="file-name" v-if="inputFileTemplateUploads['${tmpl['key']}']"> + {{ inputFileTemplateUploads['${tmpl['key']}'].name }} + </span> + </o-upload> + </o-field> + % else: + <b-field class="file is-primary" + :class="{'has-name': !!inputFileTemplateSettings['${tmpl['setting_file']}']}"> + <b-upload name="${tmpl['setting_file']}.upload" + v-model="inputFileTemplateUploads['${tmpl['key']}']" + class="file-label" + @input="settingsNeedSaved = true"> + <span class="file-cta"> + <b-icon class="file-icon" pack="fas" icon="upload"></b-icon> + <span class="file-label">Click to upload</span> + </span> + </b-upload> + <span v-if="inputFileTemplateUploads['${tmpl['key']}']" + class="file-name"> + {{ inputFileTemplateUploads['${tmpl['key']}'].name }} + </span> + </b-field> + % endif </b-field> @@ -134,6 +161,85 @@ </div> </%def> +<%def name="output_file_template_field(key)"> + <% tmpl = output_file_templates[key] %> + <b-field grouped> + + <b-field label="${tmpl['label']}"> + <b-select name="${tmpl['setting_mode']}" + v-model="outputFileTemplateSettings['${tmpl['setting_mode']}']" + @input="settingsNeedSaved = true"> + <option value="default">use default</option> + <option value="hosted">use uploaded file</option> + </b-select> + </b-field> + + <b-field label="File" + v-show="outputFileTemplateSettings['${tmpl['setting_mode']}'] == 'hosted'" + :message="outputFileTemplateSettings['${tmpl['setting_file']}'] ? 'This file lives on disk at: ${output_file_option_dirs[tmpl['key']]}' : null"> + <b-select name="${tmpl['setting_file']}" + v-model="outputFileTemplateSettings['${tmpl['setting_file']}']" + @input="settingsNeedSaved = true"> + <option value="">-new-</option> + <option v-for="option in outputFileTemplateFileOptions['${tmpl['key']}']" + :key="option" + :value="option"> + {{ option }} + </option> + </b-select> + </b-field> + + <b-field label="Upload" + v-show="outputFileTemplateSettings['${tmpl['setting_mode']}'] == 'hosted' && !outputFileTemplateSettings['${tmpl['setting_file']}']"> + + % if request.use_oruga: + <o-field class="file"> + <o-upload name="${tmpl['setting_file']}.upload" + v-model="outputFileTemplateUploads['${tmpl['key']}']" + v-slot="{ onclick }" + @input="settingsNeedSaved = true"> + <o-button variant="primary" + @click="onclick"> + <o-icon icon="upload" /> + <span>Click to upload</span> + </o-button> + <span class="file-name" v-if="outputFileTemplateUploads['${tmpl['key']}']"> + {{ outputFileTemplateUploads['${tmpl['key']}'].name }} + </span> + </o-upload> + </o-field> + % else: + <b-field class="file is-primary" + :class="{'has-name': !!outputFileTemplateSettings['${tmpl['setting_file']}']}"> + <b-upload name="${tmpl['setting_file']}.upload" + v-model="outputFileTemplateUploads['${tmpl['key']}']" + class="file-label" + @input="settingsNeedSaved = true"> + <span class="file-cta"> + <b-icon class="file-icon" pack="fas" icon="upload"></b-icon> + <span class="file-label">Click to upload</span> + </span> + </b-upload> + <span v-if="outputFileTemplateUploads['${tmpl['key']}']" + class="file-name"> + {{ outputFileTemplateUploads['${tmpl['key']}'].name }} + </span> + </b-field> + % endif + </b-field> + + </b-field> +</%def> + +<%def name="output_file_templates_section()"> + <h3 class="block is-size-3">Output File Templates</h3> + <div class="block" style="padding-left: 2rem;"> + % for key in output_file_templates: + ${self.output_file_template_field(key)} + % endfor + </div> +</%def> + <%def name="form_content()"></%def> <%def name="page_content()"> @@ -174,15 +280,14 @@ <b-button @click="purgeSettingsShowDialog = false"> Cancel </b-button> - ${h.form(request.current_route_url())} + ${h.form(request.current_route_url(), **{'@submit': 'purgingSettings = true'})} ${h.csrf_token(request)} ${h.hidden('remove_settings', 'true')} <b-button type="is-danger" native-type="submit" :disabled="purgingSettings" icon-pack="fas" - icon-left="trash" - @click="purgingSettings = true"> + icon-left="trash"> {{ purgingSettings ? "Working, please wait..." : "Remove All Settings" }} </b-button> ${h.end_form()} @@ -196,62 +301,42 @@ ${h.end_form()} </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> % if simple_settings is not Undefined: ThisPageData.simpleSettings = ${json.dumps(simple_settings)|n} % endif - % if input_file_template_settings is not Undefined: - ThisPageData.inputFileTemplateSettings = ${json.dumps(input_file_template_settings)|n} - ThisPageData.inputFileTemplateFileOptions = ${json.dumps(input_file_options)|n} - ThisPageData.inputFileTemplateUploads = { - % for key in input_file_templates: - '${key}': null, - % endfor - } - % endif - ThisPageData.purgeSettingsShowDialog = false ThisPageData.purgingSettings = false ThisPageData.settingsNeedSaved = false ThisPageData.undoChanges = false ThisPageData.savingSettings = false + ThisPageData.validators = [] ThisPage.methods.purgeSettingsInit = function() { this.purgeSettingsShowDialog = true } - % if input_file_template_settings is not Undefined: - ThisPage.methods.validateInputFileTemplateSettings = function() { - % for tmpl in six.itervalues(input_file_templates): - if (this.inputFileTemplateSettings['${tmpl['setting_mode']}'] == 'hosted') { - if (!this.inputFileTemplateSettings['${tmpl['setting_file']}']) { - if (!this.inputFileTemplateUploads['${tmpl['key']}']) { - return "You must provide a file to upload for the ${tmpl['label']} template." - } - } - } - % endfor - } - % endif - - ThisPage.methods.validateSettings = function() { - let msg - - % if input_file_template_settings is not Undefined: - msg = this.validateInputFileTemplateSettings() - if (msg) { - return msg - } - % endif - } + ThisPage.methods.validateSettings = function() {} ThisPage.methods.saveSettings = function() { - let msg = this.validateSettings() + let msg + + // nb. this is the future + for (let validator of this.validators) { + msg = validator.call(this) + if (msg) { + alert(msg) + return + } + } + + // nb. legacy method + msg = this.validateSettings() if (msg) { alert(msg) return @@ -282,8 +367,65 @@ window.addEventListener('beforeunload', this.beforeWindowUnload) } + ############################## + ## input file templates + ############################## + + % if input_file_template_settings is not Undefined: + + ThisPageData.inputFileTemplateSettings = ${json.dumps(input_file_template_settings)|n} + ThisPageData.inputFileTemplateFileOptions = ${json.dumps(input_file_options)|n} + ThisPageData.inputFileTemplateUploads = { + % for key in input_file_templates: + '${key}': null, + % endfor + } + + ThisPage.methods.validateInputFileTemplateSettings = function() { + % for tmpl in input_file_templates.values(): + if (this.inputFileTemplateSettings['${tmpl['setting_mode']}'] == 'hosted') { + if (!this.inputFileTemplateSettings['${tmpl['setting_file']}']) { + if (!this.inputFileTemplateUploads['${tmpl['key']}']) { + return "You must provide a file to upload for the ${tmpl['label']} template." + } + } + } + % endfor + } + + ThisPageData.validators.push(ThisPage.methods.validateInputFileTemplateSettings) + + % endif + + ############################## + ## output file templates + ############################## + + % if output_file_template_settings is not Undefined: + + ThisPageData.outputFileTemplateSettings = ${json.dumps(output_file_template_settings)|n} + ThisPageData.outputFileTemplateFileOptions = ${json.dumps(output_file_options)|n} + ThisPageData.outputFileTemplateUploads = { + % for key in output_file_templates: + '${key}': null, + % endfor + } + + ThisPage.methods.validateOutputFileTemplateSettings = function() { + % for tmpl in output_file_templates.values(): + if (this.outputFileTemplateSettings['${tmpl['setting_mode']}'] == 'hosted') { + if (!this.outputFileTemplateSettings['${tmpl['setting_file']}']) { + if (!this.outputFileTemplateUploads['${tmpl['key']}']) { + return "You must provide a file to upload for the ${tmpl['label']} template." + } + } + } + % endfor + } + + ThisPageData.validators.push(ThisPage.methods.validateOutputFileTemplateSettings) + + % endif + </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/customers/configure.mako b/tailbone/templates/customers/configure.mako index f465fdf5..1a6dca8b 100644 --- a/tailbone/templates/customers/configure.mako +++ b/tailbone/templates/customers/configure.mako @@ -6,15 +6,70 @@ <h3 class="block is-size-3">General</h3> <div class="block" style="padding-left: 2rem;"> - <b-field message="If not set, customer chooser is an autocomplete field."> + <b-field grouped> + + <b-field label="Key Field"> + <b-select name="rattail.customers.key_field" + v-model="simpleSettings['rattail.customers.key_field']" + @input="updateKeyLabel()"> + <option value="id">id</option> + <option value="number">number</option> + </b-select> + </b-field> + + <b-field label="Key Field Label"> + <b-input name="rattail.customers.key_label" + v-model="simpleSettings['rattail.customers.key_label']" + @input="settingsNeedSaved = true"> + </b-input> + </b-field> + + </b-field> + + <b-field message="If set, grid links are to Customer tab of Profile view."> + <b-checkbox name="rattail.customers.straight_to_profile" + v-model="simpleSettings['rattail.customers.straight_to_profile']" + native-value="true" + @input="settingsNeedSaved = true"> + Link directly to Profile when applicable + </b-checkbox> + </b-field> + + <b-field message="Set this to show the Shoppers field when viewing a Customer record."> + <b-checkbox name="rattail.customers.expose_shoppers" + v-model="simpleSettings['rattail.customers.expose_shoppers']" + native-value="true" + @input="settingsNeedSaved = true"> + Show the Shoppers field + </b-checkbox> + </b-field> + + <b-field message="Set this to show the People field when viewing a Customer record."> + <b-checkbox name="rattail.customers.expose_people" + v-model="simpleSettings['rattail.customers.expose_people']" + native-value="true" + @input="settingsNeedSaved = true"> + Show the People field + </b-checkbox> + </b-field> + + <b-field message="If not set, Customer chooser is an autocomplete field."> <b-checkbox name="rattail.customers.choice_uses_dropdown" v-model="simpleSettings['rattail.customers.choice_uses_dropdown']" native-value="true" @input="settingsNeedSaved = true"> - Show customer chooser as dropdown (select) element + Use dropdown (select element) for Customer chooser </b-checkbox> </b-field> + <b-field label="Clientele Handler" + message="Leave blank for default handler."> + <b-input name="rattail.clientele.handler" + v-model="simpleSettings['rattail.clientele.handler']" + @input="settingsNeedSaved = true"> + </b-input> + </b-field> + </div> <h3 class="block is-size-3">POS</h3> @@ -33,5 +88,26 @@ </%def> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> -${parent.body()} + ThisPage.methods.getLabelForKey = function(key) { + switch (key) { + case 'id': + return "ID" + case 'number': + return "Number" + default: + return "Key" + } + } + + ThisPage.methods.updateKeyLabel = function() { + this.simpleSettings['rattail.customers.key_label'] = this.getLabelForKey( + this.simpleSettings['rattail.customers.key_field']) + this.settingsNeedSaved = true + } + + </script> +</%def> diff --git a/tailbone/templates/customers/pending/view.mako b/tailbone/templates/customers/pending/view.mako index e9e54c99..1cea9d1f 100644 --- a/tailbone/templates/customers/pending/view.mako +++ b/tailbone/templates/customers/pending/view.mako @@ -106,9 +106,9 @@ % endif </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> ThisPageData.resolvePersonShowDialog = false ThisPageData.resolvePersonUUID = null @@ -139,5 +139,3 @@ </script> </%def> - -${parent.body()} diff --git a/tailbone/templates/customers/view.mako b/tailbone/templates/customers/view.mako index 81e05aaa..490e4757 100644 --- a/tailbone/templates/customers/view.mako +++ b/tailbone/templates/customers/view.mako @@ -2,47 +2,33 @@ <%inherit file="/master/view.mako" /> <%namespace file="/util.mako" import="view_profiles_helper" /> -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - % if master.people_detachable and request.has_perm('{}.detach_person'.format(permission_prefix)): - <script type="text/javascript"> - - $(function() { - $('.people .grid .actions a.detach').click(function() { - if (! confirm("Are you sure you wish to detach this Person from the Customer?")) { - return false; - } - }); - }); - - </script> - % endif -</%def> - <%def name="object_helpers()"> ${parent.object_helpers()} - % if show_profiles_helper and instance.people: - ${view_profiles_helper(instance.people)} + % if show_profiles_helper and show_profiles_people: + ${view_profiles_helper(show_profiles_people)} % endif </%def> -<%def name="render_buefy_form()"> +<%def name="render_form()"> <div class="form"> <tailbone-form @detach-person="detachPerson"> </tailbone-form> </div> </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> - ${form.component_studly}Data.peopleData = ${json.dumps(people_data)|n} + % if expose_shoppers: + ${form.vue_component}Data.shoppers = ${json.dumps(shoppers_data)|n} + % endif + % if expose_people: + ${form.vue_component}Data.peopleData = ${json.dumps(people_data)|n} + % endif ThisPage.methods.detachPerson = function(url) { - ## TODO: this should require POST, but we will add that once - ## we can assume a Buefy theme is present, to avoid having to - ## implement the logic in old jquery... + ## TODO: this should require POST! but for now we just redirect.. if (confirm("Are you sure you want to detach this person from this customer account?")) { location.href = url } @@ -50,5 +36,3 @@ </script> </%def> - -${parent.body()} diff --git a/tailbone/templates/custorders/configure.mako b/tailbone/templates/custorders/configure.mako index 6d51e433..16d26d21 100644 --- a/tailbone/templates/custorders/configure.mako +++ b/tailbone/templates/custorders/configure.mako @@ -24,29 +24,38 @@ </b-checkbox> </b-field> - <b-field message="Only applies if user is allowed to choose contact info."> - <b-checkbox name="rattail.custorders.new_orders.allow_contact_info_create" - v-model="simpleSettings['rattail.custorders.new_orders.allow_contact_info_create']" - native-value="true" - @input="settingsNeedSaved = true"> - Allow user to enter new contact info - </b-checkbox> - </b-field> + <div v-show="simpleSettings['rattail.custorders.new_orders.allow_contact_info_choice']" + style="padding-left: 2rem;"> - <p class="block"> - If you allow users to enter new contact info, the default action - when the order is submitted, is to send email with details of - the new contact info. Settings for these are at: - </p> + <b-field message="Only applies if user is allowed to choose contact info."> + <b-checkbox name="rattail.custorders.new_orders.allow_contact_info_create" + v-model="simpleSettings['rattail.custorders.new_orders.allow_contact_info_create']" + native-value="true" + @input="settingsNeedSaved = true"> + Allow user to enter new contact info + </b-checkbox> + </b-field> - <ul class="list"> - <li class="list-item"> - ${h.link_to("New Phone Request", url('emailprofiles.view', key='new_phone_requested'))} - </li> - <li class="list-item"> - ${h.link_to("New Email Request", url('emailprofiles.view', key='new_email_requested'))} - </li> - </ul> + <div v-show="simpleSettings['rattail.custorders.new_orders.allow_contact_info_create']" + style="padding-left: 2rem;"> + + <p class="block"> + If you allow users to enter new contact info, the default action + when the order is submitted, is to send email with details of + the new contact info. Settings for these are at: + </p> + + <ul class="list"> + <li class="list-item"> + ${h.link_to("New Phone Request", url('emailprofiles.view', key='new_phone_requested'))} + </li> + <li class="list-item"> + ${h.link_to("New Email Request", url('emailprofiles.view', key='new_email_requested'))} + </li> + </ul> + + </div> + </div> </div> <h3 class="block is-size-3">Product Handling</h3> @@ -79,15 +88,6 @@ </b-checkbox> </b-field> - <b-field message="If set, user can enter details of an arbitrary new "pending" product."> - <b-checkbox name="rattail.custorders.allow_unknown_product" - v-model="simpleSettings['rattail.custorders.allow_unknown_product']" - native-value="true" - @input="settingsNeedSaved = true"> - Allow creating orders for "unknown" products - </b-checkbox> - </b-field> - <b-field> <b-checkbox name="rattail.custorders.allow_item_discounts" v-model="simpleSettings['rattail.custorders.allow_item_discounts']" @@ -97,6 +97,29 @@ </b-checkbox> </b-field> + <b-field> + <b-checkbox name="rattail.custorders.allow_item_discounts_if_on_sale" + v-model="simpleSettings['rattail.custorders.allow_item_discounts_if_on_sale']" + native-value="true" + @input="settingsNeedSaved = true" + :disabled="!simpleSettings['rattail.custorders.allow_item_discounts']"> + Allow discount even if item is on sale + </b-checkbox> + </b-field> + + <div class="level-left block"> + <div class="level-item">Default item discount</div> + <div class="level-item"> + <b-input name="rattail.custorders.default_item_discount" + v-model="simpleSettings['rattail.custorders.default_item_discount']" + @input="settingsNeedSaved = true" + style="width: 5rem;" + :disabled="!simpleSettings['rattail.custorders.allow_item_discounts']"> + </b-input> + </div> + <div class="level-item">%</div> + </div> + <b-field> <b-checkbox name="rattail.custorders.allow_past_item_reorder" v-model="simpleSettings['rattail.custorders.allow_past_item_reorder']" @@ -107,6 +130,51 @@ </b-field> </div> + + <h3 class="block is-size-3">Unknown Products</h3> + <div class="block" style="padding-left: 2rem;"> + + <b-field message="If set, user can enter details of an arbitrary new "pending" product."> + <b-checkbox name="rattail.custorders.allow_unknown_product" + v-model="simpleSettings['rattail.custorders.allow_unknown_product']" + native-value="true" + @input="settingsNeedSaved = true"> + Allow creating orders for "unknown" products + </b-checkbox> + </b-field> + + <div v-if="simpleSettings['rattail.custorders.allow_unknown_product']"> + + <p class="block"> + Require these fields for new product: + </p> + + <div class="block" + style="margin-left: 2rem;"> + % for field in pending_product_fields: + <b-field> + <b-checkbox name="rattail.custorders.unknown_product.fields.${field}.required" + v-model="simpleSettings['rattail.custorders.unknown_product.fields.${field}.required']" + native-value="true" + @input="settingsNeedSaved = true"> + ${field} + </b-checkbox> + </b-field> + % endfor + </div> + + <b-field message="If set, user is always prompted to confirm price when adding new product."> + <b-checkbox name="rattail.custorders.unknown_product.always_confirm_price" + v-model="simpleSettings['rattail.custorders.unknown_product.always_confirm_price']" + native-value="true" + @input="settingsNeedSaved = true"> + Require price confirmation + </b-checkbox> + </b-field> + + </div> + + </div> </%def> diff --git a/tailbone/templates/custorders/create.mako b/tailbone/templates/custorders/create.mako index cdbf584c..382a121f 100644 --- a/tailbone/templates/custorders/create.mako +++ b/tailbone/templates/custorders/create.mako @@ -4,22 +4,16 @@ <%def name="extra_styles()"> ${parent.extra_styles()} - % if use_buefy: - <style type="text/css"> - .this-page-content { - flex-grow: 1; - } - </style> - % endif + <style type="text/css"> + .this-page-content { + flex-grow: 1; + } + </style> </%def> <%def name="page_content()"> <br /> - % if use_buefy: - <customer-order-creator></customer-order-creator> - % else: - <p>Sorry, but this page is not supported by your current theme configuration.</p> - % endif + <customer-order-creator></customer-order-creator> </%def> <%def name="order_form_buttons()"> @@ -33,18 +27,18 @@ @click="submitOrder()" :disabled="submittingOrder" icon-pack="fas" - icon-left="fas fa-upload"> - {{ submitOrderButtonText }} + icon-left="upload"> + {{ submittingOrder ? "Working, please wait..." : "Submit this Order" }} </b-button> <b-button @click="startOverEntirely()" icon-pack="fas" - icon-left="fas fa-redo"> + icon-left="redo"> Start Over Entirely </b-button> <b-button @click="cancelOrder()" type="is-danger" icon-pack="fas" - icon-left="fas fa-trash"> + icon-left="trash"> Cancel this Order </b-button> </div> @@ -53,44 +47,82 @@ </div> </%def> -<%def name="render_this_page_template()"> - ${parent.render_this_page_template()} +<%def name="render_vue_templates()"> + ${parent.render_vue_templates()} ${product_lookup.tailbone_product_lookup_template()} - <script type="text/x-template" id="customer-order-creator-template"> <div> ${self.order_form_buttons()} - <b-collapse class="panel" :class="customerPanelType" - :open.sync="customerPanelOpen"> + <${b}-collapse class="panel" + :class="customerPanelType" + % if request.use_oruga: + v-model:open="customerPanelOpen" + % else: + :open.sync="customerPanelOpen" + % endif + > - <div slot="trigger" - slot-scope="props" - class="panel-heading" - role="button"> - <b-icon pack="fas" - ## TODO: this icon toggling should work, according to - ## Buefy docs, but i could not ever get it to work. - ## what am i missing? - ## https://buefy.org/documentation/collapse/ - ## :icon="props.open ? 'caret-down' : 'caret-right'"> - ## (for now we just always show caret-right instead) - icon="caret-right"> - </b-icon> - <strong v-html="customerPanelHeader"></strong> - </div> + <template #trigger="props"> + <div class="panel-heading" + role="button" + style="cursor: pointer;"> + + ## TODO: for some reason buefy will "reuse" the icon + ## element in such a way that its display does not + ## refresh. so to work around that, we use different + ## structure for the two icons, so buefy is forced to + ## re-draw + + <b-icon v-if="props.open" + pack="fas" + icon="caret-down"> + </b-icon> + + <span v-if="!props.open"> + <b-icon pack="fas" + icon="caret-right"> + </b-icon> + </span> + + + <strong v-html="customerPanelHeader"></strong> + </div> + </template> <div class="panel-block"> <div style="width: 100%;"> <div style="display: flex; flex-direction: row;"> <div style="flex-grow: 1; margin-right: 1rem;"> - <b-notification :type="customerStatusType" - position="is-bottom-right" - :closable="false"> - {{ customerStatusText }} - </b-notification> + % if request.use_oruga: + ## TODO: for some reason o-notification variant is not + ## being updated properly, so for now the workaround is + ## to maintain a separate component for each variant + ## i tried to reproduce the problem in a simple page + ## but was unable; this is really a hack but it works.. + <o-notification v-if="customerStatusType == null" + :closable="false"> + {{ customerStatusText }} + </o-notification> + <o-notification v-if="customerStatusType == 'is-warning'" + variant="warning" + :closable="false"> + {{ customerStatusText }} + </o-notification> + <o-notification v-if="customerStatusType == 'is-danger'" + variant="danger" + :closable="false"> + {{ customerStatusText }} + </o-notification> + % else: + <b-notification :type="customerStatusType" + position="is-bottom-right" + :closable="false"> + {{ customerStatusText }} + </b-notification> + % endif </div> <!-- <div class="buttons"> --> <!-- <b-button @click="startOverCustomer()" --> @@ -114,23 +146,28 @@ <div :style="{'flex-grow': contactNotes.length ? 0 : 1}"> - <b-field label="Customer" grouped> - <b-field style="margin-left: 1rem;" - :expanded="!contactUUID"> + <b-field label="Customer"> + <div style="display: flex; gap: 1rem; width: 100%;"> <tailbone-autocomplete ref="contactAutocomplete" v-model="contactUUID" + :style="{'flex-grow': contactUUID ? '0' : '1'}" + expanded placeholder="Enter name or phone number" - :initial-label="contactDisplay" % if new_order_requires_customer: serviceUrl="${url('{}.customer_autocomplete'.format(route_prefix))}" % else: serviceUrl="${url('{}.person_autocomplete'.format(route_prefix))}" % endif - @input="contactChanged"> + % if request.use_oruga: + :assigned-label="contactDisplay" + @update:model-value="contactChanged" + % else: + :initial-label="contactDisplay" + @input="contactChanged" + % endif + > </tailbone-autocomplete> - </b-field> - <div v-if="contactUUID"> - <b-button v-if="contactProfileURL" + <b-button v-if="contactUUID && contactProfileURL" type="is-primary" tag="a" target="_blank" :href="contactProfileURL" @@ -138,8 +175,8 @@ icon-left="external-link-alt"> View Profile </b-button> - - <b-button @click="refreshContact" + <b-button v-if="contactUUID" + @click="refreshContact" icon-pack="fas" icon-left="redo" :disabled="refreshingContact"> @@ -183,8 +220,13 @@ Edit </b-button> - <b-modal has-modal-card - :active.sync="editPhoneNumberShowDialog"> + <${b}-modal has-modal-card + % if request.use_oruga: + v-model:active="editPhoneNumberShowDialog" + % else: + :active.sync="editPhoneNumberShowDialog" + % endif + > <div class="modal-card"> <header class="modal-card-head"> @@ -238,7 +280,7 @@ </b-button> </footer> </div> - </b-modal> + </${b}-modal> </div> % endif @@ -276,8 +318,13 @@ icon-left="edit"> Edit </b-button> - <b-modal has-modal-card - :active.sync="editEmailAddressShowDialog"> + <${b}-modal has-modal-card + % if request.use_oruga: + v-model:active.sync="editEmailAddressShowDialog" + % else: + :active.sync="editEmailAddressShowDialog" + % endif + > <div class="modal-card"> <header class="modal-card-head"> @@ -331,7 +378,7 @@ </b-button> </footer> </div> - </b-modal> + </${b}-modal> </div> % endif </div> @@ -406,8 +453,13 @@ </b-notification> </div> - <b-modal has-modal-card - :active.sync="editNewCustomerShowDialog"> + <${b}-modal has-modal-card + % if request.use_oruga: + v-model:active="editNewCustomerShowDialog" + % else: + :active.sync="editNewCustomerShowDialog" + % endif + > <div class="modal-card"> <header class="modal-card-head"> @@ -449,61 +501,85 @@ </b-button> </footer> </div> - </b-modal> + </${b}-modal> </div> </div> </div> <!-- panel-block --> - </b-collapse> + </${b}-collapse> - <b-collapse class="panel" + <${b}-collapse class="panel" open> - <div slot="trigger" - slot-scope="props" - class="panel-heading" - role="button"> - <b-icon pack="fas" - ## TODO: this icon toggling should work, according to - ## Buefy docs, but i could not ever get it to work. - ## what am i missing? - ## https://buefy.org/documentation/collapse/ - ## :icon="props.open ? 'caret-down' : 'caret-right'"> - ## (for now we just always show caret-right instead) - icon="caret-right"> - </b-icon> - <strong v-html="itemsPanelHeader"></strong> - </div> + <template #trigger="props"> + <div class="panel-heading" + role="button" + style="cursor: pointer;"> + + ## TODO: for some reason buefy will "reuse" the icon + ## element in such a way that its display does not + ## refresh. so to work around that, we use different + ## structure for the two icons, so buefy is forced to + ## re-draw + + <b-icon v-if="props.open" + pack="fas" + icon="caret-down"> + </b-icon> + + <span v-if="!props.open"> + <b-icon pack="fas" + icon="caret-right"> + </b-icon> + </span> + + + <strong v-html="itemsPanelHeader"></strong> + </div> + </template> <div class="panel-block"> <div> <div class="buttons"> <b-button type="is-primary" icon-pack="fas" - icon-left="fas fa-plus" + icon-left="plus" @click="showAddItemDialog()"> Add Item </b-button> % if allow_past_item_reorder: <b-button v-if="contactUUID" icon-pack="fas" - icon-left="fas fa-plus" + icon-left="plus" @click="showAddPastItem()"> Add Past Item </b-button> % endif </div> - <b-modal :active.sync="showingItemDialog"> + <${b}-modal + % if request.use_oruga: + v-model:active="showingItemDialog" + % else: + :active.sync="showingItemDialog" + % endif + > <div class="card"> <div class="card-content"> - <b-tabs type="is-boxed is-toggle" - v-model="itemDialogTabIndex" - :animated="false"> + <${b}-tabs :animated="false" + % if request.use_oruga: + v-model="itemDialogTab" + type="toggle" + % else: + v-model="itemDialogTabIndex" + type="is-boxed is-toggle" + % endif + > - <b-tab-item label="Product"> + <${b}-tab-item label="Product" + value="product"> <div class="field"> <b-radio v-model="productIsKnown" @@ -513,107 +589,82 @@ </div> <div v-show="productIsKnown" - style="padding-left: 5rem;"> + style="padding-left: 3rem; display: flex; gap: 1rem;"> - <b-field grouped> - <p class="label control"> - Product - </p> - <b-field :expanded="!productUUID"> - <tailbone-autocomplete ref="productAutocomplete" - v-model="productUUID" - placeholder="Enter UPC or brand, description etc." - :assigned-label="productDisplay" - serviceUrl="${url('{}.product_autocomplete'.format(route_prefix))}" - @input="productChanged"> - </tailbone-autocomplete> + <div style="flex-grow: 1;"> + <b-field label="Product"> + <tailbone-product-lookup ref="productLookup" + :product="selectedProduct" + @selected="productLookupSelected" + autocomplete-url="${url(f'{route_prefix}.product_autocomplete')}"> + </tailbone-product-lookup> </b-field> - <b-button type="is-primary" - v-if="!productUUID" - @click="productFullLookup()" - icon-pack="fas" - icon-left="search"> - Full Lookup - </b-button> + <div v-if="productUUID"> - <b-button v-if="productUUID" - type="is-primary" - tag="a" target="_blank" - :href="productURL" - :disabled="!productURL" - icon-pack="fas" - icon-left="external-link-alt"> - View Product - </b-button> - </b-field> + <b-field grouped> + <b-field :label="productKeyLabel"> + <span>{{ productKey }}</span> + </b-field> - <div v-if="productUUID"> + <b-field label="Unit Size"> + <span>{{ productSize || '' }}</span> + </b-field> - <div class="is-pulled-right has-text-centered"> - <img :src="productImageURL" - style="height: 150px; width: 150px; "/> - ## <p>{{ productKey }}</p> + <b-field label="Case Size"> + <span>{{ productCaseQuantity }}</span> + </b-field> + + <b-field label="Reg. Price" + v-if="productSalePriceDisplay"> + <span>{{ productUnitRegularPriceDisplay }}</span> + </b-field> + + <b-field label="Unit Price" + v-if="!productSalePriceDisplay"> + <span + % if product_price_may_be_questionable: + :class="productPriceNeedsConfirmation ? 'has-background-warning' : ''" + % endif + > + {{ productUnitPriceDisplay }} + </span> + </b-field> + <!-- <b-field label="Last Changed"> --> + <!-- <span>2021-01-01</span> --> + <!-- </b-field> --> + + <b-field label="Sale Price" + v-if="productSalePriceDisplay"> + <span class="has-background-warning"> + {{ productSalePriceDisplay }} + </span> + </b-field> + + <b-field label="Sale Ends" + v-if="productSaleEndsDisplay"> + <span class="has-background-warning"> + {{ productSaleEndsDisplay }} + </span> + </b-field> + + </b-field> + + % if product_price_may_be_questionable: + <b-checkbox v-model="productPriceNeedsConfirmation" + type="is-warning" + size="is-small"> + This price is questionable and should be confirmed + by someone before order proceeds. + </b-checkbox> + % endif </div> - - <b-field grouped> - <b-field :label="productKeyLabel"> - <span>{{ productKey }}</span> - </b-field> - - <b-field label="Unit Size"> - <span>{{ productSize }}</span> - </b-field> - - <b-field label="Case Size"> - <span>{{ productCaseQuantity }}</span> - </b-field> - - <b-field label="Reg. Price" - v-if="productSalePriceDisplay"> - <span>{{ productUnitRegularPriceDisplay }}</span> - </b-field> - - <b-field label="Unit Price" - v-if="!productSalePriceDisplay"> - <span - % if product_price_may_be_questionable: - :class="productPriceNeedsConfirmation ? 'has-background-warning' : ''" - % endif - > - {{ productUnitPriceDisplay }} - </span> - </b-field> - <!-- <b-field label="Last Changed"> --> - <!-- <span>2021-01-01</span> --> - <!-- </b-field> --> - - <b-field label="Sale Price" - v-if="productSalePriceDisplay"> - <span class="has-background-warning"> - {{ productSalePriceDisplay }} - </span> - </b-field> - - <b-field label="Sale Ends" - v-if="productSaleEndsDisplay"> - <span class="has-background-warning"> - {{ productSaleEndsDisplay }} - </span> - </b-field> - - </b-field> - - % if product_price_may_be_questionable: - <b-checkbox v-model="productPriceNeedsConfirmation" - type="is-warning" - size="is-small"> - This price is questionable and should be confirmed - by someone before order proceeds. - </b-checkbox> - % endif </div> + <img v-if="productUUID" + :src="productImageURL" + style="max-height: 150px; max-width: 150px; "/> + </div> <br /> @@ -632,18 +683,29 @@ <b-field grouped> - <b-field label="Brand"> + <b-field label="Brand" + % if 'brand_name' in pending_product_required_fields: + :type="pendingProduct.brand_name ? null : 'is-danger'" + % endif + > <b-input v-model="pendingProduct.brand_name"> </b-input> </b-field> <b-field label="Description" - :type="pendingProduct.description ? null : 'is-danger'"> + % if 'description' in pending_product_required_fields: + :type="pendingProduct.description ? null : 'is-danger'" + % endif + > <b-input v-model="pendingProduct.description"> </b-input> </b-field> - <b-field label="Unit Size"> + <b-field label="Unit Size" + % if 'size' in pending_product_required_fields: + :type="pendingProduct.size ? null : 'is-danger'" + % endif + > <b-input v-model="pendingProduct.size"> </b-input> </b-field> @@ -652,12 +714,20 @@ <b-field grouped> - <b-field :label="productKeyLabel"> + <b-field :label="productKeyLabel" + % if 'key' in pending_product_required_fields: + :type="pendingProduct[productKeyField] ? null : 'is-danger'" + % endif + > <b-input v-model="pendingProduct[productKeyField]"> </b-input> </b-field> - <b-field label="Department"> + <b-field label="Department" + % if 'department_uuid' in pending_product_required_fields: + :type="pendingProduct.department_uuid ? null : 'is-danger'" + % endif + > <b-select v-model="pendingProduct.department_uuid"> <option :value="null">(not known)</option> <option v-for="option in departmentOptions" @@ -668,34 +738,33 @@ </b-select> </b-field> - <b-field label="Unit Reg. Price"> - <b-input v-model="pendingProduct.regular_price_amount" - type="number" step="0.01"> - </b-input> - </b-field> - </b-field> <b-field grouped> - <b-field label="Vendor"> + <b-field label="Vendor" + % if 'vendor_name' in pending_product_required_fields: + :type="pendingProduct.vendor_name ? null : 'is-danger'" + % endif + > <b-input v-model="pendingProduct.vendor_name"> </b-input> </b-field> - <b-field label="Vendor Item Code"> + <b-field label="Vendor Item Code" + % if 'vendor_item_code' in pending_product_required_fields: + :type="pendingProduct.vendor_item_code ? null : 'is-danger'" + % endif + > <b-input v-model="pendingProduct.vendor_item_code"> </b-input> </b-field> - <b-field label="Unit Cost"> - <b-input v-model="pendingProduct.unit_cost" - type="number" step="0.01" - style="width: 10rem;"> - </b-input> - </b-field> - - <b-field label="Case Size"> + <b-field label="Case Size" + % if 'case_size' in pending_product_required_fields: + :type="pendingProduct.case_size ? null : 'is-danger'" + % endif + > <b-input v-model="pendingProduct.case_size" type="number" step="0.01" style="width: 7rem;"> @@ -704,132 +773,181 @@ </b-field> + <b-field grouped> + + <b-field label="Unit Cost" + % if 'unit_cost' in pending_product_required_fields: + :type="pendingProduct.unit_cost ? null : 'is-danger'" + % endif + > + <b-input v-model="pendingProduct.unit_cost" + type="number" step="0.01" + style="width: 10rem;"> + </b-input> + </b-field> + + <b-field label="Unit Reg. Price" + % if 'regular_price_amount' in pending_product_required_fields: + :type="pendingProduct.regular_price_amount ? null : 'is-danger'" + % endif + > + <b-input v-model="pendingProduct.regular_price_amount" + type="number" step="0.01"> + </b-input> + </b-field> + + <b-field label="Gross Margin"> + <span class="control"> + {{ pendingProductGrossMargin }} + </span> + </b-field> + + </b-field> + <b-field label="Notes"> <b-input v-model="pendingProduct.notes" - type="textarea"> - </b-input> + type="textarea" + expanded /> </b-field> </div> - </b-tab-item> - <b-tab-item label="Quantity"> + </${b}-tab-item> + <${b}-tab-item label="Quantity" + value="quantity"> - <div class="is-pulled-right has-text-centered"> - <img :src="productImageURL" - style="height: 150px; width: 150px; "/> - </div> + <div style="display: flex; gap: 1rem; white-space: nowrap;"> - <b-field grouped> - <b-field label="Product" horizontal> - <span :class="productIsKnown ? null : 'has-text-success'"> - {{ productIsKnown ? productDisplay : pendingProduct.brand_name + ' ' + pendingProduct.description + ' ' + pendingProduct.size }} - </span> - </b-field> - </b-field> - - <b-field grouped> - - <b-field label="Unit Size"> - <span :class="productIsKnown ? null : 'has-text-success'"> - {{ productIsKnown ? productSize : pendingProduct.size }} - </span> - </b-field> - - <b-field label="Reg. Price" - v-if="productSalePriceDisplay"> - <span> - {{ productUnitRegularPriceDisplay }} - </span> - </b-field> - - <b-field label="Unit Price" - v-if="!productSalePriceDisplay"> - <span :class="productIsKnown ? null : 'has-text-success'" - % if product_price_may_be_questionable: - :class="productPriceNeedsConfirmation ? 'has-background-warning' : ''" - % endif - > - {{ productIsKnown ? productUnitPriceDisplay : '$' + pendingProduct.regular_price_amount }} - </span> - </b-field> - - <b-field label="Sale Price" - v-if="productSalePriceDisplay"> - <span class="has-background-warning" - :class="productIsKnown ? null : 'has-text-success'"> - {{ productSalePriceDisplay }} - </span> - </b-field> - - <b-field label="Sale Ends" - v-if="productSaleEndsDisplay"> - <span class="has-background-warning" - :class="productIsKnown ? null : 'has-text-success'"> - {{ productSaleEndsDisplay }} - </span> - </b-field> - - <b-field label="Case Size"> - <span :class="productIsKnown ? null : 'has-text-success'"> - {{ productIsKnown ? productCaseQuantity : pendingProduct.case_size }} - </span> - </b-field> - - <b-field label="Case Price"> - <span - % if product_price_may_be_questionable: - :class="{'has-text-success': !productIsKnown, 'has-background-warning': productPriceNeedsConfirmation || productSalePriceDisplay}" - % else: - :class="{'has-text-success': !productIsKnown, 'has-background-warning': !!productSalePriceDisplay}" - % endif - > - {{ getCasePriceDisplay() }} - </span> - </b-field> - - </b-field> - - <b-field grouped> - - <b-field label="Quantity" horizontal> - <numeric-input v-model="productQuantity"> - </numeric-input> - </b-field> - - <b-select v-model="productUOM"> - <option v-for="choice in productUnitChoices" - :key="choice.key" - :value="choice.key" - v-html="choice.value"> - </option> - </b-select> - - </b-field> - - <b-field grouped> - % if allow_item_discounts: - <b-field label="Discount" horizontal> - <div class="level"> - <div class="level-item"> - <numeric-input v-model="productDiscountPercent" - style="width: 5rem;"> - </numeric-input> - </div> - <div class="level-item"> - <span> %</span> - </div> - </div> + <div style="flex-grow: 1;"> + <b-field grouped> + <b-field label="Product" horizontal> + <span :class="productIsKnown ? null : 'has-text-success'" + ## nb. hack to force refresh for vue3 + :key="refreshProductDescription"> + {{ productIsKnown ? productDisplay : (pendingProduct.brand_name || '') + ' ' + (pendingProduct.description || '') + ' ' + (pendingProduct.size || '') }} + </span> </b-field> - % endif - <b-field label="Total Price" horizontal expanded> - <span :class="productSalePriceDisplay ? 'has-background-warning': null"> - {{ getItemTotalPriceDisplay() }} - </span> - </b-field> - </b-field> + </b-field> - </b-tab-item> - </b-tabs> + <b-field grouped> + + <b-field label="Unit Size"> + <span :class="productIsKnown ? null : 'has-text-success'"> + {{ productIsKnown ? productSize : pendingProduct.size }} + </span> + </b-field> + + <b-field label="Reg. Price" + v-if="productSalePriceDisplay"> + <span> + {{ productUnitRegularPriceDisplay }} + </span> + </b-field> + + <b-field label="Unit Price" + v-if="!productSalePriceDisplay"> + <span :class="productIsKnown ? null : 'has-text-success'" + % if product_price_may_be_questionable: + :class="productPriceNeedsConfirmation ? 'has-background-warning' : ''" + % endif + > + {{ productIsKnown ? productUnitPriceDisplay : (pendingProduct.regular_price_amount ? '$' + pendingProduct.regular_price_amount : '') }} + </span> + </b-field> + + <b-field label="Sale Price" + v-if="productSalePriceDisplay"> + <span class="has-background-warning" + :class="productIsKnown ? null : 'has-text-success'"> + {{ productSalePriceDisplay }} + </span> + </b-field> + + <b-field label="Sale Ends" + v-if="productSaleEndsDisplay"> + <span class="has-background-warning" + :class="productIsKnown ? null : 'has-text-success'"> + {{ productSaleEndsDisplay }} + </span> + </b-field> + + <b-field label="Case Size"> + <span :class="productIsKnown ? null : 'has-text-success'"> + {{ productIsKnown ? productCaseQuantity : pendingProduct.case_size }} + </span> + </b-field> + + <b-field label="Case Price"> + <span + % if product_price_may_be_questionable: + :class="{'has-text-success': !productIsKnown, 'has-background-warning': productPriceNeedsConfirmation || productSalePriceDisplay}" + % else: + :class="{'has-text-success': !productIsKnown, 'has-background-warning': !!productSalePriceDisplay}" + % endif + > + {{ getCasePriceDisplay() }} + </span> + </b-field> + + </b-field> + + <b-field grouped> + + <b-field label="Quantity" horizontal> + <numeric-input v-model="productQuantity" + @input="refreshTotalPrice += 1" + style="width: 5rem;"> + </numeric-input> + </b-field> + + <b-select v-model="productUOM" + @input="refreshTotalPrice += 1"> + <option v-for="choice in productUnitChoices" + :key="choice.key" + :value="choice.key" + v-html="choice.value"> + </option> + </b-select> + + </b-field> + + <div style="display: flex; gap: 1rem;"> + % if allow_item_discounts: + <b-field label="Discount" horizontal> + <div class="level"> + <div class="level-item"> + <numeric-input v-model="productDiscountPercent" + @input="refreshTotalPrice += 1" + style="width: 5rem;" + :disabled="!allowItemDiscount"> + </numeric-input> + </div> + <div class="level-item"> + <span> %</span> + </div> + </div> + </b-field> + % endif + <b-field label="Total Price" horizontal expanded + :key="refreshTotalPrice"> + <span :class="productSalePriceDisplay ? 'has-background-warning': null"> + {{ getItemTotalPriceDisplay() }} + </span> + </b-field> + </div> + + <!-- <b-field grouped> --> + <!-- </b-field> --> + </div> + + <!-- <div class="is-pulled-right has-text-centered"> --> + <img :src="productImageURL" + style="max-height: 150px; max-width: 150px; "/> + <!-- </div> --> + + </div> + + </${b}-tab-item> + </${b}-tabs> <div class="buttons"> <b-button @click="showingItemDialog = false"> @@ -840,105 +958,167 @@ :disabled="itemDialogSaveDisabled" icon-pack="fas" icon-left="save"> - {{ itemDialogSaveButtonText }} + {{ itemDialogSaving ? "Working, please wait..." : (this.editingItem ? "Update Item" : "Add Item") }} </b-button> </div> </div> </div> - </b-modal> + </${b}-modal> - <tailbone-product-lookup ref="productLookup" - @canceled="productLookupCanceled" - @selected="productLookupSelected"> - </tailbone-product-lookup> + % if unknown_product_confirm_price: + <${b}-modal has-modal-card + % if request.use_oruga: + v-model:active="confirmPriceShowDialog" + % else: + :active.sync="confirmPriceShowDialog" + % endif + > + <div class="modal-card"> + + <header class="modal-card-head"> + <p class="modal-card-title">Confirm Price</p> + </header> + + <section class="modal-card-body"> + <p class="block"> + Please confirm the price info before proceeding. + </p> + + <div style="white-space: nowrap;"> + + <b-field label="Unit Cost" horizontal> + <span>{{ pendingProduct.unit_cost }}</span> + </b-field> + + <b-field label="Unit Reg. Price" horizontal> + <span>{{ pendingProduct.regular_price_amount }}</span> + </b-field> + + <b-field label="Gross Margin" horizontal> + <span>{{ pendingProductGrossMargin }}</span> + </b-field> + + </div> + </section> + + <footer class="modal-card-foot"> + <b-button type="is-primary" + icon-pack="fas" + icon-left="check" + @click="confirmPriceSave()"> + Confirm + </b-button> + <b-button @click="confirmPriceCancel()"> + Cancel + </b-button> + </footer> + </div> + </${b}-modal> + % endif % if allow_past_item_reorder: - <b-modal :active.sync="pastItemsShowDialog"> + <${b}-modal + % if request.use_oruga: + v-model:active="pastItemsShowDialog" + % else: + :active.sync="pastItemsShowDialog" + % endif + > <div class="card"> <div class="card-content"> - <b-table :data="pastItems" - icon-pack="fas" - :loading="pastItemsLoading" - :selected.sync="pastItemsSelected" - sortable - paginated - per-page="5" - :debounce-search="1000"> - <template slot-scope="props"> + <${b}-table :data="pastItems" + icon-pack="fas" + :loading="pastItemsLoading" + % if request.use_oruga: + v-model:selected="pastItemsSelected" + % else: + :selected.sync="pastItemsSelected" + % endif + sortable + paginated + per-page="5" + :debounce-search="1000"> - <b-table-column :label="productKeyLabel" - field="key" - sortable> - {{ props.row.key }} - </b-table-column> + <${b}-table-column :label="productKeyLabel" + field="key" + v-slot="props" + sortable> + {{ props.row.key }} + </${b}-table-column> - <b-table-column label="Brand" - field="brand_name" - sortable - searchable> - {{ props.row.brand_name }} - </b-table-column> + <${b}-table-column label="Brand" + field="brand_name" + v-slot="props" + sortable + searchable> + {{ props.row.brand_name }} + </${b}-table-column> - <b-table-column label="Description" - field="description" - sortable - searchable> - {{ props.row.description }} - {{ props.row.size }} - </b-table-column> + <${b}-table-column label="Description" + field="description" + v-slot="props" + sortable + searchable> + {{ props.row.description }} + {{ props.row.size }} + </${b}-table-column> - <b-table-column label="Unit Price" - field="unit_price" - sortable> - {{ props.row.unit_price_display }} - </b-table-column> + <${b}-table-column label="Unit Price" + field="unit_price" + v-slot="props" + sortable> + {{ props.row.unit_price_display }} + </${b}-table-column> - <b-table-column label="Sale Price" - field="sale_price" - sortable> - <span class="has-background-warning"> - {{ props.row.sale_price_display }} - </span> - </b-table-column> + <${b}-table-column label="Sale Price" + field="sale_price" + v-slot="props" + sortable> + <span class="has-background-warning"> + {{ props.row.sale_price_display }} + </span> + </${b}-table-column> - <b-table-column label="Sale Ends" - field="sale_ends" - sortable> - <span class="has-background-warning"> - {{ props.row.sale_ends_display }} - </span> - </b-table-column> + <${b}-table-column label="Sale Ends" + field="sale_ends" + v-slot="props" + sortable> + <span class="has-background-warning"> + {{ props.row.sale_ends_display }} + </span> + </${b}-table-column> - <b-table-column label="Department" - field="department_name" - sortable - searchable> - {{ props.row.department_name }} - </b-table-column> + <${b}-table-column label="Department" + field="department_name" + v-slot="props" + sortable + searchable> + {{ props.row.department_name }} + </${b}-table-column> - <b-table-column label="Vendor" - field="vendor_name" - sortable - searchable> - {{ props.row.vendor_name }} - </b-table-column> + <${b}-table-column label="Vendor" + field="vendor_name" + v-slot="props" + sortable + searchable> + {{ props.row.vendor_name }} + </${b}-table-column> - </template> - <template slot="empty"> + <template #empty> <div class="content has-text-grey has-text-centered"> <p> <b-icon pack="fas" - icon="fas fa-sad-tear" + icon="sad-tear" size="is-large"> </b-icon> </p> <p>Nothing here.</p> </div> </template> - </b-table> + </${b}-table> <div class="buttons"> <b-button @click="pastItemsShowDialog = false"> @@ -955,93 +1135,125 @@ </div> </div> - </b-modal> + </${b}-modal> % endif - <b-table v-if="items.length" + <${b}-table v-if="items.length" :data="items" :row-class="(row, i) => row.product_uuid ? null : 'has-text-success'"> - <template slot-scope="props"> - <b-table-column :label="productKeyLabel"> - {{ props.row.product_key }} - </b-table-column> + <${b}-table-column :label="productKeyLabel" + v-slot="props"> + {{ props.row.product_key }} + </${b}-table-column> - <b-table-column label="Brand"> - {{ props.row.product_brand }} - </b-table-column> + <${b}-table-column label="Brand" + v-slot="props"> + {{ props.row.product_brand }} + </${b}-table-column> - <b-table-column label="Description"> - {{ props.row.product_description }} - </b-table-column> + <${b}-table-column label="Description" + v-slot="props"> + {{ props.row.product_description }} + </${b}-table-column> - <b-table-column label="Size"> - {{ props.row.product_size }} - </b-table-column> + <${b}-table-column label="Size" + v-slot="props"> + {{ props.row.product_size }} + </${b}-table-column> - <b-table-column label="Department"> - {{ props.row.department_display }} - </b-table-column> + <${b}-table-column label="Department" + v-slot="props"> + {{ props.row.department_display }} + </${b}-table-column> - <b-table-column label="Quantity"> - <span v-html="props.row.order_quantity_display"></span> - </b-table-column> + <${b}-table-column label="Quantity" + v-slot="props"> + <span v-html="props.row.order_quantity_display"></span> + </${b}-table-column> - <b-table-column label="Unit Price"> - <span - % if product_price_may_be_questionable: - :class="props.row.price_needs_confirmation ? 'has-background-warning' : ''" - % else: - :class="props.row.pricing_reflects_sale ? 'has-background-warning' : null" - % endif - > - {{ props.row.unit_price_display }} - </span> - </b-table-column> + <${b}-table-column label="Unit Price" + v-slot="props"> + <span + % if product_price_may_be_questionable: + :class="props.row.price_needs_confirmation ? 'has-background-warning' : ''" + % else: + :class="props.row.pricing_reflects_sale ? 'has-background-warning' : null" + % endif + > + {{ props.row.unit_price_display }} + </span> + </${b}-table-column> - % if allow_item_discounts: - <b-table-column label="Discount"> - {{ props.row.discount_percent }}{{ props.row.discount_percent ? " %" : "" }} - </b-table-column> - % endif + % if allow_item_discounts: + <${b}-table-column label="Discount" + v-slot="props"> + {{ props.row.discount_percent }}{{ props.row.discount_percent ? " %" : "" }} + </${b}-table-column> + % endif - <b-table-column label="Total"> - <span - % if product_price_may_be_questionable: - :class="props.row.price_needs_confirmation ? 'has-background-warning' : ''" - % else: - :class="props.row.pricing_reflects_sale ? 'has-background-warning' : null" - % endif - > - {{ props.row.total_price_display }} - </span> - </b-table-column> + <${b}-table-column label="Total" + v-slot="props"> + <span + % if product_price_may_be_questionable: + :class="props.row.price_needs_confirmation ? 'has-background-warning' : ''" + % else: + :class="props.row.pricing_reflects_sale ? 'has-background-warning' : null" + % endif + > + {{ props.row.total_price_display }} + </span> + </${b}-table-column> - <b-table-column label="Vendor"> - {{ props.row.vendor_display }} - </b-table-column> + <${b}-table-column label="Vendor" + v-slot="props"> + {{ props.row.vendor_display }} + </${b}-table-column> - <b-table-column field="actions" label="Actions"> - <a href="#" class="grid-action" - @click.prevent="showEditItemDialog(props.row)"> - <i class="fas fa-edit"></i> - Edit - </a> - + <${b}-table-column field="actions" + label="Actions" + v-slot="props"> + <a href="#" + % if not request.use_oruga: + class="grid-action" + % endif + @click.prevent="showEditItemDialog(props.row)"> + % if request.use_oruga: + <span class="icon-text"> + <o-icon icon="edit" /> + <span>Edit</span> + </span> + % else: + <i class="fas fa-edit"></i> + Edit + % endif + </a> + - <a href="#" class="grid-action has-text-danger" - @click.prevent="deleteItem(props.index)"> - <i class="fas fa-trash"></i> - Delete - </a> - - </b-table-column> + <a href="#" + % if request.use_oruga: + class="has-text-danger" + % else: + class="grid-action has-text-danger" + % endif + @click.prevent="deleteItem(props.index)"> + % if request.use_oruga: + <span class="icon-text"> + <o-icon icon="trash" /> + <span>Delete</span> + </span> + % else: + <i class="fas fa-trash"></i> + Delete + % endif + </a> + + </${b}-table-column> - </template> - </b-table> + </${b}-table> </div> </div> - </b-collapse> + </${b}-collapse> ${self.order_form_buttons()} @@ -1052,15 +1264,11 @@ </div> </script> -</%def> - -<%def name="make_this_page_component()"> - ${parent.make_this_page_component()} - ${product_lookup.tailbone_product_lookup_component()} - <script type="text/javascript"> + <script> const CustomerOrderCreator = { template: '#customer-order-creator-template', + mixins: [SimpleRequestMixin], data() { let defaultUnitChoices = ${json.dumps(default_uom_choices)|n} @@ -1124,7 +1332,11 @@ editingItem: null, showingItemDialog: false, itemDialogSaving: false, - itemDialogTabIndex: 0, + % if request.use_oruga: + itemDialogTab: 'product', + % else: + itemDialogTabIndex: 0, + % endif % if allow_past_item_reorder: pastItemsShowDialog: false, pastItemsLoading: false, @@ -1132,6 +1344,7 @@ pastItemsSelected: null, % endif productIsKnown: true, + selectedProduct: null, productUUID: null, productDisplay: null, productKey: null, @@ -1161,14 +1374,20 @@ % endif % if allow_item_discounts: - productDiscountPercent: null, + productDiscountPercent: ${json.dumps(default_item_discount)|n}, + allowDiscountsIfOnSale: ${json.dumps(allow_item_discounts_if_on_sale)|n}, % endif pendingProduct: {}, + pendingProductRequiredFields: ${json.dumps(pending_product_required_fields)|n}, departmentOptions: ${json.dumps(department_options)|n}, + % if unknown_product_confirm_price: + confirmPriceShowDialog: false, + % endif - ## TODO: should find a better way to handle CSRF token - csrftoken: ${json.dumps(request.session.get_csrf_token() or request.session.new_csrf_token())|n}, + // nb. hack to force refresh for vue3 + refreshProductDescription: 1, + refreshTotalPrice: 1, submittingOrder: false, } @@ -1200,9 +1419,7 @@ customerHeaderClass() { if (!this.customerPanelOpen) { if (this.customerStatusType == 'is-danger') { - return 'has-text-danger' - } else if (this.customerStatusType == 'is-warning') { - return 'has-text-warning' + return 'has-text-white' } } }, @@ -1344,38 +1561,53 @@ return text }, + % if allow_item_discounts: + + allowItemDiscount() { + if (!this.allowDiscountsIfOnSale) { + if (this.productSalePriceDisplay) { + return false + } + } + return true + }, + + % endif + + pendingProductGrossMargin() { + let cost = this.pendingProduct.unit_cost + let price = this.pendingProduct.regular_price_amount + if (cost && price) { + let margin = (price - cost) / price + return (100 * margin).toFixed(2).toString() + " %" + } + }, + itemDialogSaveDisabled() { + if (this.itemDialogSaving) { return true } + if (this.productIsKnown) { if (!this.productUUID) { return true } + } else { - if (!this.pendingProduct.description) { - return true + for (let field of this.pendingProductRequiredFields) { + if (!this.pendingProduct[field]) { + return true + } } } + if (!this.productUOM) { return true } + return false }, - - itemDialogSaveButtonText() { - if (this.itemDialogSaving) { - return "Working, please wait..." - } - return this.editingItem ? "Update Item" : "Add Item" - }, - - submitOrderButtonText() { - if (this.submittingOrder) { - return "Working, please wait..." - } - return "Submit this Order" - }, }, mounted() { if (this.customerStatusType) { @@ -1403,6 +1635,18 @@ this.$refs.contactAutocomplete.clearSelection() } }, + + productIsKnown(newval, oldval) { + // TODO: seems like this should be better somehow? + // e.g. maybe we should not be clearing *everything* + // in case user accidentally clicks, and then clicks + // "is known" again? and if we *should* clear all, + // why does that require 2 steps? + if (!newval) { + this.selectedProduct = null + this.clearProduct() + } + }, }, methods: { @@ -1458,31 +1702,11 @@ submitBatchData(params, success, failure) { let url = ${json.dumps(request.current_route_url())|n} - let headers = { - ## TODO: should find a better way to handle CSRF token - 'X-CSRF-TOKEN': this.csrftoken, - } - - ## TODO: should find a better way to handle CSRF token - this.$http.post(url, params, {headers: headers}).then((response) => { - if (response.data.error) { - this.$buefy.toast.open({ - message: response.data.error, - type: 'is-danger', - duration: 2000, // 2 seconds - }) - if (failure) { - failure(response) - } - } else if (success) { + this.simplePOST(url, params, response => { + if (success) { success(response) } }, response => { - this.$buefy.toast.open({ - message: "Unexpected error occurred", - type: 'is-danger', - duration: 2000, // 2 seconds - }) if (failure) { failure(response) } @@ -1526,22 +1750,21 @@ uuid: this.contactUUID, } } - let that = this - this.submitBatchData(params, function(response) { + this.submitBatchData(params, response => { % if new_order_requires_customer: - that.contactUUID = response.data.customer_uuid + this.contactUUID = response.data.customer_uuid % else: - that.contactUUID = response.data.person_uuid + this.contactUUID = response.data.person_uuid % endif - that.contactDisplay = response.data.contact_display - that.orderPhoneNumber = response.data.phone_number - that.orderEmailAddress = response.data.email_address - that.addOtherPhoneNumber = response.data.add_phone_number - that.addOtherEmailAddress = response.data.add_email_address - that.contactProfileURL = response.data.contact_profile_url - that.contactPhones = response.data.contact_phones - that.contactEmails = response.data.contact_emails - that.contactNotes = response.data.contact_notes + this.contactDisplay = response.data.contact_display + this.orderPhoneNumber = response.data.phone_number + this.orderEmailAddress = response.data.email_address + this.addOtherPhoneNumber = response.data.add_phone_number + this.addOtherEmailAddress = response.data.add_email_address + this.contactProfileURL = response.data.contact_profile_url + this.contactPhones = response.data.contact_phones + this.contactEmails = response.data.contact_emails + this.contactNotes = response.data.contact_notes if (callback) { callback() } @@ -1773,20 +1996,12 @@ } }, - productFullLookup() { - this.showingItemDialog = false - let term = this.$refs.productAutocomplete.getUserInput() - this.$refs.productLookup.showDialog(term) - }, - - productLookupCanceled() { - this.showingItemDialog = true - }, - productLookupSelected(selected) { + // TODO: this still is a hack somehow, am sure of it. + // need to clean this up at some point + this.selectedProduct = selected this.clearProduct() - this.productChanged(selected.uuid) - this.showingItemDialog = true + this.productChanged(selected) }, copyPendingProductAttrs(from, to) { @@ -1809,6 +2024,7 @@ this.customerPanelOpen = false this.editingItem = null this.productIsKnown = true + this.selectedProduct = null this.productUUID = null this.productDisplay = null this.productKey = null @@ -1835,13 +2051,17 @@ % endif % if allow_item_discounts: - this.productDiscountPercent = null + this.productDiscountPercent = ${json.dumps(default_item_discount)|n} % endif - this.itemDialogTabIndex = 0 + % if request.use_oruga: + this.itemDialogTab = 'product' + % else: + this.itemDialogTabIndex = 0 + % endif this.showingItemDialog = true this.$nextTick(() => { - this.$refs.productAutocomplete.focus() + this.$refs.productLookup.focus() }) }, @@ -1894,7 +2114,15 @@ this.productPriceNeedsConfirmation = false % endif - this.itemDialogTabIndex = 1 + // nb. hack to force refresh for vue3 + this.refreshProductDescription += 1 + this.refreshTotalPrice += 1 + + % if request.use_oruga: + this.itemDialogTab = 'quantity' + % else: + this.itemDialogTabIndex = 1 + % endif this.showingItemDialog = true }, @@ -1905,12 +2133,25 @@ this.productIsKnown = !!row.product_uuid this.productUUID = row.product_uuid - this.pendingProduct = {} - if (row.pending_product) { - this.copyPendingProductAttrs(row.pending_product, - this.pendingProduct) + + if (row.product_uuid) { + this.selectedProduct = { + uuid: row.product_uuid, + full_description: row.product_full_description, + url: row.product_url, + } + } else { + this.selectedProduct = null } + // nb. must construct new object before updating data + // (otherwise vue does not notice the changes?) + let pending = {} + if (row.pending_product) { + this.copyPendingProductAttrs(row.pending_product, pending) + } + this.pendingProduct = pending + this.productDisplay = row.product_full_description this.productKey = row.product_key this.productSize = row.product_size @@ -1938,7 +2179,15 @@ this.productDiscountPercent = row.discount_percent % endif - this.itemDialogTabIndex = 1 + // nb. hack to force refresh for vue3 + this.refreshProductDescription += 1 + this.refreshTotalPrice += 1 + + % if request.use_oruga: + this.itemDialogTab = 'quantity' + % else: + this.itemDialogTabIndex = 1 + % endif this.showingItemDialog = true }, @@ -1983,6 +2232,10 @@ this.productImageURL = null this.productUnitChoices = this.defaultUnitChoices + % if allow_item_discounts: + this.productDiscountPercent = ${json.dumps(default_item_discount)|n} + % endif + % if product_price_may_be_questionable: this.productPriceNeedsConfirmation = false % endif @@ -2003,11 +2256,11 @@ } }, - productChanged(uuid) { - if (uuid) { + productChanged(product) { + if (product) { let params = { action: 'get_product_info', - uuid: uuid, + uuid: product.uuid, } // nb. it is possible for the handler to "swap" // the product selection, i.e. user chooses a "per @@ -2016,6 +2269,8 @@ // received above is the correct one, but just use // whatever came back from handler this.submitBatchData(params, response => { + this.selectedProduct = response.data + this.productUUID = response.data.uuid this.productKey = response.data.key this.productDisplay = response.data.full_description @@ -2029,6 +2284,11 @@ this.productSalePrice = response.data.sale_price this.productSalePriceDisplay = response.data.sale_price_display this.productSaleEndsDisplay = response.data.sale_ends_display + + % if allow_item_discounts: + this.productDiscountPercent = this.allowItemDiscount ? response.data.default_item_discount : null + % endif + this.productURL = response.data.url this.productImageURL = response.data.image_url this.setProductUnitChoices(response.data.uom_choices) @@ -2037,7 +2297,15 @@ this.productPriceNeedsConfirmation = false % endif - this.itemDialogTabIndex = 1 + % if request.use_oruga: + this.itemDialogTab = 'quantity' + % else: + this.itemDialogTabIndex = 1 + % endif + + // nb. hack to force refresh for vue3 + this.refreshProductDescription += 1 + this.refreshTotalPrice += 1 }, response => { this.clearProduct() @@ -2047,7 +2315,7 @@ } }, - itemDialogSave() { + itemDialogAttemptSave() { this.itemDialogSaving = true let params = { @@ -2095,15 +2363,44 @@ this.itemDialogSaving = false this.showingItemDialog = false + }, response => { + this.itemDialogSaving = false }) }, + + itemDialogSave() { + + % if unknown_product_confirm_price: + if (!this.productIsKnown && !this.editingItem) { + this.showingItemDialog = false + this.confirmPriceShowDialog = true + return + } + % endif + + this.itemDialogAttemptSave() + }, + + confirmPriceCancel() { + this.confirmPriceShowDialog = false + this.showingItemDialog = true + }, + + confirmPriceSave() { + this.confirmPriceShowDialog = false + this.showingItemDialog = true + this.itemDialogAttemptSave() + }, }, } Vue.component('customer-order-creator', CustomerOrderCreator) + <% request.register_component('customer-order-creator', 'CustomerOrderCreator') %> </script> </%def> - -${parent.body()} +<%def name="make_vue_components()"> + ${parent.make_vue_components()} + ${product_lookup.tailbone_product_lookup_component()} +</%def> diff --git a/tailbone/templates/custorders/items/view.mako b/tailbone/templates/custorders/items/view.mako index 030b0ade..4cc92bbf 100644 --- a/tailbone/templates/custorders/items/view.mako +++ b/tailbone/templates/custorders/items/view.mako @@ -1,7 +1,7 @@ ## -*- coding: utf-8; -*- <%inherit file="/master/view.mako" /> -<%def name="render_buefy_form()"> +<%def name="render_form()"> <div class="form"> <${form.component} ref="mainForm" % if master.has_perm('confirm_price'): @@ -9,6 +9,7 @@ % endif % if master.has_perm('change_status'): @change-status="showChangeStatus" + @mark-received="markReceivedInit" % endif % if master.has_perm('add_note'): @add-note="showAddNote" @@ -61,6 +62,67 @@ % endif % if master.has_perm('change_status'): + + ## TODO ## + <% contact = instance.order.person %> + <% email_address = rattail_app.get_contact_email_address(contact) %> + + <b-modal has-modal-card + :active.sync="markReceivedShowDialog"> + <div class="modal-card"> + + <header class="modal-card-head"> + <p class="modal-card-title">Mark Received</p> + </header> + + <section class="modal-card-body"> + <p class="block"> + new status will be: + <span class="has-text-weight-bold"> + % if email_address: + ${enum.CUSTORDER_ITEM_STATUS[enum.CUSTORDER_ITEM_STATUS_CONTACTED]} + % else: + ${enum.CUSTORDER_ITEM_STATUS[enum.CUSTORDER_ITEM_STATUS_RECEIVED]} + % endif + </span> + </p> + % if email_address: + <p class="block"> + This customer has an email address on file, which + means that we will automatically send them an email + notification, and advance the Order Product status to + "${enum.CUSTORDER_ITEM_STATUS[enum.CUSTORDER_ITEM_STATUS_CONTACTED]}". + </p> + % else: + <p class="block"> + This customer does *not* have an email address on + file, which means that we will *not* automatically + send them an email notification, so the Order + Product status will become + "${enum.CUSTORDER_ITEM_STATUS[enum.CUSTORDER_ITEM_STATUS_RECEIVED]}". + </p> + % endif + </section> + + <footer class="modal-card-foot"> + <b-button type="is-primary" + @click="markReceivedSubmit()" + :disabled="markReceivedSubmitting" + icon-pack="fas" + icon-left="check"> + {{ markReceivedSubmitting ? "Working, please wait..." : "Mark Received" }} + </b-button> + <b-button @click="markReceivedShowDialog = false"> + Cancel + </b-button> + </footer> + </div> + </b-modal> + ${h.form(url(f'{route_prefix}.mark_received'), ref='markReceivedForm')} + ${h.csrf_token(request)} + ${h.hidden('order_item_uuids', value=instance.uuid)} + ${h.end_form()} + <b-modal :active.sync="showChangeStatusDialog"> <div class="card"> <div class="card-content"> @@ -106,47 +168,50 @@ :checked-rows.sync="changeStatusCheckedRows" narrowed class="is-size-7"> - <template slot-scope="props"> - <b-table-column field="product_brand" label="Brand"> - <span v-html="props.row.product_brand"></span> - </b-table-column> - <b-table-column field="product_description" label="Product"> - <span v-html="props.row.product_description"></span> - </b-table-column> - <!-- <b-table-column field="quantity" label="Quantity"> --> - <!-- <span v-html="props.row.quantity"></span> --> - <!-- </b-table-column> --> - <b-table-column field="product_case_quantity" label="cPack"> - <span v-html="props.row.product_case_quantity"></span> - </b-table-column> - <b-table-column field="order_quantity" label="oQty"> - <span v-html="props.row.order_quantity"></span> - </b-table-column> - <b-table-column field="order_uom" label="UOM"> - <span v-html="props.row.order_uom"></span> - </b-table-column> - <b-table-column field="department_name" label="Department"> - <span v-html="props.row.department_name"></span> - </b-table-column> - <b-table-column field="product_barcode" label="Product Barcode"> - <span v-html="props.row.product_barcode"></span> - </b-table-column> - <b-table-column field="unit_price" label="Unit $"> - <span v-html="props.row.unit_price"></span> - </b-table-column> - <b-table-column field="total_price" label="Total $"> - <span v-html="props.row.total_price"></span> - </b-table-column> - <b-table-column field="order_date" label="Order Date"> - <span v-html="props.row.order_date"></span> - </b-table-column> - <b-table-column field="status_code" label="Status"> - <span v-html="props.row.status_code"></span> - </b-table-column> - <!-- <b-table-column field="flagged" label="Flagged"> --> - <!-- <span v-html="props.row.flagged"></span> --> - <!-- </b-table-column> --> - </template> + <b-table-column field="product_key" label="${rattail_app.get_product_key_label()}" + v-slot="props"> + {{ props.row.product_key }} + </b-table-column> + <b-table-column field="brand_name" label="Brand" + v-slot="props"> + <span v-html="props.row.brand_name"></span> + </b-table-column> + <b-table-column field="product_description" label="Description" + v-slot="props"> + <span v-html="props.row.product_description"></span> + </b-table-column> + <b-table-column field="product_size" label="Size" + v-slot="props"> + <span v-html="props.row.product_size"></span> + </b-table-column> + <b-table-column field="product_case_quantity" label="cPack" + v-slot="props"> + <span v-html="props.row.product_case_quantity"></span> + </b-table-column> + <b-table-column field="department_name" label="Department" + v-slot="props"> + <span v-html="props.row.department_name"></span> + </b-table-column> + <b-table-column field="order_quantity" label="oQty" + v-slot="props"> + <span v-html="props.row.order_quantity"></span> + </b-table-column> + <b-table-column field="order_uom" label="UOM" + v-slot="props"> + <span v-html="props.row.order_uom"></span> + </b-table-column> + <b-table-column field="total_price" label="Total $" + v-slot="props"> + <span v-html="props.row.total_price"></span> + </b-table-column> + <b-table-column field="status_code" label="Status" + v-slot="props"> + <span v-html="props.row.status_code"></span> + </b-table-column> + <b-table-column field="flagged" label="Flagged" + v-slot="props"> + {{ props.row.flagged ? "FLAG" : "" }} + </b-table-column> </b-table> <br /> @@ -215,7 +280,7 @@ :disabled="addNoteSaveDisabled" icon-pack="fas" icon-left="save"> - {{ addNoteSubmitText }} + {{ addNoteSubmitting ? "Working, please wait..." : "Save Note" }} </b-button> <b-button @click="showAddNoteDialog = false"> Cancel @@ -226,11 +291,11 @@ % endif </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> - ${form.component_studly}Data.notesData = ${json.dumps(notes_data)|n} + ${form.vue_component}Data.eventsData = ${json.dumps(events_data)|n} % if master.has_perm('confirm_price'): @@ -269,8 +334,20 @@ % if master.has_perm('change_status'): + ThisPageData.markReceivedShowDialog = false + ThisPageData.markReceivedSubmitting = false + + ThisPage.methods.markReceivedInit = function() { + this.markReceivedShowDialog = true + } + + ThisPage.methods.markReceivedSubmit = function() { + this.markReceivedSubmitting = true + this.$refs.markReceivedForm.submit() + } + ThisPageData.orderItemStatuses = ${json.dumps(enum.CUSTORDER_ITEM_STATUS)|n} - ThisPageData.orderItemStatusOptions = ${json.dumps([dict(key=k, label=v) for k, v in six.iteritems(enum.CUSTORDER_ITEM_STATUS)])|n} + ThisPageData.orderItemStatusOptions = ${json.dumps([dict(key=k, label=v) for k, v in enum.CUSTORDER_ITEM_STATUS.items()])|n} ThisPageData.oldStatusCode = ${instance.status_code} @@ -315,6 +392,12 @@ this.$refs.changeStatusForm.submit() } + ${form.vue_component}Data.changeFlaggedSubmitting = false + + ${form.vue_component}.methods.changeFlaggedSubmit = function() { + this.changeFlaggedSubmitting = true + } + % endif % if master.has_perm('add_note'): @@ -323,7 +406,6 @@ ThisPageData.newNoteText = null ThisPageData.newNoteApplyAll = false ThisPageData.addNoteSubmitting = false - ThisPageData.addNoteSubmitText = "Save Note" ThisPage.computed.addNoteSaveDisabled = function() { if (!this.newNoteText) { @@ -346,43 +428,19 @@ ThisPage.methods.addNoteSave = function() { this.addNoteSubmitting = true - this.addNoteSubmitText = "Working, please wait..." let url = '${url('{}.add_note'.format(route_prefix), uuid=instance.uuid)}' - let params = { note: this.newNoteText, apply_all: this.newNoteApplyAll, } - let headers = { - ## TODO: should find a better way to handle CSRF token - 'X-CSRF-TOKEN': this.csrftoken, - } - - ## TODO: should find a better way to handle CSRF token - this.$http.post(url, params, {headers: headers}).then(({ data }) => { - if (data.success) { - this.$refs.mainForm.notesData = data.notes - this.showAddNoteDialog = false - } else { - this.$buefy.toast.open({ - message: "Save failed: " + (data.error || "(unknown error)"), - type: 'is-danger', - duration: 4000, // 4 seconds - }) - } + this.simplePOST(url, params, response => { + this.$refs.mainForm.eventsData = response.data.events + this.showAddNoteDialog = false this.addNoteSubmitting = false - this.addNoteSubmitText = "Save Note" - }).catch((error) => { - // TODO: should handle this better somehow..? - this.$buefy.toast.open({ - message: "Save failed: (unknown error)", - type: 'is-danger', - duration: 4000, // 4 seconds - }) + }, response => { this.addNoteSubmitting = false - this.addNoteSubmitText = "Save Note" }) } @@ -390,5 +448,3 @@ </script> </%def> - -${parent.body()} diff --git a/tailbone/templates/custorders/view.mako b/tailbone/templates/custorders/view.mako new file mode 100644 index 00000000..e2af7bf4 --- /dev/null +++ b/tailbone/templates/custorders/view.mako @@ -0,0 +1,3 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/view.mako" /> +${parent.body()} diff --git a/tailbone/templates/datasync/changes/index.mako b/tailbone/templates/datasync/changes/index.mako index e92c3c3c..86f5c121 100644 --- a/tailbone/templates/datasync/changes/index.mako +++ b/tailbone/templates/datasync/changes/index.mako @@ -1,57 +1,34 @@ ## -*- coding: utf-8; -*- <%inherit file="/master/index.mako" /> -<%def name="context_menu_items()"> - ${parent.context_menu_items()} - % if request.has_perm('datasync.status'): - <li>${h.link_to("View DataSync Status", url('datasync.status'))}</li> - % endif -</%def> - <%def name="grid_tools()"> ${parent.grid_tools()} % if request.has_perm('datasync.restart'): - % if use_buefy: ${h.form(url('datasync.restart'), name='restart-datasync', class_='control', **{'@submit': 'submitRestartDatasyncForm'})} - % else: - ${h.form(url('datasync.restart'), name='restart-datasync', class_='autodisable')} - % endif ${h.csrf_token(request)} - % if use_buefy: <b-button native-type="submit" :disabled="restartDatasyncFormSubmitting"> {{ restartDatasyncFormButtonText }} </b-button> - % else: - ${h.submit('submit', "Restart DataSync", data_working_label="Restarting DataSync", class_='button')} - % endif ${h.end_form()} % endif % if allow_filemon_restart and request.has_perm('filemon.restart'): - % if use_buefy: ${h.form(url('filemon.restart'), name='restart-filemon', class_='control', **{'@submit': 'submitRestartFilemonForm'})} - % else: - ${h.form(url('filemon.restart'), name='restart-filemon', class_='autodisable')} - % endif ${h.csrf_token(request)} - % if use_buefy: <b-button native-type="submit" :disabled="restartFilemonFormSubmitting"> {{ restartFilemonFormButtonText }} </b-button> - % else: - ${h.submit('submit', "Restart FileMon", data_working_label="Restarting FileMon", class_='button')} - % endif ${h.end_form()} % endif </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> % if request.has_perm('datasync.restart'): TailboneGridData.restartDatasyncFormSubmitting = false @@ -73,6 +50,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/datasync/configure.mako b/tailbone/templates/datasync/configure.mako index 014668be..2e444fb5 100644 --- a/tailbone/templates/datasync/configure.mako +++ b/tailbone/templates/datasync/configure.mako @@ -1,6 +1,15 @@ ## -*- coding: utf-8; -*- <%inherit file="/configure.mako" /> +<%def name="extra_styles()"> + ${parent.extra_styles()} + <style> + .invisible-watcher { + display: none; + } + </style> +</%def> + <%def name="buttons_row()"> <div class="level"> <div class="level-left"> @@ -48,7 +57,12 @@ ${h.hidden('profiles', **{':value': 'JSON.stringify(profilesData)'})} <b-notification type="is-warning" - :active.sync="showConfigFilesNote"> + % if request.use_oruga: + v-model:active="showConfigFilesNote" + % else: + :active.sync="showConfigFilesNote" + % endif + > ## TODO: should link to some ratman page here, yes? <p class="block"> This tool works by modifying settings in the DB. It @@ -69,8 +83,8 @@ </b-notification> <b-field> - <b-checkbox name="use_profile_settings" - v-model="useProfileSettings" + <b-checkbox name="rattail.datasync.use_profile_settings" + v-model="simpleSettings['rattail.datasync.use_profile_settings']" native-value="true" @input="settingsNeedSaved = true"> Use these Settings to configure watchers and consumers @@ -85,7 +99,7 @@ </div> <div class="level-right"> <div class="level-item" - v-show="useProfileSettings"> + v-show="simpleSettings['rattail.datasync.use_profile_settings']"> <b-button type="is-primary" @click="newProfile()" icon-pack="fas" @@ -101,61 +115,89 @@ </div> </div> - <b-table :data="filteredProfilesData" - :row-class="(row, i) => row.enabled ? null : 'has-background-warning'"> - <template slot-scope="props"> - <b-table-column field="key" label="Watcher Key"> - {{ props.row.key }} - </b-table-column> - <b-table-column field="watcher_spec" label="Watcher Spec"> - {{ props.row.watcher_spec }} - </b-table-column> - <b-table-column field="watcher_dbkey" label="DB Key"> - {{ props.row.watcher_dbkey }} - </b-table-column> - <b-table-column field="watcher_delay" label="Loop Delay"> - {{ props.row.watcher_delay }} sec - </b-table-column> - <b-table-column field="watcher_retry_attempts" label="Attempts / Delay"> - {{ props.row.watcher_retry_attempts }} / {{ props.row.watcher_retry_delay }} sec - </b-table-column> - <b-table-column field="watcher_default_runas" label="Default Runas"> - {{ props.row.watcher_default_runas }} - </b-table-column> - <b-table-column label="Consumers"> - {{ consumerShortList(props.row) }} - </b-table-column> -## <b-table-column field="notes" label="Notes"> + <${b}-table :data="profilesData" + :row-class="getWatcherRowClass"> + <${b}-table-column field="key" + label="Watcher Key" + v-slot="props"> + {{ props.row.key }} + </${b}-table-column> + <${b}-table-column field="watcher_spec" + label="Watcher Spec" + v-slot="props"> + {{ props.row.watcher_spec }} + </${b}-table-column> + <${b}-table-column field="watcher_dbkey" + label="DB Key" + v-slot="props"> + {{ props.row.watcher_dbkey }} + </${b}-table-column> + <${b}-table-column field="watcher_delay" + label="Loop Delay" + v-slot="props"> + {{ props.row.watcher_delay }} sec + </${b}-table-column> + <${b}-table-column field="watcher_retry_attempts" + label="Attempts / Delay" + v-slot="props"> + {{ props.row.watcher_retry_attempts }} / {{ props.row.watcher_retry_delay }} sec + </${b}-table-column> + <${b}-table-column field="watcher_default_runas" + label="Default Runas" + v-slot="props"> + {{ props.row.watcher_default_runas }} + </${b}-table-column> + <${b}-table-column label="Consumers" + v-slot="props"> + {{ consumerShortList(props.row) }} + </${b}-table-column> +## <${b}-table-column field="notes" label="Notes"> ## TODO ## ## {{ props.row.notes }} -## </b-table-column> - <b-table-column field="enabled" label="Enabled"> - {{ props.row.enabled ? "Yes" : "No" }} - </b-table-column> - <b-table-column label="Actions" - v-if="useProfileSettings"> - <a href="#" - class="grid-action" - @click.prevent="editProfile(props.row)"> - <i class="fas fa-edit"></i> - Edit - </a> - - <a href="#" - class="grid-action has-text-danger" - @click.prevent="deleteProfile(props.row)"> - <i class="fas fa-trash"></i> - Delete - </a> - </b-table-column> - </template> - <template slot="empty"> +## </${b}-table-column> + <${b}-table-column field="enabled" + label="Enabled" + v-slot="props"> + {{ props.row.enabled ? "Yes" : "No" }} + </${b}-table-column> + <${b}-table-column label="Actions" + v-slot="props" + v-if="simpleSettings['rattail.datasync.use_profile_settings']"> + <a href="#" + class="grid-action" + @click.prevent="editProfile(props.row)"> + % if request.use_oruga: + <span class="icon-text"> + <o-icon icon="edit" /> + <span>Edit</span> + </span> + % else: + <i class="fas fa-edit"></i> + Edit + % endif + </a> + + <a href="#" + class="grid-action has-text-danger" + @click.prevent="deleteProfile(props.row)"> + % if request.use_oruga: + <span class="icon-text"> + <o-icon icon="trash" /> + <span>Delete</span> + </span> + % else: + <i class="fas fa-trash"></i> + Delete + % endif + </a> + </${b}-table-column> + <template #empty> <section class="section"> <div class="content has-text-grey has-text-centered"> <p> <b-icon pack="fas" - icon="fas fa-sad-tear" + icon="sad-tear" size="is-large"> </b-icon> </p> @@ -163,7 +205,7 @@ </div> </section> </template> - </b-table> + </${b}-table> <b-modal :active.sync="editProfileShowDialog"> <div class="card"> @@ -185,12 +227,12 @@ </b-field> - <b-field grouped> + <b-field grouped expanded> <b-field label="Watcher Spec" :type="editingProfileWatcherSpec ? null : 'is-danger'" expanded> - <b-input v-model="editingProfileWatcherSpec"> + <b-input v-model="editingProfileWatcherSpec" expanded> </b-input> </b-field> @@ -279,37 +321,54 @@ </div> - <b-table :data="editingProfilePendingWatcherKwargs" + <${b}-table :data="editingProfilePendingWatcherKwargs" style="margin-left: 1rem;"> - <template slot-scope="props"> - <b-table-column field="key" label="Key"> - {{ props.row.key }} - </b-table-column> - <b-table-column field="value" label="Value"> - {{ props.row.value }} - </b-table-column> - <b-table-column label="Actions"> - <a href="#" - @click.prevent="editProfileWatcherKwarg(props.row)"> - <i class="fas fa-edit"></i> - Edit - </a> - - <a href="#" - class="has-text-danger" - @click.prevent="deleteProfileWatcherKwarg(props.row)"> - <i class="fas fa-trash"></i> - Delete - </a> - </b-table-column> - </template> - <template slot="empty"> + <${b}-table-column field="key" + label="Key" + v-slot="props"> + {{ props.row.key }} + </${b}-table-column> + <${b}-table-column field="value" + label="Value" + v-slot="props"> + {{ props.row.value }} + </${b}-table-column> + <${b}-table-column label="Actions" + v-slot="props"> + <a href="#" + @click.prevent="editProfileWatcherKwarg(props.row)"> + % if request.use_oruga: + <span class="icon-text"> + <o-icon icon="edit" /> + <span>Edit</span> + </span> + % else: + <i class="fas fa-edit"></i> + Edit + % endif + </a> + + <a href="#" + class="has-text-danger" + @click.prevent="deleteProfileWatcherKwarg(props.row)"> + % if request.use_oruga: + <span class="icon-text"> + <o-icon icon="trash" /> + <span>Delete</span> + </span> + % else: + <i class="fas fa-trash"></i> + Delete + % endif + </a> + </${b}-table-column> + <template #empty> <section class="section"> <div class="content has-text-grey has-text-centered"> <p> <b-icon pack="fas" - icon="fas fa-sad-tear" + icon="sad-tear" size="is-large"> </b-icon> </p> @@ -317,7 +376,7 @@ </div> </section> </template> - </b-table> + </${b}-table> </div> @@ -333,39 +392,55 @@ </b-checkbox> </b-field> - <b-table :data="editingProfilePendingConsumers" + <${b}-table :data="editingProfilePendingConsumers" v-if="!editingProfileWatcherConsumesSelf" :row-class="(row, i) => row.enabled ? null : 'has-background-warning'"> - <template slot-scope="props"> - <b-table-column field="key" label="Consumer"> - {{ props.row.key }} - </b-table-column> - <b-table-column style="white-space: nowrap;"> - {{ props.row.consumer_delay }} / {{ props.row.consumer_retry_attempts }} / {{ props.row.consumer_retry_delay }} - </b-table-column> - <b-table-column label="Actions"> - <a href="#" - class="grid-action" - @click.prevent="editProfileConsumer(props.row)"> - <i class="fas fa-edit"></i> - Edit - </a> - - <a href="#" - class="grid-action has-text-danger" - @click.prevent="deleteProfileConsumer(props.row)"> - <i class="fas fa-trash"></i> - Delete - </a> - </b-table-column> - </template> - <template slot="empty"> + <${b}-table-column field="key" + label="Consumer" + v-slot="props"> + {{ props.row.key }} + </${b}-table-column> + <${b}-table-column style="white-space: nowrap;" + v-slot="props"> + {{ props.row.consumer_delay }} / {{ props.row.consumer_retry_attempts }} / {{ props.row.consumer_retry_delay }} + </${b}-table-column> + <${b}-table-column label="Actions" + v-slot="props"> + <a href="#" + class="grid-action" + @click.prevent="editProfileConsumer(props.row)"> + % if request.use_oruga: + <span class="icon-text"> + <o-icon icon="edit" /> + <span>Edit</span> + </span> + % else: + <i class="fas fa-edit"></i> + Edit + % endif + </a> + + <a href="#" + class="grid-action has-text-danger" + @click.prevent="deleteProfileConsumer(props.row)"> + % if request.use_oruga: + <span class="icon-text"> + <o-icon icon="trash" /> + <span>Delete</span> + </span> + % else: + <i class="fas fa-trash"></i> + Delete + % endif + </a> + </${b}-table-column> + <template #empty> <section class="section"> <div class="content has-text-grey has-text-centered"> <p> <b-icon pack="fas" - icon="fas fa-sad-tear" + icon="sad-tear" size="is-large"> </b-icon> </p> @@ -373,7 +448,7 @@ </div> </section> </template> - </b-table> + </${b}-table> </div> @@ -505,31 +580,41 @@ <b-field label="Supervisor Process Name" message="This should be the complete name, including group - e.g. poser:poser_datasync" expanded> - <b-input name="supervisor_process_name" - v-model="supervisorProcessName" - @input="settingsNeedSaved = true"> + <b-input name="rattail.datasync.supervisor_process_name" + v-model="simpleSettings['rattail.datasync.supervisor_process_name']" + @input="settingsNeedSaved = true" + expanded> </b-input> </b-field> + <b-field label="Consumer Batch Size" + message="Max number of changes to be consumed at once." + expanded> + <numeric-input name="rattail.datasync.batch_size_limit" + v-model="simpleSettings['rattail.datasync.batch_size_limit']" + @input="settingsNeedSaved = true" /> + </b-field> + + <h3 class="is-size-3">Legacy</h3> <b-field label="Restart Command" message="This will run as '${system_user}' system user - please configure sudoers as needed. Typical command is like: sudo supervisorctl restart poser:poser_datasync" expanded> - <b-input name="restart_command" - v-model="restartCommand" - @input="settingsNeedSaved = true"> + <b-input name="tailbone.datasync.restart" + v-model="simpleSettings['tailbone.datasync.restart']" + @input="settingsNeedSaved = true" + expanded> </b-input> </b-field> </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> ThisPageData.showConfigFilesNote = false ThisPageData.profilesData = ${json.dumps(profiles_data)|n} ThisPageData.showDisabledProfiles = false - ThisPageData.useProfileSettings = ${json.dumps(use_profile_settings)|n} ThisPageData.editProfileShowDialog = false ThisPageData.editingProfile = null @@ -554,22 +639,6 @@ ThisPageData.editingConsumerRunas = null ThisPageData.editingConsumerEnabled = true - ThisPageData.supervisorProcessName = ${json.dumps(supervisor_process_name)|n} - ThisPageData.restartCommand = ${json.dumps(restart_command)|n} - - ThisPage.computed.filteredProfilesData = function() { - if (this.showDisabledProfiles) { - return this.profilesData - } - let data = [] - for (let row of this.profilesData) { - if (row.enabled) { - data.push(row) - } - } - return data - } - ThisPage.computed.updateConsumerDisabled = function() { if (!this.editingConsumerKey) { return true @@ -597,6 +666,15 @@ this.showDisabledProfiles = !this.showDisabledProfiles } + ThisPage.methods.getWatcherRowClass = function(row, i) { + if (!row.enabled) { + if (!this.showDisabledProfiles) { + return 'invisible-watcher' + } + return 'has-background-warning' + } + } + ThisPage.methods.consumerShortList = function(row) { let keys = [] if (row.watcher_consumes_self) { @@ -612,7 +690,7 @@ } ThisPage.methods.newProfile = function() { - this.editingProfile = {} + this.editingProfile = {watcher_kwargs_data: []} this.editingConsumer = null this.editingWatcherKwargs = false @@ -661,16 +739,9 @@ this.editingProfilePendingConsumers = [] for (let consumer of row.consumers_data) { - let pending = { + const pending = { + ...consumer, original_key: consumer.key, - key: consumer.key, - consumer_spec: consumer.consumer_spec, - consumer_dbkey: consumer.consumer_dbkey, - consumer_delay: consumer.consumer_delay, - consumer_retry_attempts: consumer.consumer_retry_attempts, - consumer_retry_delay: consumer.consumer_retry_delay, - consumer_runas: consumer.consumer_runas, - enabled: consumer.enabled, } this.editingProfilePendingConsumers.push(pending) } @@ -718,8 +789,8 @@ this.editingProfilePendingWatcherKwargs.splice(i, 1) } - ThisPage.methods.findOriginalConsumer = function(key) { - for (let consumer of this.editingProfile.consumers_data) { + ThisPage.methods.findConsumer = function(profileConsumers, key) { + for (const consumer of profileConsumers) { if (consumer.key == key) { return consumer } @@ -727,11 +798,15 @@ } ThisPage.methods.updateProfile = function() { - let row = this.editingProfile + const row = this.editingProfile - if (!row.key) { + const newRow = !row.key + let originalProfile = null + if (newRow) { row.consumers_data = [] this.profilesData.push(row) + } else { + originalProfile = this.findProfile(row) } row.key = this.editingProfileKey @@ -779,7 +854,8 @@ for (let pending of this.editingProfilePendingConsumers) { persistentConsumers.push(pending.key) if (pending.original_key) { - let consumer = this.findOriginalConsumer(pending.original_key) + const consumer = this.findConsumer(originalProfile.consumers_data, + pending.original_key) consumer.key = pending.key consumer.consumer_spec = pending.consumer_spec consumer.consumer_dbkey = pending.consumer_dbkey @@ -806,10 +882,31 @@ row.consumers_data.splice(i, 1) } + if (!newRow) { + + // nb. must explicitly update the original data row; + // otherwise (with vue3) it will remain stale and + // submitting the form will keep same settings! + // TODO: this probably means i am doing something + // sloppy, but at least this hack fixes for now. + const profile = this.findProfile(row) + for (const key of Object.keys(row)) { + profile[key] = row[key] + } + } + this.settingsNeedSaved = true this.editProfileShowDialog = false } + ThisPage.methods.findProfile = function(row) { + for (const profile of this.profilesData) { + if (profile.key == row.key) { + return profile + } + } + } + ThisPage.methods.deleteProfile = function(row) { if (confirm("Are you sure you want to delete the '" + row.key + "' profile?")) { let i = this.profilesData.indexOf(row) @@ -846,8 +943,10 @@ } ThisPage.methods.updateConsumer = function() { - let pending = this.editingConsumer - let isNew = !pending.key + const pending = this.findConsumer( + this.editingProfilePendingConsumers, + this.editingConsumer.key) + const isNew = !pending.key pending.key = this.editingConsumerKey pending.consumer_spec = this.editingConsumerSpec @@ -888,6 +987,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/datasync/status.mako b/tailbone/templates/datasync/status.mako index 0d0f5994..e14686f8 100644 --- a/tailbone/templates/datasync/status.mako +++ b/tailbone/templates/datasync/status.mako @@ -5,13 +5,6 @@ <%def name="content_title()"></%def> -<%def name="context_menu_items()"> - ${parent.context_menu_items()} - % if request.has_perm('datasync_changes.list'): - <li>${h.link_to("View DataSync Changes", url('datasyncchanges'))}</li> - % endif -</%def> - <%def name="page_content()"> % if expose_websockets and not supervisor_error: <b-notification type="is-warning" @@ -47,73 +40,84 @@ </div> </b-field> - <b-field label="Watcher Status"> - <b-table :data="watchers"> - <template slot-scope="props"> - <b-table-column field="key" - label="Watcher"> - {{ props.row.key }} - </b-table-column> - <b-table-column field="spec" - label="Spec"> - {{ props.row.spec }} - </b-table-column> - <b-table-column field="dbkey" - label="DB Key"> - {{ props.row.dbkey }} - </b-table-column> - <b-table-column field="delay" - label="Delay"> - {{ props.row.delay }} second(s) - </b-table-column> - <b-table-column field="lastrun" - label="Last Watched"> - <span v-html="props.row.lastrun"></span> - </b-table-column> - <b-table-column field="status" - label="Status" - :class="props.row.status == 'okay' ? 'has-background-success' : 'has-background-warning'"> - {{ props.row.status }} - </b-table-column> - </template> - </b-table> - </b-field> + <h3 class="is-size-3">Watcher Status</h3> - <b-field label="Consumer Status"> - <b-table :data="consumers"> - <template slot-scope="props"> - <b-table-column field="key" - label="Consumer"> - {{ props.row.key }} - </b-table-column> - <b-table-column field="spec" - label="Spec"> - {{ props.row.spec }} - </b-table-column> - <b-table-column field="dbkey" - label="DB Key"> - {{ props.row.dbkey }} - </b-table-column> - <b-table-column field="delay" - label="Delay"> - {{ props.row.delay }} second(s) - </b-table-column> - <b-table-column field="changes" - label="Pending Changes"> - {{ props.row.changes }} - </b-table-column> - <b-table-column field="status" - label="Status" - :class="props.row.status == 'okay' ? 'has-background-success' : 'has-background-warning'"> - {{ props.row.status }} - </b-table-column> - </template> - </b-table> - </b-field> + <${b}-table :data="watchers"> + <${b}-table-column field="key" + label="Watcher" + v-slot="props"> + {{ props.row.key }} + </${b}-table-column> + <${b}-table-column field="spec" + label="Spec" + v-slot="props"> + {{ props.row.spec }} + </${b}-table-column> + <${b}-table-column field="dbkey" + label="DB Key" + v-slot="props"> + {{ props.row.dbkey }} + </${b}-table-column> + <${b}-table-column field="delay" + label="Delay" + v-slot="props"> + {{ props.row.delay }} second(s) + </${b}-table-column> + <${b}-table-column field="lastrun" + label="Last Watched" + v-slot="props"> + <span v-html="props.row.lastrun"></span> + </${b}-table-column> + <${b}-table-column field="status" + label="Status" + v-slot="props"> + <span :class="props.row.status == 'okay' ? 'has-background-success' : 'has-background-warning'"> + {{ props.row.status }} + </span> + </${b}-table-column> + </${b}-table> + + <h3 class="is-size-3">Consumer Status</h3> + + <${b}-table :data="consumers"> + <${b}-table-column field="key" + label="Consumer" + v-slot="props"> + {{ props.row.key }} + </${b}-table-column> + <${b}-table-column field="spec" + label="Spec" + v-slot="props"> + {{ props.row.spec }} + </${b}-table-column> + <${b}-table-column field="dbkey" + label="DB Key" + v-slot="props"> + {{ props.row.dbkey }} + </${b}-table-column> + <${b}-table-column field="delay" + label="Delay" + v-slot="props"> + {{ props.row.delay }} second(s) + </${b}-table-column> + <${b}-table-column field="changes" + label="Pending Changes" + v-slot="props"> + {{ props.row.changes }} + </${b}-table-column> + <${b}-table-column field="status" + label="Status" + v-slot="props"> + <span :class="props.row.status == 'okay' ? 'has-background-success' : 'has-background-warning'"> + {{ props.row.status }} + </span> + </${b}-table-column> + </${b}-table> </%def> -<%def name="modify_this_page_vars()"> - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> ThisPageData.processInfo = ${json.dumps(process_info)|n} @@ -142,8 +146,8 @@ ThisPage.mounted = function() { ## TODO: should be a cleaner way to get this url? - let url = '${request.route_url('ws.datasync.status')}' - url = url.replace(/^https?:/, 'wss:') + let url = '${url('ws.datasync.status')}' + url = url.replace(/^http(s?):/, 'ws$1:') this.ws = new WebSocket(url) let that = this @@ -168,6 +172,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/deform/autocomplete_jquery.pt b/tailbone/templates/deform/autocomplete_jquery.pt index dd9a6084..7a15c7f0 100644 --- a/tailbone/templates/deform/autocomplete_jquery.pt +++ b/tailbone/templates/deform/autocomplete_jquery.pt @@ -3,109 +3,10 @@ oid oid|field.oid; field_display field_display; style style|field.widget.style; - url url|field.widget.service_url; - use_buefy use_buefy|0;" + url url|field.widget.service_url;" tal:omit-tag=""> - <div tal:condition="not use_buefy" - id="${oid}-container" - class="autocomplete-container"> - <input type="hidden" - name="${name}" - id="${oid}" - value="${cstruct}" /> - - <input type="text" - name="${oid}-textbox" - id="${oid}-textbox" - value="${field_display}" - class="autocomplete-textbox" - style="display: none;" /> - - <div id="${oid}-display" - class="autocomplete-display" - style="display: none;"> - - <span>${field_display or ''}</span> - <button type="button" id="${oid}-change" class="autocomplete-change">Change</button> - - </div> - - <script type="text/javascript"> - deform.addCallback( - '${oid}', - function (oid) { - - $('#' + oid + '-textbox').autocomplete(${options}); - - $('#' + oid + '-textbox').on('autocompleteselect', function (event, ui) { - $('#' + oid).val(ui.item.value); - $('#' + oid + '-display span:first').text(ui.item.label); - $('#' + oid + '-textbox').hide(); - $('#' + oid + '-display').show(); - $('#' + oid + '-textbox').trigger('autocompletevalueselected', - [ui.item.value, ui.item.label]); - return false; - }); - - $('#' + oid + '-change').click(function() { - $('#' + oid).val(''); - $('#' + oid + '-display').hide(); - with ($('#' + oid + '-textbox')) { - val(''); - show(); - focus(); - } - $('#' + oid + '-textbox').trigger('autocompletevaluecleared'); - }); - - } - ); - </script> - - <script tal:condition="cleared_callback" type="text/javascript"> - deform.addCallback( - '${oid}', - function (oid) { - $('#' + oid + '-textbox').on('autocompletevaluecleared', function() { - ${cleared_callback}(); - }); - } - ); - </script> - - <script tal:condition="selected_callback" type="text/javascript"> - deform.addCallback( - '${oid}', - function (oid) { - $('#' + oid + '-textbox').on('autocompletevalueselected', function(event, uuid, label) { - ${selected_callback}(uuid, label); - }); - } - ); - </script> - - <script tal:condition="cstruct" type="text/javascript"> - deform.addCallback( - '${oid}', - function (oid) { - $('#' + oid + '-display').show(); - } - ); - </script> - - <script tal:condition="not cstruct" type="text/javascript"> - deform.addCallback( - '${oid}', - function (oid) { - $('#' + oid + '-textbox').show(); - } - ); - </script> - </div> - - <div tal:condition="use_buefy" - tal:define="vmodel vmodel|'field_model_' + name;" + <div tal:define="vmodel vmodel|'field_model_' + name;" tal:omit-tag=""> <tailbone-autocomplete name="${name}" ref="${ref}" diff --git a/tailbone/templates/deform/cases_units.pt b/tailbone/templates/deform/cases_units.pt index db4a49e0..b30d1d63 100644 --- a/tailbone/templates/deform/cases_units.pt +++ b/tailbone/templates/deform/cases_units.pt @@ -2,38 +2,11 @@ <div tal:define="oid oid|field.oid; name name|field.name; css_class css_class|field.widget.css_class; - style style|field.widget.style; - use_buefy use_buefy|0;" + style style|field.widget.style;" i18n:domain="deform" tal:omit-tag=""> - <div tal:condition="not use_buefy" tal:omit-tag=""> - ${field.start_mapping()} - <div> - <input type="text" name="cases" value="${cases}" - tal:attributes="style style; - class string: form-control ${css_class or ''}; - cases_attributes|field.widget.cases_attributes|{};" - placeholder="cases" - autocomplete="off" - id="${oid}-cases"/> - Cases - </div> - <div> - <input type="text" name="units" value="${units}" - tal:attributes="class string: form-control ${css_class or ''}; - style style; - units_attributes|field.widget.units_attributes|{};" - placeholder="units" - autocomplete="off" - id="${oid}-units"/> - Units - </div> - ${field.end_mapping()} - </div> - - <div tal:condition="use_buefy" - tal:define="vmodel vmodel|'field_model_' + name;" + <div tal:define="vmodel vmodel|'field_model_' + name;" tal:omit-tag=""> ${field.start_mapping()} diff --git a/tailbone/templates/deform/checkbox.pt b/tailbone/templates/deform/checkbox.pt index b00ced03..408fa1cb 100644 --- a/tailbone/templates/deform/checkbox.pt +++ b/tailbone/templates/deform/checkbox.pt @@ -2,21 +2,10 @@ true_val true_val|field.widget.true_val; css_class css_class|field.widget.css_class; style style|field.widget.style; - oid oid|field.oid; - use_buefy use_buefy|0;" + oid oid|field.oid;" tal:omit-tag=""> - <div tal:condition="not use_buefy" class="checkbox"> - <input type="checkbox" - name="${name}" value="${true_val}" - id="${oid}" - tal:attributes="checked cstruct == true_val; - class css_class; - style style;" /> - </div> - - <div tal:condition="use_buefy" - tal:define="vmodel vmodel|'field_model_' + name;"> + <div tal:define="vmodel vmodel|'field_model_' + name;"> <b-checkbox name="${name}" v-model="${vmodel}" native-value="${true_val}"> diff --git a/tailbone/templates/deform/checked_password.pt b/tailbone/templates/deform/checked_password.pt index 43657045..2121f01d 100644 --- a/tailbone/templates/deform/checked_password.pt +++ b/tailbone/templates/deform/checked_password.pt @@ -1,42 +1,15 @@ <div i18n:domain="deform" tal:omit-tag="" tal:define="oid oid|field.oid; name name|field.name; + vmodel vmodel|'field_model_' + name; css_class css_class|field.widget.css_class; - style style|field.widget.style; - use_buefy use_buefy|0;"> + style style|field.widget.style;"> - <div tal:condition="not use_buefy" tal:omit-tag=""> - ${field.start_mapping()} - <div> - <input type="password" - name="${name}" - value="${field.widget.redisplay and cstruct or ''}" - tal:attributes="class string: form-control ${css_class or ''}; - style style; - attributes|field.widget.attributes|{};" - id="${oid}" - i18n:attributes="placeholder" - placeholder="Password"/> - </div> - <div> - <input type="password" - name="${name}-confirm" - value="${field.widget.redisplay and confirm or ''}" - tal:attributes="class string: form-control ${css_class or ''}; - style style; - confirm_attributes|field.widget.confirm_attributes|{};" - id="${oid}-confirm" - i18n:attributes="placeholder" - placeholder="Confirm Password"/> - </div> - ${field.end_mapping()} - </div> - - <div tal:condition="use_buefy"> + <div> ${field.start_mapping()} <b-input type="password" name="${name}" - value="${field.widget.redisplay and cstruct or ''}" + v-model="${vmodel}" tal:attributes="class string: form-control ${css_class or ''}; style style; attributes|field.widget.attributes|{};" @@ -46,7 +19,6 @@ </b-input> <b-input type="password" name="${name}-confirm" - value="${field.widget.redisplay and confirm or ''}" tal:attributes="class string: form-control ${css_class or ''}; style style; confirm_attributes|field.widget.confirm_attributes|{};" diff --git a/tailbone/templates/deform/date_jquery.pt b/tailbone/templates/deform/date_jquery.pt index 0539b99a..c55021b9 100644 --- a/tailbone/templates/deform/date_jquery.pt +++ b/tailbone/templates/deform/date_jquery.pt @@ -3,40 +3,10 @@ oid oid|field.oid; field_name field_name|field.name; style style|field.widget.style; - type_name type_name|field.widget.type_name; - use_buefy use_buefy|0;" + type_name type_name|field.widget.type_name;" tal:omit-tag=""> - <div tal:condition="not use_buefy" tal:omit-tag=""> - ${field.start_mapping()} - <input type="${type_name}" - name="date" - value="${cstruct}" - - tal:attributes="class string: ${css_class or ''} form-control; - style style" - id="${oid}"/> - ${field.end_mapping()} - <script type="text/javascript"> - deform.addCallback( - '${oid}', - function deform_cb(oid) { - $('#' + oid).datepicker(${options_json}); - } - ); - </script> - <script tal:condition="selected_callback" type="text/javascript"> - deform.addCallback( - '${oid}', - function (oid) { - $('#' + oid).datepicker('option', 'onSelect', ${selected_callback}); - } - ); - </script> - </div> - - <div tal:condition="use_buefy" - tal:define="vmodel vmodel|'field_model_' + field_name;"> + <div tal:define="vmodel vmodel|'field_model_' + field_name;"> ${field.start_mapping()} <tailbone-datepicker name="date" id="${oid}" diff --git a/tailbone/templates/deform/datetime_falafel.pt b/tailbone/templates/deform/datetime_falafel.pt new file mode 100644 index 00000000..17cfe6c3 --- /dev/null +++ b/tailbone/templates/deform/datetime_falafel.pt @@ -0,0 +1,23 @@ +<div tal:omit-tag="" + tal:define="name name|field.name; + vmodel vmodel|'field_model_' + name;"> + + <b-field grouped> + ${field.start_mapping()} + + <b-field label="Date"> + <tailbone-datepicker name="date" + v-model="${vmodel}.date"> + </tailbone-datepicker> + </b-field> + + <b-field label="Time"> + <tailbone-timepicker name="time" + v-model="${vmodel}.time"> + </tailbone-timepicker> + </b-field> + + ${field.end_mapping()} + </b-field> + +</div> diff --git a/tailbone/templates/deform/file_upload.pt b/tailbone/templates/deform/file_upload.pt index 3cb83a5d..af78eaf9 100644 --- a/tailbone/templates/deform/file_upload.pt +++ b/tailbone/templates/deform/file_upload.pt @@ -3,30 +3,13 @@ css_class css_class|field.widget.css_class; style style|field.widget.style; field_name field_name|field.name; - use_buefy use_buefy|0;"> + use_oruga use_oruga;"> - <div tal:condition="not use_buefy" tal:omit-tag=""> + <div tal:define="vmodel vmodel|'field_model_' + field_name;"> ${field.start_mapping()} - <input type="file" name="upload" id="${oid}" - tal:attributes="style style; - accept accept|field.widget.accept; - data-filename cstruct.get('filename'); - attributes|field.widget.attributes|{};"/> - <input tal:define="uid cstruct.get('uid')" - tal:condition="uid" - type="hidden" name="uid" value="${uid}"/> - ${field.end_mapping()} - <script type="text/javascript"> - deform.addCallback('${oid}', function (oid) { - $('#' + oid).upload(); - }); - </script> - </div> - <div tal:condition="use_buefy" - tal:define="vmodel vmodel|'field_model_' + field_name;"> - ${field.start_mapping()} - <b-field class="file"> + <b-field class="file" + tal:condition="not use_oruga"> <b-upload name="upload" v-model="${vmodel}"> <a class="button is-primary"> @@ -38,6 +21,23 @@ {{ ${vmodel}.name }} </span> </b-field> + + <o-field class="file" + tal:condition="use_oruga"> + <o-upload name="upload" + v-slot="{ onclick }" + v-model="${vmodel}"> + <o-button variant="primary" + @click="onclick"> + <o-icon icon="upload" /> + <span>Click to upload</span> + </o-button> + </o-upload> + <span class="file-name" v-if="${vmodel}"> + {{ ${vmodel}.name }} + </span> + </o-field> + ${field.end_mapping()} </div> diff --git a/tailbone/templates/deform/message_recipients_buefy.pt b/tailbone/templates/deform/message_recipients.pt similarity index 100% rename from tailbone/templates/deform/message_recipients_buefy.pt rename to tailbone/templates/deform/message_recipients.pt diff --git a/tailbone/templates/deform/multi_file_upload.pt b/tailbone/templates/deform/multi_file_upload.pt new file mode 100644 index 00000000..f94e59c8 --- /dev/null +++ b/tailbone/templates/deform/multi_file_upload.pt @@ -0,0 +1,7 @@ +<tal:block tal:define="field_name field_name|field.name; + vmodel vmodel|'field_model_' + field_name;"> + ${field.start_sequence()} + <multi-file-upload v-model="${vmodel}"> + </multi-file-upload> + ${field.end_sequence()} +</tal:block> diff --git a/tailbone/templates/deform/password.pt b/tailbone/templates/deform/password.pt index 9ad77d1b..b74d763a 100644 --- a/tailbone/templates/deform/password.pt +++ b/tailbone/templates/deform/password.pt @@ -3,25 +3,14 @@ oid oid|field.oid; mask mask|field.widget.mask; mask_placeholder mask_placeholder|field.widget.mask_placeholder; - style style|field.widget.style; - use_buefy use_buefy|0;" + style style|field.widget.style;" tal:omit-tag=""> - - <input tal:condition="not use_buefy" - type="password" - name="${name}" - value="${field.widget.redisplay and cstruct or ''}" - tal:attributes="style style; - class string: form-control ${css_class or ''}; - attributes|field.widget.attributes|{};" - id="${oid}" /> - - <div tal:condition="use_buefy" - tal:define="vmodel vmodel|'field_model_' + name;" + <div tal:define="vmodel vmodel|'field_model_' + name;" tal:omit-tag=""> <b-input name="${name}" v-model="${vmodel}" - type="password"> + type="password" + tal:attributes="attributes|field.widget.attributes|{};"> </b-input> </div> </span> diff --git a/tailbone/templates/deform/percentinput.pt b/tailbone/templates/deform/percentinput.pt index 40aa71f1..d76e5848 100644 --- a/tailbone/templates/deform/percentinput.pt +++ b/tailbone/templates/deform/percentinput.pt @@ -8,26 +8,7 @@ autocomplete autocomplete|field.widget.autocomplete|'off';" tal:omit-tag=""> - <div tal:condition="not use_buefy" tal:omit-tag=""> - <input type="text" name="${name}" value="${cstruct}" - tal:attributes="class string: form-control ${css_class or ''}; - style style; - autocomplete autocomplete; - " - id="${oid}"/> - % - <script tal:condition="mask" type="text/javascript"> - deform.addCallback( - '${oid}', - function (oid) { - $("#" + oid).mask("${mask}", - {placeholder:"${mask_placeholder}"}); - }); - </script> - </div> - - <div tal:condition="use_buefy" - tal:define="vmodel vmodel|'field_model_' + field_name;"> + <div tal:define="vmodel vmodel|'field_model_' + field_name;"> <!-- TODO: need to handle mask somehow? --> <b-input name="${field_name}" id="${oid}" diff --git a/tailbone/templates/deform/permissions.pt b/tailbone/templates/deform/permissions.pt index f5cbeef4..b32f36ea 100644 --- a/tailbone/templates/deform/permissions.pt +++ b/tailbone/templates/deform/permissions.pt @@ -1,41 +1,8 @@ <div tal:define="oid oid|field.oid; - true_val true_val|field.widget.true_val; - use_buefy use_buefy|0;" + true_val true_val|field.widget.true_val;" tal:omit-tag=""> - <div tal:condition="not use_buefy" - tal:omit-tag=""> - ${field.start_mapping()} - - <div class="permissions-outer"> - - <tal:loop tal:repeat="groupkey sorted(permissions, key=lambda k: permissions[k]['label'].lower())"> - <div tal:define="perms permissions[groupkey]['perms'];" - class="permissions-group"> - <p class="group-label">${permissions[groupkey]['label']}</p> - - <tal:loop tal:repeat="key sorted(perms, key=lambda p: perms[p]['label'].lower())"> - <div class="perm"> - <label> - <input type="checkbox" - name="${key}" - id="${oid}-${key}" - value="${true_val}" - tal:attributes="checked python:field.widget.get_checked_value(cstruct, key);" /> - ${perms[key]['label']} - </label> - </div> - </tal:loop> - - </div> - </tal:loop> - - </div> - - ${field.end_mapping()} - </div> - - <div tal:condition="use_buefy"> + <div> ${field.start_mapping()} <div class="level"> diff --git a/tailbone/templates/deform/select.pt b/tailbone/templates/deform/select.pt index 4295380b..b033a8e2 100644 --- a/tailbone/templates/deform/select.pt +++ b/tailbone/templates/deform/select.pt @@ -7,58 +7,10 @@ unicode unicode|str; optgroup_class optgroup_class|field.widget.optgroup_class; multiple multiple|field.widget.multiple; - use_buefy use_buefy|0; input_handler input_handler|'';" tal:omit-tag=""> - <div tal:condition="not use_buefy" tal:omit-tag=""> - <input type="hidden" name="__start__" value="${name}:sequence" - tal:condition="multiple" /> - <div class="select"> - <select tal:attributes=" - name name; - id oid; - class string: form-control ${css_class or ''}; - multiple multiple; - size size; - style style;"> - <tal:loop tal:repeat="item values"> - <optgroup tal:condition="isinstance(item, optgroup_class)" - tal:attributes="label item.label"> - <option tal:repeat="(value, description) item.options" - tal:attributes=" - selected python:field.widget.get_select_value(cstruct, value); - class css_class; - label field.widget.long_label_generator and description; - value value" - tal:content="field.widget.long_label_generator and field.widget.long_label_generator(item.label, description) or description"/> - </optgroup> - <option tal:condition="not isinstance(item, optgroup_class)" - tal:attributes=" - selected python:field.widget.get_select_value(cstruct, item[0]); - class css_class; - value item[0]">${item[1]}</option> - </tal:loop> - </select> - </div> - <input type="hidden" name="__end__" value="${name}:sequence" - tal:condition="multiple" /> - <script tal:condition="not multiple" type="text/javascript"> - deform.addCallback( - '${oid}', - function(oid) { - $('#' + oid).selectmenu(); - $('#' + oid).on('selectmenuopen', function(event, ui) { - show_all_options($(this)); - }); - } - ); - </script> - </div> - - <div tal:condition="use_buefy" - tal:define="vmodel vmodel|'field_model_' + name;" - > + <div tal:define="vmodel vmodel|'field_model_' + name;"> <input type="hidden" name="__start__" value="${name}:sequence" tal:condition="multiple" /> <b-select tal:attributes="name name; diff --git a/tailbone/templates/deform/select_dynamic.pt b/tailbone/templates/deform/select_dynamic.pt index a0ee1daf..712830d1 100644 --- a/tailbone/templates/deform/select_dynamic.pt +++ b/tailbone/templates/deform/select_dynamic.pt @@ -26,7 +26,7 @@ <option v-for="item in ${name}_options" tal:attributes=":key 'item.value'; :value 'item.value';"> - {{ item.label }} + <span v-html="item.label"></span> </option> </b-select> diff --git a/tailbone/templates/deform/textarea.pt b/tailbone/templates/deform/textarea.pt index 25583b4e..bb9b6c84 100644 --- a/tailbone/templates/deform/textarea.pt +++ b/tailbone/templates/deform/textarea.pt @@ -3,25 +3,14 @@ css_class css_class|field.widget.css_class; oid oid|field.oid; name name|field.name; - style style|field.widget.style; - use_buefy use_buefy|0;" + style style|field.widget.style;" tal:omit-tag=""> - <div tal:condition="not use_buefy" tal:omit-tag=""> - <textarea tal:attributes="rows rows; - cols cols; - class string: form-control ${css_class or ''}; - style style; - attributes|field.widget.attributes|{};" - id="${oid}" - name="${name}">${cstruct}</textarea> - </div> - - <div tal:condition="use_buefy" - tal:define="vmodel vmodel|'field_model_' + name;"> + <div tal:define="vmodel vmodel|'field_model_' + name;"> <b-input type="textarea" name="${name}" - v-model="${vmodel}"> + v-model="${vmodel}" + tal:attributes="attributes|field.widget.attributes|{};"> </b-input> </div> </div> diff --git a/tailbone/templates/deform/textinput.pt b/tailbone/templates/deform/textinput.pt index 52873cb7..47621654 100644 --- a/tailbone/templates/deform/textinput.pt +++ b/tailbone/templates/deform/textinput.pt @@ -4,29 +4,10 @@ mask mask|field.widget.mask; mask_placeholder mask_placeholder|field.widget.mask_placeholder; style style|field.widget.style; - use_buefy use_buefy|0; placeholder placeholder|getattr(field.widget, 'placeholder', ''); autocomplete autocomplete|getattr(field.widget, 'autocomplete', 'on');" tal:omit-tag=""> - <div tal:condition="not use_buefy" tal:omit-tag=""> - <input type="text" name="${name}" value="${cstruct}" - tal:attributes="class string: form-control ${css_class or ''}; - style style; - attributes|field.widget.attributes|{};" - autocomplete="${autocomplete}" - id="${oid}"/> - <script tal:condition="mask" type="text/javascript"> - deform.addCallback( - '${oid}', - function (oid) { - $("#" + oid).mask("${mask}", - {placeholder:"${mask_placeholder}"}); - }); - </script> - </div> - - <div tal:condition="use_buefy" - tal:define="vmodel vmodel|'field_model_' + name;" + <div tal:define="vmodel vmodel|'field_model_' + name;" tal:omit-tag=""> <b-input tal:attributes="name name; v-model vmodel; diff --git a/tailbone/templates/deform/time_falafel.pt b/tailbone/templates/deform/time_falafel.pt new file mode 100644 index 00000000..00ebc2f0 --- /dev/null +++ b/tailbone/templates/deform/time_falafel.pt @@ -0,0 +1,7 @@ +<div tal:omit-tag="" + tal:define="name name|field.name; + vmodel vmodel|'field_model_' + name;"> + <tailbone-timepicker name="${name}" + v-model="${vmodel}"> + </tailbone-timepicker> +</div> diff --git a/tailbone/templates/deform/time_jquery.pt b/tailbone/templates/deform/time_jquery.pt index 1575b3fa..8fa6cbe7 100644 --- a/tailbone/templates/deform/time_jquery.pt +++ b/tailbone/templates/deform/time_jquery.pt @@ -4,32 +4,10 @@ oid oid|field.oid; style style|field.widget.style|None; type_name type_name|field.widget.type_name; - field_name field_name|field.name; - use_buefy use_buefy|0;" + field_name field_name|field.name;" tal:omit-tag=""> - <div tal:condition="not use_buefy" tal:omit-tag=""> - ${field.start_mapping()} - <input type="${type_name}" - name="time" - value="${cstruct}" - tal:attributes="size size; - class string: ${css_class or ''} form-control; - style style" - id="${oid}"/> - ${field.end_mapping()} - <script type="text/javascript"> - deform.addCallback( - '${oid}', - function(oid) { - $('#' + oid).timepicker(${options_json}); - } - ); - </script> - </div> - - <div tal:condition="use_buefy" - tal:define="vmodel vmodel|'field_model_' + field_name;"> + <div tal:define="vmodel vmodel|'field_model_' + field_name;"> ${field.start_mapping()} <tailbone-timepicker name="time" id="${oid}" diff --git a/tailbone/templates/departments/view.mako b/tailbone/templates/departments/view.mako index 20c2a266..c5c39cbb 100644 --- a/tailbone/templates/departments/view.mako +++ b/tailbone/templates/departments/view.mako @@ -1,27 +1,9 @@ ## -*- coding: utf-8; -*- <%inherit file="/master/view.mako" /> -<%def name="page_content()"> - ${parent.page_content()} - % if not use_buefy: - <h2>Employees</h2> - - % if employees: - <p>The following employees are assigned to this department:</p> - ${employees.render_grid()|n} - % else: - <p>No employees are assigned to this department.</p> - % endif - % endif -</%def> - -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> - - ${form.component_studly}Data.employeesData = ${json.dumps(employees_data)|n} - +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> + ${form.vue_component}Data.employeesData = ${json.dumps(employees_data)|n} </script> </%def> - -${parent.body()} diff --git a/tailbone/templates/diff.mako b/tailbone/templates/diff.mako index 3e5ec99e..a78bd770 100644 --- a/tailbone/templates/diff.mako +++ b/tailbone/templates/diff.mako @@ -1,5 +1,5 @@ ## -*- coding: utf-8; -*- -<table class="diff dirty${' monospace' if diff.monospace else ''}"> +<table class="diff ${diff.nature} ${' monospace' if diff.monospace else ''}"> <thead> <tr> % for column in diff.columns: diff --git a/tailbone/templates/email-bounces/view.mako b/tailbone/templates/email-bounces/view.mako index d24f3a00..f8372c88 100644 --- a/tailbone/templates/email-bounces/view.mako +++ b/tailbone/templates/email-bounces/view.mako @@ -1,38 +1,8 @@ ## -*- coding: utf-8; -*- <%inherit file="/master/view.mako" /> -## TODO: this page still uses jQuery but should use Vue.js - -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - % if not use_buefy: - <script type="text/javascript"> - - function autosize_message(scrolldown) { - var msg = $('#message'); - var height = $(window).height() - msg.offset().top - 50; - msg.height(height); - if (scrolldown) { - msg.animate({scrollTop: msg.get(0).scrollHeight - height}, 250); - } - } - - $(function () { - autosize_message(true); - $('#message').focus(); - }); - - $(window).resize(function() { - autosize_message(false); - }); - - </script> - % endif -</%def> - <%def name="extra_styles()"> ${parent.extra_styles()} - % if use_buefy: <style type="text/css"> .email-message-body { border: 1px solid #000000; @@ -40,81 +10,46 @@ height: 500px; } </style> - % else: - <style type="text/css"> - #message { - border: 1px solid #000000; - height: 400px; - overflow: auto; - padding: 4px; - } - </style> - % endif </%def> <%def name="object_helpers()"> ${parent.object_helpers()} - % if use_buefy: - <nav class="panel"> - <p class="panel-heading">Processing</p> - <div class="panel-block"> - <div class="display: flex; flex-align: column;"> - % if bounce.processed: - <p class="block"> - This bounce was processed - ${h.pretty_datetime(request.rattail_config, bounce.processed)} - by ${bounce.processed_by} - </p> - % if master.has_perm('unprocess'): - <once-button type="is-warning" - tag="a" href="${url('emailbounces.unprocess', uuid=bounce.uuid)}" - text="Mark this bounce as UN-processed"> - </once-button> - % endif - % else: - <p class="block"> - This bounce has NOT yet been processed. - </p> - % if master.has_perm('process'): - <once-button type="is-primary" - tag="a" href="${url('emailbounces.process', uuid=bounce.uuid)}" - text="Mark this bounce as Processed"> - </once-button> - % endif + <nav class="panel"> + <p class="panel-heading">Processing</p> + <div class="panel-block"> + <div class="display: flex; flex-align: column;"> + % if bounce.processed: + <p class="block"> + This bounce was processed + ${h.pretty_datetime(request.rattail_config, bounce.processed)} + by ${bounce.processed_by} + </p> + % if master.has_perm('unprocess'): + <once-button type="is-warning" + tag="a" href="${url('emailbounces.unprocess', uuid=bounce.uuid)}" + text="Mark this bounce as UN-processed"> + </once-button> % endif - </div> - </div> - </nav> - % endif -</%def> - -<%def name="context_menu_items()"> - ${parent.context_menu_items()} - % if not use_buefy: - % if not bounce.processed and request.has_perm('emailbounces.process'): - <li>${h.link_to("Mark this Email Bounce as Processed", url('emailbounces.process', uuid=bounce.uuid))}</li> - % elif bounce.processed and request.has_perm('emailbounces.unprocess'): - <li>${h.link_to("Mark this Email Bounce as UN-processed", url('emailbounces.unprocess', uuid=bounce.uuid))}</li> - % endif - % endif -</%def> - -<%def name="page_content()"> - ${parent.page_content()} - % if not use_buefy: - <pre id="message"> - ${message} - </pre> - % endif + % else: + <p class="block"> + This bounce has NOT yet been processed. + </p> + % if master.has_perm('process'): + <once-button type="is-primary" + tag="a" href="${url('emailbounces.process', uuid=bounce.uuid)}" + text="Mark this bounce as Processed"> + </once-button> + % endif + % endif + </div> + </div> + </nav> </%def> <%def name="render_this_page()"> ${parent.render_this_page()} - % if use_buefy: - <pre class="email-message-body"> - ${message} - </pre> - % endif + <pre class="email-message-body">${message}</pre> </%def> + ${parent.body()} diff --git a/tailbone/templates/employees/configure.mako b/tailbone/templates/employees/configure.mako new file mode 100644 index 00000000..dd01a006 --- /dev/null +++ b/tailbone/templates/employees/configure.mako @@ -0,0 +1,22 @@ +## -*- coding: utf-8; -*- +<%inherit file="/configure.mako" /> + +<%def name="form_content()"> + + <h3 class="block is-size-3">General</h3> + <div class="block" style="padding-left: 2rem;"> + + <b-field message="If set, grid links are to Employee tab of Profile view."> + <b-checkbox name="rattail.employees.straight_to_profile" + v-model="simpleSettings['rattail.employees.straight_to_profile']" + native-value="true" + @input="settingsNeedSaved = true"> + Link directly to Profile when applicable + </b-checkbox> + </b-field> + + </div> +</%def> + + +${parent.body()} diff --git a/tailbone/templates/feedback.mako b/tailbone/templates/feedback.mako deleted file mode 100644 index e82a6068..00000000 --- a/tailbone/templates/feedback.mako +++ /dev/null @@ -1,57 +0,0 @@ -## -*- coding: utf-8 -*- -<%inherit file="/base.mako" /> - -<%def name="title()">User Feedback</%def> - -<%def name="head_tags()"> - ${parent.head_tags()} - <style type="text/css"> - .form p { - margin: 1em 0; - } - div.field-wrapper div.field input[type=text] { - width: 25em; - } - div.field-wrapper div.field textarea { - width: 50em; - } - div.buttons { - margin-left: 15em; - } - </style> -</%def> - -<div class="form"> - ${form.begin()} - ${form.csrf_token()} - ${form.hidden('user', value=request.user.uuid if request.user else None)} - - <p> - Questions, suggestions, comments, complaints, etc. regarding this website - are welcome and may be submitted below. - </p> - <p> - Messages will be delivered to the local IT department, and possibly others. - </p> - -## % if error: -## <div class="error">${error}</div> -## % endif - - % if request.user: - ${form.field_div('user_name', form.hidden('user_name', value=six.text_type(request.user)) + six.text_type(request.user), label="Your Name")} - % else: - ${form.field_div('user_name', form.text('user_name'), label="Your Name")} - % endif - - ${form.field_div('referrer', form.hidden('referrer', value=request.get_referrer()) + request.get_referrer(), label="Referring URL")} - - ${form.field_div('message', form.textarea('message', rows=15))} - - <div class="buttons"> - ${form.submit('send', "Send Message")} - ${h.link_to("Cancel", request.get_referrer(), class_='button')} - </div> - - ${form.end()} -</div> diff --git a/tailbone/templates/feedback_dialog.mako b/tailbone/templates/feedback_dialog.mako deleted file mode 100644 index 2892a688..00000000 --- a/tailbone/templates/feedback_dialog.mako +++ /dev/null @@ -1,39 +0,0 @@ -## -*- coding: utf-8; -*- - -<%def name="feedback_dialog()"> - <div id="feedback-dialog" style="display: none;"> - ${h.form(url('feedback'))} - ${h.csrf_token(request)} - ${h.hidden('user', value=request.user.uuid if request.user else None)} - - <p> - Questions, suggestions, comments, complaints, etc. <span class="red">regarding this website</span> - are welcome and may be submitted below. - </p> - - <div class="field-wrapper referrer"> - <label for="referrer">Referring URL</label> - <div class="field"></div> - </div> - - % if request.user: - ${h.hidden('user_name', value=six.text_type(request.user))} - % else: - <div class="field-wrapper"> - <label for="user_name">Your Name</label> - <div class="field"> - ${h.text('user_name')} - </div> - </div> - % endif - - <div class="field-wrapper"> - <label for="referrer">Message</label> - <div class="field"> - ${h.textarea('message', cols=45, rows=15)} - </div> - </div> - - ${h.end_form()} - </div> -</%def> diff --git a/tailbone/templates/feedback_dialog_buefy.mako b/tailbone/templates/feedback_dialog_buefy.mako deleted file mode 100644 index 7507bd91..00000000 --- a/tailbone/templates/feedback_dialog_buefy.mako +++ /dev/null @@ -1,88 +0,0 @@ -## -*- coding: utf-8; -*- - -<%def name="feedback_dialog()"> - <script type="text/x-template" id="feedback-template"> - <div> - - <div class="level-item"> - <b-button type="is-primary" - @click="showFeedback()" - icon-pack="fas" - icon-left="fas fa-comment"> - Feedback - </b-button> - </div> - - <b-modal has-modal-card - :active.sync="showDialog"> - <div class="modal-card"> - - <header class="modal-card-head"> - <p class="modal-card-title">User Feedback</p> - </header> - - <section class="modal-card-body"> - <p> - Questions, suggestions, comments, complaints, etc. - <span class="red">regarding this website</span> are - welcome and may be submitted below. - </p> - - <b-field label="User Name"> - <b-input v-model="userName" - % if request.user: - disabled - % endif - > - </b-input> - </b-field> - - <b-field label="Referring URL"> - <b-input - ## :value="referrer" - v-model="referrer" - disabled="true"> - </b-input> - </b-field> - - <b-field label="Message"> - <b-input type="textarea" - v-model="message" - ref="textarea"> - </b-input> - </b-field> - - </section> - - <footer class="modal-card-foot"> - <b-button @click="showDialog = false"> - Cancel - </b-button> - <once-button type="is-primary" - @click="sendFeedback()" - :disabled="!message.trim()" - text="Send Message"> - </once-button> - </footer> - </div> - </b-modal> - - </div> - </script> - - <script type="text/javascript"> - - FeedbackFormData.csrftoken = ${json.dumps(request.session.get_csrf_token() or request.session.new_csrf_token())|n} - FeedbackFormData.referrer = location.href - - % if request.user: - FeedbackFormData.userUUID = ${json.dumps(request.user.uuid)|n} - FeedbackFormData.userName = ${json.dumps(six.text_type(request.user))|n} - % endif - - FeedbackForm.data = function() { return FeedbackFormData } - - Vue.component('feedback-form', FeedbackForm) - - </script> -</%def> diff --git a/tailbone/templates/form.mako b/tailbone/templates/form.mako index 11d4d6ae..e3a4d5dc 100644 --- a/tailbone/templates/form.mako +++ b/tailbone/templates/form.mako @@ -5,25 +5,64 @@ <%def name="render_form_buttons()"></%def> -<%def name="render_form()"> - ${form.render(buttons=capture(self.render_form_buttons))|n} +<%def name="render_form_template()"> + ${form.render_vue_template(buttons=capture(self.render_form_buttons))|n} </%def> -<%def name="render_buefy_form()"> +<%def name="render_form()"> <div class="form"> - <${form.component}></${form.component}> + ${form.render_vue_tag()} </div> </%def> <%def name="page_content()"> - <div class="form-wrapper"> - % if use_buefy: + % if main_form_collapsible: + <${b}-collapse class="panel" + % if request.use_oruga: + v-model:open="mainFormPanelOpen" + % else: + :open.sync="mainFormPanelOpen" + % endif + > + <template #trigger="props"> + <div class="panel-heading" + role="button" + style="cursor: pointer;"> + + ## TODO: for some reason buefy will "reuse" the icon + ## element in such a way that its display does not + ## refresh. so to work around that, we use different + ## structure for the two icons, so buefy is forced to + ## re-draw + + <b-icon v-if="props.open" + pack="fas" + icon="caret-down"> + </b-icon> + + <span v-if="!props.open"> + <b-icon pack="fas" + icon="caret-right"> + </b-icon> + </span> + + + <strong>${main_form_title}</strong> + </div> + </template> + <div class="panel-block"> + <div class="form-wrapper"> + <br /> + ${self.render_form()} + </div> + </div> + </${b}-collapse> + % else: + <div class="form-wrapper"> <br /> - ${self.render_buefy_form()} - % else: ${self.render_form()} - % endif - </div> + </div> + % endif </%def> <%def name="render_this_page()"> @@ -51,25 +90,25 @@ <%def name="before_object_helpers()"></%def> -<%def name="render_this_page_template()"> - % if form is not Underined: - ${self.render_form()} +<%def name="render_vue_templates()"> + ${parent.render_vue_templates()} + % if form is not Undefined: + ${self.render_form_template()} % endif - ${parent.render_this_page_template()} </%def> -<%def name="finalize_this_page_vars()"> - ${parent.finalize_this_page_vars()} - % if form is not Undefined: - <script type="text/javascript"> - - ${form.component_studly}.data = function() { return ${form.component_studly}Data } - - Vue.component('${form.component}', ${form.component_studly}) - +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + % if main_form_collapsible: + <script> + ThisPageData.mainFormPanelOpen = ${'false' if main_form_autocollapse else 'true'} </script> % endif </%def> - -${parent.body()} +<%def name="make_vue_components()"> + ${parent.make_vue_components()} + % if form is not Undefined: + ${form.render_vue_finalize()} + % endif +</%def> diff --git a/tailbone/templates/formposter.mako b/tailbone/templates/formposter.mako index 885ac6c2..d566a467 100644 --- a/tailbone/templates/formposter.mako +++ b/tailbone/templates/formposter.mako @@ -3,12 +3,43 @@ <%def name="declare_formposter_mixin()"> <script type="text/javascript"> - let FormPosterMixin = { + let SimpleRequestMixin = { methods: { - submitForm(action, params, success, failure) { + simpleGET(url, params, success, failure) { - let csrftoken = ${json.dumps(request.session.get_csrf_token() or request.session.new_csrf_token())|n} + this.$http.get(url, {params: params}).then(response => { + + if (response.data.error) { + this.$buefy.toast.open({ + message: `Request failed: ${'$'}{response.data.error}`, + type: 'is-danger', + duration: 4000, // 4 seconds + }) + if (failure) { + failure(response) + } + + } else { + success(response) + } + + }, response => { + this.$buefy.toast.open({ + message: "Request failed: (unknown server error)", + type: 'is-danger', + duration: 4000, // 4 seconds + }) + if (failure) { + failure(response) + } + }) + + }, + + simplePOST(action, params, success, failure) { + + let csrftoken = ${json.dumps(h.get_csrf_token(request))|n} let headers = { '${csrf_header_name}': csrftoken, @@ -16,10 +47,7 @@ this.$http.post(action, params, {headers: headers}).then(response => { - if (response.data.ok) { - success(response) - - } else { + if (response.data.error) { this.$buefy.toast.open({ message: "Submit failed: " + (response.data.error || "(unknown error)"), @@ -29,6 +57,9 @@ if (failure) { failure(response) } + + } else { + success(response) } }, response => { @@ -45,5 +76,9 @@ }, } + // TODO: deprecate / remove + SimpleRequestMixin.methods.submitForm = SimpleRequestMixin.methods.simplePOST + let FormPosterMixin = SimpleRequestMixin + </script> </%def> diff --git a/tailbone/templates/forms/deform.mako b/tailbone/templates/forms/deform.mako index ede55f12..2100b460 100644 --- a/tailbone/templates/forms/deform.mako +++ b/tailbone/templates/forms/deform.mako @@ -1,99 +1,211 @@ ## -*- coding: utf-8; -*- -% if not readonly: -<% _focus_rendered = False %> -${h.form(form.action_url, id=dform.formid, method='post', enctype='multipart/form-data', **form_kwargs)} -${h.csrf_token(request)} -% endif +<% request.register_component(form.vue_tagname, form.vue_component) %> -% 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 +<script type="text/x-template" id="${form.vue_tagname}-template"> -% for field in form.fields: + <div> + % if not form.readonly: + ${h.form(form.action_url, id=dform.formid, method='post', enctype='multipart/form-data', **(form_kwargs or {}))} + ${h.csrf_token(request)} + % endif - ## % if readonly or field.name in readonly_fields: - % if readonly: - ${render_field_readonly(field)|n} - % elif field not in dform and field in form.readonly_fields: - ${render_field_readonly(field)|n} - % elif field in dform: - <% field = dform[field] %> - - % if form.field_visible(field.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} + <section> + % if form_body is not Undefined and form_body: + ${form_body|n} + % elif getattr(form, 'grouping', None): + % for group in form.grouping: + <nav class="panel"> + <p class="panel-heading">${group}</p> + <div class="panel-block"> + <div> + % for field in form.grouping[group]: + ${form.render_field_complete(field)} + % endfor </div> </div> - % if form.has_helptext(field.name): - <span class="instructions">${form.render_helptext(field.name)}</span> - % endif - </div> - - ## % if not _focus_rendered and (fieldset.focus is True or fieldset.focus is field): - % if not readonly and not _focus_rendered: - ## % if not field.is_readonly() and getattr(field.renderer, 'needs_focus', True): - % if not field.widget.readonly: - <script type="text/javascript"> - $(function() { - ## % if hasattr(field.renderer, 'focus_name'): - ## $('#${field.renderer.focus_name}').focus(); - ## % else: - ## $('#${field.renderer.name}').focus(); - ## % endif - $('#${field.oid}').focus(); - }); - </script> - <% _focus_rendered = True %> - % endif - % endif - - % else: - ## hidden field - ${field.serialize()|n} - % endif - + </nav> + % endfor + % else: + % for fieldname in form.fields: + ${form.render_vue_field(fieldname, session=session)} + % endfor % endif + </section> -% endfor + % if buttons: + <br /> + ${buttons|n} + % elif not form.readonly and (buttons is Undefined or (buttons is not None and buttons is not False)): + <br /> + <div class="buttons"> + % if getattr(form, 'show_cancel', True): + % if form.auto_disable_cancel: + <once-button tag="a" href="${form.cancel_url or request.get_referrer()}" + text="Cancel"> + </once-button> + % else: + <b-button tag="a" href="${form.cancel_url or request.get_referrer()}"> + Cancel + </b-button> + % endif + % endif + % if getattr(form, 'show_reset', False): + <input type="reset" value="Reset" class="button" /> + % endif + ## TODO: deprecate / remove the latter option here + % if getattr(form, 'auto_disable_submit', False) or form.auto_disable_save or form.auto_disable: + <b-button type="is-primary" + native-type="submit" + :disabled="${form.vue_component}Submitting" + icon-pack="fas" + icon-left="${form.button_icon_submit}"> + {{ ${form.vue_component}Submitting ? "Working, please wait..." : "${form.button_label_submit}" }} + </b-button> + % else: + <b-button type="is-primary" + native-type="submit" + icon-pack="fas" + icon-left="save"> + ${form.button_label_submit} + </b-button> + % endif + </div> + % endif -% if buttons: - ${buttons|n} -% elif not readonly and (buttons is Undefined or (buttons is not None and buttons is not False)): - <div class="buttons"> - ## ${h.submit('create', form.create_label if form.creating else form.update_label)} - ${h.submit('save', getattr(form, 'submit_label', getattr(form, 'save_label', "Submit")), class_='button is-primary')} -## % if form.creating and form.allow_successive_creates: -## ${h.submit('create_and_continue', form.successive_create_label)} -## % endif - % if getattr(form, 'show_reset', False): - <input type="reset" value="Reset" class="button" /> + % if not form.readonly: + ${h.end_form()} + % endif + + % if can_edit_help: + <b-modal has-modal-card + :active.sync="configureFieldShowDialog"> + <div class="modal-card"> + + <header class="modal-card-head"> + <p class="modal-card-title">Field: {{ configureFieldName }}</p> + </header> + + <section class="modal-card-body"> + + <b-field label="Label"> + <b-input v-model="configureFieldLabel" disabled></b-input> + </b-field> + + <b-field label="Help Text (Markdown)"> + <b-input v-model="configureFieldMarkdown" + type="textarea" rows="8" + ref="configureFieldMarkdown"> + </b-input> + </b-field> + + </section> + + <footer class="modal-card-foot"> + <b-button @click="configureFieldShowDialog = false"> + Cancel + </b-button> + <b-button type="is-primary" + @click="configureFieldSave()" + :disabled="configureFieldSaving" + icon-pack="fas" + icon-left="save"> + {{ configureFieldSaving ? "Working, please wait..." : "Save" }} + </b-button> + </footer> + </div> + </b-modal> + % endif + + </div> +</script> + +<script type="text/javascript"> + + let ${form.vue_component} = { + template: '#${form.vue_tagname}-template', + mixins: [FormPosterMixin], + components: {}, + props: { + % if can_edit_help: + configureFieldsHelp: Boolean, + % endif + }, + watch: {}, + computed: {}, + methods: { + + ## TODO: deprecate / remove the latter option here + % if getattr(form, 'auto_disable_submit', False) or form.auto_disable_save or form.auto_disable: + submit${form.vue_component}() { + this.${form.vue_component}Submitting = true + }, + % endif + + % if can_edit_help: + + configureFieldInit(fieldname) { + this.configureFieldName = fieldname + this.configureFieldLabel = this.fieldLabels[fieldname] + this.configureFieldMarkdown = this.fieldMarkdowns[fieldname] + this.configureFieldShowDialog = true + this.$nextTick(() => { + this.$refs.configureFieldMarkdown.focus() + }) + }, + + configureFieldSave() { + this.configureFieldSaving = true + let url = '${edit_help_url}' + let params = { + field_name: this.configureFieldName, + markdown_text: this.configureFieldMarkdown, + } + this.submitForm(url, params, response => { + this.configureFieldShowDialog = false + this.$buefy.toast.open({ + message: "Info was saved; please refresh page to see changes.", + type: 'is-info', + duration: 4000, // 4 seconds + }) + this.configureFieldSaving = false + }, response => { + this.configureFieldSaving = false + }) + }, + % endif + } + } + + let ${form.vue_component}Data = { + + ## TODO: should find a better way to handle CSRF token + csrftoken: ${json.dumps(h.get_csrf_token(request))|n}, + + % if can_edit_help: + fieldLabels: ${json.dumps(field_labels)|n}, + fieldMarkdowns: ${json.dumps(field_markdowns)|n}, + configureFieldShowDialog: false, + configureFieldSaving: false, + configureFieldName: null, + configureFieldLabel: null, + configureFieldMarkdown: null, % endif - % if getattr(form, 'show_cancel', True): - ${h.link_to("Cancel", form.cancel_url, class_='cancel button{}'.format(' autodisable' if form.auto_disable_cancel else ''))} - % endif - </div> -% endif -% if not readonly: -${h.end_form()} -% endif + ## TODO: ugh, this seems pretty hacky. need to declare some data models + ## for various field components to bind to... + % if not form.readonly: + % for field in form.fields: + % if field in dform: + field_model_${field}: ${json.dumps(form.get_vue_field_value(field))|n}, + % endif + % endfor + % endif + + ## TODO: deprecate / remove the latter option here + % if getattr(form, 'auto_disable_submit', False) or form.auto_disable_save or form.auto_disable: + ${form.vue_component}Submitting: false, + % endif + } + +</script> diff --git a/tailbone/templates/forms/deform_buefy.mako b/tailbone/templates/forms/deform_buefy.mako deleted file mode 100644 index c387d965..00000000 --- a/tailbone/templates/forms/deform_buefy.mako +++ /dev/null @@ -1,116 +0,0 @@ -## -*- coding: utf-8; -*- - -<script type="text/x-template" id="${form.component}-template"> - - <div> - % if not form.readonly: - ${h.form(form.action_url, id=dform.formid, method='post', enctype='multipart/form-data', **form_kwargs)} - ${h.csrf_token(request)} - % endif - - <section> - % if form_body is not Undefined and form_body: - ${form_body|n} - % else: - % for field in form.fields: - % if form.readonly or (field not in dform and field in form.readonly_fields): - <b-field horizontal - label="${form.get_label(field)}"> - ${form.render_field_value(field) or h.HTML.tag('span')} - </b-field> - - % elif field in dform: - ${form.render_buefy_field(field)} - % endif - - % endfor - % endif - </section> - - % if buttons: - <br /> - ${buttons|n} - % elif not form.readonly and (buttons is Undefined or (buttons is not None and buttons is not False)): - <br /> - <div class="buttons"> - % if getattr(form, 'show_cancel', True): - % if form.auto_disable_cancel: - <once-button tag="a" href="${form.cancel_url or request.get_referrer()}" - text="Cancel"> - </once-button> - % else: - <b-button tag="a" href="${form.cancel_url or request.get_referrer()}"> - Cancel - </b-button> - % endif - % endif - % if getattr(form, 'show_reset', False): - <input type="reset" value="Reset" class="button" /> - % endif - ## TODO: deprecate / remove the latter option here - % if form.auto_disable_save or form.auto_disable: - <b-button type="is-primary" - native-type="submit" - :disabled="${form.component_studly}Submitting"> - {{ ${form.component_studly}ButtonText }} - </b-button> - % else: - <b-button type="is-primary" - native-type="submit"> - ${getattr(form, 'submit_label', getattr(form, 'save_label', "Submit"))} - </b-button> - % endif - </div> - % endif - - % if not form.readonly: - ${h.end_form()} - % endif - </div> -</script> - -<script type="text/javascript"> - - let ${form.component_studly} = { - template: '#${form.component}-template', - mixins: [FormPosterMixin], - components: {}, - props: {}, - watch: {}, - computed: {}, - methods: { - - ## TODO: deprecate / remove the latter option here - % if form.auto_disable_save or form.auto_disable: - submit${form.component_studly}() { - this.${form.component_studly}Submitting = true - this.${form.component_studly}ButtonText = "Working, please wait..." - } - % endif - } - } - - let ${form.component_studly}Data = { - - ## TODO: should find a better way to handle CSRF token - csrftoken: ${json.dumps(request.session.get_csrf_token() or request.session.new_csrf_token())|n}, - - ## TODO: ugh, this seems pretty hacky. need to declare some data models - ## for various field components to bind to... - % if not form.readonly: - % for field in form.fields: - % if field in dform: - <% field = dform[field] %> - field_model_${field.name}: ${form.get_vuejs_model_value(field)|n}, - % endif - % endfor - % endif - - ## TODO: deprecate / remove the latter option here - % if form.auto_disable_save or form.auto_disable: - ${form.component_studly}Submitting: false, - ${form.component_studly}ButtonText: ${json.dumps(getattr(form, 'submit_label', getattr(form, 'save_label', "Submit")))|n}, - % endif - } - -</script> diff --git a/tailbone/templates/forms/form.mako b/tailbone/templates/forms/form.mako deleted file mode 100644 index 71e28817..00000000 --- a/tailbone/templates/forms/form.mako +++ /dev/null @@ -1,8 +0,0 @@ -## -*- coding: utf-8; -*- -% if form.use_buefy: - ${form.render_deform(buttons=buttons)|n} -% else: - <div class="form"> - ${form.render_deform(buttons=buttons)|n} - </div> -% endif diff --git a/tailbone/templates/forms/form_readonly.mako b/tailbone/templates/forms/form_readonly.mako deleted file mode 100644 index 306282e9..00000000 --- a/tailbone/templates/forms/form_readonly.mako +++ /dev/null @@ -1,8 +0,0 @@ -## -*- coding: utf-8; -*- - -<div class="form"> - ${form.render_deform(readonly=True)|n} - % if buttons: - ${buttons|n} - % endif -</div><!-- form --> diff --git a/tailbone/templates/forms/util.mako b/tailbone/templates/forms/util.mako deleted file mode 100644 index 22e7f918..00000000 --- a/tailbone/templates/forms/util.mako +++ /dev/null @@ -1,7 +0,0 @@ -## -*- coding: utf-8; -*- - -## TODO: deprecate / remove this -## (tried to add deprecation warning here but it didn't seem to work) -<%def name="render_buefy_field(field, bfield_kwargs={})"> - ${form.render_buefy_field(field.name, bfield_attrs=bfield_kwargs)} -</%def> diff --git a/tailbone/templates/forms/vue_template.mako b/tailbone/templates/forms/vue_template.mako new file mode 100644 index 00000000..ac096f67 --- /dev/null +++ b/tailbone/templates/forms/vue_template.mako @@ -0,0 +1,3 @@ +## -*- coding: utf-8; -*- +<%inherit file="/forms/deform.mako" /> +${parent.body()} diff --git a/tailbone/templates/generate_feature.mako b/tailbone/templates/generate_feature.mako index acd1db2f..0f2a9f7b 100644 --- a/tailbone/templates/generate_feature.mako +++ b/tailbone/templates/generate_feature.mako @@ -5,21 +5,6 @@ <%def name="content_title()"></%def> -<%def name="extra_styles()"> - ${parent.extra_styles()} - <style type="text/css"> - - .content.result p { - margin-bottom: 1rem; - } - - .content.result .codehilite { - margin-bottom: 2rem; - } - - </style> -</%def> - <%def name="page_content()"> <b-field horizontal label="App Prefix" @@ -102,7 +87,7 @@ <div class="level-item"> <b-button type="is-primary" icon-pack="fas" - icon-left="fas fa-plus" + icon-left="plus" @click="addColumn()"> New Column </b-button> @@ -112,7 +97,7 @@ <div class="level-item"> <b-button type="is-danger" icon-pack="fas" - icon-left="fas fa-trash" + icon-left="trash" @click="new_table.columns = []" :disabled="!new_table.columns.length"> Delete All @@ -121,47 +106,68 @@ </div> </div> - <b-table + <${b}-table :data="new_table.columns"> - <template slot-scope="props"> - <b-table-column field="name" label="Name"> - {{ props.row.name }} - </b-table-column> + <${b}-table-column field="name" + label="Name" + v-slot="props"> + {{ props.row.name }} + </${b}-table-column> - <b-table-column field="data_type" label="Data Type"> - {{ props.row.data_type }} - </b-table-column> + <${b}-table-column field="data_type" + label="Data Type" + v-slot="props"> + {{ props.row.data_type }} + </${b}-table-column> - <b-table-column field="nullable" label="Nullable"> - {{ props.row.nullable }} - </b-table-column> + <${b}-table-column field="nullable" + label="Nullable" + v-slot="props"> + {{ props.row.nullable }} + </${b}-table-column> - <b-table-column field="description" label="Description"> - {{ props.row.description }} - </b-table-column> + <${b}-table-column field="description" + label="Description" + v-slot="props"> + {{ props.row.description }} + </${b}-table-column> - <b-table-column field="actions" label="Actions"> - <a href="#" class="grid-action" - @click.prevent="editColumnRow(props.row)"> - <i class="fas fa-edit"></i> - Edit - </a> - + <${b}-table-column field="actions" + label="Actions" + v-slot="props"> + <a href="#" class="grid-action" + @click.prevent="editColumnRow(props)"> + % if request.use_oruga: + <o-icon icon="edit" /> + % else: + <i class="fas fa-edit"></i> + % endif + Edit + </a> + - <a href="#" class="grid-action has-text-danger" - @click.prevent="deleteColumn(props.index)"> - <i class="fas fa-trash"></i> - Delete - </a> - - </b-table-column> + <a href="#" class="grid-action has-text-danger" + @click.prevent="deleteColumn(props.index)"> + % if request.use_oruga: + <o-icon icon="trash" /> + % else: + <i class="fas fa-trash"></i> + % endif + Delete + </a> + + </${b}-table-column> - </template> - </b-table> + </${b}-table> - <b-modal has-modal-card - :active.sync="showingEditColumn"> + <${b}-modal has-modal-card + % if request.use_oruga: + v-model:active="showingEditColumn" + % else: + :active.sync="showingEditColumn" + % endif + > <div class="modal-card"> <header class="modal-card-head"> @@ -171,11 +177,13 @@ <section class="modal-card-body"> <b-field label="Name"> - <b-input v-model="editingColumnName"></b-input> + <b-input v-model="editingColumnName" + expanded /> </b-field> <b-field label="Data Type"> - <b-input v-model="editingColumnDataType"></b-input> + <b-input v-model="editingColumnDataType" + expanded /> </b-field> <b-field label="Nullable"> @@ -186,7 +194,8 @@ </b-field> <b-field label="Description"> - <b-input v-model="editingColumnDescription"></b-input> + <b-input v-model="editingColumnDescription" + expanded /> </b-field> </section> @@ -201,7 +210,7 @@ </b-button> </footer> </div> - </b-modal> + </${b}-modal> </div> </b-field> @@ -261,15 +270,15 @@ </p> </header> <div class="card-content"> - <div class="content result">${rendered_result or ""|n}</div> + <div class="content result rendered-markdown">${rendered_result or ""|n}</div> </div> </div> </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> ThisPageData.featureType = ${json.dumps(feature_type)|n} ThisPageData.resultGenerated = ${json.dumps(bool(result))|n} @@ -287,7 +296,7 @@ % endfor } - % for key, form in six.iteritems(feature_forms): + % for key, form in feature_forms.items(): <% safekey = key.replace('-', '_') %> ThisPageData.${safekey} = { <% dform = feature_forms[key].make_deform_form() %> @@ -322,6 +331,7 @@ ThisPageData.showingEditColumn = false ThisPageData.editingColumn = null + ThisPageData.editingColumnIndex = null ThisPageData.editingColumnName = null ThisPageData.editingColumnDataType = null ThisPageData.editingColumnNullable = null @@ -329,6 +339,7 @@ ThisPage.methods.addColumn = function(column) { this.editingColumn = null + this.editingColumnIndex = null this.editingColumnName = null this.editingColumnDataType = null this.editingColumnNullable = true @@ -336,8 +347,10 @@ this.showingEditColumn = true } - ThisPage.methods.editColumnRow = function(column) { + ThisPage.methods.editColumnRow = function(props) { + const column = props.row this.editingColumn = column + this.editingColumnIndex = props.index this.editingColumnName = column.name this.editingColumnDataType = column.data_type this.editingColumnNullable = column.nullable @@ -347,7 +360,7 @@ ThisPage.methods.saveColumn = function() { if (this.editingColumn) { - column = this.editingColumn + column = this.new_table.columns[this.editingColumnIndex] } else { column = {} this.new_table.columns.push(column) @@ -372,6 +385,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/generate_project.mako b/tailbone/templates/generate_project.mako deleted file mode 100644 index 72caa83c..00000000 --- a/tailbone/templates/generate_project.mako +++ /dev/null @@ -1,461 +0,0 @@ -## -*- coding: utf-8; -*- -<%inherit file="/page.mako" /> - -<%def name="title()">Generate Project</%def> - -<%def name="content_title()"></%def> - -<%def name="page_content()"> - <b-field horizontal label="Project Type"> - <b-select v-model="projectType"> - <option value="rattail">rattail</option> - <option value="rattail_integration">rattail-integration</option> - <option value="tailbone_integration">tailbone-integration</option> - ## <option value="byjove">byjove</option> - <option value="fabric">fabric</option> - </b-select> - </b-field> - - <div v-if="projectType == 'rattail'"> - ${h.form(request.current_route_url(), ref='rattailForm')} - ${h.csrf_token(request)} - ${h.hidden('project_type', value='rattail')} - <br /> - <div class="card"> - <header class="card-header"> - <p class="card-header-title">Naming</p> - </header> - <div class="card-content"> - <div class="content"> - - <b-field horizontal label="Name" - message="The "canonical" name generally used to refer to this project"> - <b-input name="name" v-model="rattail.name"></b-input> - </b-field> - - <b-field horizontal label="Slug" - message="Used for e.g. naming the project source code folder"> - <b-input name="slug" v-model="rattail.slug"></b-input> - </b-field> - - <b-field horizontal label="Organization" - message="For use with "branding" etc."> - <b-input name="organization" v-model="rattail.organization"></b-input> - </b-field> - - <b-field horizontal label="Package Name for PyPI" - message="It's a good idea to use org name as namespace prefix here"> - <b-input name="python_project_name" v-model="rattail.python_project_name"></b-input> - </b-field> - - <b-field horizontal label="Package Name in Python" - :message="`For example, ~/src/${'$'}{rattail.slug}/${'$'}{rattail.python_package_name}/__init__.py`"> - <b-input name="python_name" v-model="rattail.python_package_name"></b-input> - </b-field> - - </div> - </div> - </div> - <br /> - <div class="card"> - <header class="card-header"> - <p class="card-header-title">Database</p> - </header> - <div class="card-content"> - <div class="content"> - - <b-field horizontal label="Has Rattail DB" - message="Note that a DB is required for the Web App"> - <b-checkbox name="has_db" - v-model="rattail.has_rattail_db" - native-value="true"> - </b-checkbox> - </b-field> - - <b-field horizontal label="Extends Rattail DB Schema" - message="For adding custom tables/columns to the core schema"> - <b-checkbox name="extends_db" - v-model="rattail.extends_rattail_db_schema" - native-value="true"> - </b-checkbox> - </b-field> - - <b-field horizontal label="Uses Rattail Batch Schema" - v-show="false" - message="Needed for "dynamic" (e.g. import/export) batches"> - <b-checkbox name="has_batch_schema" - v-model="rattail.uses_rattail_batch_schema" - native-value="true"> - </b-checkbox> - </b-field> - - </div> - </div> - </div> - <br /> - <div class="card"> - <header class="card-header"> - <p class="card-header-title">Web App</p> - </header> - <div class="card-content"> - <div class="content"> - - <b-field horizontal label="Has Tailbone Web App"> - <b-checkbox name="has_web" - v-model="rattail.has_tailbone_web_app" - native-value="true"> - </b-checkbox> - </b-field> - - <b-field horizontal label="Has Tailbone Web API" - v-show="false" - message="Needed for e.g. Vue.js SPA mobile apps"> - <b-checkbox name="has_web_api" - v-model="rattail.has_tailbone_web_api" - native-value="true"> - </b-checkbox> - </b-field> - - </div> - </div> - </div> - <br /> - <div class="card"> - <header class="card-header"> - <p class="card-header-title">Integrations</p> - </header> - <div class="card-content"> - <div class="content"> - - <b-field horizontal label="Integrates w/ Catapult" - message="Add schema, import/export logic etc. for ECRS Catapult"> - <b-checkbox name="integrates_catapult" - v-model="rattail.integrates_with_catapult" - native-value="true"> - </b-checkbox> - </b-field> - - <b-field horizontal label="Integrates w/ CORE-POS" - v-show="false"> - <b-checkbox name="integrates_corepos" - v-model="rattail.integrates_with_corepos" - native-value="true"> - </b-checkbox> - </b-field> - - <b-field horizontal label="Integrates w/ LOC SMS" - message="Add schema, import/export logic etc. for LOC SMS"> - <b-checkbox name="integrates_locsms" - v-model="rattail.integrates_with_locsms" - native-value="true"> - </b-checkbox> - </b-field> - - <b-field horizontal label="Has DataSync Service" - v-show="false"> - <b-checkbox name="has_datasync" - v-model="rattail.has_datasync_service" - native-value="true"> - </b-checkbox> - </b-field> - - </div> - </div> - </div> - <br /> - <div class="card"> - <header class="card-header"> - <p class="card-header-title">Deployment</p> - </header> - <div class="card-content"> - <div class="content"> - - <b-field horizontal label="Uses Fabric"> - <b-checkbox name="uses_fabric" - v-model="rattail.uses_fabric" - native-value="true"> - </b-checkbox> - </b-field> - - </div> - </div> - </div> - ${h.end_form()} - </div> - - <div v-if="projectType == 'rattail_integration'"> - ${h.form(request.current_route_url(), ref='rattail_integrationForm')} - ${h.csrf_token(request)} - ${h.hidden('project_type', value='rattail_integration')} - <br /> - <div class="card"> - <header class="card-header"> - <p class="card-header-title">Naming</p> - </header> - <div class="card-content"> - <div class="content"> - - <b-field horizontal label="Integration Name" - message="Name of the system to be integrated"> - <b-input name="integration_name" v-model="rattail_integration.integration_name"></b-input> - </b-field> - - <b-field horizontal label="Integration URL" - message="Reference URL for the system to be integrated"> - <b-input name="integration_url" v-model="rattail_integration.integration_url"></b-input> - </b-field> - - <b-field horizontal label="Package Name for PyPI" - message="Also will be used as slug, e.g. for folder name"> - <b-input name="python_project_name" v-model="rattail_integration.python_project_name"></b-input> - </b-field> - - ${h.hidden('slug', **{'v-model': 'rattail_integration.python_project_name'})} - - <b-field horizontal label="Package Name in Python" - :message="`For example, ~/src/${'$'}{rattail_integration.python_project_name}/${'$'}{rattail_integration.python_package_name}/__init__.py`"> - <b-input name="python_name" v-model="rattail_integration.python_package_name"></b-input> - </b-field> - - </div> - </div> - </div> - <br /> - <div class="card"> - <header class="card-header"> - <p class="card-header-title">Options</p> - </header> - <div class="card-content"> - <div class="content"> - - <b-field horizontal label="Extends Config" - message="Adds custom config extension"> - <b-checkbox name="extends_config" - v-model="rattail_integration.extends_config" - native-value="true"> - </b-checkbox> - </b-field> - - <b-field horizontal label="Extends Rattail Schema" - message="Adds custom tables/columns to the Rattail DB schema"> - <b-checkbox name="extends_db" - v-model="rattail_integration.extends_db" - native-value="true"> - </b-checkbox> - </b-field> - - </div> - </div> - </div> - ${h.end_form()} - </div> - - <div v-if="projectType == 'tailbone_integration'"> - ${h.form(request.current_route_url(), ref='tailbone_integrationForm')} - ${h.csrf_token(request)} - ${h.hidden('project_type', value='tailbone_integration')} - <br /> - <div class="card"> - <header class="card-header"> - <p class="card-header-title">Naming</p> - </header> - <div class="card-content"> - <div class="content"> - - <b-field horizontal label="Integration Name" - message="Name of the system to be integrated"> - <b-input name="integration_name" v-model="tailbone_integration.integration_name"></b-input> - </b-field> - - <b-field horizontal label="Integration URL" - message="Reference URL for the system to be integrated"> - <b-input name="integration_url" v-model="tailbone_integration.integration_url"></b-input> - </b-field> - - <b-field horizontal label="Package Name for PyPI" - message="Also will be used as slug, e.g. for folder name"> - <b-input name="python_project_name" v-model="tailbone_integration.python_project_name"></b-input> - </b-field> - - ${h.hidden('slug', **{'v-model': 'tailbone_integration.python_project_name'})} - - <b-field horizontal label="Package Name in Python" - :message="`For example, ~/src/${'$'}{tailbone_integration.python_project_name}/${'$'}{tailbone_integration.python_package_name}/__init__.py`"> - <b-input name="python_name" v-model="tailbone_integration.python_package_name"></b-input> - </b-field> - - </div> - </div> - </div> - ${h.end_form()} - </div> - - <div v-if="projectType == 'byjove'"> - ${h.form(request.current_route_url(), ref='byjoveForm')} - ${h.csrf_token(request)} - ${h.hidden('project_type', value='byjove')} - - <br /> - <div class="card"> - <header class="card-header"> - <p class="card-header-title">Naming</p> - </header> - <div class="card-content"> - <div class="content"> - - <b-field horizontal label="Name"> - <b-input name="name" v-model="byjove.name"></b-input> - </b-field> - - <b-field horizontal label="Slug"> - <b-input name="slug" v-model="byjove.slug"></b-input> - </b-field> - - </div> - </div> - </div> - - ${h.end_form()} - </div> - - <div v-if="projectType == 'fabric'"> - ${h.form(request.current_route_url(), ref='fabricForm')} - ${h.csrf_token(request)} - ${h.hidden('project_type', value='fabric')} - - <br /> - <div class="card"> - <header class="card-header"> - <p class="card-header-title">Naming</p> - </header> - <div class="card-content"> - <div class="content"> - - <b-field horizontal label="Name" - message="The "canonical" name generally used to refer to this project"> - <b-input name="name" v-model="fabric.name"></b-input> - </b-field> - - <b-field horizontal label="Slug" - message="Used for e.g. naming the project source code folder"> - <b-input name="slug" v-model="fabric.slug"></b-input> - </b-field> - - <b-field horizontal label="Organization" - message="For use with "branding" etc."> - <b-input name="organization" v-model="fabric.organization"></b-input> - </b-field> - - <b-field horizontal label="Package Name for PyPI" - message="It's a good idea to use org name as namespace prefix here"> - <b-input name="python_project_name" v-model="fabric.python_project_name"></b-input> - </b-field> - - <b-field horizontal label="Package Name in Python" - :message="`For example, ~/src/${'$'}{fabric.slug}/${'$'}{fabric.python_package_name}/__init__.py`"> - <b-input name="python_name" v-model="fabric.python_package_name"></b-input> - </b-field> - - </div> - </div> - </div> - - <br /> - <div class="card"> - <header class="card-header"> - <p class="card-header-title">Theo</p> - </header> - <div class="card-content"> - <div class="content"> - - <b-field horizontal label="Integrates With" - message="Which POS system should Theo integrate with, if any"> - <b-select name="integrates_with" v-model="fabric.integrates_with"> - <option value="">(nothing)</option> - <option value="catapult">ECRS Catapult</option> - <option value="corepos">CORE-POS</option> - ## <option value="locsms">LOC SMS</option> - </b-select> - </b-field> - - </div> - </div> - </div> - - ${h.end_form()} - </div> - - <br /> - <div class="buttons" style="padding-left: 8rem;"> - <b-button type="is-primary" - @click="submitProjectForm()"> - Generate Project - </b-button> - </div> - -</%def> - -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> - - ThisPageData.projectType = 'rattail' - - ThisPageData.rattail = { - name: "Okay-Then", - slug: "okay-then", - organization: "Acme Foods", - python_project_name: "Acme-Okay-Then", - python_package_name: "okay_then", - has_rattail_db: true, - extends_rattail_db_schema: true, - uses_rattail_batch_schema: false, - has_tailbone_web_app: true, - has_tailbone_web_api: false, - has_datasync_service: false, - integrates_with_catapult: false, - integrates_with_corepos: false, - integrates_with_locsms: false, - uses_fabric: true, - } - - ThisPageData.rattail_integration = { - integration_name: "Foo", - integration_url: "https://www.example.com/", - python_project_name: "rattail-foo", - python_package_name: "rattail_foo", - extends_config: true, - extends_db: true, - } - - ThisPageData.tailbone_integration = { - integration_name: "Foo", - integration_url: "https://www.example.com/", - python_project_name: "tailbone-foo", - python_package_name: "tailbone_foo", - } - - ThisPageData.byjove = { - name: "Okay-Then-Mobile", - slug: "okay-then-mobile", - } - - ThisPageData.fabric = { - name: "AcmeFab", - slug: "acmefab", - organization: "Acme Foods", - python_project_name: "Acme-Fabric", - python_package_name: "acmefab", - integrates_with: '', - } - - ThisPage.methods.submitProjectForm = function() { - let form = this.$refs[this.projectType + 'Form'] - form.submit() - } - - </script> -</%def> - - -${parent.body()} diff --git a/tailbone/templates/generated-projects/create.mako b/tailbone/templates/generated-projects/create.mako new file mode 100644 index 00000000..6c3af299 --- /dev/null +++ b/tailbone/templates/generated-projects/create.mako @@ -0,0 +1,25 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/create.mako" /> + +<%def name="title()">${index_title}</%def> + +<%def name="content_title()"></%def> + +<%def name="page_content()"> + % if project_type: + <b-field grouped> + <b-field horizontal expanded label="Project Type" + class="is-expanded"> + ${project_type} + </b-field> + <once-button type="is-primary" + tag="a" href="${url('generated_projects.create')}" + text="Start Over"> + </once-button> + </b-field> + % endif + ${parent.page_content()} +</%def> + + +${parent.body()} diff --git a/tailbone/templates/grids/b-table.mako b/tailbone/templates/grids/b-table.mako index 26e86359..da9f2aae 100644 --- a/tailbone/templates/grids/b-table.mako +++ b/tailbone/templates/grids/b-table.mako @@ -1,5 +1,5 @@ ## -*- coding: utf-8; -*- -<b-table +<${b}-table :data="${data_prop}" icon-pack="fas" striped @@ -20,67 +20,67 @@ % endif > - <template slot-scope="props"> - % for i, column in enumerate(grid_columns): - <b-table-column field="${column['field']}" - % if not empty_labels: - label="${column['label']}" - % elif i > 0: - label=" " - % endif - ${'sortable' if column['sortable'] else ''}> - % if empty_labels and i == 0: - <template slot="header" slot-scope="{ column }"></template> - % endif - % if grid.is_linked(column['field']): - <a :href="props.row._action_url_view" - v-html="props.row.${column['field']}" - % if view_click_handler: - @click.prevent="${view_click_handler}" - % endif - > + % for i, column in enumerate(grid_columns): + <${b}-table-column field="${column['field']}" + % if not empty_labels: + label="${column['label']}" + % elif i > 0: + label=" " + % endif + v-slot="props" + ${'sortable' if column['sortable'] else ''}> + % if empty_labels and i == 0: + <template slot="header" slot-scope="{ column }"></template> + % endif + % if grid.is_linked(column['field']): + <a :href="props.row._action_url_view" + v-html="props.row.${column['field']}" + % if view_click_handler: + @click.prevent="${view_click_handler}" + % endif + > + </a> + % elif grid.has_click_handler(column['field']): + <span> + <a href="#" + @click.prevent="${grid.click_handlers[column['field']]}" + v-html="props.row.${column['field']}"> </a> - % elif grid.has_click_handler(column['field']): - <span> - <a href="#" - @click.prevent="${grid.click_handlers[column['field']]}" - v-html="props.row.${column['field']}"> - </a> - </span> - % else: - <span v-html="props.row.${column['field']}"></span> - % endif - </b-table-column> - % endfor + </span> + % else: + <span v-html="props.row.${column['field']}"></span> + % endif + </${b}-table-column> + % endfor - % if grid.main_actions or grid.more_actions: - <b-table-column field="actions" label="Actions"> - % for action in grid.main_actions: - <a :href="props.row._action_url_${action.key}" - % if action.link_class: - class="${action.link_class}" - % else: - class="grid-action${' has-text-danger' if action.key == 'delete' else ''}" - % endif - % if action.click_handler: - @click.prevent="${action.click_handler}" - % endif - > - <i class="fas fa-${action.icon}"></i> - ${action.label} - </a> - - % endfor - </b-table-column> - % endif - </template> + % if grid.actions: + <${b}-table-column field="actions" + label="Actions" + v-slot="props"> + % for action in grid.actions: + <a :href="props.row._action_url_${action.key}" + % if action.link_class: + class="${action.link_class}" + % else: + class="grid-action${' has-text-danger' if action.key == 'delete' else ''}" + % endif + % if action.click_handler: + @click.prevent="${action.click_handler}" + % endif + > + ${action.render_icon_and_label()} + </a> + + % endfor + </${b}-table-column> + % endif - <template slot="empty"> + <template #empty> <div class="content has-text-grey has-text-centered"> <p> <b-icon pack="fas" - icon="fas fa-sad-tear" + icon="sad-tear" size="is-large"> </b-icon> </p> @@ -98,4 +98,4 @@ </template> % endif -</b-table> +</${b}-table> diff --git a/tailbone/templates/grids/buefy.mako b/tailbone/templates/grids/buefy.mako deleted file mode 100644 index 9d8359d9..00000000 --- a/tailbone/templates/grids/buefy.mako +++ /dev/null @@ -1,578 +0,0 @@ -## -*- coding: utf-8; -*- - -<script type="text/x-template" id="grid-filter-numeric-value-template"> - <div class="level"> - <div class="level-left"> - <div class="level-item"> - <b-input v-model="startValue" - ref="startValue" - @input="startValueChanged"> - </b-input> - </div> - <div v-show="wantsRange" - class="level-item"> - and - </div> - <div v-show="wantsRange" - class="level-item"> - <b-input v-model="endValue" - ref="endValue" - @input="endValueChanged"> - </b-input> - </div> - </div> - </div> -</script> - -<script type="text/x-template" id="grid-filter-date-value-template"> - <div class="level"> - <div class="level-left"> - <div class="level-item"> - <tailbone-datepicker v-model="startDate" - ref="startDate" - @input="startDateChanged"> - </tailbone-datepicker> - </div> - <div v-show="dateRange" - class="level-item"> - and - </div> - <div v-show="dateRange" - class="level-item"> - <tailbone-datepicker v-model="endDate" - ref="endDate" - @input="endDateChanged"> - </tailbone-datepicker> - </div> - </div> - </div> -</script> - -<script type="text/x-template" id="grid-filter-template"> - - <div class="level filter" v-show="filter.visible"> - <div class="level-left" - style="align-items: start;"> - - <div class="level-item filter-fieldname"> - - <b-field> - <b-checkbox-button v-model="filter.active" native-value="IGNORED"> - <b-icon pack="fas" icon="check" v-show="filter.active"></b-icon> - <span>{{ filter.label }}</span> - </b-checkbox-button> - </b-field> - - </div> - - <b-field grouped v-show="filter.active" - class="level-item" - style="align-items: start;"> - - <b-select v-model="filter.verb" - @input="focusValue()" - class="filter-verb"> - <option v-for="verb in filter.verbs" - :key="verb" - :value="verb"> - {{ filter.verb_labels[verb] }} - </option> - </b-select> - - ## only one of the following "value input" elements will be rendered - - <grid-filter-date-value v-if="filter.data_type == 'date'" - v-model="filter.value" - v-show="valuedVerb()" - :date-range="filter.verb == 'between'" - ref="valueInput"> - </grid-filter-date-value> - - <b-select v-if="filter.data_type == 'choice'" - v-model="filter.value" - v-show="valuedVerb()" - ref="valueInput"> - <option v-for="choice in filter.choices" - :key="choice" - :value="choice"> - {{ filter.choice_labels[choice] || choice }} - </option> - </b-select> - - <grid-filter-numeric-value v-if="filter.data_type == 'number'" - v-model="filter.value" - v-show="valuedVerb()" - :wants-range="filter.verb == 'between'" - ref="valueInput"> - </grid-filter-numeric-value> - - <b-input v-if="filter.data_type == 'string' && !multiValuedVerb()" - v-model="filter.value" - v-show="valuedVerb()" - ref="valueInput"> - </b-input> - - <b-input v-if="filter.data_type == 'string' && multiValuedVerb()" - type="textarea" - v-model="filter.value" - v-show="valuedVerb()" - ref="valueInput"> - </b-input> - - </b-field> - - </div><!-- level-left --> - </div><!-- level --> - -</script> - -<script type="text/x-template" id="${grid.component}-template"> - <div> - - <div style="display: flex; justify-content: space-between; margin-bottom: 0.5em;"> - - <div style="display: flex; flex-direction: column; justify-content: space-between;"> - <div></div> - <div class="filters"> - % if grid.filterable: - ## TODO: stop using |n filter - ${grid.render_filters(template='/grids/filters_buefy.mako', allow_save_defaults=allow_save_defaults)|n} - % endif - </div> - </div> - - <div style="display: flex; flex-direction: column; justify-content: space-between;"> - - <div class="context-menu"> - % if context_menu: - <ul id="context-menu"> - ## TODO: stop using |n filter - ${context_menu|n} - </ul> - % endif - </div> - - <div class="grid-tools-wrapper"> - % if tools: - <div class="grid-tools field buttons is-grouped is-pulled-right"> - ## TODO: stop using |n filter - ${tools|n} - </div> - % endif - </div> - - </div> - - </div> - - <b-table - :data="visibleData" - ## :columns="columns" - :loading="loading" - :row-class="getRowClass" - - ## TODO: this should be more configurable, maybe auto-detect based - ## on buefy version?? probably cannot do that, but this feature - ## is only supported with buefy 0.8.13 and newer - % if request.rattail_config.getbool('tailbone', 'sticky_headers'): - sticky-header - height="600px" - % endif - - :checkable="checkable" - % if grid.checkboxes: - :checked-rows.sync="checkedRows" - % if grid.clicking_row_checks_box: - @click="rowClick" - % endif - % endif - % if grid.check_handler: - @check="${grid.check_handler}" - % endif - % if grid.check_all_handler: - @check-all="${grid.check_all_handler}" - % endif - ## TODO: definitely will be wanting this... - ## :is-row-checkable="" - - :default-sort="[sortField, sortOrder]" - backend-sorting - @sort="onSort" - - :paginated="paginated" - :per-page="perPage" - :current-page="currentPage" - backend-pagination - :total="total" - @page-change="onPageChange" - - ## TODO: should let grid (or master view) decide how to set these? - icon-pack="fas" - ## note that :striped="true" was interfering with row status (e.g. warning) styles - :striped="false" - :hoverable="true" - :narrowed="true"> - - <template slot-scope="props"> - % for column in grid_columns: - <b-table-column field="${column['field']}" - label="${column['label']}" - :sortable="${json.dumps(column['sortable'])}" - % if grid.is_searchable(column['field']): - searchable - % endif - cell-class="${column['field']}" - % if grid.has_click_handler(column['field']): - @click.native="${grid.click_handlers[column['field']]}" - % endif - :visible="${json.dumps(column['visible'])}"> - % if column['field'] in grid.raw_renderers: - ${grid.raw_renderers[column['field']]()} - % elif grid.is_linked(column['field']): - <a :href="props.row._action_url_view" v-html="props.row.${column['field']}"></a> - % else: - <span v-html="props.row.${column['field']}"></span> - % endif - </b-table-column> - % endfor - - % if grid.main_actions or grid.more_actions: - <b-table-column field="actions" label="Actions"> - ## TODO: we do not currently differentiate for "main vs. more" - ## here, but ideally we would tuck "more" away in a drawer etc. - % for action in grid.main_actions + grid.more_actions: - <a v-if="props.row._action_url_${action.key}" - :href="props.row._action_url_${action.key}" - class="grid-action${' has-text-danger' if action.key == 'delete' else ''} ${action.link_class or ''}" - % if action.click_handler: - @click.prevent="${action.click_handler}" - % endif - > - ${action.render_icon()|n} - ${action.render_label()|n} - </a> - - % endfor - </b-table-column> - % endif - </template> - - <template #empty> - <section class="section"> - <div class="content has-text-grey has-text-centered"> - <p> - <b-icon - pack="fas" - icon="fas fa-sad-tear" - size="is-large"> - </b-icon> - </p> - <p>Nothing here.</p> - </div> - </section> - </template> - - % if grid.pageable: - <template slot="footer"> - <b-field grouped position="is-right" - v-if="firstItem"> - <span class="control"> - showing {{ firstItem.toLocaleString('en') }} - {{ lastItem.toLocaleString('en') }} of {{ total.toLocaleString('en') }} results; - </span> - <b-select v-model="perPage" - size="is-small" - @input="loadAsyncData()"> - % for value in grid.get_pagesize_options(): - <option value="${value}">${value}</option> - % endfor - </b-select> - <span class="control"> - per page - </span> - </b-field> - </template> - % endif - - </b-table> - </div> -</script> - -<script type="text/javascript"> - - let ${grid.component_studly}CurrentData = ${json.dumps(grid_data['data'])|n} - - let ${grid.component_studly}Data = { - loading: false, - selectedFilter: null, - ajaxDataUrl: ${json.dumps(grid.ajax_data_url)|n}, - - data: ${grid.component_studly}CurrentData, - rowStatusMap: ${json.dumps(grid_data['row_status_map'])|n}, - - checkable: ${json.dumps(grid.checkboxes)|n}, - % if grid.checkboxes: - checkedRows: ${grid_data['checked_rows_code']|n}, - % endif - - paginated: ${json.dumps(grid.pageable)|n}, - total: ${len(grid_data['data']) if static_data else grid_data['total_items']}, - perPage: ${json.dumps(grid.pagesize if grid.pageable else None)|n}, - currentPage: ${json.dumps(grid.page if grid.pageable else None)|n}, - firstItem: ${json.dumps(grid_data['first_item'] if grid.pageable else None)|n}, - lastItem: ${json.dumps(grid_data['last_item'] if grid.pageable else None)|n}, - - sortField: ${json.dumps(grid.sortkey if grid.sortable else None)|n}, - sortOrder: ${json.dumps(grid.sortdir if grid.sortable else None)|n}, - - ## filterable: ${json.dumps(grid.filterable)|n}, - filters: ${json.dumps(filters_data if grid.filterable else None)|n}, - filtersSequence: ${json.dumps(filters_sequence if grid.filterable else None)|n}, - selectedFilter: null, - } - - let ${grid.component_studly} = { - template: '#${grid.component}-template', - - mixins: [FormPosterMixin], - - props: { - csrftoken: String, - }, - - computed: { - - // note, can use this with v-model for hidden 'uuids' fields - selected_uuids: function() { - return this.checkedRowUUIDs().join(',') - }, - - // nb. this can be overridden if needed, e.g. to dynamically - // show/hide certain records in a static data set - visibleData() { - return this.data - }, - }, - - methods: { - - addRowClass(index, className) { - - // TODO: this may add duplicated name to class string - // (not a serious problem i think, but could be improved) - this.rowStatusMap[index] = (this.rowStatusMap[index] || '') - + ' ' + className - - // nb. for some reason b-table does not always "notice" - // when we update status; so we force it to refresh - this.$forceUpdate() - }, - - getRowClass(row, index) { - return this.rowStatusMap[index] - }, - - loadAsyncData(params, callback) { - - if (params === undefined || params === null) { - params = [ - 'partial=true', - `sortkey=${'$'}{this.sortField}`, - `sortdir=${'$'}{this.sortOrder}`, - `pagesize=${'$'}{this.perPage}`, - `page=${'$'}{this.currentPage}` - ].join('&') - } - - this.loading = true - this.$http.get(`${'$'}{this.ajaxDataUrl}?${'$'}{params}`).then(({ data }) => { - ${grid.component_studly}CurrentData = data.data - this.data = ${grid.component_studly}CurrentData - this.rowStatusMap = data.row_status_map - this.total = data.total_items - this.firstItem = data.first_item - this.lastItem = data.last_item - this.loading = false - this.checkedRows = this.locateCheckedRows(data.checked_rows) - if (callback) { - callback() - } - }) - .catch((error) => { - this.data = [] - this.total = 0 - this.loading = false - throw error - }) - }, - - locateCheckedRows(checked) { - let rows = [] - if (checked) { - for (let i = 0; i < this.data.length; i++) { - if (checked.includes(i)) { - rows.push(this.data[i]) - } - } - } - return rows - }, - - onPageChange(page) { - this.currentPage = page - this.loadAsyncData() - }, - - onSort(field, order) { - this.sortField = field - this.sortOrder = order - // always reset to first page when changing sort options - // TODO: i mean..right? would we ever not want that? - this.currentPage = 1 - this.loadAsyncData() - }, - - resetView() { - this.loading = true - location.href = '?reset-to-default-filters=true' - }, - - addFilter(filter_key) { - - // reset dropdown so user again sees "Add Filter" placeholder - this.$nextTick(function() { - this.selectedFilter = null - }) - - // show corresponding grid filter - this.filters[filter_key].visible = true - this.filters[filter_key].active = true - - // track down the component - var gridFilter = null - for (var gf of this.$refs.gridFilters) { - if (gf.filter.key == filter_key) { - gridFilter = gf - break - } - } - - // tell component to focus the value field, ASAP - this.$nextTick(function() { - gridFilter.focusValue() - }) - - }, - - applyFilters(params) { - if (params === undefined) { - params = [] - } - - params.push('partial=true') - params.push('filter=true') - - for (var key in this.filters) { - var filter = this.filters[key] - if (filter.active) { - params.push(key + '=' + encodeURIComponent(filter.value)) - params.push(key + '.verb=' + encodeURIComponent(filter.verb)) - } else { - filter.visible = false - } - } - - this.loadAsyncData(params.join('&')) - }, - - clearFilters() { - - // explicitly deactivate all filters - for (var key in this.filters) { - this.filters[key].active = false - } - - // then just "apply" as normal - this.applyFilters() - }, - - // explicitly set filters for the grid, to the given set. - // this totally overrides whatever might be current. the - // new filter set should look like: - // - // [ - // {key: 'status_code', - // verb: 'equal', - // value: 1}, - // {key: 'description', - // verb: 'contains', - // value: 'whatever'}, - // ] - // - setFilters(newFilters) { - for (let key in this.filters) { - let filter = this.filters[key] - let active = false - for (let newFilter of newFilters) { - if (newFilter.key == key) { - active = true - filter.active = true - filter.visible = true - filter.verb = newFilter.verb - filter.value = newFilter.value - break - } - } - if (!active) { - filter.active = false - filter.visible = false - } - } - this.applyFilters() - }, - - saveDefaults() { - - // apply current filters as normal, but add special directive - const params = ['save-current-filters-as-defaults=true'] - this.applyFilters(params) - }, - - deleteObject(event) { - // we let parent component/app deal with this, in whatever way makes sense... - // TODO: should we ever provide anything besides the URL for this? - this.$emit('deleteActionClicked', event.target.href) - }, - - checkedRowUUIDs() { - let uuids = [] - for (let row of this.$data.checkedRows) { - uuids.push(row.uuid) - } - return uuids - }, - - allRowUUIDs() { - let uuids = [] - for (let row of this.data) { - uuids.push(row.uuid) - } - return uuids - }, - - // when a user clicks a row, handle as if they clicked checkbox. - // note that this method is only used if table is "checkable" - rowClick(row) { - let i = this.checkedRows.indexOf(row) - if (i >= 0) { - this.checkedRows.splice(i, 1) - } else { - this.checkedRows.push(row) - } - % if grid.check_handler: - this.${grid.check_handler}(this.checkedRows, row) - % endif - }, - } - } - -</script> diff --git a/tailbone/templates/grids/complete.mako b/tailbone/templates/grids/complete.mako index 169264c4..60f9a3b8 100644 --- a/tailbone/templates/grids/complete.mako +++ b/tailbone/templates/grids/complete.mako @@ -1,38 +1,930 @@ -## -*- coding: utf-8 -*- -<div class="grid-wrapper"> +## -*- coding: utf-8; -*- - <table class="grid-header"> - <tbody> - <tr> +<% request.register_component(grid.vue_tagname, grid.vue_component) %> - <td class="filters" rowspan="2"> - % if grid.filterable: - ${grid.render_filters(allow_save_defaults=allow_save_defaults)|n} +<script type="text/x-template" id="${grid.vue_tagname}-template"> + <div> + + <div style="display: flex; justify-content: space-between; margin-bottom: 0.5em;"> + + <div style="display: flex; flex-direction: column; justify-content: end;"> + <div class="filters"> + % if getattr(grid, 'filterable', False): + <form method="GET" @submit.prevent="applyFilters()"> + + <div style="display: flex; flex-direction: column; gap: 0.5rem;"> + <grid-filter v-for="key in filtersSequence" + :key="key" + :filter="filters[key]" + ref="gridFilters"> + </grid-filter> + </div> + + <div style="display: flex; gap: 0.5rem; margin-top: 0.5rem;"> + + <b-button type="is-primary" + native-type="submit" + icon-pack="fas" + icon-left="check"> + Apply Filters + </b-button> + + <b-button v-if="!addFilterShow" + icon-pack="fas" + icon-left="plus" + @click="addFilterInit()"> + Add Filter + </b-button> + + <b-autocomplete v-if="addFilterShow" + ref="addFilterAutocomplete" + :data="addFilterChoices" + v-model="addFilterTerm" + placeholder="Add Filter" + field="key" + :custom-formatter="formatAddFilterItem" + open-on-focus + keep-first + icon-pack="fas" + clearable + clear-on-select + @select="addFilterSelect"> + </b-autocomplete> + + <b-button @click="resetView()" + icon-pack="fas" + icon-left="home"> + Default View + </b-button> + + <b-button @click="clearFilters()" + icon-pack="fas" + icon-left="trash"> + No Filters + </b-button> + + % if allow_save_defaults and request.user: + <b-button @click="saveDefaults()" + icon-pack="fas" + icon-left="save" + :disabled="savingDefaults"> + {{ savingDefaults ? "Working, please wait..." : "Save Defaults" }} + </b-button> + % endif + + </div> + </form> % endif - </td> + </div> + </div> - <td class="menu"> + <div style="display: flex; flex-direction: column; justify-content: space-between;"> + + <div class="context-menu"> % if context_menu: <ul id="context-menu"> + ## TODO: stop using |n filter ${context_menu|n} </ul> % endif - </td> - </tr> + </div> - <tr> - <td class="tools"> + <div class="grid-tools-wrapper"> % if tools: <div class="grid-tools"> + ## TODO: stop using |n filter ${tools|n} - </div><!-- grid-tools --> + </div> % endif - </td> - </tr> + </div> - </tbody> - </table><!-- grid-header --> + </div> - ${grid.render_grid()|n} + </div> -</div><!-- grid-wrapper --> + <${b}-table + :data="visibleData" + :loading="loading" + :row-class="getRowClass" + % if request.use_oruga: + tr-checked-class="is-checked" + % endif + + % if request.rattail_config.getbool('tailbone', 'sticky_headers'): + sticky-header + height="600px" + % endif + + :checkable="checkable" + + % if getattr(grid, 'checkboxes', False): + % if request.use_oruga: + v-model:checked-rows="checkedRows" + % else: + :checked-rows.sync="checkedRows" + % endif + % if grid.clicking_row_checks_box: + @click="rowClick" + % endif + % endif + + % if getattr(grid, 'check_handler', None): + @check="${grid.check_handler}" + % endif + % if getattr(grid, 'check_all_handler', None): + @check-all="${grid.check_all_handler}" + % endif + + % if hasattr(grid, 'checkable'): + % if isinstance(grid.checkable, str): + :is-row-checkable="${grid.row_checkable}" + % elif grid.checkable: + :is-row-checkable="row => row._checkable" + % endif + % endif + + ## sorting + % if grid.sortable: + ## nb. buefy/oruga only support *one* default sorter + :default-sort="sorters.length ? [sorters[0].field, sorters[0].order] : null" + % if grid.sort_on_backend: + backend-sorting + @sort="onSort" + % endif + % if grid.sort_multiple: + % if grid.sort_on_backend: + ## TODO: there is a bug (?) which prevents the arrow + ## from displaying for simple default single-column sort, + ## when multi-column sort is allowed for the table. for + ## now we work around that by waiting until mount to + ## enable the multi-column support. see also + ## https://github.com/buefy/buefy/issues/2584 + :sort-multiple="allowMultiSort" + :sort-multiple-data="sortingPriority" + @sorting-priority-removed="sortingPriorityRemoved" + % else: + sort-multiple + % endif + ## nb. user must ctrl-click column header for multi-sort + sort-multiple-key="ctrlKey" + % endif + % endif + + % if getattr(grid, 'click_handlers', None): + @cellclick="cellClick" + % endif + + ## paging + % if grid.paginated: + paginated + pagination-size="${'small' if request.use_oruga else 'is-small'}" + :per-page="perPage" + :current-page="currentPage" + @page-change="onPageChange" + % if grid.paginate_on_backend: + backend-pagination + :total="pagerStats.item_count" + % endif + % endif + + ## TODO: should let grid (or master view) decide how to set these? + icon-pack="fas" + ## note that :striped="true" was interfering with row status (e.g. warning) styles + :striped="false" + :hoverable="true" + :narrowed="true"> + + % for column in grid.get_vue_columns(): + <${b}-table-column field="${column['field']}" + label="${column['label']}" + v-slot="props" + :sortable="${json.dumps(column.get('sortable', False))|n}" + :searchable="${json.dumps(column.get('searchable', False))|n}" + cell-class="c_${column['field']}" + :visible="${json.dumps(column.get('visible', True))}"> + % if hasattr(grid, 'raw_renderers') and column['field'] in grid.raw_renderers: + ${grid.raw_renderers[column['field']]()} + % elif grid.is_linked(column['field']): + <a :href="props.row._action_url_view" + % if view_click_handler: + @click.prevent="${view_click_handler}" + % endif + v-html="props.row.${column['field']}"> + </a> + % else: + <span v-html="props.row.${column['field']}"></span> + % endif + </${b}-table-column> + % endfor + + % if grid.actions: + <${b}-table-column field="actions" + label="Actions" + v-slot="props"> + ## TODO: we do not currently differentiate for "main vs. more" + ## here, but ideally we would tuck "more" away in a drawer etc. + % for action in grid.actions: + <a v-if="props.row._action_url_${action.key}" + :href="props.row._action_url_${action.key}" + class="grid-action${' has-text-danger' if action.key == 'delete' else ''} ${action.link_class or ''}" + % if getattr(action, 'click_handler', None): + @click.prevent="${action.click_handler}" + % endif + % if getattr(action, 'target', None): + target="${action.target}" + % endif + > + ${action.render_icon_and_label()} + </a> + + % endfor + </${b}-table-column> + % endif + + <template #empty> + <section class="section"> + <div class="content has-text-grey has-text-centered"> + <p> + <b-icon + pack="fas" + icon="sad-tear" + size="is-large"> + </b-icon> + </p> + <p>Nothing here.</p> + </div> + </section> + </template> + + <template #footer> + <div style="display: flex; justify-content: space-between;"> + + % if getattr(grid, 'expose_direct_link', False): + <b-button type="is-primary" + size="is-small" + @click="copyDirectLink()" + title="Copy link to clipboard"> + % if request.use_oruga: + <o-icon icon="share-alt" /> + % else: + <span><i class="fa fa-share-alt"></i></span> + % endif + </b-button> + % else: + <div></div> + % endif + + % if grid.paginated: + <div v-if="pagerStats.first_item" + style="display: flex; gap: 0.5rem; align-items: center;"> + <span> + showing + {{ renderNumber(pagerStats.first_item) }} + - {{ renderNumber(pagerStats.last_item) }} + of {{ renderNumber(pagerStats.item_count) }} results; + </span> + <b-select v-model="perPage" + size="is-small" + @input="perPageUpdated"> + % for value in grid.get_pagesize_options(): + <option value="${value}">${value}</option> + % endfor + </b-select> + <span> + per page + </span> + </div> + % endif + + </div> + </template> + + </${b}-table> + + ## dummy input field needed for sharing links on *insecure* sites + % if getattr(request, 'scheme', None) == 'http': + <b-input v-model="shareLink" ref="shareLink" v-show="shareLink"></b-input> + % endif + + </div> +</script> + +<script type="text/javascript"> + + const ${grid.vue_component}Context = ${json.dumps(grid.get_vue_context())|n} + let ${grid.vue_component}CurrentData = ${grid.vue_component}Context.data + + let ${grid.vue_component}Data = { + loading: false, + ajaxDataUrl: ${json.dumps(getattr(grid, 'ajax_data_url', request.path_url))|n}, + + ## nb. this tracks whether grid.fetchFirstData() happened + fetchedFirstData: false, + + savingDefaults: false, + + data: ${grid.vue_component}CurrentData, + rowStatusMap: ${json.dumps(grid_data['row_status_map'] if grid_data is not Undefined else {})|n}, + + checkable: ${json.dumps(getattr(grid, 'checkboxes', False))|n}, + % if getattr(grid, 'checkboxes', False): + checkedRows: ${grid_data['checked_rows_code']|n}, + % endif + + ## paging + % if grid.paginated: + pageSizeOptions: ${json.dumps(grid.pagesize_options)|n}, + perPage: ${json.dumps(grid.pagesize)|n}, + currentPage: ${json.dumps(grid.page)|n}, + % if grid.paginate_on_backend: + pagerStats: ${json.dumps(grid.get_vue_pager_stats())|n}, + % endif + % endif + + ## sorting + % if grid.sortable: + sorters: ${json.dumps(grid.get_vue_active_sorters())|n}, + % if grid.sort_multiple: + % if grid.sort_on_backend: + ## TODO: there is a bug (?) which prevents the arrow + ## from displaying for simple default single-column sort, + ## when multi-column sort is allowed for the table. for + ## now we work around that by waiting until mount to + ## enable the multi-column support. see also + ## https://github.com/buefy/buefy/issues/2584 + allowMultiSort: false, + ## nb. this should be empty when current sort is single-column + % if len(grid.active_sorters) > 1: + sortingPriority: ${json.dumps(grid.get_vue_active_sorters())|n}, + % else: + sortingPriority: [], + % endif + % endif + % endif + % endif + + ## filterable: ${json.dumps(grid.filterable)|n}, + filters: ${json.dumps(filters_data if getattr(grid, 'filterable', False) else None)|n}, + filtersSequence: ${json.dumps(filters_sequence if getattr(grid, 'filterable', False) else None)|n}, + addFilterTerm: '', + addFilterShow: false, + + ## dummy input value needed for sharing links on *insecure* sites + % if getattr(request, 'scheme', None) == 'http': + shareLink: null, + % endif + } + + let ${grid.vue_component} = { + template: '#${grid.vue_tagname}-template', + + mixins: [FormPosterMixin], + + props: { + csrftoken: String, + }, + + computed: { + + ## TODO: this should be temporary? but anyway 'total' is + ## still referenced in other places, e.g. "delete results" + % if grid.paginated: + total() { return this.pagerStats.item_count }, + % endif + + % if not grid.paginate_on_backend: + + pagerStats() { + const data = this.visibleData + let last = this.currentPage * this.perPage + let first = last - this.perPage + 1 + if (last > data.length) { + last = data.length + } + return { + 'item_count': data.length, + 'items_per_page': this.perPage, + 'page': this.currentPage, + 'first_item': first, + 'last_item': last, + } + }, + + % endif + + addFilterChoices() { + // nb. this returns all choices available for "Add Filter" operation + + // collect all filters, which are *not* already shown + let choices = [] + for (let field of this.filtersSequence) { + let filtr = this.filters[field] + if (!filtr.visible) { + choices.push(filtr) + } + } + + // parse list of search terms + let terms = [] + for (let term of this.addFilterTerm.toLowerCase().split(' ')) { + term = term.trim() + if (term) { + terms.push(term) + } + } + + // only filters matching all search terms are presented + // as choices to the user + return choices.filter(option => { + let label = option.label.toLowerCase() + for (let term of terms) { + if (label.indexOf(term) < 0) { + return false + } + } + return true + }) + }, + + // note, can use this with v-model for hidden 'uuids' fields + selected_uuids: function() { + return this.checkedRowUUIDs().join(',') + }, + + // nb. this can be overridden if needed, e.g. to dynamically + // show/hide certain records in a static data set + visibleData() { + return this.data + }, + + directLink() { + let params = new URLSearchParams(this.getAllParams()) + return `${request.path_url}?${'$'}{params}` + }, + }, + + % if grid.sortable and grid.sort_multiple and grid.sort_on_backend: + + ## TODO: there is a bug (?) which prevents the arrow + ## from displaying for simple default single-column sort, + ## when multi-column sort is allowed for the table. for + ## now we work around that by waiting until mount to + ## enable the multi-column support. see also + ## https://github.com/buefy/buefy/issues/2584 + mounted() { + this.allowMultiSort = true + }, + + % endif + + methods: { + + renderNumber(value) { + if (value != undefined) { + return value.toLocaleString('en') + } + }, + + formatAddFilterItem(filtr) { + if (!filtr.key) { + filtr = this.filters[filtr] + } + return filtr.label || filtr.key + }, + + % if getattr(grid, 'click_handlers', None): + cellClick(row, column, rowIndex, columnIndex) { + % for key in grid.click_handlers: + if (column._props.field == '${key}') { + ${grid.click_handlers[key]}(row) + } + % endfor + }, + % endif + + copyDirectLink() { + + if (navigator.clipboard) { + // this is the way forward, but requires HTTPS + navigator.clipboard.writeText(this.directLink) + + } else { + // use deprecated 'copy' command, but this just + // tells the browser to copy currently-selected + // text..which means we first must "add" some text + // to screen, and auto-select that, before copying + // to clipboard + this.shareLink = this.directLink + this.$nextTick(() => { + let input = this.$refs.shareLink.$el.firstChild + input.select() + document.execCommand('copy') + // re-hide the dummy input + this.shareLink = null + }) + } + + this.$buefy.toast.open({ + message: "Link was copied to clipboard", + type: 'is-info', + duration: 2000, // 2 seconds + }) + }, + + addRowClass(index, className) { + + // TODO: this may add duplicated name to class string + // (not a serious problem i think, but could be improved) + this.rowStatusMap[index] = (this.rowStatusMap[index] || '') + + ' ' + className + + // nb. for some reason b-table does not always "notice" + // when we update status; so we force it to refresh + this.$forceUpdate() + }, + + getRowClass(row, index) { + return this.rowStatusMap[index] + }, + + getBasicParams() { + const params = { + % if grid.paginated and grid.paginate_on_backend: + pagesize: this.perPage, + page: this.currentPage, + % endif + } + % if grid.sortable and grid.sort_on_backend: + for (let i = 1; i <= this.sorters.length; i++) { + params['sort'+i+'key'] = this.sorters[i-1].field + params['sort'+i+'dir'] = this.sorters[i-1].order + } + % endif + return params + }, + + getFilterParams() { + let params = {} + for (var key in this.filters) { + var filter = this.filters[key] + if (filter.active) { + params[key] = filter.value + params[key+'.verb'] = filter.verb + } + } + if (Object.keys(params).length) { + params.filter = true + } + return params + }, + + getAllParams() { + return {...this.getBasicParams(), + ...this.getFilterParams()} + }, + + ## nb. this is meant to call for a grid which is hidden at + ## first, when it is first being shown to the user. and if + ## it was initialized with empty data set. + async fetchFirstData() { + if (this.fetchedFirstData) { + return + } + await this.loadAsyncData() + this.fetchedFirstData = true + }, + + ## TODO: i noticed buefy docs show using `async` keyword here, + ## so now i am too. knowing nothing at all of if/how this is + ## supposed to improve anything. we shall see i guess + async loadAsyncData(params, success, failure) { + + if (params === undefined || params === null) { + params = new URLSearchParams(this.getBasicParams()) + } else { + params = new URLSearchParams(params) + } + if (!params.has('partial')) { + params.append('partial', true) + } + params = params.toString() + + this.loading = true + this.$http.get(`${'$'}{this.ajaxDataUrl}?${'$'}{params}`).then(response => { + if (!response.data.error) { + ${grid.vue_component}CurrentData = response.data.data + this.data = ${grid.vue_component}CurrentData + % if grid.paginated and grid.paginate_on_backend: + this.pagerStats = response.data.pager_stats + % endif + this.rowStatusMap = response.data.row_status_map || {} + this.loading = false + this.savingDefaults = false + this.checkedRows = this.locateCheckedRows(response.data.checked_rows || []) + if (success) { + success() + } + } else { + this.$buefy.toast.open({ + message: response.data.error, + type: 'is-danger', + duration: 2000, // 4 seconds + }) + this.loading = false + this.savingDefaults = false + if (failure) { + failure() + } + } + }) + .catch((error) => { + ${grid.vue_component}CurrentData = [] + this.data = [] + % if grid.paginated and grid.paginate_on_backend: + this.pagerStats = {} + % endif + this.loading = false + this.savingDefaults = false + if (failure) { + failure() + } + throw error + }) + }, + + locateCheckedRows(checked) { + let rows = [] + if (checked) { + for (let i = 0; i < this.data.length; i++) { + if (checked.includes(i)) { + rows.push(this.data[i]) + } + } + } + return rows + }, + + onPageChange(page) { + this.currentPage = page + this.loadAsyncData() + }, + + perPageUpdated(value) { + + // nb. buefy passes value, oruga passes event + if (value.target) { + value = event.target.value + } + + this.loadAsyncData({ + pagesize: value, + }) + }, + + % if grid.sortable and grid.sort_on_backend: + + onSort(field, order, event) { + + ## nb. buefy passes field name; oruga passes field object + % if request.use_oruga: + field = field.field + % endif + + % if grid.sort_multiple: + + // did user ctrl-click the column header? + if (event.ctrlKey) { + + // toggle direction for existing, or add new sorter + const sorter = this.sorters.filter(s => s.field === field)[0] + if (sorter) { + sorter.order = sorter.order === 'desc' ? 'asc' : 'desc' + } else { + this.sorters.push({field, order}) + } + + // apply multi-column sorting + this.sortingPriority = this.sorters + + } else { + + % endif + + // sort by single column only + this.sorters = [{field, order}] + + % if grid.sort_multiple: + // multi-column sort not engaged + this.sortingPriority = [] + } + % endif + + // nb. always reset to first page when sorting changes + this.currentPage = 1 + this.loadAsyncData() + }, + + % if grid.sort_multiple: + + sortingPriorityRemoved(field) { + + // prune from active sorters + this.sorters = this.sorters.filter(s => s.field !== field) + + // nb. even though we might have just one sorter + // now, we are still technically in multi-sort mode + this.sortingPriority = this.sorters + + this.loadAsyncData() + }, + + % endif + + % endif + + resetView() { + this.loading = true + + // use current url proper, plus reset param + let url = '?reset-view=true' + + // add current hash, to preserve that in redirect + if (location.hash) { + url += '&hash=' + location.hash.slice(1) + } + + location.href = url + }, + + addFilterInit() { + this.addFilterShow = true + + this.$nextTick(() => { + const input = this.$refs.addFilterAutocomplete.$el.querySelector('input') + input.addEventListener('keydown', this.addFilterKeydown) + this.$refs.addFilterAutocomplete.focus() + }) + }, + + addFilterHide() { + const input = this.$refs.addFilterAutocomplete.$el.querySelector('input') + input.removeEventListener('keydown', this.addFilterKeydown) + this.addFilterTerm = '' + this.addFilterShow = false + }, + + addFilterKeydown(event) { + + // ESC will clear searchbox + if (event.which == 27) { + this.addFilterHide() + } + }, + + addFilterSelect(filtr) { + this.addFilter(filtr.key) + this.addFilterHide() + }, + + addFilter(filter_key) { + + // show corresponding grid filter + this.filters[filter_key].visible = true + this.filters[filter_key].active = true + + // track down the component + var gridFilter = null + for (var gf of this.$refs.gridFilters) { + if (gf.filter.key == filter_key) { + gridFilter = gf + break + } + } + + // tell component to focus the value field, ASAP + this.$nextTick(function() { + gridFilter.focusValue() + }) + + }, + + applyFilters(params) { + if (params === undefined) { + params = {} + } + + // merge in actual filter params + // cf. https://stackoverflow.com/a/171256 + params = {...params, ...this.getFilterParams()} + + // hide inactive filters + for (var key in this.filters) { + var filter = this.filters[key] + if (!filter.active) { + filter.visible = false + } + } + + // set some explicit params + params.partial = true + params.filter = true + + params = new URLSearchParams(params) + this.loadAsyncData(params) + this.appliedFiltersHook() + }, + + appliedFiltersHook() {}, + + clearFilters() { + + // explicitly deactivate all filters + for (var key in this.filters) { + this.filters[key].active = false + } + + // then just "apply" as normal + this.applyFilters() + }, + + // explicitly set filters for the grid, to the given set. + // this totally overrides whatever might be current. the + // new filter set should look like: + // + // [ + // {key: 'status_code', + // verb: 'equal', + // value: 1}, + // {key: 'description', + // verb: 'contains', + // value: 'whatever'}, + // ] + // + setFilters(newFilters) { + for (let key in this.filters) { + let filter = this.filters[key] + let active = false + for (let newFilter of newFilters) { + if (newFilter.key == key) { + active = true + filter.active = true + filter.visible = true + filter.verb = newFilter.verb + filter.value = newFilter.value + break + } + } + if (!active) { + filter.active = false + filter.visible = false + } + } + this.applyFilters() + }, + + saveDefaults() { + this.savingDefaults = true + + // apply current filters as normal, but add special directive + this.applyFilters({'save-current-filters-as-defaults': true}) + }, + + deleteObject(event) { + // we let parent component/app deal with this, in whatever way makes sense... + // TODO: should we ever provide anything besides the URL for this? + this.$emit('deleteActionClicked', event.target.href) + }, + + checkedRowUUIDs() { + let uuids = [] + for (let row of this.$data.checkedRows) { + uuids.push(row.uuid) + } + return uuids + }, + + allRowUUIDs() { + let uuids = [] + for (let row of this.data) { + uuids.push(row.uuid) + } + return uuids + }, + + // when a user clicks a row, handle as if they clicked checkbox. + // note that this method is only used if table is "checkable" + rowClick(row) { + let i = this.checkedRows.indexOf(row) + if (i >= 0) { + this.checkedRows.splice(i, 1) + } else { + this.checkedRows.push(row) + } + % if getattr(grid, 'check_handler', None): + this.${grid.check_handler}(this.checkedRows, row) + % endif + }, + } + } + +</script> diff --git a/tailbone/templates/grids/filter-components.mako b/tailbone/templates/grids/filter-components.mako new file mode 100644 index 00000000..e4915065 --- /dev/null +++ b/tailbone/templates/grids/filter-components.mako @@ -0,0 +1,350 @@ +## -*- coding: utf-8; -*- + +<%def name="make_grid_filter_components()"> + ${self.make_grid_filter_numeric_value_component()} + ${self.make_grid_filter_date_value_component()} + ${self.make_grid_filter_component()} +</%def> + +<%def name="make_grid_filter_numeric_value_component()"> + <% request.register_component('grid-filter-numeric-value', 'GridFilterNumericValue') %> + <script type="text/x-template" id="grid-filter-numeric-value-template"> + <div class="level"> + <div class="level-left"> + <div class="level-item"> + <b-input v-model="startValue" + ref="startValue" + @input="startValueChanged"> + </b-input> + </div> + <div v-show="wantsRange" + class="level-item"> + and + </div> + <div v-show="wantsRange" + class="level-item"> + <b-input v-model="endValue" + ref="endValue" + @input="endValueChanged"> + </b-input> + </div> + </div> + </div> + </script> + <script> + + const GridFilterNumericValue = { + template: '#grid-filter-numeric-value-template', + props: { + ${'modelValue' if request.use_oruga else 'value'}: String, + wantsRange: Boolean, + }, + data() { + const value = this.${'modelValue' if request.use_oruga else 'value'} + const {startValue, endValue} = this.parseValue(value) + return { + startValue, + endValue, + } + }, + watch: { + // when changing from e.g. 'equal' to 'between' filter verbs, + // must proclaim new filter value, to reflect (lack of) range + wantsRange(val) { + if (val) { + this.$emit('input', this.startValue + '|' + this.endValue) + } else { + this.$emit('input', this.startValue) + } + }, + + ${'modelValue' if request.use_oruga else 'value'}(to, from) { + const parsed = this.parseValue(to) + this.startValue = parsed.startValue + this.endValue = parsed.endValue + }, + }, + methods: { + focus() { + this.$refs.startValue.focus() + }, + startValueChanged(value) { + if (this.wantsRange) { + value += '|' + this.endValue + } + this.$emit("${'update:modelValue' if request.use_oruga else 'input'}", value) + }, + endValueChanged(value) { + value = this.startValue + '|' + value + this.$emit("${'update:modelValue' if request.use_oruga else 'input'}", value) + }, + + parseValue(value) { + let startValue = null + let endValue = null + if (this.wantsRange) { + if (value.includes('|')) { + let values = value.split('|') + if (values.length == 2) { + startValue = values[0] + endValue = values[1] + } else { + startValue = value + } + } else { + startValue = value + } + } else { + startValue = value + } + + return { + startValue, + endValue, + } + }, + }, + } + + Vue.component('grid-filter-numeric-value', GridFilterNumericValue) + + </script> +</%def> + +<%def name="make_grid_filter_date_value_component()"> + <% request.register_component('grid-filter-date-value', 'GridFilterDateValue') %> + <script type="text/x-template" id="grid-filter-date-value-template"> + <div class="level"> + <div class="level-left"> + <div class="level-item"> + <tailbone-datepicker v-model="startDate" + ref="startDate" + @${'update:model-value' if request.use_oruga else 'input'}="startDateChanged"> + </tailbone-datepicker> + </div> + <div v-show="dateRange" + class="level-item"> + and + </div> + <div v-show="dateRange" + class="level-item"> + <tailbone-datepicker v-model="endDate" + ref="endDate" + @${'update:model-value' if request.use_oruga else 'input'}="endDateChanged"> + </tailbone-datepicker> + </div> + </div> + </div> + </script> + <script> + + const GridFilterDateValue = { + template: '#grid-filter-date-value-template', + props: { + ${'modelValue' if request.use_oruga else 'value'}: String, + dateRange: Boolean, + }, + data() { + let startDate = null + let endDate = null + let value = this.${'modelValue' if request.use_oruga else 'value'} + if (value) { + + if (this.dateRange) { + let values = value.split('|') + if (values.length == 2) { + startDate = this.parseDate(values[0]) + endDate = this.parseDate(values[1]) + } else { // no end date specified? + startDate = this.parseDate(value) + } + + } else { // not a range, so start date only + startDate = this.parseDate(value) + } + } + + return { + startDate, + endDate, + } + }, + methods: { + focus() { + this.$refs.startDate.focus() + }, + formatDate(date) { + if (date === null) { + return null + } + if (typeof(date) == 'string') { + return date + } + // just need to convert to simple ISO date format here, seems + // like there should be a more obvious way to do that? + var year = date.getFullYear() + var month = date.getMonth() + 1 + var day = date.getDate() + month = month < 10 ? '0' + month : month + day = day < 10 ? '0' + day : day + return year + '-' + month + '-' + day + }, + parseDate(value) { + if (value) { + // note, this assumes classic YYYY-MM-DD (i.e. ISO?) format + const parts = value.split('-') + return new Date(parts[0], parseInt(parts[1]) - 1, parts[2]) + } + }, + startDateChanged(value) { + value = this.formatDate(value) + if (this.dateRange) { + value += '|' + this.formatDate(this.endDate) + } + this.$emit("${'update:modelValue' if request.use_oruga else 'input'}", value) + }, + endDateChanged(value) { + value = this.formatDate(this.startDate) + '|' + this.formatDate(value) + this.$emit("${'update:modelValue' if request.use_oruga else 'input'}", value) + }, + }, + } + + Vue.component('grid-filter-date-value', GridFilterDateValue) + + </script> +</%def> + +<%def name="make_grid_filter_component()"> + <% request.register_component('grid-filter', 'GridFilter') %> + <script type="text/x-template" id="grid-filter-template"> + <div class="filter" + v-show="filter.visible" + style="display: flex; gap: 0.5rem;"> + + <div class="filter-fieldname"> + <b-button @click="filter.active = !filter.active" + icon-pack="fas" + :icon-left="filter.active ? 'check' : null"> + {{ filter.label }} + </b-button> + </div> + + <div v-show="filter.active" + style="display: flex; gap: 0.5rem;"> + + <b-select v-model="filter.verb" + @input="focusValue()" + class="filter-verb"> + <option v-for="verb in filter.verbs" + :key="verb" + :value="verb"> + {{ filter.verb_labels[verb] }} + </option> + </b-select> + + ## only one of the following "value input" elements will be rendered + + <grid-filter-date-value v-if="filter.data_type == 'date'" + v-model="filter.value" + v-show="valuedVerb()" + :date-range="filter.verb == 'between'" + ref="valueInput"> + </grid-filter-date-value> + + <b-select v-if="filter.data_type == 'choice'" + v-model="filter.value" + v-show="valuedVerb()" + ref="valueInput"> + <option v-for="choice in filter.choices" + :key="choice" + :value="choice"> + {{ filter.choice_labels[choice] || choice }} + </option> + </b-select> + + <grid-filter-numeric-value v-if="filter.data_type == 'number'" + v-model="filter.value" + v-show="valuedVerb()" + :wants-range="filter.verb == 'between'" + ref="valueInput"> + </grid-filter-numeric-value> + + <b-input v-if="filter.data_type == 'string' && !multiValuedVerb()" + v-model="filter.value" + v-show="valuedVerb()" + ref="valueInput"> + </b-input> + + <b-input v-if="filter.data_type == 'string' && multiValuedVerb()" + type="textarea" + v-model="filter.value" + v-show="valuedVerb()" + ref="valueInput"> + </b-input> + + </div> + </div> + </script> + <script> + + const GridFilter = { + template: '#grid-filter-template', + props: { + filter: Object + }, + + methods: { + + changeVerb() { + // set focus to value input, "as quickly as we can" + this.$nextTick(function() { + this.focusValue() + }) + }, + + valuedVerb() { + /* this returns true if the filter's current verb should expose value input(s) */ + + // if filter has no "valueless" verbs, then all verbs should expose value inputs + if (!this.filter.valueless_verbs) { + return true + } + + // if filter *does* have valueless verbs, check if "current" verb is valueless + if (this.filter.valueless_verbs.includes(this.filter.verb)) { + return false + } + + // current verb is *not* valueless + return true + }, + + multiValuedVerb() { + /* this returns true if the filter's current verb should expose a multi-value input */ + + // if filter has no "multi-value" verbs then we safely assume false + if (!this.filter.multiple_value_verbs) { + return false + } + + // if filter *does* have multi-value verbs, see if "current" is one + if (this.filter.multiple_value_verbs.includes(this.filter.verb)) { + return true + } + + // current verb is not multi-value + return false + }, + + focusValue: function() { + this.$refs.valueInput.focus() + // this.$refs.valueInput.select() + } + } + } + + Vue.component('grid-filter', GridFilter) + + </script> +</%def> diff --git a/tailbone/templates/grids/filters.mako b/tailbone/templates/grids/filters.mako deleted file mode 100644 index 857f53b1..00000000 --- a/tailbone/templates/grids/filters.mako +++ /dev/null @@ -1,38 +0,0 @@ -## -*- coding: utf-8; -*- -<div class="newfilters"> - - ${h.form(form.action_url, method='get')} - ${h.hidden('reset-to-default-filters', value='false')} - ${h.hidden('save-current-filters-as-defaults', value='false')} - - <fieldset> - <legend>Filters</legend> - % for filtr in form.iter_filters(): - <div class="filter" id="filter-${filtr.key}" data-key="${filtr.key}"${' style="display: none;"' if not filtr.active else ''|n}> - ${h.checkbox('{}-active'.format(filtr.key), class_='active', id='filter-active-{}'.format(filtr.key), checked=filtr.active)} - <label for="filter-active-${filtr.key}">${filtr.label}</label> - <div class="inputs" style="display: inline-block;"> - ${form.filter_verb(filtr)} - ${form.filter_value(filtr)} - </div> - </div> - % endfor - </fieldset> - - <div class="buttons"> - <button type="submit" id="apply-filters">Apply Filters</button> - <select id="add-filter"> - <option value="">Add a Filter</option> - % for filtr in form.iter_filters(): - <option value="${filtr.key}"${' disabled="disabled"' if filtr.active else ''|n}>${filtr.label}</option> - % endfor - </select> - <button type="button" id="default-filters">Default View</button> - <button type="button" id="clear-filters">No Filters</button> - % if allow_save_defaults and request.user: - <button type="button" id="save-defaults">Save Defaults</button> - % endif - </div> - - ${h.end_form()} -</div><!-- newfilters --> diff --git a/tailbone/templates/grids/filters_buefy.mako b/tailbone/templates/grids/filters_buefy.mako deleted file mode 100644 index 914c98d4..00000000 --- a/tailbone/templates/grids/filters_buefy.mako +++ /dev/null @@ -1,59 +0,0 @@ -## -*- coding: utf-8; -*- - -<form action="${form.action_url}" method="GET" v-on:submit.prevent="applyFilters()"> - - <grid-filter v-for="key in filtersSequence" - :key="key" - :filter="filters[key]" - ref="gridFilters"> - </grid-filter> - - <b-field grouped> - - <b-button type="is-primary" - native-type="submit" - icon-pack="fas" - icon-left="check" - class="control"> - Apply Filters - </b-button> - - <b-select @input="addFilter" - placeholder="Add Filter" - v-model="selectedFilter"> - <option v-for="key in filtersSequence" - :key="key" - :value="key" - ## TODO: previous code here was simpler; trying to track down - ## why disabled options don't appear so on Windows Chrome (?) - :disabled="filters[key].visible ? 'disabled' : null"> - {{ filters[key].label }} - </option> - </b-select> - - <b-button @click="resetView()" - icon-pack="fas" - icon-left="home" - class="control"> - Default View - </b-button> - - <b-button @click="clearFilters()" - icon-pack="fas" - icon-left="trash" - class="control"> - No Filters - </b-button> - - % if allow_save_defaults and request.user: - <b-button @click="saveDefaults()" - icon-pack="fas" - icon-left="save" - class="control"> - Save Defaults - </b-button> - % endif - - </b-field> - -</form> diff --git a/tailbone/templates/grids/grid.mako b/tailbone/templates/grids/grid.mako deleted file mode 100644 index 146fcab6..00000000 --- a/tailbone/templates/grids/grid.mako +++ /dev/null @@ -1,21 +0,0 @@ -## -*- coding: utf-8; -*- -<div class="grid ${grid_class}" data-delete-speedbump="${'true' if grid.delete_speedbump else 'false'}" ${h.HTML.render_attrs(grid_attrs)}> - <table> - ${grid.make_webhelpers_grid()} - </table> - % if grid.pageable and grid.pager: - <div class="pager"> - <p class="showing"> - ${"showing {} thru {} of {:,d}".format(grid.pager.first_item, grid.pager.last_item, grid.pager.item_count)} - % if grid.pager.page_count > 1: - ${"(page {} of {:,d})".format(grid.pager.page, grid.pager.page_count)} - % endif - </p> - <p class="page-links"> - ${h.select('pagesize', grid.pager.items_per_page, grid.get_pagesize_options())} - per page - ${grid.pager.pager('$link_first $link_previous ~1~ $link_next $link_last', symbol_next='next', symbol_previous='prev')|n} - </p> - </div> - % endif -</div> diff --git a/tailbone/templates/grids/search.mako b/tailbone/templates/grids/search.mako deleted file mode 100644 index fbb030f9..00000000 --- a/tailbone/templates/grids/search.mako +++ /dev/null @@ -1,37 +0,0 @@ -## -*- coding: utf-8 -*- -<div class="filters" url="${search.request.current_route_url()}"> - ${search.begin()} - ${search.hidden('filters', 'true')} - <% visible = [] %> - % for f in search.sorted_filters(): - <div class="filter" id="filter-${f.name}"${' style="display: none;"' if not search.config.get('include_filter_'+f.name) else ''|n}> - ${search.checkbox('include_filter_'+f.name)} - <label for="${f.name}">${f.label}</label> - ${f.types_select()} - <div class="value"> - ${f.value_control()} - </div> - </div> - % if search.config.get('include_filter_'+f.name): - <% visible.append(f.name) %> - % endif - % endfor - <div class="buttons"> - ${search.add_filter(visible)} - ${search.submit('submit', "Search", style='display: none;' if not visible else None)} - <button type="reset"${' style="display: none;"' if not visible else ''|n}>Reset</button> - </div> - ${search.end()} - % if visible: - <script language="javascript" type="text/javascript"> - filters_to_disable = [ - % for field in visible: - '${field}', - % endfor - ]; - $(function() { - disable_filter_options(); - }); - </script> - % endif -</div> diff --git a/tailbone/templates/grids/vue_template.mako b/tailbone/templates/grids/vue_template.mako new file mode 100644 index 00000000..625f046b --- /dev/null +++ b/tailbone/templates/grids/vue_template.mako @@ -0,0 +1,3 @@ +## -*- coding: utf-8; -*- +<%inherit file="/grids/complete.mako" /> +${parent.body()} diff --git a/tailbone/templates/home.mako b/tailbone/templates/home.mako index e4f7d072..54e44d57 100644 --- a/tailbone/templates/home.mako +++ b/tailbone/templates/home.mako @@ -1,33 +1,7 @@ ## -*- coding: utf-8; -*- -<%inherit file="/page.mako" /> -<%namespace name="base_meta" file="/base_meta.mako" /> - -<%def name="title()">Home</%def> - -<%def name="extra_styles()"> - ${parent.extra_styles()} - <style type="text/css"> - .logo { - text-align: center; - } - .logo img { - margin: 3em auto; - max-height: 350px; - max-width: 800px; - } - </style> -</%def> +<%inherit file="wuttaweb:templates/home.mako" /> +## DEPRECATED; remains for back-compat <%def name="render_this_page()"> ${self.page_content()} </%def> - -<%def name="page_content()"> - <div class="logo"> - ${h.image(image_url, "{} logo".format(capture(base_meta.app_title)))} - <h1>Welcome to ${base_meta.app_title()}</h1> - </div> -</%def> - - -${parent.body()} diff --git a/tailbone/templates/importing/configure.mako b/tailbone/templates/importing/configure.mako index cbe8463c..2445341d 100644 --- a/tailbone/templates/importing/configure.mako +++ b/tailbone/templates/importing/configure.mako @@ -6,52 +6,73 @@ <h3 class="is-size-3">Designated Handlers</h3> - <b-table :data="handlersData" + <${b}-table :data="handlersData" narrowed icon-pack="fas" :default-sort="['host_title', 'asc']"> - <template slot-scope="props"> - <b-table-column field="host_title" label="Data Source" sortable> - {{ props.row.host_title }} - </b-table-column> - <b-table-column field="local_title" label="Data Target" sortable> - {{ props.row.local_title }} - </b-table-column> - <b-table-column field="direction" label="Direction" sortable> - {{ props.row.direction_display }} - </b-table-column> - <b-table-column field="handler_spec" label="Handler Spec" sortable> - {{ props.row.handler_spec }} - </b-table-column> - <b-table-column field="cmd" label="Command" sortable> - {{ props.row.command }} {{ props.row.subcommand }} - </b-table-column> - <b-table-column field="runas" label="Default Runas" sortable> - {{ props.row.default_runas }} - </b-table-column> - <b-table-column label="Actions"> - <a href="#" class="grid-action" - @click.prevent="editHandler(props.row)"> - <i class="fas fa-edit"></i> - Edit - </a> - </b-table-column> - </template> - <template slot="empty"> - <section class="section"> - <div class="content has-text-grey has-text-centered"> - <p> - <b-icon - pack="fas" - icon="fas fa-sad-tear" - size="is-large"> - </b-icon> - </p> - <p>Nothing here.</p> - </div> - </section> - </template> - </b-table> + <${b}-table-column field="host_title" + label="Data Source" + v-slot="props" + sortable> + {{ props.row.host_title }} + </${b}-table-column> + <${b}-table-column field="local_title" + label="Data Target" + v-slot="props" + sortable> + {{ props.row.local_title }} + </${b}-table-column> + <${b}-table-column field="direction" + label="Direction" + v-slot="props" + sortable> + {{ props.row.direction_display }} + </${b}-table-column> + <${b}-table-column field="handler_spec" + label="Handler Spec" + v-slot="props" + sortable> + {{ props.row.handler_spec }} + </${b}-table-column> + <${b}-table-column field="cmd" + label="Command" + v-slot="props" + sortable> + {{ props.row.command }} {{ props.row.subcommand }} + </${b}-table-column> + <${b}-table-column field="runas" + label="Default Runas" + v-slot="props" + sortable> + {{ props.row.default_runas }} + </${b}-table-column> + <${b}-table-column label="Actions" + v-slot="props"> + <a href="#" class="grid-action" + @click.prevent="editHandler(props.row)"> + % if request.use_oruga: + <o-icon icon="edit" /> + % else: + <i class="fas fa-edit"></i> + % endif + Edit + </a> + </${b}-table-column> + <template #empty> + <section class="section"> + <div class="content has-text-grey has-text-centered"> + <p> + <b-icon + pack="fas" + icon="sad-tear" + size="is-large"> + </b-icon> + </p> + <p>Nothing here.</p> + </div> + </section> + </template> + </${b}-table> <b-modal :active.sync="editHandlerShowDialog"> <div class="card"> @@ -123,9 +144,9 @@ </b-modal> </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> ThisPageData.handlersData = ${json.dumps(handlers_data)|n} @@ -182,6 +203,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/importing/runjob.mako b/tailbone/templates/importing/runjob.mako index 2bc2a4e9..a9625bc3 100644 --- a/tailbone/templates/importing/runjob.mako +++ b/tailbone/templates/importing/runjob.mako @@ -63,28 +63,26 @@ </div> </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> - ${form.component_studly}Data.submittingRun = false - ${form.component_studly}Data.submittingExplain = false - ${form.component_studly}Data.runJob = false + ${form.vue_component}Data.submittingRun = false + ${form.vue_component}Data.submittingExplain = false + ${form.vue_component}Data.runJob = false - ${form.component_studly}.methods.submitRun = function() { + ${form.vue_component}.methods.submitRun = function() { this.submittingRun = true this.runJob = true this.$nextTick(() => { - this.$refs.${form.component_studly}.submit() + this.$refs.${form.vue_component}.submit() }) } - ${form.component_studly}.methods.submitExplain = function() { + ${form.vue_component}.methods.submitExplain = function() { this.submittingExplain = true - this.$refs.${form.component_studly}.submit() + this.$refs.${form.vue_component}.submit() } </script> </%def> - -${parent.body()} diff --git a/tailbone/templates/labels/profiles/view.mako b/tailbone/templates/labels/profiles/view.mako index 2609ffbf..b93570af 100644 --- a/tailbone/templates/labels/profiles/view.mako +++ b/tailbone/templates/labels/profiles/view.mako @@ -35,7 +35,7 @@ % for name, display in printer.required_settings.items(): <div class="field-wrapper"> <label>${display}</label> - <div class="field">${instance.get_printer_setting(name) or ''}</div> + <div class="field">${label_handler.get_printer_setting(instance, name) or ''}</div> </div> % endfor </div> diff --git a/tailbone/templates/login.mako b/tailbone/templates/login.mako index 0e65b4ad..d2ea7828 100644 --- a/tailbone/templates/login.mako +++ b/tailbone/templates/login.mako @@ -1,76 +1,17 @@ ## -*- coding: utf-8; -*- -<%inherit file="/form.mako" /> -<%namespace name="base_meta" file="/base_meta.mako" /> - -<%def name="title()">Login</%def> - -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - ${h.javascript_link(request.static_url('tailbone:static/js/login.js'))} -</%def> +<%inherit file="wuttaweb:templates/auth/login.mako" /> +## TODO: this will not be needed with wuttaform <%def name="extra_styles()"> ${parent.extra_styles()} - % if use_buefy: - <style type="text/css"> - .logo img { - display: block; - margin: 3rem auto; - max-height: 350px; - max-width: 800px; - } - - /* must force a particular label with, in order to make sure */ - /* the username and password inputs are the same size */ - .field.is-horizontal .field-label .label { - text-align: left; - width: 6rem; - } - - .buttons { - justify-content: right; - } - </style> - % else: - ${h.stylesheet_link(request.static_url('tailbone:static/css/login.css'))} - % endif -</%def> - -<%def name="logo()"> - ${h.image(image_url, "{} logo".format(capture(base_meta.app_title)))} -</%def> - -<%def name="login_form()"> - <div class="form"> - ${form.render_deform(form_kwargs={'data-ajax': 'false'})|n} - </div> + <style> + .card-content .buttons { + justify-content: right; + } + </style> </%def> +## DEPRECATED; remains for back-compat <%def name="render_this_page()"> ${self.page_content()} </%def> - -<%def name="page_content()"> - <div class="logo"> - ${self.logo()} - </div> - - % if use_buefy: - - <div class="columns is-centered"> - <div class="column is-narrow"> - <div class="card"> - <div class="card-content"> - <tailbone-form></tailbone-form> - </div> - </div> - </div> - </div> - - % else: - ${self.login_form()} - % endif -</%def> - - -${parent.body()} diff --git a/tailbone/templates/luigi/configure.mako b/tailbone/templates/luigi/configure.mako index bac57b75..de364828 100644 --- a/tailbone/templates/luigi/configure.mako +++ b/tailbone/templates/luigi/configure.mako @@ -22,45 +22,56 @@ </div> <div class="block" style="padding-left: 2rem; display: flex;"> - <b-table :data="overnightTasks"> - <template slot-scope="props"> - <!-- <b-table-column field="key" --> - <!-- label="Key" --> - <!-- sortable> --> - <!-- {{ props.row.key }} --> - <!-- </b-table-column> --> - <b-table-column field="key" - label="Key"> - {{ props.row.key }} - </b-table-column> - <b-table-column field="description" - label="Description"> - {{ props.row.description }} - </b-table-column> - <b-table-column field="class_name" - label="Class Name"> - {{ props.row.class_name }} - </b-table-column> - <b-table-column field="script" - label="Script"> - {{ props.row.script }} - </b-table-column> - <b-table-column label="Actions"> - <a href="#" - @click.prevent="overnightTaskEdit(props.row)"> - <i class="fas fa-edit"></i> - Edit - </a> - - <a href="#" - class="has-text-danger" - @click.prevent="overnightTaskDelete(props.row)"> - <i class="fas fa-trash"></i> - Delete - </a> - </b-table-column> - </template> - </b-table> + <${b}-table :data="overnightTasks"> + <!-- <${b}-table-column field="key" --> + <!-- label="Key" --> + <!-- sortable> --> + <!-- {{ props.row.key }} --> + <!-- </${b}-table-column> --> + <${b}-table-column field="key" + label="Key" + v-slot="props"> + {{ props.row.key }} + </${b}-table-column> + <${b}-table-column field="description" + label="Description" + v-slot="props"> + {{ props.row.description }} + </${b}-table-column> + <${b}-table-column field="class_name" + label="Class Name" + v-slot="props"> + {{ props.row.class_name }} + </${b}-table-column> + <${b}-table-column field="script" + label="Script" + v-slot="props"> + {{ props.row.script }} + </${b}-table-column> + <${b}-table-column label="Actions" + v-slot="props"> + <a href="#" + @click.prevent="overnightTaskEdit(props.row)"> + % if request.use_oruga: + <o-icon icon="edit" /> + % else: + <i class="fas fa-edit"></i> + % endif + Edit + </a> + + <a href="#" + class="has-text-danger" + @click.prevent="overnightTaskDelete(props.row)"> + % if request.use_oruga: + <o-icon icon="trash" /> + % else: + <i class="fas fa-trash"></i> + % endif + Delete + </a> + </${b}-table-column> + </${b}-table> <b-modal has-modal-card :active.sync="overnightTaskShowDialog"> @@ -74,31 +85,31 @@ <b-field label="Key" :type="overnightTaskKey ? null : 'is-danger'"> <b-input v-model.trim="overnightTaskKey" - ref="overnightTaskKey"> - </b-input> + ref="overnightTaskKey" + expanded /> </b-field> <b-field label="Description" :type="overnightTaskDescription ? null : 'is-danger'"> <b-input v-model.trim="overnightTaskDescription" - ref="overnightTaskDescription"> - </b-input> + ref="overnightTaskDescription" + expanded /> </b-field> <b-field label="Module"> - <b-input v-model.trim="overnightTaskModule"> - </b-input> + <b-input v-model.trim="overnightTaskModule" + expanded /> </b-field> <b-field label="Class Name"> - <b-input v-model.trim="overnightTaskClass"> - </b-input> + <b-input v-model.trim="overnightTaskClass" + expanded /> </b-field> <b-field label="Script"> - <b-input v-model.trim="overnightTaskScript"> - </b-input> + <b-input v-model.trim="overnightTaskScript" + expanded /> </b-field> <b-field label="Notes"> <b-input v-model.trim="overnightTaskNotes" - type="textarea"> - </b-input> + type="textarea" + expanded /> </b-field> </section> @@ -136,44 +147,56 @@ </div> <div class="block" style="padding-left: 2rem; display: flex;"> - <b-table :data="backfillTasks"> - <template slot-scope="props"> - <b-table-column field="key" - label="Key"> - {{ props.row.key }} - </b-table-column> - <b-table-column field="description" - label="Description"> - {{ props.row.description }} - </b-table-column> - <b-table-column field="script" - label="Script"> - {{ props.row.script }} - </b-table-column> - <b-table-column field="forward" - label="Orientation"> - {{ props.row.forward ? "Forward" : "Backward" }} - </b-table-column> - <b-table-column field="target_date" - label="Target Date"> - {{ props.row.target_date }} - </b-table-column> - <b-table-column label="Actions"> - <a href="#" - @click.prevent="backfillTaskEdit(props.row)"> - <i class="fas fa-edit"></i> - Edit - </a> - - <a href="#" - class="has-text-danger" - @click.prevent="backfillTaskDelete(props.row)"> - <i class="fas fa-trash"></i> - Delete - </a> - </b-table-column> - </template> - </b-table> + <${b}-table :data="backfillTasks"> + <${b}-table-column field="key" + label="Key" + v-slot="props"> + {{ props.row.key }} + </${b}-table-column> + <${b}-table-column field="description" + label="Description" + v-slot="props"> + {{ props.row.description }} + </${b}-table-column> + <${b}-table-column field="script" + label="Script" + v-slot="props"> + {{ props.row.script }} + </${b}-table-column> + <${b}-table-column field="forward" + label="Orientation" + v-slot="props"> + {{ props.row.forward ? "Forward" : "Backward" }} + </${b}-table-column> + <${b}-table-column field="target_date" + label="Target Date" + v-slot="props"> + {{ props.row.target_date }} + </${b}-table-column> + <${b}-table-column label="Actions" + v-slot="props"> + <a href="#" + @click.prevent="backfillTaskEdit(props.row)"> + % if request.use_oruga: + <o-icon icon="edit" /> + % else: + <i class="fas fa-edit"></i> + % endif + Edit + </a> + + <a href="#" + class="has-text-danger" + @click.prevent="backfillTaskDelete(props.row)"> + % if request.use_oruga: + <o-icon icon="trash" /> + % else: + <i class="fas fa-trash"></i> + % endif + Delete + </a> + </${b}-table-column> + </${b}-table> <b-modal has-modal-card :active.sync="backfillTaskShowDialog"> @@ -187,19 +210,19 @@ <b-field label="Key" :type="backfillTaskKey ? null : 'is-danger'"> <b-input v-model.trim="backfillTaskKey" - ref="backfillTaskKey"> - </b-input> + ref="backfillTaskKey" + expanded /> </b-field> <b-field label="Description" :type="backfillTaskDescription ? null : 'is-danger'"> <b-input v-model.trim="backfillTaskDescription" - ref="backfillTaskDescription"> - </b-input> + ref="backfillTaskDescription" + expanded /> </b-field> <b-field label="Script" :type="backfillTaskScript ? null : 'is-danger'"> - <b-input v-model.trim="backfillTaskScript"> - </b-input> + <b-input v-model.trim="backfillTaskScript" + expanded /> </b-field> <b-field grouped> <b-field label="Orientation"> @@ -215,8 +238,8 @@ </b-field> <b-field label="Notes"> <b-input v-model.trim="backfillTaskNotes" - type="textarea"> - </b-input> + type="textarea" + expanded /> </b-field> </section> @@ -245,7 +268,8 @@ expanded> <b-input name="rattail.luigi.url" v-model="simpleSettings['rattail.luigi.url']" - @input="settingsNeedSaved = true"> + @input="settingsNeedSaved = true" + expanded> </b-input> </b-field> @@ -254,7 +278,8 @@ expanded> <b-input name="rattail.luigi.scheduler.supervisor_process_name" v-model="simpleSettings['rattail.luigi.scheduler.supervisor_process_name']" - @input="settingsNeedSaved = true"> + @input="settingsNeedSaved = true" + expanded> </b-input> </b-field> @@ -263,7 +288,8 @@ expanded> <b-input name="rattail.luigi.scheduler.restart_command" v-model="simpleSettings['rattail.luigi.scheduler.restart_command']" - @input="settingsNeedSaved = true"> + @input="settingsNeedSaved = true" + expanded> </b-input> </b-field> @@ -271,9 +297,9 @@ </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> ThisPageData.overnightTasks = ${json.dumps(overnight_tasks)|n} ThisPageData.overnightTaskShowDialog = false @@ -399,6 +425,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/luigi/index.mako b/tailbone/templates/luigi/index.mako index 6faade8d..0dd72d01 100644 --- a/tailbone/templates/luigi/index.mako +++ b/tailbone/templates/luigi/index.mako @@ -49,87 +49,95 @@ % endif </div> - % if master.has_perm('launch_overnight'): + % if master.has_perm('launch_overnight'): <h3 class="block is-size-3">Overnight Tasks</h3> - <b-table :data="overnightTasks" hoverable> - <template slot-scope="props"> - <b-table-column field="description" - label="Description"> - {{ props.row.description }} - </b-table-column> - <b-table-column field="script" - label="Command"> - {{ props.row.script || props.row.class_name }} - </b-table-column> - <b-table-column field="last_date" - label="Last Date" - :class="overnightTextClass(props.row)"> + <${b}-table :data="overnightTasks" hoverable> + <${b}-table-column field="description" + label="Description" + v-slot="props"> + {{ props.row.description }} + </${b}-table-column> + <${b}-table-column field="script" + label="Command" + v-slot="props"> + {{ props.row.script || props.row.class_name }} + </${b}-table-column> + <${b}-table-column field="last_date" + label="Last Date" + v-slot="props"> + <span :class="overnightTextClass(props.row)"> {{ props.row.last_date || "never!" }} - </b-table-column> - <b-table-column label="Actions"> - <b-button type="is-primary" - icon-pack="fas" - icon-left="arrow-circle-right" - @click="overnightTaskLaunchInit(props.row)"> - Launch - </b-button> - <b-modal has-modal-card - :active.sync="overnightTaskShowLaunchDialog"> - <div class="modal-card"> + </span> + </${b}-table-column> + <${b}-table-column label="Actions" + v-slot="props"> + <b-button type="is-primary" + icon-pack="fas" + icon-left="arrow-circle-right" + @click="overnightTaskLaunchInit(props.row)"> + Launch + </b-button> + <${b}-modal has-modal-card + % if request.use_oruga: + v-model:active="overnightTaskShowLaunchDialog" + % else: + :active.sync="overnightTaskShowLaunchDialog" + % endif + > + <div class="modal-card"> - <header class="modal-card-head"> - <p class="modal-card-title">Launch Overnight Task</p> - </header> + <header class="modal-card-head"> + <p class="modal-card-title">Launch Overnight Task</p> + </header> - <section class="modal-card-body" - v-if="overnightTask"> + <section class="modal-card-body" + v-if="overnightTask"> - <b-field label="Task" horizontal> - <span>{{ overnightTask.description }}</span> - </b-field> + <b-field label="Task" horizontal> + <span>{{ overnightTask.description }}</span> + </b-field> - <b-field label="Last Date" horizontal> - <span :class="overnightTextClass(overnightTask)"> - {{ overnightTask.last_date || "n/a" }} - </span> - </b-field> + <b-field label="Last Date" horizontal> + <span :class="overnightTextClass(overnightTask)"> + {{ overnightTask.last_date || "n/a" }} + </span> + </b-field> - <b-field label="Next Date" horizontal> - <span> - ${rattail_app.render_date(rattail_app.yesterday())} (yesterday) - </span> - </b-field> + <b-field label="Next Date" horizontal> + <span> + ${rattail_app.render_date(rattail_app.yesterday())} (yesterday) + </span> + </b-field> - <p class="block"> - Launching this task will schedule it to begin - within one minute. See the Luigi Task - Visualizer after that, for current status. - </p> + <p class="block"> + Launching this task will schedule it to begin + within one minute. See the Luigi Task + Visualizer after that, for current status. + </p> - </section> + </section> - <footer class="modal-card-foot"> - <b-button @click="overnightTaskShowLaunchDialog = false"> - Cancel - </b-button> - <b-button type="is-primary" - icon-pack="fas" - icon-left="arrow-circle-right" - @click="overnightTaskLaunchSubmit()" - :disabled="overnightTaskLaunching"> - {{ overnightTaskLaunching ? "Working, please wait..." : "Launch" }} - </b-button> - </footer> - </div> - </b-modal> - </b-table-column> - </template> + <footer class="modal-card-foot"> + <b-button @click="overnightTaskShowLaunchDialog = false"> + Cancel + </b-button> + <b-button type="is-primary" + icon-pack="fas" + icon-left="arrow-circle-right" + @click="overnightTaskLaunchSubmit()" + :disabled="overnightTaskLaunching"> + {{ overnightTaskLaunching ? "Working, please wait..." : "Launch" }} + </b-button> + </footer> + </div> + </${b}-modal> + </${b}-table-column> <template #empty> <p class="block">No tasks defined.</p> </template> - </b-table> + </${b}-table> % endif @@ -137,45 +145,55 @@ <h3 class="block is-size-3">Backfill Tasks</h3> - <b-table :data="backfillTasks" hoverable> - <template slot-scope="props"> - <b-table-column field="description" - label="Description"> - {{ props.row.description }} - </b-table-column> - <b-table-column field="script" - label="Script"> - {{ props.row.script }} - </b-table-column> - <b-table-column field="forward" - label="Orientation"> - {{ props.row.forward ? "Forward" : "Backward" }} - </b-table-column> - <b-table-column field="last_date" - label="Last Date" - :class="backfillTextClass(props.row)"> + <${b}-table :data="backfillTasks" hoverable> + <${b}-table-column field="description" + label="Description" + v-slot="props"> + {{ props.row.description }} + </${b}-table-column> + <${b}-table-column field="script" + label="Script" + v-slot="props"> + {{ props.row.script }} + </${b}-table-column> + <${b}-table-column field="forward" + label="Orientation" + v-slot="props"> + {{ props.row.forward ? "Forward" : "Backward" }} + </${b}-table-column> + <${b}-table-column field="last_date" + label="Last Date" + v-slot="props"> + <span :class="backfillTextClass(props.row)"> {{ props.row.last_date }} - </b-table-column> - <b-table-column field="target_date" - label="Target Date"> - {{ props.row.target_date }} - </b-table-column> - <b-table-column label="Actions"> - <b-button type="is-primary" - icon-pack="fas" - icon-left="arrow-circle-right" - @click="backfillTaskLaunch(props.row)"> - Launch - </b-button> - </b-table-column> - </template> + </span> + </${b}-table-column> + <${b}-table-column field="target_date" + label="Target Date" + v-slot="props"> + {{ props.row.target_date }} + </${b}-table-column> + <${b}-table-column label="Actions" + v-slot="props"> + <b-button type="is-primary" + icon-pack="fas" + icon-left="arrow-circle-right" + @click="backfillTaskLaunch(props.row)"> + Launch + </b-button> + </${b}-table-column> <template #empty> <p class="block">No tasks defined.</p> </template> - </b-table> + </${b}-table> - <b-modal has-modal-card - :active.sync="backfillTaskShowLaunchDialog"> + <${b}-modal has-modal-card + % if request.use_oruga: + v-model:active="backfillTaskShowLaunchDialog" + % else: + :active.sync="backfillTaskShowLaunchDialog" + % endif + > <div class="modal-card"> <header class="modal-card-head"> @@ -230,16 +248,16 @@ </b-button> </footer> </div> - </b-modal> + </${b}-modal> % endif </div> </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> % if master.has_perm('restart_scheduler'): @@ -356,6 +374,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/master/clone.mako b/tailbone/templates/master/clone.mako index 17b13751..4c7e4662 100644 --- a/tailbone/templates/master/clone.mako +++ b/tailbone/templates/master/clone.mako @@ -3,58 +3,40 @@ <%def name="title()">Clone ${model_title}: ${instance_title}</%def> -<%def name="render_buefy_form()"> +<%def name="render_form()"> <br /> - % if use_buefy: - <b-notification :closable="false"> - You are about to clone the following ${model_title} as a new record: - </b-notification> - % else: - <p>You are about to clone the following ${model_title} as a new record:</p> - % endif - - ${parent.render_buefy_form()} + <b-notification :closable="false"> + You are about to clone the following ${model_title} as a new record: + </b-notification> + ${parent.render_form()} </%def> <%def name="render_form_buttons()"> <br /> - % if use_buefy: - <b-notification :closable="false"> - Are you sure about this? - </b-notification> - % else: - <p>Are you sure about this?</p> - % endif + <b-notification :closable="false"> + Are you sure about this? + </b-notification> <br /> - % if use_buefy: ${h.form(request.current_route_url(), **{'@submit': 'submitForm'})} - % else: - ${h.form(request.current_route_url(), class_='autodisable')} - % endif ${h.csrf_token(request)} ${h.hidden('clone', value='clone')} <div class="buttons"> - % if use_buefy: - <once-button tag="a" href="${form.cancel_url}" - text="Whoops, nevermind..."> - </once-button> - <b-button type="is-primary" - native-type="submit" - :disabled="formSubmitting"> - {{ submitButtonText }} - </b-button> - % else: - ${h.link_to("Whoops, nevermind...", form.cancel_url, class_='button autodisable')} - ${h.submit('submit', "Yes, please clone away")} - % endif + <once-button tag="a" href="${form.cancel_url}" + text="Whoops, nevermind..."> + </once-button> + <b-button type="is-primary" + native-type="submit" + :disabled="formSubmitting"> + {{ submitButtonText }} + </b-button> </div> ${h.end_form()} </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> TailboneFormData.formSubmitting = false TailboneFormData.submitButtonText = "Yes, please clone away" @@ -66,6 +48,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/master/create.mako b/tailbone/templates/master/create.mako index 27cd404c..d7dcbbd8 100644 --- a/tailbone/templates/master/create.mako +++ b/tailbone/templates/master/create.mako @@ -1,6 +1,6 @@ ## -*- coding: utf-8; -*- <%inherit file="/form.mako" /> -<%def name="title()">New ${model_title_plural if master.creates_multiple else model_title}</%def> +<%def name="title()">New ${model_title_plural if getattr(master, 'creates_multiple', False) else model_title}</%def> ${parent.body()} diff --git a/tailbone/templates/master/delete.mako b/tailbone/templates/master/delete.mako index dded378f..d2f517d9 100644 --- a/tailbone/templates/master/delete.mako +++ b/tailbone/templates/master/delete.mako @@ -3,84 +3,45 @@ <%def name="title()">Delete ${model_title}: ${instance_title}</%def> -<%def name="context_menu_items()"> - % if not use_buefy and master.viewable and master.has_perm('view'): - <li>${h.link_to("View this {}".format(model_title), action_url('view', instance))}</li> - % endif - % if not use_buefy and master.editable and master.has_perm('edit'): - <li>${h.link_to("Edit this {}".format(model_title), action_url('edit', instance))}</li> - % endif - % if not use_buefy and master.creatable and master.show_create_link and master.has_perm('create'): - % if master.creates_multiple: - <li>${h.link_to("Create new {}".format(model_title_plural), url('{}.create'.format(route_prefix)))}</li> - % else: - <li>${h.link_to("Create a new {}".format(model_title), url('{}.create'.format(route_prefix)))}</li> - % endif - % endif -</%def> - -<%def name="render_buefy_form()"> +<%def name="render_form()"> <br /> - % if use_buefy: - <b-notification type="is-danger" :closable="false"> - You are about to delete the following ${model_title} and all associated data: - </b-notification> - % else: - <p>You are about to delete the following ${model_title} and all associated data:</p> - % endif - - ${parent.render_buefy_form()} + <b-notification type="is-danger" :closable="false"> + You are about to delete the following ${model_title} and all associated data: + </b-notification> + ${parent.render_form()} </%def> <%def name="render_form_buttons()"> <br /> - % if use_buefy: - <b-notification type="is-danger" :closable="false"> - Are you sure about this? - </b-notification> - % else: - <p>Are you sure about this?</p> - % endif + <b-notification type="is-danger" :closable="false"> + Are you sure about this? + </b-notification> <br /> - % if use_buefy: ${h.form(request.current_route_url(), **{'@submit': 'submitForm'})} - % else: - ${h.form(request.current_route_url(), class_='autodisable')} - % endif ${h.csrf_token(request)} <div class="buttons"> - % if use_buefy: - <once-button tag="a" href="${form.cancel_url}" - text="Whoops, nevermind..."> - </once-button> - <b-button type="is-primary is-danger" - native-type="submit" - :disabled="formSubmitting"> - {{ formButtonText }} - </b-button> - % else: - <a class="button" href="${form.cancel_url}">Whoops, nevermind...</a> - ${h.submit('submit', "Yes, please DELETE this data forever!", class_='button is-primary')} - % endif + <once-button tag="a" href="${form.cancel_url}" + text="Whoops, nevermind..."> + </once-button> + <b-button type="is-primary is-danger" + native-type="submit" + :disabled="formSubmitting"> + {{ formSubmitting ? "Working, please wait..." : "${form.button_label_submit}" }} + </b-button> </div> ${h.end_form()} </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> - TailboneFormData.formSubmitting = false - TailboneFormData.formButtonText = "Yes, please DELETE this data forever!" + ${form.vue_component}Data.formSubmitting = false - TailboneForm.methods.submitForm = function() { + ${form.vue_component}.methods.submitForm = function() { this.formSubmitting = true - this.formButtonText = "Working, please wait..." } </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/master/edit.mako b/tailbone/templates/master/edit.mako index de0cb524..a03912e6 100644 --- a/tailbone/templates/master/edit.mako +++ b/tailbone/templates/master/edit.mako @@ -1,40 +1,8 @@ ## -*- coding: utf-8; -*- <%inherit file="/master/form.mako" /> -<%def name="title()">Edit: ${instance_title}</%def> - -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - % if not use_buefy: - <script type="text/javascript"> - - $(function() { - - $('form').submit(function() { - var submit = $(this).find('input[type="submit"]'); - if (submit.length) { - submit.button('disable').button('option', 'label', "Saving, please wait..."); - } - }); - - }); - </script> - % endif -</%def> - -<%def name="context_menu_items()"> - % if not use_buefy and master.viewable and master.has_perm('view'): - <li>${h.link_to("View this {}".format(model_title), action_url('view', instance))}</li> - % endif - ${self.context_menu_item_delete()} - % if not use_buefy and master.creatable and master.show_create_link and master.has_perm('create'): - % if master.creates_multiple: - <li>${h.link_to("Create new {}".format(model_title_plural), url('{}.create'.format(route_prefix)))}</li> - % else: - <li>${h.link_to("Create a new {}".format(model_title), url('{}.create'.format(route_prefix)))}</li> - % endif - % endif -</%def> +<%def name="title()">${index_title} » ${instance_title} » Edit</%def> +<%def name="content_title()">Edit: ${instance_title}</%def> ${parent.body()} diff --git a/tailbone/templates/master/form.mako b/tailbone/templates/master/form.mako index a37e3f91..17063c21 100644 --- a/tailbone/templates/master/form.mako +++ b/tailbone/templates/master/form.mako @@ -1,35 +1,18 @@ ## -*- coding: utf-8; -*- <%inherit file="/form.mako" /> -<%def name="context_menu_item_delete()"> - % if not use_buefy and master.deletable and instance_deletable and master.has_perm('delete'): - % if master.delete_confirm == 'simple': - <li> - ## note, the `ref` here is for buefy only - ${h.form(action_url('delete', instance), ref='deleteObjectForm')} - ${h.csrf_token(request)} - <a href="${action_url('delete', instance)}" - % if use_buefy: - @click.prevent="deleteObject" - % else: - class="delete-instance" - % endif - > - Delete this ${model_title} - </a> - ${h.end_form()} - </li> - % else: - ## assuming here that: delete_confirm == 'full' - <li>${h.link_to("Delete this {}".format(model_title), action_url('delete', instance))}</li> - % endif - % endif -</%def> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - % if master.deletable and instance_deletable and request.has_perm('{}.delete'.format(permission_prefix)) and master.delete_confirm == 'simple': - <script type="text/javascript"> + ## declare extra data needed by form + % if form is not Undefined and getattr(form, 'json_data', None): + % for key, value in form.json_data.items(): + ${form.vue_component}Data.${key} = ${json.dumps(value)|n} + % endfor + % endif + + % if master.deletable and instance_deletable and master.has_perm('delete') and getattr(master, 'delete_confirm', 'full') == 'simple': ThisPage.methods.deleteObject = function() { if (confirm("Are you sure you wish to delete this ${model_title}?")) { @@ -37,9 +20,11 @@ } } - </script> + % endif + </script> + + % if form is not Undefined and hasattr(form, 'render_included_templates'): + ${form.render_included_templates()} % endif + </%def> - - -${parent.body()} diff --git a/tailbone/templates/master/index.mako b/tailbone/templates/master/index.mako index 053e09fb..a2d26c60 100644 --- a/tailbone/templates/master/index.mako +++ b/tailbone/templates/master/index.mako @@ -12,449 +12,264 @@ <%def name="content_title()"></%def> -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - % if not use_buefy: - <script type="text/javascript"> - $(function() { - - % if download_results_rows_path: - function downloadResultsRowsRedirect() { - location.href = '${url('{}.download_results_rows'.format(route_prefix))}?filename=${h.os.path.basename(download_results_rows_path)}'; - } - // we give this 1 second before attempting the redirect; so this - // way the page should fully render before redirecting - window.setTimeout(downloadResultsRowsRedirect, 1000); - % endif - - $('.grid-wrapper').gridwrapper(); - - % if master.deletable and request.has_perm('{}.delete'.format(permission_prefix)) and master.delete_confirm == 'simple': - - $('.grid-wrapper').on('click', '.grid .actions a.delete', function() { - if (confirm("Are you sure you wish to delete this ${model_title}?")) { - var link = $(this).get(0); - var form = $('#delete-object-form').get(0); - form.action = link.href; - form.submit(); - } - return false; - }); - - % endif - - % if master.mergeable and master.has_perm('merge'): - - $('form[name="merge-things"] button').button('option', 'disabled', $('.grid').gridcore('count_selected') != 2); - - $('.grid-wrapper').on('gridchecked', '.grid', function(event, count) { - $('form[name="merge-things"] button').button('option', 'disabled', count != 2); - }); - - $('form[name="merge-things"]').submit(function() { - var uuids = $('.grid').gridcore('selected_uuids'); - if (uuids.length != 2) { - return false; - } - $(this).find('[name="uuids"]').val(uuids.toString()); - $(this).find('button') - .button('option', 'label', "Preparing to Merge...") - .button('disable'); - }); - - % endif - - % if master.has_rows and master.results_rows_downloadable: - - $('#download-row-results-button').click(function() { - if (confirm("This will generate an Excel file which contains " - + "not the results themselves, but the *rows* for " - + "each.\n\nAre you sure you want this?")) { - disable_button(this); - var form = $(this).parents('form'); - form.submit(); - } - }); - - % endif - - % if master.bulk_deletable and request.has_perm('{}.bulk_delete'.format(permission_prefix)): - - $('form[name="bulk-delete"] button').click(function() { - var count = $('.grid-wrapper').gridwrapper('results_count', true); - if (count === null) { - alert("There don't seem to be any results to delete!"); - return; - } - if (! confirm("You are about to delete " + count + " ${model_title_plural}.\n\nAre you sure?")) { - return - } - $(this).button('disable').button('option', 'label', "Deleting Results..."); - $('form[name="bulk-delete"]').submit(); - }); - - % endif - - % if master.supports_set_enabled_toggle and request.has_perm('{}.enable_disable_set'.format(permission_prefix)): - $('form[name="enable-set"] button').click(function() { - var form = $(this).parents('form'); - var uuids = $('.grid').gridcore('selected_uuids'); - if (! uuids.length) { - alert("You must first select one or more objects to enable."); - return false; - } - if (! confirm("Are you sure you wish to ENABLE the " + uuids.length + " selected objects?")) { - return false; - } - form.find('[name="uuids"]').val(uuids.toString()); - disable_button(this); - form.submit(); - }); - - $('form[name="disable-set"] button').click(function() { - var form = $(this).parents('form'); - var uuids = $('.grid').gridcore('selected_uuids'); - if (! uuids.length) { - alert("You must first select one or more objects to disable."); - return false; - } - if (! confirm("Are you sure you wish to DISABLE the " + uuids.length + " selected objects?")) { - return false; - } - form.find('[name="uuids"]').val(uuids.toString()); - disable_button(this); - form.submit(); - }); - % endif - - % if master.set_deletable and request.has_perm('{}.delete_set'.format(permission_prefix)): - $('form[name="delete-set"] button').click(function() { - var form = $(this).parents('form'); - var uuids = $('.grid').gridcore('selected_uuids'); - if (! uuids.length) { - alert("You must first select one or more objects to delete."); - return false; - } - if (! confirm("Are you sure you wish to DELETE the " + uuids.length + " selected objects?")) { - return false; - } - form.find('[name="uuids"]').val(uuids.toString()); - disable_button(this); - form.submit(); - }); - % endif - }); - </script> - % endif -</%def> - -<%def name="context_menu_items()"> - % if master.results_downloadable_csv and request.has_perm('{}.results_csv'.format(permission_prefix)): - <li>${h.link_to("Download results as CSV", url('{}.results_csv'.format(route_prefix)))}</li> - % endif - % if master.results_downloadable_xlsx and request.has_perm('{}.results_xlsx'.format(permission_prefix)): - <li>${h.link_to("Download results as XLSX", url('{}.results_xlsx'.format(route_prefix)))}</li> - % endif - % if not use_buefy and master.creatable and master.show_create_link and master.has_perm('create'): - % if master.creates_multiple: - <li>${h.link_to("Create new {}".format(model_title_plural), url('{}.create'.format(route_prefix)))}</li> - % else: - <li>${h.link_to("Create a new {}".format(model_title), url('{}.create'.format(route_prefix)))}</li> - % endif - % endif - % if master.has_input_file_templates and master.has_perm('create'): - % for template in six.itervalues(input_file_templates): - <li>${h.link_to("Download {} Template".format(template['label']), template['effective_url'])}</li> - % endfor - % endif -</%def> - <%def name="grid_tools()"> + ## grid totals + % if getattr(master, 'supports_grid_totals', False): + <div style="display: flex; align-items: center;"> + <b-button v-if="gridTotalsDisplay == null" + :disabled="gridTotalsFetching" + @click="gridTotalsFetch()"> + {{ gridTotalsFetching ? "Working, please wait..." : "Show Totals" }} + </b-button> + <div v-if="gridTotalsDisplay != null" + class="control"> + Totals: {{ gridTotalsDisplay }} + </div> + </div> + % endif + ## download search results - % if master.results_downloadable and master.has_perm('download_results'): - % if use_buefy: - <b-button type="is-primary" - icon-pack="fas" - icon-left="fas fa-download" - @click="showDownloadResultsDialog = true" - :disabled="!total"> - Download Results - </b-button> + % if getattr(master, 'results_downloadable', False) and master.has_perm('download_results'): + <div> + <b-button type="is-primary" + icon-pack="fas" + icon-left="download" + @click="showDownloadResultsDialog = true" + :disabled="!total"> + Download Results + </b-button> - ${h.form(url('{}.download_results'.format(route_prefix)), ref='download_results_form')} - ${h.csrf_token(request)} - <input type="hidden" name="fmt" :value="downloadResultsFormat" /> - <input type="hidden" name="fields" :value="downloadResultsFieldsIncluded" /> - ${h.end_form()} + ${h.form(url('{}.download_results'.format(route_prefix)), ref='download_results_form')} + ${h.csrf_token(request)} + <input type="hidden" name="fmt" :value="downloadResultsFormat" /> + <input type="hidden" name="fields" :value="downloadResultsFieldsIncluded" /> + ${h.end_form()} - <b-modal :active.sync="showDownloadResultsDialog"> - <div class="card"> + <b-modal :active.sync="showDownloadResultsDialog"> + <div class="card"> - <div class="card-content"> - <p> - There are - <span class="is-size-4 has-text-weight-bold"> - {{ total.toLocaleString('en') }} ${model_title_plural} - </span> - matching your current filters. - </p> - <p> - You may download this set as a single data file if you like. - </p> - <br /> + <div class="card-content"> + <p> + There are + <span class="is-size-4 has-text-weight-bold"> + {{ total.toLocaleString('en') }} ${model_title_plural} + </span> + matching your current filters. + </p> + <p> + You may download this set as a single data file if you like. + </p> + <br /> - <b-notification type="is-warning" :closable="false" - v-if="downloadResultsFormat == 'xlsx' && total >= 1000"> - Excel downloads for large data sets can take a long time to - generate, and bog down the server in the meantime. You are - encouraged to choose CSV for a large data set, even though - the end result (file size) may be larger with CSV. - </b-notification> + <b-notification type="is-warning" :closable="false" + v-if="downloadResultsFormat == 'xlsx' && total >= 1000"> + Excel downloads for large data sets can take a long time to + generate, and bog down the server in the meantime. You are + encouraged to choose CSV for a large data set, even though + the end result (file size) may be larger with CSV. + </b-notification> - <div style="display: flex; justify-content: space-between"> + <div style="display: flex; justify-content: space-between"> - <div> - <b-field horizontal label="Format"> - <b-select v-model="downloadResultsFormat"> - % for key, label in six.iteritems(master.download_results_supported_formats()): - <option value="${key}">${label}</option> - % endfor - </b-select> - </b-field> + <div> + <b-field label="Format"> + <b-select v-model="downloadResultsFormat"> + % for key, label in master.download_results_supported_formats().items(): + <option value="${key}">${label}</option> + % endfor + </b-select> + </b-field> + </div> + + <div> + + <div v-show="downloadResultsFieldsMode != 'choose'" + class="has-text-right"> + <p v-if="downloadResultsFieldsMode == 'default'"> + Will use DEFAULT fields. + </p> + <p v-if="downloadResultsFieldsMode == 'all'"> + Will use ALL fields. + </p> + <br /> </div> - <div> + <div class="buttons is-right"> + <b-button type="is-primary" + v-show="downloadResultsFieldsMode != 'default'" + @click="downloadResultsUseDefaultFields()"> + Use Default Fields + </b-button> + <b-button type="is-primary" + v-show="downloadResultsFieldsMode != 'all'" + @click="downloadResultsUseAllFields()"> + Use All Fields + </b-button> + <b-button type="is-primary" + v-show="downloadResultsFieldsMode != 'choose'" + @click="downloadResultsFieldsMode = 'choose'"> + Choose Fields + </b-button> + </div> - <div v-show="downloadResultsFieldsMode != 'choose'" - class="has-text-right"> - <p v-if="downloadResultsFieldsMode == 'default'"> - Will use DEFAULT fields. - </p> - <p v-if="downloadResultsFieldsMode == 'all'"> - Will use ALL fields. - </p> - <br /> - </div> - - <div class="buttons is-right"> - <b-button type="is-primary" - v-show="downloadResultsFieldsMode != 'default'" - @click="downloadResultsUseDefaultFields()"> - Use Default Fields - </b-button> - <b-button type="is-primary" - v-show="downloadResultsFieldsMode != 'all'" - @click="downloadResultsUseAllFields()"> - Use All Fields - </b-button> - <b-button type="is-primary" - v-show="downloadResultsFieldsMode != 'choose'" - @click="downloadResultsFieldsMode = 'choose'"> - Choose Fields - </b-button> - </div> - - <div v-show="downloadResultsFieldsMode == 'choose'"> - <div style="display: flex;"> - <div> - <b-field label="Excluded Fields"> - <b-select multiple native-size="8" - expanded - ref="downloadResultsExcludedFields"> - <option v-for="field in downloadResultsFieldsAvailable" - v-if="!downloadResultsFieldsIncluded.includes(field)" - :key="field" - :value="field"> - {{ field }} - </option> - </b-select> - </b-field> - </div> - <div> - <br /><br /> - <b-button style="margin: 0.5rem;" - @click="downloadResultsExcludeFields()"> - < - </b-button> - <br /> - <b-button style="margin: 0.5rem;" - @click="downloadResultsIncludeFields()"> - > - </b-button> - </div> - <div> - <b-field label="Included Fields"> - <b-select multiple native-size="8" - expanded - ref="downloadResultsIncludedFields"> - <option v-for="field in downloadResultsFieldsIncluded" - :key="field" - :value="field"> - {{ field }} - </option> - </b-select> - </b-field> - </div> + <div v-show="downloadResultsFieldsMode == 'choose'"> + <div style="display: flex;"> + <div> + <b-field label="Excluded Fields"> + <b-select multiple native-size="8" + expanded + v-model="downloadResultsExcludedFieldsSelected" + ref="downloadResultsExcludedFields"> + <option v-for="field in downloadResultsFieldsExcluded" + :key="field" + :value="field"> + {{ field }} + </option> + </b-select> + </b-field> + </div> + <div> + <br /><br /> + <b-button style="margin: 0.5rem;" + @click="downloadResultsExcludeFields()"> + < + </b-button> + <br /> + <b-button style="margin: 0.5rem;" + @click="downloadResultsIncludeFields()"> + > + </b-button> + </div> + <div> + <b-field label="Included Fields"> + <b-select multiple native-size="8" + expanded + v-model="downloadResultsIncludedFieldsSelected" + ref="downloadResultsIncludedFields"> + <option v-for="field in downloadResultsFieldsIncluded" + :key="field" + :value="field"> + {{ field }} + </option> + </b-select> + </b-field> </div> </div> - </div> - </div> - </div> <!-- card-content --> - <footer class="modal-card-foot"> - <b-button @click="showDownloadResultsDialog = false"> - Cancel - </b-button> - <once-button type="is-primary" - @click="downloadResultsSubmit()" - icon-pack="fas" - icon-left="fas fa-download" - :disabled="!downloadResultsFieldsIncluded.length" - text="Download Results"> - </once-button> - </footer> - </div> - </b-modal> - % endif + </div> + </div> + </div> <!-- card-content --> + + <footer class="modal-card-foot"> + <b-button @click="showDownloadResultsDialog = false"> + Cancel + </b-button> + <once-button type="is-primary" + @click="downloadResultsSubmit()" + icon-pack="fas" + icon-left="download" + :disabled="!downloadResultsFieldsIncluded.length" + text="Download Results"> + </once-button> + </footer> + </div> + </b-modal> + </div> % endif ## download rows for search results - % if master.has_rows and master.results_rows_downloadable and master.has_perm('download_results_rows'): - % if use_buefy: - <b-button type="is-primary" - icon-pack="fas" - icon-left="fas fa-download" - @click="downloadResultsRows()" - :disabled="downloadResultsRowsButtonDisabled"> - {{ downloadResultsRowsButtonText }} - </b-button> - ${h.form(url('{}.download_results_rows'.format(route_prefix)), ref='downloadResultsRowsForm')} - ${h.csrf_token(request)} - ${h.end_form()} - % else: - ${h.form(url('{}.download_results_rows'.format(route_prefix)))} - ${h.csrf_token(request)} - <button type="button" id="download-row-results-button"> - Download Rows for Results - </button> - ${h.end_form()} - % endif + % if getattr(master, 'has_rows', False) and master.results_rows_downloadable and master.has_perm('download_results_rows'): + <b-button type="is-primary" + icon-pack="fas" + icon-left="download" + @click="downloadResultsRows()" + :disabled="downloadResultsRowsButtonDisabled"> + {{ downloadResultsRowsButtonText }} + </b-button> + ${h.form(url('{}.download_results_rows'.format(route_prefix)), ref='downloadResultsRowsForm')} + ${h.csrf_token(request)} + ${h.end_form()} % endif ## merge 2 objects - % if master.mergeable and request.has_perm('{}.merge'.format(permission_prefix)): + % if getattr(master, 'mergeable', False) and request.has_perm('{}.merge'.format(permission_prefix)): - % if use_buefy: ${h.form(url('{}.merge'.format(route_prefix)), class_='control', **{'@submit': 'submitMergeForm'})} - % else: - ${h.form(url('{}.merge'.format(route_prefix)), name='merge-things')} - % endif ${h.csrf_token(request)} - % if use_buefy: - <input type="hidden" - name="uuids" - :value="checkedRowUUIDs()" /> - <b-button type="is-primary" - native-type="submit" - icon-pack="fas" - icon-left="object-ungroup" - :disabled="mergeFormSubmitting || checkedRows.length != 2"> - {{ mergeFormButtonText }} - </b-button> - % else: - ${h.hidden('uuids')} - <button type="submit" class="button">Merge 2 ${model_title_plural}</button> - % endif + <input type="hidden" + name="uuids" + :value="checkedRowUUIDs()" /> + <b-button type="is-primary" + native-type="submit" + icon-pack="fas" + icon-left="object-ungroup" + :disabled="mergeFormSubmitting || checkedRows.length != 2"> + {{ mergeFormButtonText }} + </b-button> ${h.end_form()} % endif ## enable / disable selected objects - % if master.supports_set_enabled_toggle and master.has_perm('enable_disable_set'): + % if getattr(master, 'supports_set_enabled_toggle', False) and master.has_perm('enable_disable_set'): - % if use_buefy: - ${h.form(url('{}.enable_set'.format(route_prefix)), class_='control', ref='enable_selected_form')} - ${h.csrf_token(request)} - ${h.hidden('uuids', v_model='selected_uuids')} - <b-button :disabled="enableSelectedDisabled" - @click="enableSelectedSubmit()"> - {{ enableSelectedText }} - </b-button> - ${h.end_form()} - % else: - ${h.form(url('{}.enable_set'.format(route_prefix)), name='enable-set', class_='control')} - ${h.csrf_token(request)} - ${h.hidden('uuids')} - <button type="button" class="button">Enable Selected</button> - ${h.end_form()} - % endif + ${h.form(url('{}.enable_set'.format(route_prefix)), class_='control', ref='enable_selected_form')} + ${h.csrf_token(request)} + ${h.hidden('uuids', v_model='selected_uuids')} + <b-button :disabled="enableSelectedDisabled" + @click="enableSelectedSubmit()"> + {{ enableSelectedText }} + </b-button> + ${h.end_form()} - % if use_buefy: - ${h.form(url('{}.disable_set'.format(route_prefix)), ref='disable_selected_form', class_='control')} - ${h.csrf_token(request)} - ${h.hidden('uuids', v_model='selected_uuids')} - <b-button :disabled="disableSelectedDisabled" - @click="disableSelectedSubmit()"> - {{ disableSelectedText }} - </b-button> - ${h.end_form()} - % else: - ${h.form(url('{}.disable_set'.format(route_prefix)), name='disable-set', class_='control')} - ${h.csrf_token(request)} - ${h.hidden('uuids')} - <button type="button" class="button">Disable Selected</button> - ${h.end_form()} - % endif + ${h.form(url('{}.disable_set'.format(route_prefix)), ref='disable_selected_form', class_='control')} + ${h.csrf_token(request)} + ${h.hidden('uuids', v_model='selected_uuids')} + <b-button :disabled="disableSelectedDisabled" + @click="disableSelectedSubmit()"> + {{ disableSelectedText }} + </b-button> + ${h.end_form()} % endif ## delete selected objects - % if master.set_deletable and master.has_perm('delete_set'): - % if use_buefy: - ${h.form(url('{}.delete_set'.format(route_prefix)), ref='delete_selected_form', class_='control')} - ${h.csrf_token(request)} - ${h.hidden('uuids', v_model='selected_uuids')} - <b-button type="is-danger" - :disabled="deleteSelectedDisabled" - @click="deleteSelectedSubmit()" - icon-pack="fas" - icon-left="trash"> - {{ deleteSelectedText }} - </b-button> - ${h.end_form()} - % else: - ${h.form(url('{}.delete_set'.format(route_prefix)), name='delete-set', class_='control')} - ${h.csrf_token(request)} - ${h.hidden('uuids')} - <button type="button" class="button">Delete Selected</button> - ${h.end_form()} - % endif + % if getattr(master, 'set_deletable', False) and master.has_perm('delete_set'): + ${h.form(url('{}.delete_set'.format(route_prefix)), ref='delete_selected_form', class_='control')} + ${h.csrf_token(request)} + ${h.hidden('uuids', v_model='selected_uuids')} + <b-button type="is-danger" + :disabled="deleteSelectedDisabled" + @click="deleteSelectedSubmit()" + icon-pack="fas" + icon-left="trash"> + {{ deleteSelectedText }} + </b-button> + ${h.end_form()} % endif ## delete search results - % if master.bulk_deletable and request.has_perm('{}.bulk_delete'.format(permission_prefix)): - % if use_buefy: - ${h.form(url('{}.bulk_delete'.format(route_prefix)), ref='delete_results_form', class_='control')} - ${h.csrf_token(request)} - <b-button type="is-danger" - :disabled="deleteResultsDisabled" - :title="total ? null : 'There are no results to delete'" - @click="deleteResultsSubmit()" - icon-pack="fas" - icon-left="trash"> - {{ deleteResultsText }} - </b-button> - ${h.end_form()} - % else: - ${h.form(url('{}.bulk_delete'.format(route_prefix)), name='bulk-delete', class_='control')} - ${h.csrf_token(request)} - <button type="button">Delete Results</button> - ${h.end_form()} - % endif + % if getattr(master, 'bulk_deletable', False) and request.has_perm('{}.bulk_delete'.format(permission_prefix)): + ${h.form(url('{}.bulk_delete'.format(route_prefix)), ref='delete_results_form', class_='control')} + ${h.csrf_token(request)} + <b-button type="is-danger" + :disabled="deleteResultsDisabled" + :title="total ? null : 'There are no results to delete'" + @click="deleteResultsSubmit()" + icon-pack="fas" + icon-left="trash"> + {{ deleteResultsText }} + </b-button> + ${h.end_form()} % endif </%def> +## DEPRECATED; remains for back-compat +<%def name="render_this_page()"> + ${self.page_content()} +</%def> + <%def name="page_content()"> % if download_results_path: @@ -473,7 +288,7 @@ ${self.render_grid_component()} - % if master.deletable and request.has_perm('{}.delete'.format(permission_prefix)) and master.delete_confirm == 'simple': + % if master.deletable and master.has_perm('delete') and getattr(master, 'delete_confirm', 'full') == 'simple': ${h.form('#', ref='deleteObjectForm')} ${h.csrf_token(request)} ${h.end_form()} @@ -481,42 +296,53 @@ </%def> <%def name="render_grid_component()"> - <${grid.component} ref="grid" :csrftoken="csrftoken" - % if master.deletable and request.has_perm('{}.delete'.format(permission_prefix)) and master.delete_confirm == 'simple': - @deleteActionClicked="deleteObject" - % endif - > - </${grid.component}> + ${grid.render_vue_tag()} </%def> -<%def name="make_this_page_component()"> - ${parent.make_this_page_component()} +############################## +## vue components +############################## + +<%def name="render_vue_templates()"> + ${parent.render_vue_templates()} + + ## DEPRECATED; called for back-compat + ${self.make_grid_component()} +</%def> + +## DEPRECATED; remains for back-compat +<%def name="make_grid_component()"> + ${grid.render_vue_template(tools=capture(self.grid_tools).strip(), context_menu=capture(self.context_menu_items).strip())} +</%def> + +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} <script type="text/javascript"> - ${grid.component_studly}.data = function() { return ${grid.component_studly}Data } + % if getattr(master, 'supports_grid_totals', False): + ${grid.vue_component}Data.gridTotalsDisplay = null + ${grid.vue_component}Data.gridTotalsFetching = false - Vue.component('${grid.component}', ${grid.component_studly}) + ${grid.vue_component}.methods.gridTotalsFetch = function() { + this.gridTotalsFetching = true - </script> -</%def> + let url = '${url(f'{route_prefix}.fetch_grid_totals')}' + this.simpleGET(url, {}, response => { + this.gridTotalsDisplay = response.data.totals_display + this.gridTotalsFetching = false + }, response => { + this.gridTotalsFetching = false + }) + } -<%def name="render_this_page()"> - ${self.page_content()} -</%def> - -<%def name="render_this_page_template()"> - ${parent.render_this_page_template()} - - ## TODO: stop using |n filter - ${grid.render_buefy(tools=capture(self.grid_tools).strip(), context_menu=capture(self.context_menu_items).strip())|n} -</%def> - -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> + ${grid.vue_component}.methods.appliedFiltersHook = function() { + this.gridTotalsDisplay = null + this.gridTotalsFetching = false + } + % endif ## maybe auto-redirect to download latest results file - % if download_results_path and use_buefy: + % if download_results_path: ThisPage.methods.downloadResultsRedirect = function() { location.href = '${url('{}.download_results'.format(route_prefix))}?filename=${h.os.path.basename(download_results_path)}'; } @@ -529,7 +355,7 @@ % endif ## maybe auto-redirect to download latest "rows for results" file - % if download_results_rows_path and use_buefy: + % if download_results_rows_path: ThisPage.methods.downloadResultsRowsRedirect = function() { location.href = '${url('{}.download_results_rows'.format(route_prefix))}?filename=${h.os.path.basename(download_results_rows_path)}'; } @@ -541,20 +367,19 @@ } % endif - ## TODO: stop checking for buefy here once we only have the one session.pop() - % if use_buefy and request.session.pop('{}.results_csv.generated'.format(route_prefix), False): + % if request.session.pop('{}.results_csv.generated'.format(route_prefix), False): ThisPage.mounted = function() { location.href = '${url('{}.results_csv_download'.format(route_prefix))}'; } % endif - % if use_buefy and request.session.pop('{}.results_xlsx.generated'.format(route_prefix), False): + % if request.session.pop('{}.results_xlsx.generated'.format(route_prefix), False): ThisPage.mounted = function() { location.href = '${url('{}.results_xlsx_download'.format(route_prefix))}'; } % endif ## delete single object - % if master.deletable and master.has_perm('delete') and master.delete_confirm == 'simple': + % if master.deletable and master.has_perm('delete') and getattr(master, 'delete_confirm', 'full') == 'simple': ThisPage.methods.deleteObject = function(url) { if (confirm("Are you sure you wish to delete this ${model_title}?")) { let form = this.$refs.deleteObjectForm @@ -565,16 +390,19 @@ % endif ## download results - % if master.results_downloadable and master.has_perm('download_results'): + % if getattr(master, 'results_downloadable', False) and master.has_perm('download_results'): - ${grid.component_studly}Data.downloadResultsFormat = '${master.download_results_default_format()}' - ${grid.component_studly}Data.showDownloadResultsDialog = false - ${grid.component_studly}Data.downloadResultsFieldsMode = 'default' - ${grid.component_studly}Data.downloadResultsFieldsAvailable = ${json.dumps(download_results_fields_available)|n} - ${grid.component_studly}Data.downloadResultsFieldsDefault = ${json.dumps(download_results_fields_default)|n} - ${grid.component_studly}Data.downloadResultsFieldsIncluded = ${json.dumps(download_results_fields_default)|n} + ${grid.vue_component}Data.downloadResultsFormat = '${master.download_results_default_format()}' + ${grid.vue_component}Data.showDownloadResultsDialog = false + ${grid.vue_component}Data.downloadResultsFieldsMode = 'default' + ${grid.vue_component}Data.downloadResultsFieldsAvailable = ${json.dumps(download_results_fields_available)|n} + ${grid.vue_component}Data.downloadResultsFieldsDefault = ${json.dumps(download_results_fields_default)|n} + ${grid.vue_component}Data.downloadResultsFieldsIncluded = ${json.dumps(download_results_fields_default)|n} - ${grid.component_studly}.computed.downloadResultsFieldsExcluded = function() { + ${grid.vue_component}Data.downloadResultsExcludedFieldsSelected = [] + ${grid.vue_component}Data.downloadResultsIncludedFieldsSelected = [] + + ${grid.vue_component}.computed.downloadResultsFieldsExcluded = function() { let excluded = [] this.downloadResultsFieldsAvailable.forEach(field => { if (!this.downloadResultsFieldsIncluded.includes(field)) { @@ -584,70 +412,73 @@ return excluded } - ${grid.component_studly}.methods.downloadResultsExcludeFields = function() { - let selected = this.$refs.downloadResultsIncludedFields.selected + ${grid.vue_component}.methods.downloadResultsExcludeFields = function() { + const selected = Array.from(this.downloadResultsIncludedFieldsSelected) if (!selected) { return } - selected = Array.from(selected) - selected.forEach(field => { - // de-select the entry within "included" field input - let index = this.$refs.downloadResultsIncludedFields.selected.indexOf(field) - if (index > -1) { - this.$refs.downloadResultsIncludedFields.selected.splice(index, 1) + selected.forEach(field => { + let index + + // remove field from selected + index = this.downloadResultsIncludedFieldsSelected.indexOf(field) + if (index >= 0) { + this.downloadResultsIncludedFieldsSelected.splice(index, 1) } - // remove field from official "included" list + // remove field from included + // nb. excluded list will reflect this change too index = this.downloadResultsFieldsIncluded.indexOf(field) - if (index > -1) { + if (index >= 0) { this.downloadResultsFieldsIncluded.splice(index, 1) } - }, this) + }) } - ${grid.component_studly}.methods.downloadResultsIncludeFields = function() { - let selected = this.$refs.downloadResultsExcludedFields.selected + ${grid.vue_component}.methods.downloadResultsIncludeFields = function() { + const selected = Array.from(this.downloadResultsExcludedFieldsSelected) if (!selected) { return } - selected = Array.from(selected) - selected.forEach(field => { - // de-select the entry within "excluded" field input - let index = this.$refs.downloadResultsExcludedFields.selected.indexOf(field) - if (index > -1) { - this.$refs.downloadResultsExcludedFields.selected.splice(index, 1) + selected.forEach(field => { + let index + + // remove field from selected + index = this.downloadResultsExcludedFieldsSelected.indexOf(field) + if (index >= 0) { + this.downloadResultsExcludedFieldsSelected.splice(index, 1) } - // add field to official "included" list + // add field to included + // nb. excluded list will reflect this change too this.downloadResultsFieldsIncluded.push(field) - - }, this) + }) } - ${grid.component_studly}.methods.downloadResultsUseDefaultFields = function() { + ${grid.vue_component}.methods.downloadResultsUseDefaultFields = function() { this.downloadResultsFieldsIncluded = Array.from(this.downloadResultsFieldsDefault) this.downloadResultsFieldsMode = 'default' } - ${grid.component_studly}.methods.downloadResultsUseAllFields = function() { + ${grid.vue_component}.methods.downloadResultsUseAllFields = function() { this.downloadResultsFieldsIncluded = Array.from(this.downloadResultsFieldsAvailable) this.downloadResultsFieldsMode = 'all' } - ${grid.component_studly}.methods.downloadResultsSubmit = function() { + ${grid.vue_component}.methods.downloadResultsSubmit = function() { this.$refs.download_results_form.submit() } % endif ## download rows for results - % if master.has_rows and master.results_rows_downloadable and master.has_perm('download_results_rows'): + % if getattr(master, 'has_rows', False) and master.results_rows_downloadable and master.has_perm('download_results_rows'): - ${grid.component_studly}Data.downloadResultsRowsButtonDisabled = false - ${grid.component_studly}Data.downloadResultsRowsButtonText = "Download Rows for Results" + ${grid.vue_component}Data.downloadResultsRowsButtonDisabled = false + ${grid.vue_component}Data.downloadResultsRowsButtonText = "Download Rows for Results" - ${grid.component_studly}.methods.downloadResultsRows = function() { + ${grid.vue_component}.methods.downloadResultsRows = function() { if (confirm("This will generate an Excel file which contains " + "not the results themselves, but the *rows* for " + "each.\n\nAre you sure you want this?")) { @@ -659,12 +490,12 @@ % endif ## enable / disable selected objects - % if master.supports_set_enabled_toggle and master.has_perm('enable_disable_set'): + % if getattr(master, 'supports_set_enabled_toggle', False) and master.has_perm('enable_disable_set'): - ${grid.component_studly}Data.enableSelectedSubmitting = false - ${grid.component_studly}Data.enableSelectedText = "Enable Selected" + ${grid.vue_component}Data.enableSelectedSubmitting = false + ${grid.vue_component}Data.enableSelectedText = "Enable Selected" - ${grid.component_studly}.computed.enableSelectedDisabled = function() { + ${grid.vue_component}.computed.enableSelectedDisabled = function() { if (this.enableSelectedSubmitting) { return true } @@ -674,7 +505,7 @@ return false } - ${grid.component_studly}.methods.enableSelectedSubmit = function() { + ${grid.vue_component}.methods.enableSelectedSubmit = function() { let uuids = this.checkedRowUUIDs() if (!uuids.length) { alert("You must first select one or more objects to disable.") @@ -689,10 +520,10 @@ this.$refs.enable_selected_form.submit() } - ${grid.component_studly}Data.disableSelectedSubmitting = false - ${grid.component_studly}Data.disableSelectedText = "Disable Selected" + ${grid.vue_component}Data.disableSelectedSubmitting = false + ${grid.vue_component}Data.disableSelectedText = "Disable Selected" - ${grid.component_studly}.computed.disableSelectedDisabled = function() { + ${grid.vue_component}.computed.disableSelectedDisabled = function() { if (this.disableSelectedSubmitting) { return true } @@ -702,7 +533,7 @@ return false } - ${grid.component_studly}.methods.disableSelectedSubmit = function() { + ${grid.vue_component}.methods.disableSelectedSubmit = function() { let uuids = this.checkedRowUUIDs() if (!uuids.length) { alert("You must first select one or more objects to disable.") @@ -720,12 +551,12 @@ % endif ## delete selected objects - % if master.set_deletable and master.has_perm('delete_set'): + % if getattr(master, 'set_deletable', False) and master.has_perm('delete_set'): - ${grid.component_studly}Data.deleteSelectedSubmitting = false - ${grid.component_studly}Data.deleteSelectedText = "Delete Selected" + ${grid.vue_component}Data.deleteSelectedSubmitting = false + ${grid.vue_component}Data.deleteSelectedText = "Delete Selected" - ${grid.component_studly}.computed.deleteSelectedDisabled = function() { + ${grid.vue_component}.computed.deleteSelectedDisabled = function() { if (this.deleteSelectedSubmitting) { return true } @@ -735,7 +566,7 @@ return false } - ${grid.component_studly}.methods.deleteSelectedSubmit = function() { + ${grid.vue_component}.methods.deleteSelectedSubmit = function() { let uuids = this.checkedRowUUIDs() if (!uuids.length) { alert("You must first select one or more objects to disable.") @@ -751,12 +582,12 @@ } % endif - % if master.bulk_deletable and master.has_perm('bulk_delete'): + % if getattr(master, 'bulk_deletable', False) and master.has_perm('bulk_delete'): - ${grid.component_studly}Data.deleteResultsSubmitting = false - ${grid.component_studly}Data.deleteResultsText = "Delete Results" + ${grid.vue_component}Data.deleteResultsSubmitting = false + ${grid.vue_component}Data.deleteResultsText = "Delete Results" - ${grid.component_studly}.computed.deleteResultsDisabled = function() { + ${grid.vue_component}.computed.deleteResultsDisabled = function() { if (this.deleteResultsSubmitting) { return true } @@ -766,7 +597,7 @@ return false } - ${grid.component_studly}.methods.deleteResultsSubmit = function() { + ${grid.vue_component}.methods.deleteResultsSubmit = function() { // TODO: show "plural model title" here? if (!confirm("You are about to delete " + this.total.toLocaleString('en') + " objects.\n\nAre you sure?")) { return @@ -779,12 +610,12 @@ % endif - % if master.mergeable and master.has_perm('merge'): + % if getattr(master, 'mergeable', False) and master.has_perm('merge'): - ${grid.component_studly}Data.mergeFormButtonText = "Merge 2 ${model_title_plural}" - ${grid.component_studly}Data.mergeFormSubmitting = false + ${grid.vue_component}Data.mergeFormButtonText = "Merge 2 ${model_title_plural}" + ${grid.vue_component}Data.mergeFormSubmitting = false - ${grid.component_studly}.methods.submitMergeForm = function() { + ${grid.vue_component}.methods.submitMergeForm = function() { this.mergeFormSubmitting = true this.mergeFormButtonText = "Working, please wait..." } @@ -792,45 +623,10 @@ </script> </%def> - -% if use_buefy: - ${parent.body()} - -% else: - ## no buefy, so do the traditional thing - - % if download_results_rows_path: - <div class="flash-messages"> - <div class="ui-state-highlight ui-corner-all"> - <span style="float: left; margin-right: .3em;" class="ui-icon ui-icon-info"></span> - Your download should start automatically, or you can - ${h.link_to("click here", '{}?filename={}'.format(url('{}.download_results_rows'.format(route_prefix)), h.os.path.basename(download_results_rows_path)))} - </div> - </div> - % endif - - ${grid.render_complete(tools=capture(self.grid_tools).strip(), context_menu=capture(self.context_menu_items).strip())|n} - - % if master.deletable and request.has_perm('{}.delete'.format(permission_prefix)) and master.delete_confirm == 'simple': - ${h.form('#', id='delete-object-form')} - ${h.csrf_token(request)} - ${h.end_form()} - % endif - - ## TODO: can stop checking for buefy above once this legacy chunk is gone - % if request.session.pop('{}.results_csv.generated'.format(route_prefix), False): - <script type="text/javascript"> - $(function() { - location.href = '${url('{}.results_csv_download'.format(route_prefix))}'; - }); - </script> - % endif - % if request.session.pop('{}.results_xlsx.generated'.format(route_prefix), False): - <script type="text/javascript"> - $(function() { - location.href = '${url('{}.results_xlsx_download'.format(route_prefix))}'; - }); - </script> - % endif - -% endif +<%def name="make_vue_components()"> + ${parent.make_vue_components()} + <script> + ${grid.vue_component}.data = function() { return ${grid.vue_component}Data } + Vue.component('${grid.vue_tagname}', ${grid.vue_component}) + </script> +</%def> diff --git a/tailbone/templates/master/merge.mako b/tailbone/templates/master/merge.mako index 8924fcd0..487d258d 100644 --- a/tailbone/templates/master/merge.mako +++ b/tailbone/templates/master/merge.mako @@ -3,36 +3,6 @@ <%def name="title()">Merge 2 ${model_title_plural}</%def> -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - % if not use_buefy: - <script type="text/javascript"> - - $(function() { - - $('button.swap').click(function() { - $(this).button('disable').button('option', 'label', "Swapping, please wait..."); - var form = $(this).parents('form'); - var input = form.find('input[name="uuids"]'); - var uuids = input.val().split(','); - uuids.reverse(); - input.val(uuids.join(',')); - form.submit(); - }); - - $('form.merge input[type="submit"]').click(function() { - $(this).button('disable').button('option', 'label', "Merging, please wait..."); - var form = $(this).parents('form'); - form.append($('<input type="hidden" name="commit-merge" value="yes" />')); - form.submit(); - }); - - }); - - </script> - % endif -</%def> - <%def name="extra_styles()"> ${parent.extra_styles()} <style type="text/css"> @@ -92,70 +62,55 @@ </%def> <%def name="page_content()"> + <p> + You are about to <strong>merge</strong> two ${model_title} records, + (possibly) along with various related data. The tool you are using now + is somewhat generic and is not able to give you the full picture of the + implications of this merge. You are urged to proceed with caution! + </p> -<p> - You are about to <strong>merge</strong> two ${model_title} records, - (possibly) along with various related data. The tool you are using now - is somewhat generic and is not able to give you the full picture of the - implications of this merge. You are urged to proceed with caution! -</p> + <p class="warning"> + <strong>Unless you know what you're doing, a good rule of thumb (though still no + guarantee) is to merge <em>only</em> if the "resulting" column is all-white.</strong> + (You may be able to swap kept/removed in order to achieve this.) + </p> -<p class="warning"> - <strong>Unless you know what you're doing, a good rule of thumb (though still no - guarantee) is to merge <em>only</em> if the "resulting" column is all-white.</strong> - (You may be able to swap kept/removed in order to achieve this.) -</p> + <p> + The ${h.link_to("{} on the left".format(model_title), view_url(object_to_remove), target='_blank', class_='merge-object')} + will be <strong>deleted</strong> + and the ${h.link_to("{} on the right".format(model_title), view_url(object_to_keep), target='_blank', class_='merge-object')} + will be <strong>kept</strong>. The one which is to be kept may also + be updated to reflect certain aspects of the one being deleted; however again + the details are up to the app logic for this type of merge and aren't fully + known to the generic tool which you're using now. + </p> -<p> - The ${h.link_to("{} on the left".format(model_title), view_url(object_to_remove), target='_blank', class_='merge-object')} - will be <strong>deleted</strong> - and the ${h.link_to("{} on the right".format(model_title), view_url(object_to_keep), target='_blank', class_='merge-object')} - will be <strong>kept</strong>. The one which is to be kept may also - be updated to reflect certain aspects of the one being deleted; however again - the details are up to the app logic for this type of merge and aren't fully - known to the generic tool which you're using now. -</p> + <table class="diff"> + <thead> + <tr> + <th>field name</th> + <th>deleting ${model_title}</th> + <th>keeping ${model_title}</th> + <th>resulting ${model_title}</th> + </tr> + </thead> + <tbody> + % for field in sorted(merge_fields): + <tr${' class="diff"' if keep_data[field] != remove_data[field] else ''|n}> + <td class="field">${field}</td> + <td class="value remove-value">${repr(remove_data[field])}</td> + <td class="value keep-value">${repr(keep_data[field])}</td> + <td class="value result-value${' diff' if resulting_data[field] != keep_data[field] else ''}">${repr(resulting_data[field])}</td> + </tr> + % endfor + </tbody> + </table> -<table class="diff"> - <thead> - <tr> - <th>field name</th> - <th>deleting ${model_title}</th> - <th>keeping ${model_title}</th> - <th>resulting ${model_title}</th> - </tr> - </thead> - <tbody> - % for field in sorted(merge_fields): - <tr${' class="diff"' if keep_data[field] != remove_data[field] else ''|n}> - <td class="field">${field}</td> - <td class="value remove-value">${repr(remove_data[field])}</td> - <td class="value keep-value">${repr(keep_data[field])}</td> - <td class="value result-value${' diff' if resulting_data[field] != keep_data[field] else ''}">${repr(resulting_data[field])}</td> - </tr> - % endfor - </tbody> -</table> - -% if use_buefy: - <merge-buttons></merge-buttons> - -% else: -## no buefy; do legacy stuff -${h.form(request.current_route_url(), class_='merge')} -${h.csrf_token(request)} -<div class="buttons"> - ${h.hidden('uuids', value='{},{}'.format(object_to_remove.uuid, object_to_keep.uuid))} - <a class="button" href="${index_url}">Whoops, nevermind</a> - <button type="button" class="swap">Swap which ${model_title} is kept/removed</button> - ${h.submit('merge', "Yes, perform this merge")} -</div> -${h.end_form()} -% endif + <merge-buttons></merge-buttons> </%def> -<%def name="render_this_page_template()"> - ${parent.render_this_page_template()} +<%def name="render_vue_templates()"> + ${parent.render_vue_templates()} <script type="text/x-template" id="merge-buttons-template"> <div class="level" style="margin-top: 2em;"> @@ -168,7 +123,7 @@ ${h.end_form()} <div class="level-item"> ${h.form(request.current_route_url(), **{'@submit': 'submitSwapForm'})} ${h.csrf_token(request)} - ${h.hidden('uuids', value='{},{}'.format(object_to_keep.uuid, object_to_remove.uuid))} + ${h.hidden('uuids', value=f'{keeping_uuid},{removing_uuid}')} <b-button native-type="submit" :disabled="swapFormSubmitting"> {{ swapFormButtonText }} @@ -177,13 +132,9 @@ ${h.end_form()} </div> <div class="level-item"> - % if use_buefy: ${h.form(request.current_route_url(), **{'@submit': 'submitMergeForm'})} - % else: - ${h.form(request.current_route_url())} - % endif ${h.csrf_token(request)} - ${h.hidden('uuids', value='{},{}'.format(object_to_remove.uuid, object_to_keep.uuid))} + ${h.hidden('uuids', value=f'{removing_uuid},{keeping_uuid}')} ${h.hidden('commit-merge', value='yes')} <b-button type="is-primary" native-type="submit" @@ -196,11 +147,7 @@ ${h.end_form()} </div> </div> </script> -</%def> - -<%def name="make_this_page_component()"> - ${parent.make_this_page_component()} - <script type="text/javascript"> + <script> const MergeButtons = { template: '#merge-buttons-template', @@ -224,10 +171,13 @@ ${h.end_form()} } } - Vue.component('merge-buttons', MergeButtons) - </script> </%def> - -${parent.body()} +<%def name="make_vue_components()"> + ${parent.make_vue_components()} + <script> + Vue.component('merge-buttons', MergeButtons) + <% request.register_component('merge-buttons', 'MergeButtons') %> + </script> +</%def> diff --git a/tailbone/templates/master/versions.mako b/tailbone/templates/master/versions.mako index 2d1b4db3..a6bb14f0 100644 --- a/tailbone/templates/master/versions.mako +++ b/tailbone/templates/master/versions.mako @@ -6,19 +6,8 @@ ## ############################################################################## <%inherit file="/page.mako" /> -## TODO: this page still uses old-style grid but should use Buefy grid - <%def name="title()">${model_title_plural} » ${instance_title} » history</%def> -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - <script type="text/javascript"> - $(function() { - $('.grid-wrapper').gridwrapper(); - }); - </script> -</%def> - <%def name="content_title()"> Version History </%def> @@ -27,31 +16,16 @@ ${self.page_content()} </%def> -<%def name="make_this_page_component()"> - ${parent.make_this_page_component()} - <script type="text/javascript"> - - TailboneGrid.data = function() { return TailboneGridData } - - Vue.component('tailbone-grid', TailboneGrid) - - </script> -</%def> - -<%def name="render_this_page_template()"> - ${parent.render_this_page_template()} - - ## TODO: stop using |n filter - ${grid.render_buefy()|n} -</%def> - <%def name="page_content()"> - % if use_buefy: - <tailbone-grid :csrftoken="csrftoken"> - </tailbone-grid> - % else: - ${grid.render_complete()|n} - % endif + ${grid.render_vue_tag(**{':csrftoken': 'csrftoken'})} </%def> -${parent.body()} +<%def name="render_vue_templates()"> + ${parent.render_vue_templates()} + ${grid.render_vue_template()} +</%def> + +<%def name="make_vue_components()"> + ${parent.make_vue_components()} + ${grid.render_vue_finalize()} +</%def> diff --git a/tailbone/templates/master/view.mako b/tailbone/templates/master/view.mako index 7b0b2de5..118c028c 100644 --- a/tailbone/templates/master/view.mako +++ b/tailbone/templates/master/view.mako @@ -3,141 +3,334 @@ <%def name="title()">${index_title} » ${instance_title}</%def> -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - % if not use_buefy: - % if master.deletable and instance_deletable and request.has_perm('{}.delete'.format(permission_prefix)) and master.delete_confirm == 'simple': - <script type="text/javascript"> - - $(function () { - - $('#context-menu a.delete-instance').on('click', function() { - if (confirm("Are you sure you wish to delete this ${model_title}?")) { - $(this).parents('form').submit(); - } - return false; - }); - - }); - - </script> - % endif - % if master.has_rows: - <script type="text/javascript"> - $(function() { - $('.grid-wrapper').gridwrapper(); - }); - </script> - % endif - % endif -</%def> - -<%def name="extra_styles()"> - ${parent.extra_styles()} - % if master.has_rows and not use_buefy: - <style type="text/css"> - .grid-wrapper { - margin-top: 10px; - } - </style> - % endif -</%def> - <%def name="content_title()"> ${instance_title} </%def> -<%def name="context_menu_items()"> - ## TODO: either make this configurable, or just lose it. - ## nobody seems to ever find it useful in practice. - ## <li>${h.link_to("Permalink for this {}".format(model_title), action_url('view', instance))}</li> - % if master.has_versions and request.rattail_config.versioning_enabled() and request.has_perm('{}.versions'.format(permission_prefix)): - <li>${h.link_to("Version History", action_url('versions', instance))}</li> +<%def name="render_instance_header_title_extras()"> + % if getattr(master, 'touchable', False) and master.has_perm('touch'): + <b-button title=""Touch" this record to trigger sync" + @click="touchRecord()" + :disabled="touchSubmitting"> + % if request.use_oruga: + <o-icon icon="hand-pointer" /> + % else: + <span><i class="fa fa-hand-pointer"></i></span> + % endif + </b-button> % endif - % if not use_buefy and master.editable and instance_editable and master.has_perm('edit'): - <li>${h.link_to("Edit this {}".format(model_title), action_url('edit', instance))}</li> + % if expose_versions: + <b-button icon-pack="fas" + icon-left="history" + @click="viewingHistory = !viewingHistory"> + {{ viewingHistory ? "View Current" : "View History" }} + </b-button> % endif - ${self.context_menu_item_delete()} - % if not use_buefy and master.creatable and master.show_create_link and master.has_perm('create'): - % if master.creates_multiple: - <li>${h.link_to("Create new {}".format(model_title_plural), url('{}.create'.format(route_prefix)))}</li> - % else: - <li>${h.link_to("Create a new {}".format(model_title), url('{}.create'.format(route_prefix)))}</li> - % endif - % endif - % if not use_buefy and master.cloneable and master.has_perm('clone'): - <li>${h.link_to("Clone this as new {}".format(model_title), url('{}.clone'.format(route_prefix), uuid=instance.uuid))}</li> - % endif - % if master.touchable and request.has_perm('{}.touch'.format(permission_prefix)): - <li>${h.link_to("\"Touch\" this {}".format(model_title), master.get_action_url('touch', instance))}</li> - % endif - % if not use_buefy and master.has_rows and master.rows_downloadable_csv and master.has_perm('row_results_csv'): - <li>${h.link_to("Download row results as CSV", master.get_action_url('row_results_csv', instance))}</li> - % endif - % if not use_buefy and master.has_rows and master.rows_downloadable_xlsx and master.has_perm('row_results_xlsx'): - <li>${h.link_to("Download row results as XLSX", master.get_action_url('row_results_xlsx', instance))}</li> +</%def> + +<%def name="object_helpers()"> + ${parent.object_helpers()} + ${self.render_xref_helper()} +</%def> + +<%def name="render_xref_helper()"> + % if xref_buttons or xref_links: + <nav class="panel"> + <p class="panel-heading">Cross-Reference</p> + <div class="panel-block"> + <div style="display: flex; flex-direction: column; gap: 0.5rem;"> + % for button in xref_buttons: + ${button} + % endfor + % for link in xref_links: + ${link} + % endfor + </div> + </div> + </nav> % endif </%def> <%def name="render_row_grid_tools()"> ${rows_grid_tools} - % if use_buefy: - % if master.rows_downloadable_xlsx and master.has_perm('row_results_xlsx'): - <b-button tag="a" href="${master.get_action_url('row_results_xlsx', instance)}" - icon-pack="fas" - icon-left="download"> - Download Results XLSX - </b-button> - % endif - % if master.rows_downloadable_csv and master.has_perm('row_results_csv'): - <b-button tag="a" href="${master.get_action_url('row_results_csv', instance)}" - icon-pack="fas" - icon-left="download"> - Download Results CSV - </b-button> - % endif + % if master.rows_downloadable_xlsx and master.has_perm('row_results_xlsx'): + <b-button tag="a" href="${master.get_action_url('row_results_xlsx', instance)}" + icon-pack="fas" + icon-left="download"> + Download Results XLSX + </b-button> + % endif + % if master.rows_downloadable_csv and master.has_perm('row_results_csv'): + <b-button tag="a" href="${master.get_action_url('row_results_csv', instance)}" + icon-pack="fas" + icon-left="download"> + Download Results CSV + </b-button> % endif </%def> +<%def name="render_this_page_component()"> + ## TODO: should override this in a cleaner way! too much duplicate code w/ parent template + <this-page @change-content-title="changeContentTitle" + % if can_edit_help: + :configure-fields-help="configureFieldsHelp" + % endif + % if expose_versions: + :viewing-history="viewingHistory" + % endif + > + </this-page> +</%def> + <%def name="render_this_page()"> - ${parent.render_this_page()} - % if master.has_rows: - % if use_buefy: - <br /> - % if rows_title: - <h4 class="block is-size-4">${rows_title}</h4> - % endif - ${self.render_row_grid_component()} - % else: - ${rows_grid|n} - % endif + <div + % if expose_versions: + v-show="!viewingHistory" + % endif + > + + ## render main form + ${parent.render_this_page()} + + ## render row grid + % if getattr(master, 'has_rows', False): + <br /> + % if rows_title: + <h4 class="block is-size-4">${rows_title}</h4> + % endif + ${self.render_row_grid_component()} + % endif + </div> + + % if expose_versions: + <div v-show="viewingHistory"> + + <div style="display: flex; align-items: center; gap: 2rem;"> + <h3 class="is-size-3">Version History</h3> + <p class="block"> + <a href="${master.get_action_url('versions', instance)}" + target="_blank"> + % if request.use_oruga: + <o-icon icon="external-link-alt" /> + % else: + <i class="fas fa-external-link-alt"></i> + % endif + View as separate page + </a> + </p> + </div> + + ${versions_grid.render_vue_tag(ref='versionsGrid', **{'@view-revision': 'viewRevision'})} + + <${b}-modal :width="1200" + % if request.use_oruga: + v-model:active="viewVersionShowDialog" + % else: + :active.sync="viewVersionShowDialog" + % endif + > + <div class="card"> + <div class="card-content"> + <div style="display: flex; flex-direction: column; gap: 1.5rem;"> + + <div style="display: flex; gap: 1rem;"> + + <div style="flex-grow: 1;"> + <b-field horizontal label="Changed"> + <div v-html="viewVersionData.changed"></div> + </b-field> + <b-field horizontal label="Changed by"> + <div v-html="viewVersionData.changed_by"></div> + </b-field> + <b-field horizontal label="IP Address"> + <div v-html="viewVersionData.remote_addr"></div> + </b-field> + <b-field horizontal label="Comment"> + <div v-html="viewVersionData.comment"></div> + </b-field> + <b-field horizontal label="TXN ID"> + <div v-html="viewVersionData.txnid"></div> + </b-field> + </div> + + <div style="display: flex; flex-direction: column; justify-content: space-between;"> + + <div class="buttons"> + <b-button @click="viewPrevRevision()" + type="is-primary" + icon-pack="fas" + icon-left="arrow-left" + :disabled="!viewVersionData.prev_txnid"> + Older + </b-button> + <b-button @click="viewNextRevision()" + type="is-primary" + icon-pack="fas" + icon-right="arrow-right" + :disabled="!viewVersionData.next_txnid"> + Newer + </b-button> + </div> + + <div> + <a :href="viewVersionData.url" + target="_blank"> + % if request.use_oruga: + <o-icon icon="external-link-alt" /> + % else: + <i class="fas fa-external-link-alt"></i> + % endif + View as separate page + </a> + </div> + + <b-button @click="toggleVersionFields()"> + {{ viewVersionShowAllFields ? "Show Diffs Only" : "Show All Fields" }} + </b-button> + </div> + + </div> + + <div v-for="version in viewVersionData.versions" + :key="version.key"> + + <p class="block has-text-weight-bold"> + {{ version.model_title }} + ({{ version.operation }}) + </p> + + <table class="diff monospace is-size-7" + :class="version.diff_class"> + <thead> + <tr> + <th>field name</th> + <th>old value</th> + <th>new value</th> + </tr> + </thead> + <tbody> + <tr v-for="field in version.fields" + :key="field" + :class="{diff: version.values[field].after != version.values[field].before}" + v-show="viewVersionShowAllFields || version.values[field].after != version.values[field].before"> + <td class="field has-text-weight-bold">{{ field }}</td> + <td class="old-value" v-html="version.values[field].before"></td> + <td class="new-value" v-html="version.values[field].after"></td> + </tr> + </tbody> + </table> + + </div> + + </div> + % if request.use_oruga: + <o-loading v-model:active="viewVersionLoading" :is-full-page="false" /> + % else: + <b-loading :active.sync="viewVersionLoading" :is-full-page="false"></b-loading> + % endif + </div> + </div> + </${b}-modal> + </div> % endif </%def> <%def name="render_row_grid_component()"> - <tailbone-grid ref="rowGrid" id="rowGrid"></tailbone-grid> + ${rows_grid.render_vue_tag(id='rowGrid', ref='rowGrid')} </%def> -<%def name="render_this_page_template()"> - % if master.has_rows: - ## TODO: stop using |n filter - ${rows_grid.render_buefy(allow_save_defaults=False, tools=capture(self.render_row_grid_tools))|n} +<%def name="render_vue_templates()"> + ${parent.render_vue_templates()} + % if getattr(master, 'has_rows', False): + ${rows_grid.render_vue_template(allow_save_defaults=False, tools=capture(self.render_row_grid_tools))} + % endif + % if expose_versions: + ${versions_grid.render_vue_template()} % endif - ${parent.render_this_page_template()} </%def> -<%def name="finalize_this_page_vars()"> - ${parent.finalize_this_page_vars()} - % if master.has_rows: - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> - TailboneGrid.data = function() { return TailboneGridData } + % if getattr(master, 'touchable', False) and master.has_perm('touch'): - Vue.component('tailbone-grid', TailboneGrid) + WholePageData.touchSubmitting = false + WholePage.methods.touchRecord = function() { + this.touchSubmitting = true + location.href = '${master.get_action_url('touch', instance)}' + } + + % endif + + % if expose_versions: + + WholePageData.viewingHistory = false + ThisPage.props.viewingHistory = Boolean + + ThisPageData.gettingRevisions = false + ThisPageData.gotRevisions = false + + ThisPageData.viewVersionShowDialog = false + ThisPageData.viewVersionData = {} + ThisPageData.viewVersionShowAllFields = false + ThisPageData.viewVersionLoading = false + + // auto-fetch grid results when first viewing history + ThisPage.watch.viewingHistory = function(newval, oldval) { + if (!this.gotRevisions && !this.gettingRevisions) { + this.gettingRevisions = true + this.$refs.versionsGrid.loadAsyncData(null, () => { + this.gettingRevisions = false + this.gotRevisions = true + }, () => { + this.gettingRevisions = false + }) + } + } + + VersionsGrid.methods.viewRevision = function(row) { + this.$emit('view-revision', row) + } + + ThisPage.methods.viewRevision = function(row) { + this.viewVersionLoading = true + + let url = '${master.get_action_url('revisions_data', instance)}' + let params = {txnid: row.id} + this.simpleGET(url, params, response => { + this.viewVersionData = response.data + this.viewVersionLoading = false + }, response => { + this.viewVersionLoading = false + }) + + this.viewVersionShowDialog = true + } + + ThisPage.methods.viewPrevRevision = function() { + this.viewRevision({id: this.viewVersionData.prev_txnid}) + } + + ThisPage.methods.viewNextRevision = function() { + this.viewRevision({id: this.viewVersionData.next_txnid}) + } + + ThisPage.methods.toggleVersionFields = function() { + this.viewVersionShowAllFields = !this.viewVersionShowAllFields + } + + % endif </script> - % endif </%def> - -${parent.body()} +<%def name="make_vue_components()"> + ${parent.make_vue_components()} + % if getattr(master, 'has_rows', False): + ${rows_grid.render_vue_finalize()} + % endif + % if expose_versions: + ${versions_grid.render_vue_finalize()} + % endif +</%def> diff --git a/tailbone/templates/master/view_row.mako b/tailbone/templates/master/view_row.mako index 255caf69..623a33a0 100644 --- a/tailbone/templates/master/view_row.mako +++ b/tailbone/templates/master/view_row.mako @@ -12,9 +12,6 @@ % if master.rows_editable and instance_editable and request.has_perm('{}.edit'.format(permission_prefix)): <li>${h.link_to("Edit this {}".format(model_title), action_url('edit', instance))}</li> % endif - % if not use_buefy and instance_deletable and master.has_perm('delete_row'): - <li>${h.link_to("Delete this {}".format(model_title), action_url('delete', instance))}</li> - % endif % if rows_creatable and request.has_perm('{}.create'.format(permission_prefix)): <li>${h.link_to("Create a new {}".format(model_title), url('{}.create'.format(route_prefix)))}</li> % endif diff --git a/tailbone/templates/master/view_version.mako b/tailbone/templates/master/view_version.mako index 5dbcd15d..dfe03a64 100644 --- a/tailbone/templates/master/view_version.mako +++ b/tailbone/templates/master/view_version.mako @@ -19,102 +19,39 @@ </%def> <%def name="page_content()"> -## TODO: this was basically copied from Revel diff template..need to abstract -<div class="form-wrapper"> + <div class="form-wrapper" style="margin: 1rem; 0;"> + <div class="form"> - <div class="form"> + <b-field label="Changed" horizontal> + <span>${h.pretty_datetime(request.rattail_config, changed)}</span> + </b-field> + + <b-field label="Changed by" horizontal> + <span>${transaction.user or ''}</span> + </b-field> + + <b-field label="IP Address" horizontal> + <span>${transaction.remote_addr}</span> + </b-field> + + <b-field label="Comment" horizontal> + <span>${transaction.meta.get('comment') or ''}</span> + </b-field> + + <b-field label="TXN ID" horizontal> + <span>${transaction.id}</span> + </b-field> - <div class="field-wrapper"> - <label>Changed</label> - <div class="field">${h.pretty_datetime(request.rattail_config, changed)}</div> </div> - - <div class="field-wrapper"> - <label>Changed by</label> - <div class="field">${transaction.user or ''}</div> - </div> - - <div class="field-wrapper"> - <label>IP Address</label> - <div class="field">${transaction.remote_addr}</div> - </div> - - <div class="field-wrapper"> - <label>Comment</label> - <div class="field">${transaction.meta.get('comment') or ''}</div> - </div> - </div> -</div><!-- form-wrapper --> - -<div class="versions-wrapper"> -% for version in versions: - - <h2>${title_for_version(version)}</h2> - - % if version.previous and version.operation_type == continuum.Operation.DELETE: - <table class="diff monospace deleted"> - <thead> - <tr> - <th>field name</th> - <th>old value</th> - <th>new value</th> - </tr> - </thead> - <tbody> - % for field in fields_for_version(version): - <tr> - <td class="field">${field}</td> - <td class="value old-value">${render_old_value(version, field)}</td> - <td class="value new-value"> </td> - </tr> - % endfor - </tbody> - </table> - % elif version.previous: - <table class="diff monospace dirty"> - <thead> - <tr> - <th>field name</th> - <th>old value</th> - <th>new value</th> - </tr> - </thead> - <tbody> - % for field in fields_for_version(version): - <tr${' class="diff"' if getattr(version, field) != getattr(version.previous, field) else ''|n}> - <td class="field">${field}</td> - <td class="value old-value">${render_old_value(version, field)}</td> - <td class="value new-value">${render_new_value(version, field, 'dirty')}</td> - </tr> - % endfor - </tbody> - </table> - % else: - <table class="diff monospace new"> - <thead> - <tr> - <th>field name</th> - <th>old value</th> - <th>new value</th> - </tr> - </thead> - <tbody> - % for field in fields_for_version(version): - <tr> - <td class="field">${field}</td> - <td class="value old-value"> </td> - <td class="value new-value">${render_new_value(version, field, 'new')}</td> - </tr> - % endfor - </tbody> - </table> - % endif - -% endfor -</div> + <div class="versions-wrapper"> + % for diff in version_diffs: + <h4 class="is-size-4 block">${diff.title}</h4> + ${diff.render_html()} + % endfor + </div> </%def> diff --git a/tailbone/templates/members/configure.mako b/tailbone/templates/members/configure.mako new file mode 100644 index 00000000..f1f0e39f --- /dev/null +++ b/tailbone/templates/members/configure.mako @@ -0,0 +1,77 @@ +## -*- coding: utf-8; -*- +<%inherit file="/configure.mako" /> + +<%def name="form_content()"> + + <h3 class="block is-size-3">General</h3> + <div class="block" style="padding-left: 2rem;"> + + <b-field grouped> + + <b-field label="Key Field"> + <b-select name="rattail.members.key_field" + v-model="simpleSettings['rattail.members.key_field']" + @input="updateKeyLabel()"> + <option value="id">id</option> + <option value="number">number</option> + </b-select> + </b-field> + + <b-field label="Key Field Label"> + <b-input name="rattail.members.key_label" + v-model="simpleSettings['rattail.members.key_label']" + @input="settingsNeedSaved = true"> + </b-input> + </b-field> + + </b-field> + + <b-field message="If set, grid links are to Member tab of Profile view."> + <b-checkbox name="rattail.members.straight_to_profile" + v-model="simpleSettings['rattail.members.straight_to_profile']" + native-value="true" + @input="settingsNeedSaved = true"> + Link directly to Profile when applicable + </b-checkbox> + </b-field> + + </div> + + <h3 class="block is-size-3">Relationships</h3> + <div class="block" style="padding-left: 2rem;"> + + <b-field message="By default a Person may have multiple Member accounts."> + <b-checkbox name="rattail.members.max_one_per_person" + v-model="simpleSettings['rattail.members.max_one_per_person']" + native-value="true" + @input="settingsNeedSaved = true"> + Limit one (1) Member account per Person + </b-checkbox> + </b-field> + + </div> +</%def> + +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> + + ThisPage.methods.getLabelForKey = function(key) { + switch (key) { + case 'id': + return "ID" + case 'number': + return "Number" + default: + return "Key" + } + } + + ThisPage.methods.updateKeyLabel = function() { + this.simpleSettings['rattail.members.key_label'] = this.getLabelForKey( + this.simpleSettings['rattail.members.key_field']) + this.settingsNeedSaved = true + } + + </script> +</%def> diff --git a/tailbone/templates/members/view.mako b/tailbone/templates/members/view.mako index 3f2b6c14..071e37d3 100644 --- a/tailbone/templates/members/view.mako +++ b/tailbone/templates/members/view.mako @@ -4,16 +4,7 @@ <%def name="object_helpers()"> ${parent.object_helpers()} - <% people = h.OrderedDict() %> - % if instance.person: - <% people[instance.person.uuid] = instance.person %> - % endif - % if instance.customer: - % for person in instance.customer.people: - <% people[person.uuid] = person %> - % endfor - % endif - ${view_profiles_helper(people.values())} + ${view_profiles_helper(show_profiles_people)} </%def> ${parent.body()} diff --git a/tailbone/templates/messages/archive/index.mako b/tailbone/templates/messages/archive/index.mako index 002b9e90..16a05ee2 100644 --- a/tailbone/templates/messages/archive/index.mako +++ b/tailbone/templates/messages/archive/index.mako @@ -3,15 +3,6 @@ <%def name="title()">Message Archive</%def> -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - % if not use_buefy: - <script type="text/javascript"> - destination = "Inbox"; - </script> - % endif -</%def> - <%def name="context_menu_items()"> ${parent.context_menu_items()} <li>${h.link_to("Go to my Message Inbox", url('messages.inbox'))}</li> diff --git a/tailbone/templates/messages/create.mako b/tailbone/templates/messages/create.mako index fc046e36..39236f75 100644 --- a/tailbone/templates/messages/create.mako +++ b/tailbone/templates/messages/create.mako @@ -2,148 +2,26 @@ <%inherit file="/master/create.mako" /> <%namespace file="/messages/recipients.mako" import="message_recipients_template" /> -<%def name="content_title()">${parent.content_title() if not use_buefy else ''}</%def> +<%def name="content_title()"></%def> <%def name="extra_javascript()"> ${parent.extra_javascript()} - % if use_buefy: - ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.message_recipients.js'))} - % else: - ${h.javascript_link(request.static_url('tailbone:static/js/lib/tag-it.min.js'))} - <script type="text/javascript"> - - var recipient_mappings = new Map([ - <% last = len(available_recipients) %> - % for i, recip in enumerate(available_recipients, 1): - <% uuid, entry = recip %> - ['${uuid}', ${json.dumps(entry)|n}]${',' if i < last else ''} - % endfor - ]); - - // validate message before sending - function validate_message_form() { - var form = $('#deform'); - - if (! form.find('input[name="set_recipients"]').val()) { - alert("You must specify some recipient(s) for the message."); - $('.set_recipients input').data('ui-tagit').tagInput.focus(); - return false; - } - - if (! form.find('input[name="subject"]').val()) { - alert("You must provide a subject for the message."); - form.find('input[name="subject"]').focus(); - return false; - } - - return true; - } - - $(function() { - - var recipients = $('.set_recipients input'); - - recipients.tagit({ - - autocomplete: { - delay: 0, - minLength: 2, - autoFocus: true, - removeConfirmation: true, - - source: function(request, response) { - var term = request.term.toLowerCase(); - var data = []; - recipient_mappings.forEach(function(name, uuid) { - if (!name.toLowerCase && name.name) { - name = name.name; - } - if (name.toLowerCase().indexOf(term) >= 0) { - data.push({value: uuid, label: name}); - } - }); - response(data); - } - }, - - beforeTagAdded: ${self.before_tag_added()}, - - beforeTagRemoved: function(event, ui) { - - // Unfortunately we're responsible for cleaning up the hidden - // field, since the values there do not match the tag labels. - var tags = recipients.tagit('assignedTags'); - var uuid = ui.tag.data('uuid'); - tags = tags.filter(function(element) { - return element != uuid; - }); - recipients.data('ui-tagit')._updateSingleTagsField(tags); - } - }); - - // set focus to recipients field - recipients.data('ui-tagit').tagInput.focus(); - }); - - </script> - ${self.validate_message_js()} - % endif -</%def> - -<%def name="validate_message_js()"> - <script type="text/javascript"> - $(function() { - $('#new-message').submit(function() { - return validate_message_form(); - }); - }); - </script> + ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.message_recipients.js'))} </%def> <%def name="extra_styles()"> ${parent.extra_styles()} - % if use_buefy: - <style type="text/css"> - - .this-page-content { - width: 100%; - } - - .this-page-content .buttons { - margin-left: 20rem; - } - - </style> - % else: - ${h.stylesheet_link(request.static_url('tailbone:static/css/jquery.tagit.css'))} <style type="text/css"> - .recipients input { - min-width: 525px; + .this-page-content { + width: 100%; } - .subject input { - min-width: 540px; - } - - .body textarea { - min-width: 540px; + .this-page-content .buttons { + margin-left: 20rem; } </style> - % endif -</%def> - -<%def name="before_tag_added()"> - function(event, ui) { - - // Lookup the name in cached mapping, and show that on the tag, instead - // of the UUID. The tagit widget should take care of keeping the - // hidden field in sync for us, still using the UUID. - var uuid = ui.tagLabel; - var name = recipient_mappings.get(uuid); - ui.tag.find('.tagit-label').html(name); - } </%def> <%def name="context_menu_items()"> @@ -154,20 +32,30 @@ % endif </%def> -<%def name="render_this_page_template()"> - ${parent.render_this_page_template()} +<%def name="render_vue_templates()"> + ${parent.render_vue_templates()} ${message_recipients_template()} </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> TailboneFormData.possibleRecipients = new Map(${json.dumps(available_recipients)|n}) TailboneFormData.recipientDisplayMap = ${json.dumps(recipient_display_map)|n} + TailboneForm.methods.subjectKeydown = function(event) { + + // do not auto-submit form when user presses enter in subject field + if (event.which == 13) { + event.preventDefault() + + // set focus to msg body input if possible + if (this.$refs.messageBody && this.$refs.messageBody.focus) { + this.$refs.messageBody.focus() + } + } + } + </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/messages/inbox/index.mako b/tailbone/templates/messages/inbox/index.mako index f88010b0..2ac24b9e 100644 --- a/tailbone/templates/messages/inbox/index.mako +++ b/tailbone/templates/messages/inbox/index.mako @@ -3,15 +3,6 @@ <%def name="title()">Message Inbox</%def> -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - % if not use_buefy: - <script type="text/javascript"> - destination = "Archive"; - </script> - % endif -</%def> - <%def name="context_menu_items()"> ${parent.context_menu_items()} <li>${h.link_to("Go to my Message Archive", url('messages.archive'))}</li> diff --git a/tailbone/templates/messages/index.mako b/tailbone/templates/messages/index.mako index 4ded5571..eaa4b6c9 100644 --- a/tailbone/templates/messages/index.mako +++ b/tailbone/templates/messages/index.mako @@ -1,52 +1,6 @@ ## -*- coding: utf-8; -*- <%inherit file="/master/index.mako" /> -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - % if not use_buefy: - <script type="text/javascript"> - - var destination = null; - - function update_move_button() { - var count = $('.grid tr:not(.header) td.checkbox input:checked').length; - $('form[name="move-selected"] button') - .button('option', 'label', "Move " + count + " selected to " + destination) - .button('option', 'disabled', count < 1); - } - - $(function() { - - update_move_button(); - - $('.grid-wrapper').on('change', 'tr.header td.checkbox input', function() { - update_move_button(); - }); - - $('.grid-wrapper').on('click', 'tr:not(.header) td.checkbox input', function() { - update_move_button(); - }); - - $('form[name="move-selected"]').submit(function() { - var uuids = []; - $('.grid tr:not(.header) td.checkbox input:checked').each(function() { - uuids.push($(this).parents('tr:first').data('uuid')); - }); - if (! uuids.length) { - return false; - } - $(this).find('[name="uuids"]').val(uuids.toString()); - $(this).find('button') - .button('option', 'label', "Moving " + uuids.length + " messages to " + destination + "...") - .button('disable'); - }); - - }); - - </script> - % endif -</%def> - <%def name="context_menu_items()"> % if request.has_perm('messages.create'): <li>${h.link_to("Send a new Message", url('messages.create'))}</li> @@ -55,37 +9,28 @@ <%def name="grid_tools()"> % if request.matched_route.name in ('messages.inbox', 'messages.archive'): - % if use_buefy: - ${h.form(url('messages.move_bulk'), **{'@submit': 'moveMessagesSubmit'})} - ${h.csrf_token(request)} - ${h.hidden('destination', value='archive' if request.matched_route.name == 'messages.inbox' else 'inbox')} - ${h.hidden('uuids', v_model='selected_uuids')} - <b-button type="is-primary" - native-type="submit" - :disabled="moveMessagesSubmitting || !checkedRows.length"> - {{ moveMessagesTextCurrent }} - </b-button> - ${h.end_form()} - % else: - ${h.form(url('messages.move_bulk'), name='move-selected')} - ${h.csrf_token(request)} - ${h.hidden('destination', value='archive' if request.matched_route.name == 'messages.inbox' else 'inbox')} - ${h.hidden('uuids')} - <button type="submit">Move 0 selected to ${'Archive' if request.matched_route.name == 'messages.inbox' else 'Inbox'}</button> - ${h.end_form()} - % endif + ${h.form(url('messages.move_bulk'), **{'@submit': 'moveMessagesSubmit'})} + ${h.csrf_token(request)} + ${h.hidden('destination', value='archive' if request.matched_route.name == 'messages.inbox' else 'inbox')} + ${h.hidden('uuids', v_model='selected_uuids')} + <b-button type="is-primary" + native-type="submit" + :disabled="moveMessagesSubmitting || !checkedRows.length"> + {{ moveMessagesTextCurrent }} + </b-button> + ${h.end_form()} % endif </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} % if request.matched_route.name in ('messages.inbox', 'messages.archive'): - <script type="text/javascript"> + <script> - TailboneGridData.moveMessagesSubmitting = false - TailboneGridData.moveMessagesText = null + ${grid.vue_component}Data.moveMessagesSubmitting = false + ${grid.vue_component}Data.moveMessagesText = null - TailboneGrid.computed.moveMessagesTextCurrent = function() { + ${grid.vue_component}.computed.moveMessagesTextCurrent = function() { if (this.moveMessagesText) { return this.moveMessagesText } @@ -93,7 +38,7 @@ return "Move " + count.toString() + " selected to ${'Archive' if request.matched_route.name == 'messages.inbox' else 'Inbox'}" } - TailboneGrid.methods.moveMessagesSubmit = function() { + ${grid.vue_component}.methods.moveMessagesSubmit = function() { this.moveMessagesSubmitting = true this.moveMessagesText = "Working, please wait..." } @@ -101,6 +46,3 @@ </script> % endif </%def> - - -${parent.body()} diff --git a/tailbone/templates/messages/view.mako b/tailbone/templates/messages/view.mako index 78caab93..36418698 100644 --- a/tailbone/templates/messages/view.mako +++ b/tailbone/templates/messages/view.mako @@ -1,66 +1,20 @@ ## -*- coding: utf-8; -*- <%inherit file="/master/view.mako" /> -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - % if not use_buefy: - <script type="text/javascript"> - - $(function() { - - $('.field-wrapper.recipients .more').click(function() { - $(this).hide(); - $(this).siblings('.everyone').css('display', 'inline-block'); - return false; - }); - - $('.field-wrapper.recipients .everyone').click(function() { - $(this).hide(); - $(this).siblings('.more').show(); - }); - - }); - - </script> - % endif -</%def> - <%def name="extra_styles()"> ${parent.extra_styles()} - % if use_buefy: - <style type="text/css"> - .everyone { - cursor: pointer; - } - .tailbone-message-body { - margin: 1rem auto; - min-height: 10rem; - } - .tailbone-message-body p { - margin-bottom: 1rem; - } - </style> - % else: <style type="text/css"> - .recipients .everyone { + .everyone { cursor: pointer; - display: none; } - .message-tools { - margin-bottom: 15px; + .tailbone-message-body { + margin: 1rem auto; + min-height: 10rem; } - .message-body { - border-top: 1px solid black; - border-bottom: 1px solid black; - margin-bottom: 15px; - padding: 0 5em; - white-space: pre-line; - } - .message-body p { - margin-bottom: 15px; + .tailbone-message-body p { + margin-bottom: 1rem; } </style> - % endif </%def> <%def name="context_menu_items()"> @@ -86,43 +40,29 @@ <%def name="message_tools()"> % if recipient: - % if use_buefy: - <div class="buttons"> - % if request.has_perm('messages.create'): - <once-button type="is-primary" - tag="a" href="${url('messages.reply', uuid=instance.uuid)}" - text="Reply"> - </once-button> - <once-button type="is-primary" - tag="a" href="${url('messages.reply_all', uuid=instance.uuid)}" - text="Reply to All"> - </once-button> - % endif - % if recipient.status == enum.MESSAGE_STATUS_INBOX: - <once-button type="is-primary" - tag="a" href="${url('messages.move', uuid=instance.uuid)}?dest=archive" - text="Move to Archive"> - </once-button> - % else: - <once-button type="is-primary" - tag="a" href="${url('messages.move', uuid=instance.uuid)}?dest=inbox" - text="Move to Inbox"> - </once-button> - % endif - </div> - % else: - <div class="message-tools"> - % if request.has_perm('messages.create'): - ${h.link_to("Reply", url('messages.reply', uuid=instance.uuid), class_='button')} - ${h.link_to("Reply to All", url('messages.reply_all', uuid=instance.uuid), class_='button')} - % endif - % if recipient.status == enum.MESSAGE_STATUS_INBOX: - ${h.link_to("Move to Archive", url('messages.move', uuid=instance.uuid) + '?dest=archive', class_='button')} - % else: - ${h.link_to("Move to Inbox", url('messages.move', uuid=instance.uuid) + '?dest=inbox', class_='button')} - % endif - </div> - % endif + <div class="buttons"> + % if request.has_perm('messages.create'): + <once-button type="is-primary" + tag="a" href="${url('messages.reply', uuid=instance.uuid)}" + text="Reply"> + </once-button> + <once-button type="is-primary" + tag="a" href="${url('messages.reply_all', uuid=instance.uuid)}" + text="Reply to All"> + </once-button> + % endif + % if recipient.status == enum.MESSAGE_STATUS_INBOX: + <once-button type="is-primary" + tag="a" href="${url('messages.move', uuid=instance.uuid)}?dest=archive" + text="Move to Archive"> + </once-button> + % else: + <once-button type="is-primary" + tag="a" href="${url('messages.move', uuid=instance.uuid)}?dest=inbox" + text="Move to Inbox"> + </once-button> + % endif + </div> % endif </%def> @@ -132,40 +72,29 @@ <%def name="page_content()"> ${parent.page_content()} - % if use_buefy: - <br /> - <div style="margin-left: 5rem;"> - ${self.message_tools()} - <div class="tailbone-message-body"> - ${self.message_body()} - </div> - ${self.message_tools()} - </div> - % else: - ${self.message_tools()} - <div class="message-body"> - ${self.message_body()} - </div> - ${self.message_tools()} - % endif + <br /> + <div style="margin-left: 5rem;"> + ${self.message_tools()} + <div class="tailbone-message-body"> + ${self.message_body()} + </div> + ${self.message_tools()} + </div> </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> - TailboneFormData.showingAllRecipients = false + ${form.vue_component}Data.showingAllRecipients = false - TailboneForm.methods.showMoreRecipients = function() { + ${form.vue_component}.methods.showMoreRecipients = function() { this.showingAllRecipients = true } - TailboneForm.methods.hideMoreRecipients = function() { + ${form.vue_component}.methods.hideMoreRecipients = function() { this.showingAllRecipients = false } </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/multi_file_upload.mako b/tailbone/templates/multi_file_upload.mako new file mode 100644 index 00000000..e78de194 --- /dev/null +++ b/tailbone/templates/multi_file_upload.mako @@ -0,0 +1,60 @@ +## -*- coding: utf-8; -*- + +<%def name="render_template()"> + <script type="text/x-template" id="multi-file-upload-template"> + <section> + <b-field class="file"> + <b-upload name="upload" multiple drag-drop expanded + v-model="files"> + <section class="section"> + <div class="content has-text-centered"> + <p> + <b-icon pack="fas" icon="upload" size="is-large"></b-icon> + </p> + <p>Drop your files here or click to upload</p> + </div> + </section> + </b-upload> + </b-field> + + <div class="tags" style="max-width: 40rem;"> + <span v-for="(file, index) in files" :key="index" class="tag is-primary"> + {{file.name}} + <button class="delete is-small" type="button" + @click="deleteFile(index)"> + </button> + </span> + </div> + </section> + </script> +</%def> + +<%def name="declare_vars()"> + <script type="text/javascript"> + + let MultiFileUpload = { + template: '#multi-file-upload-template', + methods: { + + deleteFile(index) { + this.files.splice(index, 1); + }, + }, + } + + let MultiFileUploadData = { + files: [], + } + + </script> +</%def> + +<%def name="make_component()"> + <script type="text/javascript"> + + MultiFileUpload.data = function() { return MultiFileUploadData } + + Vue.component('multi-file-upload', MultiFileUpload) + + </script> +</%def> diff --git a/tailbone/templates/ordering/configure.mako b/tailbone/templates/ordering/configure.mako new file mode 100644 index 00000000..dc505c42 --- /dev/null +++ b/tailbone/templates/ordering/configure.mako @@ -0,0 +1,74 @@ +## -*- coding: utf-8; -*- +<%inherit file="/configure.mako" /> + +<%def name="form_content()"> + + <h3 class="block is-size-3">Workflows</h3> + <div class="block" style="padding-left: 2rem;"> + + <p class="block"> + Users can only choose from the workflows enabled below. + </p> + + <b-field> + <b-checkbox name="rattail.batch.purchase.allow_ordering_from_scratch" + v-model="simpleSettings['rattail.batch.purchase.allow_ordering_from_scratch']" + native-value="true" + @input="settingsNeedSaved = true"> + From Scratch + </b-checkbox> + </b-field> + + <b-field> + <b-checkbox name="rattail.batch.purchase.allow_ordering_from_file" + v-model="simpleSettings['rattail.batch.purchase.allow_ordering_from_file']" + native-value="true" + @input="settingsNeedSaved = true"> + From Order File + </b-checkbox> + </b-field> + + </div> + + <h3 class="block is-size-3">Vendors</h3> + <div class="block" style="padding-left: 2rem;"> + + <b-field message="If not set, user must choose a "supported" vendor."> + <b-checkbox name="rattail.batch.purchase.allow_ordering_any_vendor" + v-model="simpleSettings['rattail.batch.purchase.allow_ordering_any_vendor']" + native-value="true" + @input="settingsNeedSaved = true"> + Allow ordering for <span class="has-text-weight-bold">any</span> vendor + </b-checkbox> + </b-field> + + </div> + + <h3 class="block is-size-3">Order Parsers</h3> + <div class="block" style="padding-left: 2rem;"> + + <p class="block"> + Only the selected file parsers will be exposed to users. + </p> + + % for Parser in order_parsers: + <b-field message="${Parser.key}"> + <b-checkbox name="order_parser_${Parser.key}" + v-model="orderParsers['${Parser.key}']" + native-value="true" + @input="settingsNeedSaved = true"> + ${Parser.title} + </b-checkbox> + </b-field> + % endfor + + </div> + +</%def> + +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> + ThisPageData.orderParsers = ${json.dumps(order_parsers_data)|n} + </script> +</%def> diff --git a/tailbone/templates/ordering/create.mako b/tailbone/templates/ordering/create.mako index ff0fc836..8f2d5e27 100644 --- a/tailbone/templates/ordering/create.mako +++ b/tailbone/templates/ordering/create.mako @@ -1,82 +1,6 @@ ## -*- coding: utf-8; -*- <%inherit file="/batch/create.mako" /> -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - % if not use_buefy: - ${self.func_show_mode()} - <script type="text/javascript"> - - var purchases_field = '${purchases_field}'; - var purchases = null; // TODO: where is this used? - - function vendor_selected(uuid, name) { -## var mode = $('.mode select').val(); -## if (mode == ${enum.PURCHASE_BATCH_MODE_RECEIVING} || mode == ${enum.PURCHASE_BATCH_MODE_COSTING}) { -## var purchases = $('.purchase_uuid select'); -## purchases.empty(); -## -## var data = {'vendor_uuid': uuid, 'mode': mode}; -## $.get('${url('purchases.batch.eligible_purchases')}', data, function(data) { -## if (data.error) { -## alert(data.error); -## } else { -## $.each(data.purchases, function(i, purchase) { -## purchases.append($('<option value="' + purchase.key + '">' + purchase.display + '</option>')); -## }); -## } -## }); -## -## // TODO: apparently refresh doesn't work right? -## // http://stackoverflow.com/a/10280078 -## // purchases.selectmenu('refresh'); -## purchases.selectmenu('destroy').selectmenu(); -## } - } - - function vendor_cleared() { - var purchases = $('.purchase_uuid select'); - purchases.empty(); - - // TODO: apparently refresh doesn't work right? - // http://stackoverflow.com/a/10280078 - // purchases.selectmenu('refresh'); - purchases.selectmenu('destroy').selectmenu(); - } - - $(function() { - - $('.field-wrapper.mode select').selectmenu({ - change: function(event, ui) { - show_mode(ui.item.value); - } - }); - - show_mode(${enum.PURCHASE_BATCH_MODE_ORDERING}); - - }); - - </script> - % endif -</%def> - -<%def name="func_show_mode()"> - <script type="text/javascript"> - - // TODO: mode is presumably null here.. - function show_mode(mode) { - $('.field-wrapper.store_uuid').show(); - $('.field-wrapper.' + purchases_field).hide(); - $('.field-wrapper.department_uuid').show(); - $('.field-wrapper.buyer_uuid').show(); - $('.field-wrapper.date_ordered').show(); - $('.field-wrapper.date_received').hide(); - $('.field-wrapper.po_number').show(); - $('.field-wrapper.invoice_date').hide(); - $('.field-wrapper.invoice_number').hide(); - } - - </script> -</%def> +## TODO: deprecate / remove ${parent.body()} diff --git a/tailbone/templates/ordering/view.mako b/tailbone/templates/ordering/view.mako index f0e6380a..34a6085f 100644 --- a/tailbone/templates/ordering/view.mako +++ b/tailbone/templates/ordering/view.mako @@ -21,14 +21,14 @@ % endif </%def> -<%def name="render_this_page_template()"> - ${parent.render_this_page_template()} +<%def name="render_vue_templates()"> + ${parent.render_vue_templates()} % if not batch.executed and not batch.complete and master.has_perm('edit_row'): <script type="text/x-template" id="ordering-scanner-template"> <div> <b-button type="is-primary" icon-pack="fas" - icon-left="fas fa-play" + icon-left="play" @click="startScanning()"> Start Scanning </b-button> @@ -111,7 +111,7 @@ <div class="buttons"> <b-button type="is-primary" icon-pack="fas" - icon-left="fas fa-save" + icon-left="save" @click="saveCurrentRow()"> Save </b-button> @@ -185,10 +185,10 @@ % endif </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} % if not batch.executed and not batch.complete and master.has_perm('edit_row'): - <script type="text/javascript"> + <script> let OrderingScanner = { template: '#ordering-scanner-template', @@ -204,7 +204,7 @@ saving: false, ## TODO: should find a better way to handle CSRF token - csrftoken: ${json.dumps(request.session.get_csrf_token() or request.session.new_csrf_token())|n}, + csrftoken: ${json.dumps(h.get_csrf_token(request))|n}, } }, computed: { @@ -408,16 +408,11 @@ % endif </%def> -<%def name="make_this_page_component()"> - ${parent.make_this_page_component()} +<%def name="make_vue_components()"> + ${parent.make_vue_components()} % if not batch.executed and not batch.complete and master.has_perm('edit_row'): - <script type="text/javascript"> - + <script> Vue.component('ordering-scanner', OrderingScanner) - </script> % endif </%def> - - -${parent.body()} diff --git a/tailbone/templates/ordering/worksheet.mako b/tailbone/templates/ordering/worksheet.mako index 97b1b51b..eb2077e7 100644 --- a/tailbone/templates/ordering/worksheet.mako +++ b/tailbone/templates/ordering/worksheet.mako @@ -3,63 +3,6 @@ <%def name="title()">Ordering Worksheet</%def> -<%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"> - - var submitting = false; - - $(function() { - - $('.order-form td.current-order input').focus(function(event) { - $(this).parents('tr:first').addClass('active'); - }); - - $('.order-form td.current-order input').blur(function(event) { - $(this).parents('tr:first').removeClass('active'); - }); - - $('.order-form td.current-order input').keydown(function(event) { - if (key_allowed(event) || key_modifies(event)) { - return true; - } - if (event.which == 13) { - if (! submitting) { - submitting = true; - var row = $(this).parents('tr:first'); - var form = $('#item-update-form'); - form.find('[name="product_uuid"]').val(row.data('uuid')); - form.find('[name="cases_ordered"]').val(row.find('input[name^="cases_ordered_"]').val() || '0'); - form.find('[name="units_ordered"]').val(row.find('input[name^="units_ordered_"]').val() || '0'); - $.post(form.attr('action'), form.serialize(), function(data) { - if (data.error) { - alert(data.error); - } else { - if (data.row_cases_ordered || data.row_units_ordered) { - row.find('input[name^="cases_ordered_"]').val(data.row_cases_ordered); - row.find('input[name^="units_ordered_"]').val(data.row_units_ordered); - row.find('td.po-total').html(data.row_po_total_calculated); - } else { - row.find('input[name^="cases_ordered_"]').val(''); - row.find('input[name^="units_ordered_"]').val(''); - row.find('td.po-total').html(''); - } - $('.po-total .field').html(data.batch_po_total_calculated); - } - submitting = false; - }); - } - } - return false; - }); - - }); - </script> - % endif -</%def> - <%def name="extra_styles()"> ${parent.extra_styles()} <style type="text/css"> @@ -130,7 +73,7 @@ <div class="grid"> <table class="order-form"> <% column_count = 8 + len(header_columns) + (0 if ignore_cases else 1) + int(capture(self.extra_count)) %> - % for department in sorted(six.itervalues(departments), key=lambda d: d.name if d else ''): + % for department in sorted(departments.values(), key=lambda d: d.name if d else ''): <thead> <tr> <th class="department" colspan="${column_count}">Department @@ -141,7 +84,7 @@ % endif </th> </tr> - % for subdepartment in sorted(six.itervalues(department._order_subdepartments), key=lambda s: s.name if s else ''): + % for subdepartment in sorted(department._order_subdepartments.values(), key=lambda s: s.name if s else ''): <tr> <th class="subdepartment" colspan="${column_count}">Subdepartment % if subdepartment.number or subdepartment.name: @@ -187,10 +130,7 @@ <tbody> % for i, cost in enumerate(subdepartment._order_costs, 1): <tr data-uuid="${cost.product_uuid}" class="${'even' if i % 2 == 0 else 'odd'}" - % if use_buefy: - :class="{active: activeUUID == '${cost.uuid}'}" - % endif - > + :class="{active: activeUUID == '${cost.uuid}'}"> ${self.order_form_row(cost)} % for data in history: <td class="scratch_pad"> @@ -216,34 +156,21 @@ % endfor % if not ignore_cases: <td class="current-order"> - % if use_buefy: - <numeric-input v-model="worksheet.cost_${cost.uuid}_cases" - @focus="activeUUID = '${cost.uuid}'; $event.target.select()" - @blur="activeUUID = null" - @keydown.native="inputKeydown($event, '${cost.uuid}', '${cost.product_uuid}')"> - </numeric-input> - % else: - ${h.text('cases_ordered_{}'.format(cost.uuid), value=int(cost._batchrow.cases_ordered or 0) if cost._batchrow else None)} - % endif - </td> - % endif - <td class="current-order"> - % if use_buefy: - <numeric-input v-model="worksheet.cost_${cost.uuid}_units" + <numeric-input v-model="worksheet.cost_${cost.uuid}_cases" @focus="activeUUID = '${cost.uuid}'; $event.target.select()" @blur="activeUUID = null" @keydown.native="inputKeydown($event, '${cost.uuid}', '${cost.product_uuid}')"> </numeric-input> - % else: - ${h.text('units_ordered_{}'.format(cost.uuid), value=int(cost._batchrow.units_ordered or 0) if cost._batchrow else None)} - % endif - </td> - ## TODO: should not fall back to po_total - % if use_buefy: - <td class="po-total">{{ worksheet.cost_${cost.uuid}_total_display }}</td> - % else: - <td class="po-total">${'${:0,.2f}'.format(cost._batchrow.po_total_calculated or cost._batchrow.po_total or 0) if cost._batchrow else ''}</td> + </td> % endif + <td class="current-order"> + <numeric-input v-model="worksheet.cost_${cost.uuid}_units" + @focus="activeUUID = '${cost.uuid}'; $event.target.select()" + @blur="activeUUID = null" + @keydown.native="inputKeydown($event, '${cost.uuid}', '${cost.product_uuid}')"> + </numeric-input> + </td> + <td class="po-total">{{ worksheet.cost_${cost.uuid}_total_display }}</td> ${self.extra_td(cost)} </tr> % endfor @@ -269,60 +196,11 @@ </%def> <%def name="page_content()"> - % if use_buefy: - <ordering-worksheet></ordering-worksheet> - % else: - <div class="form-wrapper"> - - <div class="field-wrapper"> - <label>Vendor</label> - <div class="field">${h.link_to(vendor, url('vendors.view', uuid=vendor.uuid))}</div> - </div> - - <div class="field-wrapper"> - <label>Vendor Email</label> - <div class="field">${vendor.email or ''}</div> - </div> - - <div class="field-wrapper"> - <label>Vendor Fax</label> - <div class="field">${vendor.fax_number or ''}</div> - </div> - - <div class="field-wrapper"> - <label>Vendor Contact</label> - <div class="field">${vendor.contact or ''}</div> - </div> - - <div class="field-wrapper"> - <label>Vendor Phone</label> - <div class="field">${vendor.phone or ''}</div> - </div> - - ${self.extra_vendor_fields()} - - <div class="field-wrapper po-total"> - <label>PO Total</label> - ## TODO: should not fall back to po_total - <div class="field">$${'{:0,.2f}'.format(batch.po_total_calculated or batch.po_total or 0)}</div> - </div> - - </div><!-- form-wrapper --> - - ${self.order_form_grid()} - - ${h.form(url('ordering.worksheet_update', uuid=batch.uuid), id='item-update-form', style='display: none;')} - ${h.csrf_token(request)} - ${h.hidden('product_uuid')} - ${h.hidden('cases_ordered')} - ${h.hidden('units_ordered')} - ${h.end_form()} - % endif + <ordering-worksheet></ordering-worksheet> </%def> -<%def name="render_this_page_template()"> - ${parent.render_this_page_template()} - +<%def name="render_vue_templates()"> + ${parent.render_vue_templates()} <script type="text/x-template" id="ordering-worksheet-template"> <div> <div class="form-wrapper"> @@ -360,11 +238,7 @@ ${self.order_form_grid()} </div> </script> -</%def> - -<%def name="make_this_page_component()"> - ${parent.make_this_page_component()} - <script type="text/javascript"> + <script> const OrderingWorksheet = { template: '#ordering-worksheet-template', @@ -376,7 +250,7 @@ submitting: false, ## TODO: should find a better way to handle CSRF token - csrftoken: ${json.dumps(request.session.get_csrf_token() or request.session.new_csrf_token())|n}, + csrftoken: ${json.dumps(h.get_csrf_token(request))|n}, } }, methods: { @@ -419,14 +293,12 @@ }, } - Vue.component('ordering-worksheet', OrderingWorksheet) - </script> </%def> - -############################## -## page body -############################## - -${parent.body()} +<%def name="make_vue_components()"> + ${parent.make_vue_components()} + <script> + Vue.component('ordering-worksheet', OrderingWorksheet) + </script> +</%def> diff --git a/tailbone/templates/page.mako b/tailbone/templates/page.mako index 321e60d7..43b0a266 100644 --- a/tailbone/templates/page.mako +++ b/tailbone/templates/page.mako @@ -1,10 +1,50 @@ ## -*- coding: utf-8; -*- <%inherit file="/base.mako" /> -<%def name="context_menu_items()"></%def> +<%def name="render_vue_templates()"> + ${parent.render_vue_templates()} + ${self.render_vue_template_this_page()} +</%def> -<%def name="page_content()"></%def> +<%def name="render_vue_template_this_page()"> + ## DEPRECATED; called for back-compat + ${self.render_this_page_template()} +</%def> +<%def name="render_this_page_template()"> + <script type="text/x-template" id="this-page-template"> + <div> + ## DEPRECATED; called for back-compat + ${self.render_this_page()} + </div> + </script> + <script> + + const ThisPage = { + template: '#this-page-template', + mixins: [SimpleRequestMixin], + props: { + configureFieldsHelp: Boolean, + }, + computed: {}, + watch: {}, + methods: { + + changeContentTitle(newTitle) { + this.$emit('change-content-title', newTitle) + }, + }, + } + + const ThisPageData = { + ## TODO: should find a better way to handle CSRF token + csrftoken: ${json.dumps(h.get_csrf_token(request))|n}, + } + + </script> +</%def> + +## DEPRECATED; remains for back-compat <%def name="render_this_page()"> <div style="display: flex;"> @@ -12,65 +52,55 @@ ${self.page_content()} </div> + ## DEPRECATED; remains for back-compat <ul id="context-menu"> ${self.context_menu_items()} </ul> - </div> </%def> -<%def name="render_this_page_template()"> - <script type="text/x-template" id="this-page-template"> - <div> - ${self.render_this_page()} - </div> - </script> +## nb. this is the canonical block for page content! +<%def name="page_content()"></%def> + +## DEPRECATED; remains for back-compat +<%def name="context_menu_items()"> + % if context_menu_list_items is not Undefined: + % for item in context_menu_list_items: + <li>${item}</li> + % endfor + % endif </%def> -<%def name="declare_this_page_vars()"> - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} - let ThisPage = { - template: '#this-page-template', - mixins: [FormPosterMixin], - computed: {}, - methods: {}, - } - - let ThisPageData = { - ## TODO: should find a better way to handle CSRF token - csrftoken: ${json.dumps(request.session.get_csrf_token() or request.session.new_csrf_token())|n}, - } - - </script> + ## DEPRECATED; called for back-compat + ${self.declare_this_page_vars()} + ${self.modify_this_page_vars()} </%def> -<%def name="modify_this_page_vars()"> - ## NOTE: if you override this, must use <script> tags -</%def> +<%def name="make_vue_components()"> + ${parent.make_vue_components()} -<%def name="finalize_this_page_vars()"> - ## NOTE: if you override this, must use <script> tags + ## DEPRECATED; called for back-compat + ${self.make_this_page_component()} </%def> <%def name="make_this_page_component()"> - ${self.declare_this_page_vars()} - ${self.modify_this_page_vars()} ${self.finalize_this_page_vars()} - - <script type="text/javascript"> - + <script> ThisPage.data = function() { return ThisPageData } - Vue.component('this-page', ThisPage) - + <% request.register_component('this-page', 'ThisPage') %> </script> </%def> +############################## +## DEPRECATED +############################## -% if use_buefy: - ${self.render_this_page_template()} - ${self.make_this_page_component()} -% else: - ${self.render_this_page()} -% endif +<%def name="declare_this_page_vars()"></%def> + +<%def name="modify_this_page_vars()"></%def> + +<%def name="finalize_this_page_vars()"></%def> diff --git a/tailbone/templates/page_help.mako b/tailbone/templates/page_help.mako new file mode 100644 index 00000000..ea86c6da --- /dev/null +++ b/tailbone/templates/page_help.mako @@ -0,0 +1,310 @@ +## -*- coding: utf-8; -*- + +<%def name="render_template()"> + <script type="text/x-template" id="page-help-template"> + <div> + + % if help_url or help_markdown: + + % if request.use_oruga: + <o-button icon-left="question-circle" + % if help_markdown: + @click="displayInit()" + % elif help_url: + tag="a" href="${help_url}" + target="_blank" + % endif + > + Help + </o-button> + + % if can_edit_help: + ## TODO: this dropdown is duplicated, below + <o-dropdown position="bottom-left" + ## TODO: why does click not work here?! + :triggers="['click', 'hover']"> + <template #trigger> + <o-button> + <o-icon icon="cog" /> + </o-button> + </template> + <o-dropdown-item label="Edit Page Help" + @click="configureInit()" /> + <o-dropdown-item label="Edit Fields Help" + @click="configureFieldsInit()" /> + </o-dropdown> + % endif + + % else: + ## buefy + <b-field> + <p class="control"> + <b-button icon-pack="fas" + icon-left="question-circle" + % if help_markdown: + @click="displayInit()" + % elif help_url: + tag="a" href="${help_url}" + target="_blank" + % endif + > + Help + </b-button> + </p> + % if can_edit_help: + ## TODO: this dropdown is duplicated, below + <b-dropdown aria-role="list" position="is-bottom-left"> + <template #trigger="{ active }"> + <b-button> + <span><i class="fa fa-cog"></i></span> + </b-button> + </template> + <b-dropdown-item aria-role="listitem" + @click="configureInit()"> + Edit Page Help + </b-dropdown-item> + <b-dropdown-item aria-role="listitem" + @click="configureFieldsInit()"> + Edit Fields Help + </b-dropdown-item> + </b-dropdown> + % endif + </b-field> + % endif: + + % elif can_edit_help: + + ## TODO: this dropdown is duplicated, above + % if request.use_oruga: + <o-dropdown position="bottom-left" + ## TODO: why does click not work here?! + :triggers="['click', 'hover']"> + <template #trigger> + <o-button> + <o-icon icon="question-circle" /> + <o-icon icon="cog" /> + </o-button> + </template> + <o-dropdown-item label="Edit Page Help" + @click="configureInit()" /> + <o-dropdown-item label="Edit Fields Help" + @click="configureFieldsInit()" /> + </o-dropdown> + % else: + <b-field> + <p class="control"> + <b-dropdown aria-role="list" position="is-bottom-left"> + <template #trigger> + <b-button> + % if request.use_oruga: + <o-icon icon="question-circle" /> + <o-icon icon="cog" /> + % else: + <span><i class="fa fa-question-circle"></i></span> + <span><i class="fa fa-cog"></i></span> + % endif + </b-button> + </template> + <b-dropdown-item aria-role="listitem" + @click="configureInit()"> + Edit Page Help + </b-dropdown-item> + <b-dropdown-item aria-role="listitem" + @click="configureFieldsInit()"> + Edit Fields Help + </b-dropdown-item> + </b-dropdown> + </p> + </b-field> + % endif + % endif + + % if help_markdown: + <${b}-modal has-modal-card + % if request.use_oruga: + v-model:active="displayShowDialog" + % else: + :active.sync="displayShowDialog" + % endif + > + <div class="modal-card"> + + <header class="modal-card-head"> + <p class="modal-card-title">${index_title}</p> + </header> + + <section class="modal-card-body"> + ${h.render_markdown(help_markdown)} + </section> + + <footer class="modal-card-foot"> + % if help_url: + <b-button type="is-primary" + icon-pack="fas" + icon-left="external-link-alt" + tag="a" href="${help_url}" + target="_blank"> + More Info + </b-button> + % endif + <b-button @click="displayShowDialog = false"> + Close + </b-button> + </footer> + </div> + </${b}-modal> + % endif + + % if can_edit_help: + + <${b}-modal has-modal-card + % if request.use_oruga: + v-model:active="configureShowDialog" + % else: + :active.sync="configureShowDialog" + % endif + > + <div class="modal-card" + % if request.use_oruga: + style="margin: auto;" + % endif + > + + <header class="modal-card-head"> + <p class="modal-card-title">Configure Help</p> + </header> + + <section class="modal-card-body"> + + <p class="block"> + This help info applies to all views with the current + route prefix. + </p> + + <b-field grouped> + + <b-field label="Route Prefix"> + <span>${route_prefix}</span> + </b-field> + + <b-field label="URL Prefix"> + <span>${master.get_url_prefix()}</span> + </b-field> + + </b-field> + + <b-field label="Help Link (URL)"> + <b-input v-model="helpURL" + ref="helpURL" + expanded> + </b-input> + </b-field> + + <b-field label="Help Text (Markdown)"> + <b-input v-model="markdownText" + type="textarea" rows="8" + expanded> + </b-input> + </b-field> + + </section> + + <footer class="modal-card-foot"> + <b-button @click="configureShowDialog = false"> + Cancel + </b-button> + <b-button type="is-primary" + @click="configureSave()" + :disabled="configureSaving" + icon-pack="fas" + icon-left="save"> + {{ configureSaving ? "Working, please wait..." : "Save" }} + </b-button> + </footer> + </div> + </${b}-modal> + + % endif + + </div> + </script> +</%def> + +<%def name="declare_vars()"> + <script type="text/javascript"> + + let PageHelp = { + + template: '#page-help-template', + mixins: [FormPosterMixin], + + methods: { + + displayInit() { + this.displayShowDialog = true + }, + + % if can_edit_help: + configureInit() { + this.configureShowDialog = true + this.$nextTick(() => { + this.$refs.helpURL.focus() + }) + }, + + configureFieldsInit() { + this.$emit('configure-fields-help') + this.$buefy.toast.open({ + message: "Please see the gear icon next to configurable fields", + type: 'is-info', + duration: 4000, // 4 seconds + }) + }, + + configureSave() { + this.configureSaving = true + let url = '${url('{}.edit_help'.format(route_prefix))}' + let params = { + help_url: this.helpURL, + markdown_text: this.markdownText, + } + this.submitForm(url, params, response => { + this.configureShowDialog = false + this.$buefy.toast.open({ + message: "Info was saved; please refresh page to see changes.", + type: 'is-info', + duration: 4000, // 4 seconds + }) + this.configureSaving = false + }, response => { + this.configureSaving = false + }) + }, + % endif + }, + } + + let PageHelpData = { + displayShowDialog: false, + + % if can_edit_help: + configureShowDialog: false, + configureSaving: false, + helpURL: ${json.dumps(help_url or None)|n}, + markdownText: ${json.dumps(help_markdown or None)|n}, + % endif + } + + </script> +</%def> + +<%def name="make_component()"> + <script type="text/javascript"> + + PageHelp.data = function() { return PageHelpData } + + Vue.component('page-help', PageHelp) + <% request.register_component('page-help', 'PageHelp') %> + + </script> +</%def> diff --git a/tailbone/templates/people/configure.mako b/tailbone/templates/people/configure.mako new file mode 100644 index 00000000..257432dc --- /dev/null +++ b/tailbone/templates/people/configure.mako @@ -0,0 +1,61 @@ +## -*- coding: utf-8; -*- +<%inherit file="/configure.mako" /> + +<%def name="form_content()"> + + <h3 class="block is-size-3">General</h3> + <div class="block" style="padding-left: 2rem; width: 50%;"> + + <b-field message="If set, grid links are to Personal tab of Profile view."> + <b-checkbox name="rattail.people.straight_to_profile" + v-model="simpleSettings['rattail.people.straight_to_profile']" + native-value="true" + @input="settingsNeedSaved = true"> + Link directly to Profile when applicable + </b-checkbox> + </b-field> + + <b-field message="Allows quick profile lookup using e.g. customer number."> + <b-checkbox name="rattail.people.expose_quickie_search" + v-model="simpleSettings['rattail.people.expose_quickie_search']" + native-value="true" + @input="settingsNeedSaved = true"> + Show "quickie search" lookup + </b-checkbox> + </b-field> + + <b-field label="People Handler" + message="Leave blank for default handler."> + <b-input name="rattail.people.handler" + v-model="simpleSettings['rattail.people.handler']" + @input="settingsNeedSaved = true" + expanded /> + </b-field> + + </div> + + <h3 class="block is-size-3">Profile View</h3> + <div class="block" style="padding-left: 2rem; width: 50%;"> + + <b-field> + <b-checkbox name="tailbone.people.profile.expose_members" + v-model="simpleSettings['tailbone.people.profile.expose_members']" + native-value="true" + @input="settingsNeedSaved = true"> + Show tab for Member Accounts + </b-checkbox> + </b-field> + <b-field> + <b-checkbox name="tailbone.people.profile.expose_transactions" + v-model="simpleSettings['tailbone.people.profile.expose_transactions']" + native-value="true" + @input="settingsNeedSaved = true"> + Show tab for Customer POS Transactions + </b-checkbox> + </b-field> + + </div> +</%def> + + +${parent.body()} diff --git a/tailbone/templates/people/index.mako b/tailbone/templates/people/index.mako index 377063b8..cd6fddf1 100644 --- a/tailbone/templates/people/index.mako +++ b/tailbone/templates/people/index.mako @@ -3,8 +3,7 @@ <%def name="grid_tools()"> - % if master.mergeable and master.has_perm('request_merge'): - % if use_buefy: + % if getattr(master, 'mergeable', False) and master.has_perm('request_merge'): <b-button @click="showMergeRequest()" icon-pack="fas" icon-left="object-ungroup" @@ -22,20 +21,21 @@ <section class="modal-card-body"> <b-table :data="mergeRequestRows" striped hoverable> - <template slot-scope="props"> - <b-table-column field="customer_number" - label="Customer #"> - <span v-html="props.row.customer_number"></span> - </b-table-column> - <b-table-column field="first_name" - label="First Name"> - <span v-html="props.row.first_name"></span> - </b-table-column> - <b-table-column field="last_name" - label="Last Name"> - <span v-html="props.row.last_name"></span> - </b-table-column> - </template> + <b-table-column field="customer_number" + label="Customer #" + v-slot="props"> + <span v-html="props.row.customer_number"></span> + </b-table-column> + <b-table-column field="first_name" + label="First Name" + v-slot="props"> + <span v-html="props.row.first_name"></span> + </b-table-column> + <b-table-column field="last_name" + label="Last Name" + v-slot="props"> + <span v-html="props.row.last_name"></span> + </b-table-column> </b-table> </section> @@ -56,43 +56,42 @@ </footer> </div> </b-modal> - % endif % endif ${parent.grid_tools()} </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> - % if master.mergeable and master.has_perm('request_merge'): + % if getattr(master, 'mergeable', False) and master.has_perm('request_merge'): - ${grid.component_studly}Data.mergeRequestShowDialog = false - ${grid.component_studly}Data.mergeRequestRows = [] - ${grid.component_studly}Data.mergeRequestSubmitText = "Submit Merge Request" - ${grid.component_studly}Data.mergeRequestSubmitting = false + ${grid.vue_component}Data.mergeRequestShowDialog = false + ${grid.vue_component}Data.mergeRequestRows = [] + ${grid.vue_component}Data.mergeRequestSubmitText = "Submit Merge Request" + ${grid.vue_component}Data.mergeRequestSubmitting = false - ${grid.component_studly}.computed.mergeRequestRemovingUUID = function() { + ${grid.vue_component}.computed.mergeRequestRemovingUUID = function() { if (this.mergeRequestRows.length) { return this.mergeRequestRows[0].uuid } return null } - ${grid.component_studly}.computed.mergeRequestKeepingUUID = function() { + ${grid.vue_component}.computed.mergeRequestKeepingUUID = function() { if (this.mergeRequestRows.length) { return this.mergeRequestRows[1].uuid } return null } - ${grid.component_studly}.methods.showMergeRequest = function() { + ${grid.vue_component}.methods.showMergeRequest = function() { this.mergeRequestRows = this.checkedRows this.mergeRequestShowDialog = true } - ${grid.component_studly}.methods.submitMergeRequest = function() { + ${grid.vue_component}.methods.submitMergeRequest = function() { this.mergeRequestSubmitting = true this.mergeRequestSubmitText = "Working, please wait..." } @@ -101,5 +100,3 @@ </script> </%def> - -${parent.body()} diff --git a/tailbone/templates/people/merge-requests/view.mako b/tailbone/templates/people/merge-requests/view.mako index 5dcbea03..e2db1476 100644 --- a/tailbone/templates/people/merge-requests/view.mako +++ b/tailbone/templates/people/merge-requests/view.mako @@ -4,7 +4,6 @@ <%def name="page_content()"> ${parent.page_content()} % if not instance.merged and request.has_perm('people.merge'): - % if use_buefy: ${h.form(url('people.merge'), **{'@submit': 'submitMergeForm'})} ${h.csrf_token(request)} ${h.hidden('uuids', value=','.join([instance.removing_uuid, instance.keeping_uuid]))} @@ -16,14 +15,13 @@ {{ mergeFormButtonText }} </b-button> ${h.end_form()} - % endif % endif </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} % if not instance.merged and request.has_perm('people.merge'): - <script type="text/javascript"> + <script> ThisPageData.mergeFormButtonText = "Perform Merge" ThisPageData.mergeFormSubmitting = false @@ -36,5 +34,3 @@ </script> % endif </%def> - -${parent.body()} diff --git a/tailbone/templates/people/view.mako b/tailbone/templates/people/view.mako index 50804392..15c669fa 100644 --- a/tailbone/templates/people/view.mako +++ b/tailbone/templates/people/view.mako @@ -2,22 +2,13 @@ <%inherit file="/master/view.mako" /> <%namespace file="/util.mako" import="view_profiles_helper" /> -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - % if not use_buefy and not instance.users and request.has_perm('users.create'): - <script type="text/javascript"> - ## TODO: should do this differently for Buefy themes - $(function() { - $('#make-user').click(function() { - if (confirm("Really make a user account for this person?")) { - % if not use_buefy: - disable_button(this); - % endif - $('form[name="make-user-form"]').submit(); - } - }); - }); - </script> +<%def name="page_content()"> + ${parent.page_content()} + % if not instance.users and request.has_perm('users.create'): + ${h.form(url('people.make_user'), ref='makeUserForm')} + ${h.csrf_token(request)} + ${h.hidden('person_uuid', value=instance.uuid)} + ${h.end_form()} % endif </%def> @@ -26,17 +17,17 @@ ${view_profiles_helper([instance])} </%def> -<%def name="render_buefy_form()"> +<%def name="render_form()"> <div class="form"> - <tailbone-form v-on:make-user="makeUser"></tailbone-form> + <${form.vue_tagname} v-on:make-user="makeUser"></${form.vue_tagname}> </div> </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> - TailboneForm.methods.clickMakeUser = function(event) { + ${form.vue_component}.methods.clickMakeUser = function(event) { this.$emit('make-user') } @@ -48,21 +39,3 @@ </script> </%def> - -<%def name="page_content()"> - ${parent.page_content()} - % if not instance.users and request.has_perm('users.create'): - % if use_buefy: - ${h.form(url('people.make_user'), ref='makeUserForm')} - % else: - ${h.form(url('people.make_user'), name='make-user-form')} - % endif - ${h.csrf_token(request)} - ${h.hidden('person_uuid', value=instance.uuid)} - ${h.end_form()} - % endif -</%def> - - -${parent.body()} - diff --git a/tailbone/templates/people/view_profile.mako b/tailbone/templates/people/view_profile.mako index 07448b73..6ca5a84c 100644 --- a/tailbone/templates/people/view_profile.mako +++ b/tailbone/templates/people/view_profile.mako @@ -1,402 +1,3256 @@ ## -*- coding: utf-8; -*- <%inherit file="/master/view.mako" /> -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - <script type="text/javascript"> - - ## NOTE: we must delay activation of accordions, otherwise they do not - ## seem to "resize" correctly - var customer_accordion_activated = false; - var user_accordion_activated = false; - - $(function() { - $('#profile-tabs').tabs({ - activate: function(event, ui) { - ## activate accordion, first time tab is activated - if (ui.newPanel.selector == '#customer-tab') { - if (! customer_accordion_activated) { - $('#customers-accordion').accordion(); - customer_accordion_activated = true; - } - } else if (ui.newPanel.selector == '#user-tab') { - if (! user_accordion_activated) { - $('#users-accordion').accordion(); - user_accordion_activated = true; - } - } - } - }); - }); - </script> +<%def name="extra_styles()"> + ${parent.extra_styles()} + <style type="text/css"> + .card.personal { + margin-bottom: 1rem; + } + .field.is-horizontal .field-label .label { + white-space: nowrap; + min-width: 10rem; + } + </style> </%def> -<div id="profile-tabs"> - <ul> - <li><a href="#personal-tab">Personal</a></li> - <li><a href="#customer-tab">Customer</a></li> - <li><a href="#employee-tab">Employee</a></li> - <li><a href="#user-tab">User</a></li> - </ul> +<%def name="content_title()"> + ${dynamic_content_title or str(instance)} +</%def> - <div id="personal-tab"> +<%def name="render_instance_header_title_extras()"> + % if request.has_perm('people_profile.view_versions'): + <div class="level-item" style="margin-left: 2rem;"> + <b-button v-if="!viewingHistory" + icon-pack="fas" + icon-left="history" + @click="viewHistory()"> + View History + </b-button> + <div v-if="viewingHistory" + class="buttons"> + <b-button icon-pack="fas" + icon-left="user" + @click="viewingHistory = false"> + View Profile + </b-button> + <b-button icon-pack="fas" + icon-left="redo" + @click="refreshHistory()" + :disabled="gettingRevisions"> + {{ gettingRevisions ? "Working, please wait..." : "Refresh History" }} + </b-button> + </div> + </div> + % endif +</%def> +<%def name="page_content()"> + <profile-info @change-content-title="changeContentTitle" + % if request.has_perm('people_profile.view_versions'): + :viewing-history="viewingHistory" + :getting-revisions="gettingRevisions" + :revisions="revisions" + :revision-version-map="revisionVersionMap" + % endif + > + </profile-info> +</%def> + +<%def name="render_this_page_component()"> + ## TODO: should override this in a cleaner way! too much duplicate code w/ parent template + <this-page @change-content-title="changeContentTitle" + % if can_edit_help: + :configure-fields-help="configureFieldsHelp" + % endif + % if request.has_perm('people_profile.view_versions'): + :viewing-history="viewingHistory" + :getting-revisions="gettingRevisions" + :revisions="revisions" + :revision-version-map="revisionVersionMap" + % endif + > + </this-page> +</%def> + +<%def name="render_this_page()"> + ${self.page_content()} +</%def> + +<%def name="render_personal_name_card()"> + <div class="card personal" + ## nb. hack to force refresh for vue3 + :key="refreshPersonalCard"> + <header class="card-header"> + <p class="card-header-title">Name</p> + </header> + <div class="card-content"> + <div class="content"> + <div style="display: flex; justify-content: space-between;"> + <div style="flex-grow: 1; margin-right: 1rem;"> + + <b-field horizontal label="First Name"> + <span>{{ person.first_name }}</span> + </b-field> + + % if use_preferred_first_name: + <b-field horizontal label="Preferred First Name"> + <span>{{ person.preferred_first_name }}</span> + </b-field> + % endif + + <b-field horizontal label="Middle Name"> + <span>{{ person.middle_name }}</span> + </b-field> + + <b-field horizontal label="Last Name"> + <span>{{ person.last_name }}</span> + </b-field> + + </div> + % if request.has_perm('people_profile.edit_person'): + <div v-if="editNameAllowed()"> + <b-button type="is-primary" + @click="editNameInit()" + icon-pack="fas" + icon-left="edit"> + Edit Name + </b-button> + </div> + <${b}-modal has-modal-card + % if request.use_oruga: + v-model:active="editNameShowDialog" + % else: + :active.sync="editNameShowDialog" + % endif + > + <div class="modal-card"> + + <header class="modal-card-head"> + <p class="modal-card-title">Edit Name</p> + </header> + + <section class="modal-card-body"> + + % if use_preferred_first_name: + <b-field grouped> + <b-field label="First Name"> + <b-input v-model.trim="editNameFirst" + :maxlength="maxLengths.person_first_name || null" /> + </b-field> + <b-field label="Preferred First Name" expanded> + <b-input v-model.trim="editNameFirstPreferred" + :maxlength="maxLengths.person_preferred_first_name || null"> + </b-input> + </b-field> + </b-field> + % else: + <b-field label="First Name"> + <b-input v-model.trim="editNameFirst" + :maxlength="maxLengths.person_first_name || null" + expanded /> + </b-field> + % endif + + <b-field label="Middle Name"> + <b-input v-model.trim="editNameMiddle" + :maxlength="maxLengths.person_middle_name || null" + expanded /> + </b-field> + <b-field label="Last Name"> + <b-input v-model.trim="editNameLast" + :maxlength="maxLengths.person_last_name || null" + expanded /> + </b-field> + </section> + + <footer class="modal-card-foot"> + <b-button type="is-primary" + @click="editNameSave()" + :disabled="editNameSaveDisabled" + icon-pack="fas" + icon-left="save"> + {{ editNameSaving ? "Working, please wait..." : "Save" }} + </b-button> + <b-button @click="editNameShowDialog = false"> + Cancel + </b-button> + </footer> + </div> + </${b}-modal> + % endif + </div> + </div> + </div> + </div> +</%def> + +<%def name="render_personal_address_card()"> + <div class="card personal" + ## nb. hack to force refresh for vue3 + :key="refreshAddressCard"> + <header class="card-header"> + <p class="card-header-title">Address</p> + </header> + <div class="card-content"> + <div class="content"> + <div style="display: flex; justify-content: space-between;"> + <div style="flex-grow: 1; margin-right: 1rem;"> + + <b-field horizontal label="Street 1"> + <span>{{ person.address ? person.address.street : null }}</span> + </b-field> + + <b-field horizontal label="Street 2"> + <span>{{ person.address ? person.address.street2 : null }}</span> + </b-field> + + <b-field horizontal label="City"> + <span>{{ person.address ? person.address.city : null }}</span> + </b-field> + + <b-field horizontal label="State"> + <span>{{ person.address ? person.address.state : null }}</span> + </b-field> + + <b-field horizontal label="Zipcode"> + <span>{{ person.address ? person.address.zipcode : null }}</span> + </b-field> + + <b-field v-if="person.address && person.address.invalid" + horizontal label="Invalid" + class="has-text-danger"> + <span>Yes</span> + </b-field> + + </div> + % if request.has_perm('people_profile.edit_person'): + <b-button type="is-primary" + @click="editAddressInit()" + icon-pack="fas" + icon-left="edit"> + Edit Address + </b-button> + <${b}-modal has-modal-card + % if request.use_oruga: + v-model:active="editAddressShowDialog" + % else: + :active.sync="editAddressShowDialog" + % endif + > + <div class="modal-card"> + + <header class="modal-card-head"> + <p class="modal-card-title">Edit Address</p> + </header> + + <section class="modal-card-body"> + + <b-field label="Street 1" expanded> + <b-input v-model.trim="editAddressStreet1" + :maxlength="maxLengths.address_street || null" + expanded /> + </b-field> + + <b-field label="Street 2" expanded> + <b-input v-model.trim="editAddressStreet2" + :maxlength="maxLengths.address_street2 || null" + expanded /> + </b-field> + + <b-field label="Zipcode"> + <b-input v-model.trim="editAddressZipcode" + :maxlength="maxLengths.address_zipcode || null" + expanded /> + </b-field> + + <b-field grouped> + <b-field label="City"> + <b-input v-model.trim="editAddressCity" + :maxlength="maxLengths.address_city || null"> + </b-input> + </b-field> + <b-field label="State"> + <b-input v-model.trim="editAddressState" + :maxlength="maxLengths.address_state || null"> + </b-input> + </b-field> + </b-field> + + <b-field label="Invalid"> + <b-checkbox v-model="editAddressInvalid" + type="is-danger"> + </b-checkbox> + </b-field> + + </section> + + <footer class="modal-card-foot"> + <once-button type="is-primary" + @click="editAddressSave()" + :disabled="editAddressSaveDisabled" + icon-left="save" + text="Save"> + </once-button> + <b-button @click="editAddressShowDialog = false"> + Cancel + </b-button> + </footer> + </div> + </${b}-modal> + % endif + </div> + </div> + </div> + </div> +</%def> + +<%def name="render_personal_phone_card()"> + <div class="card personal"> + <header class="card-header"> + <p class="card-header-title">Phone(s)</p> + </header> + <div class="card-content"> + <div class="content"> + + <b-notification v-if="person.invalid_phone_number" + type="is-warning" + has-icon icon-pack="fas" + :closable="false"> + We appear to have an invalid phone number on file for this person. + </b-notification> + + % if request.has_perm('people_profile.edit_person'): + <div class="has-text-right"> + <b-button type="is-primary" + icon-pack="fas" + icon-left="plus" + @click="addPhoneInit()"> + Add Phone + </b-button> + </div> + <${b}-modal has-modal-card + % if request.use_oruga: + v-model:active="editPhoneShowDialog" + % else: + :active.sync="editPhoneShowDialog" + % endif + > + <div class="modal-card"> + + <header class="modal-card-head"> + <p class="modal-card-title"> + {{ editPhoneUUID ? "Edit" : "Add" }} Phone + </p> + </header> + + <section class="modal-card-body"> + + <b-field label="Type"> + <b-select v-model="editPhoneType"> + <option v-for="option in phoneTypeOptions" + :key="option.value" + :value="option.value"> + {{ option.label }} + </option> + </b-select> + </b-field> + + <b-field label="Number"> + <b-input v-model.trim="editPhoneNumber" + ref="editPhoneInput" + expanded /> + </b-field> + + <b-field label="Preferred?"> + <b-checkbox v-model="editPhonePreferred"> + </b-checkbox> + </b-field> + + </section> + + <footer class="modal-card-foot"> + <b-button type="is-primary" + @click="editPhoneSave()" + :disabled="editPhoneSaveDisabled" + icon-pack="fas" + icon-left="save"> + {{ editPhoneSaving ? "Working..." : "Save" }} + </b-button> + <b-button @click="editPhoneShowDialog = false"> + Cancel + </b-button> + </footer> + </div> + </${b}-modal> + % endif + + <${b}-table :data="person.phones"> + + <${b}-table-column field="preference" + label="Preferred" + v-slot="props"> + {{ props.row.preferred ? "Yes" : "" }} + </${b}-table-column> + + <${b}-table-column field="type" + label="Type" + v-slot="props"> + {{ props.row.type }} + </${b}-table-column> + + <${b}-table-column field="number" + label="Number" + v-slot="props"> + {{ props.row.number }} + </${b}-table-column> + + % if request.has_perm('people_profile.edit_person'): + <${b}-table-column label="Actions" + v-slot="props"> + <a href="#" @click.prevent="editPhoneInit(props.row)" + % if not request.use_oruga: + class="grid-action" + % endif + > + % if request.use_oruga: + <span class="icon-text"> + <o-icon icon="edit" /> + <span>Edit</span> + </span> + % else: + <i class="fas fa-edit"></i> + Edit + % endif + </a> + <a href="#" @click.prevent="deletePhoneInit(props.row)" + % if request.use_oruga: + class="has-text-danger" + % else: + class="grid-action has-text-danger" + % endif + > + % if request.use_oruga: + <span class="icon-text"> + <o-icon icon="trash" /> + <span>Delete</span> + </span> + % else: + <i class="fas fa-trash"></i> + Delete + % endif + </a> + <a v-if="!props.row.preferred" + href="#" @click.prevent="preferPhoneInit(props.row)" + % if not request.use_oruga: + class="grid-action" + % endif + > + % if request.use_oruga: + <span class="icon-text"> + <o-icon icon="star" /> + <span>Set Preferred</span> + </span> + % else: + <i class="fas fa-star"></i> + Set Preferred + % endif + </a> + </${b}-table-column> + % endif + + </${b}-table> + + % if request.has_perm('people_profile.edit_person'): + + <${b}-modal has-modal-card + % if request.use_oruga: + v-model:active="deletePhoneShowDialog" + % else: + :active.sync="deletePhoneShowDialog" + % endif + > + <div class="modal-card"> + + <header class="modal-card-head"> + <p class="modal-card-title">Delete Phone</p> + </header> + + <section class="modal-card-body"> + <p class="block">Really delete this phone number?</p> + <p class="block has-text-weight-bold">{{ deletePhoneNumber }}</p> + </section> + + <footer class="modal-card-foot"> + <b-button type="is-danger" + @click="deletePhoneSave()" + :disabled="deletePhoneSaving" + icon-pack="fas" + icon-left="trash"> + {{ deletePhoneSaving ? "Working, please wait..." : "Delete" }} + </b-button> + <b-button @click="deletePhoneShowDialog = false"> + Cancel + </b-button> + </footer> + </div> + </${b}-modal> + + <${b}-modal has-modal-card + % if request.use_oruga: + v-model:active="preferPhoneShowDialog" + % else: + :active.sync="preferPhoneShowDialog" + % endif + > + <div class="modal-card"> + + <header class="modal-card-head"> + <p class="modal-card-title">Set Preferred Phone</p> + </header> + + <section class="modal-card-body"> + <p class="block">Really make this the preferred phone number?</p> + <p class="block has-text-weight-bold">{{ preferPhoneNumber }}</p> + </section> + + <footer class="modal-card-foot"> + <b-button type="is-primary" + @click="preferPhoneSave()" + :disabled="preferPhoneSaving" + icon-pack="fas" + icon-left="save"> + {{ preferPhoneSaving ? "Working, please wait..." : "Set Preferred" }} + </b-button> + <b-button @click="preferPhoneShowDialog = false"> + Cancel + </b-button> + </footer> + </div> + </${b}-modal> + + % endif + </div> + </div> + </div> +</%def> + +<%def name="render_personal_email_card()"> + <div class="card personal"> + <header class="card-header"> + <p class="card-header-title">Email(s)</p> + </header> + <div class="card-content"> + <div class="content"> + + % if request.has_perm('people_profile.edit_person'): + <div class="has-text-right"> + <b-button type="is-primary" + icon-pack="fas" + icon-left="plus" + @click="addEmailInit()"> + Add Email + </b-button> + </div> + <${b}-modal has-modal-card + % if request.use_oruga: + v-model:active="editEmailShowDialog" + % else: + :active.sync="editEmailShowDialog" + % endif + > + <div class="modal-card"> + + <header class="modal-card-head"> + <p class="modal-card-title"> + {{ editEmailUUID ? "Edit" : "Add" }} Email + </p> + </header> + + <section class="modal-card-body"> + + <b-field label="Type"> + <b-select v-model="editEmailType"> + <option v-for="option in emailTypeOptions" + :key="option.value" + :value="option.value"> + {{ option.label }} + </option> + </b-select> + </b-field> + + <b-field label="Address"> + <b-input v-model.trim="editEmailAddress" + ref="editEmailInput" + expanded /> + </b-field> + + <b-field v-if="!editEmailUUID" + label="Preferred?"> + <b-checkbox v-model="editEmailPreferred"> + </b-checkbox> + </b-field> + + <b-field v-if="editEmailUUID" + label="Invalid?"> + <b-checkbox v-model="editEmailInvalid" + :type="editEmailInvalid ? 'is-danger': null"> + </b-checkbox> + </b-field> + + </section> + + <footer class="modal-card-foot"> + <b-button type="is-primary" + @click="editEmailSave()" + :disabled="editEmailSaveDisabled" + icon-pack="fas" + icon-left="save"> + {{ editEmailSaving ? "Working, please wait..." : "Save" }} + </b-button> + <b-button @click="editEmailShowDialog = false"> + Cancel + </b-button> + </footer> + </div> + </${b}-modal> + % endif + + <${b}-table :data="person.emails"> + + <${b}-table-column field="preference" + label="Preferred" + v-slot="props"> + {{ props.row.preferred ? "Yes" : "" }} + </${b}-table-column> + + <${b}-table-column field="type" + label="Type" + v-slot="props"> + {{ props.row.type }} + </${b}-table-column> + + <${b}-table-column field="address" + label="Address" + v-slot="props"> + {{ props.row.address }} + </${b}-table-column> + + <${b}-table-column field="invalid" + label="Invalid?" + v-slot="props"> + <span v-if="props.row.invalid" class="has-text-danger has-text-weight-bold">Invalid</span> + </${b}-table-column> + + % if request.has_perm('people_profile.edit_person'): + <${b}-table-column label="Actions" + v-slot="props"> + <a href="#" @click.prevent="editEmailInit(props.row)" + % if not request.use_oruga: + class="grid-action" + % endif + > + % if request.use_oruga: + <span class="icon-text"> + <o-icon icon="edit" /> + <span>Edit</span> + </span> + % else: + <i class="fas fa-edit"></i> + Edit + % endif + </a> + <a href="#" @click.prevent="deleteEmailInit(props.row)" + % if request.use_oruga: + class="has-text-danger" + % else: + class="grid-action has-text-danger" + % endif + > + % if request.use_oruga: + <span class="icon-text"> + <o-icon icon="trash" /> + <span>Delete</span> + </span> + % else: + <i class="fas fa-trash"></i> + Delete + % endif + </a> + <a v-if="!props.row.preferred" + % if not request.use_oruga: + class="grid-action" + % endif + href="#" @click.prevent="preferEmailInit(props.row)"> + % if request.use_oruga: + <span class="icon-text"> + <o-icon icon="star" /> + <span>Set Preferred</span> + </span> + % else: + <i class="fas fa-star"></i> + Set Preferred + % endif + </a> + </${b}-table-column> + % endif + + </${b}-table> + + % if request.has_perm('people_profile.edit_person'): + + <${b}-modal has-modal-card + % if request.use_oruga: + v-model:active="deleteEmailShowDialog" + % else: + :active.sync="deleteEmailShowDialog" + % endif + > + <div class="modal-card"> + + <header class="modal-card-head"> + <p class="modal-card-title">Delete Email</p> + </header> + + <section class="modal-card-body"> + <p class="block">Really delete this email address?</p> + <p class="block has-text-weight-bold">{{ deleteEmailAddress }}</p> + </section> + + <footer class="modal-card-foot"> + <b-button type="is-danger" + @click="deleteEmailSave()" + :disabled="deleteEmailSaving" + icon-pack="fas" + icon-left="trash"> + {{ deleteEmailSaving ? "Working, please wait..." : "Delete" }} + </b-button> + <b-button @click="deleteEmailShowDialog = false"> + Cancel + </b-button> + </footer> + </div> + </${b}-modal> + + <${b}-modal has-modal-card + % if request.use_oruga: + v-model:active="preferEmailShowDialog" + % else: + :active.sync="preferEmailShowDialog" + % endif + > + <div class="modal-card"> + + <header class="modal-card-head"> + <p class="modal-card-title">Set Preferred Email</p> + </header> + + <section class="modal-card-body"> + <p class="block">Really make this the preferred email address?</p> + <p class="block has-text-weight-bold">{{ preferEmailAddress }}</p> + </section> + + <footer class="modal-card-foot"> + <b-button type="is-primary" + @click="preferEmailSave()" + :disabled="preferEmailSaving" + icon-pack="fas" + icon-left="save"> + {{ preferEmailSaving ? "Working, please wait..." : "Set Preferred" }} + </b-button> + <b-button @click="preferEmailShowDialog = false"> + Cancel + </b-button> + </footer> + </div> + </${b}-modal> + + % endif + </div> + </div> + </div> +</%def> + +<%def name="render_personal_tab_cards()"> + ${self.render_personal_name_card()} + ${self.render_personal_address_card()} + ${self.render_personal_phone_card()} + ${self.render_personal_email_card()} +</%def> + +<%def name="render_personal_tab_template()"> + <script type="text/x-template" id="personal-tab-template"> <div style="display: flex; justify-content: space-between;"> - <div> - - <div class="field-wrapper first_name"> - <div class="field-row"> - <label>First Name</label> - <div class="field"> - ${person.first_name} - </div> - </div> - </div> - - <div class="field-wrapper middle_name"> - <div class="field-row"> - <label>Middle Name</label> - <div class="field"> - ${person.middle_name} - </div> - </div> - </div> - - <div class="field-wrapper last_name"> - <div class="field-row"> - <label>Last Name</label> - <div class="field"> - ${person.last_name} - </div> - </div> - </div> - - <div class="field-wrapper street"> - <div class="field-row"> - <label>Street 1</label> - <div class="field"> - ${person.address.street if person.address else ''} - </div> - </div> - </div> - - <div class="field-wrapper street2"> - <div class="field-row"> - <label>Street 2</label> - <div class="field"> - ${person.address.street2 if person.address else ''} - </div> - </div> - </div> - - <div class="field-wrapper city"> - <div class="field-row"> - <label>City</label> - <div class="field"> - ${person.address.city if person.address else ''} - </div> - </div> - </div> - - <div class="field-wrapper state"> - <div class="field-row"> - <label>State</label> - <div class="field"> - ${person.address.state if person.address else ''} - </div> - </div> - </div> - - <div class="field-wrapper zipcode"> - <div class="field-row"> - <label>Zipcode</label> - <div class="field"> - ${person.address.zipcode if person.address else ''} - </div> - </div> - </div> - - % if person.phones: - % for phone in person.phones: - <div class="field-wrapper"> - <div class="field-row"> - <label>Phone Number</label> - <div class="field"> - ${phone.number} (type: ${phone.type}) - </div> - </div> - </div> - % endfor - % else: - <div class="field-wrapper"> - <div class="field-row"> - <label>Phone Number</label> - <div class="field"> - (none on file) - </div> - </div> - </div> - % endif - - % if person.emails: - % for email in person.emails: - <div class="field-wrapper"> - <div class="field-row"> - <label>Email Address</label> - <div class="field"> - ${email.address} (type: ${email.type}) - </div> - </div> - </div> - % endfor - % else: - <div class="field-wrapper"> - <div class="field-row"> - <label>Email Address</label> - <div class="field"> - (none on file) - </div> - </div> - </div> - % endif - + <div style="flex-grow: 1; margin-right: 1rem;"> + ${self.render_personal_tab_cards()} </div> <div> % if request.has_perm('people.view'): - ${h.link_to("View Person", url('people.view', uuid=person.uuid), class_='button')} + <b-button tag="a" :href="person.view_url"> + View Person + </b-button> + % endif + </div> + % if request.use_oruga: + <o-loading v-model:active="refreshingTab" :full-page="false"></o-loading> + % else: + <b-loading :active.sync="refreshingTab" :is-full-page="false"></b-loading> + % endif + </div> + </script> +</%def> + +<%def name="render_personal_tab()"> + <${b}-tab-item label="Personal" + value="personal" + % if not request.use_oruga: + icon-pack="fas" + % endif + :icon="tabchecks.personal ? 'check' : null"> + <personal-tab ref="tab_personal" + :person="person" + @profile-changed="profileChanged" + :phone-type-options="phoneTypeOptions" + :email-type-options="emailTypeOptions" + :max-lengths="maxLengths"> + </personal-tab> + </${b}-tab-item> +</%def> + +% if expose_members: +<%def name="render_member_tab_template()"> + <script type="text/x-template" id="member-tab-template"> + <div> + % if max_one_member: + <p class="block"> + TODO: UI not yet implemented for "max one member per person" + </p + + % else: + ## nb. multiple members allowed per person + <div v-if="members.length"> + + <div style="display: flex; justify-content: space-between;"> + <p>{{ person.display_name }} has <strong>{{ members.length }}</strong> member account{{ members.length == 1 ? '' : 's' }}</p> + </div> + + <br /> + <${b}-collapse v-for="member in members" + :key="member.uuid" + class="panel" + :open="members.length == 1"> + + <template #trigger="props"> + <div class="panel-heading" + role="button" + style="cursor: pointer;"> + + ## TODO: for some reason buefy will "reuse" the icon + ## element in such a way that its display does not + ## refresh. so to work around that, we use different + ## structure for the two icons, so buefy is forced to + ## re-draw + + <b-icon v-if="props.open" + pack="fas" + icon="caret-down" /> + + <span v-if="!props.open"> + <b-icon pack="fas" + icon="caret-right" /> + </span> + + + <strong>{{ member._key }} - {{ member.display }}</strong> + </div> + </template> + + <div class="panel-block"> + <div style="display: flex; justify-content: space-between; width: 100%;"> + <div style="flex-grow: 1;"> + + <b-field horizontal label="${member_key_label}"> + {{ member._key }} + </b-field> + + <b-field horizontal label="Account Holder"> + <a v-if="member.person_uuid != person.uuid" + :href="member.view_profile_url"> + {{ member.person_display_name }} + </a> + <span v-if="member.person_uuid == person.uuid"> + {{ member.person_display_name }} + </span> + </b-field> + + <b-field horizontal label="Membership Type"> + <a v-if="member.view_membership_type_url" + :href="member.view_membership_type_url"> + {{ member.membership_type_name }} + </a> + <span v-if="!member.view_membership_type_url"> + {{ member.membership_type_name }} + </span> + </b-field> + + <b-field horizontal label="Active"> + {{ member.active ? "Yes" : "No" }} + </b-field> + + <b-field horizontal label="Joined"> + {{ member.joined }} + </b-field> + + <b-field horizontal label="Withdrew" + v-if="member.withdrew"> + {{ member.withdrew }} + </b-field> + + <b-field horizontal label="Equity Total"> + {{ member.equity_total_display }} + </b-field> + + </div> + <div class="buttons" style="align-items: start;"> + + <b-button v-for="link in member.external_links" + :key="link.url" + type="is-primary" + tag="a" :href="link.url" target="_blank" + icon-pack="fas" + icon-left="external-link-alt"> + {{ link.label }} + </b-button> + + % if request.has_perm('members.view'): + <b-button tag="a" :href="member.view_url"> + View Member + </b-button> + % endif + + </div> + </div> + </div> + </${b}-collapse> + </div> + + <div v-if="!members.length"> + <p>{{ person.display_name }} does not have a member account.</p> + </div> + % endif + + % if request.use_oruga: + <o-loading v-model:active="refreshingTab" :full-page="false"></o-loading> + % else: + <b-loading :active.sync="refreshingTab" :is-full-page="false"></b-loading> + % endif + </div> + </script> +</%def> + +<%def name="render_member_tab()"> + <${b}-tab-item label="Member" + value="member" + icon-pack="fas" + :icon="tabchecks.member ? 'check' : null"> + <member-tab ref="tab_member" + :person="person" + @profile-changed="profileChanged" + :phone-type-options="phoneTypeOptions"> + </member-tab> + </${b}-tab-item> +</%def> +% endif + +<%def name="render_customer_tab_template()"> + <script type="text/x-template" id="customer-tab-template"> + <div> + <div v-if="customers.length"> + + <div style="display: flex; justify-content: space-between;"> + <p>{{ person.display_name }} has <strong>{{ customers.length }}</strong> customer account{{ customers.length == 1 ? '' : 's' }}</p> + </div> + + <br /> + <${b}-collapse v-for="customer in customers" + :key="customer.uuid" + class="panel" + :open="customers.length == 1"> + + <template #trigger="props"> + <div class="panel-heading" + role="button" + style="cursor: pointer;"> + + ## TODO: for some reason buefy will "reuse" the icon + ## element in such a way that its display does not + ## refresh. so to work around that, we use different + ## structure for the two icons, so buefy is forced to + ## re-draw + + <b-icon v-if="props.open" + pack="fas" + icon="caret-down" /> + + <span v-if="!props.open"> + <b-icon pack="fas" + icon="caret-right" /> + </span> + + + <strong>{{ customer._key }} - {{ customer.name }}</strong> + </div> + </template> + + <div class="panel-block"> + <div style="display: flex; justify-content: space-between; width: 100%;"> + <div style="flex-grow: 1;"> + + <b-field horizontal label="${customer_key_label or 'TODO: Customer Key'}"> + {{ customer._key }} + </b-field> + + <b-field horizontal label="Account Name"> + {{ customer.name }} + </b-field> + + % if expose_customer_shoppers: + <b-field horizontal label="Shoppers"> + <ul> + <li v-for="shopper in customer.shoppers" + :key="shopper.uuid"> + <a v-if="shopper.person_uuid != person.uuid" + :href="shopper.view_profile_url"> + {{ shopper.display_name }} + </a> + <span v-if="shopper.person_uuid == person.uuid"> + {{ shopper.display_name }} + </span> + </li> + </ul> + </b-field> + % endif + + % if expose_customer_people: + <b-field horizontal label="People"> + <ul> + <li v-for="p in customer.people" + :key="p.uuid"> + <a v-if="p.uuid != person.uuid" + :href="p.view_profile_url"> + {{ p.display_name }} + </a> + <span v-if="p.uuid == person.uuid"> + {{ p.display_name }} + </span> + </li> + </ul> + </b-field> + % endif + + <b-field horizontal label="Address" + v-for="address in customer.addresses" + :key="address.uuid"> + {{ address.display }} + </b-field> + + </div> + <div class="buttons" style="align-items: start;"> + + <b-button v-for="link in customer.external_links" + :key="link.url" + type="is-primary" + tag="a" :href="link.url" target="_blank" + icon-pack="fas" + icon-left="external-link-alt"> + {{ link.label }} + </b-button> + + % if request.has_perm('customers.view'): + <b-button tag="a" :href="customer.view_url"> + View Customer + </b-button> + % endif + + </div> + </div> + </div> + </${b}-collapse> + </div> + + <div v-if="!customers.length"> + <p>{{ person.display_name }} does not have a customer account.</p> + </div> + % if request.use_oruga: + <o-loading v-model:active="refreshingTab" :full-page="false"></o-loading> + % else: + <b-loading :active.sync="refreshingTab" :is-full-page="false"></b-loading> + % endif + </div> + </script> +</%def> + +<%def name="render_customer_tab()"> + <${b}-tab-item label="Customer" + value="customer" + icon-pack="fas" + :icon="tabchecks.customer ? 'check' : null"> + <customer-tab ref="tab_customer" + :person="person" + @profile-changed="profileChanged"> + </customer-tab> + </${b}-tab-item> +</%def> + +<%def name="render_shopper_tab_template()"> + <script type="text/x-template" id="shopper-tab-template"> + <div> + <div v-if="shoppers.length"> + + <div style="display: flex; justify-content: space-between;"> + <p>{{ person.display_name }} is shopper for <strong>{{ shoppers.length }}</strong> customer account{{ shoppers.length == 1 ? '' : 's' }}</p> + </div> + + <br /> + <b-collapse v-for="shopper in shoppers" + :key="shopper.uuid" + class="panel" + :open="shoppers.length == 1"> + + <div slot="trigger" + slot-scope="props" + class="panel-heading" + role="button"> + <b-icon pack="fas" + icon="caret-right"> + </b-icon> + <strong>{{ shopper.customer_key }} - {{ shopper.customer_name }}</strong> + </div> + + <div class="panel-block"> + <div style="display: flex; justify-content: space-between; width: 100%;"> + <div style="flex-grow: 1;"> + + <b-field horizontal label="${customer_key_label}"> + {{ shopper.customer_key }} + </b-field> + + <b-field horizontal label="Account Name"> + {{ shopper.customer_name }} + </b-field> + + <b-field horizontal label="Account Holder"> + <span v-if="!shopper.account_holder_view_profile_url"> + {{ shopper.account_holder_name }} + </span> + <a v-if="shopper.account_holder_view_profile_url" + :href="shopper.account_holder_view_profile_url"> + {{ shopper.account_holder_name }} + </a> + </b-field> + + </div> + ## <div class="buttons" style="align-items: start;"> + ## ${self.render_shopper_panel_buttons(shopper)} + ## </div> + </div> + </div> + </b-collapse> + </div> + + <div v-if="!shoppers.length"> + <p>{{ person.display_name }} is not a shopper.</p> + </div> + % if request.use_oruga: + <o-loading v-model:active="refreshingTab" :full-page="false"></o-loading> + % else: + <b-loading :active.sync="refreshingTab" :is-full-page="false"></b-loading> + % endif + </div> + </script> +</%def> + +<%def name="render_shopper_tab()"> + <${b}-tab-item label="Shopper" + value="shopper" + icon-pack="fas" + :icon="tabchecks.shopper ? 'check' : null"> + <shopper-tab ref="tab_shopper" + :person="person" + @profile-changed="profileChanged"> + </shopper-tab> + </${b}-tab-item> +</%def> + +<%def name="render_employee_tab_template()"> + <script type="text/x-template" id="employee-tab-template"> + <div> + <div style="display: flex; justify-content: space-between;"> + + <div style="flex-grow: 1;"> + + <div v-if="employee.uuid"> + + <div :key="refreshEmployeeCard"> + <b-field horizontal label="Employee ID"> + <div class="level"> + <div class="level-left"> + <div class="level-item"> + <span>{{ employee.id }}</span> + </div> + % if request.has_perm('employees.edit'): + <div class="level-item"> + <b-button type="is-primary" + icon-pack="fas" + icon-left="edit" + @click="editEmployeeIdInit()"> + Edit ID + </b-button> + <${b}-modal has-modal-card + % if request.use_oruga: + v-model:active="editEmployeeIdShowDialog" + % else: + :active.sync="editEmployeeIdShowDialog" + % endif + > + <div class="modal-card"> + + <header class="modal-card-head"> + <p class="modal-card-title">Employee ID</p> + </header> + + <section class="modal-card-body"> + <b-field label="Employee ID"> + <b-input v-model="editEmployeeIdValue"></b-input> + </b-field> + </section> + + <footer class="modal-card-foot"> + <b-button @click="editEmployeeIdShowDialog = false"> + Cancel + </b-button> + <b-button type="is-primary" + icon-pack="fas" + icon-left="save" + :disabled="editEmployeeIdSaving" + @click="editEmployeeIdSave()"> + {{ editEmployeeIdSaving ? "Working, please wait..." : "Save" }} + </b-button> + </footer> + </div> + </${b}-modal> + </div> + % endif + </div> + </div> + </b-field> + + <b-field horizontal label="Employee Status"> + <span>{{ employee.status_display }}</span> + </b-field> + + <b-field horizontal label="Start Date"> + <span>{{ employee.start_date }}</span> + </b-field> + + <b-field horizontal label="End Date"> + <span>{{ employee.end_date }}</span> + </b-field> + </div> + + <br /> + <p><strong>Employee History</strong></p> + <br /> + + <${b}-table :data="employeeHistory"> + + <${b}-table-column field="start_date" + label="Start Date" + v-slot="props"> + {{ props.row.start_date }} + </${b}-table-column> + + <${b}-table-column field="end_date" + label="End Date" + v-slot="props"> + {{ props.row.end_date }} + </${b}-table-column> + + % if request.has_perm('people_profile.edit_employee_history'): + <${b}-table-column field="actions" + label="Actions" + v-slot="props"> + <a href="#" @click.prevent="editEmployeeHistoryInit(props.row)"> + % if request.use_oruga: + <span class="icon-text"> + <o-icon icon="edit" /> + <span>Edit</span> + </span> + % else: + <i class="fas fa-edit"></i> + Edit + % endif + </a> + </${b}-table-column> + % endif + + </${b}-table> + + </div> + + <p v-if="!employee.uuid"> + ${person} is not an employee. + </p> + + </div> + + <div style="display: flex; gap: 0.75rem;"> + + % if request.has_perm('people_profile.toggle_employee'): + + <b-button v-if="!employee.current" + type="is-primary" + @click="startEmployeeInit()"> + ${person} is now an Employee + </b-button> + + <b-button v-if="employee.current" + type="is-primary" + @click="stopEmployeeInit()"> + ${person} is no longer an Employee + </b-button> + + <${b}-modal has-modal-card + % if request.use_oruga: + v-model:active="startEmployeeShowDialog" + % else: + :active.sync="startEmployeeShowDialog" + % endif + > + <div class="modal-card"> + + <header class="modal-card-head"> + <p class="modal-card-title">Employee Start</p> + </header> + + <section class="modal-card-body"> + <b-field label="Employee Number"> + <b-input v-model="startEmployeeID"></b-input> + </b-field> + <b-field label="Start Date"> + <tailbone-datepicker v-model="startEmployeeStartDate" + ref="startEmployeeStartDate" /> + </b-field> + </section> + + <footer class="modal-card-foot"> + <b-button @click="startEmployeeShowDialog = false"> + Cancel + </b-button> + <b-button type="is-primary" + @click="startEmployeeSave()" + :disabled="startEmployeeSaveDisabled" + icon-pack="fas" + icon-left="save"> + {{ startEmployeeSaving ? "Working, please wait..." : "Save" }} + </b-button> + </footer> + </div> + </${b}-modal> + + <${b}-modal has-modal-card + % if request.use_oruga: + v-model:active="stopEmployeeShowDialog" + % else: + :active.sync="stopEmployeeShowDialog" + % endif + > + <div class="modal-card"> + + <header class="modal-card-head"> + <p class="modal-card-title">Employee End</p> + </header> + + <section class="modal-card-body"> + <b-field label="End Date" + :type="stopEmployeeEndDate ? null : 'is-danger'"> + <tailbone-datepicker v-model="stopEmployeeEndDate"></tailbone-datepicker> + </b-field> + <b-field label="Revoke Internal App Access"> + <b-checkbox v-model="stopEmployeeRevokeAccess"> + </b-checkbox> + </b-field> + </section> + + <footer class="modal-card-foot"> + <b-button @click="stopEmployeeShowDialog = false"> + Cancel + </b-button> + <b-button type="is-primary" + @click="stopEmployeeSave()" + :disabled="stopEmployeeSaveDisabled" + icon-pack="fas" + icon-left="save"> + {{ stopEmployeeSaving ? "Working, please wait..." : "Save" }} + </b-button> + </footer> + </div> + </${b}-modal> + % endif + + % if request.has_perm('people_profile.edit_employee_history'): + <${b}-modal has-modal-card + % if request.use_oruga: + v-model:active="editEmployeeHistoryShowDialog" + % else: + :active.sync="editEmployeeHistoryShowDialog" + % endif + > + <div class="modal-card"> + + <header class="modal-card-head"> + <p class="modal-card-title">Edit Employee History</p> + </header> + + <section class="modal-card-body"> + <b-field label="Start Date"> + <tailbone-datepicker v-model="editEmployeeHistoryStartDate"></tailbone-datepicker> + </b-field> + <b-field label="End Date"> + <tailbone-datepicker v-model="editEmployeeHistoryEndDate" + :disabled="!editEmployeeHistoryEndDateRequired"> + </tailbone-datepicker> + </b-field> + </section> + + <footer class="modal-card-foot"> + <b-button @click="editEmployeeHistoryShowDialog = false"> + Cancel + </b-button> + <b-button type="is-primary" + @click="editEmployeeHistorySave()" + :disabled="editEmployeeHistorySaveDisabled" + icon-pack="fas" + icon-left="save"> + {{ editEmployeeHistorySaving ? "Working, please wait..." : "Save" }} + </b-button> + </footer> + </div> + </${b}-modal> + % endif + + <div style="display: flex; flex-direction: column; align-items: right; gap: 0.75rem;"> + + <b-button v-for="link in employee.external_links" + :key="link.url" + type="is-primary" + tag="a" :href="link.url" target="_blank" + icon-pack="fas" + icon-left="external-link-alt"> + {{ link.label }} + </b-button> + + % if request.has_perm('employees.view'): + <b-button v-if="employee.view_url" + tag="a" :href="employee.view_url"> + View Employee + </b-button> + % endif + + </div> + </div> + + </div> + % if request.use_oruga: + <o-loading v-model:active="refreshingTab" :full-page="false"></o-loading> + % else: + <b-loading :active.sync="refreshingTab" :is-full-page="false"></b-loading> + % endif + </div> + </script> +</%def> + +<%def name="render_employee_tab()"> + <${b}-tab-item label="Employee" + value="employee" + icon-pack="fas" + :icon="tabchecks.employee ? 'check' : null"> + <employee-tab ref="tab_employee" + :person="person" + @profile-changed="profileChanged"> + </employee-tab> + </${b}-tab-item> +</%def> + +<%def name="render_notes_tab_template()"> + <script type="text/x-template" id="notes-tab-template"> + <div> + + % if request.has_perm('people_profile.add_note'): + <b-button type="is-primary" + class="control" + @click="addNoteInit()" + icon-pack="fas" + icon-left="plus"> + Add Note + </b-button> + % endif + + <${b}-table :data="notes"> + + <${b}-table-column field="note_type" + label="Type" + v-slot="props"> + {{ props.row.note_type_display }} + </${b}-table-column> + + <${b}-table-column field="subject" + label="Subject" + v-slot="props"> + {{ props.row.subject }} + </${b}-table-column> + + <${b}-table-column field="text" + label="Text" + v-slot="props"> + {{ props.row.text }} + </${b}-table-column> + + <${b}-table-column field="created" + label="Created" + v-slot="props"> + <span v-html="props.row.created_display"></span> + </${b}-table-column> + + <${b}-table-column field="created_by" + label="Created By" + v-slot="props"> + {{ props.row.created_by_display }} + </${b}-table-column> + + % if request.has_any_perm('people_profile.edit_note', 'people_profile.delete_note'): + <${b}-table-column label="Actions" + v-slot="props"> + % if request.has_perm('people_profile.edit_note'): + <a href="#" @click.prevent="editNoteInit(props.row)"> + % if request.use_oruga: + <span class="icon-text"> + <o-icon icon="edit" /> + <span>Edit</span> + </span> + % else: + <i class="fas fa-edit"></i> + Edit + % endif + </a> + % endif + % if request.has_perm('people_profile.delete_note'): + <a href="#" @click.prevent="deleteNoteInit(props.row)" + class="has-text-danger"> + % if request.use_oruga: + <span class="icon-text"> + <o-icon icon="trash" /> + <span>Delete</span> + </span> + % else: + <i class="fas fa-trash"></i> + Delete + % endif + + </a> + % endif + </${b}-table-column> + % endif + + </${b}-table> + + % if request.has_any_perm('people_profile.add_note', 'people_profile.edit_note', 'people_profile.delete_note'): + <${b}-modal has-modal-card + % if request.use_oruga: + v-model:active="editNoteShowDialog" + % else: + :active.sync="editNoteShowDialog" + % endif + > + + <div class="modal-card"> + + <header class="modal-card-head"> + <p class="modal-card-title"> + {{ editNoteUUID ? (editNoteDelete ? "Delete" : "Edit") : "New" }} Note + </p> + </header> + + <section class="modal-card-body"> + + <b-field label="Type" + :type="!editNoteDelete && !editNoteType ? 'is-danger' : null"> + <b-select v-model="editNoteType" + :disabled="editNoteUUID"> + <option v-for="option in noteTypeOptions" + :key="option.value" + :value="option.value"> + {{ option.label }} + </option> + </b-select> + </b-field> + + <b-field label="Subject"> + <b-input v-model.trim="editNoteSubject" + :disabled="editNoteDelete" + expanded> + </b-input> + </b-field> + + <b-field label="Text"> + <b-input v-model.trim="editNoteText" + type="textarea" + :disabled="editNoteDelete" + expanded> + </b-input> + </b-field> + + <b-notification v-if="editNoteDelete" + type="is-danger" + :closable="false"> + Are you sure you wish to delete this note? + </b-notification> + + </section> + + <footer class="modal-card-foot"> + <b-button :type="editNoteDelete ? 'is-danger' : 'is-primary'" + @click="editNoteSave()" + :disabled="editNoteSaving || (!editNoteDelete && !editNoteType)" + icon-pack="fas" + icon-left="save"> + {{ editNoteSaving ? "Working..." : (editNoteDelete ? "Delete" : "Save") }} + </b-button> + <b-button @click="editNoteShowDialog = false"> + Cancel + </b-button> + </footer> + + </div> + </${b}-modal> + % endif + + % if request.use_oruga: + <o-loading v-model:active="refreshingTab" :full-page="false"></o-loading> + % else: + <b-loading :active.sync="refreshingTab" :is-full-page="false"></b-loading> + % endif + </div> + </script> +</%def> + +<%def name="render_notes_tab()"> + <${b}-tab-item label="Notes" + value="notes" + icon-pack="fas" + :icon="tabchecks.notes ? 'check' : null"> + <notes-tab ref="tab_notes" + :person="person" + @profile-changed="profileChanged"> + </notes-tab> + </${b}-tab-item> +</%def> + +% if expose_transactions: + + <%def name="render_transactions_tab_template()"> + <script type="text/x-template" id="transactions-tab-template"> + <div> + <transactions-grid + ref="transactionsGrid" + /> + </div> + </script> + </%def> + + <%def name="render_transactions_tab()"> + <${b}-tab-item label="Transactions" + value="transactions" + % if not request.use_oruga: + icon-pack="fas" + % endif + icon="bars"> + <transactions-tab ref="tab_transactions" + :person="person" + @profile-changed="profileChanged" /> + </${b}-tab-item> + </%def> + +% endif + + +<%def name="render_user_tab_template()"> + <script type="text/x-template" id="user-tab-template"> + <div> + <div v-if="users.length"> + + <p>{{ person.display_name }} has <strong>{{ users.length }}</strong> user account{{ users.length == 1 ? '' : 's' }}</p> + <br /> + <div id="users-accordion"> + + <${b}-collapse v-for="user in users" + :key="user.uuid" + class="panel"> + + <template #trigger="props"> + <div class="panel-heading" + role="button" + style="cursor: pointer;"> + + ## TODO: for some reason buefy will "reuse" the icon + ## element in such a way that its display does not + ## refresh. so to work around that, we use different + ## structure for the two icons, so buefy is forced to + ## re-draw + + <b-icon v-if="props.open" + pack="fas" + icon="caret-down" /> + + <span v-if="!props.open"> + <b-icon pack="fas" + icon="caret-right" /> + </span> + + + <strong>{{ user.username }}</strong> + </div> + </template> + + <div class="panel-block"> + <div style="display: flex; justify-content: space-between; width: 100%;"> + + <div style="flex-grow: 1;"> + <b-field horizontal label="Username"> + {{ user.username }} + </b-field> + <b-field horizontal label="Active"> + {{ user.active ? "Yes" : "No" }} + </b-field> + </div> + + <div> + % if request.has_perm('users.view'): + <b-button tag="a" :href="user.view_url"> + View User + </b-button> + % endif + </div> + + </div> + </div> + </${b}-collapse> + </div> + </div> + + <div v-if="!users.length" + style="display: flex; justify-content: space-between;"> + + <p>{{ person.display_name }} does not have a user account.</p> + + % if request.has_perm('users.create'): + <b-button type="primary" + icon-pack="fas" + icon-left="plus" + @click="createUserInit()"> + Create User + </b-button> + + <${b}-modal has-modal-card + % if request.use_oruga: + v-model:active="createUserShowDialog" + % else: + :active.sync="createUserShowDialog" + % endif + > + <div class="modal-card"> + + <header class="modal-card-head"> + <p class="modal-card-title">Create User</p> + </header> + + <section class="modal-card-body"> + <b-field label="Person"> + <span>{{ person.display_name }}</span> + </b-field> + <b-field label="Username"> + <b-input v-model="createUserUsername" + ref="username" /> + </b-field> + <b-field label="Active"> + <b-checkbox v-model="createUserActive" /> + </b-field> + </section> + + <footer class="modal-card-foot"> + <b-button @click="createUserShowDialog = false"> + Cancel + </b-button> + <b-button type="is-primary" + @click="createUserSave()" + :disabled="createUserSaveDisabled" + icon-pack="fas" + icon-left="save"> + {{ createUserSaving ? "Working, please wait..." : "Save" }} + </b-button> + </footer> + </div> + </${b}-modal> % endif </div> + % if request.use_oruga: + <o-loading v-model:active="refreshingTab" :full-page="false"></o-loading> + % else: + <b-loading :active.sync="refreshingTab" :is-full-page="false"></b-loading> + % endif </div> - </div><!-- personal-tab --> + </script> +</%def> - <div id="customer-tab"> - % if person.customers: - <p>${person} is associated with ${len(person.customers)} customer account(s)</p> - <br /> - <div id="customers-accordion"> - % for customer in person.customers: - <h3>${customer.id} - ${customer.name}</h3> - <div> +<%def name="render_user_tab()"> + <${b}-tab-item label="User" + value="user" + icon-pack="fas" + :icon="tabchecks.user ? 'check' : null"> + <user-tab ref="tab_user" + :person="person" + @profile-changed="profileChanged"> + </user-tab> + </${b}-tab-item> +</%def> + +<%def name="render_profile_tabs()"> + ${self.render_personal_tab()} + + % if expose_members: + ${self.render_member_tab()} + % endif + + ${self.render_customer_tab()} + % if expose_customer_shoppers: + ${self.render_shopper_tab()} + % endif + ${self.render_employee_tab()} + ${self.render_notes_tab()} + % if expose_transactions: + ${self.render_transactions_tab()} + % endif + ${self.render_user_tab()} +</%def> + +<%def name="render_profile_info_extra_buttons()"></%def> + +<%def name="render_profile_info_template()"> + <script type="text/x-template" id="profile-info-template"> + <div> + + ${self.render_profile_info_extra_buttons()} + + <${b}-tabs v-model="activeTab" + % if request.has_perm('people_profile.view_versions'): + v-show="!viewingHistory" + % endif + % if request.use_oruga: + type="boxed" + @change="activeTabChanged" + % else: + type="is-boxed" + @input="activeTabChanged" + % endif + > + ${self.render_profile_tabs()} + </${b}-tabs> + + % if request.has_perm('people_profile.view_versions'): + + ${revisions_grid.render_table_element(data_prop='revisions', + show_footer=True, + vshow='viewingHistory', + loading='gettingRevisions')|n} + + <${b}-modal + % if request.use_oruga: + v-model:active="showingRevisionDialog" + % else: + :active.sync="showingRevisionDialog" + % endif + > + + <div class="card"> + <div class="card-content"> <div style="display: flex; justify-content: space-between;"> <div> - - <div class="field-wrapper id"> - <div class="field-row"> - <label>ID</label> - <div class="field"> - ${customer.id or ''} - </div> - </div> - </div> - - <div class="field-wrapper name"> - <div class="field-row"> - <label>Name</label> - <div class="field"> - ${customer.name} - </div> - </div> - </div> - - % if customer.phones: - % for phone in customer.phones: - <div class="field-wrapper"> - <div class="field-row"> - <label>Phone Number</label> - <div class="field"> - ${phone.number} (type: ${phone.type}) - </div> - </div> - </div> - % endfor - % else: - <div class="field-wrapper"> - <div class="field-row"> - <label>Phone Number</label> - <div class="field"> - (none on file) - </div> - </div> - </div> - % endif - - % if customer.emails: - % for email in customer.emails: - <div class="field-wrapper"> - <div class="field-row"> - <label>Email Address</label> - <div class="field"> - ${email.address} (type: ${email.type}) - </div> - </div> - </div> - % endfor - % else: - <div class="field-wrapper"> - <div class="field-row"> - <label>Email Address</label> - <div class="field"> - (none on file) - </div> - </div> - </div> - % endif - + <b-field horizontal label="Changed"> + <div v-html="revision.changed"></div> + </b-field> + <b-field horizontal label="Changed by"> + <div v-html="revision.changed_by"></div> + </b-field> + <b-field horizontal label="IP Address"> + <div v-html="revision.remote_addr"></div> + </b-field> + <b-field horizontal label="Comment"> + <div v-html="revision.comment"></div> + </b-field> + <b-field horizontal label="TXN ID"> + <div v-html="revision.txnid"></div> + </b-field> </div> <div> - % if request.has_perm('customers.view'): - ${h.link_to("View Customer", url('customers.view', uuid=customer.uuid), class_='button')} + <div> + <b-button @click="viewPrevRevision()" + :disabled="!revision.prev_txnid"> + « Prev + </b-button> + <b-button @click="viewNextRevision()" + :disabled="!revision.next_txnid"> + » Next + </b-button> + </div> + <br /> + <b-button @click="toggleVersionFields()"> + {{ revisionShowAllFields ? "Show Diffs Only" : "Show All Fields" }} + </b-button> + </div> + + </div> + + <br /> + + <div v-for="version in revision.versions" + :key="version.key"> + + <p class="block has-text-weight-bold"> + {{ version.model_title }} + </p> + + <table class="diff monospace is-size-7" + :class="version.diff_class"> + <thead> + <tr> + <th>field name</th> + <th>old value</th> + <th>new value</th> + </tr> + </thead> + <tbody> + <tr v-for="field in version.fields" + :key="field" + :class="{diff: version.values[field].after != version.values[field].before}" + v-show="revisionShowAllFields || version.values[field].after != version.values[field].before"> + <td class="field">{{ field }}</td> + <td class="old-value" v-html="version.values[field].before"></td> + <td class="new-value" v-html="version.values[field].after"></td> + </tr> + </tbody> + </table> + + <br /> + </div> + + </div> + </div> + </${b}-modal> + % endif + + </div> + </script> + <script> + + let ProfileInfoData = { + activeTab: location.hash ? location.hash.substring(1) : 'personal', + tabchecks: ${json.dumps(tabchecks or {})|n}, + today: '${rattail_app.today()}', + profileLastChanged: Date.now(), + person: ${json.dumps(person_data or {})|n}, + phoneTypeOptions: ${json.dumps(phone_type_options or [])|n}, + emailTypeOptions: ${json.dumps(email_type_options or [])|n}, + maxLengths: ${json.dumps(max_lengths or {})|n}, + + % if request.has_perm('people_profile.view_versions'): + loadingRevisions: false, + showingRevisionDialog: false, + revision: {}, + revisionShowAllFields: false, + % endif + } + + let ProfileInfo = { + template: '#profile-info-template', + props: { + % if request.has_perm('people_profile.view_versions'): + viewingHistory: Boolean, + gettingRevisions: Boolean, + revisions: Array, + revisionVersionMap: null, + % endif + }, + computed: {}, + mounted() { + + // auto-refresh whichever tab is shown first + ## TODO: how to not assume 'personal' is the default tab? + let tab = this.$refs['tab_' + (this.activeTab || 'personal')] + if (tab && tab.refreshTab) { + tab.refreshTab() + } + }, + methods: { + + profileChanged(data) { + this.$emit('change-content-title', data.person.dynamic_content_title) + this.person = data.person + this.tabchecks = data.tabchecks + this.profileLastChanged = Date.now() + }, + + activeTabChanged(value) { + location.hash = value + this.refreshTabIfNeeded(value) + this.activeTabChangedExtra(value) + }, + + refreshTabIfNeeded(key) { + // TODO: this is *always* refreshing, should be more selective (?) + let tab = this.$refs['tab_' + key] + if (tab && tab.refreshIfNeeded) { + tab.refreshIfNeeded(this.profileLastChanged) + } + }, + + activeTabChangedExtra(value) {}, + + % if request.has_perm('people_profile.view_versions'): + + viewRevision(row) { + this.revision = this.revisionVersionMap[row.txnid] + this.showingRevisionDialog = true + }, + + viewPrevRevision() { + let txnid = this.revision.prev_txnid + this.revision = this.revisionVersionMap[txnid] + }, + + viewNextRevision() { + let txnid = this.revision.next_txnid + this.revision = this.revisionVersionMap[txnid] + }, + + toggleVersionFields() { + this.revisionShowAllFields = !this.revisionShowAllFields + }, + + % endif + }, + } + + </script> +</%def> + +<%def name="declare_personal_tab_vars()"> + <script type="text/javascript"> + + let PersonalTabData = { + % if hasattr(master, 'profile_tab_personal'): + refreshTabURL: '${url('people.profile_tab_personal', uuid=person.uuid)}', + % endif + + // nb. hack to force refresh for vue3 + refreshPersonalCard: 1, + refreshAddressCard: 1, + + % if request.has_perm('people_profile.edit_person'): + editNameShowDialog: false, + editNameFirst: null, + % if use_preferred_first_name: + editNameFirstPreferred: null, + % endif + editNameMiddle: null, + editNameLast: null, + editNameSaving: false, + + editAddressShowDialog: false, + editAddressStreet1: null, + editAddressStreet2: null, + editAddressCity: null, + editAddressState: null, + editAddressZipcode: null, + editAddressInvalid: false, + + editPhoneShowDialog: false, + editPhoneUUID: null, + editPhoneType: null, + editPhoneNumber: null, + editPhonePreferred: false, + editPhoneSaving: false, + + deletePhoneShowDialog: false, + deletePhoneUUID: null, + deletePhoneNumber: null, + deletePhoneSaving: false, + + preferPhoneShowDialog: false, + preferPhoneUUID: null, + preferPhoneNumber: null, + preferPhoneSaving: false, + + editEmailShowDialog: false, + editEmailUUID: null, + editEmailType: null, + editEmailAddress: null, + editEmailPreferred: null, + editEmailInvalid: false, + editEmailSaving: false, + + deleteEmailShowDialog: false, + deleteEmailUUID: null, + deleteEmailAddress: null, + deleteEmailSaving: false, + + preferEmailShowDialog: false, + preferEmailUUID: null, + preferEmailAddress: null, + preferEmailSaving: false, + + % endif + } + + let PersonalTab = { + template: '#personal-tab-template', + mixins: [TabMixin, SimpleRequestMixin], + props: { + person: Object, + phoneTypeOptions: Array, + emailTypeOptions: Array, + maxLengths: Object, + }, + computed: { + + % if request.has_perm('people_profile.edit_person'): + + editNameSaveDisabled: function() { + if (this.editNameSaving) { + return true + } + if (!this.editNameFirst || !this.editNameLast) { + return true + } + return false + }, + + editAddressSaveDisabled: function() { + // TODO: should require anything here? + return false + }, + + editPhoneSaveDisabled: function() { + if (this.editPhoneSaving) { + return true + } + if (!this.editPhoneType) { + return true + } + if (!this.editPhoneNumber) { + return true + } + return false + }, + + editEmailSaveDisabled: function() { + if (this.editEmailSaving) { + return true + } + if (!this.editEmailType) { + return true + } + if (!this.editEmailAddress) { + return true + } + return false + }, + + % endif + }, + methods: { + + // refreshTabSuccess(response) {}, + + % if request.has_perm('people_profile.edit_person'): + + editNameAllowed() { + return true + }, + + editNameInit() { + this.editNameFirst = this.person.first_name + % if use_preferred_first_name: + this.editNameFirstPreferred = this.person.preferred_first_name % endif - </div> + this.editNameMiddle = this.person.middle_name + this.editNameLast = this.person.last_name + this.editNameShowDialog = true + }, - </div> + editNameSave() { + this.editNameSaving = true + let url = '${url('people.profile_edit_name', uuid=person.uuid)}' + let params = { + first_name: this.editNameFirst, + % if use_preferred_first_name: + preferred_first_name: this.editNameFirstPreferred, + % endif + middle_name: this.editNameMiddle, + last_name: this.editNameLast, + } - </div> - % endfor - </div> + this.simplePOST(url, params, response => { + this.$emit('profile-changed', response.data) + this.editNameShowDialog = false + this.refreshTab() + this.editNameSaving = false + // nb. hack to force refresh for vue3 + this.refreshPersonalCard += 1 + }, response => { + this.editNameSaving = false + }) + }, - % else: - <p>${person} has never been a customer.</p> - % endif - </div><!-- customer-tab --> + editAddressInit() { + let address = this.person.address + this.editAddressStreet1 = address ? address.street : null + this.editAddressStreet2 = address ? address.street2 : null + this.editAddressCity = address ? address.city : null + this.editAddressState = address ? address.state : null + this.editAddressZipcode = address ? address.zipcode : null + this.editAddressInvalid = address ? address.invalid : false + this.editAddressShowDialog = true + }, - <div id="employee-tab"> - % if employee: - <div style="display: flex; justify-content: space-between;"> + editAddressSave() { + let url = '${url('people.profile_edit_address', uuid=person.uuid)}' + let params = { + street: this.editAddressStreet1, + street2: this.editAddressStreet2, + city: this.editAddressCity, + state: this.editAddressState, + zipcode: this.editAddressZipcode, + invalid: this.editAddressInvalid, + } - <div> + this.simplePOST(url, params, response => { + this.$emit('profile-changed', response.data) + this.editAddressShowDialog = false + this.refreshTab() + // nb. hack to force refresh for vue3 + this.refreshAddressCard += 1 + }) + }, - <div class="field-wrapper id"> - <div class="field-row"> - <label>ID</label> - <div class="field"> - ${employee.id or ''} - </div> - </div> - </div> + addPhoneInit() { + this.editPhoneInit({ + uuid: null, + type: 'Home', + number: null, + preferred: false, + }) + }, - <div class="field-wrapper display_name"> - <div class="field-row"> - <label>Display Name</label> - <div class="field"> - ${employee.display_name or ''} - </div> - </div> - </div> + editPhoneInit(phone) { + this.editPhoneUUID = phone.uuid + this.editPhoneType = phone.type + this.editPhoneNumber = phone.number + this.editPhonePreferred = phone.preferred + this.editPhoneShowDialog = true + this.$nextTick(function() { + this.$refs.editPhoneInput.focus() + }) + }, - <div class="field-wrapper status"> - <div class="field-row"> - <label>Status</label> - <div class="field"> - ${enum.EMPLOYEE_STATUS.get(employee.status, '')} - </div> - </div> - </div> + editPhoneSave() { + this.editPhoneSaving = true + + let url + let params = { + phone_number: this.editPhoneNumber, + phone_type: this.editPhoneType, + phone_preferred: this.editPhonePreferred, + } + + // nb. create or update + if (this.editPhoneUUID) { + url = '${url('people.profile_update_phone', uuid=person.uuid)}' + params.phone_uuid = this.editPhoneUUID + } else { + url = '${url('people.profile_add_phone', uuid=person.uuid)}' + } + + this.simplePOST(url, params, response => { + this.$emit('profile-changed', response.data) + this.editPhoneShowDialog = false + this.editPhoneSaving = false + this.refreshTab() + }, response => { + this.editPhoneSaving = false + }) + }, + + deletePhoneInit(phone) { + this.deletePhoneUUID = phone.uuid + this.deletePhoneNumber = phone.number + this.deletePhoneShowDialog = true + }, + + deletePhoneSave() { + this.deletePhoneSaving = true + let url = '${url('people.profile_delete_phone', uuid=person.uuid)}' + let params = { + phone_uuid: this.deletePhoneUUID, + } + this.simplePOST(url, params, response => { + this.$emit('profile-changed', response.data) + this.refreshTab() + this.deletePhoneShowDialog = false + this.deletePhoneSaving = false + }, response => { + this.deletePhoneSaving = false + }) + }, + + preferPhoneInit(phone) { + this.preferPhoneUUID = phone.uuid + this.preferPhoneNumber = phone.number + this.preferPhoneShowDialog = true + }, + + preferPhoneSave() { + this.preferPhoneSaving = true + let url = '${url('people.profile_set_preferred_phone', uuid=person.uuid)}' + let params = { + phone_uuid: this.preferPhoneUUID, + } + + this.simplePOST(url, params, response => { + this.$emit('profile-changed', response.data) + this.refreshTab() + this.preferPhoneShowDialog = false + this.preferPhoneSaving = false + }, response => { + this.preferPhoneSaving = false + }) + }, + + addEmailInit() { + this.editEmailInit({ + uuid: null, + type: 'Home', + address: null, + invalid: false, + preferred: false, + }) + }, + + editEmailInit(email) { + this.editEmailUUID = email.uuid + this.editEmailType = email.type + this.editEmailAddress = email.address + this.editEmailInvalid = email.invalid + this.editEmailPreferred = email.preferred + this.editEmailShowDialog = true + this.$nextTick(function() { + this.$refs.editEmailInput.focus() + }) + }, + + editEmailSave() { + this.editEmailSaving = true + + let url = null + let params = { + email_address: this.editEmailAddress, + email_type: this.editEmailType, + } + + if (this.editEmailUUID) { + url = '${url('people.profile_update_email', uuid=person.uuid)}' + params.email_uuid = this.editEmailUUID + params.email_invalid = this.editEmailInvalid + } else { + url = '${url('people.profile_add_email', uuid=person.uuid)}' + params.email_preferred = this.editEmailPreferred + } + + this.simplePOST(url, params, response => { + this.$emit('profile-changed', response.data) + this.editEmailShowDialog = false + this.editEmailSaving = false + this.refreshTab() + }, response => { + this.editEmailSaving = false + }) + }, + + deleteEmailInit(email) { + this.deleteEmailUUID = email.uuid + this.deleteEmailAddress = email.address + this.deleteEmailShowDialog = true + }, + + deleteEmailSave() { + this.deleteEmailSaving = true + let url = '${url('people.profile_delete_email', uuid=person.uuid)}' + let params = { + email_uuid: this.deleteEmailUUID, + } + this.simplePOST(url, params, response => { + this.$emit('profile-changed', response.data) + this.refreshTab() + this.deleteEmailShowDialog = false + this.deleteEmailSaving = false + }, response => { + this.deleteEmailSaving = false + }) + }, + + preferEmailInit(email) { + this.preferEmailUUID = email.uuid + this.preferEmailAddress = email.address + this.preferEmailShowDialog = true + }, + + preferEmailSave() { + this.preferEmailSaving = true + let url = '${url('people.profile_set_preferred_email', uuid=person.uuid)}' + let params = { + email_uuid: this.preferEmailUUID, + } + + this.simplePOST(url, params, response => { + this.$emit('profile-changed', response.data) + this.refreshTab() + this.preferEmailShowDialog = false + this.preferEmailSaving = false + }, response => { + this.preferEmailSaving = false + }) + }, + + % endif + }, + } + + </script> +</%def> + +<%def name="make_personal_tab_component()"> + ${self.declare_personal_tab_vars()} + <script type="text/javascript"> + + PersonalTab.data = function() { return PersonalTabData } + Vue.component('personal-tab', PersonalTab) + <% request.register_component('personal-tab', 'PersonalTab') %> + + </script> +</%def> + +% if expose_members: +<%def name="declare_member_tab_vars()"> + <script type="text/javascript"> + + let MemberTabData = { + refreshTabURL: '${url('people.profile_tab_member', uuid=person.uuid)}', + % if max_one_member: + member: {}, + % else: + members: [], + % endif + } + + let MemberTab = { + template: '#member-tab-template', + mixins: [TabMixin, SimpleRequestMixin], + props: { + person: Object, + phoneTypeOptions: Array, + }, + computed: {}, + methods: { + + refreshTabSuccess(response) { + % if max_one_member: + this.member = response.data.member + % else: + this.members = response.data.members + % endif + }, + }, + } + + </script> +</%def> + +<%def name="make_member_tab_component()"> + ${self.declare_member_tab_vars()} + <script type="text/javascript"> + + MemberTab.data = function() { return MemberTabData } + Vue.component('member-tab', MemberTab) + <% request.register_component('member-tab', 'MemberTab') %> + + </script> +</%def> +% endif + +<%def name="declare_customer_tab_vars()"> + <script type="text/javascript"> + + let CustomerTabData = { + % if hasattr(master, 'profile_tab_customer'): + refreshTabURL: '${url('people.profile_tab_customer', uuid=person.uuid)}', + % endif + customers: [], + } + + let CustomerTab = { + template: '#customer-tab-template', + mixins: [TabMixin, SimpleRequestMixin], + props: { + person: Object, + }, + computed: {}, + methods: { + + refreshTabSuccess(response) { + this.customers = response.data.customers + }, + }, + } + + </script> +</%def> + +<%def name="make_customer_tab_component()"> + ${self.declare_customer_tab_vars()} + <script type="text/javascript"> + + CustomerTab.data = function() { return CustomerTabData } + Vue.component('customer-tab', CustomerTab) + <% request.register_component('customer-tab', 'CustomerTab') %> + + </script> +</%def> + +<%def name="declare_shopper_tab_vars()"> + <script type="text/javascript"> + + let ShopperTabData = { + refreshTabURL: '${url('people.profile_tab_shopper', uuid=person.uuid)}', + shoppers: [], + } + + let ShopperTab = { + template: '#shopper-tab-template', + mixins: [TabMixin, SimpleRequestMixin], + props: { + person: Object, + }, + computed: {}, + methods: { + + refreshTabSuccess(response) { + this.shoppers = response.data.shoppers + }, + }, + } + + </script> +</%def> + +<%def name="make_shopper_tab_component()"> + ${self.declare_shopper_tab_vars()} + <script type="text/javascript"> + + ShopperTab.data = function() { return ShopperTabData } + Vue.component('shopper-tab', ShopperTab) + <% request.register_component('shopper-tab', 'ShopperTab') %> + + </script> +</%def> + +<%def name="declare_employee_tab_vars()"> + <script type="text/javascript"> + + let EmployeeTabData = { + % if hasattr(master, 'profile_tab_employee'): + refreshTabURL: '${url('people.profile_tab_employee', uuid=person.uuid)}', + % endif + employee: {}, + employeeHistory: [], + + // nb. hack to force refresh for vue3 + refreshEmployeeCard: 1, + + % if request.has_perm('employees.edit'): + editEmployeeIdShowDialog: false, + editEmployeeIdValue: null, + editEmployeeIdSaving: false, + % endif + + % if request.has_perm('people_profile.toggle_employee'): + startEmployeeShowDialog: false, + startEmployeeID: null, + startEmployeeStartDate: null, + startEmployeeSaving: false, + + stopEmployeeShowDialog: false, + stopEmployeeEndDate: null, + stopEmployeeRevokeAccess: false, + stopEmployeeSaving: false, + % endif + + % if request.has_perm('people_profile.edit_employee_history'): + editEmployeeHistoryShowDialog: false, + editEmployeeHistoryUUID: null, + editEmployeeHistoryStartDate: null, + editEmployeeHistoryEndDate: null, + editEmployeeHistoryEndDateRequired: false, + editEmployeeHistorySaving: false, + % endif + } + + let EmployeeTab = { + template: '#employee-tab-template', + mixins: [TabMixin, SimpleRequestMixin], + props: { + person: Object, + }, + computed: { + + % if request.has_perm('people_profile.toggle_employee'): + + startEmployeeSaveDisabled() { + if (this.startEmployeeSaving) { + return true + } + if (!this.startEmployeeStartDate) { + return true + } + return false + }, + + stopEmployeeSaveDisabled() { + if (this.stopEmployeeSaving) { + return true + } + if (!this.stopEmployeeEndDate) { + return true + } + return false + }, - % if employee.phones: - % for phone in employee.phones: - <div class="field-wrapper"> - <div class="field-row"> - <label>Phone Number</label> - <div class="field"> - ${phone.number} (type: ${phone.type}) - </div> - </div> - </div> - % endfor - % else: - <div class="field-wrapper"> - <div class="field-row"> - <label>Phone Number</label> - <div class="field"> - (none on file) - </div> - </div> - </div> % endif - % if employee.emails: - % for email in employee.emails: - <div class="field-wrapper"> - <div class="field-row"> - <label>Email Address</label> - <div class="field"> - ${email.address} (type: ${email.type}) - </div> - </div> - </div> - % endfor - % else: - <div class="field-wrapper"> - <div class="field-row"> - <label>Email Address</label> - <div class="field"> - (none on file) - </div> - </div> - </div> + % if request.has_perm('people_profile.edit_employee_history'): + + editEmployeeHistorySaveDisabled() { + if (this.editEmployeeHistorySaving) { + return true + } + if (!this.editEmployeeHistoryStartDate) { + return true + } + if (this.editEmployeeHistoryEndDateRequired && !this.editEmployeeHistoryEndDate) { + return true + } + return false + }, + % endif - </div> + }, + methods: { + + refreshTabSuccess(response) { + this.employee = response.data.employee + // nb. hack to force refresh for vue3 + this.refreshEmployeeCard += 1 + this.employeeHistory = response.data.employee_history + }, + + % if request.has_perm('employees.edit'): + + editEmployeeIdInit() { + this.editEmployeeIdValue = this.employee.id + this.editEmployeeIdShowDialog = true + }, + + editEmployeeIdSave() { + this.editEmployeeIdSaving = true + let url = '${url('people.profile_update_employee_id', uuid=instance.uuid)}' + let params = { + 'employee_id': this.editEmployeeIdValue || null, + } + this.simplePOST(url, params, response => { + this.$emit('profile-changed', response.data) + this.editEmployeeIdShowDialog = false + this.editEmployeeIdSaving = false + this.refreshTab() + }, response => { + this.editEmployeeIdSaving = false + }) + }, - <div> - % if request.has_perm('employees.view'): - ${h.link_to("View Employee", url('employees.view', uuid=employee.uuid), class_='button')} % endif - </div> - </div> + % if request.has_perm('people_profile.toggle_employee'): - % else: - <p>${person} has never been an employee.</p> - % endif - </div><!-- employee-tab --> + startEmployeeInit() { + this.startEmployeeID = this.employee.id || null + this.startEmployeeStartDate = null + this.startEmployeeShowDialog = true + }, - <div id="user-tab"> - % if person.users: - <p>${person} is associated with ${len(person.users)} user account(s)</p> - <br /> - <div id="users-accordion"> - % for user in person.users: - <h3>${user.username}</h3> - <div> + startEmployeeSave() { + this.startEmployeeSaving = true + const url = '${url('people.profile_start_employee', uuid=person.uuid)}' + const params = { + id: this.startEmployeeID, + % if request.use_oruga: + start_date: this.$refs.startEmployeeStartDate.formatDate(this.startEmployeeStartDate), + % else: + start_date: this.startEmployeeStartDate, + % endif + } - <div style="display: flex; justify-content: space-between;"> + this.simplePOST(url, params, response => { + this.$emit('profile-changed', response.data) + this.startEmployeeShowDialog = false + this.refreshTab() + this.startEmployeeSaving = false + }, response => { + this.startEmployeeSaving = false + }) + }, - <div> + stopEmployeeInit() { + this.stopEmployeeEndDate = null + this.stopEmployeeRevokeAccess = false + this.stopEmployeeShowDialog = true + }, - <div class="field-wrapper id"> - <div class="field-row"> - <label>Username</label> - <div class="field"> - ${user.username} - </div> - </div> - </div> + stopEmployeeSave() { + this.stopEmployeeSaving = true + const url = '${url('people.profile_end_employee', uuid=person.uuid)}' + const params = { + % if request.use_oruga: + end_date: this.$refs.startEmployeeStartDate.formatDate(this.stopEmployeeEndDate), + % else: + end_date: this.stopEmployeeEndDate, + % endif + revoke_access: this.stopEmployeeRevokeAccess, + } - </div> + this.simplePOST(url, params, response => { + this.$emit('profile-changed', response.data) + this.stopEmployeeShowDialog = false + this.stopEmployeeSaving = false + this.refreshTab() + }, response => { + this.stopEmployeeSaving = false + }) + }, - <div> - % if request.has_perm('users.view'): - ${h.link_to("View User", url('users.view', uuid=user.uuid), class_='button')} + % endif + + % if request.has_perm('people_profile.edit_employee_history'): + + editEmployeeHistoryInit(row) { + this.editEmployeeHistoryUUID = row.uuid + this.editEmployeeHistoryStartDate = row.start_date + this.editEmployeeHistoryEndDate = row.end_date + this.editEmployeeHistoryEndDateRequired = !!row.end_date + this.editEmployeeHistoryShowDialog = true + }, + + editEmployeeHistorySave() { + this.editEmployeeHistorySaving = true + let url = '${url('people.profile_edit_employee_history', uuid=person.uuid)}' + let params = { + uuid: this.editEmployeeHistoryUUID, + % if request.use_oruga: + start_date: this.$refs.startEmployeeStartDate.formatDate(this.editEmployeeHistoryStartDate), + end_date: this.$refs.startEmployeeStartDate.formatDate(this.editEmployeeHistoryEndDate), + % else: + start_date: this.editEmployeeHistoryStartDate, + end_date: this.editEmployeeHistoryEndDate, + % endif + } + + this.simplePOST(url, params, response => { + this.$emit('profile-changed', response.data) + this.editEmployeeHistoryShowDialog = false + this.refreshTab() + this.editEmployeeHistorySaving = false + }, response => { + this.editEmployeeHistorySaving = false + }) + }, + + % endif + }, + } + + </script> +</%def> + +<%def name="make_employee_tab_component()"> + ${self.declare_employee_tab_vars()} + <script type="text/javascript"> + + EmployeeTab.data = function() { return EmployeeTabData } + Vue.component('employee-tab', EmployeeTab) + <% request.register_component('employee-tab', 'EmployeeTab') %> + + </script> +</%def> + +<%def name="declare_notes_tab_vars()"> + <script type="text/javascript"> + + let NotesTabData = { + % if hasattr(master, 'profile_tab_notes'): + refreshTabURL: '${url('people.profile_tab_notes', uuid=person.uuid)}', + % endif + notes: [], + noteTypeOptions: [], + + % if request.has_any_perm('people_profile.add_note', 'people_profile.edit_note', 'people_profile.delete_note'): + editNoteShowDialog: false, + editNoteUUID: null, + editNoteDelete: false, + editNoteType: null, + editNoteSubject: null, + editNoteText: null, + editNoteSaving: false, + % endif + } + + let NotesTab = { + template: '#notes-tab-template', + mixins: [TabMixin, SimpleRequestMixin], + props: { + person: Object, + }, + computed: {}, + methods: { + + refreshTabSuccess(response) { + this.notes = response.data.notes + this.noteTypeOptions = response.data.note_types + }, + + % if request.has_perm('people_profile.add_note'): + + addNoteInit() { + this.editNoteUUID = null + this.editNoteType = null + this.editNoteSubject = null + this.editNoteText = null + this.editNoteDelete = false + this.editNoteShowDialog = true + }, + + % endif + + % if request.has_perm('people_profile.edit_note'): + + editNoteInit(note) { + this.editNoteUUID = note.uuid + this.editNoteType = note.note_type + this.editNoteSubject = note.subject + this.editNoteText = note.text + this.editNoteDelete = false + this.editNoteShowDialog = true + }, + + % endif + + % if request.has_perm('people_profile.delete_note'): + + deleteNoteInit(note) { + this.editNoteUUID = note.uuid + this.editNoteType = note.note_type + this.editNoteSubject = note.subject + this.editNoteText = note.text + this.editNoteDelete = true + this.editNoteShowDialog = true + }, + + % endif + + % if request.has_any_perm('people_profile.add_note', 'people_profile.edit_note', 'people_profile.delete_note'): + + editNoteSave() { + this.editNoteSaving = true + + let url = null + if (!this.editNoteUUID) { + url = '${master.get_action_url('profile_add_note', instance)}' + } else if (this.editNoteDelete) { + url = '${master.get_action_url('profile_delete_note', instance)}' + } else { + url = '${master.get_action_url('profile_edit_note', instance)}' + } + + let params = { + uuid: this.editNoteUUID, + note_type: this.editNoteType, + note_subject: this.editNoteSubject, + note_text: this.editNoteText, + } + + this.simplePOST(url, params, response => { + this.$emit('profile-changed', response.data) + this.editNoteSaving = false + this.editNoteShowDialog = false + this.refreshTab() + }, response => { + this.editNoteSaving = false + }) + }, + + % endif + }, + } + + </script> +</%def> + +<%def name="make_notes_tab_component()"> + ${self.declare_notes_tab_vars()} + <script type="text/javascript"> + + NotesTab.data = function() { return NotesTabData } + Vue.component('notes-tab', NotesTab) + <% request.register_component('notes-tab', 'NotesTab') %> + + </script> +</%def> + +% if expose_transactions: + + <%def name="declare_transactions_tab_vars()"> + <script type="text/javascript"> + + let TransactionsTabData = {} + + let TransactionsTab = { + template: '#transactions-tab-template', + mixins: [TabMixin, SimpleRequestMixin], + props: { + person: Object, + }, + computed: {}, + methods: { + + // nb. we override this completely, just tell the grid to refresh + refreshTab() { + this.refreshingTab = true + this.$refs.transactionsGrid.loadAsyncData(null, () => { + this.refreshed = Date.now() + this.refreshingTab = false + }) + } + }, + } + + </script> + </%def> + + <%def name="make_transactions_tab_component()"> + ${self.declare_transactions_tab_vars()} + <script type="text/javascript"> + + TransactionsTab.data = function() { return TransactionsTabData } + Vue.component('transactions-tab', TransactionsTab) + <% request.register_component('transactions-tab', 'TransactionsTab') %> + + </script> + </%def> + +% endif + +<%def name="declare_user_tab_vars()"> + <script type="text/javascript"> + + let UserTabData = { + % if hasattr(master, 'profile_tab_user'): + refreshTabURL: '${url('people.profile_tab_user', uuid=person.uuid)}', + % endif + users: [], + + % if request.has_perm('users.create'): + createUserShowDialog: false, + createUserUsername: null, + createUserActive: false, + createUserSaving: false, + % endif + } + + let UserTab = { + template: '#user-tab-template', + mixins: [TabMixin, SimpleRequestMixin], + props: { + person: Object, + }, + + computed: { + + % if request.has_perm('users.create'): + + createUserSaveDisabled() { + if (this.createUserSaving) { + return true + } + if (!this.createUserUsername) { + return true + } + return false + }, + + % endif + }, + + methods: { + + refreshTabSuccess(response) { + this.users = response.data.users + this.createUserSuggestedUsername = response.data.suggested_username + }, + + % if request.has_perm('users.create'): + + createUserInit() { + this.createUserUsername = this.createUserSuggestedUsername + this.createUserActive = true + this.createUserShowDialog = true + this.$nextTick(() => { + this.$refs.username.focus() + }) + }, + + createUserSave() { + this.createUserSaving = true + + % if hasattr(master, 'profile_make_user'): + let url = '${master.get_action_url('profile_make_user', instance)}' % endif - </div> + let params = { + username: this.createUserUsername, + active: this.createUserActive, + } - </div> + this.simplePOST(url, params, response => { + this.$emit('profile-changed', response.data) + this.createUserSaving = false + this.createUserShowDialog = false + this.refreshTab() + }, response => { + this.createUserSaving = false + }) + }, - </div> - % endfor - </div> + % endif + }, + } - % else: - <p>${person} has never been a user.</p> + </script> +</%def> + +<%def name="make_user_tab_component()"> + ${self.declare_user_tab_vars()} + <script type="text/javascript"> + + UserTab.data = function() { return UserTabData } + Vue.component('user-tab', UserTab) + <% request.register_component('user-tab', 'UserTab') %> + + </script> +</%def> + +<%def name="make_profile_info_component()"> + + ## DEPRECATED; called for back-compat + ${self.declare_profile_info_vars()} + + <script> + ProfileInfo.data = function() { return ProfileInfoData } + Vue.component('profile-info', ProfileInfo) + <% request.register_component('profile-info', 'ProfileInfo') %> + </script> +</%def> + +<%def name="render_vue_templates()"> + ${parent.render_vue_templates()} + + ${self.render_personal_tab_template()} + + % if expose_members: + ${self.render_member_tab_template()} + % endif + + ${self.render_customer_tab_template()} + % if expose_customer_shoppers: + ${self.render_shopper_tab_template()} + % endif + ${self.render_employee_tab_template()} + ${self.render_notes_tab_template()} + + % if expose_transactions: + ${transactions_grid.render_complete(allow_save_defaults=False)|n} + ${self.render_transactions_tab_template()} + % endif + + ${self.render_user_tab_template()} + ${self.render_profile_info_template()} +</%def> + +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> + + % if request.has_perm('people_profile.view_versions'): + ThisPage.props.viewingHistory = Boolean + ThisPage.props.gettingRevisions = Boolean + ThisPage.props.revisions = Array + ThisPage.props.revisionVersionMap = null % endif - </div><!-- user-tab --> -</div><!-- profile-tabs --> + let TabMixin = { + + data() { + return { + refreshed: null, + refreshTabURL: null, + refreshingTab: false, + } + }, + methods: { + + refreshIfNeeded(time) { + if (this.refreshed && time && this.refreshed > time) { + return + } + this.refreshTab() + }, + + refreshTab() { + + if (this.refreshTabURL) { + this.refreshingTab = true + this.simpleGET(this.refreshTabURL, {}, response => { + this.refreshTabSuccess(response) + this.refreshTabSuccessExtra(response) + this.refreshed = Date.now() + this.refreshingTab = false + }) + } + }, + + // nb. subclass must define this as needed + refreshTabSuccess(response) {}, + + // nb. subclass may define this if needed + refreshTabSuccessExtra(response) {}, + }, + } + + + % if request.has_perm('people_profile.view_versions'): + + WholePageData.viewingHistory = false + WholePageData.gettingRevisions = false + WholePageData.gotRevisions = false + WholePageData.revisions = [] + WholePageData.revisionVersionMap = null + + WholePage.methods.viewHistory = function() { + this.viewingHistory = true + + if (!this.gotRevisions && !this.gettingRevisions) { + this.getRevisions() + } + } + + WholePage.methods.refreshHistory = function() { + if (!this.gettingRevisions) { + this.getRevisions() + } + } + + WholePage.methods.getRevisions = function() { + this.gettingRevisions = true + + let url = '${url('people.view_profile_revisions', uuid=person.uuid)}' + this.simpleGET(url, {}, response => { + this.revisions = response.data.data + this.revisionVersionMap = response.data.vmap + this.gotRevisions = true + this.gettingRevisions = false + }, response => { + this.gettingRevisions = false + }) + } + + % endif + </script> +</%def> + +<%def name="make_vue_components()"> + ${parent.make_vue_components()} + + ${self.make_personal_tab_component()} + + % if expose_members: + ${self.make_member_tab_component()} + % endif + + ${self.make_customer_tab_component()} + % if expose_customer_shoppers: + ${self.make_shopper_tab_component()} + % endif + ${self.make_employee_tab_component()} + ${self.make_notes_tab_component()} + + % if expose_transactions: + <script type="text/javascript"> + + TransactionsGrid.data = function() { return TransactionsGridData } + Vue.component('transactions-grid', TransactionsGrid) + ## TODO: why is this line not needed? + ## <% request.register_component('transactions-grid', 'TransactionsGrid') %> + + </script> + ${self.make_transactions_tab_component()} + % endif + + ${self.make_user_tab_component()} + ${self.make_profile_info_component()} +</%def> + +############################## +## DEPRECATED +############################## + +<%def name="declare_profile_info_vars()"></%def> diff --git a/tailbone/templates/people/view_profile_buefy.mako b/tailbone/templates/people/view_profile_buefy.mako deleted file mode 100644 index 51ecaed0..00000000 --- a/tailbone/templates/people/view_profile_buefy.mako +++ /dev/null @@ -1,1674 +0,0 @@ -## -*- coding: utf-8; -*- -<%inherit file="/master/view.mako" /> - -<%def name="extra_styles()"> - ${parent.extra_styles()} - <style type="text/css"> - .card.personal { - margin-bottom: 1rem; - } - .field.is-horizontal .field-label .label { - white-space: nowrap; - min-width: 10rem; - } - </style> -</%def> - -<%def name="content_title()"> - ${dynamic_content_title} -</%def> - -<%def name="page_content()"> - <profile-info @change-content-title="changeContentTitle"> - </profile-info> -</%def> - -<%def name="render_this_page()"> - ${self.page_content()} -</%def> - -<%def name="render_personal_name_card()"> - <div class="card personal"> - <header class="card-header"> - <p class="card-header-title">Name</p> - </header> - <div class="card-content"> - <div class="content"> - <div style="display: flex; justify-content: space-between;"> - <div style="flex-grow: 1; margin-right: 1rem;"> - - <b-field horizontal label="First Name"> - <span>{{ person.first_name }}</span> - </b-field> - - <b-field horizontal label="Middle Name"> - <span>{{ person.middle_name }}</span> - </b-field> - - <b-field horizontal label="Last Name"> - <span>{{ person.last_name }}</span> - </b-field> - - </div> - % if request.has_perm('people_profile.edit_person'): - <div v-if="editNameAllowed()"> - <b-button type="is-primary" - @click="editNameInit()" - icon-pack="fas" - icon-left="edit"> - Edit Name - </b-button> - </div> - <b-modal has-modal-card - :active.sync="editNameShowDialog"> - <div class="modal-card"> - - <header class="modal-card-head"> - <p class="modal-card-title">Edit Name</p> - </header> - - <section class="modal-card-body"> - <b-field label="First Name"> - <b-input v-model.trim="personFirstName" - :maxlength="maxLengths.person_first_name || null"> - </b-input> - </b-field> - <b-field label="Middle Name"> - <b-input v-model.trim="personMiddleName" - :maxlength="maxLengths.person_middle_name || null"> - </b-input> - </b-field> - <b-field label="Last Name"> - <b-input v-model.trim="personLastName" - :maxlength="maxLengths.person_last_name || null"> - </b-input> - </b-field> - </section> - - <footer class="modal-card-foot"> - <once-button type="is-primary" - @click="editNameSave()" - :disabled="editNameSaveDisabled" - icon-left="save" - text="Save"> - </once-button> - <b-button @click="editNameShowDialog = false"> - Cancel - </b-button> - </footer> - </div> - </b-modal> - % endif - </div> - </div> - </div> - </div> -</%def> - -<%def name="render_personal_address_card()"> - <div class="card personal"> - <header class="card-header"> - <p class="card-header-title">Address</p> - </header> - <div class="card-content"> - <div class="content"> - <div style="display: flex; justify-content: space-between;"> - <div style="flex-grow: 1; margin-right: 1rem;"> - - <b-field horizontal label="Street 1"> - <span>{{ person.address ? person.address.street : null }}</span> - </b-field> - - <b-field horizontal label="Street 2"> - <span>{{ person.address ? person.address.street2 : null }}</span> - </b-field> - - <b-field horizontal label="City"> - <span>{{ person.address ? person.address.city : null }}</span> - </b-field> - - <b-field horizontal label="State"> - <span>{{ person.address ? person.address.state : null }}</span> - </b-field> - - <b-field horizontal label="Zipcode"> - <span>{{ person.address ? person.address.zipcode : null }}</span> - </b-field> - - <b-field v-if="person.address && person.address.invalid" - horizontal label="Invalid" - class="has-text-danger"> - <span>Yes</span> - </b-field> - - </div> - % if request.has_perm('people_profile.edit_person'): - <b-button type="is-primary" - @click="editAddressInit()" - icon-pack="fas" - icon-left="edit"> - Edit Address - </b-button> - <b-modal has-modal-card - :active.sync="editAddressShowDialog"> - <div class="modal-card"> - - <header class="modal-card-head"> - <p class="modal-card-title">Edit Address</p> - </header> - - <section class="modal-card-body"> - - <b-field label="Street 1" expanded> - <b-input v-model.trim="personStreet1" - :maxlength="maxLengths.address_street || null"> - </b-input> - </b-field> - - <b-field label="Street 2" expanded> - <b-input v-model.trim="personStreet2" - :maxlength="maxLengths.address_street2 || null"> - </b-input> - </b-field> - - <b-field label="Zipcode"> - <b-input v-model.trim="personZipcode" - :maxlength="maxLengths.address_zipcode || null"> - </b-input> - </b-field> - - <b-field grouped> - <b-field label="City"> - <b-input v-model.trim="personCity" - :maxlength="maxLengths.address_city || null"> - </b-input> - </b-field> - <b-field label="State"> - <b-input v-model.trim="personState" - :maxlength="maxLengths.address_state || null"> - </b-input> - </b-field> - </b-field> - - <b-field label="Invalid"> - <b-checkbox v-model="personInvalidAddress" - type="is-danger"> - </b-checkbox> - </b-field> - - </section> - - <footer class="modal-card-foot"> - <once-button type="is-primary" - @click="editAddressSave()" - :disabled="editAddressSaveDisabled" - icon-left="save" - text="Save"> - </once-button> - <b-button @click="editAddressShowDialog = false"> - Cancel - </b-button> - </footer> - </div> - </b-modal> - % endif - </div> - </div> - </div> - </div> -</%def> - -<%def name="render_personal_phone_card()"> - <div class="card personal"> - <header class="card-header"> - <p class="card-header-title">Phone(s)</p> - </header> - <div class="card-content"> - <div class="content"> - - <b-notification v-if="person.invalid_phone_number" - type="is-warning" - has-icon icon-pack="fas" - :closable="false"> - We appear to have an invalid phone number on file for this person. - </b-notification> - - % if request.has_perm('people_profile.edit_person'): - <div class="is-pulled-right"> - <b-button type="is-primary" - icon-pack="fas" - icon-left="plus" - @click="addPhoneInit()"> - Add Phone - </b-button> - </div> - <b-modal has-modal-card - :active.sync="editPhoneShowDialog"> - <div class="modal-card"> - - <header class="modal-card-head"> - <p class="modal-card-title"> - {{ phoneUUID ? "Edit Phone" : "Add Phone" }} - </p> - </header> - - <section class="modal-card-body"> - <b-field grouped> - - <b-field label="Type" expanded> - <b-select v-model="phoneType" expanded> - <option v-for="option in phoneTypeOptions" - :key="option.value" - :value="option.value"> - {{ option.label }} - </option> - </b-select> - </b-field> - - <b-field label="Number" expanded> - <b-input v-model.trim="phoneNumber" - ref="editPhoneInput"> - </b-input> - </b-field> - </b-field> - - <b-field label="Preferred?"> - <b-checkbox v-model="phonePreferred"> - </b-checkbox> - </b-field> - - </section> - - <footer class="modal-card-foot"> - <b-button type="is-primary" - @click="editPhoneSave()" - :disabled="editPhoneSaveDisabled" - icon-pack="fas" - icon-left="save"> - {{ editPhoneSaveText }} - </b-button> - <b-button @click="editPhoneShowDialog = false"> - Cancel - </b-button> - </footer> - </div> - </b-modal> - % endif - - <b-table :data="person.phones"> - <template slot-scope="props"> - - <b-table-column field="preference" label="Preferred"> - {{ props.row.preferred ? "Yes" : "" }} - </b-table-column> - - <b-table-column field="type" label="Type"> - {{ props.row.type }} - </b-table-column> - - <b-table-column field="number" label="Number"> - {{ props.row.number }} - </b-table-column> - - % if request.has_perm('people_profile.edit_person'): - <b-table-column label="Actions"> - <a href="#" @click.prevent="editPhoneInit(props.row)"> - <i class="fas fa-edit"></i> - Edit - </a> - <a href="#" @click.prevent="deletePhone(props.row)" - class="has-text-danger"> - <i class="fas fa-trash"></i> - Delete - </a> - <a href="#" @click.prevent="setPreferredPhone(props.row)" - v-if="!props.row.preferred"> - <i class="fas fa-star"></i> - Set Preferred - </a> - </b-table-column> - % endif - - </template> - </b-table> - - </div> - </div> - </div> -</%def> - -<%def name="render_personal_email_card()"> - <div class="card personal"> - <header class="card-header"> - <p class="card-header-title">Email(s)</p> - </header> - <div class="card-content"> - <div class="content"> - - % if request.has_perm('people_profile.edit_person'): - <div class="is-pulled-right"> - <b-button type="is-primary" - icon-pack="fas" - icon-left="plus" - @click="addEmailInit()"> - Add Email - </b-button> - </div> - <b-modal has-modal-card - :active.sync="editEmailShowDialog"> - <div class="modal-card"> - - <header class="modal-card-head"> - <p class="modal-card-title"> - {{ emailUUID ? "Edit Email" : "Add Email" }} - </p> - </header> - - <section class="modal-card-body"> - <b-field grouped> - - <b-field label="Type" expanded> - <b-select v-model="emailType" expanded> - <option v-for="option in emailTypeOptions" - :key="option.value" - :value="option.value"> - {{ option.label }} - </option> - </b-select> - </b-field> - - <b-field label="Address" expanded> - <b-input v-model.trim="emailAddress" - ref="editEmailInput"> - </b-input> - </b-field> - - </b-field> - - <b-field v-if="!emailUUID" - label="Preferred?"> - <b-checkbox v-model="emailPreferred"> - </b-checkbox> - </b-field> - - <b-field v-if="emailUUID" - label="Invalid?"> - <b-checkbox v-model="emailInvalid" - :type="emailInvalid ? 'is-danger': null"> - </b-checkbox> - </b-field> - - </section> - - <footer class="modal-card-foot"> - <b-button type="is-primary" - @click="editEmailSave()" - :disabled="editEmailSaveDisabled" - icon-pack="fas" - icon-left="save"> - {{ editEmailSaveText }} - </b-button> - <b-button @click="editEmailShowDialog = false"> - Cancel - </b-button> - </footer> - </div> - </b-modal> - % endif - - <b-table :data="person.emails"> - <template slot-scope="props"> - - <b-table-column field="preference" label="Preferred"> - {{ props.row.preferred ? "Yes" : "" }} - </b-table-column> - - <b-table-column field="type" label="Type"> - {{ props.row.type }} - </b-table-column> - - <b-table-column field="address" label="Address"> - {{ props.row.address }} - </b-table-column> - - <b-table-column field="invalid" label="Invalid"> - <span v-if="props.row.invalid" class="has-text-danger">Yes</span> - </b-table-column> - - % if request.has_perm('people_profile.edit_person'): - <b-table-column label="Actions"> - <a href="#" @click.prevent="editEmailInit(props.row)"> - <i class="fas fa-edit"></i> - Edit - </a> - <a href="#" @click.prevent="deleteEmail(props.row)" - class="has-text-danger"> - <i class="fas fa-trash"></i> - Delete - </a> - <a href="#" @click.prevent="setPreferredEmail(props.row)" - v-if="!props.row.preferred"> - <i class="fas fa-star"></i> - Set Preferred - </a> - </b-table-column> - % endif - - </template> - </b-table> - - </div> - </div> - </div> -</%def> - -<%def name="render_personal_tab_cards()"> - ${self.render_personal_name_card()} - ${self.render_personal_address_card()} - ${self.render_personal_phone_card()} - ${self.render_personal_email_card()} -</%def> - -<%def name="render_personal_tab_template()"> - <script type="text/x-template" id="personal-tab-template"> - <div style="display: flex; justify-content: space-between;"> - - <div style="flex-grow: 1; margin-right: 1rem;"> - ${self.render_personal_tab_cards()} - </div> - - <div> - % if request.has_perm('people.view'): - ${h.link_to("View Person", url('people.view', uuid=person.uuid), class_='button')} - % endif - </div> - - </div> - </script> -</%def> - -<%def name="render_personal_tab()"> - <b-tab-item label="Personal" - icon-pack="fas" - icon="check"> - <personal-tab :person="person" - :member="member" - :max-lengths="maxLengths" - :phone-type-options="phoneTypeOptions" - :email-type-options="emailTypeOptions" - @person-updated="personUpdated" - @change-content-title="changeContentTitle"> - </personal-tab> - </b-tab-item> -</%def> - -<%def name="render_member_tab()"> - <b-tab-item label="Member" icon-pack="fas" :icon="members.length ? 'check' : null"> - - <div v-if="members.length"> - - <div style="display: flex; justify-content: space-between;"> - <p>{{ person.display_name }} is associated with <strong>{{ members.length }}</strong> member account(s)</p> - </div> - - <br /> - <b-collapse v-for="member in members" - :key="member.uuid" - class="panel" - :open="members.length == 1"> - - <div slot="trigger" - slot-scope="props" - class="panel-heading" - role="button"> - <b-icon pack="fas" - icon="caret-right"> - </b-icon> - <strong>#{{ member.number }} {{ member.display }}</strong> - </div> - - <div class="panel-block"> - <div style="display: flex; justify-content: space-between; width: 100%;"> - <div style="flex-grow: 1;"> - - <b-field horizontal label="Number"> - {{ member.number }} - </b-field> - - <b-field horizontal label="ID"> - {{ member.id }} - </b-field> - - <b-field horizontal label="Active"> - {{ member.active }} - </b-field> - - <b-field horizontal label="Joined"> - {{ member.joined }} - </b-field> - - <b-field horizontal label="Withdrew" - v-if="member.withdrew"> - {{ member.withdrew }} - </b-field> - - <b-field horizontal label="Person"> - <a v-if="member.person_uuid != person.uuid" - :href="member.view_profile_url"> - {{ member.person_display_name }} - </a> - <span v-if="member.person_uuid == person.uuid"> - {{ member.person_display_name }} - </span> - </b-field> - - </div> - <div class="buttons" style="align-items: start;"> - ${self.render_member_panel_buttons(member)} - </div> - </div> - </div> - </b-collapse> - </div> - - <div v-if="!members.length"> - <p>{{ person.display_name }} has never had a member account.</p> - </div> - - </b-tab-item> -</%def> - -<%def name="render_member_panel_buttons(member)"> - % if request.has_perm('members.view'): - <b-button tag="a" :href="member.view_url"> - View Member - </b-button> - % endif -</%def> - -<%def name="render_customer_tab()"> - <b-tab-item label="Customer" icon-pack="fas" :icon="customers.length ? 'check' : null"> - - <div v-if="customers.length"> - - <div style="display: flex; justify-content: space-between;"> - <p>{{ person.display_name }} is associated with <strong>{{ customers.length }}</strong> customer account(s)</p> - </div> - - <br /> - <b-collapse v-for="customer in customers" - :key="customer.uuid" - class="panel" - :open="customers.length == 1"> - - <div slot="trigger" - slot-scope="props" - class="panel-heading" - role="button"> - <b-icon pack="fas" - icon="caret-right"> - </b-icon> - <strong>#{{ customer.number }} {{ customer.name }}</strong> - </div> - - <div class="panel-block"> - <div style="display: flex; justify-content: space-between; width: 100%;"> - <div style="flex-grow: 1;"> - - <b-field horizontal label="Number"> - {{ customer.number }} - </b-field> - - <b-field horizontal label="ID"> - {{ customer.id }} - </b-field> - - <b-field horizontal label="Name"> - {{ customer.name }} - </b-field> - - <b-field horizontal label="People"> - <ul> - <li v-for="p in customer.people" - :key="p.uuid"> - <a v-if="p.uuid != person.uuid" - :href="p.view_profile_url"> - {{ p.display_name }} - </a> - <span v-if="p.uuid == person.uuid"> - {{ p.display_name }} - </span> - </li> - </ul> - </b-field> - - <b-field horizontal label="Address" - v-for="address in customer.addresses" - :key="address.uuid"> - {{ address.display }} - </b-field> - - </div> - <div class="buttons" style="align-items: start;"> - ${self.render_customer_panel_buttons(customer)} - </div> - </div> - </div> - </b-collapse> - </div> - - <div v-if="!customers.length"> - <p>{{ person.display_name }} has never had a customer account.</p> - </div> - - </b-tab-item> <!-- Customer --> -</%def> - -<%def name="render_customer_panel_buttons(customer)"> - % if request.has_perm('customers.view'): - <b-button tag="a" :href="customer.view_url"> - View Customer - </b-button> - % endif -</%def> - -<%def name="render_employee_tab_template()"> - <script type="text/x-template" id="employee-tab-template"> - <div> - <div style="display: flex; justify-content: space-between;"> - - <div style="flex-grow: 1;"> - - <div v-if="employee.uuid"> - - <b-field horizontal label="Employee ID"> - <div class="level"> - <div class="level-left"> - <div class="level-item"> - <span>{{ employee.id }}</span> - </div> - % if request.has_perm('employees.edit'): - <div class="level-item"> - <b-button type="is-primary" - icon-pack="fas" - icon-left="edit" - @click="initEditEmployeeID()"> - Edit ID - </b-button> - <b-modal has-modal-card - :active.sync="showEditEmployeeIDDialog"> - <div class="modal-card"> - - <header class="modal-card-head"> - <p class="modal-card-title">Employee ID</p> - </header> - - <section class="modal-card-body"> - <b-field label="Employee ID"> - <b-input v-model="newEmployeeID"></b-input> - </b-field> - </section> - - <footer class="modal-card-foot"> - <b-button @click="showEditEmployeeIDDialog = false"> - Cancel - </b-button> - <b-button type="is-primary" - icon-pack="fas" - icon-left="save" - :disabled="updatingEmployeeID" - @click="updateEmployeeID()"> - {{ editEmployeeIDSaveButtonText }} - </b-button> - </footer> - </div> - </b-modal> - </div> - % endif - </div> - </div> - </b-field> - - <b-field horizontal label="Employee Status"> - <span>{{ employee.status_display }}</span> - </b-field> - - <b-field horizontal label="Start Date"> - <span>{{ employee.start_date }}</span> - </b-field> - - <b-field horizontal label="End Date"> - <span>{{ employee.end_date }}</span> - </b-field> - - <br /> - <p><strong>Employee History</strong></p> - <br /> - - <b-table :data="employeeHistory"> - <template slot-scope="props"> - - <b-table-column field="start_date" label="Start Date"> - {{ props.row.start_date }} - </b-table-column> - - <b-table-column field="end_date" label="End Date"> - {{ props.row.end_date }} - </b-table-column> - - % if request.has_perm('people_profile.edit_employee_history'): - <b-table-column field="actions" label="Actions"> - <a href="#" @click.prevent="editEmployeeHistory(props.row)"> - <i class="fas fa-edit"></i> - Edit - </a> - </b-table-column> - % endif - - </template> - </b-table> - - </div> - - <p v-if="!employee.uuid"> - ${person} has never been an employee. - </p> - - </div> - - <div> - <div class="buttons"> - - % if request.has_perm('people_profile.toggle_employee'): - - <b-button v-if="!employee.current" - type="is-primary" - @click="startEmployeeInit()"> - ${person} is now an Employee - </b-button> - - <b-button v-if="employee.current" - type="is-primary" - @click="showStopEmployeeDialog = true"> - ${person} is no longer an Employee - </b-button> - - <b-modal has-modal-card - :active.sync="startEmployeeShowDialog"> - <div class="modal-card"> - - <header class="modal-card-head"> - <p class="modal-card-title">Employee Start</p> - </header> - - <section class="modal-card-body"> - <b-field label="Employee Number"> - <b-input v-model="employeeID"></b-input> - </b-field> - <b-field label="Start Date"> - <tailbone-datepicker v-model="employeeStartDate"></tailbone-datepicker> - </b-field> - </section> - - <footer class="modal-card-foot"> - <b-button @click="startEmployeeShowDialog = false"> - Cancel - </b-button> - <once-button type="is-primary" - @click="startEmployee()" - :disabled="!employeeStartDate" - text="Save"> - </once-button> - </footer> - </div> - </b-modal> - - <b-modal has-modal-card - :active.sync="showStopEmployeeDialog"> - <div class="modal-card"> - - <header class="modal-card-head"> - <p class="modal-card-title">Employee End</p> - </header> - - <section class="modal-card-body"> - <b-field label="End Date" - :type="employeeEndDate ? null : 'is-danger'"> - <tailbone-datepicker v-model="employeeEndDate"></tailbone-datepicker> - </b-field> - <b-field label="Revoke Internal App Access"> - <b-checkbox v-model="employeeRevokeAccess"> - </b-checkbox> - </b-field> - </section> - - <footer class="modal-card-foot"> - <b-button @click="showStopEmployeeDialog = false"> - Cancel - </b-button> - <once-button type="is-primary" - @click="endEmployee()" - :disabled="!employeeEndDate" - text="Save"> - </once-button> - </footer> - </div> - </b-modal> - % endif - - % if request.has_perm('people_profile.edit_employee_history'): - <b-modal has-modal-card - :active.sync="showEditEmployeeHistoryDialog"> - <div class="modal-card"> - - <header class="modal-card-head"> - <p class="modal-card-title">Edit Employee History</p> - </header> - - <section class="modal-card-body"> - <b-field label="Start Date"> - <tailbone-datepicker v-model="employeeHistoryStartDate"></tailbone-datepicker> - </b-field> - <b-field label="End Date"> - <tailbone-datepicker v-model="employeeHistoryEndDate" - :disabled="!employeeHistoryEndDateRequired"> - </tailbone-datepicker> - </b-field> - </section> - - <footer class="modal-card-foot"> - <b-button @click="showEditEmployeeHistoryDialog = false"> - Cancel - </b-button> - <once-button type="is-primary" - @click="saveEmployeeHistory()" - :disabled="!employeeHistoryStartDate || (employeeHistoryEndDateRequired && !employeeHistoryEndDate)" - text="Save"> - </once-button> - </footer> - </div> - </b-modal> - % endif - - % if request.has_perm('employees.view'): - <b-button v-if="employee.view_url" - tag="a" :href="employee.view_url"> - View Employee - </b-button> - % endif - - </div> - </div> - - </div> - </div> - </script> -</%def> - -<%def name="render_employee_tab()"> - <b-tab-item label="Employee" - icon-pack="fas" - :icon="employee.current ? 'check' : null"> - <employee-tab :employee="employee" - :employee-history="employeeHistory" - @employee-updated="employeeUpdated" - @employee-history-updated="employeeHistoryUpdated" - @change-content-title="changeContentTitle"> - </employee-tab> - </b-tab-item> -</%def> - -<%def name="render_user_tab()"> - <b-tab-item label="User" ${'icon="check" icon-pack="fas"' if person.users else ''|n}> - % if person.users: - <p>${person} is associated with <strong>${len(person.users)}</strong> user account(s)</p> - <br /> - <div id="users-accordion"> - % for user in person.users: - - <b-collapse class="panel" - ## TODO: what's up with aria-id here? - ## aria-id="contentIdForA11y2" - > - - <div - slot="trigger" - class="panel-heading" - role="button" - ## TODO: what's up with aria-id here? - ## aria-controls="contentIdForA11y2" - > - <strong>${user.username}</strong> - </div> - - <div class="panel-block"> - - <div style="display: flex; justify-content: space-between; width: 100%;"> - - <div> - - <div class="field-wrapper id"> - <div class="field-row"> - <label>Username</label> - <div class="field"> - ${user.username} - </div> - </div> - </div> - - </div> - - <div> - % if request.has_perm('users.view'): - ${h.link_to("View User", url('users.view', uuid=user.uuid), class_='button')} - % endif - </div> - - </div> - - </div> - </b-collapse> - % endfor - </div> - - % else: - <p>${person} has never been a user.</p> - % endif - </b-tab-item><!-- User --> -</%def> - -<%def name="render_profile_tabs()"> - ${self.render_personal_tab()} - ${self.render_customer_tab()} - ${self.render_member_tab()} - ${self.render_employee_tab()} - ${self.render_user_tab()} -</%def> - -<%def name="render_profile_info_template()"> - <script type="text/x-template" id="profile-info-template"> - <div> - <b-tabs v-model="activeTab" type="is-boxed"> - ${self.render_profile_tabs()} - </b-tabs> - </div> - </script> -</%def> - -<%def name="render_this_page_template()"> - ${parent.render_this_page_template()} - ${self.render_personal_tab_template()} - ${self.render_employee_tab_template()} - ${self.render_profile_info_template()} -</%def> - -<%def name="declare_personal_tab_vars()"> - <script type="text/javascript"> - - let PersonalTabData = { - - editNameShowDialog: false, - personFirstName: null, - personMiddleName: null, - personLastName: null, - - editAddressShowDialog: false, - personStreet1: null, - personStreet2: null, - personCity: null, - personState: null, - personZipcode: null, - personInvalidAddress: false, - - editPhoneShowDialog: false, - phoneUUID: null, - phoneType: null, - phoneNumber: null, - phonePreferred: false, - savingPhone: false, - - editEmailShowDialog: false, - emailUUID: null, - emailType: null, - emailAddress: null, - emailPreferred: null, - emailInvalid: false, - editEmailSaving: false, - } - - let PersonalTab = { - template: '#personal-tab-template', - mixins: [SubmitMixin], - props: { - person: Object, - member: Object, - phoneTypeOptions: Array, - emailTypeOptions: Array, - maxLengths: Object, - }, - computed: { - % if request.has_perm('people_profile.edit_person'): - editNameSaveDisabled: function() { - - // first and last name are required - if (!this.personFirstName || !this.personLastName) { - return true - } - - // otherwise don't disable; let user save - return false - }, - - editAddressSaveDisabled: function() { - - // TODO: should require anything here? - - // otherwise don't disable; let user save - return false - }, - - editPhoneSaveText() { - if (this.savingPhone) { - return "Working..." - } - return "Save" - }, - - editPhoneSaveDisabled: function() { - if (this.savingPhone) { - return true - } - - // phone type is required - if (!this.phoneType) { - return true - } - - // phone number is required - if (!this.phoneNumber) { - return true - } - - // otherwise don't disable; let user save - return false - }, - - editEmailSaveText() { - if (this.editEmailSaving) { - return "Working, please wait..." - } - return "Save" - }, - - editEmailSaveDisabled: function() { - - // disable if currently submitting form - if (this.editEmailSaving) { - return true - } - - // email type is required - if (!this.emailType) { - return true - } - - // email address is required - if (!this.emailAddress) { - return true - } - - // otherwise don't disable; let user save - return false - }, - % endif - }, - methods: { - - changeContentTitle(newTitle) { - this.$emit('change-content-title', newTitle) - }, - - % if request.has_perm('people_profile.edit_person'): - - editNameAllowed() { - return true - }, - - editNameInit() { - this.personFirstName = this.person.first_name - this.personMiddleName = this.person.middle_name - this.personLastName = this.person.last_name - this.editNameShowDialog = true - }, - - editNameSave() { - let url = '${url('people.profile_edit_name', uuid=person.uuid)}' - - let params = { - first_name: this.personFirstName, - middle_name: this.personMiddleName, - last_name: this.personLastName, - } - - let that = this - this.submitData(url, params, function(response) { - that.$emit('person-updated', response.data.person) - that.editNameShowDialog = false - // TODO: not sure this is standard upstream, or just in bespoke? - if (response.data.dynamic_content_title) { - that.$emit('change-content-title', response.data.dynamic_content_title) - } - }) - }, - - editAddressInit() { - let address = this.person.address - this.personStreet1 = address ? address.street : null - this.personStreet2 = address ? address.street2 : null - this.personCity = address ? address.city : null - this.personState = address ? address.state : null - this.personZipcode = address ? address.zipcode : null - this.personInvalidAddress = address ? address.invalid : false - this.editAddressShowDialog = true - }, - - editAddressSave() { - let url = '${url('people.profile_edit_address', uuid=person.uuid)}' - - let params = { - street: this.personStreet1, - street2: this.personStreet2, - city: this.personCity, - state: this.personState, - zipcode: this.personZipcode, - invalid: this.personInvalidAddress, - } - - let that = this - this.submitData(url, params, function(response) { - that.$emit('person-updated', response.data.person) - that.editAddressShowDialog = false - }) - }, - - addPhoneInit() { - this.editPhoneInit({ - uuid: null, - type: 'Home', - number: null, - preferred: false, - }) - }, - - editPhoneInit(phone) { - this.phoneUUID = phone.uuid - this.phoneType = phone.type - this.phoneNumber = phone.number - this.phonePreferred = phone.preferred - this.editPhoneShowDialog = true - this.$nextTick(function() { - this.$refs.editPhoneInput.focus() - }) - }, - - editPhoneSave() { - this.savingPhone = true - - let url - let params = { - phone_number: this.phoneNumber, - phone_type: this.phoneType, - phone_preferred: this.phonePreferred, - } - - if (this.phoneUUID) { - url = '${url('people.profile_update_phone', uuid=person.uuid)}' - params.phone_uuid = this.phoneUUID - } else { - url = '${url('people.profile_add_phone', uuid=person.uuid)}' - } - - let that = this - this.submitData(url, params, function(response) { - that.$emit('person-updated', response.data.person) - that.editPhoneShowDialog = false - that.savingPhone = false - }) - }, - - deletePhone(phone) { - let url = '${url('people.profile_delete_phone', uuid=person.uuid)}' - - let params = { - phone_uuid: phone.uuid, - } - - let that = this - this.submitData(url, params, function(response) { - that.$emit('person-updated', response.data.person) - that.$buefy.toast.open({ - message: "Phone number was deleted.", - type: 'is-info', - duration: 3000, // 3 seconds - }) - }) - }, - - setPreferredPhone(phone) { - let url = '${url('people.profile_set_preferred_phone', uuid=person.uuid)}' - - let params = { - phone_uuid: phone.uuid, - } - - let that = this - this.submitData(url, params, function(response) { - that.$emit('person-updated', response.data.person) - that.$buefy.toast.open({ - message: "Phone preference updated!", - type: 'is-info', - duration: 3000, // 3 seconds - }) - }) - }, - - addEmailInit() { - this.editEmailInit({ - uuid: null, - type: 'Home', - address: null, - invalid: false, - preferred: false, - }) - }, - - editEmailInit(email) { - this.emailUUID = email.uuid - this.emailType = email.type - this.emailAddress = email.address - this.emailInvalid = email.invalid - this.emailPreferred = email.preferred - this.editEmailShowDialog = true - this.$nextTick(function() { - this.$refs.editEmailInput.focus() - }) - }, - - editEmailSave() { - this.editEmailSaving = true - - let url = null - let params = { - email_address: this.emailAddress, - email_type: this.emailType, - } - - if (this.emailUUID) { - url = '${url('people.profile_update_email', uuid=person.uuid)}' - params.email_uuid = this.emailUUID - params.email_invalid = this.emailInvalid - } else { - url = '${url('people.profile_add_email', uuid=person.uuid)}' - params.email_preferred = this.emailPreferred - } - - let that = this - this.submitData(url, params, function(response) { - that.$emit('person-updated', response.data.person) - that.editEmailShowDialog = false - that.editEmailSaving = false - }, function(error) { - that.editEmailSaving = false - }) - }, - - deleteEmail(email) { - let url = '${url('people.profile_delete_email', uuid=person.uuid)}' - - let params = { - email_uuid: email.uuid, - } - - let that = this - this.submitData(url, params, function(response) { - that.$emit('person-updated', response.data.person) - that.$buefy.toast.open({ - message: "Email address was deleted.", - type: 'is-info', - duration: 3000, // 3 seconds - }) - }) - }, - - setPreferredEmail(email) { - let url = '${url('people.profile_set_preferred_email', uuid=person.uuid)}' - - let params = { - email_uuid: email.uuid, - } - - let that = this - this.submitData(url, params, function(response) { - that.$emit('person-updated', response.data.person) - that.$buefy.toast.open({ - message: "Email preference updated!", - type: 'is-info', - duration: 3000, // 3 seconds - }) - }) - }, - - % endif - }, - } - - </script> -</%def> - -<%def name="make_personal_tab_component()"> - ${self.declare_personal_tab_vars()} - <script type="text/javascript"> - - PersonalTab.data = function() { return PersonalTabData } - Vue.component('personal-tab', PersonalTab) - - </script> -</%def> - -<%def name="declare_employee_tab_vars()"> - <script type="text/javascript"> - - let EmployeeTabData = { - - startEmployeeShowDialog: false, - employeeID: null, - employeeStartDate: null, - showStopEmployeeDialog: false, - employeeEndDate: null, - employeeRevokeAccess: false, - showEditEmployeeHistoryDialog: false, - employeeHistoryUUID: null, - employeeHistoryStartDate: null, - employeeHistoryEndDate: null, - employeeHistoryEndDateRequired: false, - - % if request.has_perm('employees.edit'): - showEditEmployeeIDDialog: false, - newEmployeeID: null, - updatingEmployeeID: false, - % endif - } - - let EmployeeTab = { - template: '#employee-tab-template', - mixins: [SubmitMixin], - props: { - employee: Object, - employeeHistory: Array, - }, - - computed: { - - % if request.has_perm('employees.edit'): - - editEmployeeIDSaveButtonText() { - if (this.updatingEmployeeID) { - return "Working, please wait..." - } - return "Save" - }, - - % endif - }, - - methods: { - - changeContentTitle(newTitle) { - this.$emit('change-content-title', newTitle) - }, - - % if request.has_perm('employees.edit'): - - initEditEmployeeID() { - this.newEmployeeID = this.employee.id - this.updatingEmployeeID = false - this.showEditEmployeeIDDialog = true - }, - - updateEmployeeID() { - this.updatingEmployeeID = true - - let url = '${url('people.profile_update_employee_id', uuid=instance.uuid)}' - - let params = { - 'employee_id': this.newEmployeeID, - } - - let that = this - this.submitData(url, params, function(response) { - that.$emit('employee-updated', response.data.employee) - that.showEditEmployeeIDDialog = false - that.updatingEmployeeID = false - }) - }, - - % endif - - % if request.has_perm('people_profile.toggle_employee'): - - startEmployeeInit() { - this.employeeID = this.employee.id || null - this.startEmployeeShowDialog = true - }, - - startEmployee() { - let url = '${url('people.profile_start_employee', uuid=person.uuid)}' - - let params = { - id: this.employeeID, - start_date: this.employeeStartDate, - } - - let that = this - this.submitData(url, params, function(response) { - that.startEmployeeSuccess(response.data) - }) - }, - - startEmployeeSuccess(data) { - this.$emit('employee-updated', data.employee) - this.$emit('employee-history-updated', data.employee_history_data) - this.$emit('change-content-title', data.dynamic_content_title) - - // let derived component do more here if needed - this.startEmployeeSuccessExtra(data) - - this.startEmployeeShowDialog = false - }, - - startEmployeeSuccessExtra(data) {}, - - endEmployee() { - let url = '${url('people.profile_end_employee', uuid=person.uuid)}' - - let params = { - end_date: this.employeeEndDate, - revoke_access: this.employeeRevokeAccess, - } - - let that = this - this.submitData(url, params, function(response) { - that.endEmployeeSuccess(response.data) - }) - }, - - endEmployeeSuccess(data) { - this.$emit('employee-updated', data.employee) - this.$emit('employee-history-updated', data.employee_history_data) - this.$emit('change-content-title', data.dynamic_content_title) - - // let derived component do more here if needed - this.startEmployeeSuccessExtra(data) - - this.showStopEmployeeDialog = false - }, - - endEmployeeSuccessExtra(data) {}, - - % endif - - % if request.has_perm('people_profile.edit_employee_history'): - - editEmployeeHistory(row) { - this.employeeHistoryUUID = row.uuid - this.employeeHistoryStartDate = row.start_date - this.employeeHistoryEndDate = row.end_date - this.employeeHistoryEndDateRequired = !!row.end_date - this.showEditEmployeeHistoryDialog = true - }, - - saveEmployeeHistory() { - let url = '${url('people.profile_edit_employee_history', uuid=person.uuid)}' - - let params = { - uuid: this.employeeHistoryUUID, - start_date: this.employeeHistoryStartDate, - end_date: this.employeeHistoryEndDate, - } - - let that = this - this.submitData(url, params, function(response) { - that.$emit('employee-updated', response.data.employee) - that.$emit('employee-history-updated', response.data.employee_history_data) - that.showEditEmployeeHistoryDialog = false - }) - }, - - % endif - }, - } - - </script> -</%def> - -<%def name="make_employee_tab_component()"> - ${self.declare_employee_tab_vars()} - <script type="text/javascript"> - - EmployeeTab.data = function() { return EmployeeTabData } - Vue.component('employee-tab', EmployeeTab) - - </script> -</%def> - -<%def name="declare_profile_info_vars()"> - <script type="text/javascript"> - - let ProfileInfoData = { - activeTab: 0, - person: ${json.dumps(person_data)|n}, - customers: ${json.dumps(customers_data)|n}, - member: null, // TODO - members: ${json.dumps(members_data)|n}, - employee: ${json.dumps(employee_data)|n}, - employeeHistory: ${json.dumps(employee_history_data)|n}, - phoneTypeOptions: ${json.dumps(phone_type_options)|n}, - emailTypeOptions: ${json.dumps(email_type_options)|n}, - maxLengths: ${json.dumps(max_lengths)|n}, - } - - let ProfileInfo = { - template: '#profile-info-template', - mixins: [FormPosterMixin], - computed: {}, - methods: { - personUpdated(person) { - this.person = person - }, - employeeUpdated(employee) { - this.employee = employee - }, - employeeHistoryUpdated(employeeHistory) { - this.employeeHistory = employeeHistory - }, - changeContentTitle(newTitle) { - this.$emit('change-content-title', newTitle) - }, - }, - } - - </script> -</%def> - -<%def name="make_profile_info_component()"> - ${self.declare_profile_info_vars()} - <script type="text/javascript"> - - ProfileInfo.data = function() { return ProfileInfoData } - - Vue.component('profile-info', ProfileInfo) - - </script> -</%def> - -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> - - ThisPage.methods.changeContentTitle = function(newTitle) { - this.$emit('change-content-title', newTitle) - } - - var SubmitMixin = { - data() { - return { - csrftoken: ${json.dumps(request.session.get_csrf_token() or request.session.new_csrf_token())|n}, - } - }, - - methods: { - submitData(url, params, success, failure) { - let headers = { - 'X-CSRF-TOKEN': this.csrftoken, - } - this.$http.post(url, params, {headers: headers}).then((response) => { - if (response.data.success) { - if (success) { - success(response) - } - } else { - this.$buefy.toast.open({ - message: "Save failed: " + (response.data.error || "(unknown error)"), - type: 'is-danger', - duration: 4000, // 4 seconds - }) - if (failure) { - failure() - } - } - }).catch((error) => { - this.$buefy.toast.open({ - message: "Save failed: (unknown error)", - type: 'is-danger', - duration: 4000, // 4 seconds - }) - if (failure) { - failure() - } - }) - }, - }, - } - - </script> -</%def> - -<%def name="make_this_page_component()"> - ${parent.make_this_page_component()} - ${self.make_personal_tab_component()} - ${self.make_employee_tab_component()} - ${self.make_profile_info_component()} -</%def> - - -${parent.body()} diff --git a/tailbone/templates/poser/reports/view.mako b/tailbone/templates/poser/reports/view.mako index aac0c7ae..cb8b51aa 100644 --- a/tailbone/templates/poser/reports/view.mako +++ b/tailbone/templates/poser/reports/view.mako @@ -62,19 +62,13 @@ <br /> </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} % if master.has_perm('replace'): - <script type="text/javascript"> - - ${form.component_studly}Data.showUploadForm = false - - ${form.component_studly}Data.uploadFile = null - - ${form.component_studly}Data.uploadSubmitting = false - - </script> + <script> + ${form.vue_component}Data.showUploadForm = false + ${form.vue_component}Data.uploadFile = null + ${form.vue_component}Data.uploadSubmitting = false + </script> % endif </%def> - -${parent.body()} diff --git a/tailbone/templates/poser/setup.mako b/tailbone/templates/poser/setup.mako index 8d01bb33..239e7db2 100644 --- a/tailbone/templates/poser/setup.mako +++ b/tailbone/templates/poser/setup.mako @@ -118,14 +118,9 @@ % endif </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> - +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> ThisPageData.setupSubmitting = false - </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/poser/views/configure.mako b/tailbone/templates/poser/views/configure.mako index f4d75779..cdde15c5 100644 --- a/tailbone/templates/poser/views/configure.mako +++ b/tailbone/templates/poser/views/configure.mako @@ -9,7 +9,7 @@ % for topkey, topgroup in sorted(view_settings.items(), key=lambda itm: 'aaaa' if itm[0] == 'rattail' else itm[0]): <h3 class="block is-size-3">Views for: ${topkey}</h3> - % for group_key, group in six.iteritems(topgroup): + % for group_key, group in topgroup.items(): <h4 class="block is-size-4">${group_key.capitalize()}</h4> % for key, label in group: ${self.simple_flag(key, label)} diff --git a/tailbone/templates/principal/find_by_perm.mako b/tailbone/templates/principal/find_by_perm.mako index f055ce5d..ddc44e3d 100644 --- a/tailbone/templates/principal/find_by_perm.mako +++ b/tailbone/templates/principal/find_by_perm.mako @@ -3,154 +3,104 @@ <%def name="title()">Find ${model_title_plural} by Permission</%def> -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - % if not use_buefy: - <script type="text/javascript"> - - <% gcount = len(permissions) %> - var permissions_by_group = { - % for g, (gkey, group) in enumerate(permissions, 1): - <% pcount = len(group['perms']) %> - '${gkey}': { - % for p, (pkey, perm) in enumerate(group['perms'], 1): - '${pkey}': "${perm['label']}"${',' if p < pcount else ''} - % endfor - }${',' if g < gcount else ''} - % endfor - }; - - $(function() { - - $('#permission_group').selectmenu({ - change: function(event, ui) { - var perms = $('#permission'); - perms.find('option:first').siblings('option').remove(); - $.each(permissions_by_group[ui.item.value], function(key, label) { - perms.append($('<option value="' + key + '">' + label + '</option>')); - }); - perms.selectmenu('refresh'); - } - }); - - $('#permission').selectmenu(); - - $('#find-by-perm-form').submit(function() { - $('.grid').remove(); - $(this).find('#submit').button('disable').button('option', 'label', "Searching, please wait..."); - }); - - }); - - </script> - % endif -</%def> - <%def name="page_content()"> - % if use_buefy: - <find-principals :permission-groups="permissionGroups" - :sorted-groups="sortedGroups"> - </find-principals> - % else: - ## not buefy - ${h.form(request.current_route_url(), id='find-by-perm-form')} - ${h.csrf_token(request)} - - <div class="form"> - ${self.wtfield(form, 'permission_group')} - ${self.wtfield(form, 'permission')} - <div class="buttons"> - ${h.submit('submit', "Find {}".format(model_title_plural))} - </div> - </div> - - ${h.end_form()} - - % if principals is not None: - <div class="grid half"> - <br /> - <h2>${model_title_plural} with that permission (${len(principals)} total):</h2> - ${self.principal_table()} - </div> - % endif - % endif + <br /> + <find-principals :permission-groups="permissionGroups" + :sorted-groups="sortedGroups"> + </find-principals> </%def> -<%def name="render_this_page_template()"> - ${parent.render_this_page_template()} +<%def name="principal_table()"> + <div + style="width: 50%;" + > + ${grid.render_table_element(data_prop='principalsData')|n} + </div> +</%def> + +<%def name="render_vue_templates()"> + ${parent.render_vue_templates()} <script type="text/x-template" id="find-principals-template"> - <div class="app-wrapper"> + <div> - ${h.form(request.current_route_url(), **{'@submit': 'submitForm'})} - ${h.csrf_token(request)} + ${h.form(request.url, method='GET', **{'@submit': 'formSubmitting = true'})} + <div style="margin-left: 10rem; max-width: 50%;"> + + ${h.hidden('permission_group', **{':value': 'selectedGroup'})} + <b-field label="Permission Group" horizontal> + <b-autocomplete v-if="!selectedGroup" + ref="permissionGroupAutocomplete" + v-model="permissionGroupTerm" + :data="permissionGroupChoices" + :custom-formatter="filtr => filtr.label" + open-on-focus + keep-first + icon-pack="fas" + clearable + clear-on-select + expanded + @select="permissionGroupSelect"> + </b-autocomplete> + <b-button v-if="selectedGroup" + @click="permissionGroupReset()"> + {{ permissionGroups[selectedGroup].label }} + </b-button> + </b-field> + + ${h.hidden('permission', **{':value': 'selectedPermission'})} + <b-field label="Permission" horizontal> + <b-autocomplete v-if="!selectedPermission" + ref="permissionAutocomplete" + v-model="permissionTerm" + :data="permissionChoices" + :custom-formatter="filtr => filtr.label" + open-on-focus + keep-first + icon-pack="fas" + clearable + clear-on-select + expanded + @select="permissionSelect"> + </b-autocomplete> + <b-button v-if="selectedPermission" + @click="permissionReset()"> + {{ selectedPermissionLabel }} + </b-button> + </b-field> + + <b-field horizontal> + <div class="buttons" style="margin-top: 1rem;"> + <once-button tag="a" + href="${request.path_url}" + text="Reset Form"> + </once-button> + <b-button type="is-primary" + native-type="submit" + icon-pack="fas" + icon-left="search" + :disabled="formSubmitting"> + {{ formSubmitting ? "Working, please wait..." : "Find ${model_title_plural}" }} + </b-button> + </div> + </b-field> - <div class="field-wrapper"> - <label for="permission_group">${form['permission_group'].label}</label> - <div class="field"> - <b-select name="permission_group" - id="permission_group" - v-model="selectedGroup" - @input="selectGroup"> - <option v-for="groupkey in sortedGroups" - :key="groupkey" - :value="groupkey"> - {{ permissionGroups[groupkey].label }} - </option> - </b-select> </div> - </div> - - <div class="field-wrapper"> - <label for="permission">${form['permission'].label}</label> - <div class="field"> - <b-select name="permission" - v-model="selectedPermission"> - <option v-for="perm in groupPermissions" - :key="perm.permkey" - :value="perm.permkey"> - {{ perm.label }} - </option> - </b-select> - </div> - </div> - - <div class="buttons"> - <b-button type="is-primary" - native-type="submit" - :disabled="formSubmitting"> - {{ formButtonText }} - </b-button> - </div> - ${h.end_form()} % if principals is not None: - <div class="grid half"> - <br /> - <h2>Found ${len(principals)} ${model_title_plural} with permission: ${selected_permission}</h2> - ${self.principal_table()} - </div> + <br /> + <p class="block"> + Found ${len(principals)} ${model_title_plural} with permission: + <span class="has-text-weight-bold">${selected_permission}</span> + </p> + ${self.principal_table()} % endif - </div><!-- app-wrapper --> + </div> </script> -</%def> - -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} <script type="text/javascript"> - ThisPageData.permissionGroups = ${json.dumps(buefy_perms)|n} - ThisPageData.sortedGroups = ${json.dumps(buefy_sorted_groups)|n} - - </script> -</%def> - -<%def name="make_this_page_component()"> - ${parent.make_this_page_component()} - <script type="text/javascript"> - - Vue.component('find-principals', { + const FindPrincipals = { template: '#find-principals-template', props: { permissionGroups: Object, @@ -158,37 +108,139 @@ }, data() { return { - groupPermissions: ${json.dumps(buefy_perms.get(selected_group, {}).get('permissions', []))|n}, + groupPermissions: ${json.dumps(perms_data.get(selected_group, {}).get('permissions', []))|n}, + permissionGroupTerm: '', + permissionTerm: '', selectedGroup: ${json.dumps(selected_group)|n}, - % if selected_permission: selectedPermission: ${json.dumps(selected_permission)|n}, - % elif selected_group in buefy_perms: - selectedPermission: ${json.dumps(buefy_perms[selected_group]['permissions'][0]['permkey'])|n}, - % else: - selectedPermission: null, - % endif - formButtonText: "Find ${model_title_plural}", + selectedPermissionLabel: ${json.dumps(selected_permission_label or '')|n}, formSubmitting: false, + principalsData: ${json.dumps(principals_data)|n}, } }, - methods: { - selectGroup(groupkey) { + computed: { - // re-populate Permission dropdown, auto-select first option - this.groupPermissions = this.permissionGroups[groupkey].permissions - this.selectedPermission = this.groupPermissions[0].permkey + permissionGroupChoices() { + + // collect all groups + let choices = [] + for (let groupkey of this.sortedGroups) { + choices.push(this.permissionGroups[groupkey]) + } + + // parse list of search terms + let terms = [] + for (let term of this.permissionGroupTerm.toLowerCase().split(' ')) { + term = term.trim() + if (term) { + terms.push(term) + } + } + + // filter groups by search terms + choices = choices.filter(option => { + let label = option.label.toLowerCase() + for (let term of terms) { + if (label.indexOf(term) < 0) { + return false + } + } + return true + }) + + return choices }, - submitForm() { - this.formSubmitting = true - this.formButtonText = "Working, please wait..." - } + permissionChoices() { + + // collect all permissions for current group + let choices = this.groupPermissions + + // parse list of search terms + let terms = [] + for (let term of this.permissionTerm.toLowerCase().split(' ')) { + term = term.trim() + if (term) { + terms.push(term) + } + } + + // filter permissions by search terms + choices = choices.filter(option => { + let label = option.label.toLowerCase() + for (let term of terms) { + if (label.indexOf(term) < 0) { + return false + } + } + return true + }) + + return choices + }, + }, + + methods: { + + navigateTo(url) { + location.href = url + }, + + permissionGroupSelect(option) { + this.selectedPermission = null + this.selectedPermissionLabel = null + if (option) { + this.selectedGroup = option.groupkey + this.groupPermissions = this.permissionGroups[option.groupkey].permissions + this.$nextTick(() => { + this.$refs.permissionAutocomplete.focus() + }) + } + }, + + permissionGroupReset() { + this.selectedGroup = null + this.selectedPermission = null + this.selectedPermissionLabel = '' + this.$nextTick(() => { + this.$refs.permissionGroupAutocomplete.focus() + }) + }, + + permissionSelect(option) { + if (option) { + this.selectedPermission = option.permkey + this.selectedPermissionLabel = option.label + } + }, + + permissionReset() { + this.selectedPermission = null + this.selectedPermissionLabel = null + this.permissionTerm = '' + this.$nextTick(() => { + this.$refs.permissionAutocomplete.focus() + }) + }, } - }) + } </script> </%def> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> + ThisPageData.permissionGroups = ${json.dumps(perms_data)|n} + ThisPageData.sortedGroups = ${json.dumps(sorted_groups_data)|n} + </script> +</%def> -${parent.body()} +<%def name="make_vue_components()"> + ${parent.make_vue_components()} + <script> + Vue.component('find-principals', FindPrincipals) + <% request.register_component('find-principals', 'FindPrincipals') %> + </script> +</%def> diff --git a/tailbone/templates/principal/index.mako b/tailbone/templates/principal/index.mako index 4ed3ba5b..fa806455 100644 --- a/tailbone/templates/principal/index.mako +++ b/tailbone/templates/principal/index.mako @@ -3,8 +3,8 @@ <%def name="context_menu_items()"> ${parent.context_menu_items()} - % if request.has_perm('{}.find_by_perm'.format(permission_prefix)): - <li>${h.link_to("Find {} with Permission X".format(model_title_plural), url('{}.find_by_perm'.format(route_prefix)))}</li> + % if master.has_perm('find_by_perm'): + <li>${h.link_to(f"Find {model_title_plural} by Permission", url(f'{route_prefix}.find_by_perm'))}</li> % endif </%def> diff --git a/tailbone/templates/products/batch.mako b/tailbone/templates/products/batch.mako index be055b50..db029e5a 100644 --- a/tailbone/templates/products/batch.mako +++ b/tailbone/templates/products/batch.mako @@ -7,66 +7,22 @@ <li>${h.link_to("Back to Products", url('products'))}</li> </%def> -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - % if not use_buefy: - <script type="text/javascript"> - $(function() { - - $('select[name="batch_type"]').on('selectmenuchange', function(event, ui) { - $('.params-wrapper').hide(); - $('.params-wrapper.' + ui.item.value).show(); - }); - - $('.params-wrapper.' + $('select[name="batch_type"]').val()).show(); - - }); - </script> - % endif -</%def> - -<%def name="extra_styles()"> - ${parent.extra_styles()} - % if not use_buefy: - <style type="text/css"> - .params-wrapper { - display: none; - } - </style> - % endif -</%def> - <%def name="render_deform_field(form, field)"> - % if use_buefy: - <b-field horizontal - % if field.description: - message="${field.description}" - % endif - % if field.error: - type="is-danger" - :message='${form.messages_json(field.error.messages())|n}' - % endif - label="${field.title}"> - ${field.serialize(use_buefy=True)|n} - </b-field> - % else: - <div class="field-wrapper ${field.name}"> - <div class="field-row"> - <label for="${field.oid}">${field.title}</label> - <div class="field"> - ${field.serialize()|n} - </div> - </div> - </div> - % endif + <b-field horizontal + % if field.description: + message="${field.description}" + % endif + % if field.error: + type="is-danger" + :message='${form.messages_json(field.error.messages())|n}' + % endif + label="${field.title}"> + ${field.serialize()|n} + </b-field> </%def> <%def name="render_form_innards()"> - % if use_buefy: - ${h.form(request.current_route_url(), **{'@submit': 'submit{}'.format(form.component_studly)})} - % else: - ${h.form(request.current_route_url(), class_='autodisable')} - % endif + ${h.form(request.current_route_url(), **{'@submit': 'submit{}'.format(form.vue_component)})} ${h.csrf_token(request)} <section> @@ -74,77 +30,58 @@ ${render_deform_field(form, dform['description'])} ${render_deform_field(form, dform['notes'])} - % for key, pform in six.iteritems(params_forms): - % if use_buefy: - <div v-show="field_model_batch_type == '${key}'"> - % for field in pform.make_deform_form(): - ${render_deform_field(pform, field)} - % endfor - </div> - % else: - <div class="params-wrapper ${key}"> - ## TODO: hacky to use deform? at least is explicit.. - % for field in pform.make_deform_form(): - ${render_deform_field(pform, field)} - % endfor - </div> - % endif + % for key, pform in params_forms.items(): + <div v-show="field_model_batch_type == '${key}'"> + % for field in pform.make_deform_form(): + ${render_deform_field(pform, field)} + % endfor + </div> % endfor </section> <br /> <div class="buttons"> - % if use_buefy: - <b-button type="is-primary" - native-type="submit" - :disabled="${form.component_studly}Submitting"> - {{ ${form.component_studly}ButtonText }} - </b-button> - <b-button tag="a" href="${url('products')}"> - Cancel - </b-button> - % else: - ${h.submit('make-batch', "Create Batch")} - ${h.link_to("Cancel", url('products'), class_='button')} - % endif + <b-button type="is-primary" + native-type="submit" + :disabled="${form.vue_component}Submitting"> + {{ ${form.vue_component}ButtonText }} + </b-button> + <b-button tag="a" href="${url('products')}"> + Cancel + </b-button> </div> ${h.end_form()} </%def> -<%def name="render_form()"> - % if use_buefy: - <script type="text/x-template" id="${form.component}-template"> - ${self.render_form_innards()} - </script> - % else: - <div class="form"> - ${self.render_form_innards()} - </div> - % endif +<%def name="render_form_template()"> + <script type="text/x-template" id="${form.vue_tagname}-template"> + ${self.render_form_innards()} + </script> </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <% request.register_component(form.vue_tagname, form.vue_component) %> + <script> - ## TODO: ugh, an awful lot of duplicated code here (from /forms/deform_buefy.mako) + ## TODO: ugh, an awful lot of duplicated code here (from /forms/deform.mako) - let ${form.component_studly} = { - template: '#${form.component}-template', + let ${form.vue_component} = { + template: '#${form.vue_tagname}-template', methods: { ## TODO: deprecate / remove the latter option here % if form.auto_disable_save or form.auto_disable: - submit${form.component_studly}() { - this.${form.component_studly}Submitting = true - this.${form.component_studly}ButtonText = "Working, please wait..." + submit${form.vue_component}() { + this.${form.vue_component}Submitting = true + this.${form.vue_component}ButtonText = "Working, please wait..." } % endif } } - let ${form.component_studly}Data = { + let ${form.vue_component}Data = { ## TODO: ugh, this seems pretty hacky. need to declare some data models ## for various field components to bind to... @@ -159,8 +96,8 @@ ## TODO: deprecate / remove the latter option here % if form.auto_disable_save or form.auto_disable: - ${form.component_studly}Submitting: false, - ${form.component_studly}ButtonText: ${json.dumps(getattr(form, 'submit_label', getattr(form, 'save_label', "Submit")))|n}, + ${form.vue_component}Submitting: false, + ${form.vue_component}ButtonText: ${json.dumps(getattr(form, 'submit_label', getattr(form, 'save_label', "Submit")))|n}, % endif ## TODO: more hackiness, this is for the sake of batch params @@ -178,6 +115,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/products/configure.mako b/tailbone/templates/products/configure.mako index 612b8d36..a43a85d4 100644 --- a/tailbone/templates/products/configure.mako +++ b/tailbone/templates/products/configure.mako @@ -36,12 +36,21 @@ </b-checkbox> </b-field> + <b-field label="POD Image Base URL" + style="max-width: 50%;"> + <b-input name="rattail.pod.pictures.gtin.root_url" + v-model="simpleSettings['rattail.pod.pictures.gtin.root_url']" + :disabled="!simpleSettings['tailbone.products.show_pod_image']" + @input="settingsNeedSaved = true" + expanded /> + </b-field> + </div> <h3 class="block is-size-3">Handling</h3> <div class="block" style="padding-left: 2rem;"> - <b-field message="If set, GPC values like 002XXXXXYYYYY-Z will be converted to 002XXXXX00000-Z for lokkup"> + <b-field message="If set, GPC values like 002XXXXXYYYYY-Z will be converted to 002XXXXX00000-Z for lookup"> <b-checkbox name="rattail.products.convert_type2_for_gpc_lookup" v-model="simpleSettings['rattail.products.convert_type2_for_gpc_lookup']" native-value="true" @@ -50,6 +59,15 @@ </b-checkbox> </b-field> + <b-field message="If set, then "case size" etc. will not be shown."> + <b-checkbox name="rattail.products.units_only" + v-model="simpleSettings['rattail.products.units_only']" + native-value="true" + @input="settingsNeedSaved = true"> + Products only come in units + </b-checkbox> + </b-field> + </div> <h3 class="block is-size-3">Labels</h3> @@ -77,9 +95,9 @@ </div> </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> ThisPage.methods.getTitleForKey = function(key) { switch (key) { @@ -100,6 +118,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/products/index.mako b/tailbone/templates/products/index.mako index 8eada2fc..5ffa9512 100644 --- a/tailbone/templates/products/index.mako +++ b/tailbone/templates/products/index.mako @@ -1,133 +1,26 @@ ## -*- coding: utf-8; -*- <%inherit file="/master/index.mako" /> -<%def name="extra_styles()"> - ${parent.extra_styles()} - % if not use_buefy: - <style type="text/css"> - - table.label-printing th { - font-weight: normal; - padding: 0px 0px 2px 4px; - text-align: left; - } - - table.label-printing td { - padding: 0px 0px 0px 4px; - } - - table.label-printing #label-quantity { - text-align: right; - width: 30px; - } - - div.grid table tbody td.size, - div.grid table tbody td.regular_price_uuid, - div.grid table tbody td.current_price_uuid { - padding-right: 6px; - text-align: right; - } - - div.grid table tbody td.labels { - text-align: center; - } - - </style> - % endif -</%def> - -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - % if not use_buefy and label_profiles and master.has_perm('print_labels'): - <script type="text/javascript"> - - $(function() { - - $('.grid-wrapper .grid-header .tools select').selectmenu(); - - $('.grid-wrapper').on('click', 'a.print_label', function() { - var tr = $(this).parents('tr:first'); - var quantity = $('table.label-printing #label-quantity'); - if (isNaN(quantity.val())) { - alert("You must provide a valid label quantity."); - quantity.select(); - quantity.focus(); - } else { - quantity = quantity.val(); - - var threshold = ${json.dumps(quick_label_speedbump_threshold)|n}; - if (threshold && parseInt(quantity) >= threshold) { - if (!confirm("Are you sure you want to print " + quantity + " labels?")) { - return false; - } - } - - var data = { - product: tr.data('uuid'), - profile: $('#label-profile').val(), - quantity: quantity - }; - $.get('${url('products.print_labels')}', data, function(data) { - if (data.error) { - alert("An error occurred while attempting to print:\n\n" + data.error); - } else if (quantity == '1') { - alert("1 label has been printed."); - } else { - alert(quantity + " labels have been printed."); - } - }); - } - return false; - }); - }); - - </script> - % endif -</%def> - <%def name="grid_tools()"> ${parent.grid_tools()} % if label_profiles and master.has_perm('print_labels'): - % if use_buefy: - <b-field grouped> - <b-field label="Label"> - <b-select v-model="quickLabelProfile"> - % for profile in label_profiles: - <option value="${profile.uuid}"> - ${profile.description} - </option> - % endfor - </b-select> - </b-field> - <b-field label="Qty."> - <b-input v-model="quickLabelQuantity" - ref="quickLabelQuantityInput" - style="width: 4rem;"> - </b-input> - </b-field> - </b-field> - % else: - <table class="label-printing"> - <thead> - <tr> - <th>Label</th> - <th>Qty.</th> - </tr> - </thead> - <tbody> - <td> - <select name="label-profile" id="label-profile"> - % for profile in label_profiles: - <option value="${profile.uuid}">${profile.description}</option> - % endfor - </select> - </td> - <td> - <input type="text" name="label-quantity" id="label-quantity" value="1" /> - </td> - </tbody> - </table> - % endif + <b-field grouped> + <b-field label="Label"> + <b-select v-model="quickLabelProfile"> + % for profile in label_profiles: + <option value="${profile.uuid}"> + ${profile.description} + </option> + % endfor + </b-select> + </b-field> + <b-field label="Qty."> + <b-input v-model="quickLabelQuantity" + ref="quickLabelQuantityInput" + style="width: 4rem;"> + </b-input> + </b-field> + </b-field> % endif </%def> @@ -143,16 +36,16 @@ </${grid.component}> </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} % if label_profiles and master.has_perm('print_labels'): - <script type="text/javascript"> + <script> - ${grid.component_studly}Data.quickLabelProfile = ${json.dumps(label_profiles[0].uuid)|n} - ${grid.component_studly}Data.quickLabelQuantity = 1 - ${grid.component_studly}Data.quickLabelSpeedbumpThreshold = ${json.dumps(quick_label_speedbump_threshold)|n} + ${grid.vue_component}Data.quickLabelProfile = ${json.dumps(label_profiles[0].uuid)|n} + ${grid.vue_component}Data.quickLabelQuantity = 1 + ${grid.vue_component}Data.quickLabelSpeedbumpThreshold = ${json.dumps(quick_label_speedbump_threshold)|n} - ${grid.component_studly}.methods.quickLabelPrint = function(row) { + ${grid.vue_component}.methods.quickLabelPrint = function(row) { let quantity = parseInt(this.quickLabelQuantity) if (isNaN(quantity)) { @@ -190,6 +83,3 @@ </script> % endif </%def> - - -${parent.body()} diff --git a/tailbone/templates/products/lookup.mako b/tailbone/templates/products/lookup.mako index 10620749..bb9590b2 100644 --- a/tailbone/templates/products/lookup.mako +++ b/tailbone/templates/products/lookup.mako @@ -2,8 +2,54 @@ <%def name="tailbone_product_lookup_template()"> <script type="text/x-template" id="tailbone-product-lookup-template"> - <div> - <b-modal :active.sync="showingDialog"> + <div style="width: 100%;"> + <div style="display: flex; gap: 0.5rem;"> + + <b-field :style="{'flex-grow': product ? '0' : '1'}"> + <${b}-autocomplete v-if="!product" + ref="productAutocomplete" + v-model="autocompleteValue" + expanded + placeholder="Enter UPC or brand, description etc." + :data="autocompleteOptions" + % if request.use_oruga: + @input="getAutocompleteOptions" + :formatter="option => option.label" + % else: + @typing="getAutocompleteOptions" + :custom-formatter="option => option.label" + field="value" + % endif + @select="autocompleteSelected" + style="width: 100%;"> + </${b}-autocomplete> + <b-button v-if="product" + @click="clearSelection(true)"> + {{ product.full_description }} + </b-button> + </b-field> + + <b-button type="is-primary" + v-if="!product" + @click="lookupInit()" + icon-pack="fas" + icon-left="search"> + Full Lookup + </b-button> + + <b-button v-if="product" + type="is-primary" + tag="a" target="_blank" + :href="product.url" + :disabled="!product.url" + icon-pack="fas" + icon-left="external-link-alt"> + View Product + </b-button> + + </div> + + <b-modal :active.sync="lookupShowDialog"> <div class="card"> <div class="card-content"> @@ -11,8 +57,10 @@ <b-input v-model="searchTerm" ref="searchTermInput" - @keydown.native="searchTermInputKeydown"> - </b-input> + % if not request.use_oruga: + @keydown.native="searchTermInputKeydown" + % endif + /> <b-button class="control" type="is-primary" @@ -47,81 +95,103 @@ </b-field> - <b-table :data="searchResults" - narrowed - icon-pack="fas" - :loading="searchResultsLoading" - :selected.sync="searchResultSelected"> - <template slot-scope="props"> + <${b}-table :data="searchResults" + narrowed + % if request.use_oruga: + v-model:selected="searchResultSelected" + % else: + :selected.sync="searchResultSelected" + icon-pack="fas" + % endif + :loading="searchResultsLoading"> - <b-table-column label="${request.rattail_config.product_key_title()}" - field="product_key"> - {{ props.row.product_key }} - </b-table-column> + <${b}-table-column label="${request.rattail_config.product_key_title()}" + field="product_key" + v-slot="props"> + {{ props.row.product_key }} + </${b}-table-column> - <b-table-column label="Brand" - field="brand_name"> - {{ props.row.brand_name }} - </b-table-column> + <${b}-table-column label="Brand" + field="brand_name" + v-slot="props"> + {{ props.row.brand_name }} + </${b}-table-column> - <b-table-column label="Description" - field="description"> + <${b}-table-column label="Description" + field="description" + v-slot="props"> + <span :class="{organic: props.row.organic}"> {{ props.row.description }} {{ props.row.size }} - </b-table-column> + </span> + </${b}-table-column> - <b-table-column label="Unit Price" - field="unit_price"> - {{ props.row.unit_price_display }} - </b-table-column> + <${b}-table-column label="Unit Price" + field="unit_price" + v-slot="props"> + {{ props.row.unit_price_display }} + </${b}-table-column> - <b-table-column label="Sale Price" - field="sale_price"> - <span class="has-background-warning"> - {{ props.row.sale_price_display }} - </span> - </b-table-column> + <${b}-table-column label="Sale Price" + field="sale_price" + v-slot="props"> + <span class="has-background-warning"> + {{ props.row.sale_price_display }} + </span> + </${b}-table-column> - <b-table-column label="Sale Ends" - field="sale_ends"> - <span class="has-background-warning"> - {{ props.row.sale_ends_display }} - </span> - </b-table-column> + <${b}-table-column label="Sale Ends" + field="sale_ends" + v-slot="props"> + <span class="has-background-warning"> + {{ props.row.sale_ends_display }} + </span> + </${b}-table-column> - <b-table-column label="Department" - field="department_name"> - {{ props.row.department_name }} - </b-table-column> + <${b}-table-column label="Department" + field="department_name" + v-slot="props"> + {{ props.row.department_name }} + </${b}-table-column> - <b-table-column label="Vendor" - field="vendor_name"> - {{ props.row.vendor_name }} - </b-table-column> + <${b}-table-column label="Vendor" + field="vendor_name" + v-slot="props"> + {{ props.row.vendor_name }} + </${b}-table-column> - <b-table-column label="Actions"> - <a :href="props.row.url" - target="_blank" - class="grid-action"> - <i class="fas fa-external-link-alt"></i> - View - </a> - </b-table-column> + <${b}-table-column label="Actions" + v-slot="props"> + <a :href="props.row.url" + % if not request.use_oruga: + class="grid-action" + % endif + target="_blank"> + % if request.use_oruga: + <span class="icon-text"> + <o-icon icon="external-link-alt" /> + <span>View</span> + </span> + % else: + <i class="fas fa-external-link-alt"></i> + View + % endif + </a> + </${b}-table-column> - </template> - <template slot="empty"> + <template #empty> <div class="content has-text-grey has-text-centered"> <p> <b-icon pack="fas" - icon="fas fa-sad-tear" + icon="sad-tear" size="is-large"> </b-icon> </p> <p>Nothing here.</p> </div> </template> - </b-table> + </${b}-table> <br /> <div class="level"> @@ -150,6 +220,7 @@ </div> </div> </b-modal> + </div> </script> </%def> @@ -159,12 +230,27 @@ const TailboneProductLookup = { template: '#tailbone-product-lookup-template', + props: { + product: { + type: Object, + }, + autocompleteUrl: { + type: String, + default: '${url('products.autocomplete')}', + }, + }, data() { return { - showingDialog: false, + autocompleteValue: '', + autocompleteOptions: [], + + lookupShowDialog: false, searchTerm: null, searchTermLastUsed: null, + % if request.use_oruga: + searchTermInputElement: null, + % endif searchProductKey: true, searchVendorItemCode: true, @@ -178,25 +264,93 @@ searchResultSelected: null, } }, + + % if request.use_oruga: + + mounted() { + this.searchTermInputElement = this.$refs.searchTermInput.$el.querySelector('input') + this.searchTermInputElement.addEventListener('keydown', this.searchTermInputKeydown) + }, + + beforeDestroy() { + this.searchTermInputElement.removeEventListener('keydown', this.searchTermInputKeydown) + }, + + % endif + methods: { - showDialog(term) { + focus() { + if (!this.product) { + this.$refs.productAutocomplete.focus() + } + }, + clearSelection(focus) { + + // clear data + this.autocompleteValue = '' + this.$emit('selected', null) + + // maybe set focus to our (autocomplete) component + if (focus) { + this.$nextTick(() => { + this.focus() + }) + } + }, + + ## TODO: add debounce for oruga? + % if request.use_oruga: + getAutocompleteOptions(entry) { + % else: + getAutocompleteOptions: debounce(function (entry) { + % endif + + // since the `@typing` event from buefy component does not + // "self-regulate" in any way, we a) use `debounce` above, + // but also b) skip the search unless we have at least 3 + // characters of input from user + if (entry.length < 3) { + this.data = [] + return + } + + // and perform the search + this.$http.get(this.autocompleteUrl + '?term=' + encodeURIComponent(entry)) + .then(({ data }) => { + this.autocompleteOptions = data + }).catch((error) => { + this.autocompleteOptions = [] + throw error + }) + % if request.use_oruga: + }, + % else: + }), + % endif + + autocompleteSelected(option) { + this.$emit('selected', { + uuid: option.value, + full_description: option.label, + url: option.url, + image_url: option.image_url, + }) + }, + + lookupInit() { this.searchResultSelected = null + this.lookupShowDialog = true - if (term !== undefined) { - this.searchTerm = term - // perform search if invoked with new term - if (term != this.searchTermLastUsed) { + this.$nextTick(() => { + + this.searchTerm = this.autocompleteValue + if (this.searchTerm != this.searchTermLastUsed) { this.searchTermLastUsed = null this.performSearch() } - } else { - this.searchTerm = this.searchTermLastUsed - } - this.showingDialog = true - this.$nextTick(() => { this.$refs.searchTermInput.focus() }) }, @@ -207,17 +361,6 @@ } }, - cancelDialog() { - this.searchResultSelected = null - this.showingDialog = false - this.$emit('canceled') - }, - - selectResult() { - this.showingDialog = false - this.$emit('selected', this.searchResultSelected) - }, - performSearch() { if (this.searchResultsLoading) { return @@ -248,10 +391,21 @@ this.searchResultsLoading = false }) }, + + selectResult() { + this.lookupShowDialog = false + this.$emit('selected', this.searchResultSelected) + }, + + cancelDialog() { + this.searchResultSelected = null + this.lookupShowDialog = false + }, }, } Vue.component('tailbone-product-lookup', TailboneProductLookup) + <% request.register_component('tailbone-product-lookup', 'TailboneProductLookup') %> </script> </%def> diff --git a/tailbone/templates/products/pending/view.mako b/tailbone/templates/products/pending/view.mako index 90d9c687..72c9c76d 100644 --- a/tailbone/templates/products/pending/view.mako +++ b/tailbone/templates/products/pending/view.mako @@ -1,46 +1,17 @@ ## -*- coding: utf-8; -*- <%inherit file="/master/view.mako" /> +<%namespace name="product_lookup" file="/products/lookup.mako" /> -<%def name="object_helpers()"> - ${parent.object_helpers()} - % if instance.custorder_item_records: - <nav class="panel"> - <p class="panel-heading">Cross-Reference</p> - <div class="panel-block"> - <div style="display: flex; flex-direction: column;"> - <p class="block"> - This ${model_title} is referenced by the following<br /> - Customer Order Items: - </p> - <ul class="list"> - % for item in instance.custorder_item_records: - <li class="list-item"> - ${h.link_to('#{}-{}'.format(item.order.id, item.sequence), url('custorders.items.view', uuid=item.uuid))} - </li> - % endfor - </ul> - </div> - </div> - </nav> +<%def name="page_content()"> + ${parent.page_content()} + + % if master.has_perm('ignore_product') and instance.status_code in (enum.PENDING_PRODUCT_STATUS_PENDING, enum.PENDING_PRODUCT_STATUS_READY): + ${h.form(master.get_action_url('ignore_product', instance), ref='ignoreProductForm')} + ${h.csrf_token(request)} + ${h.end_form()} % endif - % if instance.status_code == enum.PENDING_PRODUCT_STATUS_PENDING and master.has_perm('resolve_product'): - <nav class="panel"> - <p class="panel-heading">Tools</p> - <div class="panel-block"> - <div style="display: flex; flex-direction: column;"> - <div class="buttons"> - <b-button type="is-primary" - @click="resolveProductInit()" - icon-pack="fas" - icon-left="object-ungroup"> - Resolve Product - </b-button> - </div> - </div> - </div> - </nav> - + % if master.has_perm('resolve_product') and instance.status_code in (enum.PENDING_PRODUCT_STATUS_PENDING, enum.PENDING_PRODUCT_STATUS_READY, enum.PENDING_PRODUCT_STATUS_IGNORED): <b-modal has-modal-card :active.sync="resolveProductShowDialog"> <div class="modal-card"> @@ -53,25 +24,24 @@ <section class="modal-card-body"> <p class="block"> - If this Product already exists, you can declare that by + If this product already exists, you can declare that by identifying the record below. </p> <p class="block"> The app will take care of updating any Customer Orders etc. as needed once you declare the match. </p> - <b-field grouped> - <b-field label="Pending"> - <span>${instance.full_description}</span> - </b-field> - <b-field label="Actual Product" expanded> - <tailbone-autocomplete name="product_uuid" - v-model="resolveProductUUID" - ref="resolveProductAutocomplete" - service-url="${url('products.autocomplete')}"> - </tailbone-autocomplete> - </b-field> + <b-field label="Pending Product"> + <span>${instance.full_description}</span> </b-field> + <b-field label="Actual Product" expanded> + <tailbone-product-lookup ref="productLookup" + autocomplete-url="${url('products.autocomplete_special', key='with_key')}" + :product="actualProduct" + @selected="productSelected"> + </tailbone-product-lookup> + </b-field> + ${h.hidden('product_uuid', **{':value': 'resolveProductUUID'})} </section> <footer class="modal-card-foot"> @@ -92,39 +62,69 @@ % endif </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="render_vue_templates()"> + ${parent.render_vue_templates()} + ${product_lookup.tailbone_product_lookup_template()} +</%def> - ThisPageData.resolveProductShowDialog = false - ThisPageData.resolveProductUUID = null - ThisPageData.resolveProductSubmitting = false +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> - ThisPage.computed.resolveProductSubmitDisabled = function() { - if (this.resolveProductSubmitting) { - return true + % if master.has_perm('ignore_product') and instance.status_code in (enum.PENDING_PRODUCT_STATUS_PENDING, enum.PENDING_PRODUCT_STATUS_READY): + + ThisPage.methods.ignoreProductInit = function() { + if (!confirm("Really ignore this product?\n\n" + + "This will leave it unresolved, but hidden via default filters.")) { + return + } + this.$refs.ignoreProductForm.submit() } - if (!this.resolveProductUUID) { - return true + + % endif + + % if master.has_perm('resolve_product') and instance.status_code in (enum.PENDING_PRODUCT_STATUS_PENDING, enum.PENDING_PRODUCT_STATUS_READY, enum.PENDING_PRODUCT_STATUS_IGNORED): + + ThisPageData.resolveProductShowDialog = false + ThisPageData.resolveProductUUID = null + ThisPageData.resolveProductSubmitting = false + + ThisPage.computed.resolveProductSubmitDisabled = function() { + if (this.resolveProductSubmitting) { + return true + } + if (!this.resolveProductUUID) { + return true + } + return false } - return false - } - ThisPage.methods.resolveProductInit = function() { - this.resolveProductUUID = null - this.resolveProductShowDialog = true - this.$nextTick(() => { - this.$refs.resolveProductAutocomplete.focus() - }) - } + ThisPage.methods.resolveProductInit = function() { + this.resolveProductUUID = null + this.resolveProductShowDialog = true + this.$nextTick(() => { + this.$refs.productLookup.focus() + }) + } - ThisPage.methods.resolveProductSubmit = function() { - this.resolveProductSubmitting = true - this.$refs.resolveProductForm.submit() - } + ThisPage.methods.resolveProductSubmit = function() { + this.resolveProductSubmitting = true + this.$refs.resolveProductForm.submit() + } + + ThisPageData.actualProduct = null + + ThisPage.methods.productSelected = function(product) { + this.actualProduct = product + this.resolveProductUUID = product ? product.uuid : null + } + + % endif </script> </%def> - -${parent.body()} +<%def name="make_vue_components()"> + ${parent.make_vue_components()} + ${product_lookup.tailbone_product_lookup_component()} +</%def> diff --git a/tailbone/templates/products/view.mako b/tailbone/templates/products/view.mako index cac17f1a..66ca3128 100644 --- a/tailbone/templates/products/view.mako +++ b/tailbone/templates/products/view.mako @@ -1,148 +1,46 @@ ## -*- coding: utf-8; -*- <%inherit file="/master/view.mako" /> -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - % if not use_buefy and request.rattail_config.versioning_enabled() and master.has_perm('versions'): - <script type="text/javascript"> - - function showPriceHistory(typ) { - var dialog = $('#' + typ + '-price-history-dialog'); - dialog.dialog({ - title: typ[0].toUpperCase() + typ.slice(1) + " Price History", - width: 600, - height: 300, - modal: true, - buttons: [ - { - text: "Close", - click: function() { - dialog.dialog('close'); - } - } - ] - }); - } - - function showCostHistory() { - var dialog = $('#cost-history-dialog'); - dialog.dialog({ - title: "Cost History", - width: 600, - height: 300, - modal: true, - buttons: [ - { - text: "Close", - click: function() { - dialog.dialog('close'); - } - } - ] - }); - } - - $(function() { - - $('#view-regular-price-history').on('click', function() { - showPriceHistory('regular'); - return false; - }); - - $('#view-current-price-history').on('click', function() { - showPriceHistory('current'); - return false; - }); - - $('#view-suggested-price-history').on('click', function() { - showPriceHistory('suggested'); - return false; - }); - - $('#view-cost-history').on('click', function() { - showCostHistory(); - return false; - }); - - }); - - </script> - % endif -</%def> - <%def name="extra_styles()"> ${parent.extra_styles()} <style type="text/css"> - % if use_buefy: - #main-product-panel { - margin-right: 2em; - margin-top: 1em; - } - #pricing-panel .field-wrapper .field { - white-space: nowrap; - } - % else: - .price-history-dialog { - display: none; - } - .price-history-dialog .grid { - color: black; - } - % endif + nav.item-panel { + min-width: 600px; + } + #main-product-panel { + margin-right: 2em; + margin-top: 1em; + } + #pricing-panel .field-wrapper .field { + white-space: nowrap; + } </style> </%def> <%def name="render_main_fields(form)"> - ${form.render_field_readonly(product_key_field)} - ${form.render_field_readonly('brand')} - ${form.render_field_readonly('description')} - ${form.render_field_readonly('size')} - ${form.render_field_readonly('unit_size')} - ${form.render_field_readonly('unit_of_measure')} - ${form.render_field_readonly('average_weight')} - ${form.render_field_readonly('case_size')} - % if instance.is_pack_item(): - ${form.render_field_readonly('pack_size')} - ${form.render_field_readonly('unit')} - ${form.render_field_readonly('default_pack')} - % elif instance.packs: - ${form.render_field_readonly('packs')} - % endif + % for field in panel_fields['main']: + ${form.render_field_readonly(field)} + % endfor ${self.extra_main_fields(form)} </%def> <%def name="left_column()"> - % if use_buefy: - <nav class="panel" id="pricing-panel"> - <p class="panel-heading">Pricing</p> - <div class="panel-block"> - <div> - ${self.render_price_fields(form)} - </div> - </div> - </nav> - <nav class="panel"> - <p class="panel-heading">Flags</p> - <div class="panel-block"> - <div> - ${self.render_flag_fields(form)} - </div> - </div> - </nav> - % else: - <div class="panel"> - <h2>Pricing</h2> - <div class="panel-body"> - ${self.render_price_fields(form)} + <nav class="panel item-panel" id="pricing-panel"> + <p class="panel-heading">Pricing</p> + <div class="panel-block"> + <div style="width: 100%;"> + ${self.render_price_fields(form)} + </div> </div> - </div> - <div class="panel"> - <h2>Flags</h2> - <div class="panel-body"> - ${self.render_flag_fields(form)} + </nav> + <nav class="panel item-panel"> + <p class="panel-heading">Flags</p> + <div class="panel-block"> + <div style="width: 100%;"> + ${self.render_flag_fields(form)} + </div> </div> - </div> - % endif + </nav> ${self.extra_left_panels()} </%def> @@ -159,23 +57,14 @@ <%def name="extra_main_fields(form)"></%def> <%def name="organization_panel()"> - % if use_buefy: - <nav class="panel"> - <p class="panel-heading">Organization</p> - <div class="panel-block"> - <div> - ${self.render_organization_fields(form)} - </div> - </div> - </nav> - % else: - <div class="panel"> - <h2>Organization</h2> - <div class="panel-body"> - ${self.render_organization_fields(form)} + <nav class="panel item-panel"> + <p class="panel-heading">Organization</p> + <div class="panel-block"> + <div style="width: 100%;"> + ${self.render_organization_fields(form)} + </div> </div> - </div> - % endif + </nav> </%def> <%def name="render_organization_fields(form)"> @@ -201,33 +90,20 @@ </%def> <%def name="render_flag_fields(form)"> - ${form.render_field_readonly('weighed')} - ${form.render_field_readonly('discountable')} - ${form.render_field_readonly('special_order')} - ${form.render_field_readonly('organic')} - ${form.render_field_readonly('not_for_sale')} - ${form.render_field_readonly('discontinued')} - ${form.render_field_readonly('deleted')} + % for field in panel_fields['flag']: + ${form.render_field_readonly(field)} + % endfor </%def> <%def name="movement_panel()"> - % if use_buefy: - <nav class="panel"> - <p class="panel-heading">Movement</p> - <div class="panel-block"> - <div> - ${self.render_movement_fields(form)} - </div> - </div> - </nav> - % else: - <div class="panel"> - <h2>Movement</h2> - <div class="panel-body"> - ${self.render_movement_fields(form)} + <nav class="panel item-panel"> + <p class="panel-heading">Movement</p> + <div class="panel-block"> + <div style="width: 100%;"> + ${self.render_movement_fields(form)} + </div> </div> - </div> - % endif + </nav> </%def> <%def name="render_movement_fields(form)"> @@ -235,145 +111,54 @@ </%def> <%def name="lookup_codes_grid()"> - % if use_buefy: - ${lookup_codes['grid'].render_buefy_table_element(data_prop='lookupCodesData')|n} - % else: - <div class="grid full no-border"> - <table> - <thead> - <th>Seq</th> - <th>Code</th> - </thead> - <tbody> - % for code in instance._codes: - <tr> - <td>${code.ordinal}</td> - <td>${code.code}</td> - </tr> - % endfor - </tbody> - </table> - </div> - % endif + ${lookup_codes['grid'].render_table_element(data_prop='lookupCodesData')|n} </%def> <%def name="lookup_codes_panel()"> - % if use_buefy: - <nav class="panel"> - <p class="panel-heading">Additional Lookup Codes</p> - <div class="panel-block"> - ${self.lookup_codes_grid()} - </div> - </nav> - % else: - <div class="panel-grid" id="product-codes"> - <h2>Additional Lookup Codes</h2> - ${self.lookup_codes_grid()} - </div> - % endif + <nav class="panel item-panel"> + <p class="panel-heading">Additional Lookup Codes</p> + <div class="panel-block"> + ${self.lookup_codes_grid()} + </div> + </nav> </%def> <%def name="sources_grid()"> - % if use_buefy: - ${vendor_sources['grid'].render_buefy_table_element(data_prop='vendorSourcesData')|n} - % else: - <div class="grid full no-border"> - <table> - <thead> - <th>${costs_label_preferred}</th> - <th>${costs_label_vendor}</th> - <th>${costs_label_code}</th> - <th>${costs_label_case_size}</th> - <th>Case Cost</th> - <th>Unit Cost</th> - <th>Status</th> - </thead> - <tbody> - % for i, cost in enumerate(instance.costs, 1): - <tr class="${'even' if i % 2 == 0 else 'odd'}"> - <td class="center">${'X' if cost.preference == 1 else ''}</td> - <td> - % if request.has_perm('vendors.view'): - ${h.link_to(cost.vendor, request.route_url('vendors.view', uuid=cost.vendor_uuid))} - % else: - ${cost.vendor} - % endif - </td> - <td class="center">${cost.code or ''}</td> - <td class="center">${h.pretty_quantity(cost.case_size)}</td> - <td class="right">${'$ %0.2f' % cost.case_cost if cost.case_cost is not None else ''}</td> - <td class="right">${'$ %0.4f' % cost.unit_cost if cost.unit_cost is not None else ''}</td> - <td>${"discontinued" if cost.discontinued else "available"}</td> - </tr> - % endfor - </tbody> - </table> - </div> - % endif + ${vendor_sources['grid'].render_table_element(data_prop='vendorSourcesData')|n} </%def> <%def name="sources_panel()"> - % if use_buefy: - <nav class="panel"> - <p class="panel-heading"> - Vendor Sources - % if request.rattail_config.versioning_enabled() and master.has_perm('versions'): - <a href="#" @click.prevent="showCostHistory()"> - (view cost history) - </a> - % endif - </p> - <div class="panel-block"> - ${self.sources_grid()} - </div> - </nav> - % else: - <div class="panel-grid" id="product-costs"> - <h2> + <nav class="panel item-panel"> + <p class="panel-heading"> Vendor Sources % if request.rattail_config.versioning_enabled() and master.has_perm('versions'): - <a id="view-cost-history" href="#">(view cost history)</a> + <a href="#" @click.prevent="showCostHistory()"> + (view cost history) + </a> % endif - </h2> - ${self.sources_grid()} - </div> - % endif + </p> + <div class="panel-block"> + ${self.sources_grid()} + </div> + </nav> </%def> <%def name="notes_panel()"> - % if use_buefy: - <nav class="panel"> - <p class="panel-heading">Notes</p> - <div class="panel-block"> - <div class="field">${form.render_field_readonly('notes')}</div> - </div> - </nav> - % else: - <div class="panel"> - <h2>Notes</h2> - <div class="panel-body"> + <nav class="panel item-panel"> + <p class="panel-heading">Notes</p> + <div class="panel-block"> <div class="field">${form.render_field_readonly('notes')}</div> </div> - </div> - % endif + </nav> </%def> <%def name="ingredients_panel()"> - % if use_buefy: - <nav class="panel"> - <p class="panel-heading">Ingredients</p> - <div class="panel-block"> - ${form.render_field_readonly('ingredients')} - </div> - </nav> - % else: - <div class="panel"> - <h2>Ingredients</h2> - <div class="panel-body"> + <nav class="panel item-panel"> + <p class="panel-heading">Ingredients</p> + <div class="panel-block"> ${form.render_field_readonly('ingredients')} </div> - </div> - % endif + </nav> </%def> <%def name="extra_left_panels()"></%def> @@ -382,7 +167,7 @@ <%def name="render_this_page()"> ${parent.render_this_page()} - % if use_buefy and request.rattail_config.versioning_enabled() and master.has_perm('versions'): + % if request.rattail_config.versioning_enabled() and master.has_perm('versions'): <b-modal :active.sync="showingPriceHistory_regular" has-modal-card> @@ -393,7 +178,7 @@ </p> </header> <section class="modal-card-body"> - ${regular_price_history_grid.render_buefy_table_element(data_prop='regularPriceHistoryData', loading='regularPriceHistoryLoading', paginated=True, per_page=10)|n} + ${regular_price_history_grid.render_table_element(data_prop='regularPriceHistoryData', loading='regularPriceHistoryLoading', paginated=True, per_page=10)|n} </section> <footer class="modal-card-foot"> <b-button @click="showingPriceHistory_regular = false"> @@ -412,7 +197,7 @@ </p> </header> <section class="modal-card-body"> - ${current_price_history_grid.render_buefy_table_element(data_prop='currentPriceHistoryData', loading='currentPriceHistoryLoading', paginated=True, per_page=10)|n} + ${current_price_history_grid.render_table_element(data_prop='currentPriceHistoryData', loading='currentPriceHistoryLoading', paginated=True, per_page=10)|n} </section> <footer class="modal-card-foot"> <b-button @click="showingPriceHistory_current = false"> @@ -431,7 +216,7 @@ </p> </header> <section class="modal-card-body"> - ${suggested_price_history_grid.render_buefy_table_element(data_prop='suggestedPriceHistoryData', loading='suggestedPriceHistoryLoading', paginated=True, per_page=10)|n} + ${suggested_price_history_grid.render_table_element(data_prop='suggestedPriceHistoryData', loading='suggestedPriceHistoryLoading', paginated=True, per_page=10)|n} </section> <footer class="modal-card-foot"> <b-button @click="showingPriceHistory_suggested = false"> @@ -450,7 +235,7 @@ </p> </header> <section class="modal-card-body"> - ${cost_history_grid.render_buefy_table_element(data_prop='costHistoryData', loading='costHistoryLoading', paginated=True, per_page=10)|n} + ${cost_history_grid.render_table_element(data_prop='costHistoryData', loading='costHistoryLoading', paginated=True, per_page=10)|n} </section> <footer class="modal-card-foot"> <b-button @click="showingCostHistory = false"> @@ -463,92 +248,43 @@ </%def> <%def name="page_content()"> - % if use_buefy: - <div style="display: flex; flex-direction: column;"> - - <nav class="panel" id="main-product-panel"> - <p class="panel-heading">Product</p> - <div class="panel-block"> - <div style="display: flex; justify-content: space-between; width: 100%;"> - <div> - ${self.render_main_fields(form)} - </div> - <div> - % if image_url: - ${h.image(image_url, "Product Image", id='product-image', width=150, height=150)} - % endif - </div> - </div> - </div> - </nav> - - <div style="display: flex;"> - <div class="panel-wrapper"> <!-- left column --> - ${self.left_column()} - </div> <!-- left column --> - <div class="panel-wrapper" style="margin-left: 1em;"> <!-- right column --> - ${self.right_column()} - </div> <!-- right column --> - </div> + <div style="display: flex; flex-direction: column;"> + <nav class="panel item-panel" id="main-product-panel"> + <p class="panel-heading">Product</p> + <div class="panel-block"> + <div style="display: flex; gap: 2rem; width: 100%;"> + <div style="flex-grow: 1;"> + ${self.render_main_fields(form)} </div> - - % else: - ## legacy / not buefy - - <div style="display: flex; flex-direction: column;"> - - <div class="panel" id="product-main"> - <h2>Product</h2> - <div class="panel-body"> - <div style="display: flex; justify-content: space-between;"> - <div> - ${self.render_main_fields(form)} - </div> - <div> - % if image_url: - ${h.image(image_url, "Product Image", id='product-image', width=150, height=150)} - % endif - </div> - </div> - </div> + <div> + % if image_url: + ${h.image(image_url, "Product Image", id='product-image', width=150, height=150)} + % endif </div> - - <div style="display: flex;"> - <div class="panel-wrapper"> <!-- left column --> - ${self.left_column()} - </div> <!-- left column --> - <div class="panel-wrapper" style="margin-left: 1em;"> <!-- right column --> - ${self.right_column()} - </div> <!-- right column --> - </div> - </div> + </div> + </nav> - % if request.rattail_config.versioning_enabled() and master.has_perm('versions'): - <div class="price-history-dialog" id="regular-price-history-dialog"> - ${regular_price_history_grid.render_grid()|n} - </div> - <div class="price-history-dialog" id="current-price-history-dialog"> - ${current_price_history_grid.render_grid()|n} - </div> - <div class="price-history-dialog" id="suggested-price-history-dialog"> - ${suggested_price_history_grid.render_grid()|n} - </div> - <div class="price-history-dialog" id="cost-history-dialog"> - ${cost_history_grid.render_grid()|n} - </div> - % endif - % endif + <div style="display: flex;"> + <div class="panel-wrapper"> <!-- left column --> + ${self.left_column()} + </div> <!-- left column --> + <div class="panel-wrapper" style="margin-left: 1em;"> <!-- right column --> + ${self.right_column()} + </div> <!-- right column --> + </div> + + </div> % if buttons: ${buttons|n} % endif </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> ThisPageData.vendorSourcesData = ${json.dumps(vendor_sources['data'])|n} ThisPageData.lookupCodesData = ${json.dumps(lookup_codes['data'])|n} @@ -556,7 +292,7 @@ % if request.rattail_config.versioning_enabled() and master.has_perm('versions'): ThisPageData.showingPriceHistory_regular = false - ThisPageData.regularPriceHistoryDataRaw = ${json.dumps(regular_price_history_grid.get_buefy_data()['data'])|n} + ThisPageData.regularPriceHistoryDataRaw = ${json.dumps(regular_price_history_grid.get_table_data()['data'])|n} ThisPageData.regularPriceHistoryLoading = false ThisPage.computed.regularPriceHistoryData = function() { @@ -585,7 +321,7 @@ } ThisPageData.showingPriceHistory_current = false - ThisPageData.currentPriceHistoryDataRaw = ${json.dumps(current_price_history_grid.get_buefy_data()['data'])|n} + ThisPageData.currentPriceHistoryDataRaw = ${json.dumps(current_price_history_grid.get_table_data()['data'])|n} ThisPageData.currentPriceHistoryLoading = false ThisPage.computed.currentPriceHistoryData = function() { @@ -615,7 +351,7 @@ } ThisPageData.showingPriceHistory_suggested = false - ThisPageData.suggestedPriceHistoryDataRaw = ${json.dumps(suggested_price_history_grid.get_buefy_data()['data'])|n} + ThisPageData.suggestedPriceHistoryDataRaw = ${json.dumps(suggested_price_history_grid.get_table_data()['data'])|n} ThisPageData.suggestedPriceHistoryLoading = false ThisPage.computed.suggestedPriceHistoryData = function() { @@ -644,7 +380,7 @@ } ThisPageData.showingCostHistory = false - ThisPageData.costHistoryDataRaw = ${json.dumps(cost_history_grid.get_buefy_data()['data'])|n} + ThisPageData.costHistoryDataRaw = ${json.dumps(cost_history_grid.get_table_data()['data'])|n} ThisPageData.costHistoryLoading = false ThisPage.computed.costHistoryData = function() { @@ -675,6 +411,3 @@ % endif </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/progress.mako b/tailbone/templates/progress.mako index 331e8e1a..ad0a1371 100644 --- a/tailbone/templates/progress.mako +++ b/tailbone/templates/progress.mako @@ -1,145 +1,219 @@ ## -*- coding: utf-8; -*- -<%namespace file="tailbone:templates/base.mako" import="core_javascript" /> -<%namespace file="/base.mako" import="jquery_theme" /> -<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd"> -<html style="direction: ltr;" xmlns="http://www.w3.org/1999/xhtml" lang="en-us"> +<%namespace file="/base.mako" import="core_javascript" /> +<%namespace file="/base.mako" import="core_styles" /> +<!DOCTYPE html> +<html lang="en"> <head> <meta http-equiv="content-type" content="text/html; charset=UTF-8" /> <title>${initial_msg or "Working"}...</title> ${core_javascript()} - ${h.stylesheet_link(request.static_url('tailbone:static/css/normalize.css'))} - ${jquery_theme()} - ${h.stylesheet_link(request.static_url('tailbone:static/css/base.css'))} - ${h.stylesheet_link(request.static_url('tailbone:static/css/layout.css'))} - ${h.stylesheet_link(request.static_url('tailbone:static/css/progress.css'))} - ${self.update_progress_func()} + ${core_styles()} ${self.extra_styles()} + </head> + <body style="height: 100%;"> + + <div id="whole-page-app"> + <whole-page></whole-page> + </div> + + <script type="text/x-template" id="whole-page-template"> + + <section class="hero is-fullheight"> + <div class="hero-body"> + <div class="container"> + + <div style="display: flex;"> + <div style="flex-grow: 1;"></div> + <div> + + <p class="block"> + {{ progressMessage }} ... {{ totalDisplay }} + </p> + + <div class="level"> + + <div class="level-item"> + <b-progress size="is-large" + style="width: 400px;" + :max="progressMax" + :value="progressValue" + show-value + format="percent" + precision="0"> + </b-progress> + </div> + + % if can_cancel: + <div class="level-item" + style="margin-left: 2rem;"> + <b-button v-show="canCancel" + @click="cancelProgress()" + :disabled="cancelingProgress" + icon-pack="fas" + icon-left="ban"> + {{ cancelingProgress ? "Canceling, please wait..." : "Cancel" }} + </b-button> + </div> + % endif + + </div> + + </div> + <div style="flex-grow: 1;"></div> + </div> + + ${self.after_progress()} + + </div> + </div> + </section> + + </script> + <script type="text/javascript"> - var stillInProgress = true; + let WholePage = { + template: '#whole-page-template', - // fetch first progress data, one second from now - setTimeout(function() { - update_progress(); - }, 1000); + computed: { - % if can_cancel: - $(function() { + totalDisplay() { - $('#cancel button').click(function() { - if (confirm("Do you really wish to cancel this operation?")) { - stillInProgress = false; - $(this).button('disable').button('option', 'label', "Canceling, please wait..."); - $.ajax({ - url: '${url('progress.cancel', key=progress.key)}?sessiontype=${progress.session.type}', - data: { - 'cancel_msg': '${cancel_msg}', - }, - success: function(data) { - location.href = '${cancel_url}'; - }, - }); - } - }); + % if can_cancel: + if (!this.stillInProgress && !this.cancelingProgress) { + % else: + if (!this.stillInProgress) { + % endif + return "done!" + } - }); - % endif - - </script> - </head> - <body> - <div id="body-wrapper"> - - <div id="wrapper"> - - <p><span id="message">${initial_msg or "Working"} (please wait)</span> ... <span id="total"></span></p> - - <table id="progress-wrapper"> - <tr> - <td> - <table id="progress"> - <tr> - <td id="complete"></td> - <td id="remaining"></td> - </tr> - </table><!-- #progress --> - </td> - <td id="percentage"></td> - % if can_cancel: - <td id="cancel"> - <button type="button" style="display: none;">Cancel</button> - </td> - % endif - </tr> - </table><!-- #progress-wrapper --> - - </div><!-- #wrapper --> - - ${self.after_progress()} - - </div><!-- #body-wrapper --> - </body> -</html> - -<%def name="update_progress_func()"> - <script type="text/javascript"> - - function update_progress() { - $.ajax({ - url: '${url('progress', key=progress.key)}?sessiontype=${progress.session.type}', - - success: function(data) { - - if (data.error) { - // errors stop the show, we redirect to "cancel" page - location.href = '${cancel_url}'; - - } else { - if (data.complete || data.maximum) { - $('#message').html(data.message); - $('#total').html('('+data.maximum_display+' total)'); - % if can_cancel: - $('#cancel button').show(); - % endif - if (data.complete) { - stillInProgress = false; - % if can_cancel: - $('#cancel button').hide(); - % endif - $('#total').html('done!'); - $('#complete').css('width', '100%'); - $('#remaining').hide(); - $('#percentage').html('100 %'); - location.href = data.success_url; - - } else { - // got progress data, so update display - var width = parseInt(data.value) / parseInt(data.maximum); - width = Math.round(100 * width); - if (width) { - $('#complete').css('width', width+'%'); - $('#percentage').html(width+' %'); - } else { - $('#complete').css('width', '0.01%'); - $('#percentage').html('0 %'); - } - $('#remaining').css('width', 'auto'); - } - } - - if (stillInProgress) { - // fetch progress data again, in one second from now - setTimeout(function() { - update_progress(); - }, 1000); - } + if (this.progressMaxDisplay) { + return `(${'$'}{this.progressMaxDisplay} total)` } }, - }); + }, + + mounted() { + + // fetch first progress data, one second from now + setTimeout(() => { + this.updateProgress() + }, 1000) + + // custom logic if applicable + this.mountedCustom() + }, + + methods: { + + mountedCustom() {}, + + updateProgress() { + + this.$http.get(this.progressURL).then(response => { + + if (response.data.error) { + // errors stop the show, we redirect to "cancel" page + location.href = '${cancel_url}' + + } else { + + if (response.data.complete || response.data.maximum) { + this.progressMessage = response.data.message + this.progressMaxDisplay = response.data.maximum_display + + if (response.data.complete) { + this.progressValue = this.progressMax + this.stillInProgress = false + % if can_cancel: + this.canCancel = false + % endif + + location.href = response.data.success_url + + } else { + this.progressValue = response.data.value + this.progressMax = response.data.maximum + } + } + + // custom logic if applicable + this.updateProgressCustom(response) + + if (this.stillInProgress) { + + // fetch progress data again, in one second from now + setTimeout(() => { + this.updateProgress() + }, 1000) + } + } + }) + }, + + updateProgressCustom(response) {}, + + % if can_cancel: + + cancelProgress() { + + if (confirm("Do you really wish to cancel this operation?")) { + + this.cancelingProgress = true + this.stillInProgress = false + + let params = {cancel_msg: ${json.dumps(cancel_msg)|n}} + this.$http.get(this.cancelURL, {params: params}).then(response => { + location.href = ${json.dumps(cancel_url)|n} + }) + } + + }, + + % endif + } } - </script> -</%def> + + let WholePageData = { + + progressURL: '${url('progress', key=progress.key, _query={'sessiontype': progress.session.type})}', + progressMessage: "${(initial_msg or "Working").replace('"', '\\"')} (please wait)", + progressMax: null, + progressMaxDisplay: null, + progressValue: null, + stillInProgress: true, + + % if can_cancel: + canCancel: true, + cancelURL: '${url('progress.cancel', key=progress.key, _query={'sessiontype': progress.session.type})}', + cancelingProgress: false, + % endif + } + + </script> + + ${self.modify_whole_page_vars()} + ${self.make_whole_page_app()} + + </body> +</html> <%def name="extra_styles()"></%def> <%def name="after_progress()"></%def> + +<%def name="modify_whole_page_vars()"></%def> + +<%def name="make_whole_page_app()"> + <script type="text/javascript"> + + WholePage.data = function() { return WholePageData } + + Vue.component('whole-page', WholePage) + + new Vue({ + el: '#whole-page-app' + }) + + </script> +</%def> diff --git a/tailbone/templates/purchases/batches/create.mako b/tailbone/templates/purchases/batches/create.mako index 3b94f7d0..35ee878a 100644 --- a/tailbone/templates/purchases/batches/create.mako +++ b/tailbone/templates/purchases/batches/create.mako @@ -1,99 +1,6 @@ ## -*- coding: utf-8; -*- <%inherit file="/batch/create.mako" /> -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - ${self.func_show_mode()} - <script type="text/javascript"> - - var purchases_field = '${purchases_field}'; - var purchases = null; // TODO: where is this used? - - function vendor_selected(uuid, name) { - var mode = $('.mode select').val(); - if (mode == ${enum.PURCHASE_BATCH_MODE_RECEIVING} || mode == ${enum.PURCHASE_BATCH_MODE_COSTING}) { - var purchases = $('.purchase_uuid select'); - purchases.empty(); - - var data = {'vendor_uuid': uuid, 'mode': mode}; - $.get('${url('purchases.batch.eligible_purchases')}', data, function(data) { - if (data.error) { - alert(data.error); - } else { - $.each(data.purchases, function(i, purchase) { - purchases.append($('<option value="' + purchase.key + '">' + purchase.display + '</option>')); - }); - } - }); - - // TODO: apparently refresh doesn't work right? - // http://stackoverflow.com/a/10280078 - // purchases.selectmenu('refresh'); - purchases.selectmenu('destroy').selectmenu(); - } - } - - function vendor_cleared() { - var purchases = $('.purchase_uuid select'); - purchases.empty(); - - // TODO: apparently refresh doesn't work right? - // http://stackoverflow.com/a/10280078 - // purchases.selectmenu('refresh'); - purchases.selectmenu('destroy').selectmenu(); - } - - $(function() { - - $('.field-wrapper.mode select').selectmenu({ - change: function(event, ui) { - show_mode(ui.item.value); - } - }); - - show_mode(${batch.mode or enum.PURCHASE_BATCH_MODE_ORDERING}); - - }); - - </script> -</%def> - -<%def name="func_show_mode()"> - <script type="text/javascript"> - - function show_mode(mode) { - if (mode == ${enum.PURCHASE_BATCH_MODE_ORDERING}) { - $('.field-wrapper.store_uuid').show(); - $('.field-wrapper.' + purchases_field).hide(); - $('.field-wrapper.department_uuid').show(); - $('.field-wrapper.buyer_uuid').show(); - $('.field-wrapper.date_ordered').show(); - $('.field-wrapper.date_received').hide(); - $('.field-wrapper.po_number').show(); - $('.field-wrapper.invoice_date').hide(); - $('.field-wrapper.invoice_number').hide(); - } else if (mode == ${enum.PURCHASE_BATCH_MODE_RECEIVING}) { - $('.field-wrapper.store_uuid').hide(); - $('.field-wrapper.purchase_uuid').show(); - $('.field-wrapper.department_uuid').hide(); - $('.field-wrapper.buyer_uuid').hide(); - $('.field-wrapper.date_ordered').hide(); - $('.field-wrapper.date_received').show(); - $('.field-wrapper.invoice_date').show(); - $('.field-wrapper.invoice_number').show(); - } else if (mode == ${enum.PURCHASE_BATCH_MODE_COSTING}) { - $('.field-wrapper.store_uuid').hide(); - $('.field-wrapper.purchase_uuid').show(); - $('.field-wrapper.department_uuid').hide(); - $('.field-wrapper.buyer_uuid').hide(); - $('.field-wrapper.date_ordered').hide(); - $('.field-wrapper.date_received').hide(); - $('.field-wrapper.invoice_date').show(); - $('.field-wrapper.invoice_number').show(); - } - } - - </script> -</%def> +## TODO: deprecate / remove this ${parent.body()} diff --git a/tailbone/templates/purchases/batches/receive_form.mako b/tailbone/templates/purchases/batches/receive_form.mako deleted file mode 100644 index 3a3ed888..00000000 --- a/tailbone/templates/purchases/batches/receive_form.mako +++ /dev/null @@ -1,468 +0,0 @@ -## -*- coding: utf-8 -*- -<%inherit file="/base.mako" /> - -<%def name="title()">Receiving Form (${batch.vendor})</%def> - -<%def name="head_tags()"> - ${parent.head_tags()} - ${h.javascript_link(request.static_url('tailbone:static/js/numeric.js'))} - <script type="text/javascript"> - - function assert_quantity() { - if ($('#cases').val() && parseFloat($('#cases').val())) { - return true; - } - if ($('#units').val() && parseFloat($('#units').val())) { - return true; - } - alert("Please provide case and/or unit quantity"); - $('#cases').select().focus(); - return false; - } - - function invalid_product(msg) { - $('#received-product-info p').text(msg); - $('#received-product-info img').hide(); - $('#upc').focus().select(); - $('.field-wrapper.cases input').prop('disabled', true); - $('.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(''); - $('#received-product-info p').html("please ENTER a scancode"); - $('#received-product-info img').hide(); - $('#received-product-info .warning').hide(); - $('.product-fields').hide(); - $('.receiving-fields').hide(); - $('.field-wrapper.cases input').prop('disabled', true); - $('.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('purchases.batch.receiving_lookup', uuid=batch.uuid)}', data, function(data) { - - if (data.error) { - alert(data.error); - if (data.redirect) { - $('#receiving-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); - - $('#received-product-info p').text(data.product.full_description); - $('#received-product-info img').attr('src', data.product.image_url).show(); - if (! data.product.uuid) { - // $('#received-product-info .warning.notfound').show(); - $('.product-fields').show(); - } - if (data.product.found_in_batch) { - show_quantity('ordered', data.product.cases_ordered, data.product.units_ordered); - show_quantity('received', data.product.cases_received, data.product.units_received); - show_quantity('damaged', data.product.cases_damaged, data.product.units_damaged); - show_quantity('expired', data.product.cases_expired, data.product.units_expired); - show_quantity('mispick', data.product.cases_mispick, data.product.units_mispick); - $('.receiving-fields').show(); - } else { - $('#received-product-info .warning.notordered').show(); - } - $('.field-wrapper.cases input').prop('disabled', false); - $('.field-wrapper.units input').prop('disabled', false); - $('.buttons button').button('enable'); - $('#cases').focus().select(); - - } else if (data.upc) { - $('#upc').val(data.upc_pretty); - $('#received-product-info p').text("product not found in our system"); - $('#received-product-info img').attr('src', data.image_url).show(); - - $('#product').val(''); - $('#brand_name').val(''); - $('#description').val(''); - $('#size').val(''); - $('#case_quantity').val(''); - - $('#received-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; - }); - - $('#received').click(function() { - if (! assert_quantity()) { - return; - } - $(this).button('disable').button('option', 'label', "Working..."); - $('#mode').val('received'); - $('#receiving-form').submit(); - }); - - $('#damaged').click(function() { - if (! assert_quantity()) { - return; - } - $(this).button('disable').button('option', 'label', "Working..."); - $('#mode').val('damaged'); - $('#damaged-dialog').dialog({ - title: "Damaged Product", - modal: true, - width: '500px', - buttons: [ - { - text: "OK", - click: function() { - $('#damaged-dialog').dialog('close'); - $('#receiving-form #trash').val($('#damaged-dialog #trash').is(':checked') ? '1' : ''); - $('#receiving-form').submit(); - } - }, - { - text: "Cancel", - click: function() { - $('#damaged').button('option', 'label', "Damaged").button('enable'); - $('#damaged-dialog').dialog('close'); - } - } - ] - }); - }); - - $('#expiration input[type="date"]').datepicker(); - - $('#expired').click(function() { - if (! assert_quantity()) { - return; - } - $(this).button('disable').button('option', 'label', "Working..."); - $('#mode').val('expired'); - $('#expiration').dialog({ - title: "Expired / Short Date", - modal: true, - width: '500px', - buttons: [ - { - text: "OK", - click: function() { - $('#expiration').dialog('close'); - $('#receiving-form #expiration_date').val( - $('#expiration input[type="date"]').val()); - $('#receiving-form #trash').val($('#expiration #trash').is(':checked') ? '1' : ''); - $('#receiving-form').submit(); - } - }, - { - text: "Cancel", - click: function() { - $('#expired').button('option', 'label', "Expired").button('enable'); - $('#expiration').dialog('close'); - } - } - ] - }); - }); - - $('#mispick').click(function() { - if (! assert_quantity()) { - return; - } - $(this).button('disable').button('option', 'label', "Working..."); - $('#ordered-product').val(''); - $('#ordered-product-textbox').val(''); - $('#ordered-product-info p').html("please ENTER a scancode"); - $('#ordered-product-info img').hide(); - $('#mispick-dialog').dialog({ - title: "Mispick - Ordered Product", - modal: true, - width: 400, - buttons: [ - { - text: "OK", - click: function() { - if ($('#ordered-product-info .warning').is(':visible')) { - alert("You must choose a product which was ordered."); - $('#ordered-product-textbox').select().focus(); - return; - } - $('#mispick-dialog').dialog('close'); - $('#mode').val('mispick'); - $('#receiving-form').submit(); - } - }, - { - text: "Cancel", - click: function() { - $('#mispick').button('option', 'label', "Mispick").button('enable'); - $('#mispick-dialog').dialog('close'); - } - } - ] - }); - }); - - $('#ordered-product-textbox').keydown(function(event) { - - if (key_allowed(event)) { - return true; - } - if (key_modifies(event)) { - $('#ordered_product').val(''); - $('#ordered-product-info p').html("please ENTER a scancode"); - $('#ordered-product-info img').hide(); - $('#ordered-product-info .warning').hide(); - return true; - } - if (event.which == 13) { - var input = $(this); - var data = {upc: input.val()}; - $.get('${url('purchases.batch.receiving_lookup', uuid=batch.uuid)}', data, function(data) { - if (data.error) { - alert(data.error); - if (data.redirect) { - $('#mispick-dialog').mask("Redirecting..."); - location.href = data.redirect; - } - } else if (data.product) { - input.val(data.product.upc_pretty); - $('#ordered_product').val(data.product.uuid); - $('#ordered-product-info p').text(data.product.full_description); - $('#ordered-product-info img').attr('src', data.product.image_url).show(); - if (data.product.found_in_batch) { - $('#ordered-product-info .warning').hide(); - } else { - $('#ordered-product-info .warning').show(); - } - } else { - $('#ordered-product-info p').text("product not found"); - $('#ordered-product-info img').hide(); - $('#ordered-product-info .warning').hide(); - } - }); - } - return false; - }); - - $('#receiving-form').submit(function() { - $(this).mask("Working..."); - }); - - $('#upc').focus(); - $('.field-wrapper.cases input').prop('disabled', true); - $('.field-wrapper.units input').prop('disabled', true); - $('.buttons button').button('disable'); - - }); - </script> -</%def> - -<%def name="extra_styles()"> - ${parent.extra_styles()} - <style type="text/css"> - - .product-info { - margin-top: 0.5em; - text-align: center; - } - - .product-info p { - margin-left: 0.5em; - } - - .product-info .img-wrapper { - height: 150px; - margin: 0.5em 0; - } - - #received-product-info .warning { - background: #f66; - display: none; - } - - #mispick-dialog input[type="text"], - #ordered-product-info { - width: 320px; - } - - #ordered-product-info .warning { - background: #f66; - display: none; - } - - </style> -</%def> - - -<%def name="context_menu_items()"> - <li>${h.link_to("Back to Purchase Batch", url('purchases.batch.view', uuid=batch.uuid))}</li> -</%def> - - -<ul id="context-menu"> - ${self.context_menu_items()} -</ul> - -<div class="form-wrapper"> - ${form.begin(id='receiving-form')} - ${form.csrf_token()} - ${h.hidden('mode')} - ${h.hidden('expiration_date')} - ${h.hidden('trash')} - ${h.hidden('ordered_product')} - - <div class="field-wrapper"> - <label for="upc">Receiving UPC</label> - <div class="field"> - ${h.hidden('product')} - <div>${h.text('upc', autocomplete='off')}</div> - <div id="received-product-info" class="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 notordered">warning: product not found on current purchase</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> - - <div class="receiving-fields" style="display: none;"> - - <div class="field-wrapper quantity_ordered"> - <label for="quantity_ordered">Ordered</label> - <div class="field"></div> - </div> - - <div class="field-wrapper quantity_received"> - <label for="quantity_received">Received</label> - <div class="field"></div> - </div> - - <div class="field-wrapper quantity_damaged"> - <label for="quantity_damaged">Damaged</label> - <div class="field"></div> - </div> - - <div class="field-wrapper quantity_expired"> - <label for="quantity_expired">Expired</label> - <div class="field"></div> - </div> - - <div class="field-wrapper quantity_mispick"> - <label for="quantity_mispick">Mispick</label> - <div class="field"></div> - </div> - - </div> - - <div class="field-wrapper cases"> - <label for="cases">Cases</label> - <div class="field">${h.text('cases', autocomplete='off')}</div> - </div> - - <div class="field-wrapper units"> - <label for="units">Units</label> - <div class="field">${h.text('units', autocomplete='off')}</div> - </div> - - <div class="buttons"> - <button type="button" id="received">Received</button> - <button type="button" id="damaged">Damaged</button> - <button type="button" id="expired">Expired</button> - <!-- <button type="button" id="mispick">Mispick</button> --> - </div> - - ${form.end()} -</div> - -<div id="damaged-dialog" style="display: none;"> - <div class="field-wrapper trash">${h.checkbox('trash', label="Product will be discarded and cannot be returned", checked=False)}</div> -</div> - -<div id="expiration" style="display: none;"> - <div class="field-wrapper expiration-date"> - <label for="expiration-date">Expiration Date</label> - <div class="field">${h.text('expiration-date', type='date')}</div> - </div> - <div class="field-wrapper trash">${h.checkbox('trash', label="Product will be discarded and cannot be returned", checked=False)}</div> -</div> - -<div id="mispick-dialog" style="display: none;"> - <div>${h.text('ordered-product-textbox', autocomplete='off')}</div> - <div id="ordered-product-info" class="product-info"> - <p>please ENTER a scancode</p> - <div class="img-wrapper"><img /></div> - <div class="warning">warning: product not found on current purchase</div> - </div> -</div> diff --git a/tailbone/templates/purchases/credits/index.mako b/tailbone/templates/purchases/credits/index.mako index db59b939..94028bdb 100644 --- a/tailbone/templates/purchases/credits/index.mako +++ b/tailbone/templates/purchases/credits/index.mako @@ -1,97 +1,82 @@ ## -*- coding: utf-8; -*- <%inherit file="/master/index.mako" /> -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - <script type="text/javascript"> - - function update_change_status_button() { - var count = $('.grid tr:not(.header) td.checkbox input:checked').length; - $('button.change-status').button('option', 'disabled', count < 1); - } - - $(function() { - - $('.grid-wrapper').on('click', 'tr.header td.checkbox input', function() { - update_change_status_button(); - }); - - $('.grid-wrapper').on('click', '.grid tr:not(.header) td.checkbox input', function() { - update_change_status_button(); - }); - $('.grid-wrapper').on('click', '.grid tr:not(.header)', function() { - update_change_status_button(); - }); - - $('button.change-status').click(function() { - var uuids = []; - $('.grid tr:not(.header) td.checkbox input:checked').each(function() { - uuids.push($(this).parents('tr:first').data('uuid')); - }); - if (! uuids.length) { - alert("You must first select one or more credits."); - return false; - } - - var form = $('form[name="change-status"]'); - form.find('[name="uuids"]').val(uuids.toString()); - - $('#change-status-dialog').dialog({ - title: "Change Credit Status", - width: 500, - height: 300, - modal: true, - open: function() { - // TODO: why must we do this here instead of using auto-enhance ? - $('#change-status-dialog select[name="status"]').selectmenu(); - }, - buttons: [ - { - text: "Submit", - click: function(event) { - disable_button(dialog_button(event)); - form.submit(); - } - }, - { - text: "Cancel", - click: function() { - $(this).dialog('close'); - } - } - ] - }); - }); - - }); - </script> -</%def> - <%def name="grid_tools()"> ${parent.grid_tools()} - <button type="button" class="change-status" disabled="disabled">Change Status</button> + + <b-button type="is-primary" + @click="changeStatusInit()" + :disabled="!selected_uuids.length"> + Change Status + </b-button> + + <b-modal has-modal-card + :active.sync="changeStatusShowDialog"> + <div class="modal-card"> + + <header class="modal-card-head"> + <p class="modal-card-title">Change Status</p> + </header> + + <section class="modal-card-body"> + + <p class="block"> + Please choose the appropriate status for the selected credits. + </p> + + <b-field label="Status"> + <b-select v-model="changeStatusValue"> + <option v-for="status in changeStatusOptions" + :key="status.value" + :value="status.value"> + {{ status.label }} + </option> + </b-select> + </b-field> + + </section> + + <footer class="modal-card-foot"> + <b-button @click="changeStatusShowDialog = false"> + Cancel + </b-button> + <b-button type="is-primary" + @click="changeStatusSubmit()" + :disabled="changeStatusSubmitting || !changeStatusValue" + icon-pack="fas" + icon-left="save"> + {{ changeStatusSubmitting ? "Working, please wait..." : "Save" }} + </b-button> + </footer> + </div> + </b-modal> + + ${h.form(url('purchases.credits.change_status'), ref='changeStatusForm')} + ${h.csrf_token(request)} + ${h.hidden('uuids', **{':value': 'selected_uuids'})} + ${h.hidden('status', **{':value': 'changeStatusValue'})} + ${h.end_form()} + </%def> -${parent.body()} +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> -<div id="change-status-dialog" style="display: none;"> - ${h.form(url('purchases.credits.change_status'), name='change-status')} - ${h.csrf_token(request)} - ${h.hidden('uuids')} + ${grid.vue_component}Data.changeStatusShowDialog = false + ${grid.vue_component}Data.changeStatusOptions = ${json.dumps(status_options)|n} + ${grid.vue_component}Data.changeStatusValue = null + ${grid.vue_component}Data.changeStatusSubmitting = false - <br /> - <p>Please choose the appropriate status for the selected credits.</p> + ${grid.vue_component}.methods.changeStatusInit = function() { + this.changeStatusValue = null + this.changeStatusShowDialog = true + } - <div class="fieldset"> + ${grid.vue_component}.methods.changeStatusSubmit = function() { + this.changeStatusSubmitting = true + this.$refs.changeStatusForm.submit() + } - <div class="field-wrapper status"> - <label for="status">Status</label> - <div class="field"> - ${h.select('status', None, status_options)} - </div> - </div> - - </div> - - ${h.end_form()} -</div> + </script> +</%def> diff --git a/tailbone/templates/receiving/configure.mako b/tailbone/templates/receiving/configure.mako index 9f4a6c3b..a36dde43 100644 --- a/tailbone/templates/receiving/configure.mako +++ b/tailbone/templates/receiving/configure.mako @@ -24,7 +24,16 @@ v-model="simpleSettings['rattail.batch.purchase.allow_receiving_from_invoice']" native-value="true" @input="settingsNeedSaved = true"> - From Invoice + From Single Invoice + </b-checkbox> + </b-field> + + <b-field> + <b-checkbox name="rattail.batch.purchase.allow_receiving_from_multi_invoice" + v-model="simpleSettings['rattail.batch.purchase.allow_receiving_from_multi_invoice']" + native-value="true" + @input="settingsNeedSaved = true"> + From Multiple (Combined) Invoices </b-checkbox> </b-field> @@ -60,12 +69,12 @@ <h3 class="block is-size-3">Vendors</h3> <div class="block" style="padding-left: 2rem;"> - <b-field message="If set, user must choose a "supported" vendor; otherwise they may choose "any" vendor."> - <b-checkbox name="rattail.batch.purchase.supported_vendors_only" - v-model="simpleSettings['rattail.batch.purchase.supported_vendors_only']" + <b-field message="If not set, user must choose a "supported" vendor."> + <b-checkbox name="rattail.batch.purchase.allow_receiving_any_vendor" + v-model="simpleSettings['rattail.batch.purchase.allow_receiving_any_vendor']" native-value="true" @input="settingsNeedSaved = true"> - Only allow batch for "supported" vendors + Allow receiving for <span class="has-text-weight-bold">any</span> vendor </b-checkbox> </b-field> @@ -106,6 +115,15 @@ </b-checkbox> </b-field> + <b-field message="NB. Allow Decimal Quantities setting also affects Ordering behavior."> + <b-checkbox name="rattail.batch.purchase.allow_decimal_quantities" + v-model="simpleSettings['rattail.batch.purchase.allow_decimal_quantities']" + native-value="true" + @input="settingsNeedSaved = true"> + Allow Decimal Quantities + </b-checkbox> + </b-field> + <b-field> <b-checkbox name="rattail.batch.purchase.allow_expired_credits" v-model="simpleSettings['rattail.batch.purchase.allow_expired_credits']" @@ -142,6 +160,15 @@ </b-checkbox> </b-field> + <b-field> + <b-checkbox name="rattail.batch.purchase.receiving.auto_missing_credits" + v-model="simpleSettings['rattail.batch.purchase.receiving.auto_missing_credits']" + native-value="true" + @input="settingsNeedSaved = true"> + Auto-generate "missing" (DNR) credits for items not accounted for + </b-checkbox> + </b-field> + </div> <h3 class="block is-size-3">Mobile Interface</h3> diff --git a/tailbone/templates/receiving/create.mako b/tailbone/templates/receiving/create.mako index c31cb849..35ee878a 100644 --- a/tailbone/templates/receiving/create.mako +++ b/tailbone/templates/receiving/create.mako @@ -1,80 +1,6 @@ ## -*- coding: utf-8; -*- <%inherit file="/batch/create.mako" /> -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - % if not use_buefy: - ${self.func_show_batch_type()} - <script type="text/javascript"> - - % if master.handler.allow_truck_dump_receiving(): - var batch_vendor_map = ${json.dumps(batch_vendor_map)|n}; - % endif - - $(function() { - - $('.batch_type select').on('selectmenuchange', function(event, ui) { - show_batch_type(ui.item.value); - }); - - $('.truck_dump_batch_uuid select').on('selectmenuchange', function(event, ui) { - var form = $(this).parents('form'); - var uuid = ui.item.value ? batch_vendor_map[ui.item.value] : ''; - form.find('input[name="vendor_uuid"]').val(uuid); - }); - - show_batch_type(); - }); - - </script> - % endif -</%def> - -<%def name="func_show_batch_type()"> - <script type="text/javascript"> - - function show_batch_type(batch_type) { - - if (batch_type === undefined) { - batch_type = $('.field-wrapper.batch_type select').val(); - } - - if (batch_type == 'from_scratch') { - $('.field-wrapper.truck_dump_batch_uuid').hide(); - $('.field-wrapper.invoice_file').hide(); - $('.field-wrapper.invoice_parser_key').hide(); - $('.field-wrapper.vendor_uuid').show(); - $('.field-wrapper.date_ordered').show(); - $('.field-wrapper.date_received').show(); - $('.field-wrapper.po_number').show(); - $('.field-wrapper.invoice_date').show(); - $('.field-wrapper.invoice_number').show(); - - } else if (batch_type == 'truck_dump_children_first') { - $('.field-wrapper.truck_dump_batch_uuid').hide(); - $('.field-wrapper.invoice_file').hide(); - $('.field-wrapper.invoice_parser_key').hide(); - $('.field-wrapper.vendor_uuid').show(); - $('.field-wrapper.date_ordered').hide(); - $('.field-wrapper.date_received').show(); - $('.field-wrapper.po_number').hide(); - $('.field-wrapper.invoice_date').hide(); - $('.field-wrapper.invoice_number').hide(); - - } else if (batch_type == 'truck_dump_children_last') { - $('.field-wrapper.truck_dump_batch_uuid').hide(); - $('.field-wrapper.invoice_file').hide(); - $('.field-wrapper.invoice_parser_key').hide(); - $('.field-wrapper.vendor_uuid').show(); - $('.field-wrapper.date_ordered').hide(); - $('.field-wrapper.date_received').show(); - $('.field-wrapper.po_number').hide(); - $('.field-wrapper.invoice_date').hide(); - $('.field-wrapper.invoice_number').hide(); - } - } - - </script> -</%def> +## TODO: deprecate / remove this ${parent.body()} diff --git a/tailbone/templates/receiving/declare_credit.mako b/tailbone/templates/receiving/declare_credit.mako index 84d4dbec..a377e270 100644 --- a/tailbone/templates/receiving/declare_credit.mako +++ b/tailbone/templates/receiving/declare_credit.mako @@ -1,6 +1,5 @@ ## -*- coding: utf-8; -*- <%inherit file="/form.mako" /> -<%namespace file="/forms/util.mako" import="render_buefy_field" /> <%def name="title()">Declare Credit for Row #${row.sequence}</%def> @@ -11,36 +10,7 @@ % endif </%def> -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - % if not use_buefy: - <script type="text/javascript"> - - function toggleFields(creditType) { - if (creditType === undefined) { - creditType = $('select[name="credit_type"]').val(); - } - if (creditType == 'expired') { - $('.field-wrapper.expiration_date').show(); - } else { - $('.field-wrapper.expiration_date').hide(); - } - } - - $(function() { - - toggleFields(); - - $('select[name="credit_type"]').on('selectmenuchange', function(event, ui) { - toggleFields(ui.item.value); - }); - - }); - </script> - % endif -</%def> - -<%def name="render_buefy_form()"> +<%def name="render_form()"> <p class="block"> Please select the "state" of the product, and enter the @@ -60,45 +30,22 @@ if you need to "receive" instead of "convert" the product. </p> - ${parent.render_buefy_form()} + ${parent.render_form()} </%def> -<%def name="buefy_form_body()"> +<%def name="form_body()"> - ${render_buefy_field(dform['credit_type'])} + ${form.render_field_complete('credit_type')} - ${render_buefy_field(dform['quantity'])} + ${form.render_field_complete('quantity')} - ${render_buefy_field(dform['expiration_date'], bfield_kwargs={'v-show': "field_model_credit_type == 'expired'"})} + ${form.render_field_complete('expiration_date', bfield_attrs={'v-show': "field_model_credit_type == 'expired'"})} </%def> -<%def name="render_form()"> - % if use_buefy: - - ${form.render_deform(buttons=capture(self.render_form_buttons), form_body=capture(self.buefy_form_body))|n} - - % else: - - <p style="padding: 1em;"> - Please select the "state" of the product, and enter the appropriate - quantity. - </p> - - <p style="padding: 1em;"> - Note that this tool will <strong>deduct</strong> from the "received" - quantity, and <strong>add</strong> to the corresponding credit quantity. - </p> - - <p style="padding: 1em;"> - Please see ${h.link_to("Receive Row", url('{}.receive_row'.format(route_prefix), uuid=batch.uuid, row_uuid=row.uuid))} - if you need to "receive" instead of "convert" the product. - </p> - - ${parent.render_form()} - - % endif +<%def name="render_form_template()"> + ${form.render_deform(buttons=capture(self.render_form_buttons), form_body=capture(self.form_body))|n} </%def> diff --git a/tailbone/templates/receiving/receive_row.mako b/tailbone/templates/receiving/receive_row.mako index b17b118a..48dc6755 100644 --- a/tailbone/templates/receiving/receive_row.mako +++ b/tailbone/templates/receiving/receive_row.mako @@ -1,6 +1,5 @@ ## -*- coding: utf-8; -*- <%inherit file="/form.mako" /> -<%namespace file="/forms/util.mako" import="render_buefy_field" /> <%def name="title()">Receive for Row #${row.sequence}</%def> @@ -11,36 +10,7 @@ % endif </%def> -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - % if not use_buefy: - <script type="text/javascript"> - - function toggleFields(mode) { - if (mode === undefined) { - mode = $('select[name="mode"]').val(); - } - if (mode == 'expired') { - $('.field-wrapper.expiration_date').show(); - } else { - $('.field-wrapper.expiration_date').hide(); - } - } - - $(function() { - - toggleFields(); - - $('select[name="mode"]').on('selectmenuchange', function(event, ui) { - toggleFields(ui.item.value); - }); - - }); - </script> - % endif -</%def> - -<%def name="render_buefy_form()"> +<%def name="render_form()"> <p class="block"> Please select the "state" of the product, and enter the appropriate @@ -57,45 +27,22 @@ if you need to "convert" some already-received amount, into a credit. </p> - ${parent.render_buefy_form()} + ${parent.render_form()} </%def> -<%def name="buefy_form_body()"> +<%def name="form_body()"> - ${render_buefy_field(dform['mode'])} + ${form.render_field_complete('mode')} - ${render_buefy_field(dform['quantity'])} + ${form.render_field_complete('quantity')} - ${render_buefy_field(dform['expiration_date'], bfield_kwargs={'v-show': "field_model_mode == 'expired'"})} + ${form.render_field_complete('expiration_date', bfield_attrs={'v-show': "field_model_mode == 'expired'"})} </%def> -<%def name="render_form()"> - % if use_buefy: - - ${form.render_deform(buttons=capture(self.render_form_buttons), form_body=capture(self.buefy_form_body))|n} - - % else: - - <p style="padding: 1em;"> - Please select the "state" of the product, and enter the appropriate - quantity. - </p> - - <p style="padding: 1em;"> - Note that this tool will <strong>add</strong> the corresponding - quantities for the row. - </p> - - <p style="padding: 1em;"> - Please see ${h.link_to("Declare Credit", url('{}.declare_credit'.format(route_prefix), uuid=batch.uuid, row_uuid=row.uuid))} - if you need to "convert" some already-received amount, into a credit. - </p> - - ${parent.render_form()} - - % endif +<%def name="render_form_template()"> + ${form.render_deform(buttons=capture(self.render_form_buttons), form_body=capture(self.form_body))|n} </%def> diff --git a/tailbone/templates/receiving/view.mako b/tailbone/templates/receiving/view.mako index b16aa5b8..710dec4a 100644 --- a/tailbone/templates/receiving/view.mako +++ b/tailbone/templates/receiving/view.mako @@ -1,386 +1,153 @@ ## -*- coding: utf-8; -*- <%inherit file="/batch/view.mako" /> -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - % if not use_buefy and master.has_perm('edit_row'): - ${h.javascript_link(request.static_url('tailbone:static/js/numeric.js'))} - <script type="text/javascript"> - - % if not batch.executed: - // keep track of which cost value is currently being edited - var editing_catalog_cost = null; - var editing_invoice_cost = null; - - function start_editing(td) { - var value = null; - var text = td.text().replace(/^\s+|\s+$/g, ''); - if (text) { - td.data('previous-value', text); - td.text(''); - value = parseFloat(text.replace('$', '')); - } - var input = $('<input type="text" />'); - td.append(input); - value = value ? value.toString() : ''; - input.val(value).select().focus(); - } - - function start_editing_catalog_cost(td) { - start_editing(td); - editing_catalog_cost = td; - } - - function start_editing_invoice_cost(td) { - start_editing(td); - editing_invoice_cost = td; - } - - function start_editing_next_catalog_cost() { - var tr = editing_catalog_cost.parents('tr:first'); - var next = tr.next('tr:first'); - if (next.length) { - start_editing_catalog_cost(next.find('td.catalog_unit_cost')); - } else { - editing_catalog_cost = null; - } - } - - function start_editing_next_invoice_cost() { - var tr = editing_invoice_cost.parents('tr:first'); - var next = tr.next('tr:first'); - if (next.length) { - start_editing_invoice_cost(next.find('td.invoice_unit_cost')); - } else { - editing_invoice_cost = null; - } - } - - function cancel_edit(td) { - var input = td.find('input'); - input.blur(); - input.remove(); - var value = td.data('previous-value'); - if (value) { - td.text(value); - } - } - - function cancel_edit_catalog_cost() { - cancel_edit(editing_catalog_cost); - editing_catalog_cost = null; - } - - function cancel_edit_invoice_cost() { - cancel_edit(editing_invoice_cost); - editing_invoice_cost = null; - } - - % endif - - $(function() { - - % if not batch.executed: - $('.grid-wrapper').on('click', '.grid td.catalog_unit_cost', function() { - if (editing_catalog_cost) { - editing_catalog_cost.find('input').focus(); - return - } - if (editing_invoice_cost) { - editing_invoice_cost.find('input').focus(); - return - } - var td = $(this); - start_editing_catalog_cost(td); - }); - - $('.grid-wrapper').on('click', '.grid td.invoice_unit_cost', function() { - if (editing_invoice_cost) { - editing_invoice_cost.find('input').focus(); - return - } - if (editing_catalog_cost) { - editing_catalog_cost.find('input').focus(); - return - } - var td = $(this); - start_editing_invoice_cost(td); - }); - - $('.grid-wrapper').on('keyup', '.grid td.catalog_unit_cost input', function(event) { - var input = $(this); - - // let numeric keys modify input value - if (! key_modifies(event)) { - - // when user presses Enter while editing cost value, submit - // value to server for immediate persistence - if (event.which == 13) { - $('.grid-wrapper').mask("Updating cost..."); - var url = '${url('receiving.update_row_cost', uuid=batch.uuid)}'; - var td = input.parents('td:first'); - var tr = td.parents('tr:first'); - var data = { - '_csrf': $('[name="_csrf"]').val(), - 'row_uuid': tr.data('uuid'), - 'catalog_unit_cost': input.val() - }; - $.post(url, data, function(data) { - if (data.error) { - alert(data.error); - } else { - var total = null; - - // update catalog cost for row - td.text(data.row.catalog_unit_cost); - - // mark cost as confirmed - if (data.row.catalog_cost_confirmed) { - tr.addClass('catalog_cost_confirmed'); - } - - input.blur(); - input.remove(); - start_editing_next_catalog_cost(); - } - $('.grid-wrapper').unmask(); - }); - - // When user presses Escape while editing totals, cancel the edit. - } else if (event.which == 27) { - cancel_edit_catalog_cost(); - - // Most other keys at this point should be unwanted... - } else if (! key_allowed(event)) { - return false; - } - } - }); - - $('.grid-wrapper').on('keyup', '.grid td.invoice_unit_cost input', function(event) { - var input = $(this); - - // let numeric keys modify input value - if (! key_modifies(event)) { - - // when user presses Enter while editing cost value, submit - // value to server for immediate persistence - if (event.which == 13) { - $('.grid-wrapper').mask("Updating cost..."); - var url = '${url('receiving.update_row_cost', uuid=batch.uuid)}'; - var td = input.parents('td:first'); - var tr = td.parents('tr:first'); - var data = { - '_csrf': $('[name="_csrf"]').val(), - 'row_uuid': tr.data('uuid'), - 'invoice_unit_cost': input.val() - }; - $.post(url, data, function(data) { - if (data.error) { - alert(data.error); - } else { - var total = null; - - // update unit cost for row - td.text(data.row.invoice_unit_cost); - - // update invoice total for row - total = tr.find('td.invoice_total_calculated'); - total.text('$' + data.row.invoice_total_calculated); - - // update invoice total for batch - total = $('.form .field-wrapper.invoice_total_calculated .field'); - total.text('$' + data.batch.invoice_total_calculated); - - // mark cost as confirmed - if (data.row.invoice_cost_confirmed) { - tr.addClass('invoice_cost_confirmed'); - } - - input.blur(); - input.remove(); - start_editing_next_invoice_cost(); - } - $('.grid-wrapper').unmask(); - }); - - // When user presses Escape while editing totals, cancel the edit. - } else if (event.which == 27) { - cancel_edit_invoice_cost(); - - // Most other keys at this point should be unwanted... - } else if (! key_allowed(event)) { - return false; - } - } - }); - % endif - - $('.grid-wrapper').on('click', '.grid .actions a.transform', function() { - - var form = $('form[name="transform-unit-form"]'); - var row_uuid = $(this).parents('tr:first').data('uuid'); - form.find('[name="row_uuid"]').val(row_uuid); - - $.get(form.attr('action'), {row_uuid: row_uuid}, function(data) { - - if (typeof(data) == 'object') { - alert(data.error); - - } else { - $('#transform-unit-dialog').html(data); - $('#transform-unit-dialog').dialog({ - title: "Transform Pack to Unit Item", - width: 800, - height: 450, - modal: true, - buttons: [ - { - text: "Transform", - click: function(event) { - disable_button(dialog_button(event)); - form.submit(); - } - }, - { - text: "Cancel", - click: function() { - $(this).dialog('close'); - } - } - ] - }); - } - }); - - return false; - }); - - }); - - </script> - % endif -</%def> - <%def name="extra_styles()"> ${parent.extra_styles()} - % if use_buefy: - <style type="text/css"> - % if allow_edit_catalog_unit_cost: - td.catalog_unit_cost { - cursor: pointer; - background-color: #fcc; - } - tr.catalog_cost_confirmed td.catalog_unit_cost { - background-color: #cfc; - } - % endif - % if allow_edit_invoice_unit_cost: - td.invoice_unit_cost { - cursor: pointer; - background-color: #fcc; - } - tr.invoice_cost_confirmed td.invoice_unit_cost { - background-color: #cfc; - } - % endif - </style> - % elif not use_buefy and not batch.executed and master.has_perm('edit_row'): - <style type="text/css"> - .grid tr:not(.header) td.catalog_unit_cost, - .grid tr:not(.header) td.invoice_unit_cost { - cursor: pointer; - background-color: #fcc; + <style type="text/css"> + % if allow_edit_catalog_unit_cost: + td.c_catalog_unit_cost { + cursor: pointer; + background-color: #fcc; } - .grid tr.catalog_cost_confirmed:not(.header) td.catalog_unit_cost, - .grid tr.invoice_cost_confirmed:not(.header) td.invoice_unit_cost { - background-color: #cfc; + tr.catalog_cost_confirmed td.c_catalog_unit_cost { + background-color: #cfc; } - .grid td.catalog_unit_cost input, - .grid td.invoice_unit_cost input { - width: 4rem; + % endif + % if allow_edit_invoice_unit_cost: + td.c_invoice_unit_cost { + cursor: pointer; + background-color: #fcc; } - </style> - % endif + tr.invoice_cost_confirmed td.c_invoice_unit_cost { + background-color: #cfc; + } + % endif + </style> </%def> <%def name="render_po_vs_invoice_helper()"> - % if use_buefy and master.handler.has_purchase_order(batch) and master.handler.has_invoice_file(batch): - <div class="object-helper"> - <h3>PO vs. Invoice</h3> - <div class="object-helper-content"> - ${po_vs_invoice_breakdown_grid} + % if master.handler.has_purchase_order(batch) and master.handler.has_invoice_file(batch): + <nav class="panel"> + <p class="panel-heading">PO vs. Invoice</p> + <div class="panel-block"> + <div style="width: 100%;"> + ${po_vs_invoice_breakdown_grid} + </div> </div> - </div> + </nav> % endif </%def> -<%def name="render_auto_receive_helper()"> - % if master.has_perm('auto_receive') and master.can_auto_receive(batch): +<%def name="render_tools_helper()"> + % if allow_confirm_all_costs or (master.has_perm('auto_receive') and master.can_auto_receive(batch)): + <nav class="panel"> + <p class="panel-heading">Tools</p> + <div class="panel-block"> + <div style="display: flex; flex-direction: column; gap: 0.5rem; width: 100%;"> - <div class="object-helper"> - <h3>Tools</h3> - <div class="object-helper-content"> - % if use_buefy: - <b-button type="is-primary" - @click="autoReceiveShowDialog = true" - icon-pack="fas" - icon-left="check"> - Auto-Receive All Items - </b-button> - % else: - ${h.form(url('{}.auto_receive'.format(route_prefix), uuid=batch.uuid), class_='autodisable')} - ${h.csrf_token(request)} - ${h.submit('submit', "Auto-Receive All Items")} - ${h.end_form()} - % endif - </div> - </div> - - % if use_buefy: - <b-modal has-modal-card - :active.sync="autoReceiveShowDialog"> - <div class="modal-card"> - - <header class="modal-card-head"> - <p class="modal-card-title">Auto-Receive All Items</p> - </header> - - <section class="modal-card-body"> - <p class="block"> - You can automatically set the "received" quantity to - match the "shipped" quantity for all items, based on - the invoice. - </p> - <p class="block"> - Would you like to do so? - </p> - </section> - - <footer class="modal-card-foot"> - <b-button @click="autoReceiveShowDialog = false"> - Cancel - </b-button> - ${h.form(url('{}.auto_receive'.format(route_prefix), uuid=batch.uuid), **{'@submit': 'autoReceiveSubmitting = true'})} - ${h.csrf_token(request)} + % if allow_confirm_all_costs: <b-button type="is-primary" - native-type="submit" - :disabled="autoReceiveSubmitting" + icon-pack="fas" + icon-left="check" + @click="confirmAllCostsShowDialog = true"> + Confirm All Costs + </b-button> + <b-modal has-modal-card + :active.sync="confirmAllCostsShowDialog"> + <div class="modal-card"> + + <header class="modal-card-head"> + <p class="modal-card-title">Confirm All Costs</p> + </header> + + <section class="modal-card-body"> + <p class="block"> + You can automatically mark all catalog and invoice + cost amounts as "confirmed" if you wish. + </p> + <p class="block"> + Would you like to do this? + </p> + </section> + + <footer class="modal-card-foot"> + <b-button @click="confirmAllCostsShowDialog = false"> + Cancel + </b-button> + ${h.form(url(f'{route_prefix}.confirm_all_costs', uuid=batch.uuid), **{'@submit': 'confirmAllCostsSubmitting = true'})} + ${h.csrf_token(request)} + <b-button type="is-primary" + native-type="submit" + :disabled="confirmAllCostsSubmitting" + icon-pack="fas" + icon-left="check"> + {{ confirmAllCostsSubmitting ? "Working, please wait..." : "Confirm All" }} + </b-button> + ${h.end_form()} + </footer> + </div> + </b-modal> + % endif + + % if master.has_perm('auto_receive') and master.can_auto_receive(batch): + <b-button type="is-primary" + @click="autoReceiveShowDialog = true" icon-pack="fas" icon-left="check"> - {{ autoReceiveSubmitting ? "Working, please wait..." : "Auto-Receive All Items" }} + Auto-Receive All Items </b-button> - ${h.end_form()} - </footer> - </div> - </b-modal> - % endif + <b-modal has-modal-card + :active.sync="autoReceiveShowDialog"> + <div class="modal-card"> + + <header class="modal-card-head"> + <p class="modal-card-title">Auto-Receive All Items</p> + </header> + + <section class="modal-card-body"> + <p class="block"> + You can automatically set the "received" quantity to + match the "shipped" quantity for all items, based on + the invoice. + </p> + <p class="block"> + Would you like to do so? + </p> + </section> + + <footer class="modal-card-foot"> + <b-button @click="autoReceiveShowDialog = false"> + Cancel + </b-button> + ${h.form(url('{}.auto_receive'.format(route_prefix), uuid=batch.uuid), **{'@submit': 'autoReceiveSubmitting = true'})} + ${h.csrf_token(request)} + <b-button type="is-primary" + native-type="submit" + :disabled="autoReceiveSubmitting" + icon-pack="fas" + icon-left="check"> + {{ autoReceiveSubmitting ? "Working, please wait..." : "Auto-Receive All Items" }} + </b-button> + ${h.end_form()} + </footer> + </div> + </b-modal> + % endif + </div> + </div> + </nav> % endif </%def> -<%def name="render_this_page_template()"> - ${parent.render_this_page_template()} +<%def name="object_helpers()"> + ${self.render_status_breakdown()} + ${self.render_po_vs_invoice_helper()} + ${self.render_execute_helper()} + ${self.render_tools_helper()} +</%def> +<%def name="render_vue_templates()"> + ${parent.render_vue_templates()} % if allow_edit_catalog_unit_cost or allow_edit_invoice_unit_cost: <script type="text/x-template" id="receiving-cost-editor-template"> <div> @@ -390,24 +157,27 @@ <b-input v-model="inputValue" ref="input" v-show="editing" + size="is-small" @keydown.native="inputKeyDown" - @blur="inputBlur"> + @focus="selectAll" + @blur="inputBlur" + style="width: 6rem;"> </b-input> </div> </script> % endif </%def> -<%def name="object_helpers()"> - ${self.render_status_breakdown()} - ${self.render_po_vs_invoice_helper()} - ${self.render_execute_helper()} - ${self.render_auto_receive_helper()} -</%def> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> + % if allow_confirm_all_costs: + + ThisPageData.confirmAllCostsShowDialog = false + ThisPageData.confirmAllCostsSubmitting = false + + % endif ThisPageData.autoReceiveShowDialog = false ThisPageData.autoReceiveSubmitting = false @@ -463,6 +233,7 @@ let ReceivingCostEditor = { template: '#receiving-cost-editor-template', + mixins: [SimpleRequestMixin], props: { row: Object, 'field': String, @@ -476,8 +247,16 @@ }, methods: { + selectAll() { + // nb. must traverse into the <b-input> element + let trueInput = this.$refs.input.$el.firstChild + trueInput.select() + }, + startEdit() { - this.inputValue = this.value + // nb. must strip $ sign etc. to get the real value + let value = this.value.replace(/[^\-\d\.]/g, '') + this.inputValue = parseFloat(value) || null this.editing = true this.$nextTick(() => { this.$refs.input.focus() @@ -512,41 +291,21 @@ submitEdit() { let url = '${url('{}.update_row_cost'.format(route_prefix), uuid=batch.uuid)}' - // TODO: should get csrf token from parent component? - let csrftoken = ${json.dumps(request.session.get_csrf_token() or request.session.new_csrf_token())|n} - let headers = {'${csrf_header_name}': csrftoken} - let params = { row_uuid: this.$props.row.uuid, } params[this.$props.field] = this.inputValue - this.$http.post(url, params, {headers: headers}).then(response => { - if (!response.data.error) { + this.simplePOST(url, params, response => { - // let parent know cost value has changed - // (this in turn will update data in *this* - // component, and display will refresh) - this.$emit('input', response.data.row[this.$props.field], - this.$props.row._index) + // let parent know cost value has changed + // (this in turn will update data in *this* + // component, and display will refresh) + this.$emit('input', response.data.row[this.$props.field], + this.$props.row._index) - // and hide the input box - this.editing = false - - } else { - this.$buefy.toast.open({ - message: "Submit failed: " + response.data.error, - type: 'is-warning', - duration: 4000, // 4 seconds - }) - } - - }, response => { - this.$buefy.toast.open({ - message: "Submit failed: (unknown error)", - type: 'is-warning', - duration: 4000, // 4 seconds - }) + // and hide the input box + this.editing = false }) }, }, @@ -558,22 +317,34 @@ % if allow_edit_catalog_unit_cost: - ${rows_grid.component_studly}.methods.catalogUnitCostClicked = function(row) { + ${rows_grid.vue_component}.methods.catalogUnitCostClicked = function(row) { // start edit for clicked cell this.$refs['catalogUnitCost_' + row.uuid].startEdit() } - ${rows_grid.component_studly}.methods.catalogCostConfirmed = function(amount, index) { + ${rows_grid.vue_component}.methods.catalogCostConfirmed = function(amount, index) { // update display to indicate cost was confirmed this.addRowClass(index, 'catalog_cost_confirmed') - // start editing next row, unless there are no more - let nextRow = index + 1 - if (this.data.length > nextRow) { - nextRow = this.data[nextRow] - this.$refs['catalogUnitCost_' + nextRow.uuid].startEdit() + // advance to next editable cost input... + + // first try invoice cost within same row + let thisRow = this.data[index] + let cost = this.$refs['invoiceUnitCost_' + thisRow.uuid] + if (!cost) { + + // or, try catalog cost from next row + let nextRow = this.data[index + 1] + if (nextRow) { + cost = this.$refs['catalogUnitCost_' + nextRow.uuid] + } + } + + // start editing next cost if found + if (cost) { + cost.startEdit() } } @@ -581,22 +352,35 @@ % if allow_edit_invoice_unit_cost: - ${rows_grid.component_studly}.methods.invoiceUnitCostClicked = function(row) { + ${rows_grid.vue_component}.methods.invoiceUnitCostClicked = function(row) { // start edit for clicked cell this.$refs['invoiceUnitCost_' + row.uuid].startEdit() } - ${rows_grid.component_studly}.methods.invoiceCostConfirmed = function(amount, index) { + ${rows_grid.vue_component}.methods.invoiceCostConfirmed = function(amount, index) { // update display to indicate cost was confirmed this.addRowClass(index, 'invoice_cost_confirmed') - // start editing next row, unless there are no more - let nextRow = index + 1 - if (this.data.length > nextRow) { - nextRow = this.data[nextRow] - this.$refs['invoiceUnitCost_' + nextRow.uuid].startEdit() + // advance to next editable cost input... + + // nb. always advance to next row, regardless of field + let nextRow = this.data[index + 1] + if (nextRow) { + + // first try catalog cost from next row + let cost = this.$refs['catalogUnitCost_' + nextRow.uuid] + if (!cost) { + + // or, try invoice cost from next row + cost = this.$refs['invoiceUnitCost_' + nextRow.uuid] + } + + // start editing next cost if found + if (cost) { + cost.startEdit() + } } } @@ -604,17 +388,3 @@ </script> </%def> - - -${parent.body()} - -% if not use_buefy and master.handler.allow_truck_dump_receiving() and master.has_perm('edit_row'): - ${h.form(url('{}.transform_unit_row'.format(route_prefix), uuid=batch.uuid), name='transform-unit-form')} - ${h.csrf_token(request)} - ${h.hidden('row_uuid')} - ${h.end_form()} - - <div id="transform-unit-dialog" style="display: none;"> - <p>hello world</p> - </div> -% endif diff --git a/tailbone/templates/receiving/view_row.mako b/tailbone/templates/receiving/view_row.mako index dca71c35..086754c6 100644 --- a/tailbone/templates/receiving/view_row.mako +++ b/tailbone/templates/receiving/view_row.mako @@ -4,485 +4,489 @@ <%def name="extra_styles()"> ${parent.extra_styles()} <style type="text/css"> - % if use_buefy: - nav.panel { - margin: 0.5rem; - } + nav.panel { + margin: 0.5rem; + } - .header-fields { - margin-top: 1rem; - } + .header-fields { + margin-top: 1rem; + } - .header-fields .field.is-horizontal { - margin-left: 3rem; - } + .header-fields .field.is-horizontal { + margin-left: 3rem; + } - .header-fields .field.is-horizontal .field-label .label { - white-space: nowrap; - } + .header-fields .field.is-horizontal .field-label .label { + white-space: nowrap; + } - .quantity-form-fields { - margin: 2rem auto; - padding-left: 2rem; - } + .quantity-form-fields { + margin: 2rem; + } - .quantity-form-fields .field.is-horizontal .field-label .label { - text-align: left; - width: 8rem; - } + .quantity-form-fields .field.is-horizontal .field-label .label { + text-align: left; + width: 8rem; + } - .remove-credit .field.is-horizontal .field-label .label { - white-space: nowrap; - } + .remove-credit .field.is-horizontal .field-label .label { + white-space: nowrap; + } - % endif </style> </%def> -<%def name="object_helpers()"> - ${parent.object_helpers()} - % if not use_buefy and master.row_editable(row) and not batch.is_truck_dump_child(): - <div class="object-helper"> - <h3>Receiving Tools</h3> - <div class="object-helper-content"> - <div style="white-space: nowrap;"> - ${h.link_to("Receive Product", url('{}.receive_row'.format(route_prefix), uuid=batch.uuid, row_uuid=row.uuid), class_='button autodisable')} - ${h.link_to("Declare Credit", url('{}.declare_credit'.format(route_prefix), uuid=batch.uuid, row_uuid=row.uuid), class_='button autodisable')} +<%def name="page_content()"> + + <b-field grouped class="header-fields"> + + <b-field label="Sequence" horizontal> + {{ rowData.sequence }} + </b-field> + + <b-field label="Status" horizontal> + {{ rowData.status }} + </b-field> + + <b-field label="Calculated Total" horizontal> + {{ rowData.invoice_total_calculated }} + </b-field> + + </b-field> + + <div style="display: flex;"> + + <nav class="panel"> + <p class="panel-heading">Product</p> + <div class="panel-block"> + <div style="display: flex; gap: 1rem;"> + <div style="flex-grow: 1;" + % if request.use_oruga: + class="form-wrapper" + % endif + > + ${form.render_field_readonly('item_entry')} + % if row.product: + ${form.render_field_readonly(product_key_field)} + ${form.render_field_readonly('product')} + % else: + ${form.render_field_readonly(product_key_field)} + % if product_key_field != 'upc': + ${form.render_field_readonly('upc')} + % endif + ${form.render_field_readonly('brand_name')} + ${form.render_field_readonly('description')} + ${form.render_field_readonly('size')} + % endif + ${form.render_field_readonly('vendor_code')} + ${form.render_field_readonly('case_quantity')} + ${form.render_field_readonly('catalog_unit_cost')} </div> + % if image_url: + <div> + ${h.image(image_url, "Product Image", width=150, height=150)} + </div> + % endif </div> </div> - % endif -</%def> + </nav> -<%def name="page_content()"> - % if use_buefy: - - <b-field grouped class="header-fields"> - - <b-field label="Sequence" horizontal> - {{ rowData.sequence }} - </b-field> - - <b-field label="Status" horizontal> - {{ rowData.status }} - </b-field> - - <b-field label="Calculated Total" horizontal> - {{ rowData.invoice_total_calculated }} - </b-field> - - </b-field> - - <div style="display: flex;"> - - <nav class="panel"> - <p class="panel-heading">Product</p> - <div class="panel-block"> - <div style="display: flex;"> - <div> - % if row.product: - ${form.render_field_readonly(product_key_field)} - ${form.render_field_readonly('product')} - % else: - ${form.render_field_readonly(product_key_field)} - ${form.render_field_readonly('item_entry')} - % if product_key_field != 'upc': - ${form.render_field_readonly('upc')} - % endif - ${form.render_field_readonly('brand_name')} - ${form.render_field_readonly('description')} - ${form.render_field_readonly('size')} - % endif - ${form.render_field_readonly('vendor_code')} - ${form.render_field_readonly('case_quantity')} - ${form.render_field_readonly('catalog_unit_cost')} - </div> - % if image_url: - <div class="is-pulled-right"> - ${h.image(image_url, "Product Image", width=150, height=150)} - </div> - % endif - </div> - </div> - </nav> - - <nav class="panel"> - <p class="panel-heading">Quantities</p> - <div class="panel-block"> - <div> - <div class="quantity-form-fields"> - - <b-field label="Ordered" horizontal> - {{ rowData.ordered }} - </b-field> - - <hr /> - - <b-field label="Shipped" horizontal> - {{ rowData.shipped }} - </b-field> - - <hr /> - - <b-field label="Received" horizontal - v-if="rowData.received"> - {{ rowData.received }} - </b-field> - - <b-field label="Damaged" horizontal - v-if="rowData.damaged"> - {{ rowData.damaged }} - </b-field> - - <b-field label="Expired" horizontal - v-if="rowData.expired"> - {{ rowData.expired }} - </b-field> - - <b-field label="Mispick" horizontal - v-if="rowData.mispick"> - {{ rowData.mispick }} - </b-field> - - <b-field label="Missing" horizontal - v-if="rowData.missing"> - {{ rowData.missing }} - </b-field> - - </div> - - % if master.has_perm('edit_row') and master.row_editable(row): - <div class="buttons"> - <b-button type="is-primary" - @click="accountForProductInit()" - icon-pack="fas" - icon-left="check"> - Account for Product - </b-button> - <b-button type="is-warning" - @click="declareCreditInit()" - :disabled="!rowData.received" - icon-pack="fas" - icon-left="thumbs-down"> - Declare Credit - </b-button> - </div> - % endif - - </div> - </div> - </nav> - - </div> - - <b-modal has-modal-card - :active.sync="accountForProductShowDialog"> - <div class="modal-card"> - - <header class="modal-card-head"> - <p class="modal-card-title">Account for Product</p> - </header> - - <section class="modal-card-body"> - - <p class="block"> - This is for declaring that you have encountered some - amount of the product. Ideally you will just - "receive" it normally, but you can indicate a "credit" - state if there is something amiss. - </p> - - <b-field grouped> - - % if allow_cases: - <b-field label="Case Qty."> - <span class="control"> - {{ rowData.case_quantity }} - </span> - </b-field> - - <span class="control"> - - </span> - % endif - - <b-field label="Product State" - :type="accountForProductMode ? null : 'is-danger'"> - <b-select v-model="accountForProductMode"> - <option v-for="mode in possibleReceivingModes" - :key="mode" - :value="mode"> - {{ mode }} - </option> - </b-select> - </b-field> - - <b-field label="Expiration Date" - v-show="accountForProductMode == 'expired'" - :type="accountForProductExpiration ? null : 'is-danger'"> - <tailbone-datepicker v-model="accountForProductExpiration"> - </tailbone-datepicker> - </b-field> + <nav class="panel"> + <p class="panel-heading">Quantities</p> + <div class="panel-block"> + <div> + <div class="quantity-form-fields"> + <b-field label="Ordered" horizontal> + {{ rowData.ordered }} </b-field> - <div class="level"> - <div class="level-left"> + <hr /> - <div class="level-item"> - <numeric-input v-model="accountForProductQuantity" - ref="accountForProductQuantityInput"> - </numeric-input> - </div> + <b-field label="Shipped" horizontal> + {{ rowData.shipped }} + </b-field> - <div class="level-item"> - % if allow_cases: - <b-field> - <b-radio-button v-model="accountForProductUOM" - @click.native="accountForProductUOMClicked('units')" - native-value="units"> - Units - </b-radio-button> - <b-radio-button v-model="accountForProductUOM" - @click.native="accountForProductUOMClicked('cases')" - native-value="cases"> - Cases - </b-radio-button> - </b-field> - % else: - <b-field> - <input type="hidden" v-model="accountForProductUOM" /> - Units - </b-field> - % endif - </div> + <hr /> - % if allow_cases: - <div class="level-item" - v-if="accountForProductUOM == 'cases' && accountForProductQuantity"> - = {{ accountForProductTotalUnits }} - </div> - % endif + <b-field label="Received" horizontal + v-if="rowData.received"> + {{ rowData.received }} + </b-field> + <b-field label="Damaged" horizontal + v-if="rowData.damaged"> + {{ rowData.damaged }} + </b-field> + + <b-field label="Expired" horizontal + v-if="rowData.expired"> + {{ rowData.expired }} + </b-field> + + <b-field label="Mispick" horizontal + v-if="rowData.mispick"> + {{ rowData.mispick }} + </b-field> + + <b-field label="Missing" horizontal + v-if="rowData.missing"> + {{ rowData.missing }} + </b-field> + + </div> + + % if master.has_perm('edit_row') and master.row_editable(row): + <div class="buttons"> + <b-button type="is-primary" + @click="accountForProductInit()" + icon-pack="fas" + icon-left="check"> + Account for Product + </b-button> + <b-button type="is-warning" + @click="declareCreditInit()" + :disabled="!rowData.received" + icon-pack="fas" + icon-left="thumbs-down"> + Declare Credit + </b-button> </div> - </div> + % endif - </section> - - <footer class="modal-card-foot"> - <b-button @click="accountForProductShowDialog = false"> - Cancel - </b-button> - <b-button type="is-primary" - @click="accountForProductSubmit()" - :disabled="accountForProductSubmitDisabled" - icon-pack="fas" - icon-left="check"> - {{ accountForProductSubmitting ? "Working, please wait..." : "Account for Product" }} - </b-button> - </footer> </div> - </b-modal> + </div> + </nav> - <b-modal has-modal-card - :active.sync="declareCreditShowDialog"> - <div class="modal-card"> + </div> - <header class="modal-card-head"> - <p class="modal-card-title">Declare Credit</p> - </header> + <${b}-modal has-modal-card + % if request.use_oruga: + v-model:active="accountForProductShowDialog" + % else: + :active.sync="accountForProductShowDialog" + % endif + > + <div class="modal-card"> - <section class="modal-card-body"> + <header class="modal-card-head"> + <p class="modal-card-title">Account for Product</p> + </header> - <p class="block"> - This is for <span class="is-italic">converting</span> - some amount you <span class="is-italic">already - received</span>, and now declaring there is something - wrong with it. - </p> + <section class="modal-card-body"> - <b-field grouped> + <p class="block"> + This is for declaring that you have encountered some + amount of the product. Ideally you will just + "receive" it normally, but you can indicate a "credit" + state if there is something amiss. + </p> - <b-field label="Received"> + <b-field grouped> + + % if allow_cases: + <b-field label="Case Qty."> <span class="control"> - {{ rowData.received }} + {{ rowData.case_quantity }} </span> </b-field> <span class="control"> </span> + % endif - <b-field label="Credit Type" - :type="declareCreditType ? null : 'is-danger'"> - <b-select v-model="declareCreditType"> - <option v-for="typ in possibleCreditTypes" - :key="typ" - :value="typ"> - {{ typ }} - </option> - </b-select> - </b-field> + <b-field label="Product State" + :type="accountForProductMode ? null : 'is-danger'"> + <b-select v-model="accountForProductMode"> + <option v-for="mode in possibleReceivingModes" + :key="mode" + :value="mode"> + {{ mode }} + </option> + </b-select> + </b-field> - <b-field label="Expiration Date" - v-show="declareCreditType == 'expired'" - :type="declareCreditExpiration ? null : 'is-danger'"> - <tailbone-datepicker v-model="declareCreditExpiration"> - </tailbone-datepicker> - </b-field> + <b-field label="Expiration Date" + v-show="accountForProductMode == 'expired'" + :type="accountForProductExpiration ? null : 'is-danger'"> + <tailbone-datepicker v-model="accountForProductExpiration"> + </tailbone-datepicker> + </b-field> - </b-field> + </b-field> - <div class="level"> - <div class="level-left"> + <div style="display: flex; gap: 0.5rem; align-items: center;"> - <div class="level-item"> - <numeric-input v-model="declareCreditQuantity" - ref="declareCreditQuantityInput"> - </numeric-input> - </div> + <numeric-input v-model="accountForProductQuantity" + ref="accountForProductQuantityInput"> + </numeric-input> - <div class="level-item"> - % if allow_cases: - <b-field> - <b-radio-button v-model="declareCreditUOM" - @click.native="declareCreditUOMClicked('units')" - native-value="units"> - Units - </b-radio-button> - <b-radio-button v-model="declareCreditUOM" - @click.native="declareCreditUOMClicked('cases')" - native-value="cases"> - Cases - </b-radio-button> - </b-field> - % else: - <b-field> - <input type="hidden" v-model="declareCreditUOM" /> - Units - </b-field> - % endif - </div> + % if allow_cases: + % if request.use_oruga: + <div> + <o-button label="Units" + :variant="accountForProductUOM == 'units' ? 'primary' : null" + @click="accountForProductUOMClicked('units')" /> + <o-button label="Cases" + :variant="accountForProductUOM == 'cases' ? 'primary' : null" + @click="accountForProductUOMClicked('cases')" /> + </div> + % else: + <b-field + ## TODO: a bit hacky, but otherwise buefy styles throw us off here + style="margin-bottom: 0;"> + <b-radio-button v-model="accountForProductUOM" + @click.native="accountForProductUOMClicked('units')" + native-value="units"> + Units + </b-radio-button> + <b-radio-button v-model="accountForProductUOM" + @click.native="accountForProductUOMClicked('cases')" + native-value="cases"> + Cases + </b-radio-button> + </b-field> + % endif + <span v-if="accountForProductUOM == 'cases' && accountForProductQuantity"> + = {{ accountForProductTotalUnits }} + </span> - % if allow_cases: - <div class="level-item" - v-if="declareCreditUOM == 'cases' && declareCreditQuantity"> - = {{ declareCreditTotalUnits }} + % else: + <input type="hidden" v-model="accountForProductUOM" /> + <span>Units</span> + % endif + + </div> + </section> + + <footer class="modal-card-foot"> + <b-button @click="accountForProductShowDialog = false"> + Cancel + </b-button> + <b-button type="is-primary" + @click="accountForProductSubmit()" + :disabled="accountForProductSubmitDisabled" + icon-pack="fas" + icon-left="check"> + {{ accountForProductSubmitting ? "Working, please wait..." : "Account for Product" }} + </b-button> + </footer> + </div> + </${b}-modal> + + <${b}-modal has-modal-card + % if request.use_oruga: + v-model:active="declareCreditShowDialog" + % else: + :active.sync="declareCreditShowDialog" + % endif + > + <div class="modal-card"> + + <header class="modal-card-head"> + <p class="modal-card-title">Declare Credit</p> + </header> + + <section class="modal-card-body"> + + <p class="block"> + This is for <span class="is-italic">converting</span> + some amount you <span class="is-italic">already + received</span>, and now declaring there is something + wrong with it. + </p> + + <b-field grouped> + + <b-field label="Received"> + <span class="control"> + {{ rowData.received }} + </span> + </b-field> + + <span class="control"> + + </span> + + <b-field label="Credit Type" + :type="declareCreditType ? null : 'is-danger'"> + <b-select v-model="declareCreditType"> + <option v-for="typ in possibleCreditTypes" + :key="typ" + :value="typ"> + {{ typ }} + </option> + </b-select> + </b-field> + + <b-field label="Expiration Date" + v-show="declareCreditType == 'expired'" + :type="declareCreditExpiration ? null : 'is-danger'"> + <tailbone-datepicker v-model="declareCreditExpiration"> + </tailbone-datepicker> + </b-field> + + </b-field> + + <div style="display: flex; gap: 0.5rem; align-items: center;"> + + <numeric-input v-model="declareCreditQuantity" + ref="declareCreditQuantityInput"> + </numeric-input> + + % if allow_cases: + + % if request.use_oruga: + <div> + <o-button label="Units" + :variant="declareCreditUOM == 'units' ? 'primary' : null" + @click="declareCreditUOM = 'units'" /> + <o-button label="Cases" + :variant="declareCreditUOM == 'cases' ? 'primary' : null" + @click="declareCreditUOM = 'cases'" /> </div> + % else: + <b-field + ## TODO: a bit hacky, but otherwise buefy styles throw us off here + style="margin-bottom: 0;"> + <b-radio-button v-model="declareCreditUOM" + @click.native="declareCreditUOMClicked('units')" + native-value="units"> + Units + </b-radio-button> + <b-radio-button v-model="declareCreditUOM" + @click.native="declareCreditUOMClicked('cases')" + native-value="cases"> + Cases + </b-radio-button> + </b-field> % endif + <span v-if="declareCreditUOM == 'cases' && declareCreditQuantity"> + = {{ declareCreditTotalUnits }} + </span> - </div> - </div> + % else: + <b-field> + <input type="hidden" v-model="declareCreditUOM" /> + Units + </b-field> + % endif - </section> - - <footer class="modal-card-foot"> - <b-button @click="declareCreditShowDialog = false"> - Cancel - </b-button> - <b-button type="is-warning" - @click="declareCreditSubmit()" - :disabled="declareCreditSubmitDisabled" - icon-pack="fas" - icon-left="thumbs-down"> - {{ declareCreditSubmitting ? "Working, please wait..." : "Declare this Credit" }} - </b-button> - </footer> </div> - </b-modal> + </section> - <nav class="panel" > - <p class="panel-heading">Credits</p> - <div class="panel-block"> - <div> - ${form.render_field_value('credits')} - </div> - </div> - </nav> - - <b-modal has-modal-card - :active.sync="removeCreditShowDialog"> - <div class="modal-card remove-credit"> - - <header class="modal-card-head"> - <p class="modal-card-title">Un-Declare Credit</p> - </header> - - <section class="modal-card-body"> - - <p class="block"> - If you un-declare this credit, the quantity below will - be added back to the - <span class="has-text-weight-bold">Received</span> tally. - </p> - - <b-field label="Credit Type" horizontal> - {{ removeCreditRow.credit_type }} - </b-field> - - <b-field label="Quantity" horizontal> - {{ removeCreditRow.shorted }} - </b-field> - - </section> - - <footer class="modal-card-foot"> - <b-button @click="removeCreditShowDialog = false"> - Cancel - </b-button> - <b-button type="is-danger" - @click="removeCreditSubmit()" - :disabled="removeCreditSubmitting" - icon-pack="fas" - icon-left="trash"> - {{ removeCreditSubmitting ? "Working, please wait..." : "Un-Declare this Credit" }} - </b-button> - </footer> - </div> - </b-modal> - - <div style="display: flex;"> - - % if master.batch_handler.has_purchase_order(batch): - <nav class="panel" > - <p class="panel-heading">Purchase Order</p> - <div class="panel-block"> - <div> - ${form.render_field_readonly('po_line_number')} - ${form.render_field_readonly('po_unit_cost')} - ${form.render_field_readonly('po_case_size')} - ${form.render_field_readonly('po_total')} - </div> - </div> - </nav> - % endif - - % if master.batch_handler.has_invoice_file(batch): - <nav class="panel" > - <p class="panel-heading">Invoice</p> - <div class="panel-block"> - <div> - ${form.render_field_readonly('invoice_line_number')} - ${form.render_field_readonly('invoice_unit_cost')} - ${form.render_field_readonly('invoice_case_size')} - ${form.render_field_readonly('invoice_total', label="Invoice Total")} - </div> - </div> - </nav> - % endif + <footer class="modal-card-foot"> + <b-button @click="declareCreditShowDialog = false"> + Cancel + </b-button> + <b-button type="is-warning" + @click="declareCreditSubmit()" + :disabled="declareCreditSubmitDisabled" + icon-pack="fas" + icon-left="thumbs-down"> + {{ declareCreditSubmitting ? "Working, please wait..." : "Declare this Credit" }} + </b-button> + </footer> + </div> + </${b}-modal> + <nav class="panel" > + <p class="panel-heading">Credits</p> + <div class="panel-block"> + <div> + ${form.render_field_value('credits')} </div> + </div> + </nav> - % else: - ## legacy / not buefy - ${parent.page_content()} - % endif + <b-modal has-modal-card + :active.sync="removeCreditShowDialog"> + <div class="modal-card remove-credit"> + + <header class="modal-card-head"> + <p class="modal-card-title">Un-Declare Credit</p> + </header> + + <section class="modal-card-body"> + + <p class="block"> + If you un-declare this credit, the quantity below will + be added back to the + <span class="has-text-weight-bold">Received</span> tally. + </p> + + <b-field label="Credit Type" horizontal> + {{ removeCreditRow.credit_type }} + </b-field> + + <b-field label="Quantity" horizontal> + {{ removeCreditRow.shorted }} + </b-field> + + </section> + + <footer class="modal-card-foot"> + <b-button @click="removeCreditShowDialog = false"> + Cancel + </b-button> + <b-button type="is-danger" + @click="removeCreditSubmit()" + :disabled="removeCreditSubmitting" + icon-pack="fas" + icon-left="trash"> + {{ removeCreditSubmitting ? "Working, please wait..." : "Un-Declare this Credit" }} + </b-button> + </footer> + </div> + </b-modal> + + <div style="display: flex;"> + + % if master.batch_handler.has_purchase_order(batch): + <nav class="panel" > + <p class="panel-heading">Purchase Order</p> + <div class="panel-block"> + <div + % if request.use_oruga: + class="form-wrapper" + % endif + > + ${form.render_field_readonly('po_line_number')} + ${form.render_field_readonly('po_unit_cost')} + ${form.render_field_readonly('po_case_size')} + ${form.render_field_readonly('po_total')} + </div> + </div> + </nav> + % endif + + % if master.batch_handler.has_invoice_file(batch): + <nav class="panel" > + <p class="panel-heading">Invoice</p> + <div class="panel-block"> + <div + % if request.use_oruga: + class="form-wrapper" + % endif + > + ${form.render_field_readonly('invoice_number')} + ${form.render_field_readonly('invoice_line_number')} + ${form.render_field_readonly('invoice_unit_cost')} + ${form.render_field_readonly('invoice_case_size')} + ${form.render_field_readonly('invoice_total', label="Invoice Total")} + </div> + </div> + </nav> + % endif + + </div> </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> ## ThisPage.methods.editUnitCost = function() { ## alert("TODO: not yet implemented") @@ -538,6 +542,10 @@ ThisPage.methods.accountForProductUOMClicked = function(uom) { + % if request.use_oruga: + this.accountForProductUOM = uom + % endif + // TODO: this does not seem to work as expected..even though // the code appears to be correct this.$nextTick(() => { @@ -712,6 +720,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/reports/base.mako b/tailbone/templates/reports/base.mako deleted file mode 100644 index 5833b0ec..00000000 --- a/tailbone/templates/reports/base.mako +++ /dev/null @@ -1,3 +0,0 @@ -## -*- coding: utf-8 -*- -<%inherit file="/base.mako" /> -${parent.body()} diff --git a/tailbone/templates/reports/generated/choose.mako b/tailbone/templates/reports/generated/choose.mako index 7f24bfde..0921530c 100644 --- a/tailbone/templates/reports/generated/choose.mako +++ b/tailbone/templates/reports/generated/choose.mako @@ -1,47 +1,14 @@ ## -*- coding: utf-8; -*- <%inherit file="/master/create.mako" /> -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - % if not use_buefy: - <script type="text/javascript"> - - var report_descriptions = ${json.dumps(report_descriptions)|n}; - - function show_description(key) { - var desc = report_descriptions[key]; - $('#report-description').text(desc); - } - - $(function() { - - var report_type = $('select[name="report_type"]'); - - report_type.change(function(event) { - show_description(report_type.val()); - }); - - }); - - </script> - % endif -</%def> - <%def name="extra_styles()"> ${parent.extra_styles()} <style type="text/css"> % if use_form: - % if use_buefy: - #report-description { - margin-left: 2em; - } - % else: - #report-description { - margin-top: 2em; - margin-left: 2em; - } - % endif + #report-description { + margin-left: 2em; + } % else: .report-selection { margin-left: 10em; @@ -56,7 +23,7 @@ </style> </%def> -<%def name="render_buefy_form()"> +<%def name="render_form()"> <div class="form"> <p>Please select the type of report you wish to generate.</p> <br /> @@ -69,19 +36,7 @@ <%def name="page_content()"> % if use_form: - % if use_buefy: - ${parent.page_content()} - % else: - <div class="form-wrapper"> - <p>Please select the type of report you wish to generate.</p> - - <div style="display: flex;"> - ${form.render()|n} - <div id="report-description"></div> - </div> - - </div><!-- form-wrapper --> - % endif + ${parent.page_content()} % else: <div> <br /> @@ -98,13 +53,13 @@ % endif </%def> -<%def name="finalize_this_page_vars()"> - ${parent.finalize_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> - TailboneFormData.reportDescriptions = ${json.dumps(report_descriptions)|n} + ${form.vue_component}Data.reportDescriptions = ${json.dumps(report_descriptions)|n} - TailboneForm.methods.reportTypeChanged = function(reportType) { + ${form.vue_component}.methods.reportTypeChanged = function(reportType) { this.$emit('report-change', this.reportDescriptions[reportType]) } @@ -116,6 +71,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/reports/generated/delete.mako b/tailbone/templates/reports/generated/delete.mako index 0c994ad0..f60a9819 100644 --- a/tailbone/templates/reports/generated/delete.mako +++ b/tailbone/templates/reports/generated/delete.mako @@ -1,16 +1,11 @@ ## -*- coding: utf-8; -*- <%inherit file="/master/delete.mako" /> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> - +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> % if params_data is not Undefined: - ${form.component_studly}Data.paramsData = ${json.dumps(params_data)|n} + ${form.vue_component}Data.paramsData = ${json.dumps(params_data)|n} % endif - </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/reports/generated/generate.mako b/tailbone/templates/reports/generated/generate.mako index 38adfe34..2b8fa66c 100644 --- a/tailbone/templates/reports/generated/generate.mako +++ b/tailbone/templates/reports/generated/generate.mako @@ -5,19 +5,24 @@ <%def name="content_title()">New Report: ${report.name}</%def> -<%def name="render_buefy_form()"> +<%def name="render_form()"> <div class="form"> - <p style="padding: 1em;">${report.__doc__}</p> - <br /> + <p class="block"> + ${report.__doc__} + </p> + % if report.help_url: + <p class="block"> + <b-button icon-pack="fas" + icon-left="question-circle" + tag="a" target="_blank" + href="${report.help_url}"> + Help for this report + </b-button> + </p> + % endif <tailbone-form></tailbone-form> </div> </%def> -<%def name="render_form()"> - % if not use_buefy: - <p style="padding: 1em;">${report.__doc__}</p> - % endif - ${parent.render_form()} -</%def> ${parent.body()} diff --git a/tailbone/templates/reports/generated/view.mako b/tailbone/templates/reports/generated/view.mako index 6260efba..cce6f346 100644 --- a/tailbone/templates/reports/generated/view.mako +++ b/tailbone/templates/reports/generated/view.mako @@ -23,16 +23,11 @@ % endif </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> - +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> % if params_data is not Undefined: - ${form.component_studly}Data.paramsData = ${json.dumps(params_data)|n} + ${form.vue_component}Data.paramsData = ${json.dumps(params_data)|n} % endif - </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/reports/inventory.mako b/tailbone/templates/reports/inventory.mako index 41c74cda..cc5adc10 100644 --- a/tailbone/templates/reports/inventory.mako +++ b/tailbone/templates/reports/inventory.mako @@ -1,35 +1,57 @@ -## -*- coding: utf-8 -*- -<%inherit file="/reports/base.mako" /> +## -*- coding: utf-8; -*- +<%inherit file="/page.mako" /> -<%def name="title()">Report : Inventory Worksheet</%def> +<%def name="title()">Inventory Worksheet</%def> -<p>Please provide the following criteria to generate your report:</p> -<br /> +<%def name="page_content()"> -${h.form(request.current_route_url())} -${h.csrf_token(request)} + <p class="block"> + Please provide the following criteria to generate your report: + </p> -<div class="field-wrapper"> - <label for="department">Department</label> - <div class="field"> - <select name="department"> - % for department in departments: - <option value="${department.uuid}">${department.name}</option> - % endfor - </select> + ${h.form(request.current_route_url())} + ${h.csrf_token(request)} + + <b-field label="Department"> + <b-select name="department"> + <option v-for="dept in departments" + :key="dept.uuid" + :value="dept.uuid"> + {{ dept.name }} + </option> + </b-select> + </b-field> + + <b-field> + <b-checkbox name="weighted-only" native-value="1"> + Only include items which are sold by weight. + </b-checkbox> + </b-field> + + <b-field> + <b-checkbox name="exclude-not-for-sale" + v-model="excludeNotForSale" + native-value="1"> + Exclude items marked "not for sale". + </b-checkbox> + </b-field> + + <div class="buttons"> + <b-button type="is-primary" + native-type="submit" + icon-pack="fas" + icon-left="arrow-circle-right"> + Generate Report + </b-button> </div> -</div> -<div class="field-wrapper"> - ${h.checkbox('weighted-only', label="Only include items which are sold by weight.")} -</div> + ${h.end_form()} +</%def> -<div class="field-wrapper"> - ${h.checkbox('exclude-not-for-sale', label="Exclude items marked \"not for sale\".", checked=True)} -</div> - -<div class="buttons"> - ${h.submit('submit', "Generate Report")} -</div> - -${h.end_form()} +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> + ThisPageData.departments = ${json.dumps([{'uuid': d.uuid, 'name': d.name} for d in departments])|n} + ThisPageData.excludeNotForSale = true + </script> +</%def> diff --git a/tailbone/templates/reports/ordering.mako b/tailbone/templates/reports/ordering.mako index 1b8d555c..61ccdb16 100644 --- a/tailbone/templates/reports/ordering.mako +++ b/tailbone/templates/reports/ordering.mako @@ -1,89 +1,129 @@ ## -*- coding: utf-8 -*- -<%inherit file="/reports/base.mako" /> +<%inherit file="/page.mako" /> -<%def name="title()">Report : Ordering Worksheet</%def> +<%def name="title()">Ordering Worksheet</%def> -<%def name="head_tags()"> - ${parent.head_tags()} - <style type="text/css"> +<%def name="page_content()"> - div.grid { - clear: none; - } + <p class="block"> + Please provide the following criteria to generate your report: + </p> + + <div style="max-width: 50%;"> + ${h.form(request.current_route_url(), **{'@submit': 'validateForm'})} + ${h.csrf_token(request)} + ${h.hidden('departments', **{':value': 'departmentUUIDs'})} + + <b-field label="Vendor"> + <tailbone-autocomplete v-model="vendorUUID" + service-url="${url('vendors.autocomplete')}" + name="vendor" + expanded + % if request.use_oruga: + @update:model-value="vendorChanged" + % else: + @input="vendorChanged" + % endif + > + </tailbone-autocomplete> + </b-field> + + <b-field label="Departments"> + <${b}-table v-if="fetchedDepartments" + :data="departments" + narrowed + checkable + % if request.use_oruga: + v-model:checked-rows="checkedDepartments" + % else: + :checked-rows.sync="checkedDepartments" + % endif + :loading="fetchingDepartments"> + + <${b}-table-column field="number" + label="Number" + v-slot="props"> + {{ props.row.number }} + </${b}-table-column> + + <${b}-table-column field="name" + label="Name" + v-slot="props"> + {{ props.row.name }} + </${b}-table-column> + + </${b}-table> + </b-field> + + <b-field> + <b-checkbox name="preferred_only" + v-model="preferredVendorOnly" + native-value="1"> + Only include products for which this vendor is preferred. + </b-checkbox> + </b-field> + + ${self.extra_fields()} + + <div class="buttons"> + <b-button type="is-primary" + native-type="submit" + icon-pack="fas" + icon-left="arrow-circle-right"> + Generate Report + </b-button> + </div> + + ${h.end_form()} + </div> - </style> </%def> -<p>Please provide the following criteria to generate your report:</p> -<br /> +<%def name="extra_fields()"></%def> -${h.form(request.current_route_url())} -${h.hidden('departments', value='')} +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> -<div class="field-wrapper"> - ${h.hidden('vendor', value='')} - <label for="vendor-name">Vendor:</label> - ${h.text('vendor-name', size='40', value='')} - <div id="vendor-display" style="display: none;"> - <span>(no vendor)</span> - <button type="button" id="change-vendor">Change</button> - </div> -</div> + ThisPageData.vendorUUID = null + ThisPageData.departments = [] + ThisPageData.checkedDepartments = [] + ThisPageData.preferredVendorOnly = true + ThisPageData.fetchingDepartments = false + ThisPageData.fetchedDepartments = false -<div class="field-wrapper"> - <label>Departments:</label> - <div class="grid"></div> -</div> + ThisPage.computed.departmentUUIDs = function() { + let uuids = [] + for (let dept of this.checkedDepartments) { + uuids.push(dept.uuid) + } + return uuids.join(',') + } -<div class="field-wrapper"> - ${h.checkbox('preferred_only', label="Include only those products for which this vendor is preferred.", checked=True)} -</div> + ThisPage.methods.vendorChanged = function(uuid) { + if (uuid) { + this.fetchingDepartments = true -<div class="buttons"> - ${h.submit('submit', "Generate Report")} -</div> + let url = '${url('departments.by_vendor')}' + let params = {uuid: uuid} + this.$http.get(url, {params: params}).then(response => { + this.departments = response.data + this.fetchingDepartments = false + this.fetchedDepartments = true + }) -${h.end_form()} + } else { + this.departments = [] + this.fetchedDepartments = false + } + } -<script type="text/javascript"> + ThisPage.methods.validateForm = function(event) { + if (!this.departmentUUIDs.length) { + alert("You must select at least one Department.") + event.preventDefault() + } + } -$(function() { - - var autocompleter = $('#vendor-name').autocomplete({ - serviceUrl: '${url('vendors.autocomplete')}', - width: 300, - onSelect: function(value, data) { - $('#vendor').val(data); - $('#vendor-name').hide(); - $('#vendor-name').val(''); - $('#vendor-display span').html(value); - $('#vendor-display').show(); - loading($('div.grid')); - $('div.grid').load('${url('departments.by_vendor')}', {'uuid': data}); - }, - }); - - $('#vendor-name').focus(); - - $('#change-vendor').click(function() { - $('#vendor').val(''); - $('#vendor-display').hide(); - $('#vendor-name').show(); - $('#vendor-name').focus(); - $('div.grid').empty(); - }); - - $('form').submit(function() { - var depts = []; - $('div.grid table tbody tr').each(function() { - if ($(this).find('td.checkbox input[type=checkbox]').is(':checked')) { - depts.push(get_uuid(this)); - } - $('#departments').val(depts.toString()); - return true; - }); - }); - -}); - -</script> + </script> +</%def> diff --git a/tailbone/templates/reports/problems/view.mako b/tailbone/templates/reports/problems/view.mako index 026c73dc..5cdf2be5 100644 --- a/tailbone/templates/reports/problems/view.mako +++ b/tailbone/templates/reports/problems/view.mako @@ -45,11 +45,10 @@ <b-button @click="runReportShowDialog = false"> Cancel </b-button> - ${h.form(master.get_action_url('execute', instance))} + ${h.form(master.get_action_url('execute', instance), **{'@submit': 'runReportSubmitting = true'})} ${h.csrf_token(request)} <b-button type="is-primary" native-type="submit" - @click="runReportSubmitting = true" :disabled="runReportSubmitting" icon-pack="fas" icon-left="arrow-circle-right"> @@ -62,12 +61,12 @@ % endif </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> % if weekdays_data is not Undefined: - ${form.component_studly}Data.weekdaysData = ${json.dumps(weekdays_data)|n} + ${form.vue_component}Data.weekdaysData = ${json.dumps(weekdays_data)|n} % endif ThisPageData.runReportShowDialog = false @@ -75,6 +74,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/roles/create.mako b/tailbone/templates/roles/create.mako index 625b2675..89dd56c3 100644 --- a/tailbone/templates/roles/create.mako +++ b/tailbone/templates/roles/create.mako @@ -6,15 +6,11 @@ ${h.stylesheet_link(request.static_url('tailbone:static/css/perms.css'))} </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> - +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> // TODO: this variable name should be more dynamic (?) since this is // connected to (and only here b/c of) the permissions field - TailboneFormData.showingPermissionGroup = '' - + ${form.vue_component}Data.showingPermissionGroup = '' </script> </%def> - -${parent.body()} diff --git a/tailbone/templates/roles/edit.mako b/tailbone/templates/roles/edit.mako index 67f63013..e77cca33 100644 --- a/tailbone/templates/roles/edit.mako +++ b/tailbone/templates/roles/edit.mako @@ -6,15 +6,11 @@ ${h.stylesheet_link(request.static_url('tailbone:static/css/perms.css'))} </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> - +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> // TODO: this variable name should be more dynamic (?) since this is // connected to (and only here b/c of) the permissions field - TailboneFormData.showingPermissionGroup = '' - + ${form.vue_component}Data.showingPermissionGroup = '' </script> </%def> - -${parent.body()} diff --git a/tailbone/templates/roles/find_by_perm.mako b/tailbone/templates/roles/find_by_perm.mako deleted file mode 100644 index 8908d12e..00000000 --- a/tailbone/templates/roles/find_by_perm.mako +++ /dev/null @@ -1,21 +0,0 @@ -## -*- coding: utf-8 -*- -<%inherit file="/principal/find_by_perm.mako" /> - -<%def name="principal_table()"> - <table> - <thead> - <tr> - <th>Name</th> - </tr> - </thead> - <tbody> - % for role in principals: - <tr> - <td>${h.link_to(role.name, url('roles.view', uuid=role.uuid))}</td> - </tr> - % endfor - </tbody> - </table> -</%def> - -${parent.body()} diff --git a/tailbone/templates/roles/view.mako b/tailbone/templates/roles/view.mako index c5a5b78d..f5588695 100644 --- a/tailbone/templates/roles/view.mako +++ b/tailbone/templates/roles/view.mako @@ -6,36 +6,16 @@ ${h.stylesheet_link(request.static_url('tailbone:static/css/perms.css'))} </%def> -<%def name="page_content()"> - ${parent.page_content()} - % if not use_buefy: - <h2>Users</h2> - - % if instance is guest_role: - <p>The guest role is implied for all anonymous users, i.e. when not logged in.</p> - % elif instance is authenticated_role: - <p>The authenticated role is implied for all users, but only when logged in.</p> - % elif users: - <p>The following users are assigned to this role:</p> - ${users.render_grid()|n} - % else: - <p>There are no users assigned to this role.</p> - % endif - % endif -</%def> - -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> % if users_data is not Undefined: - ${form.component_studly}Data.usersData = ${json.dumps(users_data)|n} + ${form.vue_component}Data.usersData = ${json.dumps(users_data)|n} % endif ThisPage.methods.detachPerson = function(url) { - ## TODO: this should require POST, but we will add that once - ## we can assume a Buefy theme is present, to avoid having to - ## implement the logic in old jquery... + ## TODO: this should require POST! but for now we just redirect.. if (confirm("Are you sure you want to detach this person from this customer account?")) { location.href = url } @@ -43,5 +23,3 @@ </script> </%def> - -${parent.body()} diff --git a/tailbone/templates/settings/email/configure.mako b/tailbone/templates/settings/email/configure.mako index 13bceb3e..f9c815c2 100644 --- a/tailbone/templates/settings/email/configure.mako +++ b/tailbone/templates/settings/email/configure.mako @@ -3,6 +3,24 @@ <%def name="form_content()"> + <h3 class="block is-size-3">General</h3> + <div class="block" style="padding-left: 2rem;"> + <b-field label="Mail Handler" + message="Leave blank for default handler."> + <b-input name="rattail.mail.handler" + v-model="simpleSettings['rattail.mail.handler']" + @input="settingsNeedSaved = true"> + </b-input> + </b-field> + <b-field label="Template Paths" + message="Leave blank for default paths."> + <b-input name="rattail.mail.templates" + v-model="simpleSettings['rattail.mail.templates']" + @input="settingsNeedSaved = true"> + </b-input> + </b-field> + </div> + <h3 class="block is-size-3">Sending</h3> <div class="block" style="padding-left: 2rem;"> @@ -26,30 +44,72 @@ </div> - % if request.has_perm('errors.bogus'): - <h3 class="block is-size-3">Testing</h3> - <div class="block" style="padding-left: 2rem;"> + <h3 class="block is-size-3">Testing</h3> + <div class="block" style="padding-left: 2rem;"> - <b-field grouped> - <p class="control"> - You can raise a "bogus" error to test if/how it generates email: - </p> + <b-field grouped> + <b-field horizontal label="Recipient"> + <b-input v-model="testRecipient"></b-input> + </b-field> + <b-button type="is-primary" + @click="sendTest()" + :disabled="sendingTest"> + {{ sendingTest ? "Working, please wait..." : "Send Test Email" }} + </b-button> + </b-field> + + <div class="level"> + <div class="level-left"> + <div class="level-item"> + <p>You can raise a "bogus" error to test if/how that generates email:</p> + </div> + <div class="level-item"> <b-button type="is-primary" + % if request.has_perm('errors.bogus'): @click="raiseBogusError()" - :disabled="raisingBogusError"> + :disabled="raisingBogusError" + % else: + disabled + title="your permissions do not allow this" + % endif + > + % if request.has_perm('errors.bogus'): {{ raisingBogusError ? "Working, please wait..." : "Raise Bogus Error" }} + % else: + Raise Bogus Error + % endif </b-button> - </b-field> - + </div> </div> - </h3> - % endif + </div> + + </div> </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - % if request.has_perm('errors.bogus'): - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> + + ThisPageData.testRecipient = ${json.dumps(user_email_address)|n} + ThisPageData.sendingTest = false + + ThisPage.methods.sendTest = function() { + this.sendingTest = true + let url = '${url('emailprofiles.send_test')}' + let params = {recipient: this.testRecipient} + this.simplePOST(url, params, response => { + this.$buefy.toast.open({ + message: "Test email was sent!", + type: 'is-success', + duration: 4000, // 4 seconds + }) + this.sendingTest = false + }, response => { + this.sendingTest = false + }) + } + + % if request.has_perm('errors.bogus'): ThisPageData.raisingBogusError = false @@ -74,9 +134,6 @@ }) } - </script> - % endif + % endif + </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/settings/email/index.mako b/tailbone/templates/settings/email/index.mako index 11881285..ab8d6fa4 100644 --- a/tailbone/templates/settings/email/index.mako +++ b/tailbone/templates/settings/email/index.mako @@ -15,10 +15,10 @@ ${parent.render_grid_component()} </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} % if master.has_perm('configure'): - <script type="text/javascript"> + <script> ThisPageData.showEmails = 'available' @@ -26,9 +26,9 @@ this.$refs.grid.showEmails = this.showEmails } - ${grid.component_studly}Data.showEmails = 'available' + ${grid.vue_component}Data.showEmails = 'available' - ${grid.component_studly}.computed.visibleData = function() { + ${grid.vue_component}.computed.visibleData = function() { if (this.showEmails == 'available') { return this.data.filter(email => email.hidden == 'No') @@ -41,23 +41,27 @@ return this.data } - ${grid.component_studly}.methods.renderLabelToggleHidden = function(row) { + ${grid.vue_component}.methods.renderLabelToggleHidden = function(row) { return row.hidden == 'Yes' ? "Un-hide" : "Hide" } - ${grid.component_studly}.methods.toggleHidden = function(row) { + ${grid.vue_component}.methods.toggleHidden = function(row) { let url = '${url('{}.toggle_hidden'.format(route_prefix))}' let params = { key: row.key, - hidden: row.hidden == 'No'? true : false, + hidden: row.hidden == 'No' ? true : false, } this.submitForm(url, params, response => { - row.hidden = params.hidden ? 'Yes' : 'No' + // must update "original" data row, since our row arg + // may just be a proxy and not trigger view refresh + for (let email of this.data) { + if (email.key == row.key) { + email.hidden = params.hidden ? 'Yes' : 'No' + } + } }) } </script> % endif </%def> - -${parent.body()} diff --git a/tailbone/templates/settings/email/view.mako b/tailbone/templates/settings/email/view.mako index be4f5774..73ad7066 100644 --- a/tailbone/templates/settings/email/view.mako +++ b/tailbone/templates/settings/email/view.mako @@ -1,48 +1,13 @@ ## -*- coding: utf-8; -*- <%inherit file="/master/view.mako" /> -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - % if not use_buefy: - <script type="text/javascript"> - % if not email.get_template('html'): - $(function() { - $('#preview-html').button('disable'); - $('#preview-html').attr('title', "There is no HTML template on file for this email."); - }); - % endif - % if not email.get_template('txt'): - $(function() { - $('#preview-txt').button('disable'); - $('#preview-txt').attr('title', "There is no TXT template on file for this email."); - }); - % endif - </script> - % endif -</%def> - <%def name="render_form()"> ${parent.render_form()} - % if not use_buefy: - ${h.form(url('email.preview'), name='send-email-preview', class_='autodisable')} - ${h.csrf_token(request)} - ${h.hidden('email_key', value=instance['key'])} - ${h.link_to("Preview HTML", '{}?key={}&type=html'.format(url('email.preview'), instance['key']), id='preview-html', class_='button', target='_blank')} - ${h.link_to("Preview TXT", '{}?key={}&type=txt'.format(url('email.preview'), instance['key']), id='preview-txt', class_='button', target='_blank')} - or - ${h.text('recipient', value=request.user.email_address or '')} - ${h.submit('send_{}'.format(instance['key']), value="Send Preview Email")} - ${h.end_form()} - % endif -</%def> - -<%def name="render_buefy_form()"> - ${parent.render_buefy_form()} <email-preview-tools></email-preview-tools> </%def> -<%def name="render_this_page_template()"> - ${parent.render_this_page_template()} +<%def name="render_vue_templates()"> + ${parent.render_vue_templates()} <script type="text/x-template" id="email-preview-tools-template"> ${h.form(url('email.preview'), **{'@submit': 'submitPreviewForm'})} @@ -107,10 +72,6 @@ ${h.end_form()} </script> -</%def> - -<%def name="make_this_page_component()"> - ${parent.make_this_page_component()} <script type="text/javascript"> const EmailPreviewTools = { @@ -119,7 +80,7 @@ return { previewFormButtonText: "Send Preview Email", previewFormSubmitting: false, - userEmailAddress: ${json.dumps(request.user.email_address)|n}, + userEmailAddress: ${json.dumps(user_email_address)|n}, } }, methods: { @@ -135,10 +96,13 @@ } } - Vue.component('email-preview-tools', EmailPreviewTools) - </script> </%def> - -${parent.body()} +<%def name="make_vue_components()"> + ${parent.make_vue_components()} + <script> + Vue.component('email-preview-tools', EmailPreviewTools) + <% request.register_component('email-preview-tools', 'EmailPreviewTools') %> + </script> +</%def> diff --git a/tailbone/templates/shifts/base.mako b/tailbone/templates/shifts/base.mako index 7543712f..52b48832 100644 --- a/tailbone/templates/shifts/base.mako +++ b/tailbone/templates/shifts/base.mako @@ -1,102 +1,13 @@ -## -*- coding: utf-8 -*- -<%inherit file="/base.mako" /> -<%namespace file="/autocomplete.mako" import="autocomplete" /> +## -*- coding: utf-8; -*- +<%inherit file="/page.mako" /> <%def name="title()">${page_title}</%def> -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - <script type="text/javascript"> - - var data_modified = false; - var okay_to_leave = true; - var previous_selections = {}; - % if weekdays is not Undefined: - var weekdays = [ - % for i, day in enumerate(weekdays, 1): - '${day.strftime('%a %d %b %Y')}'${',' if i < len(weekdays) else ''} - % endfor - ]; - % endif - - window.onbeforeunload = function() { - if (! okay_to_leave) { - return "If you leave this page, you will lose all unsaved changes!"; - } - } - - function employee_selected(uuid, name) { - $('#filter-form').submit(); - } - - function confirm_leave() { - if (data_modified) { - if (confirm("If you navigate away from this page now, you will lose " + - "unsaved changes.\n\nAre you sure you wish to do this?")) { - okay_to_leave = true; - return true; - } - return false; - } - return true; - } - - function date_selected(dateText, inst) { - if (confirm_leave()) { - $('#filter-form').submit(); - } else { - // revert date value - $('.week-picker input[name="date"]').val($('.week-picker').data('week')); - } - } - - $(function() { - - $('#filter-form').submit(function() { - $('.timesheet-header').mask("Fetching data"); - }); - - $('.timesheet-header select').each(function() { - previous_selections[$(this).attr('name')] = $(this).val(); - }); - - $('.timesheet-header select').selectmenu({ - change: function(event, ui) { - if (confirm_leave()) { - $('#filter-form').submit(); - } else { - var select = ui.item.element.parents('select'); - select.val(previous_selections[select.attr('name')]); - select.selectmenu('refresh'); - } - } - }); - - $('.timesheet-header a.goto').click(function() { - $('.timesheet-header').mask("Fetching data"); - }); - - $('.week-picker button.nav').click(function() { - if (confirm_leave()) { - $('.week-picker input[name="date"]').val($(this).data('date')); - $('#filter-form').submit(); - } - }); - - }); - - </script> -</%def> - <%def name="extra_styles()"> ${parent.extra_styles()} ${h.stylesheet_link(request.static_url('tailbone:static/css/timesheet.css'))} </%def> -<%def name="edit_timetable_javascript()"> - ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.edit-shifts.js'))} -</%def> - <%def name="edit_timetable_styles()"> <style type="text/css"> .timesheet .day { @@ -146,7 +57,7 @@ <div class="field-wrapper employee"> <label>Employee</label> <div class="field"> - ${dform['employee'].serialize(text=six.text_type(employee), selected_callback='employee_selected')|n} + ${dform['employee'].serialize(text=str(employee), selected_callback='employee_selected')|n} </div> </div> % endif @@ -241,7 +152,7 @@ </tr> </thead> <tbody> - % for emp in sorted(employees, key=six.text_type): + % for emp in sorted(employees, key=str): <tr data-employee-uuid="${emp.uuid}"> <td class="employee"> ## TODO: add link to single employee schedule / timesheet here... @@ -304,5 +215,9 @@ <%def name="render_extra_totals(employee)"></%def> +<%def name="page_content()"> + ${self.timesheet_wrapper()} +</%def> -${self.timesheet_wrapper()} + +${parent.body()} diff --git a/tailbone/templates/shifts/schedule_edit.mako b/tailbone/templates/shifts/schedule_edit.mako index 7157ee27..4455c74d 100644 --- a/tailbone/templates/shifts/schedule_edit.mako +++ b/tailbone/templates/shifts/schedule_edit.mako @@ -1,60 +1,6 @@ -## -*- coding: utf-8 -*- +## -*- coding: utf-8; -*- <%inherit file="/shifts/base.mako" /> -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - ${self.edit_timetable_javascript()} - <script type="text/javascript"> - - $(function() { - - % if allow_clear: - $('.clear-schedule').click(function() { - if (confirm("This will remove all shifts from the schedule you're " + - "currently viewing.\n\nAre you sure you wish to do this?")) { - $(this).button('disable').button('option', 'label', "Clearing..."); - okay_to_leave = true; - $('#clear-schedule-form').submit(); - } - }); - % endif - - $('#copy-week').datepicker({ - dateFormat: 'mm/dd/yy' - }); - - $('.copy-schedule').click(function() { - $('#copy-details').dialog({ - modal: true, - title: "Copy from Another Week", - width: '500px', - buttons: [ - { - text: "Copy Schedule", - click: function(event) { - if (! $('#copy-week').val()) { - alert("You must specify the week from which to copy shift data."); - $('#copy-week').focus(); - return; - } - disable_button(dialog_button(event), "Copying Schedule"); - $('#copy-schedule-form').submit(); - } - }, - { - text: "Cancel", - click: function() { - $('#copy-details').dialog('close'); - } - } - ] - }); - }); - - }); - </script> -</%def> - <%def name="extra_styles()"> ${parent.extra_styles()} ${self.edit_timetable_styles()} @@ -97,43 +43,50 @@ </div> </%def> +<%def name="page_content()"> -${self.timesheet_wrapper(with_edit_form=True)} + ${self.timesheet_wrapper(with_edit_form=True)} -${edit_tools()} + ${edit_tools()} -% if allow_clear: -${h.form(url('schedule.edit'), id="clear-schedule-form")} -${h.csrf_token(request)} -${h.hidden('clear-schedule', value='clear')} -${h.end_form()} -% endif - -<div id="day-editor" style="display: none;"> - <div class="shifts"></div> - <button type="button" id="add-shift">Add Shift</button> -</div> - -<div id="copy-details" style="display: none;"> - <p> - This tool will replace the currently visible schedule, with one from - another week. - </p> - <p> - <strong>NOTE:</strong> If you do this, all shifts in the current - schedule will be <em>removed</em>, - and then new shifts will be created based on the week you specify. - </p> - ${h.form(url('schedule.edit'), id='copy-schedule-form')} + % if allow_clear: + ${h.form(url('schedule.edit'), id="clear-schedule-form")} ${h.csrf_token(request)} - <label for="copy-week">Copy from week:</label> - ${h.text('copy-week')} + ${h.hidden('clear-schedule', value='clear')} ${h.end_form()} -</div> + % endif -<div id="snippets"> - <div class="shift" data-uuid=""> - ${h.text('edit_start_time')} thru ${h.text('edit_end_time')} - <button type="button"><span class="ui-icon ui-icon-trash"></span></button> + <div id="day-editor" style="display: none;"> + <div class="shifts"></div> + <button type="button" id="add-shift">Add Shift</button> </div> -</div> + + <div id="copy-details" style="display: none;"> + <p> + This tool will replace the currently visible schedule, with one from + another week. + </p> + <p> + <strong>NOTE:</strong> If you do this, all shifts in the current + schedule will be <em>removed</em>, + and then new shifts will be created based on the week you specify. + </p> + ${h.form(url('schedule.edit'), id='copy-schedule-form')} + ${h.csrf_token(request)} + <label for="copy-week">Copy from week:</label> + ${h.text('copy-week')} + ${h.end_form()} + </div> + + <div id="snippets"> + <div class="shift" data-uuid=""> + ${h.text('edit_start_time')} thru ${h.text('edit_end_time')} + <button type="button"><span class="ui-icon ui-icon-trash"></span></button> + </div> + </div> + +</%def> + + +${parent.body()} + diff --git a/tailbone/templates/shifts/timesheet.mako b/tailbone/templates/shifts/timesheet.mako index 562cdb35..b93de6ac 100644 --- a/tailbone/templates/shifts/timesheet.mako +++ b/tailbone/templates/shifts/timesheet.mako @@ -1,4 +1,4 @@ -## -*- coding: utf-8 -*- +## -*- coding: utf-8; -*- <%inherit file="/shifts/base.mako" /> <%def name="context_menu()"> @@ -25,4 +25,4 @@ </%def> -${self.timesheet_wrapper()} +${parent.body()} diff --git a/tailbone/templates/shifts/timesheet_edit.mako b/tailbone/templates/shifts/timesheet_edit.mako index 96035663..c4dc7a6b 100644 --- a/tailbone/templates/shifts/timesheet_edit.mako +++ b/tailbone/templates/shifts/timesheet_edit.mako @@ -1,58 +1,6 @@ -## -*- coding: utf-8 -*- +## -*- coding: utf-8; -*- <%inherit file="/shifts/base.mako" /> -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.timesheet.edit.js'))} - <script type="text/javascript"> - - show_timepicker = false; - - $(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 times = $.trim($(this).children('span').text()).split(' - '); - times[0] = times[0] == '??' ? '' : times[0]; - times[1] = times[1] == '??' ? '' : times[1]; - add_shift(false, uuid, times[0], times[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: "Save Changes", - click: save_dialog - }, - { - text: "Cancel", - click: function() { - editor.dialog('close'); - } - } - ] - }); - }); - - }); - - </script> -</%def> - <%def name="extra_styles()"> ${parent.extra_styles()} ${self.edit_timetable_styles()} @@ -88,17 +36,23 @@ ${h.csrf_token(request)} </%def> +<%def name="page_content()"> -${self.timesheet_wrapper(with_edit_form=True, change_employee='confirm_leave')} + ${self.timesheet_wrapper(with_edit_form=True, change_employee='confirm_leave')} -<div id="day-editor" style="display: none;"> - <div class="shifts"></div> - <button type="button" id="add-shift">Add Shift</button> -</div> - -<div id="snippets"> - <div class="shift" data-uuid=""> - ${h.text('edit_start_time')} thru ${h.text('edit_end_time')} - <button type="button"><span class="ui-icon ui-icon-trash"></span></button> + <div id="day-editor" style="display: none;"> + <div class="shifts"></div> + <button type="button" id="add-shift">Add Shift</button> </div> -</div> + + <div id="snippets"> + <div class="shift" data-uuid=""> + ${h.text('edit_start_time')} thru ${h.text('edit_end_time')} + <button type="button"><span class="ui-icon ui-icon-trash"></span></button> + </div> + </div> + +</%def> + + +${parent.body()} diff --git a/tailbone/templates/tables/create.mako b/tailbone/templates/tables/create.mako new file mode 100644 index 00000000..34844c5c --- /dev/null +++ b/tailbone/templates/tables/create.mako @@ -0,0 +1,985 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/create.mako" /> + +<%def name="extra_styles()"> + ${parent.extra_styles()} + <style type="text/css"> + .label { + white-space: nowrap; + } + </style> +</%def> + +<%def name="render_this_page()"> + + ## scroll target used when navigating prev/next + <div ref="showme"></div> + + % if not alembic_current_head: + <b-notification type="is-warning" + :closable="false"> + <p class="block"> + DB is not up to date! There are + ${h.link_to("pending migrations", url('{}.migrations'.format(route_prefix)))}. + </p> + <p class="block"> + (This will be a problem if you wish to auto-generate a migration for a new table.) + </p> + </b-notification> + % endif + + <b-steps v-model="activeStep" + animated + rounded + :has-navigation="false" + vertical + icon-pack="fas"> + + <b-step-item step="1" + value="enter-details" + label="Enter Details" + clickable> + <h3 class="is-size-3 block"> + Enter Details + </h3> + + <b-field label="Schema Branch" + message="Leave this set to your custom app branch, unless you know what you're doing."> + <b-select v-model="alembicBranch" + @input="dirty = true"> + <option v-for="branch in alembicBranchOptions" + :key="branch" + :value="branch"> + {{ branch }} + </option> + </b-select> + </b-field> + + <b-field grouped> + + <b-field label="Table Name" + message="Should be singular in nature, i.e. 'widget' not 'widgets'"> + <b-input v-model="tableName" + @input="dirty = true"> + </b-input> + </b-field> + + <b-field label="Model/Class Name" + message="Should be singular in nature, i.e. 'Widget' not 'Widgets'"> + <b-input v-model="tableModelName" + @input="dirty = true"> + </b-input> + </b-field> + + </b-field> + + <b-field grouped> + + <b-field label="Model Title" + message="Human-friendly singular model title."> + <b-input v-model="tableModelTitle" + @input="dirty = true"> + </b-input> + </b-field> + + <b-field label="Model Title Plural" + message="Human-friendly plural model title."> + <b-input v-model="tableModelTitlePlural" + @input="dirty = true"> + </b-input> + </b-field> + + </b-field> + + <b-field label="Description" + message="Brief description of what a record in this table represents."> + <b-input v-model="tableDescription" + @input="dirty = true"> + </b-input> + </b-field> + + <b-field> + <b-checkbox v-model="tableVersioned" + @input="dirty = true"> + Record version data for this table + </b-checkbox> + </b-field> + + <br /> + + <div class="level-left"> + <div class="level-item"> + <h4 class="block is-size-4">Columns</h4> + </div> + <div class="level-item"> + <b-button type="is-primary" + icon-pack="fas" + icon-left="plus" + @click="tableAddColumn()"> + New + </b-button> + </div> + </div> + + <b-table + :data="tableColumns"> + + <b-table-column field="name" + label="Name" + v-slot="props"> + {{ props.row.name }} + </b-table-column> + + <b-table-column field="data_type" + label="Data Type" + v-slot="props"> + {{ formatDataType(props.row.data_type) }} + </b-table-column> + + <b-table-column field="nullable" + label="Nullable" + v-slot="props"> + {{ props.row.nullable ? "Yes" : "No" }} + </b-table-column> + + <b-table-column field="versioned" + label="Versioned" + :visible="tableVersioned" + v-slot="props"> + {{ props.row.versioned ? "Yes" : "No" }} + </b-table-column> + + <b-table-column field="description" + label="Description" + v-slot="props"> + {{ props.row.description }} + </b-table-column> + + <b-table-column field="actions" + label="Actions" + v-slot="props"> + <a v-if="props.row.name != 'uuid'" + href="#" + @click.prevent="tableEditColumn(props.row)"> + <i class="fas fa-edit"></i> + Edit + </a> + + + <a v-if="props.row.name != 'uuid'" + href="#" + class="has-text-danger" + @click.prevent="tableDeleteColumn(props.index)"> + <i class="fas fa-trash"></i> + Delete + </a> + + </b-table-column> + + </b-table> + + <b-modal has-modal-card + :active.sync="editingColumnShowDialog"> + <div class="modal-card"> + + <header class="modal-card-head"> + <p class="modal-card-title"> + {{ (editingColumn && editingColumn.name) ? "Edit" : "New" }} Column + </p> + </header> + + <section class="modal-card-body"> + + <b-field label="Name"> + <b-input v-model="editingColumnName" + ref="editingColumnName"> + </b-input> + </b-field> + + <b-field grouped> + + <b-field label="Data Type"> + <b-select v-model="editingColumnDataType"> + <option value="String">String</option> + <option value="Boolean">Boolean</option> + <option value="Integer">Integer</option> + <option value="Numeric">Numeric</option> + <option value="Date">Date</option> + <option value="DateTime">DateTime</option> + <option value="Text">Text</option> + <option value="LargeBinary">LargeBinary</option> + <option value="_fk_uuid_">FK/UUID</option> + <option value="_other_">Other</option> + </b-select> + </b-field> + + <b-field v-if="editingColumnDataType == 'String'" + label="Length" + :type="{'is-danger': !editingColumnDataTypeLength}" + style="max-width: 6rem;"> + <b-input v-model="editingColumnDataTypeLength"> + </b-input> + </b-field> + + <b-field v-if="editingColumnDataType == 'Numeric'" + label="Precision" + :type="{'is-danger': !editingColumnDataTypePrecision}" + style="max-width: 6rem;"> + <b-input v-model="editingColumnDataTypePrecision"> + </b-input> + </b-field> + + <b-field v-if="editingColumnDataType == 'Numeric'" + label="Scale" + :type="{'is-danger': !editingColumnDataTypeScale}" + style="max-width: 6rem;"> + <b-input v-model="editingColumnDataTypeScale"> + </b-input> + </b-field> + + <b-field v-if="editingColumnDataType == '_fk_uuid_'" + label="Reference Table" + :type="{'is-danger': !editingColumnDataTypeReference}"> + <b-select v-model="editingColumnDataTypeReference"> + <option v-for="table in existingTables" + :key="table.name" + :value="table.name"> + {{ table.name }} + </option> + </b-select> + </b-field> + + <b-field v-if="editingColumnDataType == '_other_'" + label="Literal (include parens!)" + :type="{'is-danger': !editingColumnDataTypeLiteral}" + expanded> + <b-input v-model="editingColumnDataTypeLiteral"> + </b-input> + </b-field> + + </b-field> + + <b-field grouped> + + <b-field label="Nullable"> + <b-checkbox v-model="editingColumnNullable" + native-value="true"> + {{ editingColumnNullable }} + </b-checkbox> + </b-field> + + <b-field label="Versioned" + v-if="tableVersioned"> + <b-checkbox v-model="editingColumnVersioned" + native-value="true"> + {{ editingColumnVersioned }} + </b-checkbox> + </b-field> + + <b-field v-if="editingColumnDataType == '_fk_uuid_'" + label="Relationship"> + <b-input v-model="editingColumnRelationship"></b-input> + </b-field> + + </b-field> + + <b-field label="Description"> + <b-input v-model="editingColumnDescription"></b-input> + </b-field> + + </section> + + <footer class="modal-card-foot"> + <b-button @click="editingColumnShowDialog = false"> + Cancel + </b-button> + <b-button type="is-primary" + icon-pack="fas" + icon-left="save" + @click="editingColumnSave()"> + Save + </b-button> + </footer> + </div> + </b-modal> + + <br /> + + <div class="buttons"> + <b-button type="is-primary" + icon-pack="fas" + icon-left="check" + @click="showStep('write-model')"> + Details are complete + </b-button> + </div> + + </b-step-item> + + <b-step-item step="2" + value="write-model" + label="Write Model"> + <h3 class="is-size-3 block"> + Write Model + </h3> + + <b-field label="Schema Branch" horizontal> + {{ alembicBranch }} + </b-field> + + <b-field label="Table Name" horizontal> + {{ tableName }} + </b-field> + + <b-field label="Model Class" horizontal> + {{ tableModelName }} + </b-field> + + <b-field horizontal label="File"> + <b-input v-model="tableModelFile"></b-input> + </b-field> + + <b-field horizontal> + <b-checkbox v-model="tableModelFileOverwrite"> + Overwrite file if it exists + </b-checkbox> + </b-field> + + <div class="form"> + <div class="buttons"> + <b-button icon-pack="fas" + icon-left="arrow-left" + @click="showStep('enter-details')"> + Back + </b-button> + <b-button type="is-primary" + icon-pack="fas" + icon-left="save" + @click="writeModelFile()" + :disabled="writingModelFile"> + {{ writingModelFile ? "Working, please wait..." : "Write model class to file" }} + </b-button> + <b-button icon-pack="fas" + icon-left="arrow-right" + @click="showStep('review-model')"> + Skip + </b-button> + </div> + </div> + </b-step-item> + + <b-step-item step="3" + value="review-model" + label="Review Model" + clickable> + <h3 class="is-size-3 block"> + Review Model + </h3> + + <p class="block"> + Model code was generated to file: + </p> + + <p class="block is-family-code" style="padding-left: 3rem;"> + {{ tableModelFile }} + </p> + + <p class="block"> + First, review that code and adjust to your liking. + </p> + + <p class="block"> + Next be sure to import the new model. Typically this is done + by editing the file... + </p> + + <p class="block is-family-code" style="padding-left: 3rem;"> + ${model_dir}__init__.py + </p> + + <p class="block"> + ...and adding a line such as: + </p> + + <p class="block is-family-code" style="padding-left: 3rem;"> + from .{{ tableModelFileModuleName }} import {{ tableModelName }} + </p> + + <p class="block"> + Once you've done all that, the web app must be restarted. + This may happen automatically depending on your setup. + Test the model import status below. + </p> + + <div class="card block"> + <header class="card-header"> + <p class="card-header-title"> + Model Import Status + </p> + </header> + <div class="card-content"> + <div class="content"> + <div class="level"> + <div class="level-left"> + + <div class="level-item"> + <span v-if="!modelImported && !modelImportProblem"> + import not yet attempted + </span> + <span v-if="modelImported" + class="has-text-success has-text-weight-bold"> + imported okay + </span> + <span v-if="modelImportProblem" + class="has-text-danger"> + import failed: {{ modelImportStatus }} + </span> + </div> + </div> + <div class="level-right"> + <div class="level-item"> + <b-field horizontal label="Model Class"> + <b-input v-model="modelImportName"></b-input> + </b-field> + </div> + <div class="level-item"> + <b-button type="is-primary" + icon-pack="fas" + icon-left="redo" + @click="modelImportTest()"> + Refresh / Test Import + </b-button> + </div> + </div> + </div> + </div> + </div> + </div> + + <div class="buttons"> + <b-button icon-pack="fas" + icon-left="arrow-left" + @click="showStep('write-model')"> + Back + </b-button> + <b-button type="is-primary" + icon-pack="fas" + icon-left="check" + @click="showStep('write-revision')" + :disabled="!modelImported"> + Model class looks good! + </b-button> + <b-button icon-pack="fas" + icon-left="arrow-right" + @click="showStep('write-revision')"> + Skip + </b-button> + </div> + </b-step-item> + + <b-step-item step="4" + value="write-revision" + label="Write Revision" + clickable> + <h3 class="is-size-3 block"> + Write Revision + </h3> + <p class="block"> + You said the model class looked good, so next we will generate + a revision script, used to modify DB schema. + </p> + + <b-field label="Schema Branch" + message="Leave this set to your custom app branch, unless you know what you're doing."> + <b-select v-model="alembicBranch"> + <option v-for="branch in alembicBranchOptions" + :key="branch" + :value="branch"> + {{ branch }} + </option> + </b-select> + </b-field> + + <b-field label="Message" + message="Human-friendly brief description of the changes"> + <b-input v-model="revisionMessage"></b-input> + </b-field> + + <br /> + + <div class="buttons"> + <b-button icon-pack="fas" + icon-left="arrow-left" + @click="showStep('review-model')"> + Back + </b-button> + <b-button type="is-primary" + icon-pack="fas" + icon-left="save" + @click="writeRevisionScript()" + :disabled="writingRevisionScript"> + {{ writingRevisionScript ? "Working, please wait..." : "Generate revision script" }} + </b-button> + <b-button icon-pack="fas" + icon-left="arrow-right" + @click="showStep('review-revision')"> + Skip + </b-button> + </div> + </b-step-item> + + <b-step-item step="5" + value="review-revision" + label="Review Revision"> + <h3 class="is-size-3 block"> + Review Revision + </h3> + + <p class="block"> + Revision script was generated to file: + </p> + + <p class="block is-family-code" style="padding-left: 3rem;"> + {{ revisionScript }} + </p> + + <p class="block"> + Please review that code and adjust to your liking. + </p> + + <div class="buttons"> + <b-button icon-pack="fas" + icon-left="arrow-left" + @click="showStep('write-revision')"> + Back + </b-button> + <b-button type="is-primary" + icon-pack="fas" + icon-left="check" + @click="showStep('upgrade-db')"> + Revision script looks good! + </b-button> + </div> + </b-step-item> + + <b-step-item step="6" + value="upgrade-db" + label="Upgrade DB" + clickable> + <h3 class="is-size-3 block"> + Upgrade DB + </h3> + <p class="block"> + You said the revision script looked good, so next we will use + it to upgrade your actual database. + </p> + + <div class="buttons"> + <b-button icon-pack="fas" + icon-left="arrow-left" + @click="showStep('review-revision')"> + Back + </b-button> + <b-button type="is-primary" + icon-pack="fas" + icon-left="arrow-up" + @click="upgradeDB()" + :disabled="upgradingDB"> + {{ upgradingDB ? "Working, please wait..." : "Upgrade database" }} + </b-button> + </div> + </b-step-item> + + <b-step-item step="7" + value="review-db" + label="Review DB" + clickable> + <h3 class="is-size-3 block"> + Review DB + </h3> + + <p class="block"> + At this point your new table should be present in the DB. + Test below. + </p> + + <div class="card block"> + <header class="card-header"> + <p class="card-header-title"> + Table Status + </p> + </header> + <div class="card-content"> + <div class="content"> + <div class="level"> + <div class="level-left"> + + <div class="level-item"> + <span v-if="!tableCheckAttempted"> + check not yet attempted + </span> + <span v-if="tableCheckAttempted && !tableCheckProblem" + class="has-text-success has-text-weight-bold"> + table exists! + </span> + <span v-if="tableCheckProblem" + class="has-text-danger"> + {{ tableCheckProblem }} + </span> + </div> + </div> + <div class="level-right"> + <div class="level-item"> + <b-field horizontal label="Table Name"> + <b-input v-model="tableName"></b-input> + </b-field> + </div> + <div class="level-item"> + <b-button type="is-primary" + icon-pack="fas" + icon-left="redo" + @click="tableCheck()"> + Test for Table + </b-button> + </div> + </div> + </div> + </div> + </div> + </div> + + <div class="buttons"> + <b-button icon-pack="fas" + icon-left="arrow-left" + @click="showStep('upgrade-db')"> + Back + </b-button> + <b-button type="is-primary" + icon-pack="fas" + icon-left="check" + @click="showStep('commit-code')" + :disabled="!tableCheckAttempted || tableCheckProblem"> + DB looks good! + </b-button> + </div> + </b-step-item> + + <b-step-item step="8" + value="commit-code" + label="Commit Code"> + <h3 class="is-size-3 block"> + Commit Code + </h3> + + <p class="block"> + Hope you're having a great day. + </p> + + <p class="block"> + Don't forget to commit code changes to your source repo. + </p> + + <div class="buttons"> + <b-button icon-pack="fas" + icon-left="arrow-left" + @click="showStep('review-db')"> + Back + </b-button> + <once-button type="is-primary" + tag="a" :href="tableURL" + icon-left="arrow-right" + :text="`Show me my new table: ${'$'}{tableName}`"> + </once-button> + </div> + </b-step-item> + </b-steps> +</%def> + +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> + + // nb. for warning user they may lose changes if leaving page + ThisPageData.dirty = false + + ThisPageData.activeStep = null + ThisPageData.alembicBranchOptions = ${json.dumps(branch_name_options)|n} + + ThisPageData.existingTables = ${json.dumps(existing_tables)|n} + + ThisPageData.alembicBranch = ${json.dumps(branch_name)|n} + ThisPageData.tableName = '${rattail_app.get_table_prefix()}_widget' + ThisPageData.tableModelName = '${rattail_app.get_class_prefix()}Widget' + ThisPageData.tableModelTitle = 'Widget' + ThisPageData.tableModelTitlePlural = 'Widgets' + ThisPageData.tableDescription = "Represents a cool widget." + ThisPageData.tableVersioned = true + + ThisPageData.tableColumns = [{ + name: 'uuid', + data_type: { + type: 'String', + length: 32, + }, + nullable: false, + description: "UUID primary key", + versioned: true, + }] + + ThisPageData.editingColumnShowDialog = false + ThisPageData.editingColumn = null + ThisPageData.editingColumnName = null + ThisPageData.editingColumnDataType = null + ThisPageData.editingColumnDataTypeLength = null + ThisPageData.editingColumnDataTypePrecision = null + ThisPageData.editingColumnDataTypeScale = null + ThisPageData.editingColumnDataTypeReference = null + ThisPageData.editingColumnDataTypeLiteral = null + ThisPageData.editingColumnNullable = true + ThisPageData.editingColumnDescription = null + ThisPageData.editingColumnVersioned = true + ThisPageData.editingColumnRelationship = null + + ThisPage.methods.showStep = function(step) { + this.activeStep = step + + // scroll so top of page is shown + this.$nextTick(() => { + this.$refs['showme'].scrollIntoView(true) + }) + } + + ThisPage.methods.tableAddColumn = function() { + this.editingColumn = null + this.editingColumnName = null + this.editingColumnDataType = null + this.editingColumnDataTypeLength = null + this.editingColumnDataTypePrecision = null + this.editingColumnDataTypeScale = null + this.editingColumnDataTypeReference = null + this.editingColumnDataTypeLiteral = null + this.editingColumnNullable = true + this.editingColumnDescription = null + this.editingColumnVersioned = true + this.editingColumnRelationship = null + this.editingColumnShowDialog = true + this.$nextTick(() => { + this.$refs.editingColumnName.focus() + }) + } + + ThisPage.methods.tableEditColumn = function(column) { + this.editingColumn = column + this.editingColumnName = column.name + this.editingColumnDataType = column.data_type.type + this.editingColumnDataTypeLength = column.data_type.length + this.editingColumnDataTypePrecision = column.data_type.precision + this.editingColumnDataTypeScale = column.data_type.scale + this.editingColumnDataTypeReference = column.data_type.reference + this.editingColumnDataTypeLiteral = column.data_type.literal + this.editingColumnNullable = column.nullable + this.editingColumnDescription = column.description + this.editingColumnVersioned = column.versioned + this.editingColumnRelationship = column.relationship + this.editingColumnShowDialog = true + this.$nextTick(() => { + this.$refs.editingColumnName.focus() + }) + } + + ThisPage.methods.formatDataType = function(dataType) { + if (dataType.type == 'String') { + return `sa.String(length=${'$'}{dataType.length})` + } else if (dataType.type == 'Numeric') { + return `sa.Numeric(precision=${'$'}{dataType.precision}, scale=${'$'}{dataType.scale})` + } else if (dataType.type == '_fk_uuid_') { + return 'sa.String(length=32)' + } else if (dataType.type == '_other_') { + return dataType.literal + } else { + return `sa.${'$'}{dataType.type}()` + } + } + + ThisPage.watch.editingColumnDataTypeReference = function(newval, oldval) { + this.editingColumnRelationship = newval + if (newval && !this.editingColumnName) { + this.editingColumnName = `${'$'}{newval}_uuid` + } + } + + ThisPage.methods.editingColumnSave = function() { + let column + if (this.editingColumn) { + column = this.editingColumn + } else { + column = {} + this.tableColumns.push(column) + } + + column.name = this.editingColumnName + + let dataType = {type: this.editingColumnDataType} + if (dataType.type == 'String') { + dataType.length = this.editingColumnDataTypeLength + } else if (dataType.type == 'Numeric') { + dataType.precision = this.editingColumnDataTypePrecision + dataType.scale = this.editingColumnDataTypeScale + } else if (dataType.type == '_fk_uuid_') { + dataType.reference = this.editingColumnDataTypeReference + } else if (dataType.type == '_other_') { + dataType.literal = this.editingColumnDataTypeLiteral + } + column.data_type = dataType + + column.nullable = this.editingColumnNullable + column.description = this.editingColumnDescription + column.versioned = this.editingColumnVersioned + column.relationship = this.editingColumnRelationship + + this.dirty = true + this.editingColumnShowDialog = false + } + + ThisPage.methods.tableDeleteColumn = function(index) { + if (confirm("Really delete this column?")) { + this.tableColumns.splice(index, 1) + this.dirty = true + } + } + + ThisPageData.tableModelFile = '${model_dir}widget.py' + ThisPageData.tableModelFileOverwrite = false + ThisPageData.writingModelFile = false + + ThisPage.methods.writeModelFile = function() { + this.writingModelFile = true + + this.modelImportName = this.tableModelName + this.modelImported = false + this.modelImportStatus = "import not yet attempted" + this.modelImportProblem = false + + for (let column of this.tableColumns) { + column.formatted_data_type = this.formatDataType(column.data_type) + } + + let url = '${url('{}.write_model_file'.format(route_prefix))}' + let params = { + branch_name: this.alembicBranch, + table_name: this.tableName, + model_name: this.tableModelName, + model_title: this.tableModelTitle, + model_title_plural: this.tableModelTitlePlural, + description: this.tableDescription, + versioned: this.tableVersioned, + columns: this.tableColumns, + module_file: this.tableModelFile, + overwrite: this.tableModelFileOverwrite, + } + this.submitForm(url, params, response => { + this.writingModelFile = false + this.activeStep = 'review-model' + }, response => { + this.writingModelFile = false + }) + } + + ThisPageData.modelImportName = '${rattail_app.get_class_prefix()}Widget' + ThisPageData.modelImportStatus = "import not yet attempted" + ThisPageData.modelImported = false + ThisPageData.modelImportProblem = false + + ThisPage.computed.tableModelFileModuleName = function() { + let path = this.tableModelFile + path = path.replace(/^.*\//, '') + path = path.replace(/\.py$/, '') + return path + } + + ThisPage.methods.modelImportTest = function() { + let url = '${url('{}.check_model'.format(route_prefix))}' + let params = {model_name: this.modelImportName} + this.submitForm(url, params, response => { + if (response.data.problem) { + this.modelImportProblem = true + this.modelImported = false + this.modelImportStatus = response.data.problem + } else { + this.modelImportProblem = false + this.modelImported = true + this.revisionMessage = `add table for ${'$'}{this.tableModelTitlePlural}` + } + }) + } + + ThisPageData.writingRevisionScript = false + ThisPageData.revisionMessage = null + ThisPageData.revisionScript = null + + ThisPage.methods.writeRevisionScript = function() { + this.writingRevisionScript = true + + let url = '${url('{}.write_revision_script'.format(route_prefix))}' + let params = { + branch: this.alembicBranch, + message: this.revisionMessage, + } + this.submitForm(url, params, response => { + this.writingRevisionScript = false + this.revisionScript = response.data.script + this.activeStep = 'review-revision' + }, response => { + this.writingRevisionScript = false + }) + } + + ThisPageData.upgradingDB = false + + ThisPage.methods.upgradeDB = function() { + this.upgradingDB = true + + let url = '${url('{}.upgrade_db'.format(route_prefix))}' + let params = {} + this.submitForm(url, params, response => { + this.upgradingDB = false + this.activeStep = 'review-db' + }, response => { + this.upgradingDB = false + }) + } + + ThisPageData.tableCheckAttempted = false + ThisPageData.tableCheckProblem = null + + ThisPageData.tableURL = null + + ThisPage.methods.tableCheck = function() { + let url = '${url('{}.check_table'.format(route_prefix))}' + let params = {table_name: this.tableName} + this.submitForm(url, params, response => { + if (response.data.problem) { + this.tableCheckProblem = response.data.problem + } else { + this.tableURL = response.data.url + } + this.tableCheckAttempted = true + }) + } + + // cf. https://stackoverflow.com/a/56551646 + ThisPage.methods.beforeWindowUnload = function(e) { + + // warn user if navigating away would lose changes + if (this.dirty) { + e.preventDefault() + e.returnValue = '' + } + } + + ThisPage.created = function() { + window.addEventListener('beforeunload', this.beforeWindowUnload) + } + + </script> +</%def> diff --git a/tailbone/templates/trainwreck/transactions/index.mako b/tailbone/templates/tables/index.mako similarity index 55% rename from tailbone/templates/trainwreck/transactions/index.mako rename to tailbone/templates/tables/index.mako index 31d956fc..b13f0785 100644 --- a/tailbone/templates/trainwreck/transactions/index.mako +++ b/tailbone/templates/tables/index.mako @@ -3,8 +3,8 @@ <%def name="context_menu_items()"> ${parent.context_menu_items()} - % if master.has_perm('rollover'): - <li>${h.link_to("Yearly Rollover", url('{}.rollover'.format(route_prefix)))}</li> + % if master.has_perm('migrations'): + <li>${h.link_to("View / Apply Migrations", url('{}.migrations'.format(route_prefix)))}</li> % endif </%def> diff --git a/tailbone/templates/tables/migrations.mako b/tailbone/templates/tables/migrations.mako new file mode 100644 index 00000000..af1734eb --- /dev/null +++ b/tailbone/templates/tables/migrations.mako @@ -0,0 +1,11 @@ +## -*- coding: utf-8; -*- +<%inherit file="/page.mako" /> + +<%def name="title()">Schema Migrations</%def> + +<%def name="render_this_page()"> + <h3 class="is-size-3">TODO: show current revisions and allow DB upgrades</h3> +</%def> + + +${parent.body()} diff --git a/tailbone/templates/tempmon/appliances/view.mako b/tailbone/templates/tempmon/appliances/view.mako index bbaa0e3f..a55af922 100644 --- a/tailbone/templates/tempmon/appliances/view.mako +++ b/tailbone/templates/tempmon/appliances/view.mako @@ -8,5 +8,9 @@ % endif </%def> - -${parent.body()} +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> + ${form.vue_component}Data.probesData = ${json.dumps(probes_data)|n} + </script> +</%def> diff --git a/tailbone/templates/tempmon/clients/view.mako b/tailbone/templates/tempmon/clients/view.mako index ab65bac6..434da4c8 100644 --- a/tailbone/templates/tempmon/clients/view.mako +++ b/tailbone/templates/tempmon/clients/view.mako @@ -1,20 +1,6 @@ ## -*- coding: utf-8; -*- <%inherit file="/master/view.mako" /> -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - % if not use_buefy: - <script type="text/javascript"> - $(function() { - $('#restart-client').click(function() { - disable_button(this); - location.href = '${url('tempmon.clients.restart', uuid=instance.uuid)}'; - }); - }); - </script> - % endif -</%def> - <%def name="context_menu_items()"> ${parent.context_menu_items()} % if request.has_perm('tempmon.appliances.dashboard'): @@ -27,17 +13,18 @@ <div class="object-helper"> <h3>Client Tools</h3> <div class="object-helper-content"> - % if use_buefy: - <once-button tag="a" href="${url('{}.restart'.format(route_prefix), uuid=instance.uuid)}" - type="is-primary" - text="Restart tempmon-client daemon"> - </once-button> - % else: - <button type="button" id="restart-client">Restart tempmon-client daemon</button> - % endif + <once-button tag="a" href="${url('{}.restart'.format(route_prefix), uuid=instance.uuid)}" + type="is-primary" + text="Restart tempmon-client daemon"> + </once-button> </div> </div> % endif </%def> -${parent.body()} +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> + ${form.vue_component}Data.probesData = ${json.dumps(probes_data)|n} + </script> +</%def> diff --git a/tailbone/templates/tempmon/dashboard.mako b/tailbone/templates/tempmon/dashboard.mako index 815eb89e..befaf8b4 100644 --- a/tailbone/templates/tempmon/dashboard.mako +++ b/tailbone/templates/tempmon/dashboard.mako @@ -8,33 +8,85 @@ <%def name="extra_javascript()"> ${parent.extra_javascript()} <script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.5.0/Chart.bundle.min.js"></script> - % if not use_buefy: - <script type="text/javascript"> +</%def> - var contexts = {}; - var charts = {}; +<%def name="render_this_page()"> + ${h.form(request.current_route_url(), ref='applianceForm')} + ${h.csrf_token(request)} + <div class="level-left"> - function fetchReadings(appliance_uuid) { - if (appliance_uuid === undefined) { - appliance_uuid = $('#appliance_uuid').val(); + <div class="level-item"> + <b-field label="Appliance" horizontal> + <b-select name="appliance_uuid" + v-model="applianceUUID" + @input="$refs.applianceForm.submit()"> + <option v-for="appliance in appliances" + :key="appliance.uuid" + :value="appliance.uuid"> + {{ appliance.name }} + </option> + </b-select> + </b-field> + </div> + + % if appliance: + <div class="level-item"> + <a href="${url('tempmon.appliances.view', uuid=appliance.uuid)}"> + ${h.image(url('tempmon.appliances.thumbnail', uuid=appliance.uuid), "")} + </a> + </div> + % endif + + </div> + ${h.end_form()} + + % if appliance and appliance.probes: + % for probe in appliance.probes: + <h4 class="is-size-4"> + Probe: ${h.link_to(probe.description, url('tempmon.probes.graph', uuid=probe.uuid))} + (status: ${enum.TEMPMON_PROBE_STATUS[probe.status]}) + </h4> + % if probe.enabled: + <canvas ref="tempchart-${probe.uuid}" width="400" height="60"></canvas> + % else: + <p>This probe is not enabled.</p> + % endif + % endfor + % elif appliance: + <h3>This appliance has no probes configured!</h3> + % else: + <h3>Please choose an appliance.</h3> + % endif +</%def> + +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> + + ThisPageData.appliances = ${json.dumps(appliances_data)|n} + ThisPageData.applianceUUID = ${json.dumps(appliance.uuid if appliance else None)|n} + ThisPageData.charts = {} + + ThisPage.methods.fetchReadings = function(uuid) { + + if (!uuid) { + uuid = this.applianceUUID } - $('.form-wrapper').mask("Fetching data"); - - if (Object.keys(charts).length) { - Object.keys(charts).forEach(function(key) { - charts[key].destroy(); - delete charts[key]; - }); + for (let chart in this.charts) { + chart.destroy() } + this.charts = [] - var url = '${url("tempmon.dashboard.readings")}'; - var params = {'appliance_uuid': appliance_uuid}; - $.get(url, params, function(data) { + let url = '${url('tempmon.dashboard.readings')}' + let params = {appliance_uuid: uuid} + this.$http.get(url, {params: params}).then(response => { + if (response.data.probes) { - if (data.probes) { - data.probes.forEach(function(probe) { - charts[probe.uuid] = new Chart(contexts[probe.uuid], { + for (let probe of response.data.probes) { + + let context = this.$refs[`tempchart-${'$'}{probe.uuid}`] + this.charts[probe.uuid] = new Chart(context, { type: 'scatter', data: { datasets: [{ @@ -51,85 +103,18 @@ }] } } - }); - }); - } else { - // TODO: should improve this - alert(data.error); - } + }) + } - $('.form-wrapper').unmask(); - }); + } else { + alert(response.data.error) + } + }) } - $(function() { - - % for probe in appliance.probes: - contexts['${probe.uuid}'] = $('#tempchart-${probe.uuid}'); - % endfor - - $('#appliance_uuid').selectmenu({ - change: function(event, ui) { - $('.form-wrapper').mask("Fetching data"); - $(this).parents('form').submit(); - } - }); - - fetchReadings(); - }); + ThisPage.mounted = function() { + this.fetchReadings() + } </script> - % endif </%def> - -<%def name="render_this_page()"> - <div style="display: flex;"> - - <div class="form-wrapper"> - <div class="form"> - ${h.form(request.current_route_url())} - ${h.csrf_token(request)} - % if use_buefy: - <b-field horizontal label="Appliance"> - ${appliance_select} - </b-field> - % else: - <div class="field-wrapper"> - <label>Appliance</label> - <div class="field"> - ${appliance_select} - </div> - </div> - % endif - ${h.end_form()} - </div> - </div> - - <a href="${url('tempmon.appliances.view', uuid=appliance.uuid)}"> - ${h.image(url('tempmon.appliances.thumbnail', uuid=appliance.uuid), "")} - </a> - </div> - - % if appliance.probes: - % for probe in appliance.probes: - <h3> - Probe: ${h.link_to(probe.description, url('tempmon.probes.graph', uuid=probe.uuid))} - (status: ${enum.TEMPMON_PROBE_STATUS[probe.status]}) - </h3> - % if probe.enabled: - % if use_buefy: - <canvas ref="tempchart" width="400" height="150"></canvas> - % else: - <canvas id="tempchart-${probe.uuid}" width="400" height="60"></canvas> - % endif - % else: - <p>This probe is not enabled.</p> - % endif - % endfor - % else: - <h3>This appliance has no probes configured!</h3> - % endif -</%def> - - -${parent.body()} diff --git a/tailbone/templates/tempmon/probes/create.mako b/tailbone/templates/tempmon/probes/create.mako deleted file mode 100644 index 062997d7..00000000 --- a/tailbone/templates/tempmon/probes/create.mako +++ /dev/null @@ -1,17 +0,0 @@ -## -*- coding: utf-8 -*- -<%inherit file="/master/create.mako" /> - -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - <script type="text/javascript"> - $(function() { - - $('.field-wrapper.client_uuid select').selectmenu(); - - $('.field-wrapper.appliance_type select').selectmenu(); - - }); - </script> -</%def> - -${parent.body()} diff --git a/tailbone/templates/tempmon/probes/edit.mako b/tailbone/templates/tempmon/probes/edit.mako deleted file mode 100644 index b9f2a6b2..00000000 --- a/tailbone/templates/tempmon/probes/edit.mako +++ /dev/null @@ -1,17 +0,0 @@ -## -*- coding: utf-8 -*- -<%inherit file="/master/edit.mako" /> - -<%def name="head_tags()"> - ${parent.head_tags()} - <script type="text/javascript"> - $(function() { - - $('.field-wrapper.client_uuid select').selectmenu(); - - $('.field-wrapper.appliance_type select').selectmenu(); - - }); - </script> -</%def> - -${parent.body()} diff --git a/tailbone/templates/tempmon/probes/graph.mako b/tailbone/templates/tempmon/probes/graph.mako index 3255edb7..94a440e0 100644 --- a/tailbone/templates/tempmon/probes/graph.mako +++ b/tailbone/templates/tempmon/probes/graph.mako @@ -6,71 +6,6 @@ <%def name="extra_javascript()"> ${parent.extra_javascript()} <script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.5.0/Chart.bundle.min.js"></script> - % if not use_buefy: - <script type="text/javascript"> - - var ctx = null; - var chart = null; - - function fetchReadings(timeRange) { - if (timeRange === undefined) { - timeRange = $('#time-range').val(); - } - - var timeUnit; - if (timeRange == 'last hour') { - timeUnit = 'minute'; - } else if (['last 6 hours', 'last day'].includes(timeRange)) { - timeUnit = 'hour'; - } else { - timeUnit = 'day'; - } - - $('.form-wrapper').mask("Fetching data"); - if (chart) { - chart.destroy(); - } - - $.get('${url('{}.graph_readings'.format(route_prefix), uuid=probe.uuid)}', {'time-range': timeRange}, function(data) { - - chart = new Chart(ctx, { - type: 'scatter', - data: { - datasets: [{ - label: "${probe.description}", - data: data - }] - }, - options: { - scales: { - xAxes: [{ - type: 'time', - time: {unit: timeUnit}, - position: 'bottom' - }] - } - } - }); - - $('.form-wrapper').unmask(); - }); - } - - $(function() { - - ctx = $('#tempchart'); - - $('#time-range').selectmenu({ - change: function(event, ui) { - fetchReadings(ui.item.value); - } - }); - - fetchReadings(); - }); - - </script> - % endif </%def> <%def name="context_menu_items()"> @@ -89,50 +24,29 @@ <div class="form-wrapper"> <div class="form"> - % if use_buefy: - <b-field horizontal label="Appliance"> - <div> - % if probe.appliance: - <a href="${url('tempmon.appliances.view', uuid=probe.appliance.uuid)}">${probe.appliance}</a> - % endif - </div> - </b-field> - % else: - <div class="field-wrapper"> - <label>Appliance</label> - <div class="field"> - % if probe.appliance: - <a href="${url('tempmon.appliances.view', uuid=probe.appliance.uuid)}">${probe.appliance}</a> - % endif - </div> - </div> - % endif + <b-field horizontal label="Appliance"> + <div> + % if probe.appliance: + <a href="${url('tempmon.appliances.view', uuid=probe.appliance.uuid)}">${probe.appliance}</a> + % endif + </div> + </b-field> - % if use_buefy: - <b-field horizontal label="Probe Location"> - <div> - ${probe.location or ""} - </div> - </b-field> - % else: - <div class="field-wrapper"> - <label>Probe Location</label> - <div class="field">${probe.location or ""}</div> - </div> - % endif + <b-field horizontal label="Probe Location"> + <div> + ${probe.location or ""} + </div> + </b-field> - % if use_buefy: - <b-field horizontal label="Showing"> - ${time_range} - </b-field> - % else: - <div class="field-wrapper"> - <label>Showing</label> - <div class="field"> - ${time_range} - </div> - </div> - % endif + <b-field horizontal label="Showing"> + <b-select v-model="currentTimeRange" + @input="timeRangeChanged"> + <option value="last hour">Last Hour</option> + <option value="last 6 hours">Last 6 Hours</option> + <option value="last day">Last Day</option> + <option value="last week">Last Week</option> + </b-select> + </b-field> </div> </div> @@ -149,16 +63,12 @@ </div> - % if use_buefy: - <canvas ref="tempchart" width="400" height="150"></canvas> - % else: - <canvas id="tempchart" width="400" height="150"></canvas> - % endif + <canvas ref="tempchart" width="400" height="150"></canvas> </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> ThisPageData.currentTimeRange = ${json.dumps(current_time_range)|n} ThisPageData.chart = null @@ -182,7 +92,9 @@ this.chart.destroy() } - this.$http.get('${url('{}.graph_readings'.format(route_prefix), uuid=probe.uuid)}', {params: {'time-range': timeRange}}).then(({ data }) => { + let url = '${url(f'{route_prefix}.graph_readings', uuid=probe.uuid)}' + let params = {'time-range': timeRange} + this.$http.get(url, {params: params}).then(({ data }) => { this.chart = new Chart(this.$refs.tempchart, { type: 'scatter', @@ -216,6 +128,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/tempmon/probes/view.mako b/tailbone/templates/tempmon/probes/view.mako index 1e309129..7afd2427 100644 --- a/tailbone/templates/tempmon/probes/view.mako +++ b/tailbone/templates/tempmon/probes/view.mako @@ -1,87 +1,30 @@ ## -*- coding: utf-8; -*- <%inherit file="/master/view.mako" /> -<%def name="render_form_complete()"> - % if use_buefy: - - ## ${self.render_form()} - - <script type="text/x-template" id="form-page-template"> - - <div style="display: flex; justify-content: space-between;"> - - <div class="form-wrapper"> - - <div style="display: flex; flex-direction: column;"> - - <nav class="panel" id="probe-main"> - <p class="panel-heading">General</p> - <div class="panel-block"> - <div> - ${self.render_main_fields(form)} - </div> - </div> - </nav> - - <div style="display: flex;"> - <div class="panel-wrapper"> - ${self.left_column()} - </div> - <div class="panel-wrapper" style="margin-left: 1em;"> <!-- right column --> - ${self.right_column()} - </div> - </div> - - </div> - </div> - - <ul id="context-menu"> - ${self.context_menu_items()} - </ul> - - </div> - </script> - - <div id="form-page-app"> - <form-page></form-page> - </div> - - % else: - ## legacy / not buefy - - <div style="display: flex; justify-content: space-between;"> - - <div class="form-wrapper"> - - <div style="display: flex; flex-direction: column;"> - - <div class="panel" id="probe-main"> - <h2>General</h2> - <div class="panel-body"> - <div> - ${self.render_main_fields(form)} - </div> - </div> - </div> - - <div style="display: flex;"> - <div class="panel-wrapper"> - ${self.left_column()} - </div> - <div class="panel-wrapper" style="margin-left: 1em;"> <!-- right column --> - ${self.right_column()} - </div> - </div> +<%def name="page_content()"> + <div class="form-wrapper"> + <div style="display: flex; flex-direction: column;"> + <nav class="panel" id="probe-main"> + <p class="panel-heading">General</p> + <div class="panel-block"> + <div> + ${self.render_main_fields(form)} </div> </div> + </nav> - <ul id="context-menu"> - ${self.context_menu_items()} - </ul> - + <div style="display: flex;"> + <div class="panel-wrapper"> + ${self.left_column()} + </div> + <div class="panel-wrapper" style="margin-left: 1em;"> <!-- right column --> + ${self.right_column()} + </div> </div> - % endif + + </div> + </div> </%def> @@ -113,43 +56,25 @@ </%def> <%def name="left_column()"> - % if use_buefy: - <nav class="panel"> - <p class="panel-heading">Temperatures</p> - <div class="panel-block"> - <div> - ${self.render_temperature_fields(form)} - </div> - </div> - </nav> - % else: - <div class="panel"> - <h2>Temperatures</h2> - <div class="panel-body"> - ${self.render_temperature_fields(form)} + <nav class="panel"> + <p class="panel-heading">Temperatures</p> + <div class="panel-block"> + <div> + ${self.render_temperature_fields(form)} + </div> </div> - </div> - % endif + </nav> </%def> <%def name="right_column()"> - % if use_buefy: - <nav class="panel"> - <p class="panel-heading">Timeouts</p> - <div class="panel-block"> - <div> - ${self.render_timeout_fields(form)} - </div> - </div> - </nav> - % else: - <div class="panel"> - <h2>Timeouts</h2> - <div class="panel-body"> - ${self.render_timeout_fields(form)} + <nav class="panel"> + <p class="panel-heading">Timeouts</p> + <div class="panel-block"> + <div> + ${self.render_timeout_fields(form)} + </div> </div> - </div> - % endif + </nav> </%def> <%def name="render_temperature_fields(form)"> diff --git a/tailbone/templates/themes/butterball/base.mako b/tailbone/templates/themes/butterball/base.mako new file mode 100644 index 00000000..b69eacfb --- /dev/null +++ b/tailbone/templates/themes/butterball/base.mako @@ -0,0 +1,1245 @@ +## -*- coding: utf-8; -*- +<%namespace name="base_meta" file="/base_meta.mako" /> +<%namespace name="page_help" file="/page_help.mako" /> +<%namespace file="/field-components.mako" import="make_field_components" /> +<%namespace file="/formposter.mako" import="declare_formposter_mixin" /> +<%namespace file="/grids/filter-components.mako" import="make_grid_filter_components" /> +<%namespace file="/buefy-components.mako" import="make_buefy_components" /> +<%namespace file="/buefy-plugin.mako" import="make_buefy_plugin" /> +<%namespace file="/http-plugin.mako" import="make_http_plugin" /> +## <%namespace file="/grids/nav.mako" import="grid_index_nav" /> +## <%namespace name="multi_file_upload" file="/multi_file_upload.mako" /> +<!DOCTYPE html> +<html lang="en"> + <head> + <meta http-equiv="content-type" content="text/html; charset=UTF-8" /> + <title>${base_meta.global_title()} » ${capture(self.title)|n}</title> + ${base_meta.favicon()} + ${self.header_core()} + ${self.head_tags()} + </head> + + <body> + <div id="app" style="height: 100%;"> + <whole-page></whole-page> + </div> + + ## TODO: this must come before the self.body() call..but why? + ${declare_formposter_mixin()} + + ## content body from derived/child template + ${self.body()} + + ## Vue app + ${self.render_vue_templates()} + ${self.modify_vue_vars()} + ${self.make_vue_components()} + ${self.make_vue_app()} + </body> +</html> + +<%def name="title()"></%def> + +<%def name="content_title()"> + ${self.title()} +</%def> + +<%def name="header_core()"> + ${self.core_javascript()} + ${self.core_styles()} +</%def> + +<%def name="core_javascript()"> + <script type="importmap"> + { + ## TODO: eventually version / url should be configurable + "imports": { + "vue": "${h.get_liburl(request, 'bb_vue', prefix='tailbone')}", + "@oruga-ui/oruga-next": "${h.get_liburl(request, 'bb_oruga', prefix='tailbone')}", + "@oruga-ui/theme-bulma": "${h.get_liburl(request, 'bb_oruga_bulma', prefix='tailbone')}", + "@fortawesome/fontawesome-svg-core": "${h.get_liburl(request, 'bb_fontawesome_svg_core', prefix='tailbone')}", + "@fortawesome/free-solid-svg-icons": "${h.get_liburl(request, 'bb_free_solid_svg_icons', prefix='tailbone')}", + "@fortawesome/vue-fontawesome": "${h.get_liburl(request, 'bb_vue_fontawesome', prefix='tailbone')}" + } + } + </script> + <script> + // empty stub to avoid errors for older buefy templates + const Vue = { + component(tagname, classname) {}, + } + </script> +</%def> + +<%def name="core_styles()"> + % if user_css: + ${h.stylesheet_link(user_css)} + % else: + ${h.stylesheet_link(h.get_liburl(request, 'bb_oruga_bulma_css', prefix='tailbone'))} + % endif +</%def> + +<%def name="head_tags()"> + ${self.extra_javascript()} + ${self.extra_styles()} +</%def> + +<%def name="extra_javascript()"> +## ## some commonly-useful logic for detecting (non-)numeric input +## ${h.javascript_link(request.static_url('tailbone:static/js/numeric.js') + '?ver={}'.format(tailbone.__version__))} +## +## ## debounce, for better autocomplete performance +## ${h.javascript_link(request.static_url('tailbone:static/js/debounce.js') + '?ver={}'.format(tailbone.__version__))} + +## ## Tailbone / Buefy stuff +## ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.numericinput.js') + '?ver={}'.format(tailbone.__version__))} +## ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.timepicker.js') + '?ver={}'.format(tailbone.__version__))} + +## <script type="text/javascript"> +## +## ## NOTE: this code was copied from +## ## https://bulma.io/documentation/components/navbar/#navbar-menu +## +## document.addEventListener('DOMContentLoaded', () => { +## +## // Get all "navbar-burger" elements +## const $navbarBurgers = Array.prototype.slice.call(document.querySelectorAll('.navbar-burger'), 0) +## +## // Add a click event on each of them +## $navbarBurgers.forEach( el => { +## el.addEventListener('click', () => { +## +## // Get the target from the "data-target" attribute +## const target = el.dataset.target +## const $target = document.getElementById(target) +## +## // Toggle the "is-active" class on both the "navbar-burger" and the "navbar-menu" +## el.classList.toggle('is-active') +## $target.classList.toggle('is-active') +## +## }) +## }) +## }) +## +## </script> +</%def> + +<%def name="extra_styles()"> + +## ${h.stylesheet_link(request.static_url('tailbone:static/css/base.css') + '?ver={}'.format(tailbone.__version__))} +## ${h.stylesheet_link(request.static_url('tailbone:static/css/layout.css') + '?ver={}'.format(tailbone.__version__))} +## ${h.stylesheet_link(request.static_url('tailbone:static/css/grids.css') + '?ver={}'.format(tailbone.__version__))} +## ${h.stylesheet_link(request.static_url('tailbone:static/css/filters.css') + '?ver={}'.format(tailbone.__version__))} +## ${h.stylesheet_link(request.static_url('tailbone:static/css/forms.css') + '?ver={}'.format(tailbone.__version__))} + + ${h.stylesheet_link(request.static_url('tailbone:static/css/grids.rowstatus.css') + '?ver={}'.format(tailbone.__version__))} + ${h.stylesheet_link(request.static_url('tailbone:static/css/diffs.css') + '?ver={}'.format(tailbone.__version__))} + + ## nb. this is used (only?) in /generate-feature page + ${h.stylesheet_link(request.static_url('tailbone:static/css/codehilite.css') + '?ver={}'.format(tailbone.__version__))} + + <style> + + /* ****************************** */ + /* page */ + /* ****************************** */ + + /* nb. helps force footer to bottom of screen */ + html, body { + height: 100%; + } + + ## maybe add testing watermark + % if not request.rattail_config.production(): + html, .navbar, .footer { + background-image: url(${request.static_url('tailbone:static/img/testing.png')}); + } + % endif + + ## maybe force global background color + % if background_color: + body, .navbar, .footer { + background-color: ${background_color}; + } + % endif + + #content-title h1 { + max-width: 50%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + ## TODO: is this a good idea? + h1.title { + font-size: 2rem; + font-weight: bold; + margin-bottom: 0 !important; + } + + #context-menu { + margin-bottom: 1em; + /* margin-left: 1em; */ + text-align: right; + /* white-space: nowrap; */ + } + + ## TODO: ugh why is this needed to center modal on screen? + .modal .modal-content .modal-card { + margin: auto; + } + + .object-helpers .panel { + margin: 1rem; + margin-bottom: 1.5rem; + } + + /* ****************************** */ + /* grids */ + /* ****************************** */ + + .filters .filter-fieldname .button { + min-width: ${filter_fieldname_width}; + justify-content: left; + } + .filters .filter-verb { + min-width: ${filter_verb_width}; + } + + .grid-tools { + display: flex; + gap: 0.5rem; + justify-content: end; + } + + a.grid-action { + align-items: center; + display: inline-flex; + gap: 0.1rem; + white-space: nowrap; + } + + /************************************************** + * grid rows which are "checked" (selected) + **************************************************/ + + /* TODO: this references some color values, whereas it would be preferable + * to refer to some sort of "state" instead, color of which was + * configurable. b/c these are just the default Buefy theme colors. */ + + tr.is-checked { + background-color: #7957d5; + color: white; + } + + tr.is-checked:hover { + color: #363636; + } + + tr.is-checked a { + color: white; + } + + tr.is-checked:hover a { + color: #7957d5; + } + + /* ****************************** */ + /* forms */ + /* ****************************** */ + + /* note that these should only apply to "normal" primary forms */ + + .form { + padding-left: 5em; + } + + /* .form-wrapper .form .field.is-horizontal .field-label .label, */ + .form-wrapper .field.is-horizontal .field-label { + text-align: left; + white-space: nowrap; + min-width: 18em; + } + + .form-wrapper .form .field.is-horizontal .field-body { + min-width: 30em; + } + + .form-wrapper .form .field.is-horizontal .field-body .autocomplete, + .form-wrapper .form .field.is-horizontal .field-body .autocomplete .dropdown-trigger, + .form-wrapper .form .field.is-horizontal .field-body .select, + .form-wrapper .form .field.is-horizontal .field-body .select select { + width: 100%; + } + + .form-wrapper .form .buttons { + padding-left: 10rem; + } + + /****************************** + * fix datepicker within modals + * TODO: someday this may not be necessary? cf. + * https://github.com/buefy/buefy/issues/292#issuecomment-347365637 + ******************************/ + + /* TODO: this does change some things, but does not actually work 100% */ + /* right for oruga 0.8.7 or 0.8.9 */ + + .modal .animation-content .modal-card { + overflow: visible !important; + } + + .modal-card-body { + overflow: visible !important; + } + + /* TODO: a simpler option we might try sometime instead? */ + /* cf. https://github.com/buefy/buefy/issues/292#issuecomment-1073851313 */ + + /* .dropdown-content{ */ + /* position: fixed; */ + /* } */ + + </style> + ${base_meta.extra_styles()} +</%def> + +<%def name="make_feedback_component()"> + <% request.register_component('feedback-form', 'FeedbackForm') %> + <script type="text/x-template" id="feedback-form-template"> + <div> + + <o-button variant="primary" + @click="showFeedback()" + icon-left="comment"> + Feedback + </o-button> + + <o-modal v-model:active="showDialog"> + <div class="modal-card"> + + <header class="modal-card-head"> + <p class="modal-card-title"> + User Feedback + </p> + </header> + + <section class="modal-card-body"> + <p class="block"> + Questions, suggestions, comments, complaints, etc. + <span class="red">regarding this website</span> are + welcome and may be submitted below. + </p> + + <b-field label="User Name"> + <b-input v-model="userName" + % if request.user: + disabled + % endif + expanded> + </b-input> + </b-field> + + <b-field label="Referring URL"> + <b-input + v-model="referrer" + disabled expanded> + </b-input> + </b-field> + + <o-field label="Message"> + <o-input type="textarea" + v-model="message" + ref="message" + expanded> + </o-input> + </o-field> + + % if request.rattail_config.getbool('tailbone', 'feedback_allows_reply'): + <div class="level"> + <div class="level-left"> + <div class="level-item"> + <b-checkbox v-model="pleaseReply" + @input="pleaseReplyChanged"> + Please email me back{{ pleaseReply ? " at: " : "" }} + </b-checkbox> + </div> + <div class="level-item" v-show="pleaseReply"> + <b-input v-model="userEmail" + ref="userEmail"> + </b-input> + </div> + </div> + </div> + % endif + + </section> + + <footer class="modal-card-foot"> + <o-button @click="showDialog = false"> + Cancel + </o-button> + <o-button variant="primary" + @click="sendFeedback()" + :disabled="sending || !message?.trim()"> + {{ sending ? "Working, please wait..." : "Send Message" }} + </o-button> + </footer> + </div> + </o-modal> + </div> + </script> + <script> + + const FeedbackForm = { + template: '#feedback-form-template', + mixins: [SimpleRequestMixin], + + props: { + action: String, + }, + + data() { + return { + referrer: null, + % if request.user: + userUUID: ${json.dumps(request.user.uuid)|n}, + userName: ${json.dumps(str(request.user))|n}, + % else: + userUUID: null, + userName: null, + % endif + message: null, + pleaseReply: false, + userEmail: null, + showDialog: false, + sending: false, + } + }, + + methods: { + + pleaseReplyChanged(value) { + this.$nextTick(() => { + this.$refs.userEmail.focus() + }) + }, + + showFeedback() { + this.referrer = location.href + this.message = null + this.showDialog = true + this.$nextTick(function() { + this.$refs.message.focus() + }) + }, + + sendFeedback() { + this.sending = true + + const params = { + referrer: this.referrer, + user: this.userUUID, + user_name: this.userName, + please_reply_to: this.pleaseReply ? this.userEmail : '', + message: this.message?.trim(), + } + + this.simplePOST(this.action, params, response => { + + this.$buefy.toast.open({ + message: "Message sent! Thank you for your feedback.", + type: 'is-info', + duration: 4000, // 4 seconds + }) + + this.sending = false + this.showDialog = false + + }, response => { + this.sending = false + }) + }, + } + } + + </script> +</%def> + +<%def name="make_menu_search_component()"> + <% request.register_component('menu-search', 'MenuSearch') %> + <script type="text/x-template" id="menu-search-template"> + <div style="display: flex;"> + + <a v-show="!searchActive" + href="${url('home')}" + class="navbar-item" + style="display: flex; gap: 0.5rem;"> + ${base_meta.header_logo()} + <div id="global-header-title"> + ${base_meta.global_title()} + </div> + </a> + + <div v-show="searchActive" + class="navbar-item"> + <o-autocomplete ref="searchAutocomplete" + v-model="searchTerm" + :data="searchFilteredData" + field="label" + open-on-focus + keep-first + icon-pack="fas" + clearable + @select="searchSelect"> + </o-autocomplete> + </div> + </div> + </script> + <script> + + const MenuSearch = { + template: '#menu-search-template', + + props: { + searchData: Array, + }, + + data() { + return { + searchActive: false, + searchTerm: null, + searchInput: null, + } + }, + + computed: { + + searchFilteredData() { + if (!this.searchTerm || !this.searchTerm.length) { + return this.searchData + } + + let terms = [] + for (let term of this.searchTerm.toLowerCase().split(' ')) { + term = term.trim() + if (term) { + terms.push(term) + } + } + if (!terms.length) { + return this.searchData + } + + // all terms must match + return this.searchData.filter((option) => { + let label = option.label.toLowerCase() + for (let term of terms) { + if (label.indexOf(term) < 0) { + return false + } + } + return true + }) + }, + }, + + mounted() { + this.searchInput = this.$refs.searchAutocomplete.$el.querySelector('input') + this.searchInput.addEventListener('keydown', this.searchKeydown) + }, + + beforeDestroy() { + this.searchInput.removeEventListener('keydown', this.searchKeydown) + }, + + methods: { + + searchInit() { + this.searchTerm = '' + this.searchActive = true + this.$nextTick(() => { + this.$refs.searchAutocomplete.focus() + }) + }, + + searchKeydown(event) { + // ESC will dismiss searchbox + if (event.which == 27) { + this.searchActive = false + } + }, + + searchSelect(option) { + location.href = option.url + }, + }, + } + + </script> +</%def> + +<%def name="render_vue_template_whole_page()"> + <script type="text/x-template" id="whole-page-template"> + <div id="whole-page" style="height: 100%; display: flex; flex-direction: column; justify-content: space-between;"> + + <div class="header-wrapper"> + + <header> + + <!-- this main menu, with search --> + <nav class="navbar" role="navigation" aria-label="main navigation" + style="display: flex; align-items: center;"> + + <div class="navbar-brand"> + <menu-search :search-data="globalSearchData" + ref="menuSearch" /> + <a role="button" class="navbar-burger" data-target="navbarMenu" aria-label="menu" aria-expanded="false"> + <span aria-hidden="true"></span> + <span aria-hidden="true"></span> + <span aria-hidden="true"></span> + <span aria-hidden="true"></span> + </a> + </div> + + <div class="navbar-menu" id="navbarMenu" + style="display: flex; align-items: center;" + > + <div class="navbar-start"> + + ## global search button + <div v-if="globalSearchData.length" + class="navbar-item"> + <o-button variant="primary" + size="small" + @click="globalSearchInit()"> + <o-icon icon="search" size="small" /> + </o-button> + </div> + + ## main menu + % for topitem in menus: + % if topitem['is_link']: + ${h.link_to(topitem['title'], topitem['url'], target=topitem['target'], class_='navbar-item')} + % else: + <div class="navbar-item has-dropdown is-hoverable"> + <a class="navbar-link">${topitem['title']}</a> + <div class="navbar-dropdown"> + % for item in topitem['items']: + % if item['is_menu']: + <% item_hash = id(item) %> + <% toggle = f'menu_{item_hash}_shown' %> + <div> + <a class="navbar-link" @click.prevent="toggleNestedMenu('${item_hash}')"> + ${item['title']} + </a> + </div> + % for subitem in item['items']: + % if subitem['is_sep']: + <hr class="navbar-divider" v-show="${toggle}"> + % else: + ${h.link_to("{}".format(subitem['title']), subitem['url'], class_='navbar-item nested', target=subitem['target'], **{'v-show': toggle})} + % endif + % endfor + % else: + % if item['is_sep']: + <hr class="navbar-divider"> + % else: + ${h.link_to(item['title'], item['url'], class_='navbar-item', target=item['target'])} + % endif + % endif + % endfor + </div> + </div> + % endif + % endfor + + </div><!-- navbar-start --> + ${self.render_navbar_end()} + </div> + </nav> + + <!-- nb. this has index title, help button etc. --> + <nav style="display: flex; justify-content: space-between; align-items: center; padding: 0.5rem;"> + + ## Current Context + <div style="display: flex; gap: 0.5rem; align-items: center;"> + % if master: + % if master.listing: + <h1 class="title"> + ${index_title} + </h1> + % if master.creatable and getattr(master, 'show_create_link', True) and master.has_perm('create'): + <once-button type="is-primary" + tag="a" href="${url('{}.create'.format(route_prefix))}" + icon-left="plus" + style="margin-left: 1rem;" + text="Create New"> + </once-button> + % endif + % elif index_url: + <h1 class="title"> + ${h.link_to(index_title, index_url)} + </h1> + % if parent_url is not Undefined: + <h1 class="title"> + » + </h1> + <h1 class="title"> + ${h.link_to(parent_title, parent_url)} + </h1> + % elif instance_url is not Undefined: + <h1 class="title"> + » + </h1> + <h1 class="title"> + ${h.link_to(instance_title, instance_url)} + </h1> + % elif master.creatable and getattr(master, 'show_create_link', True) and master.has_perm('create'): + % if not request.matched_route.name.endswith('.create'): + <once-button type="is-primary" + tag="a" href="${url('{}.create'.format(route_prefix))}" + icon-left="plus" + style="margin-left: 1rem;" + text="Create New"> + </once-button> + % endif + % endif +## % if master.viewing and grid_index: +## ${grid_index_nav()} +## % endif + % else: + <h1 class="title"> + ${index_title} + </h1> + % endif + % elif index_title: + % if index_url: + <h1 class="title"> + ${h.link_to(index_title, index_url)} + </h1> + % else: + <h1 class="title"> + ${index_title} + </h1> + % endif + % endif + + % if expose_db_picker is not Undefined and expose_db_picker: + <span>DB:</span> + ${h.form(url('change_db_engine'), ref='dbPickerForm')} + ${h.csrf_token(request)} + ${h.hidden('engine_type', value=master.engine_type_key)} + <input type="hidden" name="referrer" :value="referrer" /> + <b-select name="dbkey" + v-model="dbSelected" + @input="changeDB()"> + % for option in db_picker_options: + <option value="${option.value}"> + ${option.label} + </option> + % endfor + </b-select> + ${h.end_form()} + % endif + + </div> + + <div style="display: flex; gap: 0.5rem;"> + + ## Quickie Lookup + % if quickie is not Undefined and quickie and request.has_perm(quickie.perm): + ${h.form(quickie.url, method='get', style='display: flex; gap: 0.5rem; margin-right: 1rem;')} + <b-input name="entry" + placeholder="${quickie.placeholder}" + autocomplete="off"> + </b-input> + <o-button variant="primary" + native-type="submit" + icon-left="search"> + Lookup + </o-button> + ${h.end_form()} + % endif + + % if master and master.configurable and master.has_perm('configure'): + % if not request.matched_route.name.endswith('.configure'): + <once-button type="is-primary" + tag="a" + href="${url('{}.configure'.format(route_prefix))}" + icon-left="cog" + text="${(configure_button_title or "Configure") if configure_button_title is not Undefined else "Configure"}"> + </once-button> + % endif + % endif + + ## Theme Picker + % if expose_theme_picker and request.has_perm('common.change_app_theme'): + ${h.form(url('change_theme'), method="post", ref='themePickerForm')} + ${h.csrf_token(request)} + <input type="hidden" name="referrer" :value="referrer" /> + <div style="display: flex; align-items: center; gap: 0.5rem;"> + <span>Theme:</span> + <b-select name="theme" + v-model="globalTheme" + @input="changeTheme()"> + % for option in theme_picker_options: + <option value="${option.value}"> + ${option.label} + </option> + % endfor + </b-select> + </div> + ${h.end_form()} + % endif + + % if help_url or help_markdown or can_edit_help: + <page-help + % if can_edit_help: + @configure-fields-help="configureFieldsHelp = true" + % endif + > + </page-help> + % endif + + ## Feedback Button / Dialog + % if request.has_perm('common.feedback'): + <feedback-form action="${url('feedback')}" /> + % endif + </div> + </nav> + </header> + + ## Page Title + % if capture(self.content_title): + <section class="has-background-primary" + ## TODO: id is only for css, do we need it? + id="content-title" + style="padding: 0.5rem; padding-left: 1rem;"> + <div style="display: flex; align-items: center; gap: 1rem;"> + + <h1 class="title has-text-white" v-html="contentTitleHTML" /> + + <div style="flex-grow: 1; display: flex; gap: 0.5rem;"> + ${self.render_instance_header_title_extras()} + </div> + + <div style="display: flex; gap: 0.5rem;"> + ${self.render_instance_header_buttons()} + </div> + + </div> + </section> + % endif + + </div> <!-- header-wrapper --> + + <div class="content-wrapper" + style="flex-grow: 1; padding: 0.5rem;"> + + ## Page Body + <section id="page-body"> + + % if request.session.peek_flash('error'): + % for error in request.session.pop_flash('error'): + <b-notification type="is-warning"> + ${error} + </b-notification> + % endfor + % endif + + % if request.session.peek_flash('warning'): + % for msg in request.session.pop_flash('warning'): + <b-notification type="is-warning"> + ${msg} + </b-notification> + % endfor + % endif + + % if request.session.peek_flash(): + % for msg in request.session.pop_flash(): + <b-notification type="is-info"> + ${msg} + </b-notification> + % endfor + % endif + + ## true page content + <div> + ${self.render_this_page_component()} + </div> + </section> + </div><!-- content-wrapper --> + + ## Footer + <footer class="footer"> + <div class="content"> + ${base_meta.footer()} + </div> + </footer> + </div> + </script> +</%def> + +<%def name="render_this_page_component()"> + <this-page @change-content-title="changeContentTitle" + % if can_edit_help: + :configure-fields-help="configureFieldsHelp" + % endif + > + </this-page> +</%def> + +<%def name="render_navbar_end()"> + <div class="navbar-end"> + ${self.render_user_menu()} + </div> +</%def> + +<%def name="render_user_menu()"> + % if request.user: + <div class="navbar-item has-dropdown is-hoverable"> + % if messaging_enabled: + <a class="navbar-link ${'has-background-danger has-text-white' if request.is_root else ''}">${request.user}${" ({})".format(inbox_count) if inbox_count else ''}</a> + % else: + <a class="navbar-link ${'has-background-danger has-text-white' if request.is_root else ''}">${request.user}</a> + % endif + <div class="navbar-dropdown"> + % if request.is_root: + ${h.form(url('stop_root'), ref='stopBeingRootForm')} + ${h.csrf_token(request)} + <input type="hidden" name="referrer" value="${request.current_route_url()}" /> + <a @click="$refs.stopBeingRootForm.submit()" + class="navbar-item has-background-danger has-text-white"> + Stop being root + </a> + ${h.end_form()} + % elif request.is_admin: + ${h.form(url('become_root'), ref='startBeingRootForm')} + ${h.csrf_token(request)} + <input type="hidden" name="referrer" value="${request.current_route_url()}" /> + <a @click="$refs.startBeingRootForm.submit()" + class="navbar-item has-background-danger has-text-white"> + Become root + </a> + ${h.end_form()} + % endif + % if messaging_enabled: + ${h.link_to("Messages{}".format(" ({})".format(inbox_count) if inbox_count else ''), url('messages.inbox'), class_='navbar-item')} + % endif + % if request.is_root or not request.user.prevent_password_change: + ${h.link_to("Change Password", url('change_password'), class_='navbar-item')} + % endif + ${h.link_to("Edit Preferences", url('my.preferences'), class_='navbar-item')} + ${h.link_to("Logout", url('logout'), class_='navbar-item')} + </div> + </div> + % else: + ${h.link_to("Login", url('login'), class_='navbar-item')} + % endif +</%def> + +<%def name="render_instance_header_title_extras()"></%def> + +<%def name="render_instance_header_buttons()"> + ${self.render_crud_header_buttons()} + ${self.render_prevnext_header_buttons()} +</%def> + +<%def name="render_crud_header_buttons()"> +% if master and master.viewing and not getattr(master, 'cloning', False): + ## TODO: is there a better way to check if viewing parent? + % if parent_instance is Undefined: + % if master.editable and instance_editable and master.has_perm('edit'): + <once-button tag="a" href="${master.get_action_url('edit', instance)}" + icon-left="edit" + text="Edit This"> + </once-button> + % endif + % if not getattr(master, 'cloning', False) and getattr(master, 'cloneable', False) and master.has_perm('clone'): + <once-button tag="a" href="${master.get_action_url('clone', instance)}" + icon-left="object-ungroup" + text="Clone This"> + </once-button> + % endif + % if master.deletable and instance_deletable and master.has_perm('delete'): + <once-button tag="a" href="${master.get_action_url('delete', instance)}" + type="is-danger" + icon-left="trash" + text="Delete This"> + </once-button> + % endif + % else: + ## viewing row + % if instance_deletable and master.has_perm('delete_row'): + <once-button tag="a" href="${master.get_action_url('delete', instance)}" + type="is-danger" + icon-left="trash" + text="Delete This"> + </once-button> + % endif + % endif + % elif master and master.editing: + % if master.viewable and master.has_perm('view'): + <once-button tag="a" href="${master.get_action_url('view', instance)}" + icon-left="eye" + text="View This"> + </once-button> + % endif + % if master.deletable and instance_deletable and master.has_perm('delete'): + <once-button tag="a" href="${master.get_action_url('delete', instance)}" + type="is-danger" + icon-left="trash" + text="Delete This"> + </once-button> + % endif + % elif master and master.deleting: + % if master.viewable and master.has_perm('view'): + <once-button tag="a" href="${master.get_action_url('view', instance)}" + icon-left="eye" + text="View This"> + </once-button> + % endif + % if master.editable and instance_editable and master.has_perm('edit'): + <once-button tag="a" href="${master.get_action_url('edit', instance)}" + icon-left="edit" + text="Edit This"> + </once-button> + % endif + % elif master and getattr(master, 'cloning', False): + % if master.viewable and master.has_perm('view'): + <once-button tag="a" href="${master.get_action_url('view', instance)}" + icon-left="eye" + text="View This"> + </once-button> + % endif + % endif +</%def> + +<%def name="render_prevnext_header_buttons()"> + % if show_prev_next is not Undefined and show_prev_next: + % if prev_url: + <b-button tag="a" href="${prev_url}" + icon-pack="fas" + icon-left="arrow-left"> + Older + </b-button> + % else: + <b-button tag="a" href="#" + disabled + icon-pack="fas" + icon-left="arrow-left"> + Older + </b-button> + % endif + % if next_url: + <b-button tag="a" href="${next_url}" + icon-pack="fas" + icon-left="arrow-right"> + Newer + </b-button> + % else: + <b-button tag="a" href="#" + disabled + icon-pack="fas" + icon-left="arrow-right"> + Newer + </b-button> + % endif + % endif +</%def> + +<%def name="render_vue_script_whole_page()"> + <script> + + const WholePage = { + template: '#whole-page-template', + mixins: [SimpleRequestMixin], + computed: {}, + + mounted() { + window.addEventListener('keydown', this.globalKey) + for (let hook of this.mountedHooks) { + hook(this) + } + }, + beforeDestroy() { + window.removeEventListener('keydown', this.globalKey) + }, + + methods: { + + changeContentTitle(newTitle) { + this.contentTitleHTML = newTitle + }, + + % if expose_db_picker is not Undefined and expose_db_picker: + changeDB() { + this.$refs.dbPickerForm.submit() + }, + % endif + + % if expose_theme_picker and request.has_perm('common.change_app_theme'): + changeTheme() { + this.$refs.themePickerForm.submit() + }, + % endif + + globalKey(event) { + + // Ctrl+8 opens global search + if (event.target.tagName == 'BODY') { + if (event.ctrlKey && event.key == '8') { + this.globalSearchInit() + } + } + }, + + globalSearchInit() { + this.$refs.menuSearch.searchInit() + }, + + toggleNestedMenu(hash) { + const key = 'menu_' + hash + '_shown' + this[key] = !this[key] + }, + }, + } + + const WholePageData = { + contentTitleHTML: ${json.dumps(capture(self.content_title))|n}, + globalSearchData: ${json.dumps(global_search_data)|n}, + mountedHooks: [], + + % if expose_db_picker is not Undefined and expose_db_picker: + dbSelected: ${json.dumps(db_picker_selected)|n}, + % endif + + % if expose_theme_picker and request.has_perm('common.change_app_theme'): + globalTheme: ${json.dumps(theme)|n}, + referrer: location.href, + % endif + + % if can_edit_help: + configureFieldsHelp: false, + % endif + } + + ## declare nested menu visibility toggle flags + % for topitem in menus: + % if topitem['is_menu']: + % for item in topitem['items']: + % if item['is_menu']: + WholePageData.menu_${id(item)}_shown = false + % endif + % endfor + % endif + % endfor + + </script> +</%def> + +############################## +## vue components + app +############################## + +<%def name="render_vue_templates()"> +## ${multi_file_upload.render_template()} +## ${multi_file_upload.declare_vars()} + + ## global components used by various (but not all) pages + ${make_field_components()} + ${make_grid_filter_components()} + + ## global components for buefy-based template compatibility + ${make_http_plugin()} + ${make_buefy_plugin()} + ${make_buefy_components()} + + ## special global components, used by WholePage + ${self.make_menu_search_component()} + ${page_help.render_template()} + ${page_help.declare_vars()} + % if request.has_perm('common.feedback'): + ${self.make_feedback_component()} + % endif + + ## DEPRECATED; called for back-compat + ${self.render_whole_page_template()} + + ## DEPRECATED; called for back-compat + ${self.declare_whole_page_vars()} +</%def> + +## DEPRECATED; remains for back-compat +<%def name="render_whole_page_template()"> + ${self.render_vue_template_whole_page()} + ${self.render_vue_script_whole_page()} +</%def> + +<%def name="modify_vue_vars()"> + ## DEPRECATED; called for back-compat + ${self.modify_whole_page_vars()} +</%def> + +<%def name="make_vue_components()"> + ${page_help.make_component()} + ## ${multi_file_upload.make_component()} + + ## DEPRECATED; called for back-compat (?) + ${self.make_whole_page_component()} +</%def> + +## DEPRECATED; remains for back-compat +<%def name="make_whole_page_component()"> + <script> + WholePage.data = () => { return WholePageData } + </script> + <% request.register_component('whole-page', 'WholePage') %> +</%def> + +<%def name="make_vue_app()"> + ## DEPRECATED; called for back-compat + ${self.make_whole_page_app()} +</%def> + +## DEPRECATED; remains for back-compat +<%def name="make_whole_page_app()"> + <script type="module"> + import {createApp} from 'vue' + import {Oruga} from '@oruga-ui/oruga-next' + import {bulmaConfig} from '@oruga-ui/theme-bulma' + import { library } from "@fortawesome/fontawesome-svg-core" + import { fas } from "@fortawesome/free-solid-svg-icons" + import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome" + library.add(fas) + + const app = createApp() + app.component('vue-fontawesome', FontAwesomeIcon) + + % if hasattr(request, '_tailbone_registered_components'): + % for tagname, classname in request._tailbone_registered_components.items(): + app.component('${tagname}', ${classname}) + % endfor + % endif + + app.use(Oruga, { + ...bulmaConfig, + iconComponent: 'vue-fontawesome', + iconPack: 'fas', + }) + + app.use(HttpPlugin) + app.use(BuefyPlugin) + + app.mount('#app') + </script> +</%def> + +############################## +## DEPRECATED +############################## + +<%def name="declare_whole_page_vars()"></%def> + +<%def name="modify_whole_page_vars()"></%def> diff --git a/tailbone/templates/themes/butterball/buefy-components.mako b/tailbone/templates/themes/butterball/buefy-components.mako new file mode 100644 index 00000000..3a2cd798 --- /dev/null +++ b/tailbone/templates/themes/butterball/buefy-components.mako @@ -0,0 +1,759 @@ + +<%def name="make_buefy_components()"> + ${self.make_b_autocomplete_component()} + ${self.make_b_button_component()} + ${self.make_b_checkbox_component()} + ${self.make_b_collapse_component()} + ${self.make_b_datepicker_component()} + ${self.make_b_dropdown_component()} + ${self.make_b_dropdown_item_component()} + ${self.make_b_field_component()} + ${self.make_b_icon_component()} + ${self.make_b_input_component()} + ${self.make_b_loading_component()} + ${self.make_b_modal_component()} + ${self.make_b_notification_component()} + ${self.make_b_radio_component()} + ${self.make_b_select_component()} + ${self.make_b_steps_component()} + ${self.make_b_step_item_component()} + ${self.make_b_table_component()} + ${self.make_b_table_column_component()} + ${self.make_b_tooltip_component()} + ${self.make_once_button_component()} +</%def> + +<%def name="make_b_autocomplete_component()"> + <script type="text/x-template" id="b-autocomplete-template"> + <o-autocomplete v-model="orugaValue" + :data="data" + :field="field" + :open-on-focus="openOnFocus" + :keep-first="keepFirst" + :clearable="clearable" + :clear-on-select="clearOnSelect" + :formatter="customFormatter" + :placeholder="placeholder" + @update:model-value="orugaValueUpdated" + ref="autocomplete"> + </o-autocomplete> + </script> + <script> + const BAutocomplete = { + template: '#b-autocomplete-template', + props: { + modelValue: String, + data: Array, + field: String, + openOnFocus: Boolean, + keepFirst: Boolean, + clearable: Boolean, + clearOnSelect: Boolean, + customFormatter: null, + placeholder: String, + }, + data() { + return { + orugaValue: this.modelValue, + } + }, + watch: { + modelValue(to, from) { + if (this.orugaValue != to) { + this.orugaValue = to + } + }, + }, + methods: { + focus() { + const input = this.$refs.autocomplete.$el.querySelector('input') + input.focus() + }, + orugaValueUpdated(value) { + this.$emit('update:modelValue', value) + }, + }, + } + </script> + <% request.register_component('b-autocomplete', 'BAutocomplete') %> +</%def> + +<%def name="make_b_button_component()"> + <script type="text/x-template" id="b-button-template"> + <o-button :variant="variant" + :size="orugaSize" + :native-type="nativeType" + :tag="tag" + :href="href" + :icon-left="iconLeft"> + <slot /> + </o-button> + </script> + <script> + const BButton = { + template: '#b-button-template', + props: { + type: String, + nativeType: String, + tag: String, + href: String, + size: String, + iconPack: String, // ignored + iconLeft: String, + }, + computed: { + orugaSize() { + if (this.size) { + return this.size.replace(/^is-/, '') + } + }, + variant() { + if (this.type) { + return this.type.replace(/^is-/, '') + } + }, + }, + } + </script> + <% request.register_component('b-button', 'BButton') %> +</%def> + +<%def name="make_b_checkbox_component()"> + <script type="text/x-template" id="b-checkbox-template"> + <o-checkbox v-model="orugaValue" + @update:model-value="orugaValueUpdated" + :name="name" + :native-value="nativeValue"> + <slot /> + </o-checkbox> + </script> + <script> + const BCheckbox = { + template: '#b-checkbox-template', + props: { + modelValue: null, + name: String, + nativeValue: null, + }, + data() { + return { + orugaValue: this.modelValue, + } + }, + watch: { + modelValue(to, from) { + this.orugaValue = to + }, + }, + methods: { + orugaValueUpdated(value) { + this.$emit('update:modelValue', value) + }, + }, + } + </script> + <% request.register_component('b-checkbox', 'BCheckbox') %> +</%def> + +<%def name="make_b_collapse_component()"> + <script type="text/x-template" id="b-collapse-template"> + <o-collapse :open="open"> + <slot name="trigger" /> + <slot /> + </o-collapse> + </script> + <script> + const BCollapse = { + template: '#b-collapse-template', + props: { + open: Boolean, + }, + } + </script> + <% request.register_component('b-collapse', 'BCollapse') %> +</%def> + +<%def name="make_b_datepicker_component()"> + <script type="text/x-template" id="b-datepicker-template"> + <o-datepicker :name="name" + v-model="orugaValue" + @update:model-value="orugaValueUpdated" + :value="value" + :placeholder="placeholder" + :date-formatter="dateFormatter" + :date-parser="dateParser" + :disabled="disabled" + :editable="editable" + :icon="icon" + :close-on-click="false"> + </o-datepicker> + </script> + <script> + const BDatepicker = { + template: '#b-datepicker-template', + props: { + dateFormatter: null, + dateParser: null, + disabled: Boolean, + editable: Boolean, + icon: String, + // iconPack: String, // ignored + modelValue: Date, + name: String, + placeholder: String, + value: null, + }, + data() { + return { + orugaValue: this.modelValue, + } + }, + watch: { + modelValue(to, from) { + if (this.orugaValue != to) { + this.orugaValue = to + } + }, + }, + methods: { + orugaValueUpdated(value) { + if (this.modelValue != value) { + this.$emit('update:modelValue', value) + } + }, + }, + } + </script> + <% request.register_component('b-datepicker', 'BDatepicker') %> +</%def> + +<%def name="make_b_dropdown_component()"> + <script type="text/x-template" id="b-dropdown-template"> + <o-dropdown :position="buefyPosition" + :triggers="triggers"> + <slot name="trigger" /> + <slot /> + </o-dropdown> + </script> + <script> + const BDropdown = { + template: '#b-dropdown-template', + props: { + position: String, + triggers: Array, + }, + computed: { + buefyPosition() { + if (this.position) { + return this.position.replace(/^is-/, '') + } + }, + }, + } + </script> + <% request.register_component('b-dropdown', 'BDropdown') %> +</%def> + +<%def name="make_b_dropdown_item_component()"> + <script type="text/x-template" id="b-dropdown-item-template"> + <o-dropdown-item :label="label"> + <slot /> + </o-dropdown-item> + </script> + <script> + const BDropdownItem = { + template: '#b-dropdown-item-template', + props: { + label: String, + }, + } + </script> + <% request.register_component('b-dropdown-item', 'BDropdownItem') %> +</%def> + +<%def name="make_b_field_component()"> + <script type="text/x-template" id="b-field-template"> + <o-field :grouped="grouped" + :label="label" + :horizontal="horizontal" + :expanded="expanded" + :variant="variant"> + <slot /> + </o-field> + </script> + <script> + const BField = { + template: '#b-field-template', + props: { + expanded: Boolean, + grouped: Boolean, + horizontal: Boolean, + label: String, + type: String, + }, + computed: { + variant() { + if (this.type) { + return this.type.replace(/^is-/, '') + } + }, + }, + } + </script> + <% request.register_component('b-field', 'BField') %> +</%def> + +<%def name="make_b_icon_component()"> + <script type="text/x-template" id="b-icon-template"> + <o-icon :icon="icon" + :size="orugaSize" /> + </script> + <script> + const BIcon = { + template: '#b-icon-template', + props: { + icon: String, + size: String, + }, + computed: { + orugaSize() { + if (this.size) { + return this.size.replace(/^is-/, '') + } + }, + }, + } + </script> + <% request.register_component('b-icon', 'BIcon') %> +</%def> + +<%def name="make_b_input_component()"> + <script type="text/x-template" id="b-input-template"> + <o-input :type="type" + :disabled="disabled" + v-model="orugaValue" + @update:modelValue="val => $emit('update:modelValue', val)" + :autocomplete="autocomplete" + ref="input" + :expanded="expanded"> + <slot /> + </o-input> + </script> + <script> + const BInput = { + template: '#b-input-template', + props: { + modelValue: null, + type: String, + autocomplete: String, + disabled: Boolean, + expanded: Boolean, + }, + data() { + return { + orugaValue: this.modelValue + } + }, + watch: { + modelValue(to, from) { + if (this.orugaValue != to) { + this.orugaValue = to + } + }, + }, + methods: { + focus() { + if (this.type == 'textarea') { + // TODO: this does not always work right? + this.$refs.input.$el.querySelector('textarea').focus() + } else { + // TODO: pretty sure we can rely on the <o-input> focus() + // here, but not sure why we weren't already doing that? + //this.$refs.input.$el.querySelector('input').focus() + this.$refs.input.focus() + } + }, + }, + } + </script> + <% request.register_component('b-input', 'BInput') %> +</%def> + +<%def name="make_b_loading_component()"> + <script type="text/x-template" id="b-loading-template"> + <o-loading :full-page="isFullPage"> + <slot /> + </o-loading> + </script> + <script> + const BLoading = { + template: '#b-loading-template', + props: { + isFullPage: Boolean, + }, + } + </script> + <% request.register_component('b-loading', 'BLoading') %> +</%def> + +<%def name="make_b_modal_component()"> + <script type="text/x-template" id="b-modal-template"> + <o-modal v-model:active="trueActive" + @update:active="activeChanged"> + <slot /> + </o-modal> + </script> + <script> + const BModal = { + template: '#b-modal-template', + props: { + active: Boolean, + hasModalCard: Boolean, // nb. this is ignored + }, + data() { + return { + trueActive: this.active, + } + }, + watch: { + active(to, from) { + this.trueActive = to + }, + trueActive(to, from) { + if (this.active != to) { + this.tellParent(to) + } + }, + }, + methods: { + + tellParent(active) { + // TODO: this does not work properly + this.$emit('update:active', active) + }, + + activeChanged(active) { + this.tellParent(active) + }, + }, + } + </script> + <% request.register_component('b-modal', 'BModal') %> +</%def> + +<%def name="make_b_notification_component()"> + <script type="text/x-template" id="b-notification-template"> + <o-notification :variant="variant" + :closable="closable"> + <slot /> + </o-notification> + </script> + <script> + const BNotification = { + template: '#b-notification-template', + props: { + type: String, + closable: { + type: Boolean, + default: true, + }, + }, + computed: { + variant() { + if (this.type) { + return this.type.replace(/^is-/, '') + } + }, + }, + } + </script> + <% request.register_component('b-notification', 'BNotification') %> +</%def> + +<%def name="make_b_radio_component()"> + <script type="text/x-template" id="b-radio-template"> + <o-radio v-model="orugaValue" + @update:model-value="orugaValueUpdated" + :native-value="nativeValue"> + <slot /> + </o-radio> + </script> + <script> + const BRadio = { + template: '#b-radio-template', + props: { + modelValue: null, + nativeValue: null, + }, + data() { + return { + orugaValue: this.modelValue, + } + }, + watch: { + modelValue(to, from) { + this.orugaValue = to + }, + }, + methods: { + orugaValueUpdated(value) { + this.$emit('update:modelValue', value) + }, + }, + } + </script> + <% request.register_component('b-radio', 'BRadio') %> +</%def> + +<%def name="make_b_select_component()"> + <script type="text/x-template" id="b-select-template"> + <o-select :name="name" + ref="select" + v-model="orugaValue" + @update:model-value="orugaValueUpdated" + :expanded="expanded" + :multiple="multiple" + :size="orugaSize" + :native-size="nativeSize"> + <slot /> + </o-select> + </script> + <script> + const BSelect = { + template: '#b-select-template', + props: { + expanded: Boolean, + modelValue: null, + multiple: Boolean, + name: String, + nativeSize: null, + size: null, + }, + data() { + return { + orugaValue: this.modelValue, + } + }, + watch: { + modelValue(to, from) { + this.orugaValue = to + }, + }, + computed: { + orugaSize() { + if (this.size) { + return this.size.replace(/^is-/, '') + } + }, + }, + methods: { + focus() { + this.$refs.select.focus() + }, + orugaValueUpdated(value) { + this.$emit('update:modelValue', value) + this.$emit('input', value) + }, + }, + } + </script> + <% request.register_component('b-select', 'BSelect') %> +</%def> + +<%def name="make_b_steps_component()"> + <script type="text/x-template" id="b-steps-template"> + <o-steps v-model="orugaValue" + @update:model-value="orugaValueUpdated" + :animated="animated" + :rounded="rounded" + :has-navigation="hasNavigation" + :vertical="vertical"> + <slot /> + </o-steps> + </script> + <script> + const BSteps = { + template: '#b-steps-template', + props: { + modelValue: null, + animated: Boolean, + rounded: Boolean, + hasNavigation: Boolean, + vertical: Boolean, + }, + data() { + return { + orugaValue: this.modelValue, + } + }, + watch: { + modelValue(to, from) { + this.orugaValue = to + }, + }, + methods: { + orugaValueUpdated(value) { + this.$emit('update:modelValue', value) + this.$emit('input', value) + }, + }, + } + </script> + <% request.register_component('b-steps', 'BSteps') %> +</%def> + +<%def name="make_b_step_item_component()"> + <script type="text/x-template" id="b-step-item-template"> + <o-step-item :step="step" + :value="value" + :label="label" + :clickable="clickable"> + <slot /> + </o-step-item> + </script> + <script> + const BStepItem = { + template: '#b-step-item-template', + props: { + step: null, + value: null, + label: String, + clickable: Boolean, + }, + } + </script> + <% request.register_component('b-step-item', 'BStepItem') %> +</%def> + +<%def name="make_b_table_component()"> + <script type="text/x-template" id="b-table-template"> + <o-table :data="data"> + <slot /> + </o-table> + </script> + <script> + const BTable = { + template: '#b-table-template', + props: { + data: Array, + }, + } + </script> + <% request.register_component('b-table', 'BTable') %> +</%def> + +<%def name="make_b_table_column_component()"> + <script type="text/x-template" id="b-table-column-template"> + <o-table-column :field="field" + :label="label" + v-slot="props"> + ## TODO: this does not seem to really work for us... + <slot :props="props" /> + </o-table-column> + </script> + <script> + const BTableColumn = { + template: '#b-table-column-template', + props: { + field: String, + label: String, + }, + } + </script> + <% request.register_component('b-table-column', 'BTableColumn') %> +</%def> + +<%def name="make_b_tooltip_component()"> + <script type="text/x-template" id="b-tooltip-template"> + <o-tooltip :label="label" + :position="orugaPosition" + :multiline="multilined"> + <slot /> + </o-tooltip> + </script> + <script> + const BTooltip = { + template: '#b-tooltip-template', + props: { + label: String, + multilined: Boolean, + position: String, + }, + computed: { + orugaPosition() { + if (this.position) { + return this.position.replace(/^is-/, '') + } + }, + }, + } + </script> + <% request.register_component('b-tooltip', 'BTooltip') %> +</%def> + +<%def name="make_once_button_component()"> + <script type="text/x-template" id="once-button-template"> + <b-button :type="type" + :native-type="nativeType" + :tag="tag" + :href="href" + :title="title" + :disabled="buttonDisabled" + @click="clicked" + icon-pack="fas" + :icon-left="iconLeft"> + {{ buttonText }} + </b-button> + </script> + <script> + const OnceButton = { + template: '#once-button-template', + props: { + type: String, + nativeType: String, + tag: String, + href: String, + text: String, + title: String, + iconLeft: String, + working: String, + workingText: String, + disabled: Boolean, + }, + data() { + return { + currentText: null, + currentDisabled: null, + } + }, + computed: { + buttonText: function() { + return this.currentText || this.text + }, + buttonDisabled: function() { + if (this.currentDisabled !== null) { + return this.currentDisabled + } + return this.disabled + }, + }, + methods: { + + clicked(event) { + this.currentDisabled = true + if (this.workingText) { + this.currentText = this.workingText + } else if (this.working) { + this.currentText = this.working + ", please wait..." + } else { + this.currentText = "Working, please wait..." + } + // this.$nextTick(function() { + // this.$emit('click', event) + // }) + } + }, + } + </script> + <% request.register_component('once-button', 'OnceButton') %> +</%def> diff --git a/tailbone/templates/themes/butterball/buefy-plugin.mako b/tailbone/templates/themes/butterball/buefy-plugin.mako new file mode 100644 index 00000000..4cbedfea --- /dev/null +++ b/tailbone/templates/themes/butterball/buefy-plugin.mako @@ -0,0 +1,32 @@ + +<%def name="make_buefy_plugin()"> + <script> + + const BuefyPlugin = { + install(app, options) { + app.config.globalProperties.$buefy = { + + toast: { + open(options) { + + let variant = null + if (options.type) { + variant = options.type.replace(/^is-/, '') + } + + const opts = { + duration: options.duration, + message: options.message, + position: 'top', + variant, + } + + const oruga = app.config.globalProperties.$oruga + oruga.notification.open(opts) + }, + }, + } + }, + } + </script> +</%def> diff --git a/tailbone/templates/themes/butterball/field-components.mako b/tailbone/templates/themes/butterball/field-components.mako new file mode 100644 index 00000000..917083c4 --- /dev/null +++ b/tailbone/templates/themes/butterball/field-components.mako @@ -0,0 +1,542 @@ +## -*- coding: utf-8; -*- + +<%def name="make_field_components()"> + ${self.make_numeric_input_component()} + ${self.make_tailbone_autocomplete_component()} + ${self.make_tailbone_datepicker_component()} + ${self.make_tailbone_timepicker_component()} +</%def> + +<%def name="make_numeric_input_component()"> + <% request.register_component('numeric-input', 'NumericInput') %> + ${h.javascript_link(request.static_url('tailbone:static/js/numeric.js') + f'?ver={tailbone.__version__}')} + <script type="text/x-template" id="numeric-input-template"> + <o-input v-model="orugaValue" + @update:model-value="orugaValueUpdated" + ref="input" + :disabled="disabled" + :icon="icon" + :name="name" + :placeholder="placeholder" + :size="size" + /> + </script> + <script> + + const NumericInput = { + template: '#numeric-input-template', + + props: { + modelValue: [Number, String], + allowEnter: Boolean, + disabled: Boolean, + icon: String, + iconPack: String, // ignored + name: String, + placeholder: String, + size: String, + }, + + data() { + return { + orugaValue: this.modelValue, + inputElement: null, + } + }, + + watch: { + modelValue(to, from) { + this.orugaValue = to + }, + }, + + mounted() { + this.inputElement = this.$refs.input.$el.querySelector('input') + this.inputElement.addEventListener('keydown', this.keyDown) + }, + + beforeDestroy() { + this.inputElement.removeEventListener('keydown', this.keyDown) + }, + + methods: { + + focus() { + this.$refs.input.focus() + }, + + keyDown(event) { + // by default we only allow numeric keys, and general navigation + // keys, but we might also allow Enter key + if (!key_modifies(event) && !key_allowed(event)) { + if (!this.allowEnter || event.which != 13) { + event.preventDefault() + } + } + }, + + orugaValueUpdated(value) { + this.$emit('update:modelValue', value) + this.$emit('input', value) + }, + + select() { + this.$el.children[0].select() + }, + }, + } + + </script> +</%def> + +<%def name="make_tailbone_autocomplete_component()"> + <% request.register_component('tailbone-autocomplete', 'TailboneAutocomplete') %> + <script type="text/x-template" id="tailbone-autocomplete-template"> + <div> + + <o-button v-if="modelValue" + style="width: 100%; justify-content: left;" + @click="clearSelection(true)" + expanded> + {{ internalLabel }} (click to change) + </o-button> + + <o-autocomplete ref="autocompletex" + v-show="!modelValue" + v-model="orugaValue" + :placeholder="placeholder" + :data="filteredData" + :field="field" + :formatter="customFormatter" + @input="inputChanged" + @select="optionSelected" + keep-first + open-on-focus + :expanded="expanded" + :clearable="clearable" + :clear-on-select="clearOnSelect"> + <template #default="{ option }"> + {{ option.label }} + </template> + </o-autocomplete> + + <input type="hidden" :name="name" :value="modelValue" /> + </div> + </script> + <script> + + const TailboneAutocomplete = { + template: '#tailbone-autocomplete-template', + + props: { + + // this is the "input" field name essentially. primarily + // is useful for "traditional" tailbone forms; it normally + // is not used otherwise. it is passed as-is to the oruga + // autocomplete component `name` prop + name: String, + + // static data set; used when serviceUrl is not provided + data: Array, + + // the url from which search results are to be obtained. the + // url should expect a GET request with a query string with a + // single `term` parameter, and return results as a JSON array + // containing objects with `value` and `label` properties. + serviceUrl: String, + + // callers do not specify this directly but rather by way of + // the `v-model` directive. this component will emit `input` + // events when the value changes + modelValue: String, + + // callers may set an initial label if needed. this is useful + // in cases where the autocomplete needs to "already have a + // value" on page load. for instance when a user fills out + // the autocomplete field, but leaves other required fields + // blank and submits the form; page will re-load showing + // errors but the autocomplete field should remain "set" - + // normally it is only given a "value" (e.g. uuid) but this + // allows for the "label" to display correctly as well + initialLabel: String, + + // while the `initialLabel` above is useful for setting the + // *initial* label (of course), it cannot be used to + // arbitrarily update the label during the component's life. + // if you do need to *update* the label after initial page + // load, then you should set `assignedLabel` instead. one + // place this happens is in /custorders/create page, where + // product autocomplete shows some results, and user clicks + // one, but then handler logic can forcibly "swap" the + // selection, causing *different* product data to come back + // from the server, and autocomplete label should be updated + // to match. this feels a bit awkward still but does work.. + assignedLabel: String, + + // simple placeholder text for the input box + placeholder: String, + + // these are passed as-is to <o-autocomplete> + clearable: Boolean, + clearOnSelect: Boolean, + customFormatter: null, + expanded: Boolean, + field: String, + }, + + data() { + + const internalLabel = this.assignedLabel || this.initialLabel + + // we want to track the "currently selected option" - which + // should normally be `null` to begin with, unless we were + // given a value, in which case we use `initialLabel` to + // complete the option + let selected = null + if (this.modelValue) { + selected = { + value: this.modelValue, + label: internalLabel, + } + } + + return { + + // this contains the search results; its contents may + // change over time as new searches happen. the + // "currently selected option" should be one of these, + // unless it is null + fetchedData: [], + + // this tracks our "currently selected option" - per above + selected, + + // since we are wrapping a component which also makes + // use of the "value" paradigm, we must separate the + // concerns. so we use our own `modelValue` prop to + // interact with the caller, but then we use this + // `orugaValue` data point to communicate with the + // oruga autocomplete component. note that + // `this.modelValue` will always be either a uuid or + // null, whereas `this.orugaValue` may be raw text as + // entered by the user. + // orugaValue: this.modelValue, + orugaValue: null, + + // this stores the "internal" label for the button + internalLabel, + } + }, + + computed: { + + filteredData() { + + // do not filter if data comes from backend + if (this.serviceUrl) { + return this.fetchedData + } + + if (!this.orugaValue || !this.orugaValue.length) { + return this.data + } + + const terms = [] + for (let term of this.orugaValue.toLowerCase().split(' ')) { + term = term.trim() + if (term) { + terms.push(term) + } + } + if (!terms.length) { + return this.data + } + + // all terms must match + return this.data.filter((option) => { + const label = option.label.toLowerCase() + for (const term of terms) { + if (label.indexOf(term) < 0) { + return false + } + } + return true + }) + }, + }, + + watch: { + + assignedLabel(to, from) { + // update button label when caller changes it + this.internalLabel = to + }, + }, + + methods: { + + inputChanged(entry) { + if (this.serviceUrl) { + this.getAsyncData(entry) + } + }, + + // fetch new search results from the server. this is + // invoked via the `@input` event from oruga autocomplete + // component. + getAsyncData(entry) { + + // since the `@input` event from oruga component does + // not "self-regulate" in any way (?), we skip the + // search unless we have at least 3 characters of + // input from user + if (entry.length < 3) { + this.fetchedData = [] + return + } + + // and perform the search + this.$http.get(this.serviceUrl + '?term=' + encodeURIComponent(entry)) + .then(({ data }) => { + this.fetchedData = data + }) + .catch((error) => { + this.fetchedData = [] + throw error + }) + }, + + // this method is invoked via the `@select` event of the + // oruga autocomplete component. the `option` received + // will be one of: + // - object with (at least) `value` and `label` keys + // - simple string (e.g. when data set is static) + // - null + optionSelected(option) { + + this.selected = option + this.internalLabel = option?.label || option + + // reset the internal value for oruga autocomplete + // component. note that this value will normally hold + // either the raw text entered by the user, or a uuid. + // we will not be needing either of those b/c they are + // not visible to user once selection is made, and if + // the selection is cleared we want user to start over + // anyway + this.orugaValue = null + this.fetchedData = [] + + // here is where we alert callers to the new value + if (option) { + this.$emit('newLabel', option.label) + } + const value = option?.[this.field || 'value'] || option + this.$emit('update:modelValue', value) + // this.$emit('select', option) + // this.$emit('input', value) + }, + +## // set selection to the given option, which should a simple +## // object with (at least) `value` and `label` properties +## setSelection(option) { +## this.$refs.autocomplete.setSelected(option) +## }, + + // clear the field of any value, i.e. set the "currently + // selected option" to null. this is invoked when you click + // the button, which is visible while the field has a value. + // but callers can invoke it directly as well. + clearSelection(focus) { + + this.$emit('update:modelValue', null) + this.$emit('input', null) + this.$emit('newLabel', null) + this.internalLabel = null + this.selected = null + this.orugaValue = null + +## // clear selection for the oruga autocomplete component +## this.$refs.autocomplete.setSelected(null) + + // maybe set focus to our (autocomplete) component + if (focus) { + this.$nextTick(function() { + this.focus() + }) + } + }, + + // nb. this used to be relevant but now is here only for sake + // of backward-compatibility (for callers) + getDisplayText() { + return this.internalLabel + }, + + // set focus to this component, which will just set focus + // to the oruga autocomplete component + focus() { + // TODO: why is this ref null?! + if (this.$refs.autocompletex) { + this.$refs.autocompletex.focus() + } + }, + + // returns the "raw" user input from the underlying oruga + // autocomplete component + getUserInput() { + return this.orugaValue + }, + }, + } + + </script> +</%def> + +<%def name="make_tailbone_datepicker_component()"> + <% request.register_component('tailbone-datepicker', 'TailboneDatepicker') %> + <script type="text/x-template" id="tailbone-datepicker-template"> + <o-datepicker placeholder="Click to select ..." + icon="calendar-alt" + :date-formatter="formatDate" + :date-parser="parseDate" + v-model="orugaValue" + @update:model-value="orugaValueUpdated" + :disabled="disabled" + ref="trueDatePicker"> + </o-datepicker> + </script> + <script> + + const TailboneDatepicker = { + template: '#tailbone-datepicker-template', + + props: { + modelValue: [Date, String], + disabled: Boolean, + }, + + data() { + return { + orugaValue: this.parseDate(this.modelValue), + } + }, + + watch: { + modelValue(to, from) { + this.orugaValue = this.parseDate(to) + }, + }, + + methods: { + + formatDate(date) { + if (date === null) { + return null + } + if (typeof(date) == 'string') { + return date + } + // just need to convert to simple ISO date format here, seems + // like there should be a more obvious way to do that? + var year = date.getFullYear() + var month = date.getMonth() + 1 + var day = date.getDate() + month = month < 10 ? '0' + month : month + day = day < 10 ? '0' + day : day + return year + '-' + month + '-' + day + }, + + parseDate(value) { + if (typeof(value) == 'object') { + // nb. we are assuming it is a Date here + return value + } + if (value) { + // note, this assumes classic YYYY-MM-DD (i.e. ISO?) format + const parts = value.split('-') + return new Date(parts[0], parseInt(parts[1]) - 1, parts[2]) + } + return null + }, + + orugaValueUpdated(date) { + this.$emit('update:modelValue', date) + }, + + focus() { + this.$refs.trueDatePicker.focus() + }, + }, + } + + </script> +</%def> + +<%def name="make_tailbone_timepicker_component()"> + <% request.register_component('tailbone-timepicker', 'TailboneTimepicker') %> + <script type="text/x-template" id="tailbone-timepicker-template"> + <o-timepicker :name="name" + v-model="orugaValue" + @update:model-value="orugaValueUpdated" + placeholder="Click to select ..." + icon="clock" + hour-format="12" + :time-formatter="formatTime" /> + </script> + <script> + + const TailboneTimepicker = { + template: '#tailbone-timepicker-template', + + props: { + modelValue: [Date, String], + name: String, + }, + + data() { + return { + orugaValue: this.parseTime(this.modelValue), + } + }, + + watch: { + modelValue(to, from) { + this.orugaValue = this.parseTime(to) + }, + }, + + methods: { + + formatTime(value) { + if (!value) { + return null + } + + return value.toLocaleTimeString('en-US') + }, + + parseTime(value) { + if (!value) { + return value + } + + if (value.getHours) { + return value + } + + let found = value.match(/^(\d\d):(\d\d):\d\d$/) + if (found) { + return new Date(null, null, null, + parseInt(found[1]), parseInt(found[2])) + } + }, + + orugaValueUpdated(value) { + this.$emit('update:modelValue', value) + }, + }, + } + + </script> +</%def> diff --git a/tailbone/templates/themes/butterball/http-plugin.mako b/tailbone/templates/themes/butterball/http-plugin.mako new file mode 100644 index 00000000..06afc2bb --- /dev/null +++ b/tailbone/templates/themes/butterball/http-plugin.mako @@ -0,0 +1,100 @@ + +<%def name="make_http_plugin()"> + <script> + + const HttpPlugin = { + + install(app, options) { + app.config.globalProperties.$http = { + + get(url, options) { + if (options === undefined) { + options = {} + } + + if (options.params) { + // convert params to query string + const data = new URLSearchParams() + for (let [key, value] of Object.entries(options.params)) { + // nb. all values get converted to string here, so + // fallback to empty string to avoid null value + // from being interpreted as "null" string + if (value === null) { + value = '' + } + data.append(key, value) + } + // TODO: this should be smarter in case query string already exists + url += '?' + data.toString() + // params is not a valid arg for options to fetch() + delete options.params + } + + return new Promise((resolve, reject) => { + fetch(url, options).then(response => { + // original response does not contain 'data' + // attribute, so must use a "mock" response + // which does contain everything + response.json().then(json => { + resolve({ + data: json, + headers: response.headers, + ok: response.ok, + redirected: response.redirected, + status: response.status, + statusText: response.statusText, + type: response.type, + url: response.url, + }) + }, json => { + reject(response) + }) + }, response => { + reject(response) + }) + }) + }, + + post(url, params, options) { + + if (params) { + + // attach params as json + options.body = JSON.stringify(params) + + // and declare content-type + options.headers = new Headers(options.headers) + options.headers.append('Content-Type', 'application/json') + } + + options.method = 'POST' + + return new Promise((resolve, reject) => { + fetch(url, options).then(response => { + // original response does not contain 'data' + // attribute, so must use a "mock" response + // which does contain everything + response.json().then(json => { + resolve({ + data: json, + headers: response.headers, + ok: response.ok, + redirected: response.redirected, + status: response.status, + statusText: response.statusText, + type: response.type, + url: response.url, + }) + }, json => { + reject(response) + }) + }, response => { + reject(response) + }) + }) + }, + } + }, + } + </script> +</%def> diff --git a/tailbone/templates/themes/butterball/progress.mako b/tailbone/templates/themes/butterball/progress.mako new file mode 100644 index 00000000..1c389fb8 --- /dev/null +++ b/tailbone/templates/themes/butterball/progress.mako @@ -0,0 +1,244 @@ +## -*- coding: utf-8; -*- +<%namespace name="base_meta" file="/base_meta.mako" /> +<%namespace file="/base.mako" import="core_javascript" /> +<%namespace file="/base.mako" import="core_styles" /> +<%namespace file="/http-plugin.mako" import="make_http_plugin" /> +<!DOCTYPE html> +<html lang="en"> + <head> + <meta http-equiv="content-type" content="text/html; charset=UTF-8" /> + ${base_meta.favicon()} + <title>${initial_msg or "Working"}...</title> + ${core_javascript()} + ${core_styles()} + ${self.extra_styles()} + </head> + + <body> + <div id="app" style="height: 100%; display: flex; flex-direction: column; justify-content: space-between;"> + <whole-page></whole-page> + </div> + + ${make_http_plugin()} + ${self.make_whole_page_component()} + ${self.modify_whole_page_vars()} + ${self.make_whole_page_app()} + </body> +</html> + +<%def name="extra_styles()"></%def> + +<%def name="make_whole_page_component()"> + <script type="text/x-template" id="whole-page-template"> + <section class="hero is-fullheight"> + <div class="hero-body"> + <div class="container"> + + <div style="display: flex; flex-direction: column; justify-content: center;"> + <div style="margin: auto; display: flex; gap: 1rem; align-items: end;"> + + <div style="display: flex; flex-direction: column; gap: 1rem;"> + + <div style="display: flex; gap: 3rem;"> + <span>{{ progressMessage }} ... {{ totalDisplay }}</span> + <span>{{ percentageDisplay }}</span> + </div> + + <div style="display: flex; gap: 1rem; align-items: center;"> + + <div> + <progress class="progress is-large" + style="width: 400px;" + :max="progressMax" + :value="progressValue" /> + </div> + + % if can_cancel: + <o-button v-show="canCancel" + @click="cancelProgress()" + :disabled="cancelingProgress" + icon-left="ban"> + {{ cancelingProgress ? "Canceling, please wait..." : "Cancel" }} + </o-button> + % endif + + </div> + </div> + + </div> + </div> + + ${self.after_progress()} + + </div> + </div> + </section> + </script> + <script> + + const WholePage = { + template: '#whole-page-template', + + computed: { + + percentageDisplay() { + if (!this.progressMax) { + return + } + + const percent = this.progressValue / this.progressMax + return percent.toLocaleString(undefined, { + style: 'percent', + minimumFractionDigits: 0}) + }, + + totalDisplay() { + + % if can_cancel: + if (!this.stillInProgress && !this.cancelingProgress) { + return "done!" + } + % else: + if (!this.stillInProgress) { + return "done!" + } + % endif + + if (this.progressMaxDisplay) { + return `(${'$'}{this.progressMaxDisplay} total)` + } + }, + }, + + mounted() { + + // fetch first progress data, one second from now + setTimeout(() => { + this.updateProgress() + }, 1000) + + // custom logic if applicable + this.mountedCustom() + }, + + methods: { + + mountedCustom() {}, + + updateProgress() { + + this.$http.get(this.progressURL).then(response => { + + if (response.data.error) { + // errors stop the show, we redirect to "cancel" page + location.href = '${cancel_url}' + + } else { + + if (response.data.complete || response.data.maximum) { + this.progressMessage = response.data.message + this.progressMaxDisplay = response.data.maximum_display + + if (response.data.complete) { + this.progressValue = this.progressMax + this.stillInProgress = false + % if can_cancel: + this.canCancel = false + % endif + + location.href = response.data.success_url + + } else { + this.progressValue = response.data.value + this.progressMax = response.data.maximum + } + } + + // custom logic if applicable + this.updateProgressCustom(response) + + if (this.stillInProgress) { + + // fetch progress data again, in one second from now + setTimeout(() => { + this.updateProgress() + }, 1000) + } + } + }) + }, + + updateProgressCustom(response) {}, + + % if can_cancel: + + cancelProgress() { + + if (confirm("Do you really wish to cancel this operation?")) { + + this.cancelingProgress = true + this.stillInProgress = false + + let params = {cancel_msg: ${json.dumps(cancel_msg)|n}} + this.$http.get(this.cancelURL, {params: params}).then(response => { + location.href = ${json.dumps(cancel_url)|n} + }) + } + + }, + + % endif + } + } + + const WholePageData = { + + progressURL: '${url('progress', key=progress.key, _query={'sessiontype': progress.session.type})}', + progressMessage: "${(initial_msg or "Working").replace('"', '\\"')} (please wait)", + progressMax: null, + progressMaxDisplay: null, + progressValue: null, + stillInProgress: true, + + % if can_cancel: + canCancel: true, + cancelURL: '${url('progress.cancel', key=progress.key, _query={'sessiontype': progress.session.type})}', + cancelingProgress: false, + % endif + } + + </script> +</%def> + +<%def name="after_progress()"></%def> + +<%def name="modify_whole_page_vars()"></%def> + +<%def name="make_whole_page_app()"> + <script type="module"> + import {createApp} from 'vue' + import {Oruga} from '@oruga-ui/oruga-next' + import {bulmaConfig} from '@oruga-ui/theme-bulma' + import { library } from "@fortawesome/fontawesome-svg-core" + import { fas } from "@fortawesome/free-solid-svg-icons" + import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome" + library.add(fas) + + const app = createApp() + + app.component('vue-fontawesome', FontAwesomeIcon) + + WholePage.data = () => { return WholePageData } + app.component('whole-page', WholePage) + + app.use(Oruga, { + ...bulmaConfig, + iconComponent: 'vue-fontawesome', + iconPack: 'fas', + }) + + app.use(HttpPlugin) + + app.mount('#app') + </script> +</%def> diff --git a/tailbone/templates/themes/excite-bike/base.mako b/tailbone/templates/themes/excite-bike/base.mako deleted file mode 100644 index d4328621..00000000 --- a/tailbone/templates/themes/excite-bike/base.mako +++ /dev/null @@ -1,8 +0,0 @@ -## -*- coding: utf-8; -*- -<%inherit file="tailbone:templates/base.mako" /> - -<%def name="jquery_theme()"> - ${h.stylesheet_link('https://code.jquery.com/ui/1.11.4/themes/excite-bike/jquery-ui.css')} -</%def> - -${parent.body()} diff --git a/tailbone/templates/themes/falafel/base.mako b/tailbone/templates/themes/falafel/base.mako index fe3ef429..1869043b 100644 --- a/tailbone/templates/themes/falafel/base.mako +++ b/tailbone/templates/themes/falafel/base.mako @@ -1,857 +1,4 @@ ## -*- coding: utf-8; -*- -<%namespace file="/grids/nav.mako" import="grid_index_nav" /> -<%namespace file="/autocomplete.mako" import="tailbone_autocomplete_template" /> -<%namespace name="base_meta" file="/base_meta.mako" /> -<%namespace file="/formposter.mako" import="declare_formposter_mixin" /> -<!DOCTYPE html> -<html lang="en"> - <head> - <meta http-equiv="content-type" content="text/html; charset=UTF-8" /> - <title>${base_meta.global_title()} » ${capture(self.title)|n}</title> - ${base_meta.favicon()} - ${self.header_core()} +<%inherit file="tailbone:templates/base.mako" /> - % if background_color: - <style type="text/css"> - body, .navbar, .footer { - background-color: ${background_color}; - } - </style> - % endif - - % if not request.rattail_config.production(): - <style type="text/css"> - body, .navbar, .footer { - background-image: url(${request.static_url('tailbone:static/img/testing.png')}); - } - </style> - % endif - - ${self.head_tags()} - </head> - - <body> - ${declare_formposter_mixin()} - - ${self.body()} - - <div id="whole-page-app"> - <whole-page></whole-page> - </div> - - ${self.render_whole_page_template()} - ${self.make_whole_page_component()} - ${self.make_whole_page_app()} - </body> -</html> - -<%def name="title()"></%def> - -<%def name="content_title()"> - ${self.title()} -</%def> - -<%def name="header_core()"> - - ${self.core_javascript()} - ${self.extra_javascript()} - ${self.core_styles()} - ${self.extra_styles()} - - ## TODO: should this be elsewhere / more customizable? - % if dform is not Undefined: - <% resources = dform.get_widget_resources() %> - % for path in resources['js']: - ${h.javascript_link(request.static_url(path))} - % endfor - % for path in resources['css']: - ${h.stylesheet_link(request.static_url(path))} - % endfor - % endif -</%def> - -<%def name="core_javascript()"> - ${self.jquery()} - ${self.vuejs()} - ${self.buefy()} - ${self.fontawesome()} - - ## some commonly-useful logic for detecting (non-)numeric input - ${h.javascript_link(request.static_url('tailbone:static/js/numeric.js') + '?ver={}'.format(tailbone.__version__))} - - ## debounce, for better autocomplete performance - ${h.javascript_link(request.static_url('tailbone:static/js/debounce.js') + '?ver={}'.format(tailbone.__version__))} - - ## Tailbone / Buefy stuff - ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.datepicker.js') + '?ver={}'.format(tailbone.__version__))} - ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.numericinput.js') + '?ver={}'.format(tailbone.__version__))} - ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.oncebutton.js') + '?ver={}'.format(tailbone.__version__))} - ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.timepicker.js') + '?ver={}'.format(tailbone.__version__))} - ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.grid.js') + '?ver={}'.format(tailbone.__version__))} - - <script type="text/javascript"> - var session_timeout = ${request.get_session_timeout() or 'null'}; - var logout_url = '${request.route_url('logout')}'; - var noop_url = '${request.route_url('noop')}'; - $(function() { - ## NOTE: this code was copied from - ## https://bulma.io/documentation/components/navbar/#navbar-menu - $('.navbar-burger').click(function() { - $('.navbar-burger').toggleClass('is-active'); - $('.navbar-menu').toggleClass('is-active'); - }); - }); - </script> -</%def> - -<%def name="jquery()"> - ## jQuery 1.12.4 - ${h.javascript_link('https://code.jquery.com/jquery-1.12.4.min.js')} -</%def> - -<%def name="vuejs()"> - ${h.javascript_link('https://unpkg.com/vue@{}/dist/vue.js'.format(vue_version))} - - ## vue-resource - ## (needed for e.g. this.$http.get() calls, used by grid at least) - ## TODO: make this configurable also - ${h.javascript_link('https://cdn.jsdelivr.net/npm/vue-resource@1.5.1')} -</%def> - -<%def name="buefy()"> - ${h.javascript_link('https://unpkg.com/buefy@{}/dist/buefy.min.js'.format(buefy_version))} -</%def> - -<%def name="fontawesome()"> - <script defer src="https://use.fontawesome.com/releases/v5.3.1/js/all.js"></script> -</%def> - -<%def name="extra_javascript()"></%def> - -<%def name="core_styles()"> - - ${self.buefy_styles()} - - ${h.stylesheet_link(request.static_url('tailbone:static/themes/falafel/css/base.css') + '?ver={}'.format(tailbone.__version__))} - ${h.stylesheet_link(request.static_url('tailbone:static/themes/falafel/css/layout.css') + '?ver={}'.format(tailbone.__version__))} - ${h.stylesheet_link(request.static_url('tailbone:static/css/grids.css') + '?ver={}'.format(tailbone.__version__))} - ${h.stylesheet_link(request.static_url('tailbone:static/themes/falafel/css/grids.css') + '?ver={}'.format(tailbone.__version__))} - ${h.stylesheet_link(request.static_url('tailbone:static/themes/falafel/css/grids.rowstatus.css') + '?ver={}'.format(tailbone.__version__))} -## ${h.stylesheet_link(request.static_url('tailbone:static/css/filters.css') + '?ver={}'.format(tailbone.__version__))} - ${h.stylesheet_link(request.static_url('tailbone:static/themes/falafel/css/filters.css') + '?ver={}'.format(tailbone.__version__))} - ${h.stylesheet_link(request.static_url('tailbone:static/themes/falafel/css/forms.css') + '?ver={}'.format(tailbone.__version__))} - ${h.stylesheet_link(request.static_url('tailbone:static/css/diffs.css') + '?ver={}'.format(tailbone.__version__))} - - ${h.stylesheet_link(request.static_url('tailbone:static/css/codehilite.css') + '?ver={}'.format(tailbone.__version__))} - - <style type="text/css"> - .filters .filter-fieldname { - min-width: ${filter_fieldname_width}; - justify-content: left; - } - .filters .filter-verb { - min-width: ${filter_verb_width}; - } - </style> -</%def> - -<%def name="buefy_styles()"> - % if buefy_css: - ## custom Buefy CSS - ${h.stylesheet_link(buefy_css)} - % else: - ## upstream Buefy CSS - ${h.stylesheet_link('https://unpkg.com/buefy@{}/dist/buefy.min.css'.format(buefy_version))} - % endif -</%def> - -## TODO: this is only being referenced by the progress template i think? -## (so, should make a Buefy progress page at least) -<%def name="jquery_theme()"> - ${h.stylesheet_link('https://code.jquery.com/ui/1.11.4/themes/dark-hive/jquery-ui.css')} -</%def> - -<%def name="extra_styles()"></%def> - -<%def name="head_tags()"></%def> - -<%def name="render_whole_page_template()"> - <script type="text/x-template" id="whole-page-template"> - <div> - <header> - - <nav class="navbar" role="navigation" aria-label="main navigation"> - - <div class="navbar-brand"> - <a class="navbar-item" href="${url('home')}"> - ${base_meta.header_logo()} - <div id="global-header-title"> - ${base_meta.global_title()} - </div> - </a> - <a role="button" class="navbar-burger" aria-label="menu" aria-expanded="false"> - <span aria-hidden="true"></span> - <span aria-hidden="true"></span> - <span aria-hidden="true"></span> - </a> - </div> - - <div class="navbar-menu"> - <div class="navbar-start"> - - % for topitem in menus: - % if topitem['is_link']: - ${h.link_to(topitem['title'], topitem['url'], target=topitem['target'], class_='navbar-item')} - % else: - <div class="navbar-item has-dropdown is-hoverable"> - <a class="navbar-link">${topitem['title']}</a> - <div class="navbar-dropdown"> - % for item in topitem['items']: - % if item['is_menu']: - <% item_hash = id(item) %> - <% toggle = 'menu_{}_shown'.format(item_hash) %> - <div> - <a class="navbar-link" @click.prevent="toggleNestedMenu('${item_hash}')"> - ${item['title']} - </a> - </div> - % for subitem in item['items']: - % if subitem['is_sep']: - <hr class="navbar-divider" v-show="${toggle}"> - % else: - ${h.link_to("{}".format(subitem['title']), subitem['url'], class_='navbar-item nested', target=subitem['target'], **{'v-show': toggle})} - % endif - % endfor - % else: - % if item['is_sep']: - <hr class="navbar-divider"> - % else: - ${h.link_to(item['title'], item['url'], class_='navbar-item', target=item['target'])} - % endif - % endif - % endfor - </div> - </div> - % endif - % endfor - - </div><!-- navbar-start --> - ${self.render_navbar_end()} - </div> - </nav> - - <nav class="level" style="margin: 0.5rem auto;"> - <div class="level-left"> - - ## Current Context - <div id="current-context" class="level-item"> - % if master: - % if master.listing: - <span class="header-text"> - ${index_title} - </span> - % if master.creatable and master.show_create_link and master.has_perm('create'): - <once-button type="is-primary" - tag="a" href="${url('{}.create'.format(route_prefix))}" - icon-left="plus" - style="margin-left: 1rem;" - text="Create New"> - </once-button> - % endif - % elif index_url: - <span class="header-text"> - ${h.link_to(index_title, index_url)} - </span> - % if parent_url is not Undefined: - <span class="header-text"> - » - </span> - <span class="header-text"> - ${h.link_to(parent_title, parent_url)} - </span> - % elif instance_url is not Undefined: - <span class="header-text"> - » - </span> - <span class="header-text"> - ${h.link_to(instance_title, instance_url)} - </span> - % elif master.creatable and master.show_create_link and master.has_perm('create'): - % if not request.matched_route.name.endswith('.create'): - <once-button type="is-primary" - tag="a" href="${url('{}.create'.format(route_prefix))}" - icon-left="plus" - style="margin-left: 1rem;" - text="Create New"> - </once-button> - % endif - % endif - % if master.viewing and grid_index: - ${grid_index_nav()} - % endif - % else: - <span class="header-text"> - ${index_title} - </span> - % endif - % elif index_title: - % if index_url: - <span class="header-text"> - ${h.link_to(index_title, index_url)} - </span> - % else: - <span class="header-text"> - ${index_title} - </span> - % endif - % endif - </div> - - % if expose_db_picker is not Undefined and expose_db_picker: - <div class="level-item"> - <p>DB:</p> - </div> - <div class="level-item"> - ${h.form(url('change_db_engine'), ref='dbPickerForm')} - ${h.csrf_token(request)} - ${h.hidden('engine_type', value=master.engine_type_key)} - <div class="select"> - ${h.select('dbkey', db_picker_selected, db_picker_options, **{'@change': 'changeDB()'})} - </div> - ${h.end_form()} - </div> - % endif - - </div><!-- level-left --> - <div class="level-right"> - - ## Quickie Lookup - % if quickie is not Undefined and quickie and request.has_perm(quickie.perm): - <div class="level-item"> - ${h.form(quickie.url, method="get")} - <div class="level"> - <div class="level-right"> - <div class="level-item"> - % if use_buefy: - <b-input name="entry" - placeholder="${quickie.placeholder}" - autocomplete="off"> - </b-input> - % else: - ${h.text('entry', placeholder=quickie.placeholder, autocomplete='off')} - % endif - </div> - <div class="level-item"> - <button type="submit" class="button is-primary"> - <span class="icon is-small"> - <i class="fas fa-search"></i> - </span> - <span>Lookup</span> - </button> - </div> - </div> - </div> - ${h.end_form()} - </div> - % endif - - % if master and master.configurable and master.has_perm('configure'): - % if not request.matched_route.name.endswith('.configure'): - <div class="level-item"> - <once-button type="is-primary" - tag="a" - href="${url('{}.configure'.format(route_prefix))}" - icon-left="cog" - text="Configure"> - </once-button> - </div> - % endif - % endif - - ## Theme Picker - % if expose_theme_picker and request.has_perm('common.change_app_theme'): - <div class="level-item"> - ${h.form(url('change_theme'), method="post", ref='themePickerForm')} - ${h.csrf_token(request)} - Theme: - <div class="theme-picker"> - <div class="select"> - ${h.select('theme', theme, theme_picker_options, **{'@change': 'changeTheme()'})} - </div> - </div> - ${h.end_form()} - </div> - % endif - - ## Help Button - % if help_url is not Undefined and help_url: - <div class="level-item"> - <b-button tag="a" href="${help_url}" - target="_blank" - icon-pack="fas" - icon-left="fas fa-question-circle"> - Help - </b-button> - </div> - % endif - - ## Feedback Button / Dialog - % if request.has_perm('common.feedback'): - <feedback-form - action="${url('feedback')}" - :message="feedbackMessage"> - </feedback-form> - % endif - - </div><!-- level-right --> - </nav><!-- level --> - </header> - - ## Page Title - % if capture(self.content_title): - <section id="content-title" class="hero is-primary"> - <div class="level"> - <div class="level-left"> - <div class="level-item"> - <h1 class="title" v-html="contentTitleHTML"></h1> - </div> - </div> - <div class="level-right"> - ${self.render_instance_header_buttons()} - </div> - </div> - </section> - % endif - - <div class="content-wrapper"> - - ## Page Body - <section id="page-body"> - - % if request.session.peek_flash('error'): - % for error in request.session.pop_flash('error'): - <b-notification type="is-warning"> - ${error} - </b-notification> - % endfor - % endif - - % if request.session.peek_flash('warning'): - % for msg in request.session.pop_flash('warning'): - <b-notification type="is-warning"> - ${msg} - </b-notification> - % endfor - % endif - - % if request.session.peek_flash(): - % for msg in request.session.pop_flash(): - <b-notification type="is-info"> - ${msg} - </b-notification> - % endfor - % endif - - ${self.render_this_page_component()} - </section> - - ## Footer - <footer class="footer"> - <div class="content"> - ${base_meta.footer()} - </div> - </footer> - - </div><!-- content-wrapper --> - </div> - </script> - - <script type="text/x-template" id="feedback-template"> - <div> - - <div class="level-item"> - <b-button type="is-primary" - @click="showFeedback()" - icon-pack="fas" - icon-left="fas fa-comment"> - Feedback - </b-button> - </div> - - <b-modal has-modal-card - :active.sync="showDialog"> - <div class="modal-card"> - - <header class="modal-card-head"> - <p class="modal-card-title">User Feedback</p> - </header> - - <section class="modal-card-body"> - <p class="block"> - Questions, suggestions, comments, complaints, etc. - <span class="red">regarding this website</span> are - welcome and may be submitted below. - </p> - - <b-field label="User Name"> - <b-input v-model="userName" - % if request.user: - disabled - % endif - > - </b-input> - </b-field> - - <b-field label="Referring URL"> - <b-input - v-model="referrer" - disabled="true"> - </b-input> - </b-field> - - <b-field label="Message"> - <b-input type="textarea" - v-model="message" - ref="textarea"> - </b-input> - </b-field> - - % if request.rattail_config.getbool('tailbone', 'feedback_allows_reply'): - <div class="level"> - <div class="level-left"> - <div class="level-item"> - <b-checkbox v-model="pleaseReply" - @input="pleaseReplyChanged"> - Please email me back{{ pleaseReply ? " at: " : "" }} - </b-checkbox> - </div> - <div class="level-item" v-show="pleaseReply"> - <b-input v-model="userEmail" - ref="userEmail"> - </b-input> - </div> - </div> - </div> - % endif - - </section> - - <footer class="modal-card-foot"> - <b-button @click="showDialog = false"> - Cancel - </b-button> - <once-button type="is-primary" - @click="sendFeedback()" - :disabled="!message.trim()" - text="Send Message"> - </once-button> - </footer> - </div> - </b-modal> - - </div> - </script> - - ${tailbone_autocomplete_template()} -</%def> - -<%def name="render_this_page_component()"> - <this-page - v-on:change-content-title="changeContentTitle"> - </this-page> -</%def> - -<%def name="render_navbar_end()"> - <div class="navbar-end"> - ${self.render_user_menu()} - </div> -</%def> - -<%def name="render_user_menu()"> - % if request.user: - <div class="navbar-item has-dropdown is-hoverable"> - % if messaging_enabled: - <a class="navbar-link ${'root-user' if request.is_root else ''}">${request.user}${" ({})".format(inbox_count) if inbox_count else ''}</a> - % else: - <a class="navbar-link ${'root-user' if request.is_root else ''}">${request.user}</a> - % endif - <div class="navbar-dropdown"> - % if request.is_root: - ${h.link_to("Stop being root", url('stop_root'), class_='navbar-item root-user')} - % elif request.is_admin: - ${h.link_to("Become root", url('become_root'), class_='navbar-item root-user')} - % endif - % if messaging_enabled: - ${h.link_to("Messages{}".format(" ({})".format(inbox_count) if inbox_count else ''), url('messages.inbox'), class_='navbar-item')} - % endif - ${h.link_to("Change Password", url('change_password'), class_='navbar-item')} - ${h.link_to("Edit Preferences", url('my.preferences'), class_='navbar-item')} - ${h.link_to("Logout", url('logout'), class_='navbar-item')} - </div> - </div> - % else: - ${h.link_to("Login", url('login'), class_='navbar-item')} - % endif -</%def> - -<%def name="render_instance_header_buttons()"> - ${self.render_crud_header_buttons()} - ${self.render_prevnext_header_buttons()} -</%def> - -<%def name="render_crud_header_buttons()"> - % if master and master.viewing: - ## TODO: is there a better way to check if viewing parent? - % if parent_instance is Undefined: - % if master.editable and instance_editable and master.has_perm('edit'): - <div class="level-item"> - <once-button tag="a" href="${action_url('edit', instance)}" - icon-left="edit" - text="Edit This"> - </once-button> - </div> - % endif - % if master.cloneable and master.has_perm('clone'): - <div class="level-item"> - <once-button tag="a" href="${action_url('clone', instance)}" - icon-left="object-ungroup" - text="Clone This"> - </once-button> - </div> - % endif - % if master.deletable and instance_deletable and master.has_perm('delete'): - <div class="level-item"> - <once-button tag="a" href="${action_url('delete', instance)}" - type="is-danger" - icon-left="trash" - text="Delete This"> - </once-button> - </div> - % endif - % else: - ## viewing row - % if instance_deletable and master.has_perm('delete_row'): - <div class="level-item"> - <once-button tag="a" href="${action_url('delete', instance)}" - type="is-danger" - icon-left="trash" - text="Delete This"> - </once-button> - </div> - % endif - % endif - % elif master and master.editing: - % if master.viewable and master.has_perm('view'): - <div class="level-item"> - <once-button tag="a" href="${action_url('view', instance)}" - icon-left="eye" - text="View This"> - </once-button> - </div> - % endif - % if master.deletable and instance_deletable and master.has_perm('delete'): - <div class="level-item"> - <once-button tag="a" href="${action_url('delete', instance)}" - type="is-danger" - icon-left="trash" - text="Delete This"> - </once-button> - </div> - % endif - % elif master and master.deleting: - % if master.viewable and master.has_perm('view'): - <div class="level-item"> - <once-button tag="a" href="${action_url('view', instance)}" - icon-left="eye" - text="View This"> - </once-button> - </div> - % endif - % if master.editable and instance_editable and master.has_perm('edit'): - <div class="level-item"> - <once-button tag="a" href="${action_url('edit', instance)}" - icon-left="edit" - text="Edit This"> - </once-button> - </div> - % endif - % endif -</%def> - -<%def name="render_prevnext_header_buttons()"> - % if show_prev_next is not Undefined and show_prev_next: - % if prev_url: - <div class="level-item"> - % if use_buefy: - <b-button tag="a" href="${prev_url}" - icon-pack="fas" - icon-left="arrow-left"> - Older - </b-button> - % else: - ${h.link_to(u"« Older", prev_url, class_='button autodisable')} - % endif - </div> - % else: - <div class="level-item"> - % if use_buefy: - <b-button tag="a" href="#" - disabled - icon-pack="fas" - icon-left="arrow-left"> - Older - </b-button> - % else: - ${h.link_to(u"« Older", '#', class_='button', disabled='disabled')} - % endif - </div> - % endif - % if next_url: - <div class="level-item"> - % if use_buefy: - <b-button tag="a" href="${next_url}" - icon-pack="fas" - icon-left="arrow-right"> - Newer - </b-button> - % else: - ${h.link_to(u"Newer »", next_url, class_='button autodisable')} - % endif - </div> - % else: - <div class="level-item"> - % if use_buefy: - <b-button tag="a" href="#" - disabled - icon-pack="fas" - icon-left="arrow-right"> - Newer - </b-button> - % else: - ${h.link_to(u"Newer »", '#', class_='button', disabled='disabled')} - % endif - </div> - % endif - % endif -</%def> - -<%def name="declare_whole_page_vars()"> - ${h.javascript_link(request.static_url('tailbone:static/themes/falafel/js/tailbone.feedback.js') + '?ver={}'.format(tailbone.__version__))} - <script type="text/javascript"> - - let WholePage = { - template: '#whole-page-template', - mixins: [FormPosterMixin], - computed: {}, - methods: { - - changeContentTitle(newTitle) { - this.contentTitleHTML = newTitle - }, - - % if expose_db_picker is not Undefined and expose_db_picker: - changeDB() { - this.$refs.dbPickerForm.submit() - }, - % endif - - % if expose_theme_picker and request.has_perm('common.change_app_theme'): - changeTheme() { - this.$refs.themePickerForm.submit() - }, - % endif - - toggleNestedMenu(hash) { - const key = 'menu_' + hash + '_shown' - this[key] = !this[key] - }, - }, - } - - let WholePageData = { - contentTitleHTML: ${json.dumps(capture(self.content_title))|n}, - feedbackMessage: "", - } - - ## declare nested menu visibility toggle flags - % for topitem in menus: - % if topitem['is_menu']: - % for item in topitem['items']: - % if item['is_menu']: - WholePageData.menu_${id(item)}_shown = false - % endif - % endfor - % endif - % endfor - - </script> -</%def> - -<%def name="modify_whole_page_vars()"> - <script type="text/javascript"> - - FeedbackFormData.referrer = location.href - - % if request.user: - FeedbackFormData.userUUID = ${json.dumps(request.user.uuid)|n} - FeedbackFormData.userName = ${json.dumps(six.text_type(request.user))|n} - % endif - - </script> -</%def> - -<%def name="finalize_whole_page_vars()"> - ## NOTE: if you override this, must use <script> tags -</%def> - -<%def name="make_whole_page_component()"> - ${self.declare_whole_page_vars()} - ${self.modify_whole_page_vars()} - ${self.finalize_whole_page_vars()} - - ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.autocomplete.js') + '?ver={}'.format(tailbone.__version__))} - - <script type="text/javascript"> - - FeedbackForm.data = function() { return FeedbackFormData } - - Vue.component('feedback-form', FeedbackForm) - - WholePage.data = function() { return WholePageData } - - Vue.component('whole-page', WholePage) - - </script> -</%def> - -<%def name="make_whole_page_app()"> - <script type="text/javascript"> - - new Vue({ - el: '#whole-page-app' - }) - - </script> -</%def> - -<%def name="wtfield(form, name, **kwargs)"> - <div class="field-wrapper${' error' if form[name].errors else ''}"> - <label for="${name}">${form[name].label}</label> - <div class="field"> - ${form[name](**kwargs)} - </div> - </div> -</%def> - -<%def name="simple_field(label, value)"> - ## TODO: keep this? only used by personal profile view currently - ## (although could be useful for any readonly scenario) - <div class="field-wrapper"> - <div class="field-row"> - <label>${label}</label> - <div class="field"> - ${'' if value is None else value} - </div> - </div> - </div> -</%def> +${parent.body()} diff --git a/tailbone/templates/themes/falafel/progress.mako b/tailbone/templates/themes/falafel/progress.mako new file mode 100644 index 00000000..4bb27014 --- /dev/null +++ b/tailbone/templates/themes/falafel/progress.mako @@ -0,0 +1,4 @@ +## -*- coding: utf-8; -*- +<%inherit file="tailbone:templates/progress.mako" /> + +${parent.body()} diff --git a/tailbone/templates/themes/falafel/upgrade.mako b/tailbone/templates/themes/falafel/upgrade.mako new file mode 100644 index 00000000..b7562653 --- /dev/null +++ b/tailbone/templates/themes/falafel/upgrade.mako @@ -0,0 +1,4 @@ +## -*- coding: utf-8; -*- +<%inherit file="tailbone:templates/upgrade.mako" /> + +${parent.body()} diff --git a/tailbone/templates/themes/waterpark/base.mako b/tailbone/templates/themes/waterpark/base.mako new file mode 100644 index 00000000..774479ba --- /dev/null +++ b/tailbone/templates/themes/waterpark/base.mako @@ -0,0 +1,504 @@ +## -*- coding: utf-8; -*- +<%inherit file="wuttaweb:templates/base.mako" /> +<%namespace name="base_meta" file="/base_meta.mako" /> +<%namespace file="/formposter.mako" import="declare_formposter_mixin" /> +<%namespace file="/grids/filter-components.mako" import="make_grid_filter_components" /> +<%namespace name="page_help" file="/page_help.mako" /> + +<%def name="base_styles()"> + ${parent.base_styles()} + ${h.stylesheet_link(request.static_url('tailbone:static/css/diffs.css') + '?ver={}'.format(tailbone.__version__))} + <style> + + .filters .filter-fieldname .field, + .filters .filter-fieldname .field label { + width: 100%; + } + + .filters .filter-fieldname, + .filters .filter-fieldname .field label, + .filters .filter-fieldname .button { + justify-content: left; + } + + .filters .filter-verb .select, + .filters .filter-verb .select select { + width: 100%; + } + + % if filter_fieldname_width is not Undefined: + + .filters .filter-fieldname, + .filters .filter-fieldname .button { + min-width: ${filter_fieldname_width}; + } + + .filters .filter-verb { + min-width: ${filter_verb_width}; + } + + % endif + + </style> +</%def> + +<%def name="before_content()"> + ## TODO: this must come before the self.body() call..but why? + ${declare_formposter_mixin()} +</%def> + +<%def name="render_navbar_brand()"> + <div class="navbar-brand"> + <a class="navbar-item" href="${url('home')}" + v-show="!menuSearchActive"> + <div style="display: flex; align-items: center;"> + ${base_meta.header_logo()} + <div id="navbar-brand-title"> + ${base_meta.global_title()} + </div> + </div> + </a> + <div v-show="menuSearchActive" + class="navbar-item"> + <b-autocomplete ref="menuSearchAutocomplete" + v-model="menuSearchTerm" + :data="menuSearchFilteredData" + field="label" + open-on-focus + keep-first + icon-pack="fas" + clearable + @keydown.native="menuSearchKeydown" + @select="menuSearchSelect"> + </b-autocomplete> + </div> + <a role="button" class="navbar-burger" data-target="navbar-menu" aria-label="menu" aria-expanded="false"> + <span aria-hidden="true"></span> + <span aria-hidden="true"></span> + <span aria-hidden="true"></span> + </a> + </div> +</%def> + +<%def name="render_navbar_start()"> + <div class="navbar-start"> + + <div v-if="menuSearchData.length" + class="navbar-item"> + <b-button type="is-primary" + size="is-small" + @click="menuSearchInit()"> + <span><i class="fa fa-search"></i></span> + </b-button> + </div> + + % for topitem in menus: + % if topitem['is_link']: + ${h.link_to(topitem['title'], topitem['url'], target=topitem['target'], class_='navbar-item')} + % else: + <div class="navbar-item has-dropdown is-hoverable"> + <a class="navbar-link">${topitem['title']}</a> + <div class="navbar-dropdown"> + % for item in topitem['items']: + % if item['is_menu']: + <% item_hash = id(item) %> + <% toggle = 'menu_{}_shown'.format(item_hash) %> + <div> + <a class="navbar-link" @click.prevent="toggleNestedMenu('${item_hash}')"> + ${item['title']} + </a> + </div> + % for subitem in item['items']: + % if subitem['is_sep']: + <hr class="navbar-divider" v-show="${toggle}"> + % else: + ${h.link_to("{}".format(subitem['title']), subitem['url'], class_='navbar-item nested', target=subitem['target'], **{'v-show': toggle})} + % endif + % endfor + % else: + % if item['is_sep']: + <hr class="navbar-divider"> + % else: + ${h.link_to(item['title'], item['url'], class_='navbar-item', target=item['target'])} + % endif + % endif + % endfor + </div> + </div> + % endif + % endfor + + </div> +</%def> + +<%def name="render_theme_picker()"> + % if expose_theme_picker and request.has_perm('common.change_app_theme'): + <div class="level-item"> + ${h.form(url('change_theme'), method="post", ref='themePickerForm')} + ${h.csrf_token(request)} + <input type="hidden" name="referrer" :value="referrer" /> + <div style="display: flex; align-items: center; gap: 0.5rem;"> + <span>Theme:</span> + <b-select name="theme" + v-model="globalTheme" + @input="changeTheme()"> + % for option in theme_picker_options: + <option value="${option.value}"> + ${option.label} + </option> + % endfor + </b-select> + </div> + ${h.end_form()} + </div> + % endif +</%def> + +<%def name="render_feedback_button()"> + + <div class="level-item"> + <page-help + % if can_edit_help: + @configure-fields-help="configureFieldsHelp = true" + % endif + /> + </div> + + ${parent.render_feedback_button()} +</%def> + +<%def name="render_crud_header_buttons()"> + % if master: + % if master.viewing: + % if instance_editable and master.has_perm('edit'): + <wutta-button once + tag="a" href="${master.get_action_url('edit', instance)}" + icon-left="edit" + label="Edit This" /> + % endif + % if getattr(master, 'cloneable', False) and not master.cloning and master.has_perm('clone'): + <wutta-button once + tag="a" href="${master.get_action_url('clone', instance)}" + icon-left="object-ungroup" + label="Clone This" /> + % endif + % if instance_deletable and master.has_perm('delete'): + <wutta-button once type="is-danger" + tag="a" href="${master.get_action_url('delete', instance)}" + icon-left="trash" + label="Delete This" /> + % endif + % elif master.editing: + % if master.has_perm('view'): + <wutta-button once + tag="a" href="${master.get_action_url('view', instance)}" + icon-left="eye" + label="View This" /> + % endif + % if instance_deletable and master.has_perm('delete'): + <wutta-button once type="is-danger" + tag="a" href="${master.get_action_url('delete', instance)}" + icon-left="trash" + label="Delete This" /> + % endif + % elif master.deleting: + % if master.has_perm('view'): + <wutta-button once + tag="a" href="${master.get_action_url('view', instance)}" + icon-left="eye" + label="View This" /> + % endif + % if instance_editable and master.has_perm('edit'): + <wutta-button once + tag="a" href="${master.get_action_url('edit', instance)}" + icon-left="edit" + label="Edit This" /> + % endif + % endif + % endif +</%def> + +<%def name="render_prevnext_header_buttons()"> + % if show_prev_next is not Undefined and show_prev_next: + % if prev_url: + <wutta-button once + tag="a" href="${prev_url}" + icon-left="arrow-left" + label="Older" /> + % else: + <b-button tag="a" href="#" + disabled + icon-pack="fas" + icon-left="arrow-left"> + Older + </b-button> + % endif + % if next_url: + <wutta-button once + tag="a" href="${next_url}" + icon-left="arrow-right" + label="Newer" /> + % else: + <b-button tag="a" href="#" + disabled + icon-pack="fas" + icon-left="arrow-right"> + Newer + </b-button> + % endif + % endif +</%def> + +<%def name="render_this_page_component()"> + <this-page @change-content-title="changeContentTitle" + % if can_edit_help: + :configure-fields-help="configureFieldsHelp" + % endif + /> +</%def> + +<%def name="render_vue_template_feedback()"> + <script type="text/x-template" id="feedback-template"> + <div> + + <div class="level-item"> + <b-button type="is-primary" + @click="showFeedback()" + icon-pack="fas" + icon-left="comment"> + Feedback + </b-button> + </div> + + <b-modal has-modal-card + :active.sync="showDialog"> + <div class="modal-card"> + + <header class="modal-card-head"> + <p class="modal-card-title">User Feedback</p> + </header> + + <section class="modal-card-body"> + <p class="block"> + Questions, suggestions, comments, complaints, etc. + <span class="red">regarding this website</span> are + welcome and may be submitted below. + </p> + + <b-field label="User Name"> + <b-input v-model="userName" + % if request.user: + disabled + % endif + > + </b-input> + </b-field> + + <b-field label="Referring URL"> + <b-input + v-model="referrer" + disabled="true"> + </b-input> + </b-field> + + <b-field label="Message"> + <b-input type="textarea" + v-model="message" + ref="textarea"> + </b-input> + </b-field> + + % if config.get_bool('tailbone.feedback_allows_reply'): + <div class="level"> + <div class="level-left"> + <div class="level-item"> + <b-checkbox v-model="pleaseReply" + @input="pleaseReplyChanged"> + Please email me back{{ pleaseReply ? " at: " : "" }} + </b-checkbox> + </div> + <div class="level-item" v-show="pleaseReply"> + <b-input v-model="userEmail" + ref="userEmail"> + </b-input> + </div> + </div> + </div> + % endif + + </section> + + <footer class="modal-card-foot"> + <b-button @click="showDialog = false"> + Cancel + </b-button> + <b-button type="is-primary" + icon-pack="fas" + icon-left="paper-plane" + @click="sendFeedback()" + :disabled="sendingFeedback || !message || !message.trim()"> + {{ sendingFeedback ? "Working, please wait..." : "Send Message" }} + </b-button> + </footer> + </div> + </b-modal> + + </div> + </script> +</%def> + +<%def name="render_vue_script_feedback()"> + ${parent.render_vue_script_feedback()} + <script> + + WuttaFeedbackForm.template = '#feedback-template' + WuttaFeedbackForm.props.message = String + + % if config.get_bool('tailbone.feedback_allows_reply'): + + WuttaFeedbackFormData.pleaseReply = false + WuttaFeedbackFormData.userEmail = null + + WuttaFeedbackForm.methods.pleaseReplyChanged = function(value) { + this.$nextTick(() => { + this.$refs.userEmail.focus() + }) + } + + WuttaFeedbackForm.methods.getExtraParams = function() { + return { + please_reply_to: this.pleaseReply ? this.userEmail : null, + } + } + + % endif + + // TODO: deprecate / remove these + const FeedbackForm = WuttaFeedbackForm + const FeedbackFormData = WuttaFeedbackFormData + + </script> +</%def> + +<%def name="render_vue_templates()"> + ${parent.render_vue_templates()} + ${page_help.render_template()} + ${page_help.declare_vars()} +</%def> + +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> + + ############################## + ## menu search + ############################## + + WholePageData.menuSearchActive = false + WholePageData.menuSearchTerm = '' + WholePageData.menuSearchData = ${json.dumps(global_search_data or [])|n} + + WholePage.computed.menuSearchFilteredData = function() { + if (!this.menuSearchTerm.length) { + return this.menuSearchData + } + + const terms = [] + for (let term of this.menuSearchTerm.toLowerCase().split(' ')) { + term = term.trim() + if (term) { + terms.push(term) + } + } + if (!terms.length) { + return this.menuSearchData + } + + // all terms must match + return this.menuSearchData.filter((option) => { + const label = option.label.toLowerCase() + for (const term of terms) { + if (label.indexOf(term) < 0) { + return false + } + } + return true + }) + } + + WholePage.methods.globalKey = function(event) { + + // Ctrl+8 opens menu search + if (event.target.tagName == 'BODY') { + if (event.ctrlKey && event.key == '8') { + this.menuSearchInit() + } + } + } + + WholePage.mounted = function() { + window.addEventListener('keydown', this.globalKey) + for (let hook of this.mountedHooks) { + hook(this) + } + } + + WholePage.beforeDestroy = function() { + window.removeEventListener('keydown', this.globalKey) + } + + WholePage.methods.menuSearchInit = function() { + this.menuSearchTerm = '' + this.menuSearchActive = true + this.$nextTick(() => { + this.$refs.menuSearchAutocomplete.focus() + }) + } + + WholePage.methods.menuSearchKeydown = function(event) { + + // ESC will dismiss searchbox + if (event.which == 27) { + this.menuSearchActive = false + } + } + + WholePage.methods.menuSearchSelect = function(option) { + location.href = option.url + } + + ############################## + ## theme picker + ############################## + + % if expose_theme_picker and request.has_perm('common.change_app_theme'): + + WholePageData.globalTheme = ${json.dumps(theme or None)|n} + ## WholePageData.referrer = location.href + + WholePage.methods.changeTheme = function() { + this.$refs.themePickerForm.submit() + } + + % endif + + ############################## + ## edit fields help + ############################## + + % if can_edit_help: + WholePageData.configureFieldsHelp = false + % endif + + </script> +</%def> + +<%def name="make_vue_components()"> + ${parent.make_vue_components()} + ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.datepicker.js') + f'?ver={tailbone.__version__}')} + ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.numericinput.js') + f'?ver={tailbone.__version__}')} + ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.oncebutton.js') + f'?ver={tailbone.__version__}')} + ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.timepicker.js') + f'?ver={tailbone.__version__}')} + ${make_grid_filter_components()} + ${page_help.make_component()} +</%def> diff --git a/tailbone/templates/themes/waterpark/configure.mako b/tailbone/templates/themes/waterpark/configure.mako new file mode 100644 index 00000000..7a3e5261 --- /dev/null +++ b/tailbone/templates/themes/waterpark/configure.mako @@ -0,0 +1,78 @@ +## -*- coding: utf-8; -*- +<%inherit file="wuttaweb:templates/configure.mako" /> +<%namespace name="tailbone_base" file="tailbone:templates/configure.mako" /> + +<%def name="input_file_templates_section()"> + ${tailbone_base.input_file_templates_section()} +</%def> + +<%def name="output_file_templates_section()"> + ${tailbone_base.output_file_templates_section()} +</%def> + +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> + + ############################## + ## input file templates + ############################## + + % if input_file_template_settings is not Undefined: + + ThisPageData.inputFileTemplateSettings = ${json.dumps(input_file_template_settings)|n} + ThisPageData.inputFileTemplateFileOptions = ${json.dumps(input_file_options)|n} + ThisPageData.inputFileTemplateUploads = { + % for key in input_file_templates: + '${key}': null, + % endfor + } + + ThisPage.methods.validateInputFileTemplateSettings = function() { + % for tmpl in input_file_templates.values(): + if (this.inputFileTemplateSettings['${tmpl['setting_mode']}'] == 'hosted') { + if (!this.inputFileTemplateSettings['${tmpl['setting_file']}']) { + if (!this.inputFileTemplateUploads['${tmpl['key']}']) { + return "You must provide a file to upload for the ${tmpl['label']} template." + } + } + } + % endfor + } + + ThisPageData.validators.push(ThisPage.methods.validateInputFileTemplateSettings) + + % endif + + ############################## + ## output file templates + ############################## + + % if output_file_template_settings is not Undefined: + + ThisPageData.outputFileTemplateSettings = ${json.dumps(output_file_template_settings)|n} + ThisPageData.outputFileTemplateFileOptions = ${json.dumps(output_file_options)|n} + ThisPageData.outputFileTemplateUploads = { + % for key in output_file_templates: + '${key}': null, + % endfor + } + + ThisPage.methods.validateOutputFileTemplateSettings = function() { + % for tmpl in output_file_templates.values(): + if (this.outputFileTemplateSettings['${tmpl['setting_mode']}'] == 'hosted') { + if (!this.outputFileTemplateSettings['${tmpl['setting_file']}']) { + if (!this.outputFileTemplateUploads['${tmpl['key']}']) { + return "You must provide a file to upload for the ${tmpl['label']} template." + } + } + } + % endfor + } + + ThisPageData.validators.push(ThisPage.methods.validateOutputFileTemplateSettings) + + % endif + + </script> +</%def> diff --git a/tailbone/templates/themes/waterpark/form.mako b/tailbone/templates/themes/waterpark/form.mako new file mode 100644 index 00000000..f88d6821 --- /dev/null +++ b/tailbone/templates/themes/waterpark/form.mako @@ -0,0 +1,10 @@ +## -*- coding: utf-8; -*- +<%inherit file="wuttaweb:templates/form.mako" /> + +<%def name="render_vue_template_form()"> + % if form is not Undefined: + ${form.render_vue_template(buttons=capture(self.render_form_buttons))} + % endif +</%def> + +<%def name="render_form_buttons()"></%def> diff --git a/tailbone/templates/themes/waterpark/master/configure.mako b/tailbone/templates/themes/waterpark/master/configure.mako new file mode 100644 index 00000000..51da5b0a --- /dev/null +++ b/tailbone/templates/themes/waterpark/master/configure.mako @@ -0,0 +1,2 @@ +## -*- coding: utf-8; -*- +<%inherit file="wuttaweb:templates/master/configure.mako" /> diff --git a/tailbone/templates/themes/waterpark/master/create.mako b/tailbone/templates/themes/waterpark/master/create.mako new file mode 100644 index 00000000..23399b9e --- /dev/null +++ b/tailbone/templates/themes/waterpark/master/create.mako @@ -0,0 +1,2 @@ +## -*- coding: utf-8; -*- +<%inherit file="wuttaweb:templates/master/create.mako" /> diff --git a/tailbone/templates/themes/waterpark/master/delete.mako b/tailbone/templates/themes/waterpark/master/delete.mako new file mode 100644 index 00000000..a15dfaf8 --- /dev/null +++ b/tailbone/templates/themes/waterpark/master/delete.mako @@ -0,0 +1,46 @@ +## -*- coding: utf-8; -*- +<%inherit file="tailbone:templates/form.mako" /> + +<%def name="title()">Delete ${model_title}: ${instance_title}</%def> + +<%def name="render_form()"> + <br /> + <b-notification type="is-danger" :closable="false"> + You are about to delete the following ${model_title} and all associated data: + </b-notification> + ${parent.render_form()} +</%def> + +<%def name="render_form_buttons()"> + <br /> + <b-notification type="is-danger" :closable="false"> + Are you sure about this? + </b-notification> + <br /> + + ${h.form(request.current_route_url(), **{'@submit': 'submitForm'})} + ${h.csrf_token(request)} + <div class="buttons"> + <wutta-button once tag="a" href="${form.cancel_url}" + label="Whoops, nevermind..." /> + <b-button type="is-primary is-danger" + native-type="submit" + :disabled="formSubmitting"> + {{ formSubmitting ? "Working, please wait..." : "${form.button_label_submit}" }} + </b-button> + </div> + ${h.end_form()} +</%def> + +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> + + ${form.vue_component}Data.formSubmitting = false + + ${form.vue_component}.methods.submitForm = function() { + this.formSubmitting = true + } + + </script> +</%def> diff --git a/tailbone/templates/themes/waterpark/master/edit.mako b/tailbone/templates/themes/waterpark/master/edit.mako new file mode 100644 index 00000000..18a2fa2f --- /dev/null +++ b/tailbone/templates/themes/waterpark/master/edit.mako @@ -0,0 +1,2 @@ +## -*- coding: utf-8; -*- +<%inherit file="wuttaweb:templates/master/edit.mako" /> diff --git a/tailbone/templates/themes/waterpark/master/form.mako b/tailbone/templates/themes/waterpark/master/form.mako new file mode 100644 index 00000000..db56843b --- /dev/null +++ b/tailbone/templates/themes/waterpark/master/form.mako @@ -0,0 +1,2 @@ +## -*- coding: utf-8; -*- +<%inherit file="wuttaweb:templates/master/form.mako" /> diff --git a/tailbone/templates/themes/waterpark/master/index.mako b/tailbone/templates/themes/waterpark/master/index.mako new file mode 100644 index 00000000..e6702599 --- /dev/null +++ b/tailbone/templates/themes/waterpark/master/index.mako @@ -0,0 +1,299 @@ +## -*- coding: utf-8; -*- +<%inherit file="wuttaweb:templates/master/index.mako" /> + +<%def name="grid_tools()"> + + ## grid totals + % if getattr(master, 'supports_grid_totals', False): + <div style="display: flex; align-items: center;"> + <b-button v-if="gridTotalsDisplay == null" + :disabled="gridTotalsFetching" + @click="gridTotalsFetch()"> + {{ gridTotalsFetching ? "Working, please wait..." : "Show Totals" }} + </b-button> + <div v-if="gridTotalsDisplay != null" + class="control"> + Totals: {{ gridTotalsDisplay }} + </div> + </div> + % endif + + ## download search results + % if getattr(master, 'results_downloadable', False) and master.has_perm('download_results'): + <div> + <b-button type="is-primary" + icon-pack="fas" + icon-left="download" + @click="showDownloadResultsDialog = true" + :disabled="!total"> + Download Results + </b-button> + + ${h.form(url('{}.download_results'.format(route_prefix)), ref='download_results_form')} + ${h.csrf_token(request)} + <input type="hidden" name="fmt" :value="downloadResultsFormat" /> + <input type="hidden" name="fields" :value="downloadResultsFieldsIncluded" /> + ${h.end_form()} + + <b-modal :active.sync="showDownloadResultsDialog"> + <div class="card"> + + <div class="card-content"> + <p> + There are + <span class="is-size-4 has-text-weight-bold"> + {{ total.toLocaleString('en') }} ${model_title_plural} + </span> + matching your current filters. + </p> + <p> + You may download this set as a single data file if you like. + </p> + <br /> + + <b-notification type="is-warning" :closable="false" + v-if="downloadResultsFormat == 'xlsx' && total >= 1000"> + Excel downloads for large data sets can take a long time to + generate, and bog down the server in the meantime. You are + encouraged to choose CSV for a large data set, even though + the end result (file size) may be larger with CSV. + </b-notification> + + <div style="display: flex; justify-content: space-between"> + + <div> + <b-field label="Format"> + <b-select v-model="downloadResultsFormat"> + % for key, label in master.download_results_supported_formats().items(): + <option value="${key}">${label}</option> + % endfor + </b-select> + </b-field> + </div> + + <div> + + <div v-show="downloadResultsFieldsMode != 'choose'" + class="has-text-right"> + <p v-if="downloadResultsFieldsMode == 'default'"> + Will use DEFAULT fields. + </p> + <p v-if="downloadResultsFieldsMode == 'all'"> + Will use ALL fields. + </p> + <br /> + </div> + + <div class="buttons is-right"> + <b-button type="is-primary" + v-show="downloadResultsFieldsMode != 'default'" + @click="downloadResultsUseDefaultFields()"> + Use Default Fields + </b-button> + <b-button type="is-primary" + v-show="downloadResultsFieldsMode != 'all'" + @click="downloadResultsUseAllFields()"> + Use All Fields + </b-button> + <b-button type="is-primary" + v-show="downloadResultsFieldsMode != 'choose'" + @click="downloadResultsFieldsMode = 'choose'"> + Choose Fields + </b-button> + </div> + + <div v-show="downloadResultsFieldsMode == 'choose'"> + <div style="display: flex;"> + <div> + <b-field label="Excluded Fields"> + <b-select multiple native-size="8" + expanded + v-model="downloadResultsExcludedFieldsSelected" + ref="downloadResultsExcludedFields"> + <option v-for="field in downloadResultsFieldsExcluded" + :key="field" + :value="field"> + {{ field }} + </option> + </b-select> + </b-field> + </div> + <div> + <br /><br /> + <b-button style="margin: 0.5rem;" + @click="downloadResultsExcludeFields()"> + < + </b-button> + <br /> + <b-button style="margin: 0.5rem;" + @click="downloadResultsIncludeFields()"> + > + </b-button> + </div> + <div> + <b-field label="Included Fields"> + <b-select multiple native-size="8" + expanded + v-model="downloadResultsIncludedFieldsSelected" + ref="downloadResultsIncludedFields"> + <option v-for="field in downloadResultsFieldsIncluded" + :key="field" + :value="field"> + {{ field }} + </option> + </b-select> + </b-field> + </div> + </div> + </div> + + </div> + </div> + </div> <!-- card-content --> + + <footer class="modal-card-foot"> + <b-button @click="showDownloadResultsDialog = false"> + Cancel + </b-button> + <once-button type="is-primary" + @click="downloadResultsSubmit()" + icon-pack="fas" + icon-left="download" + :disabled="!downloadResultsFieldsIncluded.length" + text="Download Results"> + </once-button> + </footer> + </div> + </b-modal> + </div> + % endif + + ## download rows for search results + % if getattr(master, 'has_rows', False) and master.results_rows_downloadable and master.has_perm('download_results_rows'): + <b-button type="is-primary" + icon-pack="fas" + icon-left="download" + @click="downloadResultsRows()" + :disabled="downloadResultsRowsButtonDisabled"> + {{ downloadResultsRowsButtonText }} + </b-button> + ${h.form(url('{}.download_results_rows'.format(route_prefix)), ref='downloadResultsRowsForm')} + ${h.csrf_token(request)} + ${h.end_form()} + % endif + + ## merge 2 objects + % if getattr(master, 'mergeable', False) and request.has_perm('{}.merge'.format(permission_prefix)): + + ${h.form(url('{}.merge'.format(route_prefix)), class_='control', **{'@submit': 'submitMergeForm'})} + ${h.csrf_token(request)} + <input type="hidden" + name="uuids" + :value="checkedRowUUIDs()" /> + <b-button type="is-primary" + native-type="submit" + icon-pack="fas" + icon-left="object-ungroup" + :disabled="mergeFormSubmitting || checkedRows.length != 2"> + {{ mergeFormButtonText }} + </b-button> + ${h.end_form()} + % endif + + ## enable / disable selected objects + % if getattr(master, 'supports_set_enabled_toggle', False) and master.has_perm('enable_disable_set'): + + ${h.form(url('{}.enable_set'.format(route_prefix)), class_='control', ref='enable_selected_form')} + ${h.csrf_token(request)} + ${h.hidden('uuids', v_model='selected_uuids')} + <b-button :disabled="enableSelectedDisabled" + @click="enableSelectedSubmit()"> + {{ enableSelectedText }} + </b-button> + ${h.end_form()} + + ${h.form(url('{}.disable_set'.format(route_prefix)), ref='disable_selected_form', class_='control')} + ${h.csrf_token(request)} + ${h.hidden('uuids', v_model='selected_uuids')} + <b-button :disabled="disableSelectedDisabled" + @click="disableSelectedSubmit()"> + {{ disableSelectedText }} + </b-button> + ${h.end_form()} + % endif + + ## delete selected objects + % if getattr(master, 'set_deletable', False) and master.has_perm('delete_set'): + ${h.form(url('{}.delete_set'.format(route_prefix)), ref='delete_selected_form', class_='control')} + ${h.csrf_token(request)} + ${h.hidden('uuids', v_model='selected_uuids')} + <b-button type="is-danger" + :disabled="deleteSelectedDisabled" + @click="deleteSelectedSubmit()" + icon-pack="fas" + icon-left="trash"> + {{ deleteSelectedText }} + </b-button> + ${h.end_form()} + % endif + + ## delete search results + % if getattr(master, 'bulk_deletable', False) and request.has_perm('{}.bulk_delete'.format(permission_prefix)): + ${h.form(url('{}.bulk_delete'.format(route_prefix)), ref='delete_results_form', class_='control')} + ${h.csrf_token(request)} + <b-button type="is-danger" + :disabled="deleteResultsDisabled" + :title="total ? null : 'There are no results to delete'" + @click="deleteResultsSubmit()" + icon-pack="fas" + icon-left="trash"> + {{ deleteResultsText }} + </b-button> + ${h.end_form()} + % endif + +</%def> + +## DEPRECATED; remains for back-compat +<%def name="render_this_page()"> + ${self.page_content()} +</%def> + +<%def name="render_vue_template_grid()"> + ${grid.render_vue_template(tools=capture(self.grid_tools).strip(), context_menu=capture(self.context_menu_items).strip())} +</%def> + +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> + + % if getattr(master, 'bulk_deletable', False) and master.has_perm('bulk_delete'): + + ${grid.vue_component}Data.deleteResultsSubmitting = false + ${grid.vue_component}Data.deleteResultsText = "Delete Results" + + ${grid.vue_component}.computed.deleteResultsDisabled = function() { + if (this.deleteResultsSubmitting) { + return true + } + if (!this.total) { + return true + } + return false + } + + ${grid.vue_component}.methods.deleteResultsSubmit = function() { + // TODO: show "plural model title" here? + if (!confirm("You are about to delete " + this.total.toLocaleString('en') + " objects.\n\nAre you sure?")) { + return + } + + this.deleteResultsSubmitting = true + this.deleteResultsText = "Working, please wait..." + this.$refs.delete_results_form.submit() + } + + % endif + + </script> +</%def> diff --git a/tailbone/templates/themes/waterpark/master/view.mako b/tailbone/templates/themes/waterpark/master/view.mako new file mode 100644 index 00000000..99194469 --- /dev/null +++ b/tailbone/templates/themes/waterpark/master/view.mako @@ -0,0 +1,2 @@ +## -*- coding: utf-8; -*- +<%inherit file="wuttaweb:templates/master/view.mako" /> diff --git a/tailbone/templates/themes/waterpark/page.mako b/tailbone/templates/themes/waterpark/page.mako new file mode 100644 index 00000000..66ce47dc --- /dev/null +++ b/tailbone/templates/themes/waterpark/page.mako @@ -0,0 +1,48 @@ +## -*- coding: utf-8; -*- +<%inherit file="wuttaweb:templates/page.mako" /> + +<%def name="render_vue_template_this_page()"> + <script type="text/x-template" id="this-page-template"> + <div style="height: 100%;"> + ## DEPRECATED; called for back-compat + ${self.render_this_page()} + </div> + </script> +</%def> + +## DEPRECATED; remains for back-compat +<%def name="render_this_page()"> + <div style="display: flex;"> + + <div class="this-page-content" style="flex-grow: 1;"> + ${self.page_content()} + </div> + + ## DEPRECATED; remains for back-compat + <ul id="context-menu"> + ${self.context_menu_items()} + </ul> + </div> +</%def> + +## DEPRECATED; remains for back-compat +<%def name="context_menu_items()"> + % if context_menu_list_items is not Undefined: + % for item in context_menu_list_items: + <li>${item}</li> + % endfor + % endif +</%def> + +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> + + ThisPageData.csrftoken = ${json.dumps(h.get_csrf_token(request))|n} + + % if can_edit_help: + ThisPage.props.configureFieldsHelp = Boolean + % endif + + </script> +</%def> diff --git a/tailbone/templates/trainwreck/transactions/configure.mako b/tailbone/templates/trainwreck/transactions/configure.mako index 7cf03165..10c57e18 100644 --- a/tailbone/templates/trainwreck/transactions/configure.mako +++ b/tailbone/templates/trainwreck/transactions/configure.mako @@ -3,13 +3,57 @@ <%def name="form_content()"> + <h3 class="block is-size-3">Display</h3> + <div class="block" style="padding-left: 2rem;"> + + <b-field> + <b-checkbox name="tailbone.trainwreck.view_txn.autocollapse_header" + v-model="simpleSettings['tailbone.trainwreck.view_txn.autocollapse_header']" + native-value="true" + @input="settingsNeedSaved = true"> + Auto-collapse header when viewing transaction + </b-checkbox> + </b-field> + </div> + + <h3 class="block is-size-3">Rotation</h3> + <div class="block" style="padding-left: 2rem;"> + + <b-field message="There is only one Trainwreck DB, unless rotation is used."> + <b-checkbox name="trainwreck.use_rotation" + v-model="simpleSettings['trainwreck.use_rotation']" + native-value="true" + @input="settingsNeedSaved = true"> + Rotate Databases + </b-checkbox> + </b-field> + + <b-field grouped> + <b-field label="Current Years" + message="How many years (max) to keep in "current" DB. Default is 2 if not set."> + <b-input name="trainwreck.current_years" + v-model="simpleSettings['trainwreck.current_years']" + @input="settingsNeedSaved = true"> + </b-input> + </b-field> + </b-field> + + </div> + <h3 class="block is-size-3">Hidden Databases</h3> <div class="block" style="padding-left: 2rem;"> - % for key, engine in six.iteritems(trainwreck_engines): + <p class="block"> + The selected DBs will be hidden from the DB picker when viewing + Trainwreck data. + </p> + % for key, engine in trainwreck_engines.items(): <b-field> <b-checkbox name="hidedb_${key}" v-model="hiddenDatabases['${key}']" native-value="true" + % if key == 'default': + disabled + % endif @input="settingsNeedSaved = true"> ${key} </b-checkbox> @@ -18,14 +62,9 @@ </div> </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> - +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> ThisPageData.hiddenDatabases = ${json.dumps(hidden_databases)|n} - </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/trainwreck/transactions/rollover.mako b/tailbone/templates/trainwreck/transactions/rollover.mako index 6d6e0b17..f26515b5 100644 --- a/tailbone/templates/trainwreck/transactions/rollover.mako +++ b/tailbone/templates/trainwreck/transactions/rollover.mako @@ -8,7 +8,7 @@ <%def name="page_content()"> <br /> - % if six.text_type(next_year) not in trainwreck_engines: + % if str(next_year) not in trainwreck_engines: <b-notification type="is-warning"> You do not have a database configured for next year (${next_year}). You should be sure to configure it before next year rolls around. @@ -20,38 +20,37 @@ </p> <b-table :data="engines"> - <template slot-scope="props"> - <b-table-column field="key" label="DB Key"> - {{ props.row.key }} - </b-table-column> - <b-table-column field="oldest_date" label="Oldest Date"> - <span v-if="props.row.error" class="has-text-danger"> - error - </span> - <span v-if="!props.row.error"> - {{ props.row.oldest_date }} - </span> - </b-table-column> - <b-table-column field="newest_date" label="Newest Date"> - <span v-if="props.row.error" class="has-text-danger"> - error - </span> - <span v-if="!props.row.error"> - {{ props.row.newest_date }} - </span> - </b-table-column> - </template> + <b-table-column field="key" + label="DB Key" + v-slot="props"> + {{ props.row.key }} + </b-table-column> + <b-table-column field="oldest_date" + label="Oldest Date" + v-slot="props"> + <span v-if="props.row.error" class="has-text-danger"> + error + </span> + <span v-if="!props.row.error"> + {{ props.row.oldest_date }} + </span> + </b-table-column> + <b-table-column field="newest_date" + label="Newest Date" + v-slot="props"> + <span v-if="props.row.error" class="has-text-danger"> + error + </span> + <span v-if="!props.row.error"> + {{ props.row.newest_date }} + </span> + </b-table-column> </b-table> </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> - +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> ThisPageData.engines = ${json.dumps(engines_data)|n} - </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/trainwreck/transactions/view.mako b/tailbone/templates/trainwreck/transactions/view.mako index 2be51c7d..630950cf 100644 --- a/tailbone/templates/trainwreck/transactions/view.mako +++ b/tailbone/templates/trainwreck/transactions/view.mako @@ -1,15 +1,11 @@ ## -*- coding: utf-8; -*- <%inherit file="/master/view.mako" /> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> - +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> % if custorder_xref_markers_data is not Undefined: - ${form.component_studly}Data.custorderXrefMarkersData = ${json.dumps(custorder_xref_markers_data)|n} + ${form.vue_component}Data.custorderXrefMarkersData = ${json.dumps(custorder_xref_markers_data)|n} % endif - </script> </%def> - -${parent.body()} diff --git a/tailbone/templates/trainwreck/transactions/view_row.mako b/tailbone/templates/trainwreck/transactions/view_row.mako index 9abcb8ba..2507492e 100644 --- a/tailbone/templates/trainwreck/transactions/view_row.mako +++ b/tailbone/templates/trainwreck/transactions/view_row.mako @@ -1,16 +1,11 @@ ## -*- coding: utf-8; -*- <%inherit file="/master/view_row.mako" /> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> - +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> % if discounts_data is not Undefined: - ${form.component_studly}Data.discountsData = ${json.dumps(discounts_data)|n} + ${form.vue_component}Data.discountsData = ${json.dumps(discounts_data)|n} % endif - </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/units-of-measure/index.mako b/tailbone/templates/units-of-measure/index.mako index fb3a3219..4815fc79 100644 --- a/tailbone/templates/units-of-measure/index.mako +++ b/tailbone/templates/units-of-measure/index.mako @@ -7,7 +7,7 @@ % if master.has_perm('collect_wild_uoms'): <b-button type="is-primary" icon-pack="fas" - icon-left="fas fa-shopping-basket" + icon-left="shopping-basket" @click="showingCollectWildDialog = true"> Collect from the Wild </b-button> @@ -51,20 +51,17 @@ % endif </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} % if master.has_perm('collect_wild_uoms'): - <script type="text/javascript"> + <script> - TailboneGridData.showingCollectWildDialog = false + ${grid.vue_component}Data.showingCollectWildDialog = false - TailboneGrid.methods.collectFromWild = function() { - this.$refs['collect-wild-uoms-form'].submit() - } + ${grid.vue_component}.methods.collectFromWild = function() { + this.$refs['collect-wild-uoms-form'].submit() + } - </script> + </script> % endif </%def> - - -${parent.body()} diff --git a/tailbone/templates/upgrade.mako b/tailbone/templates/upgrade.mako index a12361c5..7cc73941 100644 --- a/tailbone/templates/upgrade.mako +++ b/tailbone/templates/upgrade.mako @@ -1,86 +1,69 @@ ## -*- coding: utf-8; -*- <%inherit file="/progress.mako" /> -<%def name="update_progress_func()"> - <script type="text/javascript"> - - function update_progress() { - $.ajax({ - url: '${url('upgrades.execute_progress', uuid=instance.uuid)}', - success: function(data) { - if (data.error) { - location.href = '${cancel_url}'; - } else { - - if (data.stdout) { - var stdout = $('.stdout'); - var height = $(window).height() - stdout.offset().top - 50; - stdout.height(height); - stdout.append(data.stdout); - stdout.animate({scrollTop: stdout.get(0).scrollHeight - height}, 250); - } - - if (data.complete || data.maximum) { - $('#message').html(data.message); - $('#total').html('('+data.maximum_display+' total)'); - $('#cancel button').show(); - if (data.complete) { - stillInProgress = false; - $('#cancel button').hide(); - $('#total').html('done!'); - $('#complete').css('width', '100%'); - $('#remaining').hide(); - $('#percentage').html('100 %'); - location.href = data.success_url; - } else { - var width = parseInt(data.value) / parseInt(data.maximum); - width = Math.round(100 * width); - if (width) { - $('#complete').css('width', width+'%'); - $('#percentage').html(width+' %'); - } else { - $('#complete').css('width', '0.01%'); - $('#percentage').html('0 %'); - } - $('#remaining').css('width', 'auto'); - } - } - - if (stillInProgress) { - // fetch progress data again, in one second from now - setTimeout(function() { - update_progress(); - }, 1000); - } - } - } - }); - } - </script> -</%def> - <%def name="extra_styles()"> ${parent.extra_styles()} <style type="text/css"> - #wrapper { - top: 6em; - } - .stdout { + + .progress-with-textout { border: 1px solid Black; - height: 500px; - margin-left: 4.5%; + line-height: 1.2; + margin-top: 1rem; overflow: auto; - padding: 4px; - position: absolute; - top: 10em; - white-space: nowrap; - width: 90%; + padding: 1rem; } + </style> </%def> <%def name="after_progress()"> - <div class="stdout"></div> + <!-- <div ref="stdout" class="stdout"></div> --> + + <div ref="textout" + class="progress-with-textout is-family-monospace is-size-7"> + <span v-for="line in progressOutput" + :key="line.key" + v-html="line.text"> + </span> + + ## nb. we auto-scroll down to "see" this element + <div ref="seeme"></div> + </div> + </%def> +<%def name="modify_whole_page_vars()"> + <script type="text/javascript"> + + WholePageData.progressURL = '${url('upgrades.execute_progress', uuid=instance.uuid)}' + WholePageData.progressOutput = [] + WholePageData.progressOutputCounter = 0 + + WholePage.methods.mountedCustom = function() { + + // grow the textout area to fill most of screen + let textout = this.$refs.textout + let height = window.innerHeight - textout.offsetTop - 100 + textout.style.height = height + 'px' + } + + WholePage.methods.updateProgressCustom = function(response) { + if (response.data.stdout) { + + // add lines to textout area + this.progressOutput.push({ + key: ++this.progressOutputCounter, + text: response.data.stdout}) + + // scroll down to end of textout area + this.$nextTick(() => { + this.$refs.seeme.scrollIntoView({behavior: 'smooth'}) + }) + } + } + + </script> +</%def> + + ${parent.body()} diff --git a/tailbone/templates/upgrades/configure.mako b/tailbone/templates/upgrades/configure.mako index cde81b9e..9439f830 100644 --- a/tailbone/templates/upgrades/configure.mako +++ b/tailbone/templates/upgrades/configure.mako @@ -7,41 +7,51 @@ <h3 class="is-size-3">Upgradable Systems</h3> <div class="block" style="padding-left: 2rem; display: flex;"> - <b-table :data="upgradeSystems" + <${b}-table :data="upgradeSystems" sortable> - <template slot-scope="props"> - <b-table-column field="key" - label="Key" - sortable> - {{ props.row.key }} - </b-table-column> - <b-table-column field="label" - label="Label" - sortable> - {{ props.row.label }} - </b-table-column> - <b-table-column field="command" - label="Command" - sortable> - {{ props.row.command }} - </b-table-column> - <b-table-column label="Actions"> - <a href="#" - @click.prevent="upgradeSystemEdit(props.row)"> - <i class="fas fa-edit"></i> - Edit - </a> - - <a href="#" - v-if="props.row.key != 'rattail'" - class="has-text-danger" - @click.prevent="updateSystemDelete(props.row)"> - <i class="fas fa-trash"></i> - Delete - </a> - </b-table-column> - </template> - </b-table> + <${b}-table-column field="key" + label="Key" + v-slot="props" + sortable> + {{ props.row.key }} + </${b}-table-column> + <${b}-table-column field="label" + label="Label" + v-slot="props" + sortable> + {{ props.row.label }} + </${b}-table-column> + <${b}-table-column field="command" + label="Command" + v-slot="props" + sortable> + {{ props.row.command }} + </${b}-table-column> + <${b}-table-column label="Actions" + v-slot="props"> + <a href="#" + @click.prevent="upgradeSystemEdit(props.row)"> + % if request.use_oruga: + <o-icon icon="edit" /> + % else: + <i class="fas fa-edit"></i> + % endif + Edit + </a> + + <a href="#" + v-if="props.row.key != 'rattail'" + class="has-text-danger" + @click.prevent="updateSystemDelete(props.row)"> + % if request.use_oruga: + <o-icon icon="trash" /> + % else: + <i class="fas fa-trash"></i> + % endif + Delete + </a> + </${b}-table-column> + </${b}-table> <div style="margin-left: 1rem;"> <b-button type="is-primary" @@ -64,21 +74,21 @@ :type="upgradeSystemKey ? null : 'is-danger'"> <b-input v-model.trim="upgradeSystemKey" ref="upgradeSystemKey" - :disabled="upgradeSystemKey == 'rattail'"> - </b-input> + :disabled="upgradeSystemKey == 'rattail'" + expanded /> </b-field> <b-field label="Label" :type="upgradeSystemLabel ? null : 'is-danger'"> <b-input v-model.trim="upgradeSystemLabel" ref="upgradeSystemLabel" - :disabled="upgradeSystemKey == 'rattail'"> - </b-input> + :disabled="upgradeSystemKey == 'rattail'" + expanded /> </b-field> <b-field label="Command" :type="upgradeSystemCommand ? null : 'is-danger'"> <b-input v-model.trim="upgradeSystemCommand" - ref="upgradeSystemCommand"> - </b-input> + ref="upgradeSystemCommand" + expanded /> </b-field> </section> @@ -101,9 +111,9 @@ </div> </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> ThisPageData.upgradeSystems = ${json.dumps(upgrade_systems)|n} ThisPageData.upgradeSystemShowDialog = false @@ -151,6 +161,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/upgrades/view.mako b/tailbone/templates/upgrades/view.mako index c6ae11f2..c3fca81d 100644 --- a/tailbone/templates/upgrades/view.mako +++ b/tailbone/templates/upgrades/view.mako @@ -1,43 +1,6 @@ ## -*- coding: utf-8; -*- <%inherit file="/master/view.mako" /> -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - % if not use_buefy: - <script type="text/javascript"> - - function show_packages(type) { - if (type == 'all') { - $('.showing .diffs').css('font-weight', 'normal'); - $('table.diff tbody tr').show(); - $('.showing .all').css('font-weight', 'bold'); - } else if (type == 'diffs') { - $('.showing .all').css('font-weight', 'normal'); - $('table.diff tbody tr:not(.diff)').hide(); - $('.showing .diffs').css('font-weight', 'bold'); - } - } - - $(function() { - - show_packages('diffs'); - - $('.showing .all').click(function() { - show_packages('all'); - return false; - }); - - $('.showing .diffs').click(function() { - show_packages('diffs') - return false; - }); - - }); - - </script> - % endif -</%def> - <%def name="extra_styles()"> ${parent.extra_styles()} % if master.has_perm('execute'): @@ -56,9 +19,15 @@ ${parent.render_this_page()} % if expose_websockets and master.has_perm('execute'): - <b-modal :active.sync="upgradeExecuting" - full-screen - :can-cancel="false"> + <${b}-modal full-screen + % if request.use_oruga: + v-model:active="upgradeExecuting" + :cancelable="false" + % else: + :active.sync="upgradeExecuting" + :can-cancel="false" + % endif + > <div class="card"> <div class="card-content"> @@ -66,9 +35,13 @@ <div class="level-item has-text-centered" style="display: flex; flex-direction: column;"> <p class="block"> - Upgrading (please wait) ... + Upgrading ${system_title} (please wait) ... {{ executeUpgradeComplete ? "DONE!" : "" }} </p> + % if request.use_oruga: + <progress class="progress is-large" + style="width: 400px;" /> + % else: <b-progress size="is-large" style="width: 400px;" ## :value="80" @@ -76,6 +49,7 @@ ## format="percent" > </b-progress> + % endif </div> <div class="level-right"> <div class="level-item"> @@ -102,7 +76,7 @@ </div> </div> - </b-modal> + </${b}-modal> % endif % if master.has_perm('execute'): @@ -112,16 +86,18 @@ % endif </%def> -<%def name="render_buefy_form()"> +<%def name="render_form()"> <div class="form"> <${form.component} - % if expose_websockets and master.has_perm('execute'): + % if master.has_perm('execute'): + @declare-failure-click="declareFailureClick" + :declare-failure-submitting="declareFailureSubmitting" + % if expose_websockets: % if instance_executable: @execute-upgrade-click="executeUpgrade" % endif :upgrade-executing="upgradeExecuting" - @declare-failure-click="declareFailureClick" - :declare-failure-submitting="declareFailureSubmitting" + % endif % endif > </${form.component}> @@ -132,7 +108,7 @@ % if instance_executable and master.has_perm('execute'): <div class="buttons"> % if instance.enabled and not instance.executing: - % if use_buefy and expose_websockets: + % if expose_websockets: <b-button type="is-primary" icon-pack="fas" icon-left="arrow-circle-right" @@ -140,7 +116,7 @@ @click="$emit('execute-upgrade-click')"> {{ upgradeExecuting ? "Working, please wait..." : "Execute this upgrade" }} </b-button> - % elif use_buefy: + % else: ${h.form(url('{}.execute'.format(route_prefix), uuid=instance.uuid), **{'@submit': 'submitForm'})} ${h.csrf_token(request)} <b-button type="is-primary" @@ -151,11 +127,6 @@ {{ formSubmitting ? "Working, please wait..." : "Execute this upgrade" }} </b-button> ${h.end_form()} - % else: - ${h.form(url('{}.execute'.format(route_prefix), uuid=instance.uuid), class_='autodisable')} - ${h.csrf_token(request)} - ${h.submit('execute', "Execute this upgrade", class_='button is-primary')} - ${h.end_form()} % endif % elif instance.enabled: <button type="button" class="button is-primary" disabled="disabled" title="This upgrade is currently executing">Execute this upgrade</button> @@ -166,11 +137,11 @@ % endif </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> - TailboneFormData.showingPackages = 'diffs' + ${form.vue_component}Data.showingPackages = 'diffs' % if master.has_perm('execute'): @@ -182,7 +153,7 @@ // execute upgrade ////////////////////////////// - TailboneForm.props.upgradeExecuting = { + ${form.vue_component}.props.upgradeExecuting = { type: Boolean, default: false, } @@ -202,6 +173,7 @@ ThisPage.methods.showExecuteDialog = function() { this.upgradeExecuting = true + document.title = "Upgrading ${system_title} ..." this.$nextTick(() => { this.adjustTextoutHeight() }) @@ -210,8 +182,8 @@ ThisPage.methods.establishWebsocket = function() { ## TODO: should be a cleaner way to get this url? - url = '${request.route_url('ws.upgrades.execution_progress', _query={'uuid': instance.uuid})}' - url = url.replace(/^https?:/, 'wss:') + let url = '${url('ws.upgrades.execution_progress', _query={'uuid': instance.uuid})}' + url = url.replace(/^http(s?):/, 'ws$1:') this.ws = new WebSocket(url) @@ -281,9 +253,9 @@ // execute upgrade ////////////////////////////// - TailboneFormData.formSubmitting = false + ${form.vue_component}Data.formSubmitting = false - TailboneForm.methods.submitForm = function() { + ${form.vue_component}.methods.submitForm = function() { this.formSubmitting = true } @@ -293,12 +265,12 @@ // declare failure ////////////////////////////// - TailboneForm.props.declareFailureSubmitting = { + ${form.vue_component}.props.declareFailureSubmitting = { type: Boolean, default: false, } - TailboneForm.methods.declareFailureClick = function() { + ${form.vue_component}.methods.declareFailureClick = function() { this.$emit('declare-failure-click') } @@ -315,6 +287,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/users/find_by_perm.mako b/tailbone/templates/users/find_by_perm.mako deleted file mode 100644 index 59fcf643..00000000 --- a/tailbone/templates/users/find_by_perm.mako +++ /dev/null @@ -1,23 +0,0 @@ -## -*- coding: utf-8 -*- -<%inherit file="/principal/find_by_perm.mako" /> - -<%def name="principal_table()"> - <table> - <thead> - <tr> - <th>Username</th> - <th>Person</th> - </tr> - </thead> - <tbody> - % for user in principals: - <tr> - <td>${h.link_to(user.username, url('users.view', uuid=user.uuid))}</td> - <td>${user.person or ''}</td> - </tr> - % endfor - </tbody> - </table> -</%def> - -${parent.body()} diff --git a/tailbone/templates/users/preferences.mako b/tailbone/templates/users/preferences.mako index a44534dc..ecfdd1c7 100644 --- a/tailbone/templates/users/preferences.mako +++ b/tailbone/templates/users/preferences.mako @@ -27,10 +27,10 @@ <div class="block" style="padding-left: 2rem;"> <b-field label="Theme Style"> - <b-select name="tailbone.${user.uuid}.buefy_css" - v-model="simpleSettings['tailbone.${user.uuid}.buefy_css']" + <b-select name="tailbone.${user.uuid}.user_css" + v-model="simpleSettings['tailbone.${user.uuid}.user_css']" @input="settingsNeedSaved = true"> - <option v-for="option in buefyCSSOptions" + <option v-for="option in themeStyleOptions" :key="option.value" :value="option.value"> {{ option.label }} @@ -42,14 +42,9 @@ </div> </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> - - ThisPageData.buefyCSSOptions = ${json.dumps(buefy_css_options)|n} - +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> + ThisPageData.themeStyleOptions = ${json.dumps(theme_style_options)|n} </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/users/view.mako b/tailbone/templates/users/view.mako index b34902a1..d1afd218 100644 --- a/tailbone/templates/users/view.mako +++ b/tailbone/templates/users/view.mako @@ -14,12 +14,123 @@ % endif </%def> -<%def name="context_menu_items()"> - ${parent.context_menu_items()} - % if master.has_perm('preferences'): - <li>${h.link_to("Edit User Preferences", action_url('preferences', instance))}</li> +<%def name="render_this_page()"> + ${parent.render_this_page()} + + % if master.has_perm('manage_api_tokens'): + + <b-modal :active.sync="apiNewTokenShowDialog" + has-modal-card> + <div class="modal-card"> + <header class="modal-card-head"> + <p class="modal-card-title"> + New API Token + </p> + </header> + <section class="modal-card-body"> + + <div v-if="!apiNewTokenSaved"> + <b-field label="Description" + :type="{'is-danger': !apiNewTokenDescription}"> + <b-input v-model.trim="apiNewTokenDescription" + expanded + ref="apiNewTokenDescription"> + </b-input> + </b-field> + </div> + + <div v-if="apiNewTokenSaved"> + <p class="block"> + Your new API token is shown below. + </p> + <p class="block"> + IMPORTANT: You must record this token elsewhere + for later reference. You will NOT be able to + recover the value if you lose it. + </p> + <b-field horizontal label="API Token"> + {{ apiNewTokenRaw }} + </b-field> + <b-field horizontal label="Description"> + {{ apiNewTokenDescription }} + </b-field> + </div> + + </section> + <footer class="modal-card-foot"> + <b-button @click="apiNewTokenShowDialog = false"> + {{ apiNewTokenSaved ? "Close" : "Cancel" }} + </b-button> + <b-button v-if="!apiNewTokenSaved" + type="is-primary" + icon-pack="fas" + icon-left="save" + @click="apiNewTokenSave()" + :disabled="!apiNewTokenDescription || apiNewTokenSaving"> + Save + </b-button> + </footer> + </div> + </b-modal> + % endif </%def> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + % if master.has_perm('manage_api_tokens'): + <script> -${parent.body()} + ${form.vue_component}.props.apiTokens = null + + ThisPageData.apiTokens = ${json.dumps(api_tokens_data)|n} + + ThisPageData.apiNewTokenShowDialog = false + ThisPageData.apiNewTokenDescription = null + + ThisPage.methods.apiNewToken = function() { + this.apiNewTokenDescription = null + this.apiNewTokenSaved = false + this.apiNewTokenShowDialog = true + this.$nextTick(() => { + this.$refs.apiNewTokenDescription.focus() + }) + } + + ThisPageData.apiNewTokenSaving = false + ThisPageData.apiNewTokenSaved = false + ThisPageData.apiNewTokenRaw = null + + ThisPage.methods.apiNewTokenSave = function() { + this.apiNewTokenSaving = true + + let url = '${master.get_action_url('add_api_token', instance)}' + let params = { + description: this.apiNewTokenDescription, + } + + this.simplePOST(url, params, response => { + this.apiTokens = response.data.tokens + this.apiNewTokenSaving = false + this.apiNewTokenRaw = response.data.raw_token + this.apiNewTokenSaved = true + }, response => { + this.apiNewTokenSaving = false + }) + } + + ThisPage.methods.apiTokenDelete = function(token) { + if (!confirm("Really delete this API token?")) { + return + } + + let url = '${master.get_action_url('delete_api_token', instance)}' + let params = {uuid: token.uuid} + this.simplePOST(url, params, response => { + this.apiTokens = response.data.tokens + }) + } + + </script> + % endif +</%def> diff --git a/tailbone/templates/util.mako b/tailbone/templates/util.mako index 2d4653aa..19a1b89d 100644 --- a/tailbone/templates/util.mako +++ b/tailbone/templates/util.mako @@ -2,43 +2,27 @@ <%def name="view_profile_button(person)"> <div class="buttons"> - % if use_buefy: - <b-button type="is-primary" - tag="a" href="${url('people.view_profile', uuid=person.uuid)}" - icon-pack="fas" - icon-left="user"> - ${person} - </b-button> - % else: - ${h.link_to(person, url('people.view_profile', uuid=person.uuid), class_='button is-primary')} - % endif + <b-button type="is-primary" + tag="a" href="${url('people.view_profile', uuid=person.uuid)}" + icon-pack="fas" + icon-left="user"> + ${person} + </b-button> </div> </%def> <%def name="view_profiles_helper(people)"> % if request.has_perm('people.view_profile'): - % if use_buefy: - <nav class="panel"> - <p class="panel-heading">Profiles</p> - <div class="panel-block"> - <div style="display: flex; flex-direction: column;"> - <p class="block">View full profile for:</p> - % for person in people: - ${view_profile_button(person)} - % endfor - </div> - </div> - </nav> - % else: - <div class="object-helper"> - <h3>Profiles</h3> - <div class="object-helper-content"> - <p>View full profile for:</p> - % for person in people: - ${view_profile_button(person)} - % endfor - </div> + <nav class="panel"> + <p class="panel-heading">Profiles</p> + <div class="panel-block"> + <div style="display: flex; flex-direction: column;"> + <p class="block">View full profile for:</p> + % for person in people: + ${view_profile_button(person)} + % endfor </div> - % endif + </div> + </nav> % endif </%def> diff --git a/tailbone/templates/vendors/configure.mako b/tailbone/templates/vendors/configure.mako index 79dad455..6b135346 100644 --- a/tailbone/templates/vendors/configure.mako +++ b/tailbone/templates/vendors/configure.mako @@ -44,14 +44,9 @@ </div> </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> - +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> ThisPageData.supportedVendorSettings = ${json.dumps(supported_vendor_settings)|n} - </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/views/model/create.mako b/tailbone/templates/views/model/create.mako new file mode 100644 index 00000000..e902fd48 --- /dev/null +++ b/tailbone/templates/views/model/create.mako @@ -0,0 +1,336 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/create.mako" /> + +<%def name="extra_styles()"> + ${parent.extra_styles()} + <style type="text/css"> + .label { + white-space: nowrap; + } + </style> +</%def> + +<%def name="render_this_page()"> + <b-steps v-model="activeStep" + :animated="false" + rounded + :has-navigation="false" + vertical + icon-pack="fas"> + + <b-step-item step="1" + value="enter-details" + label="Enter Details" + clickable> + <h3 class="is-size-3 block"> + Enter Details + </h3> + + <b-field grouped> + + <b-field label="Model Name"> + <b-select v-model="modelName"> + <option v-for="name in modelNames" + :key="name" + :value="name"> + {{ name }} + </option> + </b-select> + </b-field> + + <b-field label="View Class Name"> + <b-input v-model="viewClassName"> + </b-input> + </b-field> + + <b-field label="View Route Prefix"> + <b-input v-model="viewRoutePrefix"> + </b-input> + </b-field> + + </b-field> + + <br /> + + <div class="buttons"> + <b-button type="is-primary" + icon-pack="fas" + icon-left="check" + @click="activeStep = 'write-view'"> + Details are complete + </b-button> + </div> + + </b-step-item> + + <b-step-item step="2" + value="write-view" + label="Write View"> + <h3 class="is-size-3 block"> + Write View + </h3> + + <b-field label="Model Name" horizontal> + {{ modelName }} + </b-field> + + <b-field label="View Class" horizontal> + {{ viewClassName }} + </b-field> + + <b-field horizontal label="File"> + <b-input v-model="viewFile"></b-input> + </b-field> + + <b-field horizontal> + <b-checkbox v-model="viewFileOverwrite"> + Overwrite file if it exists + </b-checkbox> + </b-field> + + <div class="form"> + <div class="buttons"> + <b-button icon-pack="fas" + icon-left="arrow-left" + @click="activeStep = 'enter-details'"> + Back + </b-button> + <b-button type="is-primary" + icon-pack="fas" + icon-left="save" + @click="writeViewFile()" + :disabled="writingViewFile"> + {{ writingViewFile ? "Working, please wait..." : "Write view class to file" }} + </b-button> + <b-button icon-pack="fas" + icon-left="arrow-right" + @click="activeStep = 'review-view'"> + Skip + </b-button> + </div> + </div> + </b-step-item> + + <b-step-item step="3" + value="review-view" + label="Review View" + ## clickable + > + <h3 class="is-size-3 block"> + Review View + </h3> + + <p class="block"> + View code was generated to file: + </p> + + <p class="block is-family-code" style="padding-left: 3rem;"> + {{ viewFile }} + </p> + + <p class="block"> + First, review that code and adjust to your liking. + </p> + + <p class="block"> + Next be sure to include the new view in your config. + Typically this is done by editing the file... + </p> + + <p class="block is-family-code" style="padding-left: 3rem;"> + ${view_dir}__init__.py + </p> + + <p class="block"> + ...and adding a line to the includeme() block such as: + </p> + + <pre class="block"> +def includeme(config): + + # ...existing config includes here... + + ## TODO: stop hard-coding widgets + config.include('${pkgroot}.web.views.widgets') + </pre> + + <p class="block"> + Once you've done all that, the web app must be restarted. + This may happen automatically depending on your setup. + Test the view status below. + </p> + + <div class="card block"> + <header class="card-header"> + <p class="card-header-title"> + View Status + </p> + </header> + <div class="card-content"> + <div class="content"> + <div class="level"> + <div class="level-left"> + + <div class="level-item"> + <span v-if="!viewImportAttempted"> + check not yet attempted + </span> + <span v-if="viewImported" + class="has-text-success has-text-weight-bold"> + route found! + </span> + <span v-if="viewImportAttempted && viewImportProblem" + class="has-text-danger"> + {{ viewImportProblem }} + </span> + </div> + </div> + <div class="level-right"> + <div class="level-item"> + <b-field horizontal label="Route Prefix"> + <b-input v-model="viewRoutePrefix"></b-input> + </b-field> + </div> + <div class="level-item"> + <b-button type="is-primary" + icon-pack="fas" + icon-left="redo" + @click="testView()"> + Test View + </b-button> + </div> + </div> + </div> + </div> + </div> + </div> + + <div class="buttons"> + <b-button icon-pack="fas" + icon-left="arrow-left" + @click="activeStep = 'write-view'"> + Back + </b-button> + <b-button type="is-primary" + icon-pack="fas" + icon-left="check" + @click="activeStep = 'commit-code'" + :disabled="!viewImported"> + View class looks good! + </b-button> + <b-button icon-pack="fas" + icon-left="arrow-right" + @click="activeStep = 'commit-code'"> + Skip + </b-button> + </div> + </b-step-item> + + <b-step-item step="4" + value="commit-code" + label="Commit Code"> + <h3 class="is-size-3 block"> + Commit Code + </h3> + + <p class="block"> + Hope you're having a great day. + </p> + + <p class="block"> + Don't forget to commit code changes to your source repo. + </p> + + <div class="buttons"> + <b-button icon-pack="fas" + icon-left="arrow-left" + @click="activeStep = 'review-view'"> + Back + </b-button> + <once-button type="is-primary" + tag="a" :href="viewURL" + icon-left="arrow-right" + :disabled="!viewURL" + :text="`Show me my new view: ${'$'}{viewClassName}`"> + </once-button> + </div> + </b-step-item> + + </b-steps> +</%def> + +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> + + ThisPageData.activeStep = 'enter-details' + + ThisPageData.modelNames = ${json.dumps(model_names)|n} + ThisPageData.modelName = null + ThisPageData.viewClassName = null + ThisPageData.viewRoutePrefix = null + + ThisPage.watch.modelName = function(newName, oldName) { + this.viewClassName = `${'$'}{newName}View` + this.viewRoutePrefix = newName.toLowerCase() + } + + ThisPage.mounted = function() { + let params = new URLSearchParams(location.search) + if (params.has('model_name')) { + this.modelName = params.get('model_name') + } + } + + ThisPageData.viewFile = '${view_dir}widgets.py' + ThisPageData.viewFileOverwrite = false + ThisPageData.writingViewFile = false + + ThisPage.methods.writeViewFile = function() { + this.writingViewFile = true + + let url = '${url('{}.write_view_file'.format(route_prefix))}' + let params = { + view_file: this.viewFile, + overwrite: this.viewFileOverwrite, + view_class_name: this.viewClassName, + model_name: this.modelName, + route_prefix: this.viewRoutePrefix, + } + this.submitForm(url, params, response => { + this.writingViewFile = false + this.activeStep = 'review-view' + }, response => { + this.writingViewFile = false + }) + } + + ThisPageData.viewImported = false + ThisPageData.viewImportAttempted = false + ThisPageData.viewImportProblem = null + + ThisPage.methods.testView = function() { + + this.viewImported = false + this.viewImportProblem = null + + let url = '${url('{}.check_view'.format(route_prefix))}' + + let params = { + route_prefix: this.viewRoutePrefix, + } + this.submitForm(url, params, response => { + this.viewImportAttempted = true + if (response.data.problem) { + this.viewImportProblem = response.data.problem + } else { + this.viewImported = true + this.viewURL = response.data.url + } + }) + } + + ThisPageData.viewURL = null + + </script> +</%def> diff --git a/tailbone/templates/workorders/view.mako b/tailbone/templates/workorders/view.mako index e631c141..432e011d 100644 --- a/tailbone/templates/workorders/view.mako +++ b/tailbone/templates/workorders/view.mako @@ -24,7 +24,7 @@ ${h.csrf_token(request)} <b-button type="is-primary" icon-pack="fas" - icon-left="fas fa-arrow-right" + icon-left="arrow-right" @click="receive()" :disabled="receiveButtonDisabled"> {{ receiveButtonText }} @@ -41,7 +41,7 @@ ${h.csrf_token(request)} <b-button type="is-primary" icon-pack="fas" - icon-left="fas fa-arrow-right" + icon-left="arrow-right" @click="awaitEstimate()" :disabled="awaitEstimateButtonDisabled"> {{ awaitEstimateButtonText }} @@ -58,7 +58,7 @@ ${h.csrf_token(request)} <b-button type="is-primary" icon-pack="fas" - icon-left="fas fa-arrow-right" + icon-left="arrow-right" @click="awaitParts()" :disabled="awaitPartsButtonDisabled"> {{ awaitPartsButtonText }} @@ -75,7 +75,7 @@ ${h.csrf_token(request)} <b-button type="is-primary" icon-pack="fas" - icon-left="fas fa-arrow-right" + icon-left="arrow-right" @click="workOnIt()" :disabled="workOnItButtonDisabled"> {{ workOnItButtonText }} @@ -92,7 +92,7 @@ ${h.csrf_token(request)} <b-button type="is-primary" icon-pack="fas" - icon-left="fas fa-arrow-right" + icon-left="arrow-right" @click="release()" :disabled="releaseButtonDisabled"> {{ releaseButtonText }} @@ -109,7 +109,7 @@ ${h.csrf_token(request)} <b-button type="is-primary" icon-pack="fas" - icon-left="fas fa-arrow-right" + icon-left="arrow-right" @click="deliver()" :disabled="deliverButtonDisabled"> {{ deliverButtonText }} @@ -132,7 +132,7 @@ ${h.csrf_token(request)} <b-button type="is-warning" icon-pack="fas" - icon-left="fas fa-ban" + icon-left="ban" @click="confirmCancel()" :disabled="cancelButtonDisabled"> {{ cancelButtonText }} @@ -145,9 +145,9 @@ </nav> </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> ThisPageData.receiveButtonDisabled = false ThisPageData.receiveButtonText = "I've received the order from customer" @@ -216,6 +216,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/tweens.py b/tailbone/tweens.py index f944a66f..9c06c1be 100644 --- a/tailbone/tweens.py +++ b/tailbone/tweens.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2018 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,9 +24,6 @@ Tween Factories """ -from __future__ import unicode_literals, absolute_import - -import six from sqlalchemy.exc import OperationalError @@ -64,7 +61,7 @@ def sqlerror_tween_factory(handler, registry): mark_error_retryable(error) raise error else: - raise TransientError(six.text_type(error)) + raise TransientError(str(error)) # if connection was *not* invalid, raise original error raise diff --git a/tailbone/util.py b/tailbone/util.py index 5dee997f..71aa35e3 100644 --- a/tailbone/util.py +++ b/tailbone/util.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,16 +24,14 @@ Utilities """ -from __future__ import unicode_literals, absolute_import - import datetime - -import six -import pytz -import humanize +import importlib import logging +import warnings + +import humanize +import markdown -from rattail.time import timezone, make_utc from rattail.files import resource_path import colander @@ -41,62 +39,97 @@ from pyramid.renderers import get_renderer from pyramid.interfaces import IRoutesMapper from webhelpers2.html import HTML, tags +from wuttaweb.util import (get_form_data as wutta_get_form_data, + get_libver as wutta_get_libver, + get_liburl as wutta_get_liburl, + get_csrf_token as wutta_get_csrf_token, + render_csrf_token) + log = logging.getLogger(__name__) +class SortColumn(object): + """ + Generic representation of a sort column, for use with sorting grid + data as well as with API. + """ + + def __init__(self, field_name, model_name=None): + self.field_name = field_name + self.model_name = model_name + + def get_csrf_token(request): - """ - Convenience function to retrieve the effective CSRF token for the given - request. - """ - token = request.session.get_csrf_token() - if token is None: - token = request.session.new_csrf_token() - return token + """ """ + warnings.warn("tailbone.util.get_csrf_token() is deprecated; " + "please use wuttaweb.util.get_csrf_token() instead", + DeprecationWarning, stacklevel=2) + return wutta_get_csrf_token(request) def csrf_token(request, name='_csrf'): - """ - Convenience function. Returns CSRF hidden tag inside hidden DIV. - """ - token = get_csrf_token(request) - return HTML.tag("div", tags.hidden(name, value=token), style="display:none;") + """ """ + warnings.warn("tailbone.util.csrf_token() is deprecated; " + "please use wuttaweb.util.render_csrf_token() instead", + DeprecationWarning, stacklevel=2) + return render_csrf_token(request, name=name) def get_form_data(request): """ - Returns the effective form data for the given request. Mostly - this is a convenience, to return either POST or JSON depending on - the type of request. + DEPECATED - use :func:`wuttaweb:wuttaweb.util.get_form_data()` + instead. """ - # nb. we prefer JSON only if no POST is present - # TODO: this seems to work for our use case at least, but perhaps - # there is a better way? see also - # https://docs.pylonsproject.org/projects/pyramid/en/latest/api/request.html#pyramid.request.Request.is_xhr - if request.is_xhr and not request.POST: - return request.json_body - return request.POST + warnings.warn("tailbone.util.get_form_data() is deprecated; " + "please use wuttaweb.util.get_form_data() instead", + DeprecationWarning, stacklevel=2) + return wutta_get_form_data(request) -def should_use_buefy(request): +def get_global_search_options(request): """ - Returns a flag indicating whether or not the current theme supports (and - therefore should use) the Buefy JS library. + Returns global search options for current request. Basically a + list of all "index views" minus the ones they aren't allowed to + access. """ - # first check theme-specific setting, if one has been defined - theme = request.registry.settings['tailbone.theme'] - buefy = request.rattail_config.getbool('tailbone', 'themes.{}.use_buefy'.format(theme)) - if buefy is not None: - return buefy + options = [] + pages = sorted(request.registry.settings['tailbone_index_pages'], + key=lambda page: page['label']) + for page in pages: + if not page['permission'] or request.has_perm(page['permission']): + option = dict(page) + option['url'] = request.route_url(page['route']) + options.append(option) + return options - # TODO: should not hard-code this surely, but works for now... - if theme == 'falafel': - return True - # TODO: probably should not use this fallback? it was the first setting - # i tested with, but is poorly named to say the least - return request.rattail_config.getbool('tailbone', 'grids.use_buefy', default=False) +def get_libver(request, key, fallback=True, default_only=False): # pragma: no cover + """ + DEPRECATED - use :func:`wuttaweb:wuttaweb.util.get_libver()` + instead. + """ + warnings.warn("tailbone.util.get_libver() is deprecated; " + "please use wuttaweb.util.get_libver() instead", + DeprecationWarning, stacklevel=2) + + return wutta_get_libver(request, key, prefix='tailbone', + configured_only=not fallback, + default_only=default_only) + + +def get_liburl(request, key, fallback=True): # pragma: no cover + """ + DEPRECATED - use :func:`wuttaweb:wuttaweb.util.get_liburl()` + instead. + """ + warnings.warn("tailbone.util.get_liburl() is deprecated; " + "please use wuttaweb.util.get_liburl() instead", + DeprecationWarning, stacklevel=2) + + return wutta_get_liburl(request, key, prefix='tailbone', + configured_only=not fallback, + default_only=False) def pretty_datetime(config, value): @@ -112,16 +145,18 @@ def pretty_datetime(config, value): if not value: return '' + app = config.get_app() + # Make sure we're dealing with a tz-aware value. If we're given a naive # value, we assume it to be local to the UTC timezone. if not value.tzinfo: - value = pytz.utc.localize(value) + value = app.make_utc(value, tzinfo=True) # Calculate time diff using UTC. - time_ago = datetime.datetime.utcnow() - make_utc(value) + time_ago = datetime.datetime.utcnow() - app.make_utc(value) # Convert value to local timezone. - local = timezone(config) + local = app.get_timezone() value = local.normalize(value.astimezone(local)) return HTML.tag('span', @@ -147,13 +182,13 @@ def raw_datetime(config, value, verbose=False, as_date=False): # Make sure we're dealing with a tz-aware value. If we're given a naive # value, we assume it to be local to the UTC timezone. if not value.tzinfo: - value = pytz.utc.localize(value) + value = app.make_utc(value, tzinfo=True) # Calculate time diff using UTC. - time_ago = datetime.datetime.utcnow() - make_utc(value) + time_ago = datetime.datetime.utcnow() - app.make_utc(value) # Convert value to local timezone. - local = timezone(config) + local = app.get_timezone() value = local.normalize(value.astimezone(local)) kwargs = {} @@ -165,7 +200,7 @@ def raw_datetime(config, value, verbose=False, as_date=False): else: kwargs['c'] = value.strftime('%Y-%m-%d %I:%M:%S %p') else: - kwargs['c'] = six.text_type(value) + kwargs['c'] = str(value) time_diff = app.render_time_ago(time_ago, fallback=None) if time_diff is not None: @@ -181,6 +216,18 @@ def raw_datetime(config, value, verbose=False, as_date=False): return HTML.tag('span', **kwargs) +def render_markdown(text, raw=False, **kwargs): + """ + Render the given markdown text as HTML. + """ + kwargs.setdefault('extensions', ['fenced_code', 'codehilite']) + md = markdown.markdown(text, **kwargs) + if raw: + return md + md = HTML.literal(md) + return HTML.tag('div', class_='rendered-markdown', c=[md]) + + def set_app_theme(request, theme, session=None): """ Set the app theme. This modifies the *global* Mako template lookup @@ -226,6 +273,41 @@ def get_theme_template_path(rattail_config, theme=None, session=None): return resource_path(theme_path) +def get_available_themes(rattail_config, include=None): + """ + Returns a list of theme names which are available. If config does + not specify, some defaults will be assumed. + """ + # get available list from config, if it has one + available = rattail_config.getlist('tailbone', 'themes.keys') + if not available: + available = rattail_config.getlist('tailbone', 'themes', + ignore_ambiguous=True) + if available: + warnings.warn("URGENT: instead of 'tailbone.themes', " + "you should set 'tailbone.themes.keys'", + DeprecationWarning, stacklevel=2) + else: + available = [] + + # include any themes specified by caller + if include is not None: + for theme in include: + if theme not in available: + available.append(theme) + + # sort the list by name + available.sort() + + # make default theme the first option + i = available.index('default') + if i >= 0: + available.pop(i) + available.insert(0, 'default') + + return available + + def get_effective_theme(rattail_config, theme=None, session=None): """ Validates and returns the "effective" theme. If you provide a theme, that @@ -243,15 +325,25 @@ def get_effective_theme(rattail_config, theme=None, session=None): session.close() # confirm requested theme is available - available = rattail_config.getlist('tailbone', 'themes', - default=['bobcat']) - available.append('default') + available = get_available_themes(rattail_config) if theme not in available: raise ValueError("theme not available: {}".format(theme)) return theme +def should_use_oruga(request): + """ + Returns a flag indicating whether or not the current theme + supports (and therefore should use) Oruga + Vue 3 as opposed to + the default of Buefy + Vue 2. + """ + theme = request.registry.settings.get('tailbone.theme') + if theme and 'butterball' in theme: + return True + return False + + def validate_email_address(address): """ Perform basic validation on the given email address. This leverages the @@ -291,7 +383,7 @@ def include_configured_views(pyramid_config): """ rattail_config = pyramid_config.registry.settings.get('rattail_config') app = rattail_config.get_app() - model = rattail_config.get_model() + model = app.model session = app.make_session() # fetch all include-related settings at once diff --git a/tailbone/views/__init__.py b/tailbone/views/__init__.py index 6b6ebc19..29c73b61 100644 --- a/tailbone/views/__init__.py +++ b/tailbone/views/__init__.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2021 Lance Edgar +# Copyright © 2010-2022 Lance Edgar # # This file is part of Rattail. # @@ -27,7 +27,7 @@ Pyramid Views from __future__ import unicode_literals, absolute_import from .core import View -from .master import MasterView +from .master import MasterView, ViewSupplement def includeme(config): diff --git a/tailbone/views/asgi/__init__.py b/tailbone/views/asgi/__init__.py index d0c12d9c..33888654 100644 --- a/tailbone/views/asgi/__init__.py +++ b/tailbone/views/asgi/__init__.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -41,12 +41,13 @@ class MockRequest(dict): pass -class WebsocketView(object): +class WebsocketView: def __init__(self, pyramid_config): self.pyramid_config = pyramid_config self.registry = self.pyramid_config.registry - self.model = self.rattail_config.get_model() + app = self.get_rattail_app() + self.model = app.model @property def rattail_config(self): @@ -89,7 +90,7 @@ class WebsocketView(object): session=session) as s: # load user proper - return s.query(model.User).get(user_uuid) + return s.get(model.User, user_uuid) def get_user_session(self, scope): settings = self.registry.settings diff --git a/tailbone/views/auth.py b/tailbone/views/auth.py index e7922e3d..eceab803 100644 --- a/tailbone/views/auth.py +++ b/tailbone/views/auth.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,10 +24,6 @@ Auth Views """ -from __future__ import unicode_literals, absolute_import - -from rattail.db.auth import authenticate_user, set_user_password - import colander from deform import widget as dfwidget from pyramid.httpexceptions import HTTPForbidden @@ -48,28 +44,6 @@ class UserLogin(colander.MappingSchema): widget=dfwidget.PasswordWidget()) -@colander.deferred -def current_password_correct(node, kw): - request = kw['request'] - app = request.rattail_config.get_app() - auth = app.get_auth_handler() - user = kw['user'] - def validate(node, value): - if not auth.authenticate_user(Session(), user.username, value): - raise colander.Invalid(node, "The password is incorrect") - return validate - - -class ChangePassword(colander.MappingSchema): - - current_password = colander.SchemaNode(colander.String(), - widget=dfwidget.PasswordWidget(), - validator=current_password_correct) - - new_password = colander.SchemaNode(colander.String(), - widget=dfwidget.CheckedPasswordWidget()) - - class AuthenticationView(View): def forbidden(self): @@ -94,6 +68,7 @@ class AuthenticationView(View): """ The login view, responsible for displaying and handling the login form. """ + app = self.get_rattail_app() referrer = self.request.get_referrer(default=self.request.route_url('home')) # redirect if already logged in @@ -101,15 +76,12 @@ class AuthenticationView(View): self.request.session.flash("{} is already logged in".format(self.request.user), 'error') return self.redirect(referrer) - use_buefy = self.get_use_buefy() - form = forms.Form(schema=UserLogin(), request=self.request, - use_buefy=use_buefy) + form = forms.Form(schema=UserLogin(), request=self.request) form.save_label = "Login" - form.auto_disable_save = False - form.auto_disable = False # TODO: deprecate / remove this form.show_reset = True form.show_cancel = False - if form.validate(newstyle=True): + form.button_icon_submit = 'user' + if form.validate(): user = self.authenticate_user(form.validated['username'], form.validated['password']) if user: @@ -122,20 +94,21 @@ class AuthenticationView(View): else: self.request.session.flash("Invalid username or password", 'error') - image_url = self.rattail_config.get( - 'tailbone', 'main_image_url', - default=self.request.static_url('tailbone:static/img/home_logo.png')) + # nb. hacky..but necessary, to add the refs, for autofocus + # (also add key handler, so ENTER acts like TAB) + dform = form.make_deform_form() + dform['username'].widget.attributes = { + 'ref': 'username', + 'autocomplete': 'off', + } + dform['password'].widget.attributes = {'ref': 'password'} - context = { + return { 'form': form, 'referrer': referrer, - 'image_url': image_url, - 'use_buefy': use_buefy, + 'index_title': app.get_node_title(), 'help_url': global_help_url(self.rattail_config), } - if use_buefy: - context['index_title'] = self.rattail_config.node_title() - return context def authenticate_user(self, username, password): app = self.get_rattail_app() @@ -173,19 +146,40 @@ class AuthenticationView(View): if not self.request.user: return self.redirect(self.request.route_url('home')) - if self.user_is_protected(self.request.user) and not self.request.is_root: - self.request.session.flash("Cannot change password for user: {}".format(self.request.user)) + if ((self.request.user.prevent_password_change + or self.user_is_protected(self.request.user)) + and not self.request.is_root): + + self.request.session.flash("Cannot change password for user: {}".format( + self.request.user)) return self.redirect(self.request.get_referrer()) - use_buefy = self.get_use_buefy() - schema = ChangePassword().bind(user=self.request.user, request=self.request) - form = forms.Form(schema=schema, request=self.request, use_buefy=use_buefy) - if form.validate(newstyle=True): - set_user_password(self.request.user, form.validated['new_password']) + def check_user_password(node, value): + auth = self.app.get_auth_handler() + user = self.request.user + if not auth.check_user_password(user, value): + node.raise_invalid("The password is incorrect") + + schema = colander.Schema() + + schema.add(colander.SchemaNode(colander.String(), + name='current_password', + widget=dfwidget.PasswordWidget(), + validator=check_user_password)) + + schema.add(colander.SchemaNode(colander.String(), + name='new_password', + widget=dfwidget.CheckedPasswordWidget())) + + form = forms.Form(schema=schema, request=self.request) + if form.validate(): + auth = self.app.get_auth_handler() + auth.set_user_password(self.request.user, form.validated['new_password']) self.request.session.flash("Your password has been changed.") return self.redirect(self.request.get_referrer()) - return {'form': form, 'use_buefy': use_buefy} + return {'index_title': str(self.request.user), + 'form': form} def become_root(self): """ @@ -233,6 +227,9 @@ class AuthenticationView(View): config.add_view(cls, attr='change_password', route_name='change_password', renderer='/change_password.mako') # become/stop root + # TODO: these should require POST but i won't bother until + # after butterball becomes default theme..or probably should + # just refactor the falafel theme accordingly..? config.add_route('become_root', '/root/yes') config.add_view(cls, attr='become_root', route_name='become_root') config.add_route('stop_root', '/root/no') diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index 6dc2436d..c162b579 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,8 +24,6 @@ Base views for maintaining "new-style" batches. """ -from __future__ import unicode_literals, absolute_import - import os import sys import json @@ -34,31 +32,25 @@ import logging import socket import subprocess import tempfile -from six import StringIO +import warnings import json -import six import markdown import sqlalchemy as sa from sqlalchemy import orm -from rattail.db import model, Session as RattailSession -from rattail.db.util import short_session from rattail.threads import Thread -from rattail.util import prettify, simple_error -from rattail.progress import SocketProgress +from rattail.util import simple_error import colander -import deform from deform import widget as dfwidget -from pyramid.renderers import render_to_response -from pyramid.response import FileResponse from webhelpers2.html import HTML, tags +from wuttaweb.util import render_csrf_token + from tailbone import forms, grids from tailbone.db import Session from tailbone.views import MasterView -from tailbone.util import csrf_token log = logging.getLogger(__name__) @@ -72,6 +64,8 @@ class BatchMasterView(MasterView): batch_handler_class = None has_rows = True rows_deletable = True + rows_deletable_if_executed = False + rows_bulk_deletable = True rows_downloadable_csv = True rows_downloadable_xlsx = True refreshable = True @@ -103,10 +97,10 @@ class BatchMasterView(MasterView): 'description', 'notes', 'params', - 'created', - 'created_by', 'rowcount', 'status_code', + 'created', + 'created_by', 'executed', 'executed_by', ] @@ -118,7 +112,7 @@ class BatchMasterView(MasterView): } def __init__(self, request): - super(BatchMasterView, self).__init__(request) + super().__init__(request) self.batch_handler = self.get_handler() # TODO: deprecate / remove this (?) self.handler = self.batch_handler @@ -170,7 +164,7 @@ class BatchMasterView(MasterView): return self.rattail_config.batch_filepath(batch.batch_key, batch.uuid, filename) def template_kwargs_view(self, **kwargs): - use_buefy = self.get_use_buefy() + kwargs = super().template_kwargs_view(**kwargs) batch = kwargs['instance'] kwargs['batch'] = batch kwargs['handler'] = self.handler @@ -192,33 +186,27 @@ class BatchMasterView(MasterView): breakdown = self.make_status_breakdown(batch) - if use_buefy: - factory = self.get_grid_factory() - g = factory('batch_row_status_breakdown', [], - columns=['title', 'count']) - g.set_click_handler('title', "autoFilterStatus(props.row)") - kwargs['status_breakdown_data'] = breakdown - kwargs['status_breakdown_grid'] = HTML.literal( - g.render_buefy_table_element(data_prop='statusBreakdownData', - empty_labels=True)) - - else: - kwargs['status_breakdown'] = [ - (status['title'], status['count']) - for status in breakdown] + factory = self.get_grid_factory() + g = factory(self.request, + key='batch_row_status_breakdown', + data=[], + columns=['title', 'count']) + g.set_click_handler('title', "autoFilterStatus(props.row)") + kwargs['status_breakdown_data'] = breakdown + kwargs['status_breakdown_grid'] = HTML.literal( + g.render_table_element(data_prop='statusBreakdownData', + empty_labels=True)) return kwargs def make_upload_worksheet_form(self, batch): action_url = self.get_action_url('upload_worksheet', batch) - use_buefy = self.get_use_buefy() form = forms.Form(schema=UploadWorksheet(), request=self.request, action_url=action_url, - use_buefy=use_buefy, component='upload-worksheet-form') form.set_type('worksheet_file', 'file') - # TODO: must set these to avoid some default Buefy code + # TODO: must set these to avoid some default code form.auto_disable = False form.auto_disable_save = False return form @@ -246,7 +234,7 @@ class BatchMasterView(MasterView): Thread target for updating a batch from worksheet. """ session = self.make_isolated_session() - batch = session.query(self.model_class).get(batch_uuid) + batch = session.get(self.model_class, batch_uuid) try: self.handler.update_from_worksheet(batch, path, progress=progress) @@ -299,7 +287,8 @@ class BatchMasterView(MasterView): return not batch.executed and not batch.complete def configure_grid(self, g): - super(BatchMasterView, self).configure_grid(g) + super().configure_grid(g) + model = self.model # created_by CreatedBy = orm.aliased(model.User) @@ -348,7 +337,7 @@ class BatchMasterView(MasterView): return batch.id_str def configure_form(self, f): - super(BatchMasterView, self).configure_form(f) + super().configure_form(f) # id f.set_readonly('id') @@ -360,6 +349,7 @@ class BatchMasterView(MasterView): f.remove('params') else: f.set_readonly('params') + f.set_renderer('params', self.render_params) # created f.set_readonly('created') @@ -394,7 +384,7 @@ class BatchMasterView(MasterView): f.set_label('executed_by', "Executed by") # notes - f.set_type('notes', 'text') + f.set_type('notes', 'text_wrapped') # if self.creating and self.request.user: # batch = fs.model @@ -417,23 +407,20 @@ class BatchMasterView(MasterView): f.remove_fields('executed', 'executed_by') - def make_status_renderer(self, enum): - def render_status(batch, field): - value = batch.status_code - if value is None: - return "" - status_code_text = enum.get(value, six.text_type(value)) - if batch.status_text: - return HTML.tag('span', title=batch.status_text, c=status_code_text) - return status_code_text - return render_status + def render_params(self, batch, field): + params = self.get_visible_params(batch) + if not params: + return + + return params + + def get_visible_params(self, batch): + return dict(batch.params or {}) def render_complete(self, batch, field): - permission_prefix = self.get_permission_prefix() - use_buefy = self.get_use_buefy() text = "Yes" if batch.complete else "No" - if batch.executed or not self.request.has_perm('{}.edit'.format(permission_prefix)): + if batch.executed or not self.has_perm('edit'): return text if batch.complete: @@ -443,44 +430,34 @@ class BatchMasterView(MasterView): label = "Mark Complete" value = 'true' - kwargs = {} - if not use_buefy: - kwargs['class_'] = 'autodisable' - begin_form = tags.form(self.get_action_url('toggle_complete', batch), **kwargs) + url = self.get_action_url('toggle_complete', batch) + kwargs = {'@submit': 'togglingBatchComplete = true'} + begin_form = tags.form(url, **kwargs) - if use_buefy: - submit = HTML.tag('once-button', - type='is-primary', - native_type='submit', - text=label) - else: - submit = tags.submit('submit', label) + label = HTML.literal( + '{{{{ togglingBatchComplete ? "Working, please wait..." : "{}" }}}}'.format(label)) + submit = self.make_button(label, is_primary=True, + native_type='submit', + **{':disabled': 'togglingBatchComplete'}) form = [ begin_form, - csrf_token(self.request), + render_csrf_token(self.request), tags.hidden('complete', value=value), submit, tags.end_form(), ] - if use_buefy: - text = HTML.tag('div', class_='control', c=text) - form = HTML.tag('div', class_='control', c=form) - content = [ - HTML.tag('nav', class_='level', - c=[HTML.tag('div', class_='level-left', c=[ - text, - HTML.literal(' '), - form, - ])]), - ] - - else: - content = [ - text, - HTML.literal(' '), - ] + form + text = HTML.tag('div', class_='control', c=text) + form = HTML.tag('div', class_='control', c=form) + content = [ + HTML.tag('nav', class_='level', + c=[HTML.tag('div', class_='level-left', c=[ + text, + HTML.literal(' '), + form, + ])]), + ] return HTML.tag('div', c=content) @@ -488,7 +465,7 @@ class BatchMasterView(MasterView): user = getattr(batch, field) if not user: return "" - title = six.text_type(user) + title = str(user) url = self.request.route_url('users.view', uuid=user.uuid) return tags.link_to(title, url) @@ -510,7 +487,7 @@ class BatchMasterView(MasterView): # TODO: this needs work yet surely...why is this an issue? # treat 'filename' field specially, for some reason it can be a filedict? - if 'filename' in kwargs and not isinstance(kwargs['filename'], six.string_types): + if 'filename' in kwargs and not isinstance(kwargs['filename'], str): kwargs['filename'] = '' # null not allowed # TODO: is this still necessary with colander? @@ -524,11 +501,21 @@ class BatchMasterView(MasterView): return batch def process_uploads(self, batch, form, uploads): - for key, upload in six.iteritems(uploads): + + def process(upload, key): self.handler.set_input_file(batch, upload['temp_path'], attr=key) os.remove(upload['temp_path']) os.rmdir(upload['tempdir']) + for key, upload in uploads.items(): + if isinstance(upload, dict): + process(upload, key) + else: + uploads = upload + for upload in uploads: + if isinstance(upload, dict): + process(upload, key) + def get_batch_kwargs(self, batch, **kwargs): """ Return a kwargs dict for use with ``self.handler.make_batch()``, using @@ -579,7 +566,7 @@ class BatchMasterView(MasterView): self.request.session.flash("Request ignored, since batch has already been executed") else: form = forms.Form(schema=ToggleComplete(), request=self.request) - if form.validate(newstyle=True): + if form.validate(): if form.validated['complete']: self.mark_batch_complete(batch) else: @@ -616,7 +603,7 @@ class BatchMasterView(MasterView): return True def configure_row_grid(self, g): - super(BatchMasterView, self).configure_row_grid(g) + super().configure_row_grid(g) g.set_sort_defaults('sequence') g.set_link('sequence') @@ -644,7 +631,7 @@ class BatchMasterView(MasterView): code = row.status_code if code is None: return "" - text = self.get_row_status_enum().get(code, six.text_type(code)) + text = self.get_row_status_enum().get(code, str(code)) if row.status_text: return HTML.tag('span', title=row.status_text, c=text) return text @@ -657,7 +644,7 @@ class BatchMasterView(MasterView): if batch.executed: self.request.session.flash("You cannot add new rows to a batch which has been executed") return self.redirect(self.get_action_url('view', batch)) - return super(BatchMasterView, self).create_row() + return super().create_row() def save_create_row_form(self, form): batch = self.get_instance() @@ -670,7 +657,7 @@ class BatchMasterView(MasterView): self.handler.refresh_row(row) def configure_row_form(self, f): - super(BatchMasterView, self).configure_row_form(f) + super().configure_row_form(f) # sequence f.set_readonly('sequence') @@ -693,16 +680,13 @@ class BatchMasterView(MasterView): if self.rows_creatable and not batch.executed and not batch.complete: permission_prefix = self.get_permission_prefix() if self.request.has_perm('{}.create_row'.format(permission_prefix)): - link = tags.link_to("Create a new {}".format(self.get_row_model_title()), - self.get_action_url('create_row', batch)) - return HTML.tag('p', c=[link]) + url = self.get_action_url('create_row', batch) + return self.make_button("New Row", url=url, + is_primary=True, + icon_left='plus') def make_batch_row_grid_tools(self, batch): - if self.get_use_buefy(): - return - if self.rows_bulk_deletable and not batch.executed and self.request.has_perm('{}.delete_rows'.format(self.get_permission_prefix())): - url = self.request.route_url('{}.delete_rows'.format(self.get_route_prefix()), uuid=batch.uuid) - return HTML.tag('p', c=[tags.link_to("Delete all rows matching current search", url)]) + pass def make_row_grid_kwargs(self, **kwargs): """ @@ -712,32 +696,30 @@ class BatchMasterView(MasterView): batch = self.get_instance() # TODO: most of this logic is copied from MasterView, should refactor/merge somehow... - if 'main_actions' not in kwargs: + if 'actions' not in kwargs: actions = [] - use_buefy = self.get_use_buefy() # view action if self.rows_viewable: view = lambda r, i: self.get_row_action_url('view', r) - icon = 'eye' if use_buefy else 'zoomin' - actions.append(self.make_action('view', icon=icon, url=view)) + actions.append(self.make_action('view', icon='eye', url=view)) - # edit and delete are NOT allowed after execution, or if batch is "complete" - if not batch.executed and not batch.complete: + # edit and delete are NOT allowed if batch is "complete" + if not batch.complete: # edit action - if self.rows_editable and self.has_perm('edit_row'): - icon = 'edit' if use_buefy else 'pencil' - actions.append(self.make_action('edit', icon=icon, url=self.row_edit_action_url)) + if self.rows_editable and not batch.executed and self.has_perm('edit_row'): + actions.append(self.make_action('edit', icon='edit', + url=self.row_edit_action_url)) # delete action if self.rows_deletable and self.has_perm('delete_row'): actions.append(self.make_action('delete', icon='trash', url=self.row_delete_action_url)) kwargs.setdefault('delete_speedbump', self.rows_deletable_speedbump) - kwargs['main_actions'] = actions + kwargs['actions'] = actions - return super(BatchMasterView, self).make_row_grid_kwargs(**kwargs) + return super().make_row_grid_kwargs(**kwargs) def make_row_grid_tools(self, batch): return (self.make_default_row_grid_tools(batch) or '') + (self.make_batch_row_grid_tools(batch) or '') @@ -774,7 +756,7 @@ class BatchMasterView(MasterView): # nb. must make new session, separate from main thread session = app.make_session() batch = self.get_instance_for_key(key, session) - batch_str = six.text_type(batch) + batch_str = str(batch) try: # try to delete batch @@ -850,7 +832,6 @@ class BatchMasterView(MasterView): """ defaults = {} route_prefix = self.get_route_prefix() - use_buefy = self.get_use_buefy() schema = None if self.has_execution_options(batch): @@ -871,16 +852,23 @@ class BatchMasterView(MasterView): labels = kwargs.setdefault('labels', {}) labels[field.name] = field.title - # auto-convert select widgets for buefy theme - if use_buefy and isinstance(field.widget, forms.widgets.PlainSelectWidget): + # auto-convert select widgets for theme + if isinstance(field.widget, forms.widgets.PlainSelectWidget): + warnings.warn("PlainSelectWidget is deprecated; " + "please use deform.widget.SelectWidget instead", + DeprecationWarning, stacklevel=2) field.widget = dfwidget.SelectWidget(values=field.widget.values) if not schema: schema = colander.Schema() - kwargs['use_buefy'] = use_buefy - kwargs['component'] = 'execute-form' - return forms.Form(schema=schema, request=self.request, defaults=defaults, **kwargs) + kwargs['vue_tagname'] = 'execute-form' + form = forms.Form(schema=schema, request=self.request, defaults=defaults, **kwargs) + self.configure_execute_form(form) + return form + + def configure_execute_form(self, form): + pass def get_execute_title(self, batch): if hasattr(self.handler, 'get_execute_title'): @@ -946,7 +934,7 @@ class BatchMasterView(MasterView): prefix = self.rattail_config.get('rattail', 'command_prefix', default=sys.prefix) cmd = [os.path.join(prefix, 'bin/{}'.format(command))] - for path in self.rattail_config.files_read: + for path in self.rattail_config.prioritized_files: cmd.extend(['--config', path]) if username: cmd.extend(['--runas', username]) @@ -963,12 +951,15 @@ class BatchMasterView(MasterView): # run command in subprocess log.debug("launching command in subprocess: %s", cmd) try: - subprocess.check_output(cmd, stderr=subprocess.PIPE) + # nb. we do not capture stderr, but on failure the stdout + # will contain a simple error string + subprocess.check_output(cmd) except subprocess.CalledProcessError as error: log.warning("command failed with exit code %s! output was:", error.returncode) - log.warning(error.stderr.decode('utf_8')) - raise + output = error.output.decode('utf_8') + log.warning(output) + raise Exception(output) def action_subprocess_thread(self, key, port, username, handler_action, progress, **kwargs): """ @@ -981,6 +972,10 @@ class BatchMasterView(MasterView): batch_uuid = key[0] # figure out the (sub)command args we'll be passing + if handler_action == 'auto_receive': + subcommand = 'auto-receive' + else: + subcommand = f'{handler_action}-batch' subargs = [ '--batch-type', self.handler.batch_key, @@ -999,7 +994,7 @@ class BatchMasterView(MasterView): command_args=[ '--no-versioning', ], - subcommand='{}-batch'.format(handler_action), + subcommand=subcommand, subcommand_args=subargs) except Exception as error: log.warning("%s of '%s' batch failed: %s", handler_action, self.handler.batch_key, batch_uuid, exc_info=True) @@ -1009,8 +1004,8 @@ class BatchMasterView(MasterView): progress.session.load() progress.session['error'] = True progress.session['error_msg'] = ( - "{} of '{}' batch failed (see logs for more info)").format( - handler_action, self.handler.batch_key) + "{} of '{}' batch failed: {} (see logs for more info)").format( + handler_action, self.handler.batch_key, error) progress.session.save() return @@ -1024,17 +1019,17 @@ class BatchMasterView(MasterView): data = json.dumps({ 'everything_complete': True, }) - if six.PY3: - data = data.encode('utf_8') + data = data.encode('utf_8') cxn.send(data) cxn.send(suffix) cxn.close() def catchup_versions(self, port, batch_uuid, username, *models): - with short_session() as s: - batch = s.query(self.model_class).get(batch_uuid) + app = self.get_rattail_app() + with app.short_session() as s: + batch = s.get(self.model_class, batch_uuid) batch_id = batch.id_str - description = six.text_type(batch) + description = str(batch) self.launch_subprocess( port=port, username=username, @@ -1057,16 +1052,19 @@ class BatchMasterView(MasterView): """ Thread target for populating batch data with progress indicator. """ + app = self.get_rattail_app() + model = self.model # mustn't use tailbone web session here - session = RattailSession() - batch = session.query(self.model_class).get(batch_uuid) - user = session.query(model.User).get(user_uuid) + session = app.make_session() + batch = session.get(self.model_class, batch_uuid) + user = session.get(model.User, user_uuid) try: self.handler.do_populate(batch, user, progress=progress) session.flush() except Exception as error: session.rollback() - log.exception("population failed for batch %s: %s", batch.uuid, batch) + log.warning("population failed for batch %s: %s", batch.uuid, batch, + exc_info=True) session.close() if progress: progress.session.load() @@ -1115,9 +1113,11 @@ class BatchMasterView(MasterView): # Refresh data for the batch, with progress. Note that we must use the # rattail session here; can't use tailbone because it has web request # transaction binding etc. - session = RattailSession() - batch = session.query(self.model_class).get(batch_uuid) - cognizer = session.query(model.User).get(user_uuid) if user_uuid else None + app = self.get_rattail_app() + model = self.model + session = app.make_session() + batch = session.get(self.model_class, batch_uuid) + cognizer = session.get(model.User, user_uuid) if user_uuid else None try: self.refresh_data(session, batch, cognizer, progress=progress) session.flush() @@ -1168,9 +1168,11 @@ class BatchMasterView(MasterView): """ Thread target for refreshing multiple batches with progress indicator. """ - session = RattailSession() + app = self.get_rattail_app() + model = self.model + session = app.make_session() batches = batches.with_session(session).all() - user = session.query(model.User).get(user_uuid) + user = session.get(model.User, user_uuid) try: self.handler.refresh_many(batches, user=user, progress=progress) @@ -1243,9 +1245,16 @@ class BatchMasterView(MasterView): return False batch = self.get_parent(row) - if batch.complete or batch.executed: + + if batch.complete: return False + if batch.executed: + if not self.rows_deletable_if_executed: + return False + if not self.has_perm('delete_row_if_executed'): + return False + return True def template_kwargs_view_row(self, **kwargs): @@ -1265,7 +1274,7 @@ class BatchMasterView(MasterView): self.handler.do_remove_row(row) def delete_row_objects(self, rows): - deleted = super(BatchMasterView, self).delete_row_objects(rows) + deleted = super().delete_row_objects(rows) batch = self.get_instance() # decrement rowcount for batch @@ -1286,7 +1295,7 @@ class BatchMasterView(MasterView): batch = self.get_instance() self.executing = True form = self.make_execute_form(batch) - if form.validate(newstyle=True): + if form.validate(): kwargs = dict(form.validated) # cache options to use as defaults next time @@ -1308,9 +1317,11 @@ class BatchMasterView(MasterView): # Execute the batch, with progress. Note that we must use the rattail # session here; can't use tailbone because it has web request # transaction binding etc. - session = RattailSession() + app = self.get_rattail_app() + model = self.model + session = app.make_session() batch = self.get_instance_for_key(key, session) - user = session.query(model.User).get(user_uuid) + user = session.get(model.User, user_uuid) try: result = self.handler.do_execute(batch, user=user, progress=progress, **kwargs) @@ -1357,7 +1368,7 @@ class BatchMasterView(MasterView): indicator page. """ form = self.make_execute_form() - if form.validate(newstyle=True): + if form.validate(): kwargs = dict(form.validated) # cache options to use as defaults next time @@ -1383,9 +1394,11 @@ class BatchMasterView(MasterView): """ Thread target for executing multiple batches with progress indicator. """ - session = RattailSession() + app = self.get_rattail_app() + model = self.model + session = app.make_session() batches = batches.with_session(session).all() - user = session.query(model.User).get(user_uuid) + user = session.get(model.User, user_uuid) try: result = self.handler.execute_many(batches, user=user, progress=progress, **kwargs) @@ -1423,7 +1436,7 @@ class BatchMasterView(MasterView): return self.get_index_url() def get_row_csv_fields(self): - fields = super(BatchMasterView, self).get_row_csv_fields() + fields = super().get_row_csv_fields() fields = [field for field in fields if field not in ('uuid', 'batch_uuid', 'removed')] return fields @@ -1502,6 +1515,12 @@ class BatchMasterView(MasterView): config.add_tailbone_permission(permission_prefix, '{}.refresh'.format(permission_prefix), "Refresh data for {}".format(model_title)) + # delete row if executed + if cls.rows_deletable_if_executed: + config.add_tailbone_permission(permission_prefix, + f'{permission_prefix}.delete_row_if_executed', + "Delete rows after batch is executed") + # toggle complete config.add_route('{}.toggle_complete'.format(route_prefix), '{}/{{{}}}/toggle-complete'.format(url_prefix, model_key)) config.add_view(cls, attr='toggle_complete', route_name='{}.toggle_complete'.format(route_prefix), @@ -1546,7 +1565,7 @@ class FileBatchMasterView(BatchMasterView): return uploads def configure_form(self, f): - super(FileBatchMasterView, self).configure_form(f) + super().configure_form(f) batch = f.model_instance # filename diff --git a/tailbone/views/batch/handheld.py b/tailbone/views/batch/handheld.py index d4f15ffd..486d8774 100644 --- a/tailbone/views/batch/handheld.py +++ b/tailbone/views/batch/handheld.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,15 +24,14 @@ Views for handheld batches """ -from __future__ import unicode_literals, absolute_import +from collections import OrderedDict -from rattail.db import model -from rattail.util import OrderedDict +from rattail.db.model import HandheldBatch, HandheldBatchRow import colander +from deform import widget as dfwidget from webhelpers2.html import tags -from tailbone import forms from tailbone.views.batch import FileBatchMasterView @@ -47,14 +46,14 @@ class ExecutionOptions(colander.Schema): action = colander.SchemaNode( colander.String(), validator=colander.OneOf(ACTION_OPTIONS), - widget=forms.widgets.PlainSelectWidget(values=ACTION_OPTIONS.items())) + widget=dfwidget.SelectWidget(values=list(ACTION_OPTIONS.items()))) class HandheldBatchView(FileBatchMasterView): """ Master view for handheld batches. """ - model_class = model.HandheldBatch + model_class = HandheldBatch default_handler_spec = 'rattail.batch.handheld:HandheldBatchHandler' model_title_plural = "Handheld Batches" route_prefix = 'batch.handheld' @@ -62,7 +61,7 @@ class HandheldBatchView(FileBatchMasterView): execution_options_schema = ExecutionOptions editable = False - model_row_class = model.HandheldBatchRow + model_row_class = HandheldBatchRow rows_creatable = False rows_editable = True @@ -117,7 +116,7 @@ class HandheldBatchView(FileBatchMasterView): ] def configure_grid(self, g): - super(HandheldBatchView, self).configure_grid(g) + super().configure_grid(g) device_types = OrderedDict(sorted(self.enum.HANDHELD_DEVICE_TYPE.items(), key=lambda item: item[1])) g.set_enum('device_type', device_types) @@ -127,7 +126,7 @@ class HandheldBatchView(FileBatchMasterView): return 'notice' def configure_form(self, f): - super(HandheldBatchView, self).configure_form(f) + super().configure_form(f) batch = f.model_instance # device_type @@ -157,13 +156,13 @@ class HandheldBatchView(FileBatchMasterView): return tags.link_to(text, url) def get_batch_kwargs(self, batch): - kwargs = super(HandheldBatchView, self).get_batch_kwargs(batch) + kwargs = super().get_batch_kwargs(batch) kwargs['device_type'] = batch.device_type kwargs['device_name'] = batch.device_name return kwargs def configure_row_grid(self, g): - super(HandheldBatchView, self).configure_row_grid(g) + super().configure_row_grid(g) g.set_type('cases', 'quantity') g.set_type('units', 'quantity') g.set_label('brand_name', "Brand") @@ -173,7 +172,7 @@ class HandheldBatchView(FileBatchMasterView): return 'warning' def configure_row_form(self, f): - super(HandheldBatchView, self).configure_row_form(f) + super().configure_row_form(f) # readonly fields f.set_readonly('upc') @@ -189,7 +188,7 @@ class HandheldBatchView(FileBatchMasterView): return self.request.route_url('batch.inventory.view', uuid=result.uuid) elif kwargs['action'] == 'make_label_batch': return self.request.route_url('labels.batch.view', uuid=result.uuid) - return super(HandheldBatchView, self).get_execute_success_url(batch) + return super().get_execute_success_url(batch) def get_execute_results_success_url(self, result, **kwargs): if result is True: diff --git a/tailbone/views/batch/importer.py b/tailbone/views/batch/importer.py index 001de0ff..ea4e1c74 100644 --- a/tailbone/views/batch/importer.py +++ b/tailbone/views/batch/importer.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2020 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,11 +24,9 @@ Views for importer batches """ -from __future__ import unicode_literals, absolute_import - import sqlalchemy as sa -from rattail.db import model +from rattail.db.model import ImporterBatch import colander @@ -39,9 +37,8 @@ class ImporterBatchView(BatchMasterView): """ Master view for importer batches. """ - model_class = model.ImporterBatch + model_class = ImporterBatch default_handler_spec = 'rattail.batch.importer:ImporterBatchHandler' - model_title_plural = "Import / Export Batches" route_prefix = 'batch.importer' url_prefix = '/batches/importer' template_prefix = '/batch/importer' @@ -94,7 +91,7 @@ class ImporterBatchView(BatchMasterView): ] def configure_form(self, f): - super(ImporterBatchView, self).configure_form(f) + super().configure_form(f) # readonly fields f.set_readonly('import_handler_spec') @@ -113,21 +110,21 @@ class ImporterBatchView(BatchMasterView): self.make_row_table(batch.row_table) kwargs['rows'] = self.Session.query(self.current_row_table).all() kwargs.setdefault('status_enum', self.enum.IMPORTER_BATCH_ROW_STATUS) - breakdown = super(ImporterBatchView, self).make_status_breakdown( - batch, **kwargs) + breakdown = super().make_status_breakdown(batch, **kwargs) return breakdown def delete_instance(self, batch): self.make_row_table(batch.row_table) if self.current_row_table is not None: self.current_row_table.drop() - super(ImporterBatchView, self).delete_instance(batch) + super().delete_instance(batch) def make_row_table(self, name): if not hasattr(self, 'current_row_table'): - metadata = sa.MetaData(schema='batch', bind=self.Session.bind) + metadata = sa.MetaData(schema='batch') try: - self.current_row_table = sa.Table(name, metadata, autoload=True) + self.current_row_table = sa.Table(name, metadata, + autoload_with=self.Session.bind) except sa.exc.NoSuchTableError: self.current_row_table = None @@ -139,8 +136,7 @@ class ImporterBatchView(BatchMasterView): return self.enum.IMPORTER_BATCH_ROW_STATUS def configure_row_grid(self, g): - super(ImporterBatchView, self).configure_row_grid(g) - use_buefy = self.get_use_buefy() + super().configure_row_grid(g) def make_filter(field, **kwargs): column = getattr(self.current_row_table.c, field) @@ -149,13 +145,8 @@ class ImporterBatchView(BatchMasterView): make_filter('object_key') make_filter('object_str') - # for some reason we have to do this differently for Buefy? - kwargs = {} - if not use_buefy: - kwargs['value_enum'] = self.enum.IMPORTER_BATCH_ROW_STATUS - make_filter('status_code', label="Status", **kwargs) - if use_buefy: - g.filters['status_code'].set_choices(self.enum.IMPORTER_BATCH_ROW_STATUS) + make_filter('status_code', label="Status") + g.filters['status_code'].set_choices(self.enum.IMPORTER_BATCH_ROW_STATUS) def make_sorter(field): column = getattr(self.current_row_table.c, field) @@ -197,7 +188,7 @@ class ImporterBatchView(BatchMasterView): def get_parent(self, row): uuid = self.current_row_table.name - return self.Session.query(model.ImporterBatch).get(uuid) + return self.Session.get(ImporterBatch, uuid) def get_row_instance_title(self, row): if row.object_str: @@ -249,7 +240,7 @@ class ImporterBatchView(BatchMasterView): kwargs.setdefault('schema', colander.Schema()) kwargs.setdefault('cancel_url', None) - return super(ImporterBatchView, self).make_row_form(instance=row, **kwargs) + return super().make_row_form(instance=row, **kwargs) def configure_row_form(self, f): """ @@ -284,9 +275,33 @@ class ImporterBatchView(BatchMasterView): query = self.get_effective_row_data(sort=False) batch.rowcount -= query.count() delete_query = self.current_row_table.delete().where(self.current_row_table.c.uuid.in_([row.uuid for row in query])) - delete_query.execute() + self.Session.bind.execute(delete_query) return self.redirect(self.get_action_url('view', batch)) + def get_row_xlsx_fields(self): + return [ + 'sequence', + 'object_key', + 'object_str', + 'status', + 'status_code', + 'status_text', + ] + + def get_row_xlsx_row(self, row, fields): + xlrow = super().get_row_xlsx_row(row, fields) + + xlrow['status'] = self.enum.IMPORTER_BATCH_ROW_STATUS[row.status_code] + + return xlrow + + +def defaults(config, **kwargs): + base = globals() + + ImporterBatchView = kwargs.get('ImporterBatchView', base['ImporterBatchView']) + ImporterBatchView.defaults(config) + def includeme(config): - ImporterBatchView.defaults(config) + defaults(config) diff --git a/tailbone/views/batch/inventory.py b/tailbone/views/batch/inventory.py index f8699725..e9f72ceb 100644 --- a/tailbone/views/batch/inventory.py +++ b/tailbone/views/batch/inventory.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2021 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,19 +24,16 @@ Views for inventory batches """ -from __future__ import unicode_literals, absolute_import - import re import decimal import logging - -import six +from collections import OrderedDict from rattail import pod from rattail.db import model from rattail.db.util import make_full_description from rattail.gpc import GPC -from rattail.util import pretty_quantity, OrderedDict +from rattail.util import pretty_quantity import colander from deform import widget as dfwidget @@ -218,7 +215,7 @@ class InventoryBatchView(BatchMasterView): return super(InventoryBatchView, self).save_edit_row_form(form) def delete_row(self): - row = self.Session.query(model.InventoryBatchRow).get(self.request.matchdict['row_uuid']) + row = self.Session.get(model.InventoryBatchRow, self.request.matchdict['row_uuid']) if not row: raise self.notfound() batch = row.batch @@ -231,43 +228,49 @@ class InventoryBatchView(BatchMasterView): Desktop workflow view for adding items to inventory batch. """ batch = self.get_instance() - if batch.executed: + if batch.executed or batch.complete: return self.redirect(self.get_action_url('view', batch)) schema = DesktopForm().bind(session=self.Session()) form = forms.Form(schema=schema, request=self.request) - if form.validate(newstyle=True): + if self.request.method == 'POST': + if form.validate(): - product = self.Session.query(model.Product).get(form.validated['product']) + product = self.Session.get(model.Product, form.validated['product']) - row = None - if self.should_aggregate_products(batch): - row = self.find_row_for_product(batch, product) - if row: + row = None + if self.should_aggregate_products(batch): + row = self.find_row_for_product(batch, product) + if row: + row.cases = form.validated['cases'] + row.units = form.validated['units'] + self.handler.refresh_row(row) + + if not row: + row = model.InventoryBatchRow() + row.product = product + row.upc = form.validated['upc'] + row.brand_name = form.validated['brand_name'] + row.description = form.validated['description'] + row.size = form.validated['size'] + row.case_quantity = form.validated['case_quantity'] row.cases = form.validated['cases'] row.units = form.validated['units'] - self.handler.refresh_row(row) + self.handler.capture_current_units(row) + self.handler.add_row(batch, row) - if not row: - row = model.InventoryBatchRow() - row.product = product - row.upc = form.validated['upc'] - row.brand_name = form.validated['brand_name'] - row.description = form.validated['description'] - row.size = form.validated['size'] - row.case_quantity = form.validated['case_quantity'] - row.cases = form.validated['cases'] - row.units = form.validated['units'] - self.handler.capture_current_units(row) - self.handler.add_row(batch, row) + description = make_full_description(form.validated['brand_name'], + form.validated['description'], + form.validated['size']) + self.request.session.flash("{} cases, {} units: {} {}".format( + form.validated['cases'] or 0, form.validated['units'] or 0, + form.validated['upc'].pretty(), description)) + return self.redirect(self.request.current_route_url()) - description = make_full_description(form.validated['brand_name'], - form.validated['description'], - form.validated['size']) - self.request.session.flash("{} cases, {} units: {} {}".format( - form.validated['cases'] or 0, form.validated['units'] or 0, - form.validated['upc'].pretty(), description)) - return self.redirect(self.request.current_route_url()) + else: + dform = form.make_deform_form() + msg = "Form did not validate: {}".format(str(dform.error)) + self.request.session.flash(msg, 'error') title = self.get_instance_title(batch) return self.render_to_response('desktop_form', { @@ -338,7 +341,7 @@ class InventoryBatchView(BatchMasterView): upc = re.sub(r'\D', '', entry.strip()) if upc: upc = GPC(upc) - result['upc'] = six.text_type(upc) + result['upc'] = str(upc) result['upc_pretty'] = upc.pretty() result['image_url'] = pod.get_image_url(self.rattail_config, upc) @@ -357,20 +360,26 @@ class InventoryBatchView(BatchMasterView): # TODO: deprecate / remove (?) def find_product(self, entry): - lookup_by_code = self.rattail_config.getbool( - 'tailbone', 'inventory.lookup_by_code', default=False) + lookup_fields = [ + 'uuid', + '_product_key_', + ] - return self.handler.locate_product_for_entry( - self.Session(), entry, lookup_by_code=lookup_by_code) + if self.rattail_config.getbool('tailbone', 'inventory.lookup_by_code', + default=False): + lookup_fields.append('alt_code') + + return self.handler.locate_product_for_entry(self.Session(), entry, + lookup_fields=lookup_fields) def product_info(self, product): data = {} if product and (not product.deleted or self.request.has_perm('products.view_deleted')): data['uuid'] = product.uuid - data['upc'] = six.text_type(product.upc) + data['upc'] = str(product.upc) data['upc_pretty'] = product.upc.pretty() data['full_description'] = product.full_description - data['brand_name'] = six.text_type(product.brand or '') + data['brand_name'] = str(product.brand or '') data['description'] = product.description data['size'] = product.size data['case_quantity'] = 1 # default @@ -513,7 +522,7 @@ class InventoryBatchView(BatchMasterView): def valid_product(node, kw): session = kw['session'] def validate(node, value): - product = session.query(model.Product).get(value) + product = session.get(model.Product, value) if not product: raise colander.Invalid(node, "Product not found") return product.uuid diff --git a/tailbone/views/batch/labels.py b/tailbone/views/batch/labels.py index 79b14a76..7291b05e 100644 --- a/tailbone/views/batch/labels.py +++ b/tailbone/views/batch/labels.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,10 +24,6 @@ Views for label batches """ -from __future__ import unicode_literals, absolute_import - -import six - from rattail.db import model from deform import widget as dfwidget @@ -123,7 +119,7 @@ class LabelBatchView(BatchMasterView): ] def configure_form(self, f): - super(LabelBatchView, self).configure_form(f) + super().configure_form(f) # handheld_batches if self.creating: @@ -142,7 +138,7 @@ class LabelBatchView(BatchMasterView): f.replace('label_profile', 'label_profile_uuid') # TODO: should restrict somehow? just allow override? profiles = self.Session.query(model.LabelProfile) - values = [(p.uuid, six.text_type(p)) + values = [(p.uuid, str(p)) for p in profiles] require_profile = False if not require_profile: @@ -159,7 +155,7 @@ class LabelBatchView(BatchMasterView): return HTML.tag('ul', c=items) def configure_row_grid(self, g): - super(LabelBatchView, self).configure_row_grid(g) + super().configure_row_grid(g) # short labels g.set_label('brand_name', "Brand") @@ -171,7 +167,7 @@ class LabelBatchView(BatchMasterView): return 'warning' def configure_row_form(self, f): - super(LabelBatchView, self).configure_row_form(f) + super().configure_row_form(f) # readonly fields f.set_readonly('sequence') @@ -219,7 +215,7 @@ class LabelBatchView(BatchMasterView): profiles = self.Session.query(model.LabelProfile)\ .filter(model.LabelProfile.visible == True)\ .order_by(model.LabelProfile.ordinal) - profile_values = [(p.uuid, six.text_type(p)) + profile_values = [(p.uuid, str(p)) for p in profiles] f.set_widget('label_profile_uuid', forms.widgets.JQuerySelectWidget(values=profile_values)) diff --git a/tailbone/views/batch/newproduct.py b/tailbone/views/batch/newproduct.py index 03ca638b..bd46ad52 100644 --- a/tailbone/views/batch/newproduct.py +++ b/tailbone/views/batch/newproduct.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,10 +24,10 @@ Views for new product batches """ -from __future__ import unicode_literals, absolute_import - from rattail.db import model +from deform import widget as dfwidget + from tailbone.views.batch import BatchMasterView @@ -49,11 +49,17 @@ class NewProductBatchView(BatchMasterView): configurable = True has_input_file_templates = True + labels = { + 'type2_lookup': "Type-2 UPC Lookups", + } + form_fields = [ 'id', 'input_filename', 'description', 'notes', + 'type2_lookup', + 'params', 'created', 'created_by', 'rowcount', @@ -129,7 +135,7 @@ class NewProductBatchView(BatchMasterView): ] def configure_form(self, f): - super(NewProductBatchView, self).configure_form(f) + super().configure_form(f) # input_filename if self.creating: @@ -138,6 +144,34 @@ class NewProductBatchView(BatchMasterView): f.set_readonly('input_filename') f.set_renderer('input_filename', self.render_downloadable_file) + # type2_lookup + if self.creating: + values = [ + ('', "(use default behavior)"), + ('always', "Always try Type-2 lookup, when applicable"), + ('never', "Never try Type-2 lookup"), + ] + f.set_widget('type2_lookup', dfwidget.SelectWidget(values=values)) + f.set_default('type2_lookup', '') + else: + f.remove('type2_lookup') + + def save_create_form(self, form): + batch = super().save_create_form(form) + + if 'type2_lookup' in form: + type2_lookup = form.validated['type2_lookup'] + if type2_lookup == 'always': + type2_lookup = True + elif type2_lookup == 'never': + type2_lookup = False + else: + type2_lookup = None + if type2_lookup is not None: + batch.set_param('type2_lookup', type2_lookup) + + return batch + def configure_row_grid(self, g): super(NewProductBatchView, self).configure_row_grid(g) @@ -165,7 +199,7 @@ class NewProductBatchView(BatchMasterView): return 'notice' def configure_row_form(self, f): - super(NewProductBatchView, self).configure_row_form(f) + super().configure_row_form(f) f.set_readonly('product') f.set_readonly('vendor') @@ -177,6 +211,7 @@ class NewProductBatchView(BatchMasterView): f.set_type('upc', 'gpc') + f.set_renderer('product', self.render_product) f.set_renderer('vendor', self.render_vendor) f.set_renderer('department', self.render_department) f.set_renderer('subdepartment', self.render_subdepartment) diff --git a/tailbone/views/batch/pos.py b/tailbone/views/batch/pos.py new file mode 100644 index 00000000..b6fef6c8 --- /dev/null +++ b/tailbone/views/batch/pos.py @@ -0,0 +1,312 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2024 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. +# +################################################################################ +""" +Views for POS batches +""" + +from rattail.db.model import POSBatch, POSBatchRow + +from webhelpers2.html import HTML + +from tailbone.views.batch import BatchMasterView + + +class POSBatchView(BatchMasterView): + """ + Master view for POS batches + """ + model_class = POSBatch + model_row_class = POSBatchRow + default_handler_spec = 'rattail.batch.pos:POSBatchHandler' + route_prefix = 'batch.pos' + url_prefix = '/batch/pos' + creatable = False + editable = False + cloneable = True + refreshable = False + rows_deletable = False + rows_bulk_deletable = False + + labels = { + 'terminal_id': "Terminal ID", + 'fs_tender_total': "FS Tender Total", + } + + grid_columns = [ + 'id', + 'created', + 'terminal_id', + 'cashier', + 'customer', + 'rowcount', + 'sales_total', + 'void', + 'status_code', + 'executed', + ] + + form_fields = [ + 'id', + 'terminal_id', + 'cashier', + 'customer', + 'customer_is_member', + 'customer_is_employee', + 'params', + 'rowcount', + 'sales_total', + 'taxes', + 'tender_total', + 'fs_tender_total', + 'balance', + 'void', + 'training_mode', + 'status_code', + 'created', + 'created_by', + 'executed', + 'executed_by', + ] + + row_grid_columns = [ + 'sequence', + 'row_type', + 'item_entry', + 'description', + 'reg_price', + 'txn_price', + 'quantity', + 'sales_total', + 'tender_total', + 'tax_code', + 'user', + ] + + row_form_fields = [ + 'sequence', + 'row_type', + 'item_entry', + 'product', + 'description', + 'department_number', + 'department_name', + 'reg_price', + 'cur_price', + 'cur_price_type', + 'cur_price_start', + 'cur_price_end', + 'txn_price', + 'txn_price_adjusted', + 'quantity', + 'sales_total', + 'tax_code', + 'tender_total', + 'tender', + 'void', + 'status_code', + 'timestamp', + 'user', + ] + + def configure_grid(self, g): + super().configure_grid(g) + model = self.model + + # terminal_id + g.set_label('terminal_id', "Terminal") + if 'terminal_id' in g.filters: + g.filters['terminal_id'].label = self.labels.get('terminal_id', "Terminal ID") + + # cashier + def join_cashier(q): + return q.outerjoin(model.Employee, + model.Employee.uuid == model.POSBatch.cashier_uuid)\ + .outerjoin(model.Person, + model.Person.uuid == model.Employee.person_uuid) + g.set_joiner('cashier', join_cashier) + g.set_sorter('cashier', model.Person.display_name) + + # customer + g.set_link('customer') + g.set_joiner('customer', lambda q: q.outerjoin(model.Customer)) + g.set_sorter('customer', model.Customer.name) + + g.set_link('created') + g.set_link('created_by') + + g.set_type('sales_total', 'currency') + g.set_type('tender_total', 'currency') + g.set_type('fs_tender_total', 'currency') + + # executed + # nb. default view should show "all recent" batches regardless + # of execution (i think..) + if 'executed' in g.filters: + g.filters['executed'].default_active = False + + def grid_extra_class(self, batch, i): + if batch.void: + return 'warning' + if (batch.training_mode + or batch.status_code == batch.STATUS_SUSPENDED): + return 'notice' + + def configure_form(self, f): + super().configure_form(f) + app = self.get_rattail_app() + + # cashier + f.set_renderer('cashier', self.render_employee) + + # customer + f.set_renderer('customer', self.render_customer) + + f.set_type('sales_total', 'currency') + f.set_type('tender_total', 'currency') + f.set_type('fs_tender_total', 'currency') + + if self.viewing: + f.set_renderer('taxes', self.render_taxes) + + f.set_renderer('balance', lambda batch, field: app.render_currency(batch.get_balance())) + + def render_taxes(self, batch, field): + route_prefix = self.get_route_prefix() + + factory = self.get_grid_factory() + g = factory( + self.request, + key=f'{route_prefix}.taxes', + data=[], + columns=[ + 'code', + 'description', + 'rate', + 'total', + ], + ) + + return HTML.literal( + g.render_table_element(data_prop='taxesData')) + + def template_kwargs_view(self, **kwargs): + kwargs = super().template_kwargs_view(**kwargs) + app = self.get_rattail_app() + batch = kwargs['instance'] + + taxes = [] + for btax in batch.taxes.values(): + data = { + 'uuid': btax.uuid, + 'code': btax.tax_code, + 'description': btax.tax.description, + 'rate': app.render_percent(btax.tax_rate), + 'total': app.render_currency(btax.tax_total), + } + taxes.append(data) + taxes.sort(key=lambda t: t['code']) + kwargs['taxes_data'] = taxes + + kwargs['execute_enabled'] = False + kwargs['why_not_execute'] = "POS batch must be executed at POS" + + return kwargs + + def configure_row_grid(self, g): + super().configure_row_grid(g) + + g.set_enum('row_type', self.enum.POS_ROW_TYPE) + + g.set_type('quantity', 'quantity') + g.set_type('reg_price', 'currency') + g.set_type('txn_price', 'currency') + g.set_type('sales_total', 'currency') + g.set_type('tender_total', 'currency') + + g.set_link('product') + g.set_link('description') + + def row_grid_extra_class(self, row, i): + if row.void: + return 'warning' + + def configure_row_form(self, f): + super().configure_row_form(f) + + f.set_enum('row_type', self.enum.POS_ROW_TYPE) + + f.set_renderer('product', self.render_product) + f.set_renderer('tender', self.render_tender) + + f.set_type('quantity', 'quantity') + f.set_type('reg_price', 'currency') + f.set_type('txn_price', 'currency') + f.set_type('sales_total', 'currency') + f.set_type('tender_total', 'currency') + + f.set_renderer('user', self.render_user) + + @classmethod + def defaults(cls, config): + cls._batch_defaults(config) + cls._defaults(config) + cls._pos_batch_defaults(config) + + @classmethod + def _pos_batch_defaults(cls, config): + rattail_config = config.registry.settings.get('rattail_config') + + if rattail_config.getbool('tailbone', 'expose_pos_permissions', + default=False): + + config.add_tailbone_permission_group('pos', "POS", overwrite=False) + + config.add_tailbone_permission('pos', 'pos.test_error', + "Force error to test error handling") + config.add_tailbone_permission('pos', 'pos.ring_sales', + "Make transactions (ring up sales)") + config.add_tailbone_permission('pos', 'pos.override_price', + "Override price for any item") + config.add_tailbone_permission('pos', 'pos.del_customer', + "Remove customer from current transaction") + # config.add_tailbone_permission('pos', 'pos.resume', + # "Resume previously-suspended transaction") + config.add_tailbone_permission('pos', 'pos.toggle_training', + "Start/end training mode") + config.add_tailbone_permission('pos', 'pos.suspend', + "Suspend current transaction") + config.add_tailbone_permission('pos', 'pos.swap_customer', + "Swap customer for current transaction") + config.add_tailbone_permission('pos', 'pos.void_txn', + "Void current transaction") + + +def defaults(config, **kwargs): + base = globals() + + POSBatchView = kwargs.get('POSBatchView', base['POSBatchView']) + POSBatchView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/batch/pricing.py b/tailbone/views/batch/pricing.py index 6ba28889..5b5d013b 100644 --- a/tailbone/views/batch/pricing.py +++ b/tailbone/views/batch/pricing.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,10 +24,6 @@ Views for pricing batches """ -from __future__ import unicode_literals, absolute_import - -import six - from rattail.db import model from rattail.time import localtime @@ -155,7 +151,7 @@ class PricingBatchView(BatchMasterView): return self.batch_handler.allow_future() def configure_form(self, f): - super(PricingBatchView, self).configure_form(f) + super().configure_form(f) app = self.get_rattail_app() batch = f.model_instance @@ -192,7 +188,7 @@ class PricingBatchView(BatchMasterView): f.set_required('input_filename', False) def get_batch_kwargs(self, batch, **kwargs): - kwargs = super(PricingBatchView, self).get_batch_kwargs(batch, **kwargs) + kwargs = super().get_batch_kwargs(batch, **kwargs) kwargs['start_date'] = batch.start_date kwargs['min_diff_threshold'] = batch.min_diff_threshold kwargs['min_diff_percent'] = batch.min_diff_percent @@ -213,7 +209,7 @@ class PricingBatchView(BatchMasterView): return kwargs def configure_row_grid(self, g): - super(PricingBatchView, self).configure_row_grid(g) + super().configure_row_grid(g) g.set_joiner('vendor_id', lambda q: q.outerjoin(model.Vendor)) g.set_sorter('vendor_id', model.Vendor.id) @@ -241,13 +237,13 @@ class PricingBatchView(BatchMasterView): if row.subdepartment_number: if row.subdepartment_name: return HTML.tag('span', title=row.subdepartment_name, - c=six.text_type(row.subdepartment_number)) + c=str(row.subdepartment_number)) return row.subdepartment_number def render_true_margin(self, row, field): margin = row.true_margin if margin: - margin = six.text_type(margin) + margin = str(margin) else: margin = HTML.literal(' ') if row.old_true_margin is not None: @@ -295,7 +291,7 @@ class PricingBatchView(BatchMasterView): return HTML.tag('span', title=title, c=text) def configure_row_form(self, f): - super(PricingBatchView, self).configure_row_form(f) + super().configure_row_form(f) # readonly fields f.set_readonly('product') @@ -328,7 +324,7 @@ class PricingBatchView(BatchMasterView): return tags.link_to(text, url) def get_row_csv_fields(self): - fields = super(PricingBatchView, self).get_row_csv_fields() + fields = super().get_row_csv_fields() if 'vendor_uuid' in fields: i = fields.index('vendor_uuid') @@ -344,7 +340,7 @@ class PricingBatchView(BatchMasterView): # TODO: this is the same as xlsx row! should merge/share somehow? def get_row_csv_row(self, row, fields): - csvrow = super(PricingBatchView, self).get_row_csv_row(row, fields) + csvrow = super().get_row_csv_row(row, fields) vendor = row.vendor if 'vendor_id' in fields: @@ -358,7 +354,7 @@ class PricingBatchView(BatchMasterView): # TODO: this is the same as csv row! should merge/share somehow? def get_row_xlsx_row(self, row, fields): - xlrow = super(PricingBatchView, self).get_row_xlsx_row(row, fields) + xlrow = super().get_row_xlsx_row(row, fields) vendor = row.vendor if 'vendor_id' in fields: diff --git a/tailbone/views/batch/product.py b/tailbone/views/batch/product.py index 50b18953..590c3ff0 100644 --- a/tailbone/views/batch/product.py +++ b/tailbone/views/batch/product.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2020 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,15 +24,14 @@ Views for generic product batches """ -from __future__ import unicode_literals, absolute_import +from collections import OrderedDict -from rattail.db import model -from rattail.util import OrderedDict +from rattail.db.model import ProductBatch, ProductBatchRow import colander +from deform import widget as dfwidget from webhelpers2.html import HTML -from tailbone import forms from tailbone.views.batch import BatchMasterView @@ -47,15 +46,15 @@ class ExecutionOptions(colander.Schema): action = colander.SchemaNode( colander.String(), validator=colander.OneOf(ACTION_OPTIONS), - widget=forms.widgets.PlainSelectWidget(values=ACTION_OPTIONS.items())) + widget=dfwidget.SelectWidget(values=list(ACTION_OPTIONS.items()))) class ProductBatchView(BatchMasterView): """ Master view for product batches. """ - model_class = model.ProductBatch - model_row_class = model.ProductBatchRow + model_class = ProductBatch + model_row_class = ProductBatchRow default_handler_spec = 'rattail.batch.product:ProductBatchHandler' route_prefix = 'batch.product' url_prefix = '/batches/product' @@ -130,7 +129,7 @@ class ProductBatchView(BatchMasterView): ] def configure_form(self, f): - super(ProductBatchView, self).configure_form(f) + super().configure_form(f) # input_filename if self.creating: @@ -140,7 +139,8 @@ class ProductBatchView(BatchMasterView): f.set_renderer('input_filename', self.render_downloadable_file) def configure_row_grid(self, g): - super(ProductBatchView, self).configure_row_grid(g) + super().configure_row_grid(g) + model = self.model g.set_joiner('vendor', lambda q: q.outerjoin(model.Vendor)) g.set_sorter('vendor', model.Vendor.name) @@ -166,7 +166,7 @@ class ProductBatchView(BatchMasterView): return 'warning' def configure_row_form(self, f): - super(ProductBatchView, self).configure_row_form(f) + super().configure_row_form(f) f.set_type('upc', 'gpc') @@ -205,10 +205,10 @@ class ProductBatchView(BatchMasterView): return self.request.route_url('labels.batch.view', uuid=result.uuid) elif kwargs['action'] == 'make_pricing_batch': return self.request.route_url('batch.pricing.view', uuid=result.uuid) - return super(ProductBatchView, self).get_execute_success_url(batch) + return super().get_execute_success_url(batch) def get_row_csv_fields(self): - fields = super(ProductBatchView, self).get_row_csv_fields() + fields = super().get_row_csv_fields() if 'vendor_uuid' in fields: i = fields.index('vendor_uuid') @@ -274,12 +274,12 @@ class ProductBatchView(BatchMasterView): data['report_name'] = (report.name or '') if report else '' def get_row_csv_row(self, row, fields): - csvrow = super(ProductBatchView, self).get_row_csv_row(row, fields) + csvrow = super().get_row_csv_row(row, fields) self.supplement_row_data(row, fields, csvrow) return csvrow def get_row_xlsx_row(self, row, fields): - xlrow = super(ProductBatchView, self).get_row_xlsx_row(row, fields) + xlrow = super().get_row_xlsx_row(row, fields) self.supplement_row_data(row, fields, xlrow) return xlrow diff --git a/tailbone/views/batch/vendorcatalog.py b/tailbone/views/batch/vendorcatalog.py index ba4d3482..ec8da979 100644 --- a/tailbone/views/batch/vendorcatalog.py +++ b/tailbone/views/batch/vendorcatalog.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,12 +24,8 @@ Views for maintaining vendor catalogs """ -from __future__ import unicode_literals, absolute_import - import logging -import six - from rattail.db import model import colander @@ -196,9 +192,6 @@ class VendorCatalogView(FileBatchMasterView): values = [(p.key, p.display) for p in parsers] if len(values) == 1: f.set_default('parser_key', parsers[0].key) - use_buefy = self.get_use_buefy() - if not use_buefy: - values.insert(0, ('', "(please choose)")) f.set_widget('parser_key', dfwidget.SelectWidget(values=values)) else: f.set_readonly('parser_key') @@ -209,6 +202,8 @@ class VendorCatalogView(FileBatchMasterView): if self.creating and 'vendor' in f: f.replace('vendor', 'vendor_uuid') f.set_label('vendor_uuid', "Vendor") + f.set_required('vendor_uuid') + f.set_validator('vendor_uuid', self.valid_vendor_uuid) # should we use dropdown or autocomplete? note that if # autocomplete is to be used, we also must make sure we @@ -232,10 +227,10 @@ class VendorCatalogView(FileBatchMasterView): vendor_display = "" if self.request.method == 'POST': if self.request.POST.get('vendor_uuid'): - vendor = self.Session.query(model.Vendor).get( - self.request.POST['vendor_uuid']) + vendor = self.Session.get(model.Vendor, + self.request.POST['vendor_uuid']) if vendor: - vendor_display = six.text_type(vendor) + vendor_display = str(vendor) f.set_widget('vendor_uuid', forms.widgets.JQueryAutocompleteWidget( field_display=vendor_display, @@ -275,35 +270,20 @@ class VendorCatalogView(FileBatchMasterView): return parser.display def template_kwargs_create(self, **kwargs): - use_buefy = self.get_use_buefy() app = self.get_rattail_app() vendor_handler = app.get_vendor_handler() parsers = self.get_parsers() parsers_data = {} for parser in parsers: - if use_buefy: - pdata = {'key': parser.key, - 'vendor_key': parser.vendor_key} - if parser.vendor_key: - vendor = vendor_handler.get_vendor(self.Session(), - parser.vendor_key) - if vendor: - pdata['vendor_uuid'] = vendor.uuid - pdata['vendor_name'] = vendor.name - parsers_data[parser.key] = pdata - else: - if parser.vendor_key: - vendor = vendor_handler.get_vendor(self.Session(), - parser.vendor_key) - if vendor: - parser.vendormap_value = "{{uuid: '{}', name: '{}'}}".format( - vendor.uuid, vendor.name.replace("'", "\\'")) - else: - log.warning("vendor '{}' not found for parser: {}".format( - parser.vendor_key, parser.key)) - parser.vendormap_value = 'null' - else: - parser.vendormap_value = 'null' + pdata = {'key': parser.key, + 'vendor_key': parser.vendor_key} + if parser.vendor_key: + vendor = vendor_handler.get_vendor(self.Session(), + parser.vendor_key) + if vendor: + pdata['vendor_uuid'] = vendor.uuid + pdata['vendor_name'] = vendor.name + parsers_data[parser.key] = pdata kwargs['parsers'] = parsers kwargs['parsers_data'] = parsers_data return kwargs diff --git a/tailbone/views/batch/vendorinvoice.py b/tailbone/views/batch/vendorinvoice.py index 6b8bdef7..4815d1f4 100644 --- a/tailbone/views/batch/vendorinvoice.py +++ b/tailbone/views/batch/vendorinvoice.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,10 +24,6 @@ Views for maintaining vendor invoices """ -from __future__ import unicode_literals, absolute_import - -import six - from rattail.db import model from rattail.vendors.invoices import iter_invoice_parsers, require_invoice_parser @@ -89,10 +85,10 @@ class VendorInvoiceView(FileBatchMasterView): ] def get_instance_title(self, batch): - return six.text_type(batch.vendor) + return str(batch.vendor) def configure_grid(self, g): - super(VendorInvoiceView, self).configure_grid(g) + super().configure_grid(g) # vendor g.set_joiner('vendor', lambda q: q.join(model.Vendor)) @@ -118,7 +114,7 @@ class VendorInvoiceView(FileBatchMasterView): g.set_link('executed', False) def configure_form(self, f): - super(VendorInvoiceView, self).configure_form(f) + super().configure_form(f) # vendor if self.creating: @@ -167,7 +163,7 @@ class VendorInvoiceView(FileBatchMasterView): # raise formalchemy.ValidationError(unicode(error)) def get_batch_kwargs(self, batch): - kwargs = super(VendorInvoiceView, self).get_batch_kwargs(batch) + kwargs = super().get_batch_kwargs(batch) kwargs['parser_key'] = batch.parser_key return kwargs @@ -183,7 +179,7 @@ class VendorInvoiceView(FileBatchMasterView): return True def configure_row_grid(self, g): - super(VendorInvoiceView, self).configure_row_grid(g) + super().configure_row_grid(g) g.set_label('upc', "UPC") g.set_label('brand_name', "Brand") g.set_label('shipped_cases', "Cases") diff --git a/tailbone/views/bouncer.py b/tailbone/views/bouncer.py index 628ed07c..7afcc567 100644 --- a/tailbone/views/bouncer.py +++ b/tailbone/views/bouncer.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,18 +24,12 @@ Views for Email Bounces """ -from __future__ import unicode_literals, absolute_import - import os import datetime -import six - from rattail.db import model -from rattail.bouncer import get_handler from rattail.bouncer.config import get_profile_keys -from pyramid.response import FileResponse from webhelpers2.html import HTML, tags from tailbone.views import MasterView @@ -50,6 +44,7 @@ class EmailBounceView(MasterView): url_prefix = '/email-bounces' creatable = False editable = False + downloadable = True labels = { 'config_key': "Source", @@ -66,24 +61,29 @@ class EmailBounceView(MasterView): ] def __init__(self, request): - super(EmailBounceView, self).__init__(request) + super().__init__(request) self.handler_options = sorted(get_profile_keys(self.rattail_config)) def get_handler(self, bounce): - return get_handler(self.rattail_config, bounce.config_key) + app = self.get_rattail_app() + return app.get_bounce_handler(bounce.config_key) def configure_grid(self, g): - super(EmailBounceView, self).configure_grid(g) + super().configure_grid(g) + model = self.model g.filters['config_key'].set_choices(self.handler_options) g.filters['config_key'].default_active = True g.filters['config_key'].default_verb = 'equal' - g.joiners['processed_by'] = lambda q: q.outerjoin(model.User) g.filters['processed'].default_active = True g.filters['processed'].default_verb = 'is_null' - g.filters['processed_by'] = g.make_filter('processed_by', model.User.username) - g.sorters['processed_by'] = g.make_sorter(model.User.username) + + # processed_by + g.set_joiner('processed_by', lambda q: q.outerjoin(model.User)) + g.set_sorter('processed_by', model.User.username) + g.set_filter('processed_by', model.User.username) + g.set_sort_defaults('bounced', 'desc') g.set_label('bounce_recipient_address', "Bounced To") @@ -93,7 +93,7 @@ class EmailBounceView(MasterView): g.set_link('intended_recipient_address') def configure_form(self, f): - super(EmailBounceView, self).configure_form(f) + super().configure_form(f) bounce = f.model_instance f.set_renderer('message', self.render_message_file) f.set_renderer('links', self.render_links) @@ -142,11 +142,16 @@ class EmailBounceView(MasterView): path = handler.msgpath(bounce) if os.path.exists(path): with open(path, 'rb') as f: - kwargs['message'] = f.read() + # TODO: how to determine encoding? (is utf_8 guaranteed?) + kwargs['message'] = f.read().decode('utf_8') else: kwargs['message'] = "(file not found)" return kwargs + def download_path(self, bounce, filename): + handler = self.get_handler(bounce) + return handler.msgpath(bounce) + # TODO: should require POST here def process(self): """ @@ -169,20 +174,13 @@ class EmailBounceView(MasterView): self.request.session.flash("Email bounce has been marked UN-processed.") return self.redirect(self.get_action_url('view', bounce)) - def download(self): - """ - View for downloading the message file associated with a bounce. - """ - bounce = self.get_instance() - handler = self.get_handler(bounce) - path = handler.msgpath(bounce) - response = FileResponse(path, request=self.request) - response.headers[b'Content-Length'] = six.binary_type(os.path.getsize(path)) - response.headers[b'Content-Disposition'] = b'attachment; filename="bounce.eml"' - return response - @classmethod def defaults(cls, config): + cls._bounce_defaults(config) + cls._defaults(config) + + @classmethod + def _bounce_defaults(cls, config): config.add_tailbone_permission_group('emailbounces', "Email Bounces", overwrite=False) @@ -200,15 +198,6 @@ class EmailBounceView(MasterView): config.add_tailbone_permission('emailbounces', 'emailbounces.unprocess', "Mark Email Bounce as UN-processed") - # download raw email - config.add_route('emailbounces.download', '/email-bounces/{uuid}/download') - config.add_view(cls, attr='download', route_name='emailbounces.download', - permission='emailbounces.download') - config.add_tailbone_permission('emailbounces', 'emailbounces.download', - "Download raw message of Email Bounce") - - cls._defaults(config) - def defaults(config, **kwargs): base = globals() diff --git a/tailbone/views/common.py b/tailbone/views/common.py index f531b48e..f4d98c05 100644 --- a/tailbone/views/common.py +++ b/tailbone/views/common.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,18 +24,14 @@ Various common views """ -from __future__ import unicode_literals, absolute_import - import os -import six +import warnings +from collections import OrderedDict from rattail.batch import consume_batch_id -from rattail.util import OrderedDict, simple_error, import_module_path +from rattail.util import get_pkg_version, simple_error from rattail.files import resource_path -from pyramid import httpexceptions -from pyramid.response import Response - from tailbone import forms from tailbone.forms.common import Feedback from tailbone.db import Session @@ -54,28 +50,62 @@ class CommonView(View): """ Home page view. """ + app = self.get_rattail_app() + + # maybe auto-redirect anons to login if not self.request.user: - if self.rattail_config.getbool('tailbone', 'login_is_home', default=True): - raise self.redirect(self.request.route_url('login')) + redirect = self.config.get_bool('wuttaweb.home_redirect_to_login') + if redirect is None: + redirect = self.config.get_bool('tailbone.login_is_home') + if redirect is not None: + warnings.warn("tailbone.login_is_home setting is deprecated; " + "please set wuttaweb.home_redirect_to_login instead", + DeprecationWarning) + else: + # TODO: this is opposite of upstream default, should change + redirect = True + if redirect: + return self.redirect(self.request.route_url('login')) - image_url = self.rattail_config.get( - 'tailbone', 'main_image_url', - default=self.request.static_url('tailbone:static/img/home_logo.png')) + image_url = self.config.get('wuttaweb.logo_url') + if not image_url: + image_url = self.config.get('tailbone.main_image_url') + if image_url: + warnings.warn("tailbone.main_image_url setting is deprecated; " + "please set wuttaweb.logo_url instead", + DeprecationWarning) + else: + image_url = self.request.static_url('tailbone:static/img/home_logo.png') - use_buefy = self.get_use_buefy() context = { 'image_url': image_url, - 'use_buefy': use_buefy, + 'index_title': app.get_node_title(), 'help_url': global_help_url(self.rattail_config), } - if use_buefy: - context['index_title'] = self.rattail_config.node_title() - if self.expose_quickie_search: + if self.should_expose_quickie_search(): context['quickie'] = self.get_quickie_context() return context + # nb. this is only invoked from home() view + def should_expose_quickie_search(self): + if self.expose_quickie_search: + return True + # TODO: for now we are assuming *people* search + app = self.get_rattail_app() + return app.get_people_handler().should_expose_quickie_search() + + def get_quickie_perm(self): + return 'people.quickie' + + def get_quickie_url(self): + return self.request.route_url('people.quickie') + + def get_quickie_placeholder(self): + app = self.get_rattail_app() + return app.get_people_handler().get_quickie_search_placeholder() + def robots_txt(self): """ Returns a basic 'robots.txt' response @@ -83,16 +113,13 @@ class CommonView(View): with open(self.robots_txt_path, 'rt') as f: content = f.read() response = self.request.response - if six.PY3: - response.text = content - response.content_type = 'text/plain' - else: - response.body = content - response.content_type = b'text/plain' + response.text = content + response.content_type = 'text/plain' return response def get_project_title(self): - return self.rattail_config.app_title() + app = self.get_rattail_app() + return app.get_title() def get_project_version(self): @@ -100,9 +127,8 @@ class CommonView(View): if hasattr(self, 'project_version'): return self.project_version - pkg = self.rattail_config.app_package() - mod = import_module_path(pkg) - return mod.__version__ + app = self.get_rattail_app() + return app.get_version() def exception(self): """ @@ -114,26 +140,22 @@ class CommonView(View): """ Generic view to show "about project" info page. """ - use_buefy = self.get_use_buefy() - context = { + app = self.get_rattail_app() + return { 'project_title': self.get_project_title(), 'project_version': self.get_project_version(), 'packages': self.get_packages(), - 'use_buefy': use_buefy, + 'index_title': app.get_node_title(), } - if use_buefy: - context['index_title'] = self.rattail_config.node_title() - return context def get_packages(self): """ Should return the full set of packages which should be displayed on the 'about' page. """ - import rattail, tailbone return OrderedDict([ - ('rattail', rattail.__version__), - ('Tailbone', tailbone.__version__), + ('rattail', get_pkg_version('rattail')), + ('Tailbone', get_pkg_version('Tailbone')), ]) def change_theme(self): @@ -148,9 +170,8 @@ class CommonView(View): except Exception as error: msg = "Failed to set theme: {}: {}".format(error.__class__.__name__, error) self.request.session.flash(msg, 'error') - else: - self.request.session.flash("App theme has been changed to: {}".format(theme)) - return self.redirect(self.request.get_referrer()) + referrer = self.request.params.get('referrer') or self.request.get_referrer() + return self.redirect(referrer) def change_db_engine(self): """ @@ -174,15 +195,16 @@ class CommonView(View): model = self.model schema = Feedback().bind(session=Session()) form = forms.Form(schema=schema, request=self.request) - if form.validate(newstyle=True): + if form.validate(): data = dict(form.validated) if data['user']: - data['user'] = Session.query(model.User).get(data['user']) + data['user'] = Session.get(model.User, data['user']) data['user_url'] = self.request.route_url('users.view', uuid=data['user'].uuid) data['client_ip'] = self.request.client_addr app.send_email('user_feedback', data=data) return {'ok': True} - return {'error': "Form did not validate!"} + dform = form.make_deform_form() + return {'error': str(dform.error)} def consume_batch_id(self): """ @@ -203,9 +225,8 @@ class CommonView(View): if not self.request.is_root: raise self.forbidden() - use_buefy = self.get_use_buefy() app = self.get_rattail_app() - app_title = self.rattail_config.app_title() + app_title = app.get_title() poser_handler = app.get_poser_handler() poser_dir = poser_handler.get_default_poser_dir() poser_dir_exists = os.path.isdir(poser_dir) @@ -253,7 +274,6 @@ class CommonView(View): poser_error = simple_error(error) return { - 'use_buefy': use_buefy, 'app_title': app_title, 'index_title': app_title, 'poser_dir': poser_dir, @@ -287,6 +307,13 @@ class CommonView(View): # permissions config.add_tailbone_permission_group('common', "(common)", overwrite=False) + config.add_tailbone_permission('common', 'common.edit_help', + "Edit help info for *any* page") + + # API swagger + if rattail_config.getbool('tailbone', 'expose_api_swagger'): + config.add_tailbone_permission('common', 'common.api_swagger', + "Explore the API with Swagger tools") # home config.add_route('home', '/') diff --git a/tailbone/views/core.py b/tailbone/views/core.py index c0f03e19..88b2519f 100644 --- a/tailbone/views/core.py +++ b/tailbone/views/core.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,16 +24,8 @@ Base View Class """ -from __future__ import unicode_literals, absolute_import - import os -import six - -from rattail.db import model -from rattail.core import Object -from rattail.util import progress_loop - from pyramid import httpexceptions from pyramid.renderers import render_to_response from pyramid.response import FileResponse @@ -41,11 +33,10 @@ from pyramid.response import FileResponse from tailbone.db import Session from tailbone.auth import logout_user from tailbone.progress import SessionProgress -from tailbone.util import should_use_buefy from tailbone.config import protected_usernames -class View(object): +class View: """ Base class for all class-based views. """ @@ -67,8 +58,10 @@ class View(object): config = self.rattail_config if config: - self.enum = config.get_enum() - self.model = config.get_model() + self.config = config + self.app = self.config.get_app() + self.model = self.app.model + self.enum = self.app.enum @property def rattail_config(self): @@ -94,22 +87,16 @@ class View(object): def notfound(self): return httpexceptions.HTTPNotFound() - def get_use_buefy(self): - """ - Returns a flag indicating whether or not the current theme supports - (and therefore should use) the Buefy JS library. - """ - return should_use_buefy(self.request) - def late_login_user(self): """ Returns the :class:`rattail:rattail.db.model.User` instance corresponding to the "late login" form data (if any), or ``None``. """ + model = self.model if self.request.method == 'POST': uuid = self.request.POST.get('late-login-user') if uuid: - return Session.query(model.User).get(uuid) + return Session.get(model.User, uuid) def user_is_protected(self, user): """ @@ -132,7 +119,8 @@ class View(object): return httpexceptions.HTTPFound(location=url, **kwargs) def progress_loop(self, func, items, factory, *args, **kwargs): - return progress_loop(func, items, factory, *args, **kwargs) + app = self.get_rattail_app() + return app.progress_loop(func, items, factory, *args, **kwargs) def make_progress(self, key, **kwargs): """ @@ -170,13 +158,15 @@ class View(object): if attachment: if not filename: filename = os.path.basename(path) - if six.PY2: - filename = filename.encode('ascii', 'replace') response.content_disposition = str('attachment; filename="{}"'.format(filename)) return response + def should_expose_quickie_search(self): + return self.expose_quickie_search + def get_quickie_context(self): - return Object( + app = self.get_rattail_app() + return app.make_object( url=self.get_quickie_url(), perm=self.get_quickie_perm(), placeholder=self.get_quickie_placeholder()) diff --git a/tailbone/views/customers.py b/tailbone/views/customers.py index a905ea07..7e49ccef 100644 --- a/tailbone/views/customers.py +++ b/tailbone/views/customers.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,10 +24,10 @@ Customer Views """ -from __future__ import unicode_literals, absolute_import +from collections import OrderedDict -import six import sqlalchemy as sa +from sqlalchemy import orm import colander from pyramid.httpexceptions import HTTPNotFound @@ -37,14 +37,14 @@ from tailbone import grids from tailbone.db import Session from tailbone.views import MasterView -from rattail.db import model +from rattail.db.model import Customer, CustomerShopper, PendingCustomer class CustomerView(MasterView): """ Master view for the Customer class. """ - model_class = model.Customer + model_class = Customer is_contact = True has_versions = True results_downloadable = True @@ -58,6 +58,7 @@ class CustomerView(MasterView): labels = { 'id': "ID", + 'name': "Account Name", 'default_phone': "Phone Number", 'default_email': "Email Address", 'default_address': "Physical Address", @@ -66,17 +67,16 @@ class CustomerView(MasterView): } grid_columns = [ - 'id', - 'number', + '_customer_key_', 'name', 'phone', 'email', ] form_fields = [ - 'id', - 'number', + '_customer_key_', 'name', + 'account_holder', 'default_phone', 'default_address', 'address_street', @@ -89,6 +89,7 @@ class CustomerView(MasterView): 'wholesale', 'active_in_pos', 'active_in_pos_sticky', + 'shoppers', 'people', 'groups', 'members', @@ -106,6 +107,22 @@ class CustomerView(MasterView): 'name', ] + def should_expose_quickie_search(self): + if self.expose_quickie_search: + return True + app = self.get_rattail_app() + return app.get_people_handler().should_expose_quickie_search() + + def get_quickie_perm(self): + return 'people.quickie' + + def get_quickie_url(self): + return self.request.route_url('people.quickie') + + def get_quickie_placeholder(self): + app = self.get_rattail_app() + return app.get_people_handler().get_quickie_search_placeholder() + def get_expose_active_in_pos(self): if not hasattr(self, '_expose_active_in_pos'): self._expose_active_in_pos = self.rattail_config.getbool( @@ -113,56 +130,112 @@ class CustomerView(MasterView): default=False) return self._expose_active_in_pos + # TODO: this is duplicated in people view module + def should_expose_shoppers(self): + return self.rattail_config.getbool('rattail', + 'customers.expose_shoppers', + default=True) + + # TODO: this is duplicated in people view module + def should_expose_people(self): + return self.rattail_config.getbool('rattail', + 'customers.expose_people', + default=True) + + def query(self, session): + query = super().query(session) + app = self.get_rattail_app() + model = self.model + query = query.outerjoin(model.Person, + model.Person.uuid == model.Customer.account_holder_uuid) + return query + def configure_grid(self, g): - super(CustomerView, self).configure_grid(g) + super().configure_grid(g) + app = self.get_rattail_app() + model = self.model + route_prefix = self.get_route_prefix() + + # customer key + field = self.get_customer_key_field() + g.filters[field].default_active = True + g.filters[field].default_verb = 'equal' + g.set_sort_defaults(field) + g.set_link(field) # name g.filters['name'].default_active = True g.filters['name'].default_verb = 'contains' - g.set_sort_defaults('name') # phone + g.set_label('phone', "Phone Number") g.set_joiner('phone', lambda q: q.outerjoin(model.CustomerPhoneNumber, sa.and_( model.CustomerPhoneNumber.parent_uuid == model.Customer.uuid, model.CustomerPhoneNumber.preference == 1))) - g.sorters['phone'] = lambda q, d: q.order_by(getattr(model.CustomerPhoneNumber.number, d)()) + g.set_sorter('phone', model.CustomerPhoneNumber.number) g.set_filter('phone', model.CustomerPhoneNumber.number, # label="Phone Number", factory=grids.filters.AlchemyPhoneNumberFilter) - g.set_label('phone', "Phone Number") # email + g.set_label('email', "Email Address") g.set_joiner('email', lambda q: q.outerjoin(model.CustomerEmailAddress, sa.and_( model.CustomerEmailAddress.parent_uuid == model.Customer.uuid, model.CustomerEmailAddress.preference == 1))) - g.sorters['email'] = lambda q, d: q.order_by(getattr(model.CustomerEmailAddress.address, d)()) + g.set_sorter('email', model.CustomerEmailAddress.address) g.set_filter('email', model.CustomerEmailAddress.address)#, label="Email Address") - g.set_label('email', "Email Address") # email_preference g.set_enum('email_preference', self.enum.EMAIL_PREFERENCE) + # account_holder_*_name + g.set_filter('account_holder_first_name', model.Person.first_name) + g.set_filter('account_holder_last_name', model.Person.last_name) + # person - g.set_joiner('person', lambda q: - q.outerjoin(model.CustomerPerson, - sa.and_( - model.CustomerPerson.customer_uuid == model.Customer.uuid, - model.CustomerPerson.ordinal == 1))\ - .outerjoin(model.Person)) - g.set_sorter('person', model.Person.display_name) g.set_renderer('person', self.grid_render_person) + g.set_sorter('person', model.Person.display_name) # active_in_pos if self.get_expose_active_in_pos(): g.filters['active_in_pos'].default_active = True g.filters['active_in_pos'].default_verb = 'is_true' - g.set_link('id') - g.set_link('number') + if (self.request.has_perm('people.view_profile') + and self.should_link_straight_to_profile()): + + # add View Raw action + url = lambda r, i: self.request.route_url( + f'{route_prefix}.view', **self.get_action_route_kwargs(r)) + # nb. insert to slot 1, just after normal View action + g.actions.insert(1, self.make_action('view_raw', url=url, icon='eye')) + g.set_link('name') g.set_link('person') g.set_link('email') + def default_view_url(self): + if (self.request.has_perm('people.view_profile') + and self.should_link_straight_to_profile()): + app = self.get_rattail_app() + + def url(customer, i): + person = app.get_person(customer) + if person: + return self.request.route_url( + 'people.view_profile', uuid=person.uuid, + _anchor='customer') + return self.get_action_url('view', customer) + + return url + + return super().default_view_url() + + def should_link_straight_to_profile(self): + return self.rattail_config.getbool('rattail', + 'customers.straight_to_profile', + default=False) + def grid_extra_class(self, customer, i): if self.get_expose_active_in_pos(): if not customer.active_in_pos: @@ -170,13 +243,14 @@ class CustomerView(MasterView): def get_instance(self): try: - instance = super(CustomerView, self).get_instance() + instance = super().get_instance() except HTTPNotFound: pass else: if instance: return instance + model = self.model key = self.request.matchdict['uuid'] # search by Customer.id @@ -187,27 +261,35 @@ class CustomerView(MasterView): return instance # search by CustomerPerson.uuid - instance = self.Session.query(model.CustomerPerson).get(key) + instance = self.Session.get(model.CustomerPerson, key) if instance: return instance.customer # search by CustomerGroupAssignment.uuid - instance = self.Session.query(model.CustomerGroupAssignment).get(key) + instance = self.Session.get(model.CustomerGroupAssignment, key) if instance: return instance.customer - raise HTTPNotFound + raise self.notfound() - def configure_common_form(self, f): - super(CustomerView, self).configure_common_form(f) + def configure_form(self, f): + super().configure_form(f) customer = f.model_instance permission_prefix = self.get_permission_prefix() - use_buefy = self.get_use_buefy() + # account_holder + if self.creating: + f.remove_field('account_holder') + else: + f.set_readonly('account_holder') + f.set_renderer('account_holder', self.render_person) + + # default_email f.set_renderer('default_email', self.render_default_email) if not self.creating and customer.emails: f.set_default('default_email', customer.emails[0].address) + # default_phone f.set_renderer('default_phone', self.render_default_phone) if not self.creating and customer.phones: f.set_default('default_phone', customer.phones[0].number) @@ -234,6 +316,7 @@ class CustomerView(MasterView): f.set_default('address_state', addr.state) f.set_default('address_zipcode', addr.zipcode) + # email_preference f.set_enum('email_preference', self.enum.EMAIL_PREFERENCE) preferences = list(self.enum.EMAIL_PREFERENCE.items()) preferences.insert(0, ('', "(no preference)")) @@ -246,14 +329,21 @@ class CustomerView(MasterView): f.set_readonly('person') f.set_renderer('person', self.form_render_person) - # people - if self.viewing: - if use_buefy: - f.set_renderer('people', self.render_people_buefy) - elif self.people_detachable and self.has_perm('detach_person'): - f.set_renderer('people', self.render_people_removable) + # shoppers + if self.should_expose_shoppers(): + if self.viewing: + f.set_renderer('shoppers', self.render_shoppers) else: + f.remove('shoppers') + else: + f.remove('shoppers') + + # people + if self.should_expose_people(): + if self.viewing: f.set_renderer('people', self.render_people) + else: + f.remove('people') else: f.remove('people') @@ -264,11 +354,7 @@ class CustomerView(MasterView): f.set_renderer('groups', self.render_groups) f.set_readonly('groups') - def configure_form(self, f): - super(CustomerView, self).configure_form(f) - customer = f.model_instance - permission_prefix = self.get_permission_prefix() - + # active_in_pos* if not self.get_expose_active_in_pos(): f.remove('active_in_pos', 'active_in_pos_sticky') @@ -281,13 +367,32 @@ class CustomerView(MasterView): f.set_readonly('members') def template_kwargs_view(self, **kwargs): - kwargs = super(CustomerView, self).template_kwargs_view(**kwargs) + kwargs = super().template_kwargs_view(**kwargs) + customer = kwargs['instance'] - kwargs['show_profiles_helper'] = self.show_profiles_helper + kwargs['expose_shoppers'] = self.should_expose_shoppers() + if kwargs['expose_shoppers']: + shoppers = [] + for shopper in customer.shoppers: + person = shopper.person + active = None + if shopper.active is not None: + active = "Yes" if shopper.active else "No" + data = { + 'uuid': shopper.uuid, + 'shopper_number': shopper.shopper_number, + 'first_name': person.first_name, + 'last_name': person.last_name, + 'full_name': person.display_name, + 'phone': person.first_phone_number(), + 'email': person.first_email_address(), + 'active': active, + } + shoppers.append(data) + kwargs['shoppers_data'] = shoppers - use_buefy = self.get_use_buefy() - if use_buefy: - customer = kwargs['instance'] + kwargs['expose_people'] = self.should_expose_people() + if kwargs['expose_people']: people = [] for person in customer.people: data = { @@ -310,9 +415,28 @@ class CustomerView(MasterView): people.append(data) kwargs['people_data'] = people + kwargs['show_profiles_helper'] = self.show_profiles_helper + if kwargs['show_profiles_helper']: + people = OrderedDict() + + if customer.account_holder: + person = customer.account_holder + people.setdefault(person.uuid, person) + + for shopper in customer.shoppers: + if shopper.active: + person = shopper.person + people.setdefault(person.uuid, person) + + for person in customer.people: + people.setdefault(person.uuid, person) + + kwargs['show_profiles_people'] = list(people.values()) + return kwargs def unique_id(self, node, value): + model = self.model query = self.Session.query(model.Customer)\ .filter(model.Customer.id == value) if self.editing: @@ -323,72 +447,61 @@ class CustomerView(MasterView): def render_default_address(self, customer, field): if customer.addresses: - return six.text_type(customer.addresses[0]) + return str(customer.addresses[0]) def grid_render_person(self, customer, field): person = getattr(customer, field) if not person: return "" - return six.text_type(person) + return str(person) def form_render_person(self, customer, field): person = getattr(customer, field) if not person: return "" - text = six.text_type(person) + text = str(person) url = self.request.route_url('people.view', uuid=person.uuid) return tags.link_to(text, url) - def render_people(self, customer, field): - people = customer.people - if not people: - return "" - - items = [] - for person in people: - text = six.text_type(person) - url = self.request.route_url('people.view', uuid=person.uuid) - link = tags.link_to(text, url) - items.append(HTML.tag('li', c=[link])) - return HTML.tag('ul', c=items) - - def render_people_removable(self, customer, field): - people = customer.people - if not people: - return "" - - route_prefix = self.get_route_prefix() - permission_prefix = self.get_permission_prefix() - - view_url = lambda p, i: self.request.route_url('people.view', uuid=p.uuid) - actions = [ - grids.GridAction('view', icon='zoomin', url=view_url), - ] - if self.people_detachable and self.request.has_perm('{}.detach_person'.format(permission_prefix)): - url = lambda p, i: self.request.route_url('{}.detach_person'.format(route_prefix), - uuid=customer.uuid, person_uuid=p.uuid) - actions.append( - grids.GridAction('detach', icon='trash', url=url)) - - columns = ['first_name', 'last_name', 'display_name'] - g = grids.Grid( - key='{}.people'.format(route_prefix), - data=customer.people, - columns=columns, - labels={'display_name': "Full Name"}, - url=lambda p: self.request.route_url('people.view', uuid=p.uuid), - linked_columns=columns, - main_actions=actions) - return HTML.literal(g.render_grid()) - - def render_people_buefy(self, customer, field): + def render_shoppers(self, customer, field): route_prefix = self.get_route_prefix() permission_prefix = self.get_permission_prefix() factory = self.get_grid_factory() g = factory( - key='{}.people'.format(route_prefix), + self.request, + key=f'{route_prefix}.people', + data=[], + columns=[ + 'shopper_number', + 'first_name', + 'last_name', + 'phone', + 'email', + 'active', + ], + sortable=True, + sorters={'shopper_number': True, + 'first_name': True, + 'last_name': True, + 'phone': True, + 'email': True, + 'active': True}, + labels={'shopper_number': "Shopper #"}, + ) + + return HTML.literal( + g.render_table_element(data_prop='shoppers')) + + def render_people(self, customer, field): + route_prefix = self.get_route_prefix() + permission_prefix = self.get_permission_prefix() + + factory = self.get_grid_factory() + g = factory( + self.request, + key=f'{route_prefix}.people', data=[], columns=[ 'full_name', @@ -400,16 +513,16 @@ class CustomerView(MasterView): ) if self.request.has_perm('people.view'): - g.main_actions.append(self.make_action('view', icon='eye')) + g.actions.append(self.make_action('view', icon='eye')) if self.request.has_perm('people.edit'): - g.main_actions.append(self.make_action('edit', icon='edit')) + g.actions.append(self.make_action('edit', icon='edit')) if self.people_detachable and self.has_perm('detach_person'): - g.main_actions.append(self.make_action('detach', icon='minus-circle', - link_class='has-text-warning', - click_handler="$emit('detach-person', props.row._action_url_detach)")) + g.actions.append(self.make_action('detach', icon='minus-circle', + link_class='has-text-warning', + click_handler="$emit('detach-person', props.row._action_url_detach)")) return HTML.literal( - g.render_buefy_table_element(data_prop='peopleData')) + g.render_table_element(data_prop='peopleData')) def render_groups(self, customer, field): groups = customer.groups @@ -428,24 +541,28 @@ class CustomerView(MasterView): return "" items = [] for member in members: - text = six.text_type(member) + text = str(member) url = self.request.route_url('members.view', uuid=member.uuid) items.append(HTML.tag('li', tags.link_to(text, url))) return HTML.tag('ul', HTML.literal('').join(items)) def get_version_child_classes(self): - return [ + classes = super().get_version_child_classes() + model = self.model + classes.extend([ (model.CustomerGroupAssignment, 'customer_uuid'), (model.CustomerPhoneNumber, 'parent_uuid'), (model.CustomerEmailAddress, 'parent_uuid'), (model.CustomerMailingAddress, 'parent_uuid'), (model.CustomerPerson, 'customer_uuid'), (model.CustomerNote, 'parent_uuid'), - ] + ]) + return classes def detach_person(self): + model = self.model customer = self.get_instance() - person = self.Session.query(model.Person).get(self.request.matchdict['person_uuid']) + person = self.Session.get(model.Person, self.request.matchdict['person_uuid']) if not person: return self.notfound() @@ -492,9 +609,26 @@ class CustomerView(MasterView): return [ # General + {'section': 'rattail', + 'option': 'customers.key_field'}, + {'section': 'rattail', + 'option': 'customers.key_label'}, {'section': 'rattail', 'option': 'customers.choice_uses_dropdown', 'type': bool}, + {'section': 'rattail', + 'option': 'customers.straight_to_profile', + 'type': bool}, + {'section': 'rattail', + 'option': 'customers.expose_shoppers', + 'type': bool, + 'default': True}, + {'section': 'rattail', + 'option': 'customers.expose_people', + 'type': bool, + 'default': True}, + {'section': 'rattail', + 'option': 'clientele.handler'}, # POS {'section': 'rattail', @@ -522,9 +656,7 @@ class CustomerView(MasterView): config.add_tailbone_permission(permission_prefix, '{}.detach_person'.format(permission_prefix), "Detach a Person from a {}".format(model_title)) - # TODO: this should require POST, but we'll add that once - # we can assume a Buefy theme is present, to avoid having - # to implement the logic in old jquery... + # TODO: this should require POST! config.add_route('{}.detach_person'.format(route_prefix), '{}/detach-person/{{person_uuid}}'.format(instance_url_prefix), # request_method='POST', @@ -534,11 +666,92 @@ class CustomerView(MasterView): permission='{}.detach_person'.format(permission_prefix)) +class CustomerShopperView(MasterView): + """ + Master view for the CustomerShopper class. + """ + model_class = CustomerShopper + route_prefix = 'customer_shoppers' + url_prefix = '/customer-shoppers' + + grid_columns = [ + 'customer_key', + 'customer', + 'shopper_number', + 'person', + 'active', + ] + + form_fields = [ + 'customer', + 'shopper_number', + 'person', + 'active', + ] + + def should_expose_quickie_search(self): + if self.expose_quickie_search: + return True + app = self.get_rattail_app() + return app.get_people_handler().should_expose_quickie_search() + + def get_quickie_perm(self): + return 'people.quickie' + + def get_quickie_url(self): + return self.request.route_url('people.quickie') + + def get_quickie_placeholder(self): + app = self.get_rattail_app() + return app.get_people_handler().get_quickie_search_placeholder() + + def query(self, session): + query = super().query(session) + model = self.model + return query.join(model.Customer)\ + .join(model.Person, + model.Person.uuid == model.CustomerShopper.person_uuid) + + def configure_grid(self, g): + super().configure_grid(g) + app = self.get_rattail_app() + model = self.model + + # customer_key + key = app.get_customer_key_field() + label = app.get_customer_key_label() + g.set_label('customer_key', label) + g.set_renderer('customer_key', + lambda shopper, field: getattr(shopper.customer, key)) + g.set_sorter('customer_key', getattr(model.Customer, key)) + g.set_sort_defaults('customer_key') + g.set_filter('customer_key', getattr(model.Customer, key), + label=f"Customer {label}", + default_active=True, + default_verb='equal') + + # customer (name) + g.set_sorter('customer', model.Customer.name) + g.set_filter('customer', model.Customer.name, + label="Customer Account Name") + + # person (name) + g.set_sorter('person', model.Person.display_name) + g.set_filter('person', model.Person.display_name, + label="Person Name") + + def configure_form(self, f): + super().configure_form(f) + + f.set_renderer('customer', self.render_customer) + f.set_renderer('person', self.render_person) + + class PendingCustomerView(MasterView): """ Master view for the Pending Customer class. """ - model_class = model.PendingCustomer + model_class = PendingCustomer route_prefix = 'pending_customers' url_prefix = '/customers/pending' @@ -579,19 +792,19 @@ class PendingCustomerView(MasterView): ] def configure_grid(self, g): - super(PendingCustomerView, self).configure_grid(g) + super().configure_grid(g) g.set_enum('status_code', self.enum.PENDING_CUSTOMER_STATUS) g.filters['status_code'].default_active = True g.filters['status_code'].default_verb = 'not_equal' - g.filters['status_code'].default_value = six.text_type(self.enum.PENDING_CUSTOMER_STATUS_RESOLVED) + g.filters['status_code'].default_value = str(self.enum.PENDING_CUSTOMER_STATUS_RESOLVED) g.set_sort_defaults('display_name') g.set_link('id') g.set_link('display_name') def configure_form(self, f): - super(PendingCustomerView, self).configure_form(f) + super().configure_form(f) f.set_enum('status_code', self.enum.PENDING_CUSTOMER_STATUS) @@ -619,7 +832,7 @@ class PendingCustomerView(MasterView): redirect = self.redirect(self.get_action_url('view', pending)) uuid = self.request.POST['person_uuid'] - person = self.Session.query(model.Person).get(uuid) + person = self.Session.get(model.Person, uuid) if not person: self.request.session.flash("Person not found!", 'error') return redirect @@ -667,7 +880,7 @@ class PendingCustomerView(MasterView): # TODO: this only works when creating, need to add edit support? # TODO: can this just go away? since we have unique_id() view method above def unique_id(node, value): - customers = Session.query(model.Customer).filter(model.Customer.id == value) + customers = Session.query(Customer).filter(Customer.id == value) if customers.count(): raise colander.Invalid(node, "Customer ID must be unique") @@ -676,8 +889,10 @@ def customer_info(request): """ View which returns simple dictionary of info for a particular customer. """ + app = request.rattail_config.get_app() + model = app.model uuid = request.params.get('uuid') - customer = Session.query(model.Customer).get(uuid) if uuid else None + customer = Session.get(model.Customer, uuid) if uuid else None if not customer: return {} return { @@ -700,6 +915,10 @@ def defaults(config, **kwargs): base['CustomerView']) CustomerView.defaults(config) + CustomerShopperView = kwargs.get('CustomerShopperView', + base['CustomerShopperView']) + CustomerShopperView.defaults(config) + PendingCustomerView = kwargs.get('PendingCustomerView', base['PendingCustomerView']) PendingCustomerView.defaults(config) diff --git a/tailbone/views/custorders/batch.py b/tailbone/views/custorders/batch.py index 26ac5cde..fa0df901 100644 --- a/tailbone/views/custorders/batch.py +++ b/tailbone/views/custorders/batch.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2021 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,11 +24,7 @@ Base class for customer order batch views """ -from __future__ import unicode_literals, absolute_import - -import six - -from rattail.db import model +from rattail.db.model import CustomerOrderBatch, CustomerOrderBatchRow import colander from webhelpers2.html import tags @@ -42,8 +38,8 @@ class CustomerOrderBatchView(BatchMasterView): Master view base class, for customer order batches. The views for the various mode/workflow batches will derive from this. """ - model_class = model.CustomerOrderBatch - model_row_class = model.CustomerOrderBatchRow + model_class = CustomerOrderBatch + model_row_class = CustomerOrderBatchRow default_handler_spec = 'rattail.batch.custorder:CustomerOrderBatchHandler' grid_columns = [ @@ -126,7 +122,7 @@ class CustomerOrderBatchView(BatchMasterView): ] def configure_grid(self, g): - super(CustomerOrderBatchView, self).configure_grid(g) + super().configure_grid(g) g.set_type('total_price', 'currency') @@ -135,9 +131,9 @@ class CustomerOrderBatchView(BatchMasterView): g.set_link('created_by') def configure_form(self, f): - super(CustomerOrderBatchView, self).configure_form(f) + super().configure_form(f) order = f.model_instance - model = self.rattail_config.get_model() + model = self.model # readonly fields f.set_readonly('rows') @@ -152,12 +148,12 @@ class CustomerOrderBatchView(BatchMasterView): customer_display = "" if self.request.method == 'POST': if self.request.POST.get('customer_uuid'): - customer = self.Session.query(model.Customer)\ - .get(self.request.POST['customer_uuid']) + customer = self.Session.get(model.Customer, + self.request.POST['customer_uuid']) if customer: - customer_display = six.text_type(customer) + customer_display = str(customer) elif self.editing: - customer_display = six.text_type(order.customer or "") + customer_display = str(order.customer or "") customers_url = self.request.route_url('customers.autocomplete') f.set_widget('customer_uuid', forms.widgets.JQueryAutocompleteWidget( field_display=customer_display, service_url=customers_url)) @@ -172,12 +168,12 @@ class CustomerOrderBatchView(BatchMasterView): person_display = "" if self.request.method == 'POST': if self.request.POST.get('person_uuid'): - person = self.Session.query(model.Person)\ - .get(self.request.POST['person_uuid']) + person = self.Session.get(model.Person, + self.request.POST['person_uuid']) if person: - person_display = six.text_type(person) + person_display = str(person) elif self.editing: - person_display = six.text_type(order.person or "") + person_display = str(order.person or "") people_url = self.request.route_url('people.autocomplete') f.set_widget('person_uuid', forms.widgets.JQueryAutocompleteWidget( field_display=person_display, service_url=people_url)) @@ -194,7 +190,7 @@ class CustomerOrderBatchView(BatchMasterView): pending = batch.pending_customer if not pending: return - text = six.text_type(pending) + text = str(pending) url = self.request.route_url('pending_customers.view', uuid=pending.uuid) return tags.link_to(text, url) @@ -205,7 +201,7 @@ class CustomerOrderBatchView(BatchMasterView): return 'notice' def configure_row_grid(self, g): - super(CustomerOrderBatchView, self).configure_row_grid(g) + super().configure_row_grid(g) g.set_type('case_quantity', 'quantity') g.set_type('cases_ordered', 'quantity') @@ -219,7 +215,7 @@ class CustomerOrderBatchView(BatchMasterView): g.set_link('product_description') def configure_row_form(self, f): - super(CustomerOrderBatchView, self).configure_row_form(f) + super().configure_row_form(f) f.set_renderer('product', self.render_product) f.set_renderer('pending_product', self.render_pending_product) diff --git a/tailbone/views/custorders/items.py b/tailbone/views/custorders/items.py index c780756a..e7edf3aa 100644 --- a/tailbone/views/custorders/items.py +++ b/tailbone/views/custorders/items.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,27 +24,23 @@ Customer order item views """ -from __future__ import unicode_literals, absolute_import - import datetime -import six from sqlalchemy import orm -from rattail.db import model -from rattail.time import localtime +from rattail.db.model import CustomerOrderItem from webhelpers2.html import HTML, tags from tailbone.views import MasterView -from tailbone.util import raw_datetime +from tailbone.util import raw_datetime, csrf_token class CustomerOrderItemView(MasterView): """ Master view for customer order items """ - model_class = model.CustomerOrderItem + model_class = CustomerOrderItem route_prefix = 'custorders.items' url_prefix = '/custorders/items' creatable = False @@ -61,94 +57,125 @@ class CustomerOrderItemView(MasterView): grid_columns = [ 'order_id', 'person', + '_product_key_', 'product_brand', 'product_description', 'product_size', + 'department_name', + 'case_quantity', 'order_quantity', 'order_uom', - 'case_quantity', + 'total_price', 'order_created', 'status_code', - ] - - has_rows = True - model_row_class = model.CustomerOrderItemEvent - rows_title = "Event History" - rows_filterable = False - rows_sortable = False - rows_pageable = False - rows_viewable = False - - row_grid_columns = [ - 'occurred', - 'type_code', - 'user', - 'note', + 'flagged', ] form_fields = [ 'order', - 'sequence', + 'customer', 'person', + 'sequence', + '_product_key_', 'product', 'pending_product', 'product_brand', 'product_description', 'product_size', + 'case_quantity', 'order_quantity', 'order_uom', - 'case_quantity', 'unit_price', 'total_price', + 'special_order', 'price_needs_confirmation', 'paid_amount', + 'payment_transaction_number', 'status_code', - 'notes', + 'flagged', + 'contact_attempts', + 'last_contacted', + 'events', ] + def __init__(self, request): + super().__init__(request) + app = self.get_rattail_app() + self.custorder_handler = app.get_custorder_handler() + self.batch_handler = app.get_batch_handler( + 'custorder', + default='rattail.batch.custorder:CustomerOrderBatchHandler') + def query(self, session): + model = self.model return session.query(model.CustomerOrderItem)\ .join(model.CustomerOrder)\ .options(orm.joinedload(model.CustomerOrderItem.order)\ .joinedload(model.CustomerOrder.person)) def configure_grid(self, g): - super(CustomerOrderItemView, self).configure_grid(g) + super().configure_grid(g) + model = self.model + # order_id g.set_renderer('order_id', self.render_order_id) + g.set_link('order_id') - g.set_joiner('person', lambda q: q.outerjoin(model.Person)) - - g.filters['person'] = g.make_filter('person', model.Person.display_name, - default_active=True, default_verb='contains') - - g.set_sorter('person', model.Person.display_name) - g.set_sorter('order_created', model.CustomerOrder.created) - - g.set_sort_defaults('order_created', 'desc') - - g.set_type('case_quantity', 'quantity') - g.set_type('cases_ordered', 'quantity') - g.set_type('units_ordered', 'quantity') - g.set_type('total_price', 'currency') - g.set_type('order_quantity', 'quantity') - - g.set_enum('order_uom', self.enum.UNIT_OF_MEASURE) - - g.set_renderer('person', self.render_person_text) - g.set_renderer('order_created', self.render_order_created) - - g.set_renderer('status_code', self.render_status_code_column) - + # person g.set_label('person', "Person Name") + g.set_renderer('person', self.render_person_text) + g.set_link('person') + g.set_joiner('person', lambda q: q.outerjoin(model.Person)) + g.set_sorter('person', model.Person.display_name) + g.set_filter('person', model.Person.display_name, + default_active=True, default_verb='contains') + + # product_key + field = self.get_product_key_field() + g.set_renderer(field, lambda item, field: getattr(item, f'product_{field}')) + + # product_* g.set_label('product_brand', "Brand") + g.set_link('product_brand') g.set_label('product_description', "Description") + g.set_link('product_description') g.set_label('product_size', "Size") - g.set_link('order_id') - g.set_link('person') - g.set_link('product_brand') - g.set_link('product_description') + # "numbers" + g.set_type('case_quantity', 'quantity') + g.set_type('order_quantity', 'quantity') + g.set_type('total_price', 'currency') + # TODO: deprecate / remove these + g.set_type('cases_ordered', 'quantity') + g.set_type('units_ordered', 'quantity') + + # order_uom + # nb. this is not relevant if "case orders only" + if not self.batch_handler.allow_unit_orders(): + g.remove('order_uom') + else: + g.set_enum('order_uom', self.enum.UNIT_OF_MEASURE) + + # order_created + g.set_renderer('order_created', self.render_order_created) + g.set_sorter('order_created', model.CustomerOrder.created) + g.set_sort_defaults('order_created', 'desc') + + # status_code + g.set_renderer('status_code', self.render_status_code_column) + + # abbreviate some labels, only in grid header + g.set_label('case_quantity', "Case Qty") + g.filters['case_quantity'].label = "Case Quantity" + g.set_label('order_quantity', "Order Qty") + g.filters['order_quantity'].label = "Order Quantity" + g.set_label('department_name', "Department") + g.filters['department_name'].label = "Department Name" + g.set_label('total_price', "Total") + g.filters['total_price'].label = "Total Price" + g.set_label('order_created', "Ordered") + if 'order_created' in g.filters: + g.filters['order_created'].label = "Order Created" def render_order_id(self, item, field): return item.order.id @@ -156,28 +183,38 @@ class CustomerOrderItemView(MasterView): def render_person_text(self, item, field): person = item.order.person if person: - text = six.text_type(person) + text = str(person) return text def render_order_created(self, item, column): - value = localtime(self.rattail_config, item.order.created, from_utc=True) + app = self.get_rattail_app() + value = app.localtime(item.order.created, from_utc=True) return raw_datetime(self.rattail_config, value) def render_status_code_column(self, item, field): text = self.enum.CUSTORDER_ITEM_STATUS.get(item.status_code, - six.text_type(item.status_code)) + str(item.status_code)) if item.status_text: return HTML.tag('span', title=item.status_text, c=[text]) return text def configure_form(self, f): - super(CustomerOrderItemView, self).configure_form(f) - use_buefy = self.get_use_buefy() + super().configure_form(f) item = f.model_instance # order f.set_renderer('order', self.render_order) + # contact + if self.batch_handler.new_order_requires_customer(): + f.remove('person') + else: + f.remove('customer') + + # product key + key = self.get_product_key_field() + f.set_renderer(key, lambda item, field: getattr(item, f'product_{key}')) + # (pending) product f.set_renderer('product', self.render_product) f.set_renderer('pending_product', self.render_pending_product) @@ -187,7 +224,9 @@ class CustomerOrderItemView(MasterView): elif item.pending_product and not item.product: f.remove('product') - # product uom + # product* + if not self.creating and item.product: + f.remove('product_brand', 'product_description') f.set_enum('product_unit_of_measure', self.enum.UNIT_OF_MEASURE) # highlight pending fields @@ -196,13 +235,6 @@ class CustomerOrderItemView(MasterView): f.set_renderer('product_size', self.highlight_pending_field) f.set_renderer('case_quantity', self.highlight_pending_field_quantity) - 'unit_price', - 'total_price', - 'price_needs_confirmation', - 'paid_amount', - 'status_code', - 'notes', - # quantity fields f.set_type('cases_ordered', 'quantity') f.set_type('units_ordered', 'quantity') @@ -221,11 +253,52 @@ class CustomerOrderItemView(MasterView): # status_code f.set_renderer('status_code', self.render_status_code) - # notes - if use_buefy: - f.set_renderer('notes', self.render_notes) - else: - f.remove('notes') + # flagged + f.set_renderer('flagged', self.render_flagged) + + # events + f.set_renderer('events', self.render_events) + + def render_flagged(self, item, field): + text = "Yes" if item.flagged else "No" + items = [HTML.tag('span', c=text)] + + if self.has_perm('change_status'): + button_text = "Un-Flag This" if item.flagged else "Flag This" + form = [ + tags.form(self.get_action_url('change_flagged', item), + **{'@submit': 'changeFlaggedSubmit'}), + csrf_token(self.request), + tags.hidden('new_flagged', + value='false' if item.flagged else 'true'), + HTML.tag('b-button', + type='is-warning' if item.flagged else 'is-primary', + c=f"{{{{ changeFlaggedSubmitting ? 'Working, please wait...' : '{button_text}' }}}}", + native_type='submit', + style='margin-left: 1rem;', + icon_pack='fas', icon_left='flag', + **{':disabled': 'changeFlaggedSubmitting'}), + tags.end_form(), + ] + items.append(HTML.literal('').join(form)) + + left = HTML.tag('div', class_='level-left', c=items) + outer = HTML.tag('div', class_='level', c=[left]) + return outer + + def change_flagged(self): + """ + View for changing "flagged" status of one or more order products. + """ + item = self.get_instance() + redirect = self.redirect(self.get_action_url('view', item)) + + new_flagged = self.request.POST['new_flagged'] == 'true' + item.flagged = new_flagged + + flagged = "FLAGGED" if new_flagged else "UN-FLAGGED" + self.request.session.flash(f"Order item has been {flagged}") + return redirect def highlight_pending_field(self, item, field, value=None): if value is None: @@ -271,13 +344,22 @@ class CustomerOrderItemView(MasterView): return outer def render_status_code(self, item, field): - use_buefy = self.get_use_buefy() text = self.enum.CUSTORDER_ITEM_STATUS[item.status_code] if item.status_text: text = "{} ({})".format(text, item.status_text) items = [HTML.tag('span', c=[text])] - if use_buefy and self.has_perm('change_status'): + if self.has_perm('change_status'): + + # Mark Received + if self.can_be_received(item): + button = HTML.tag('b-button', type='is-primary', c="Mark Received", + style='margin-left: 1rem;', + icon_pack='fas', icon_left='check', + **{'@click': "$emit('mark-received')"}) + items.append(button) + + # Change Status button = HTML.tag('b-button', type='is-primary', c="Change Status", style='margin-left: 1rem;', icon_pack='fas', icon_left='edit', @@ -288,25 +370,39 @@ class CustomerOrderItemView(MasterView): outer = HTML.tag('div', class_='level', c=[left]) return outer - def render_notes(self, item, field): + def can_be_received(self, item): + + # TODO: is this generic enough? probably belongs in handler anyway.. + if item.status_code in (self.enum.CUSTORDER_ITEM_STATUS_INITIATED, + self.enum.CUSTORDER_ITEM_STATUS_READY, + self.enum.CUSTORDER_ITEM_STATUS_PLACED): + return True + + return False + + def render_events(self, item, field): route_prefix = self.get_route_prefix() factory = self.get_grid_factory() g = factory( - key='{}.notes'.format(route_prefix), + self.request, + key=f'{route_prefix}.events', data=[], columns=[ - 'text', - 'created_by', - 'created', + 'occurred', + 'type_code', + 'user', + 'note', ], labels={ - 'text': "Note", + 'occurred': "When", + 'type_code': "What", + 'user': "Who", }, ) table = HTML.literal( - g.render_buefy_table_element(data_prop='notesData')) + g.render_table_element(data_prop='eventsData')) elements = [table] if self.has_perm('add_note'): @@ -323,12 +419,13 @@ class CustomerOrderItemView(MasterView): c=elements) def template_kwargs_view(self, **kwargs): - kwargs = super(CustomerOrderItemView, self).template_kwargs_view(**kwargs) + kwargs = super().template_kwargs_view(**kwargs) + model = self.model app = self.get_rattail_app() item = kwargs['instance'] - # fetch notes for current item - kwargs['notes_data'] = self.get_context_notes(item) + # fetch events for current item + kwargs['events_data'] = self.get_context_events(item) # fetch "other" order items, siblings of current one order = item.order @@ -337,16 +434,19 @@ class CustomerOrderItemView(MasterView): .filter(model.CustomerOrderItem.uuid != item.uuid)\ .all() other_data = [] + product_key_field = self.get_product_key_field() for other in other_items: order_date = None if order.created: - order_date = localtime(self.rattail_config, order.created, from_utc=True).date() + order_date = app.localtime(order.created, from_utc=True).date() other_data.append({ 'uuid': other.uuid, + 'product_key': getattr(other, f'product_{product_key_field}'), 'brand_name': other.product_brand, 'product_description': other.product_description, + 'product_size': other.product_size, 'product_case_quantity': app.render_quantity(other.case_quantity), 'order_quantity': app.render_quantity(other.order_quantity), 'order_uom': self.enum.UNIT_OF_MEASURE[other.order_uom], @@ -356,21 +456,24 @@ class CustomerOrderItemView(MasterView): 'total_price': app.render_currency(other.total_price), 'order_date': app.render_date(order_date), 'status_code': self.enum.CUSTORDER_ITEM_STATUS[other.status_code], + 'flagged': other.flagged, }) kwargs['other_order_items_data'] = other_data return kwargs - def get_context_notes(self, item): - notes = [] - for note in reversed(item.notes): - created = localtime(self.rattail_config, note.created, from_utc=True) - notes.append({ - 'created': raw_datetime(self.rattail_config, created), - 'created_by': note.created_by.display_name, - 'text': note.text, + def get_context_events(self, item): + app = self.get_rattail_app() + events = [] + for event in item.events: + occurred = app.localtime(event.occurred, from_utc=True) + events.append({ + 'occurred': raw_datetime(self.rattail_config, occurred), + 'type_code': self.enum.CUSTORDER_ITEM_EVENT.get(event.type_code, event.type_code), + 'user': str(event.user), + 'note': event.note, }) - return notes + return events def confirm_price(self): """ @@ -398,10 +501,33 @@ class CustomerOrderItemView(MasterView): self.request.session.flash("Price has been confirmed.") return redirect + def mark_received(self): + """ + View to mark some order item(s) as having been received. + """ + app = self.get_rattail_app() + model = self.model + uuids = self.request.POST['order_item_uuids'].split(',') + + order_items = self.Session.query(model.CustomerOrderItem)\ + .filter(model.CustomerOrderItem.uuid.in_(uuids))\ + .all() + + handler = app.get_custorder_handler() + handler.mark_received(order_items, self.request.user) + + msg = self.mark_received_get_flash(order_items) + self.request.session.flash(msg) + return self.redirect(self.request.get_referrer(default=self.get_index_url())) + + def mark_received_get_flash(self, order_items): + return "Order item statuses have been updated." + def change_status(self): """ View for changing status of one or more order items. """ + model = self.model order_item = self.get_instance() redirect = self.redirect(self.get_action_url('view', order_item)) @@ -416,7 +542,7 @@ class CustomerOrderItemView(MasterView): uuids = self.request.POST['uuids'] if uuids: for uuid in uuids.split(','): - item = self.Session.query(model.CustomerOrderItem).get(uuid) + item = self.Session.get(model.CustomerOrderItem, uuid) if item: order_items.append(item) @@ -455,62 +581,31 @@ class CustomerOrderItemView(MasterView): View for adding a new note to current order item, optinally also adding it to all other items under the parent order. """ - order_item = self.get_instance() + item = self.get_instance() data = self.request.json_body - new_note = data['note'] - apply_all = data['apply_all'] == True - user = self.request.user - if apply_all: - order_items = order_item.order.items - else: - order_items = [order_item] - - for item in order_items: - item.notes.append(model.CustomerOrderItemNote( - created_by=user, text=new_note)) - - # # attach event - # item.events.append(model.CustomerOrderItemEvent( - # type_code=self.enum.CUSTORDER_ITEM_EVENT_ADDED_NOTE, - # user=user, note=new_note)) + self.custorder_handler.add_note(item, data['note'], self.request.user, + apply_all=data['apply_all'] == True) self.Session.flush() - self.Session.refresh(order_item) - return {'success': True, - 'notes': self.get_context_notes(order_item)} + self.Session.refresh(item) + return {'events': self.get_context_events(item)} def render_order(self, item, field): order = item.order if not order: return "" - text = six.text_type(order) + text = str(order) url = self.request.route_url('custorders.view', uuid=order.uuid) return tags.link_to(text, url) def render_person(self, item, field): person = item.order.person if person: - text = six.text_type(person) + text = str(person) url = self.request.route_url('people.view', uuid=person.uuid) return tags.link_to(text, url) - def get_row_data(self, item): - return self.Session.query(model.CustomerOrderItemEvent)\ - .filter(model.CustomerOrderItemEvent.item == item)\ - .order_by(model.CustomerOrderItemEvent.occurred.desc(), - model.CustomerOrderItemEvent.type_code) - - def configure_row_grid(self, g): - super(CustomerOrderItemView, self).configure_row_grid(g) - - g.set_enum('type_code', self.enum.CUSTORDER_ITEM_EVENT) - - g.set_label('occurred', "When") - g.set_label('type_code', "What") # TODO: enum renderer - g.set_label('user', "Who") - g.set_label('note', "Notes") - @classmethod def defaults(cls, config): cls._order_item_defaults(config) @@ -519,6 +614,7 @@ class CustomerOrderItemView(MasterView): @classmethod def _order_item_defaults(cls, config): route_prefix = cls.get_route_prefix() + url_prefix = cls.get_url_prefix() instance_url_prefix = cls.get_instance_url_prefix() permission_prefix = cls.get_permission_prefix() model_title = cls.get_model_title() @@ -538,6 +634,14 @@ class CustomerOrderItemView(MasterView): route_name='{}.confirm_price'.format(route_prefix), permission='{}.confirm_price'.format(permission_prefix)) + # mark received + config.add_route(f'{route_prefix}.mark_received', + f'{url_prefix}/mark-received', + request_method='POST') + config.add_view(cls, attr='mark_received', + route_name=f'{route_prefix}.mark_received', + permission=f'{permission_prefix}.change_status') + # change status config.add_tailbone_permission(permission_prefix, '{}.change_status'.format(permission_prefix), @@ -549,6 +653,14 @@ class CustomerOrderItemView(MasterView): route_name='{}.change_status'.format(route_prefix), permission='{}.change_status'.format(permission_prefix)) + # change flagged + config.add_route(f'{route_prefix}.change_flagged', + f'{instance_url_prefix}/change-flagged', + request_method='POST') + config.add_view(cls, attr='change_flagged', + route_name=f'{route_prefix}.change_flagged', + permission=f'{permission_prefix}.change_status') + # add note config.add_tailbone_permission(permission_prefix, '{}.add_note'.format(permission_prefix), diff --git a/tailbone/views/custorders/orders.py b/tailbone/views/custorders/orders.py index e8ce8fd3..b1a9831a 100644 --- a/tailbone/views/custorders/orders.py +++ b/tailbone/views/custorders/orders.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,21 +24,17 @@ Customer Order Views """ -from __future__ import unicode_literals, absolute_import - import decimal import logging -import six from sqlalchemy import orm -from rattail.db import model -from rattail.util import pretty_quantity, simple_error +from rattail.db.model import CustomerOrder, CustomerOrderItem +from rattail.util import simple_error from rattail.batch import get_batch_handler from webhelpers2.html import tags, HTML -from tailbone.db import Session from tailbone.views import MasterView @@ -49,13 +45,13 @@ class CustomerOrderView(MasterView): """ Master view for customer orders """ - model_class = model.CustomerOrder + model_class = CustomerOrder route_prefix = 'custorders' editable = False configurable = True labels = { - 'id': "ID", + 'id': "Order ID", 'status_code': "Status", } @@ -63,8 +59,9 @@ class CustomerOrderView(MasterView): 'id', 'customer', 'person', - 'created', 'status_code', + 'created', + 'created_by', ] form_fields = [ @@ -82,7 +79,7 @@ class CustomerOrderView(MasterView): ] has_rows = True - model_row_class = model.CustomerOrderItem + model_row_class = CustomerOrderItem rows_viewable = False row_labels = { @@ -91,30 +88,56 @@ class CustomerOrderView(MasterView): row_grid_columns = [ 'sequence', + '_product_key_', 'product_brand', 'product_description', 'product_size', 'order_quantity', 'order_uom', 'case_quantity', + 'department_name', 'total_price', 'status_code', + 'flagged', + ] + + PENDING_PRODUCT_ENTRY_FIELDS = [ + 'key', + 'department_uuid', + 'brand_name', + 'description', + 'size', + 'vendor_name', + 'vendor_item_code', + 'unit_cost', + 'case_size', + 'regular_price_amount', ] def __init__(self, request): - super(CustomerOrderView, self).__init__(request) + super().__init__(request) self.batch_handler = self.get_batch_handler() def query(self, session): + model = self.app.model return session.query(model.CustomerOrder)\ .options(orm.joinedload(model.CustomerOrder.customer)) def configure_grid(self, g): - super(CustomerOrderView, self).configure_grid(g) + super().configure_grid(g) + model = self.app.model + + # id + g.set_link('id') + g.filters['id'].default_active = True + g.filters['id'].default_verb = 'equal' + + # import ipdb; ipdb.set_trace() # customer or person if self.batch_handler.new_order_requires_customer(): g.remove('person') + g.set_link('customer') g.set_joiner('customer', lambda q: q.outerjoin(model.Customer)) g.set_sorter('customer', model.Customer.name) g.filters['customer'] = g.make_filter('customer', model.Customer.name, @@ -123,6 +146,7 @@ class CustomerOrderView(MasterView): default_verb='contains') else: g.remove('customer') + g.set_link('person') g.set_joiner('person', lambda q: q.outerjoin(model.Person)) g.set_sorter('person', model.Person.display_name) g.filters['person'] = g.make_filter('person', model.Person.display_name, @@ -130,16 +154,17 @@ class CustomerOrderView(MasterView): default_active=True, default_verb='contains') + # status_code g.set_enum('status_code', self.enum.CUSTORDER_STATUS) + # created g.set_sort_defaults('created', 'desc') - g.set_link('id') - g.set_link('customer') - g.set_link('person') + def get_instance_title(self, order): + return f"#{order.id} for {order.customer or order.person}" def configure_form(self, f): - super(CustomerOrderView, self).configure_form(f) + super().configure_form(f) order = f.model_instance f.set_readonly('id') @@ -195,7 +220,7 @@ class CustomerOrderView(MasterView): person = order.person if not person: return "" - text = six.text_type(person) + text = str(person) url = self.request.route_url('people.view', uuid=person.uuid) return tags.link_to(text, url) @@ -203,12 +228,13 @@ class CustomerOrderView(MasterView): pending = batch.pending_customer if not pending: return - text = six.text_type(pending) + text = str(pending) url = self.request.route_url('pending_customers.view', uuid=pending.uuid) return tags.link_to(text, url, class_='has-background-warning') def get_row_data(self, order): + model = self.app.model return self.Session.query(model.CustomerOrderItem)\ .filter(model.CustomerOrderItem.order == order) @@ -216,11 +242,13 @@ class CustomerOrderView(MasterView): return item.order def make_row_grid_kwargs(self, **kwargs): - kwargs = super(CustomerOrderView, self).make_row_grid_kwargs(**kwargs) + kwargs = super().make_row_grid_kwargs(**kwargs) - assert not kwargs['main_actions'] - kwargs['main_actions'].append( - self.make_action('view', icon='eye', url=self.row_view_action_url)) + actions = kwargs.get('actions', []) + if not actions: + actions.append(self.make_action('view', icon='eye', + url=self.row_view_action_url)) + kwargs['actions'] = actions return kwargs @@ -229,12 +257,16 @@ class CustomerOrderView(MasterView): return self.request.route_url('custorders.items.view', uuid=item.uuid) def configure_row_grid(self, g): - super(CustomerOrderView, self).configure_row_grid(g) + super().configure_row_grid(g) app = self.get_rattail_app() handler = app.get_batch_handler( 'custorder', default='rattail.batch.custorder:CustomerOrderBatchHandler') + # product key + key = self.get_product_key_field() + g.set_renderer(key, lambda item, field: getattr(item, f'product_{key}')) + g.set_type('case_quantity', 'quantity') g.set_type('order_quantity', 'quantity') g.set_type('cases_ordered', 'quantity') @@ -275,7 +307,7 @@ class CustomerOrderView(MasterView): def render_row_status_code(self, item, field): text = self.enum.CUSTORDER_ITEM_STATUS.get(item.status_code, - six.text_type(item.status_code)) + str(item.status_code)) if item.status_text: return HTML.tag('span', title=item.status_text, c=[text]) return text @@ -293,6 +325,7 @@ class CustomerOrderView(MasterView): submits the order, at which point the batch is converted to a proper order. """ + app = self.get_rattail_app() # TODO: deprecate / remove this self.handler = self.batch_handler batch = self.get_current_batch() @@ -343,12 +376,20 @@ class CustomerOrderView(MasterView): 'allow_contact_info_choice': self.batch_handler.allow_contact_info_choice(), 'allow_contact_info_create': self.batch_handler.allow_contact_info_creation(), 'order_items': items, - 'product_key_label': self.rattail_config.product_key_title(), - 'allow_unknown_product': self.batch_handler.allow_unknown_product(), + 'product_key_label': app.get_product_key_label(), + 'allow_unknown_product': (self.batch_handler.allow_unknown_product() + and self.has_perm('create_unknown_product')), + 'pending_product_required_fields': self.get_pending_product_required_fields(), + 'unknown_product_confirm_price': self.rattail_config.getbool( + 'rattail.custorders', 'unknown_product.always_confirm_price'), 'department_options': self.get_department_options(), 'default_uom_choices': self.batch_handler.uom_choices_for_product(None), 'default_uom': None, 'allow_item_discounts': self.batch_handler.allow_item_discounts(), + 'allow_item_discounts_if_on_sale': self.batch_handler.allow_item_discounts_if_on_sale(), + # nb. render quantity so that '10.0' => '10' + 'default_item_discount': app.render_quantity( + self.batch_handler.get_default_item_discount()), 'allow_past_item_reorder': self.batch_handler.allow_past_item_reorder(), }) @@ -370,11 +411,23 @@ class CustomerOrderView(MasterView): 'value': department.uuid}) return options + def get_pending_product_required_fields(self): + required = [] + for field in self.PENDING_PRODUCT_ENTRY_FIELDS: + require = self.rattail_config.getbool('rattail.custorders', + f'unknown_product.fields.{field}.required') + if require is None and field == 'description': + require = True + if require: + required.append(field) + return required + def get_current_batch(self): user = self.request.user if not user: raise RuntimeError("this feature requires a user to be logged in") + model = self.app.model try: # there should be at most *one* new batch per user batch = self.Session.query(model.CustomerOrderBatch)\ @@ -440,7 +493,8 @@ class CustomerOrderView(MasterView): if not uuid: return {'error': "Must specify a customer UUID"} - customer = self.Session.query(model.Customer).get(uuid) + model = self.app.model + customer = self.Session.get(model.Customer, uuid) if not customer: return {'error': "Customer not found"} @@ -460,6 +514,7 @@ class CustomerOrderView(MasterView): return info def assign_contact(self, batch, data): + model = self.app.model kwargs = {} # this will either be a Person or Customer UUID @@ -467,14 +522,14 @@ class CustomerOrderView(MasterView): if self.batch_handler.new_order_requires_customer(): - customer = self.Session.query(model.Customer).get(uuid) + customer = self.Session.get(model.Customer, uuid) if not customer: return {'error': "Customer not found"} kwargs['customer'] = customer else: - person = self.Session.query(model.Person).get(uuid) + person = self.Session.get(model.Person, uuid) if not person: return {'error': "Person not found"} kwargs['person'] = person @@ -483,7 +538,7 @@ class CustomerOrderView(MasterView): try: self.batch_handler.assign_contact(batch, **kwargs) except ValueError as error: - return {'error': six.text_type(error)} + return {'error': str(error)} self.Session.flush() context = self.get_context_contact(batch) @@ -585,7 +640,7 @@ class CustomerOrderView(MasterView): self.batch_handler.update_pending_customer(batch, self.request.user, data) except Exception as error: - return {'error': six.text_type(error)} + return {'error': str(error)} self.Session.flush() context = self.get_context_contact(batch) @@ -614,7 +669,8 @@ class CustomerOrderView(MasterView): if not uuid: return {'error': "Must specify a product UUID"} - product = self.Session.query(model.Product).get(uuid) + model = self.app.model + product = self.Session.get(model.Product, uuid) if not product: return {'error': "Product not found"} @@ -630,12 +686,14 @@ class CustomerOrderView(MasterView): try: info = self.batch_handler.get_product_info(batch, product) except Exception as error: - return {'error': six.text_type(error)} + return {'error': str(error)} else: info['url'] = self.request.route_url('products.view', uuid=info['uuid']) - return info + app = self.get_rattail_app() + return app.json_friendly(info) def get_past_items(self, batch, data): + app = self.get_rattail_app() past_products = self.batch_handler.get_past_products(batch) past_items = [] @@ -646,6 +704,7 @@ class CustomerOrderView(MasterView): # nb. handler may raise error if product is "unsupported" pass else: + item = app.json_friendly(item) past_items.append(item) return {'past_items': past_items} @@ -653,7 +712,7 @@ class CustomerOrderView(MasterView): def normalize_batch(self, batch): return { 'uuid': batch.uuid, - 'total_price': six.text_type(batch.total_price or 0), + 'total_price': str(batch.total_price or 0), 'total_price_display': "${:0.2f}".format(batch.total_price or 0), 'status_code': batch.status_code, 'status_text': batch.status_text, @@ -674,15 +733,14 @@ class CustomerOrderView(MasterView): return app.render_currency(obj.unit_price) def normalize_row(self, row): - app = self.get_rattail_app() - products_handler = app.get_products_handler() + products_handler = self.app.get_products_handler() data = { 'uuid': row.uuid, 'sequence': row.sequence, 'item_entry': row.item_entry, 'product_uuid': row.product_uuid, - 'product_upc': six.text_type(row.product_upc or ''), + 'product_upc': str(row.product_upc or ''), 'product_item_id': row.product_item_id, 'product_scancode': row.product_scancode, 'product_upc_pretty': row.product_upc.pretty() if row.product_upc else None, @@ -691,20 +749,20 @@ class CustomerOrderView(MasterView): 'product_size': row.product_size, 'product_weighed': row.product_weighed, - 'case_quantity': pretty_quantity(row.case_quantity), - 'cases_ordered': pretty_quantity(row.cases_ordered), - 'units_ordered': pretty_quantity(row.units_ordered), - 'order_quantity': pretty_quantity(row.order_quantity), + 'case_quantity': self.app.render_quantity(row.case_quantity), + 'cases_ordered': self.app.render_quantity(row.cases_ordered), + 'units_ordered': self.app.render_quantity(row.units_ordered), + 'order_quantity': self.app.render_quantity(row.order_quantity), 'order_uom': row.order_uom, 'order_uom_choices': self.uom_choices_for_row(row), - 'discount_percent': pretty_quantity(row.discount_percent), + 'discount_percent': self.app.render_quantity(row.discount_percent), 'department_display': row.department_name, 'unit_price': float(row.unit_price) if row.unit_price is not None else None, 'unit_price_display': self.get_unit_price_display(row), 'total_price': float(row.total_price) if row.total_price is not None else None, - 'total_price_display': app.render_currency(row.total_price), + 'total_price_display': self.app.render_currency(row.total_price), 'status_code': row.status_code, 'status_text': row.status_text, @@ -712,15 +770,15 @@ class CustomerOrderView(MasterView): if row.unit_regular_price: data['unit_regular_price'] = float(row.unit_regular_price) - data['unit_regular_price_display'] = app.render_currency(row.unit_regular_price) + data['unit_regular_price_display'] = self.app.render_currency(row.unit_regular_price) if row.unit_sale_price: data['unit_sale_price'] = float(row.unit_sale_price) - data['unit_sale_price_display'] = app.render_currency(row.unit_sale_price) + data['unit_sale_price_display'] = self.app.render_currency(row.unit_sale_price) if row.sale_ends: - sale_ends = app.localtime(row.sale_ends, from_utc=True).date() - data['sale_ends'] = six.text_type(sale_ends) - data['sale_ends_display'] = app.render_date(sale_ends) + sale_ends = self.app.localtime(row.sale_ends, from_utc=True).date() + data['sale_ends'] = str(sale_ends) + data['sale_ends_display'] = self.app.render_date(sale_ends) if row.unit_sale_price and row.unit_price == row.unit_sale_price: data['pricing_reflects_sale'] = True @@ -740,7 +798,7 @@ class CustomerOrderView(MasterView): pending = row.pending_product data['pending_product'] = { 'uuid': pending.uuid, - 'upc': six.text_type(pending.upc) if pending.upc is not None else None, + 'upc': str(pending.upc) if pending.upc is not None else None, 'item_id': pending.item_id, 'scancode': pending.scancode, 'brand_name': pending.brand_name, @@ -757,12 +815,12 @@ class CustomerOrderView(MasterView): case_price = self.batch_handler.get_case_price_for_row(row) data['case_price'] = float(case_price) if case_price is not None else None - data['case_price_display'] = app.render_currency(case_price) + data['case_price_display'] = self.app.render_currency(case_price) if self.batch_handler.product_price_may_be_questionable(): data['price_needs_confirmation'] = row.price_needs_confirmation - key = self.rattail_config.product_key() + key = self.app.get_product_key_field() if key == 'upc': data['product_key'] = data['product_upc_pretty'] elif key == 'item_id': @@ -786,7 +844,7 @@ class CustomerOrderView(MasterView): case_qty = unit_qty = '??' else: case_qty = data['case_quantity'] - unit_qty = pretty_quantity(row.order_quantity * row.case_quantity) + unit_qty = self.app.render_quantity(row.order_quantity * row.case_quantity) data.update({ 'order_quantity_display': "{} {} (× {} {} = {} {})".format( data['order_quantity'], @@ -799,14 +857,14 @@ class CustomerOrderView(MasterView): else: data.update({ 'order_quantity_display': "{} {}".format( - pretty_quantity(row.order_quantity), + self.app.render_quantity(row.order_quantity), self.enum.UNIT_OF_MEASURE[unit_uom]), }) return data def add_item(self, batch, data): - app = self.get_rattail_app() + model = self.app.model order_quantity = decimal.Decimal(data.get('order_quantity') or '0') order_uom = data.get('order_uom') @@ -818,7 +876,7 @@ class CustomerOrderView(MasterView): if not uuid: return {'error': "Must specify a product UUID"} - product = self.Session.query(model.Product).get(uuid) + product = self.Session.get(model.Product, uuid) if not product: return {'error': "Product not found"} @@ -837,11 +895,14 @@ class CustomerOrderView(MasterView): pending_info = dict(data['pending_product']) if 'upc' in pending_info: - pending_info['upc'] = app.make_gpc(pending_info['upc']) + pending_info['upc'] = self.app.make_gpc(pending_info['upc']) for field in ('unit_cost', 'regular_price_amount', 'case_size'): if field in pending_info: - pending_info[field] = decimal.Decimal(pending_info[field]) + try: + pending_info[field] = decimal.Decimal(pending_info[field]) + except decimal.InvalidOperation: + return {'error': f"Invalid entry for field: {field}"} pending_info['user'] = self.request.user @@ -863,7 +924,8 @@ class CustomerOrderView(MasterView): if not uuid: return {'error': "Must specify a row UUID"} - row = self.Session.query(model.CustomerOrderBatchRow).get(uuid) + model = self.app.model + row = self.Session.get(model.CustomerOrderBatchRow, uuid) if not row: return {'error': "Row not found"} @@ -880,7 +942,7 @@ class CustomerOrderView(MasterView): if not uuid: return {'error': "Must specify a product UUID"} - product = self.Session.query(model.Product).get(uuid) + product = self.Session.get(model.Product, uuid) if not product: return {'error': "Product not found"} @@ -921,7 +983,8 @@ class CustomerOrderView(MasterView): if not uuid: return {'error': "Must specify a row UUID"} - row = self.Session.query(model.CustomerOrderBatchRow).get(uuid) + model = self.app.model + row = self.Session.get(model.CustomerOrderBatchRow, uuid) if not row: return {'error': "Row not found"} @@ -933,6 +996,11 @@ class CustomerOrderView(MasterView): 'batch': self.normalize_batch(batch)} def submit_new_order(self, batch, data): + + reason = self.batch_handler.why_not_execute(batch, user=self.request.user) + if reason: + return {'error': reason} + try: result = self.execute_new_order_batch(batch, data) except Exception as error: @@ -957,8 +1025,63 @@ class CustomerOrderView(MasterView): def execute_new_order_batch(self, batch, data): return self.batch_handler.do_execute(batch, self.request.user) + def fetch_order_data(self): + app = self.get_rattail_app() + model = self.model + + order = None + uuid = self.request.GET.get('uuid') + if uuid: + order = self.Session.get(model.CustomerOrder, uuid) + if not order: + # raise self.notfound() + return {'error': "Customer order not found"} + + address = None + if self.batch_handler.new_order_requires_customer(): + contact = order.customer + else: + contact = order.person + if contact and contact.address: + a = contact.address + address = { + 'street_1': a.street, + 'street_2': a.street2, + 'city': a.city, + 'state': a.state, + 'zip': a.zipcode, + } + + # gather all the order items + items = [] + grand_total = 0 + for item in order.items: + item_data = { + 'uuid': item.uuid, + 'special_order': False, # TODO + 'product_description': item.product_description, + 'order_quantity': app.render_quantity(item.order_quantity), + 'department': item.department_name, + 'price': app.render_currency(item.unit_price), + 'total': app.render_currency(item.total_price), + } + items.append(item_data) + grand_total += item.total_price + + return { + 'uuid': order.uuid, + 'id': order.id, + 'created_display': app.render_datetime(app.localtime(order.created, from_utc=True)), + 'contact_display': str(contact or ''), + 'address': address, + 'phone_display': str(contact.phone) if contact and contact.phone else "", + 'email_display': str(contact.email) if contact and contact.email else "", + 'items': items, + 'grand_total_display': app.render_currency(grand_total), + } + def configure_get_simple_settings(self): - return [ + settings = [ # customer handling {'section': 'rattail.custorders', @@ -981,17 +1104,45 @@ class CustomerOrderView(MasterView): {'section': 'rattail.custorders', 'option': 'product_price_may_be_questionable', 'type': bool}, - {'section': 'rattail.custorders', - 'option': 'allow_unknown_product', - 'type': bool}, {'section': 'rattail.custorders', 'option': 'allow_item_discounts', 'type': bool}, + {'section': 'rattail.custorders', + 'option': 'allow_item_discounts_if_on_sale', + 'type': bool}, + {'section': 'rattail.custorders', + 'option': 'default_item_discount', + 'type': float}, {'section': 'rattail.custorders', 'option': 'allow_past_item_reorder', 'type': bool}, + + # unknown products + {'section': 'rattail.custorders', + 'option': 'allow_unknown_product', + 'type': bool}, + {'section': 'rattail.custorders', + 'option': 'unknown_product.always_confirm_price', + 'type': bool}, ] + for field in self.PENDING_PRODUCT_ENTRY_FIELDS: + setting = {'section': 'rattail.custorders', + 'option': f'unknown_product.fields.{field}.required', + 'type': bool} + if field == 'description': + setting['default'] = True + settings.append(setting) + + return settings + + def configure_get_context(self, **kwargs): + context = super().configure_get_context(**kwargs) + + context['pending_product_fields'] = self.PENDING_PRODUCT_ENTRY_FIELDS + + return context + @classmethod def defaults(cls, config): cls._order_defaults(config) @@ -1001,6 +1152,21 @@ class CustomerOrderView(MasterView): def _order_defaults(cls, config): route_prefix = cls.get_route_prefix() url_prefix = cls.get_url_prefix() + model_title = cls.get_model_title() + model_title_plural = cls.get_model_title_plural() + permission_prefix = cls.get_permission_prefix() + + config.add_tailbone_permission_group(permission_prefix, model_title_plural, overwrite=False) + + config.add_tailbone_permission(permission_prefix, + f'{permission_prefix}.create_unknown_product', + f"Create new {model_title} for unknown product") + + # add pseudo-index page for creating new custorder + # (makes it available when building menus etc.) + config.add_tailbone_index_page('{}.create'.format(route_prefix), + "New {}".format(model_title), + '{}.create'.format(permission_prefix)) # person autocomplete config.add_route('{}.person_autocomplete'.format(route_prefix), @@ -1029,6 +1195,14 @@ class CustomerOrderView(MasterView): renderer='json', permission='products.list') + # fetch order data + config.add_route(f'{route_prefix}.fetch_order_data', + f'{url_prefix}/fetch-order-data') + config.add_view(cls, attr='fetch_order_data', + route_name=f'{route_prefix}.fetch_order_data', + renderer='json', + permission=f'{permission_prefix}.view') + # TODO: deprecate / remove this CustomerOrdersView = CustomerOrderView diff --git a/tailbone/views/datasync.py b/tailbone/views/datasync.py index e6c31721..2b955b5f 100644 --- a/tailbone/views/datasync.py +++ b/tailbone/views/datasync.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,19 +24,18 @@ DataSync Views """ -from __future__ import unicode_literals, absolute_import - import json import subprocess import logging -import six import sqlalchemy as sa -from rattail.db import model +from rattail.db.model import DataSyncChange from rattail.datasync.util import purge_datasync_settings from rattail.util import simple_error +from webhelpers2.html import tags + from tailbone.views import MasterView from tailbone.util import raw_datetime from tailbone.config import should_expose_websockets @@ -74,10 +73,22 @@ class DataSyncThreadView(MasterView): ] def __init__(self, request, context=None): - super(DataSyncThreadView, self).__init__(request, context=context) + super().__init__(request, context=context) app = self.get_rattail_app() self.datasync_handler = app.get_datasync_handler() + def get_context_menu_items(self, thread=None): + items = super().get_context_menu_items(thread) + route_prefix = self.get_route_prefix() + + # nb. do not show this for /configure page + if self.request.matched_route.name != f'{route_prefix}.configure': + if self.request.has_perm('datasync_changes.list'): + url = self.request.route_url('datasyncchanges') + items.append(tags.link_to("View DataSync Changes", url)) + + return items + def status(self): """ View to list/filter/sort the model data. @@ -109,7 +120,7 @@ class DataSyncThreadView(MasterView): from datasync_change group by source, consumer """ - result = self.Session.execute(sql) + result = self.Session.execute(sa.text(sql)) all_changes = {} for row in result: all_changes[(row.source, row.consumer)] = row.changes @@ -117,7 +128,7 @@ class DataSyncThreadView(MasterView): watcher_data = [] consumer_data = [] now = app.localtime() - for key, profile in six.iteritems(profiles): + for key, profile in profiles.items(): watcher = profile.watcher lastrun = self.datasync_handler.get_watcher_lastrun( @@ -191,10 +202,36 @@ class DataSyncThreadView(MasterView): return self.redirect(self.request.get_referrer( default=self.request.route_url('datasyncchanges'))) - def configure_get_context(self): + def configure_get_simple_settings(self): + """ """ + return [ + + # basic + {'section': 'rattail.datasync', + 'option': 'use_profile_settings', + 'type': bool}, + + # misc. + {'section': 'rattail.datasync', + 'option': 'supervisor_process_name'}, + {'section': 'rattail.datasync', + 'option': 'batch_size_limit', + 'type': int}, + + # legacy + {'section': 'tailbone', + 'option': 'datasync.restart'}, + + ] + + def configure_get_context(self, **kwargs): + """ """ + context = super().configure_get_context(**kwargs) + profiles = self.datasync_handler.get_configured_profiles( include_disabled=True, ignore_problems=True) + context['profiles'] = profiles profiles_data = [] for profile in sorted(profiles.values(), key=lambda p: p.key): @@ -232,25 +269,15 @@ class DataSyncThreadView(MasterView): data['consumers_data'] = consumers profiles_data.append(data) - return { - 'profiles': profiles, - 'profiles_data': profiles_data, - 'use_profile_settings': self.datasync_handler.should_use_profile_settings(), - 'supervisor_process_name': self.rattail_config.get( - 'rattail.datasync', 'supervisor_process_name'), - 'restart_command': self.rattail_config.get( - 'tailbone', 'datasync.restart'), - } + context['profiles_data'] = profiles_data + return context - def configure_gather_settings(self, data): - settings = [] - watch = [] + def configure_gather_settings(self, data, **kwargs): + """ """ + settings = super().configure_gather_settings(data, **kwargs) - use_profile_settings = data.get('use_profile_settings') == 'true' - settings.append({'name': 'rattail.datasync.use_profile_settings', - 'value': 'true' if use_profile_settings else 'false'}) - - if use_profile_settings: + if data.get('rattail.datasync.use_profile_settings') == 'true': + watch = [] for profile in json.loads(data['profiles']): pkey = profile['key'] @@ -258,7 +285,7 @@ class DataSyncThreadView(MasterView): watch.append(pkey) settings.extend([ - {'name': 'rattail.datasync.{}.watcher'.format(pkey), + {'name': 'rattail.datasync.{}.watcher.spec'.format(pkey), 'value': profile['watcher_spec']}, {'name': 'rattail.datasync.{}.watcher.db'.format(pkey), 'value': profile['watcher_dbkey']}, @@ -289,7 +316,7 @@ class DataSyncThreadView(MasterView): if consumer['enabled']: consumers.append(ckey) settings.extend([ - {'name': 'rattail.datasync.{}.consumer.{}'.format(pkey, ckey), + {'name': f'rattail.datasync.{pkey}.consumer.{ckey}.spec', 'value': consumer['consumer_spec']}, {'name': 'rattail.datasync.{}.consumer.{}.db'.format(pkey, ckey), 'value': consumer['consumer_dbkey']}, @@ -304,7 +331,7 @@ class DataSyncThreadView(MasterView): ]) settings.extend([ - {'name': 'rattail.datasync.{}.consumers'.format(pkey), + {'name': 'rattail.datasync.{}.consumers.list'.format(pkey), 'value': ', '.join(consumers)}, ]) @@ -312,17 +339,12 @@ class DataSyncThreadView(MasterView): settings.append({'name': 'rattail.datasync.watch', 'value': ', '.join(watch)}) - if data['supervisor_process_name']: - settings.append({'name': 'rattail.datasync.supervisor_process_name', - 'value': data['supervisor_process_name']}) - - if data['restart_command']: - settings.append({'name': 'tailbone.datasync.restart', - 'value': data['restart_command']}) - return settings - def configure_remove_settings(self): + def configure_remove_settings(self, **kwargs): + """ """ + super().configure_remove_settings(**kwargs) + purge_datasync_settings(self.rattail_config, self.Session()) @classmethod @@ -371,7 +393,7 @@ class DataSyncChangeView(MasterView): """ Master view for the DataSyncChange model. """ - model_class = model.DataSyncChange + model_class = DataSyncChange url_prefix = '/datasync/changes' permission_prefix = 'datasync_changes' creatable = False @@ -392,8 +414,19 @@ class DataSyncChangeView(MasterView): 'consumer', ] + def get_context_menu_items(self, change=None): + items = super().get_context_menu_items(change) + + if self.listing: + + if self.request.has_perm('datasync.status'): + url = self.request.route_url('datasync.status') + items.append(tags.link_to("View DataSync Status", url)) + + return items + def configure_grid(self, g): - super(DataSyncChangeView, self).configure_grid(g) + super().configure_grid(g) # batch_sequence g.set_label('batch_sequence', "Batch Seq.") @@ -407,7 +440,7 @@ class DataSyncChangeView(MasterView): return kwargs def configure_form(self, f): - super(DataSyncChangeView, self).configure_form(f) + super().configure_form(f) f.set_readonly('obtained') diff --git a/tailbone/views/departments.py b/tailbone/views/departments.py index 96dcfb61..47de8dca 100644 --- a/tailbone/views/departments.py +++ b/tailbone/views/departments.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,15 +24,10 @@ Department Views """ -from __future__ import unicode_literals, absolute_import - -import six - -from rattail.db import model +from rattail.db.model import Department, Product from webhelpers2.html import HTML -from tailbone import grids from tailbone.views import MasterView @@ -40,7 +35,7 @@ class DepartmentView(MasterView): """ Master view for the Department class. """ - model_class = model.Department + model_class = Department touchable = True has_versions = True results_downloadable = True @@ -51,6 +46,8 @@ class DepartmentView(MasterView): 'name', 'product', 'personnel', + 'tax', + 'food_stampable', 'exempt_from_gross_sales', ] @@ -59,13 +56,17 @@ class DepartmentView(MasterView): 'name', 'product', 'personnel', + 'tax', + 'food_stampable', 'exempt_from_gross_sales', + 'default_custorder_discount', 'allow_product_deletions', 'employees', ] has_rows = True - model_row_class = model.Product + model_row_class = Product + rows_title = "Products" row_labels = { 'upc': "UPC", @@ -82,22 +83,26 @@ class DepartmentView(MasterView): ] def configure_grid(self, g): - super(DepartmentView, self).configure_grid(g) + super().configure_grid(g) + + # number + g.set_sort_defaults('number') + g.set_link('number') + + # name g.filters['name'].default_active = True g.filters['name'].default_verb = 'contains' - g.set_sort_defaults('number') - g.set_type('product', 'boolean') - g.set_type('personnel', 'boolean') - g.set_link('number') g.set_link('name') + g.set_type('product', 'boolean') + g.set_type('personnel', 'boolean') + def configure_form(self, f): - super(DepartmentView, self).configure_form(f) - use_buefy = self.get_use_buefy() + super().configure_form(f) f.remove_field('subdepartments') - if not use_buefy or self.creating or self.editing: + if self.creating or self.editing: f.remove('employees') else: f.set_renderer('employees', self.render_employees) @@ -105,13 +110,26 @@ class DepartmentView(MasterView): f.set_type('product', 'boolean') f.set_type('personnel', 'boolean') + # tax + if self.creating: + # TODO: make this editable instead + f.remove('tax') + else: + f.set_renderer('tax', self.render_tax) + # TODO: make this editable + f.set_readonly('tax') + + # default_custorder_discount + f.set_type('default_custorder_discount', 'percent') + def render_employees(self, department, field): route_prefix = self.get_route_prefix() permission_prefix = self.get_permission_prefix() factory = self.get_grid_factory() g = factory( - key='{}.employees'.format(route_prefix), + self.request, + key=f'{route_prefix}.employees', data=[], columns=[ 'first_name', @@ -122,42 +140,29 @@ class DepartmentView(MasterView): ) if self.request.has_perm('employees.view'): - g.main_actions.append(self.make_action('view', icon='eye')) + g.actions.append(self.make_action('view', icon='eye')) if self.request.has_perm('employees.edit'): - g.main_actions.append(self.make_action('edit', icon='edit')) + g.actions.append(self.make_action('edit', icon='edit')) return HTML.literal( - g.render_buefy_table_element(data_prop='employeesData')) + g.render_table_element(data_prop='employeesData')) def template_kwargs_view(self, **kwargs): - kwargs = super(DepartmentView, self).template_kwargs_view(**kwargs) - use_buefy = self.get_use_buefy() + kwargs = super().template_kwargs_view(**kwargs) department = kwargs['instance'] - department_employees = sorted(department.employees, key=six.text_type) + department_employees = sorted(department.employees, key=str) - if use_buefy: - employees = [] - for employee in department_employees: - person = employee.person - employees.append({ - 'uuid': employee.uuid, - 'first_name': person.first_name, - 'last_name': person.last_name, - '_action_url_view': self.request.route_url('employees.view', uuid=employee.uuid), - '_action_url_edit': self.request.route_url('employees.edit', uuid=employee.uuid), - }) - kwargs['employees_data'] = employees - - else: # not buefy - if department.employees: - actions = [ - grids.GridAction('view', icon='zoomin', - url=lambda r, i: self.request.route_url('employees.view', uuid=r.uuid)) - ] - kwargs['employees'] = grids.Grid(None, department_employees, ['display_name'], request=self.request, - model_class=model.Employee, main_actions=actions) - else: - kwargs['employees'] = None + employees = [] + for employee in department_employees: + person = employee.person + employees.append({ + 'uuid': employee.uuid, + 'first_name': person.first_name, + 'last_name': person.last_name, + '_action_url_view': self.request.route_url('employees.view', uuid=employee.uuid), + '_action_url_edit': self.request.route_url('employees.edit', uuid=employee.uuid), + }) + kwargs['employees_data'] = employees return kwargs @@ -166,6 +171,7 @@ class DepartmentView(MasterView): Check to see if there are any products which belong to the department; if there are then we do not allow delete and redirect the user. """ + model = self.model count = self.Session.query(model.Product)\ .filter(model.Product.department == department)\ .count() @@ -175,6 +181,7 @@ class DepartmentView(MasterView): raise self.redirect(self.get_action_url('view', department)) def get_row_data(self, department): + model = self.model return self.Session.query(model.Product)\ .filter(model.Product.department == department) @@ -182,7 +189,7 @@ class DepartmentView(MasterView): return product.department def configure_row_grid(self, g): - super(DepartmentView, self).configure_row_grid(g) + super().configure_row_grid(g) app = self.get_rattail_app() self.handler = app.get_products_handler() @@ -204,6 +211,7 @@ class DepartmentView(MasterView): """ View list of departments by vendor """ + model = self.model data = self.Session.query(model.Department)\ .outerjoin(model.Product)\ .join(model.ProductCost)\ @@ -212,20 +220,14 @@ class DepartmentView(MasterView): .distinct()\ .order_by(model.Department.name) - def configure(g): - g.configure(include=[ - g.name, - ], readonly=True) + def normalize(dept): + return { + 'uuid': dept.uuid, + 'number': dept.number, + 'name': dept.name, + } - def row_attrs(row, i): - return {'data-uuid': row.uuid} - - grid = self.make_grid(data=data, sortable=False, filterable=False, pageable=False, - configure=configure, width=None, checkboxes=True, - row_attrs=row_attrs, main_actions=[], more_actions=[]) - self.request.response.content_type = str('text/html') - self.request.response.text = grid.render_grid() - return self.request.response + return self.json_response([normalize(d) for d in data]) @classmethod def defaults(cls, config): diff --git a/tailbone/views/email.py b/tailbone/views/email.py index 536bf6ed..98bd4295 100644 --- a/tailbone/views/email.py +++ b/tailbone/views/email.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,27 +24,26 @@ Email Views """ -from __future__ import unicode_literals, absolute_import - +import logging import re import warnings -import six +from wuttjamaican.util import parse_list -from rattail import mail -from rattail.db import model -from rattail.config import parse_list +from rattail.db.model import EmailAttempt from rattail.util import simple_error import colander from deform import widget as dfwidget -from webhelpers2.html import HTML from tailbone import grids from tailbone.db import Session from tailbone.views import View, MasterView +log = logging.getLogger(__name__) + + class EmailSettingView(MasterView): """ Master view for email admin (settings/preview). @@ -85,7 +84,7 @@ class EmailSettingView(MasterView): ] def __init__(self, request): - super(EmailSettingView, self).__init__(request) + super().__init__(request) self.email_handler = self.get_handler() @property @@ -105,17 +104,24 @@ class EmailSettingView(MasterView): emails = self.email_handler.get_all_emails() else: emails = self.email_handler.get_available_emails() - for key, Email in six.iteritems(emails): + for key, Email in emails.items(): email = Email(self.rattail_config, key) - data.append(self.normalize(email)) + try: + normalized = self.normalize(email) + except: + log.warning("cannot normalize email: %s", email, + exc_info=True) + else: + data.append(normalized) return data def configure_grid(self, g): - g.sorters['key'] = g.make_simple_sorter('key', foldcase=True) - g.sorters['prefix'] = g.make_simple_sorter('prefix', foldcase=True) - g.sorters['subject'] = g.make_simple_sorter('subject', foldcase=True) - g.sorters['enabled'] = g.make_simple_sorter('enabled') + super().configure_grid(g) + + g.sort_on_backend = False + g.sort_multiple = False g.set_sort_defaults('key') + g.set_type('enabled', 'boolean') g.set_link('key') g.set_link('subject') @@ -125,18 +131,16 @@ class EmailSettingView(MasterView): # to g.set_renderer('to', self.render_to_short) - g.sorters['to'] = g.make_simple_sorter('to', foldcase=True) # hidden if self.has_perm('configure'): - g.sorters['hidden'] = g.make_simple_sorter('hidden') g.set_type('hidden', 'boolean') else: g.remove('hidden') # toggle hidden if self.has_perm('configure'): - g.main_actions.append( + g.actions.append( self.make_action('toggle_hidden', url='#', icon='ban', click_handler='toggleHidden(props.row)', factory=ToggleHidden)) @@ -198,7 +202,7 @@ class EmailSettingView(MasterView): return True def configure_form(self, f): - super(EmailSettingView, self).configure_form(f) + super().configure_form(f) profile = f.model_instance['_email'] # key @@ -266,20 +270,32 @@ class EmailSettingView(MasterView): app.save_setting(session, 'rattail.mail.{}.to'.format(key), (data['to'] or '').replace('\n', ', ')) app.save_setting(session, 'rattail.mail.{}.cc'.format(key), (data['cc'] or '').replace('\n', ', ')) app.save_setting(session, 'rattail.mail.{}.bcc'.format(key), (data['bcc'] or '').replace('\n', ', ')) - app.save_setting(session, 'rattail.mail.{}.enabled'.format(key), six.text_type(data['enabled']).lower()) + app.save_setting(session, 'rattail.mail.{}.enabled'.format(key), str(data['enabled']).lower()) if self.has_perm('configure'): - app.save_setting(session, 'rattail.mail.{}.hidden'.format(key), six.text_type(data['hidden']).lower()) + app.save_setting(session, 'rattail.mail.{}.hidden'.format(key), str(data['hidden']).lower()) return data def template_kwargs_view(self, **kwargs): + kwargs = super().template_kwargs_view(**kwargs) + app = self.get_rattail_app() + key = self.request.matchdict['key'] kwargs['email'] = self.email_handler.get_email(key) + + kwargs['user_email_address'] = app.get_contact_email_address(self.request.user) + return kwargs def configure_get_simple_settings(self): config = self.rattail_config return [ + # general + {'section': 'rattail.mail', + 'option': 'handler'}, + {'section': 'rattail.mail', + 'option': 'templates'}, + # sending {'section': 'rattail.mail', 'option': 'record_attempts', @@ -289,6 +305,19 @@ class EmailSettingView(MasterView): 'type': bool}, ] + def configure_get_context(self, *args, **kwargs): + context = super().configure_get_context(*args, **kwargs) + app = self.get_rattail_app() + + # prettify list of template paths + templates = self.rattail_config.parse_list( + context['simple_settings']['rattail.mail.templates']) + context['simple_settings']['rattail.mail.templates'] = ', '.join(templates) + + context['user_email_address'] = app.get_contact_email_address(self.request.user) + + return context + def toggle_hidden(self): app = self.get_rattail_app() data = self.request.json_body @@ -297,6 +326,22 @@ class EmailSettingView(MasterView): 'true' if data['hidden'] else 'false') return {'ok': True} + def send_test(self): + """ + AJAX view for sending a test email. + """ + data = self.request.json_body + + recip = data.get('recipient') + if not recip: + return {'error': "Must specify recipient"} + + app = self.get_rattail_app() + app.send_email('hello', to=[recip], cc=None, bcc=None, + default_subject="Hello world") + + return {'ok': True} + @classmethod def defaults(cls, config): cls._email_defaults(config) @@ -318,6 +363,16 @@ class EmailSettingView(MasterView): permission='{}.configure'.format(permission_prefix), renderer='json') + # send test + config.add_route('{}.send_test'.format(route_prefix), + '{}/send-test'.format(url_prefix), + request_method='POST') + config.add_view(cls, attr='send_test', + route_name='{}.send_test'.format(route_prefix), + permission='{}.configure'.format(permission_prefix), + renderer='json') + + # TODO: deprecate / remove this ProfilesView = EmailSettingView @@ -380,7 +435,7 @@ class EmailPreview(View): """ def __init__(self, request): - super(EmailPreview, self).__init__(request) + super().__init__(request) if hasattr(self, 'get_handler'): warnings.warn("defining a get_handler() method is deprecated; " @@ -463,7 +518,7 @@ class EmailAttemptView(MasterView): """ Master view for email attempts. """ - model_class = model.EmailAttempt + model_class = EmailAttempt route_prefix = 'email_attempts' url_prefix = '/email/attempts' creatable = False @@ -496,7 +551,7 @@ class EmailAttemptView(MasterView): ] def configure_grid(self, g): - super(EmailAttemptView, self).configure_grid(g) + super().configure_grid(g) # sent g.set_sort_defaults('sent', 'desc') @@ -526,13 +581,12 @@ class EmailAttemptView(MasterView): if len(recips) > 2: recips = recips[:2] recips.append('...') - recips = [HTML.escape(r) for r in recips] return ', '.join(recips) return value def configure_form(self, f): - super(EmailAttemptView, self).configure_form(f) + super().configure_form(f) # key f.set_renderer('key', self.render_email_key) diff --git a/tailbone/views/employees.py b/tailbone/views/employees.py index b45e78e7..debd8fcb 100644 --- a/tailbone/views/employees.py +++ b/tailbone/views/employees.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,9 +24,6 @@ Employee Views """ -from __future__ import unicode_literals, absolute_import - -import six import sqlalchemy as sa from rattail.db import model @@ -48,6 +45,7 @@ class EmployeeView(MasterView): touchable = True supports_autocomplete = True results_downloadable = True + configurable = True labels = { 'id': "ID", @@ -81,10 +79,25 @@ class EmployeeView(MasterView): 'departments', ] + def should_expose_quickie_search(self): + if self.expose_quickie_search: + return True + app = self.get_rattail_app() + return app.get_people_handler().should_expose_quickie_search() + + def get_quickie_perm(self): + return 'people.quickie' + + def get_quickie_url(self): + return self.request.route_url('people.quickie') + + def get_quickie_placeholder(self): + app = self.get_rattail_app() + return app.get_people_handler().get_quickie_search_placeholder() + def configure_grid(self, g): - super(EmployeeView, self).configure_grid(g) + super().configure_grid(g) route_prefix = self.get_route_prefix() - use_buefy = self.get_use_buefy() # phone g.set_joiner('phone', lambda q: q.outerjoin(model.EmployeePhoneNumber, sa.and_( @@ -102,9 +115,20 @@ class EmployeeView(MasterView): g.filters['email'] = g.make_filter('email', model.EmployeeEmailAddress.address, label="Email Address") - # first/last name - g.filters['first_name'] = g.make_filter('first_name', model.Person.first_name) - g.filters['last_name'] = g.make_filter('last_name', model.Person.last_name) + # first_name + g.set_link('first_name') + g.set_sorter('first_name', model.Person.first_name) + g.set_sort_defaults('first_name') + g.set_filter('first_name', model.Person.first_name, + default_active=True, + default_verb='contains') + + # last_name + g.set_link('last_name') + g.set_sorter('last_name', model.Person.last_name) + g.set_filter('last_name', model.Person.last_name, + default_active=True, + default_verb='contains') # username if self.request.has_perm('users.view'): @@ -127,37 +151,52 @@ class EmployeeView(MasterView): g.set_enum('status', self.enum.EMPLOYEE_STATUS) g.filters['status'].default_active = True g.filters['status'].default_verb = 'equal' - if use_buefy: - g.filters['status'].default_value = six.text_type(self.enum.EMPLOYEE_STATUS_CURRENT) - else: - g.filters['status'].default_value = self.enum.EMPLOYEE_STATUS_CURRENT + g.filters['status'].default_value = str(self.enum.EMPLOYEE_STATUS_CURRENT) else: g.remove('status') del g.filters['status'] - g.filters['first_name'].default_active = True - g.filters['first_name'].default_verb = 'contains' - - g.filters['last_name'].default_active = True - g.filters['last_name'].default_verb = 'contains' - - g.sorters['first_name'] = lambda q, d: q.order_by(getattr(model.Person.first_name, d)()) - g.sorters['last_name'] = lambda q, d: q.order_by(getattr(model.Person.last_name, d)()) - - g.sorters['email'] = lambda q, d: q.order_by(getattr(model.EmployeeEmailAddress.address, d)()) - - g.set_sort_defaults('first_name') + g.set_sorter('email', model.EmployeeEmailAddress.address) g.set_label('email', "Email Address") - g.set_link('first_name') - g.set_link('last_name') + if (self.request.has_perm('people.view_profile') + and self.should_link_straight_to_profile()): + + # add View Raw action + url = lambda r, i: self.request.route_url( + f'{route_prefix}.view', **self.get_action_route_kwargs(r)) + # nb. insert to slot 1, just after normal View action + g.actions.insert(1, self.make_action('view_raw', url=url, icon='eye')) + + def default_view_url(self): + if (self.request.has_perm('people.view_profile') + and self.should_link_straight_to_profile()): + app = self.get_rattail_app() + + def url(employee, i): + person = app.get_person(employee) + if person: + return self.request.route_url( + 'people.view_profile', uuid=person.uuid, + _anchor='employee') + return self.get_action_url('view', employee) + + return url + + return super().default_view_url() + + def should_link_straight_to_profile(self): + return self.rattail_config.getbool('rattail', + 'employees.straight_to_profile', + default=False) def query(self, session): - q = session.query(model.Employee).join(model.Person) + query = super().query(session) + query = query.join(model.Person) if not self.has_perm('view_all'): - q = q.filter(model.Employee.status == self.enum.EMPLOYEE_STATUS_CURRENT) - return q + query = query.filter(model.Employee.status == self.enum.EMPLOYEE_STATUS_CURRENT) + return query def grid_render_username(self, employee, field): person = employee.person if employee else None @@ -186,7 +225,7 @@ class EmployeeView(MasterView): return not self.is_employee_protected(employee) def configure_form(self, f): - super(EmployeeView, self).configure_form(f) + super().configure_form(f) employee = f.model_instance f.set_renderer('person', self.render_person) @@ -201,7 +240,7 @@ class EmployeeView(MasterView): f.set_label('stores', "Stores") # TODO: should not be necessary if self.creating or self.editing: stores = self.get_possible_stores().all() - store_values = [(s.uuid, six.text_type(s)) for s in stores] + store_values = [(s.uuid, str(s)) for s in stores] f.set_node('stores', colander.SchemaNode(colander.Set())) f.set_widget('stores', dfwidget.SelectWidget(multiple=True, size=len(stores), @@ -213,7 +252,7 @@ class EmployeeView(MasterView): f.set_label('departments', "Departments") # TODO: should not be necessary if self.creating or self.editing: departments = self.get_possible_departments().all() - dept_values = [(d.uuid, six.text_type(d)) for d in departments] + dept_values = [(d.uuid, str(d)) for d in departments] f.set_node('departments', colander.SchemaNode(colander.Set())) f.set_widget('departments', dfwidget.SelectWidget(multiple=True, size=len(departments), @@ -240,7 +279,7 @@ class EmployeeView(MasterView): def objectify(self, form, data=None): if data is None: data = form.validated - employee = super(EmployeeView, self).objectify(form, data) + employee = super().objectify(form, data) self.update_stores(employee, data) self.update_departments(employee, data) return employee @@ -255,7 +294,7 @@ class EmployeeView(MasterView): employee._stores.append(model.EmployeeStore(store_uuid=uuid)) for uuid in old_stores: if uuid not in new_stores: - store = self.Session.query(model.Store).get(uuid) + store = self.Session.get(model.Store, uuid) employee.stores.remove(store) def update_departments(self, employee, data): @@ -268,7 +307,7 @@ class EmployeeView(MasterView): employee._departments.append(model.EmployeeDepartment(department_uuid=uuid)) for uuid in old_depts: if uuid not in new_depts: - dept = self.Session.query(model.Department).get(uuid) + dept = self.Session.get(model.Department, uuid) employee.departments.remove(dept) def get_possible_stores(self): @@ -283,7 +322,7 @@ class EmployeeView(MasterView): person = employee.person if employee else None if not person: return "" - text = six.text_type(person) + text = str(person) url = self.request.route_url('people.view', uuid=person.uuid) return tags.link_to(text, url) @@ -292,8 +331,8 @@ class EmployeeView(MasterView): if not stores: return "" items = [] - for store in sorted(stores, key=six.text_type): - items.append(HTML.tag('li', c=six.text_type(store))) + for store in sorted(stores, key=str): + items.append(HTML.tag('li', c=str(store))) return HTML.tag('ul', c=items) def render_departments(self, employee, field): @@ -301,8 +340,8 @@ class EmployeeView(MasterView): if not departments: return "" items = [] - for department in sorted(departments, key=six.text_type): - items.append(HTML.tag('li', c=six.text_type(department))) + for department in sorted(departments, key=str): + items.append(HTML.tag('li', c=str(department))) return HTML.tag('ul', c=items) def touch_instance(self, employee): @@ -319,6 +358,15 @@ class EmployeeView(MasterView): (model.EmployeeDepartment, 'employee_uuid'), ] + def configure_get_simple_settings(self): + return [ + + # General + {'section': 'rattail', + 'option': 'employees.straight_to_profile', + 'type': bool}, + ] + @classmethod def defaults(cls, config): cls._defaults(config) @@ -327,6 +375,7 @@ class EmployeeView(MasterView): @classmethod def _employee_defaults(cls, config): permission_prefix = cls.get_permission_prefix() + model_title = cls.get_model_title() model_title_plural = cls.get_model_title_plural() # view *all* employees @@ -334,6 +383,11 @@ class EmployeeView(MasterView): '{}.view_all'.format(permission_prefix), "View *all* (not just current) {}".format(model_title_plural)) + # view employee "secrets" + config.add_tailbone_permission(permission_prefix, + '{}.view_secrets'.format(permission_prefix), + "View \"secrets\" for {} (e.g. login ID, passcode)".format(model_title)) + def defaults(config, **kwargs): base = globals() diff --git a/tailbone/views/essentials.py b/tailbone/views/essentials.py index b38749d1..08d2e0c4 100644 --- a/tailbone/views/essentials.py +++ b/tailbone/views/essentials.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,18 +24,36 @@ Essential views for convenient includes """ -from __future__ import unicode_literals, absolute_import + +def defaults(config, **kwargs): + mod = lambda spec: kwargs.get(spec, spec) + + config.include(mod('tailbone.views.auth')) + config.include(mod('tailbone.views.common')) + config.include(mod('tailbone.views.datasync')) + config.include(mod('tailbone.views.email')) + config.include(mod('tailbone.views.importing')) + config.include(mod('tailbone.views.luigi')) + config.include(mod('tailbone.views.menus')) + config.include(mod('tailbone.views.people')) + config.include(mod('tailbone.views.permissions')) + config.include(mod('tailbone.views.progress')) + config.include(mod('tailbone.views.reports')) + config.include(mod('tailbone.views.roles')) + config.include(mod('tailbone.views.settings')) + config.include(mod('tailbone.views.tables')) + config.include(mod('tailbone.views.upgrades')) + config.include(mod('tailbone.views.users')) + config.include(mod('tailbone.views.views')) + + # include project views by default, but let caller avoid that by + # passing False + projects = kwargs.get('tailbone.views.projects', True) + if projects: + if projects is True: + projects = 'tailbone.views.projects' + config.include(projects) def includeme(config): - config.include('tailbone.views.auth') - config.include('tailbone.views.common') - config.include('tailbone.views.email') - config.include('tailbone.views.menus') - config.include('tailbone.views.people') - config.include('tailbone.views.progress') - config.include('tailbone.views.roles') - config.include('tailbone.views.settings') - config.include('tailbone.views.tables') - config.include('tailbone.views.upgrades') - config.include('tailbone.views.users') + defaults(config) diff --git a/tailbone/views/exports.py b/tailbone/views/exports.py index 3f6d417c..44df359f 100644 --- a/tailbone/views/exports.py +++ b/tailbone/views/exports.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,19 +24,12 @@ Master class for generic export history views """ -from __future__ import unicode_literals, absolute_import - import os import shutil -import six - -from rattail.db import model - from pyramid.response import FileResponse -from webhelpers2.html import HTML, tags +from webhelpers2.html import tags -from tailbone import forms from tailbone.views import MasterView @@ -49,6 +42,11 @@ class ExportMasterView(MasterView): downloadable = False delete_export_files = False + labels = { + 'id': "ID", + 'created_by': "Created by", + } + grid_columns = [ 'id', 'created', @@ -81,26 +79,30 @@ class ExportMasterView(MasterView): return self.get_file_path(export) def configure_grid(self, g): - super(ExportMasterView, self).configure_grid(g) + super().configure_grid(g) + model = self.model - g.joiners['created_by'] = lambda q: q.join(model.User) - g.sorters['created_by'] = g.make_sorter(model.User.username) - g.filters['created_by'] = g.make_filter('created_by', model.User.username) + # id + g.set_renderer('id', self.render_id) + g.set_link('id') + + # filename + g.set_link('filename') + + # created g.set_sort_defaults('created', 'desc') - g.set_renderer('id', self.render_id) - - g.set_label('id', "ID") - g.set_label('created_by', "Created by") - - g.set_link('id') - g.set_link('filename') + # created_by + g.set_joiner('created_by', + lambda q: q.join(model.User).outerjoin(model.Person)) + g.set_sorter('created_by', model.Person.display_name) + g.set_filter('created_by', model.Person.display_name) def render_id(self, export, field): return export.id_str def configure_form(self, f): - super(ExportMasterView, self).configure_form(f) + super().configure_form(f) export = f.model_instance # NOTE: we try to handle the 'creating' scenario even though this class @@ -143,7 +145,7 @@ class ExportMasterView(MasterView): f.set_renderer('filename', self.render_downloadable_file) def objectify(self, form, data=None): - obj = super(ExportMasterView, self).objectify(form, data=data) + obj = super().objectify(form, data=data) if self.creating: obj.created_by = self.request.user return obj @@ -152,7 +154,7 @@ class ExportMasterView(MasterView): user = export.created_by if not user: return "" - text = six.text_type(user) + text = str(user) if self.request.has_perm('users.view'): url = self.request.route_url('users.view', uuid=user.uuid) return tags.link_to(text, url) @@ -169,12 +171,8 @@ class ExportMasterView(MasterView): export = self.get_instance() path = self.get_file_path(export) response = FileResponse(path, request=self.request) - if six.PY3: - response.headers['Content-Length'] = str(os.path.getsize(path)) - response.headers['Content-Disposition'] = 'attachment; filename="{}"'.format(export.filename) - else: - response.headers[b'Content-Length'] = six.binary_type(os.path.getsize(path)) - response.headers[b'Content-Disposition'] = b'attachment; filename="{}"'.format(export.filename) + response.headers['Content-Length'] = str(os.path.getsize(path)) + response.headers['Content-Disposition'] = 'attachment; filename="{}"'.format(export.filename) return response def delete_instance(self, export): @@ -189,4 +187,4 @@ class ExportMasterView(MasterView): shutil.rmtree(dirname) # continue w/ normal deletion - super(ExportMasterView, self).delete_instance(export) + super().delete_instance(export) diff --git a/tailbone/views/features.py b/tailbone/views/features.py index d55be524..d9417452 100644 --- a/tailbone/views/features.py +++ b/tailbone/views/features.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,9 +24,6 @@ Feature views """ -from __future__ import unicode_literals, absolute_import - -import six import colander import markdown @@ -49,27 +46,23 @@ class GenerateFeatureView(View): return handler def __call__(self): - use_buefy = self.get_use_buefy() - schema = self.handler.make_schema() - app_form = forms.Form(schema=schema, request=self.request, - use_buefy=use_buefy) - for key, value in six.iteritems(self.handler.get_defaults()): + app_form = forms.Form(schema=schema, request=self.request) + for key, value in self.handler.get_defaults().items(): app_form.set_default(key, value) feature_forms = {} for feature in self.handler.iter_features(): schema = feature.make_schema() - form = forms.Form(schema=schema, request=self.request, - use_buefy=use_buefy) - for key, value in six.iteritems(feature.get_defaults()): + form = forms.Form(schema=schema, request=self.request) + for key, value in feature.get_defaults().items(): form.set_default(key, value) feature_forms[feature.feature_key] = form result = rendered_result = None feature_type = 'new-report' if self.request.method == 'POST': - if app_form.validate(newstyle=True): + if app_form.validate(): feature_type = self.request.POST['feature_type'] feature = self.handler.get_feature(feature_type) @@ -77,7 +70,7 @@ class GenerateFeatureView(View): raise ValueError("Unknown feature type: {}".format(feature_type)) feature_form = feature_forms[feature.feature_key] - if feature_form.validate(newstyle=True): + if feature_form.validate(): context = dict(app_form.validated) context.update(feature_form.validated) result = self.handler.do_generate(feature, **context) @@ -86,7 +79,6 @@ class GenerateFeatureView(View): context = { 'index_title': "Generate Feature", 'handler': self.handler, - 'use_buefy': use_buefy, 'app_form': app_form, 'feature_type': feature_type, 'feature_forms': feature_forms, diff --git a/tailbone/views/handheld.py b/tailbone/views/handheld.py index 4d702c92..34211c30 100644 --- a/tailbone/views/handheld.py +++ b/tailbone/views/handheld.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,8 +24,6 @@ (DEPRECATED) Views for handheld batches """ -from __future__ import unicode_literals, absolute_import - import warnings # nb. this is imported only for sake of legacy callers @@ -35,5 +33,5 @@ from tailbone.views.batch.handheld import HandheldBatchView def includeme(config): warnings.warn("tailbone.views.handheld is a deprecated module; " "please use tailbone.views.batch.handheld instead", - DeprecationWarning) + DeprecationWarning, stacklevel=2) config.include('tailbone.views.batch.handheld') diff --git a/tailbone/views/importing.py b/tailbone/views/importing.py index 003d7ac4..48b32cc2 100644 --- a/tailbone/views/importing.py +++ b/tailbone/views/importing.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,20 +24,16 @@ View for running arbitrary import/export jobs """ -from __future__ import unicode_literals, absolute_import - import getpass -import socket -import sys +import json import logging +import socket import subprocess +import sys import time -import json -import six import sqlalchemy as sa -from rattail.exceptions import ConfigurationError from rattail.threads import Thread import colander @@ -155,10 +151,15 @@ class ImportingView(MasterView): return data def configure_grid(self, g): - super(ImportingView, self).configure_grid(g) + super().configure_grid(g) g.set_link('host_title') + g.set_searchable('host_title') + g.set_link('local_title') + g.set_searchable('local_title') + + g.set_searchable('handler_spec') def get_instance(self): """ @@ -180,7 +181,7 @@ class ImportingView(MasterView): return ImportHandlerSchema() def make_form_kwargs(self, **kwargs): - kwargs = super(ImportingView, self).make_form_kwargs(**kwargs) + kwargs = super().make_form_kwargs(**kwargs) # nb. this is set as sort of a hack, to prevent SA model # inspection logic @@ -189,7 +190,7 @@ class ImportingView(MasterView): return kwargs def configure_form(self, f): - super(ImportingView, self).configure_form(f) + super().configure_form(f) f.set_renderer('models', self.render_models) @@ -201,7 +202,7 @@ class ImportingView(MasterView): return HTML.tag('ul', c=items) def template_kwargs_view(self, **kwargs): - kwargs = super(ImportingView, self).template_kwargs_view(**kwargs) + kwargs = super().template_kwargs_view(**kwargs) handler_info = kwargs['instance'] kwargs['handler'] = handler_info['_handler'] return kwargs @@ -222,7 +223,7 @@ class ImportingView(MasterView): try: return self.do_runjob(handler_info, form) except Exception as error: - self.request.session.flash(six.text_type(error), 'error') + self.request.session.flash(str(error), 'error') return self.redirect(self.request.current_route_url()) return self.render_to_response('runjob', { @@ -274,7 +275,6 @@ class ImportingView(MasterView): handler = handler_info['_handler'] defaults = { 'request': self.request, - 'use_buefy': self.get_use_buefy(), 'model_instance': handler, 'cancel_url': self.request.route_url('{}.view'.format(route_prefix), key=handler.get_key()), @@ -299,15 +299,42 @@ class ImportingView(MasterView): f.set_widget('models', dfwidget.SelectWidget(values=[(k, k) for k in keys], multiple=True, size=len(keys))) - # f.set_default('models', keys) - f.set_default('create', True) - f.set_default('update', True) - f.set_default('delete', False) + allow_create = True + allow_update = True + allow_delete = True + if len(keys) == 1: + importers = handler.get_importers().values() + importer = list(importers)[0] + allow_create = importer.allow_create + allow_update = importer.allow_update + allow_delete = importer.allow_delete + + if allow_create: + f.set_default('create', True) + else: + f.remove('create') + + if allow_update: + f.set_default('update', True) + else: + f.remove('update') + + if allow_delete: + f.set_default('delete', False) + else: + f.remove('delete') + # f.set_default('runas', self.rattail_config.get('rattail', 'runas.default') or '') + f.set_default('versioning', True) + f.set_helptext('versioning', "If set, version history will be updated as appropriate") + f.set_default('dry_run', False) + f.set_helptext('dry_run', "If set, data will not actually be written") + f.set_default('warnings', False) + f.set_helptext('warnings', "If set, will send an email if any diffs") def do_runjob(self, handler_info, form): handler = handler_info['_handler'] @@ -411,8 +438,7 @@ And here is the output: data = json.dumps({ 'everything_complete': True, }) - if six.PY3: - data = data.encode('utf_8') + data = data.encode('utf_8') cxn.send(data) cxn.send(suffix) cxn.close() @@ -431,22 +457,7 @@ And here is the output: return HTML.tag('div', class_='tailbone-markdown', c=[notes]) def get_cmd_for_handler(self, handler, ignore_errors=False): - handler_key = handler.get_key() - - cmd = self.rattail_config.getlist('rattail.importing', - '{}.cmd'.format(handler_key)) - if not cmd or len(cmd) != 2: - cmd = self.rattail_config.getlist('rattail.importing', - '{}.default_cmd'.format(handler_key)) - - if not cmd or len(cmd) != 2: - msg = ("Missing or invalid config; please set '{}.default_cmd' in the " - "[rattail.importing] section of your config file".format(handler_key)) - if ignore_errors: - return - raise ConfigurationError(msg) - - return cmd + return handler.get_cmd(ignore_errors=ignore_errors) def get_runas_for_handler(self, handler): handler_key = handler.get_key() @@ -627,11 +638,14 @@ class ImportHandlerSchema(colander.MappingSchema): class RunJobSchema(colander.MappingSchema): - handler_spec = colander.SchemaNode(colander.String()) + handler_spec = colander.SchemaNode(colander.String(), + missing=colander.null) - host_title = colander.SchemaNode(colander.String()) + host_title = colander.SchemaNode(colander.String(), + missing=colander.null) - local_title = colander.SchemaNode(colander.String()) + local_title = colander.SchemaNode(colander.String(), + missing=colander.null) models = colander.SchemaNode(colander.List()) diff --git a/tailbone/views/labels/batch.py b/tailbone/views/labels/batch.py index b4910466..e9d2971b 100644 --- a/tailbone/views/labels/batch.py +++ b/tailbone/views/labels/batch.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2019 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -26,8 +26,6 @@ Please use `tailbone.views.batch.labels` instead. """ -from __future__ import unicode_literals, absolute_import - import warnings @@ -35,6 +33,6 @@ def includeme(config): warnings.warn("The `tailbone.views.labels.batch` module is deprecated, " "please use `tailbone.views.batch.labels` instead.", - DeprecationWarning) + DeprecationWarning, stacklevel=2) config.include('tailbone.views.batch.labels') diff --git a/tailbone/views/labels/profiles.py b/tailbone/views/labels/profiles.py index c392e510..fa878448 100644 --- a/tailbone/views/labels/profiles.py +++ b/tailbone/views/labels/profiles.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,8 +24,6 @@ Label Profile Views """ -from __future__ import unicode_literals, absolute_import - from rattail.db import model import colander @@ -80,6 +78,13 @@ class LabelProfileView(MasterView): # format f.set_type('format', 'codeblock') + def template_kwargs_view(self, **kwargs): + kwargs = super(LabelProfileView, self).template_kwargs_view(**kwargs) + + kwargs['label_handler'] = self.label_handler + + return kwargs + def after_create(self, profile): self.after_edit(profile) @@ -93,18 +98,16 @@ class LabelProfileView(MasterView): pass def make_printer_settings_form(self, profile, printer): - use_buefy = self.get_use_buefy() schema = colander.Schema() for name, label in printer.required_settings.items(): node = colander.SchemaNode(colander.String(), name=name, title=label, - default=profile.get_printer_setting(name)) + default=self.label_handler.get_printer_setting(profile, name)) schema.add(node) form = forms.Form(schema=schema, request=self.request, - use_buefy=use_buefy, model_instance=profile, # TODO: ugh, this is necessary to avoid some logic # which assumes a ColanderAlchemy schema i think? diff --git a/tailbone/views/luigi.py b/tailbone/views/luigi.py index aaa7e2be..568183ad 100644 --- a/tailbone/views/luigi.py +++ b/tailbone/views/luigi.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,15 +24,12 @@ Views for Luigi """ -from __future__ import unicode_literals, absolute_import - import json import logging import os import re import shlex -import six import sqlalchemy as sa from rattail.util import simple_error @@ -62,13 +59,25 @@ class LuigiTaskView(MasterView): def __init__(self, request, context=None): super(LuigiTaskView, self).__init__(request, context=context) app = self.get_rattail_app() - self.luigi_handler = app.get_luigi_handler() + + # nb. luigi may not be installed, which (for now) may prevent + # us from getting our handler; in which case warn user + try: + self.luigi_handler = app.get_luigi_handler() + except Exception as error: + self.luigi_handler = None + self.luigi_handler_error = error + log.warning("could not get luigi handler", exc_info=True) def index(self): + + if not self.luigi_handler: + self.request.session.flash("Could not create handler: {}".format( + simple_error(self.luigi_handler_error)), 'error') + luigi_url = self.rattail_config.get('rattail.luigi', 'url') history_url = '{}/history'.format(luigi_url.rstrip('/')) if luigi_url else None return self.render_to_response('index', { - 'use_buefy': self.get_use_buefy(), 'index_url': None, 'luigi_url': luigi_url, 'luigi_history_url': history_url, @@ -108,7 +117,10 @@ class LuigiTaskView(MasterView): start_date = app.parse_date(data['start_date']) end_date = app.parse_date(data['end_date']) try: - self.luigi_handler.launch_backfill_task(task, start_date, end_date) + self.luigi_handler.launch_backfill_task(task, start_date, end_date, + keep_config=False, + email_if_empty=True, + wait=False) except Exception as error: log.warning("failed to launch backfill task: %s", task, exc_info=True) @@ -147,19 +159,25 @@ class LuigiTaskView(MasterView): return context def get_overnight_tasks(self): - tasks = self.luigi_handler.get_all_overnight_tasks() + if self.luigi_handler: + tasks = self.luigi_handler.get_all_overnight_tasks() + else: + tasks = [] for task in tasks: if task['last_date']: - task['last_date'] = six.text_type(task['last_date']) + task['last_date'] = str(task['last_date']) return tasks def get_backfill_tasks(self): - tasks = self.luigi_handler.get_all_backfill_tasks() + if self.luigi_handler: + tasks = self.luigi_handler.get_all_backfill_tasks() + else: + tasks = [] for task in tasks: if task['last_date']: - task['last_date'] = six.text_type(task['last_date']) + task['last_date'] = str(task['last_date']) if task['target_date']: - task['target_date'] = six.text_type(task['target_date']) + task['target_date'] = str(task['target_date']) return tasks def configure_gather_settings(self, data): @@ -202,7 +220,7 @@ class LuigiTaskView(MasterView): {'name': 'rattail.luigi.backfill.task.{}.notes'.format(key), 'value': task['notes']}, {'name': 'rattail.luigi.backfill.task.{}.target_date'.format(key), - 'value': six.text_type(task['target_date'])}, + 'value': str(task['target_date'])}, ]) if keys: settings.append({'name': 'rattail.luigi.backfill.tasks', diff --git a/tailbone/views/master.py b/tailbone/views/master.py index af776d97..21a5e58f 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,38 +24,34 @@ Model Master View """ -from __future__ import unicode_literals, absolute_import - +import io import os import csv import datetime import getpass import shutil -import tempfile import logging +from collections import OrderedDict import json -import six import sqlalchemy as sa from sqlalchemy import orm import sqlalchemy_continuum as continuum from sqlalchemy_utils.functions import get_primary_keys, get_columns -from rattail.db import model, Session as RattailSession +from wuttjamaican.util import get_class_hierarchy from rattail.db.continuum import model_transaction_query -from rattail.util import prettify, OrderedDict, simple_error -from rattail.time import localtime +from rattail.util import simple_error from rattail.threads import Thread from rattail.csvutil import UnicodeDictWriter -from rattail.files import temp_path from rattail.excel import ExcelWriter from rattail.gpc import GPC import colander import deform +from deform import widget as dfwidget from pyramid import httpexceptions from pyramid.renderers import get_renderer, render_to_response, render -from pyramid.response import FileResponse from webhelpers2.html import HTML, tags from webob.compat import cgi_FieldStorage @@ -107,6 +103,7 @@ class MasterView(View): set_deletable = False supports_autocomplete = False supports_set_enabled_toggle = False + supports_grid_totals = False populatable = False mergeable = False merge_handler = None @@ -116,9 +113,11 @@ class MasterView(View): executable = False execute_progress_template = None execute_progress_initial_msg = None + execute_can_cancel = True supports_prev_next = False supports_import_batch_from_file = False has_input_file_templates = False + has_output_file_templates = False configurable = False # set to True to add "View *global* Objects" permission, and @@ -139,6 +138,7 @@ class MasterView(View): deleting = False executing = False cloning = False + configuring = False has_pk_fields = False has_image = False has_thumbnail = False @@ -162,6 +162,8 @@ class MasterView(View): labels = {'uuid': "UUID"} + customer_key_fields = {} + member_key_fields = {} product_key_fields = {} # ROW-RELATED ATTRS FOLLOW: @@ -217,7 +219,8 @@ class MasterView(View): to the current thread (one per request), this method should instead return e.g. a new independent ``rattail.db.Session`` instance. """ - return RattailSession() + app = self.get_rattail_app() + return app.make_session() @classmethod def get_grid_factory(cls): @@ -251,7 +254,7 @@ class MasterView(View): def set_labels(self, obj): labels = self.collect_labels() - for key, label in six.iteritems(labels): + for key, label in labels.items(): obj.set_label(key, label) def collect_labels(self): @@ -259,6 +262,8 @@ class MasterView(View): Collect all labels defined within the master class hierarchy. """ labels = {} + for supp in self.iter_view_supplements(): + labels.update(supp.labels) hierarchy = self.get_class_hierarchy() for cls in hierarchy: if hasattr(cls, 'labels'): @@ -266,21 +271,11 @@ class MasterView(View): return labels def get_class_hierarchy(self): - hierarchy = [] - - def traverse(cls): - if cls is not object: - hierarchy.append(cls) - for parent in cls.__bases__: - traverse(parent) - - traverse(self.__class__) - hierarchy.reverse() - return hierarchy + return get_class_hierarchy(self.__class__) def set_row_labels(self, obj): labels = self.collect_row_labels() - for key, label in six.iteritems(labels): + for key, label in labels.items(): obj.set_label(key, label) def collect_row_labels(self): @@ -328,31 +323,36 @@ class MasterView(View): string, then the view will return the rendered grid only. Otherwise returns the full page. """ + # nb. normally this "save defaults" flag is checked within make_grid() + # but it returns JSON data so we can't just do a redirect when there + # is no user; must return JSON error message instead + if (self.request.GET.get('save-current-filters-as-defaults') == 'true' + and not self.request.user): + return self.json_response({'error': "User is not currently logged in"}) + self.listing = True grid = self.make_grid() - use_buefy = self.get_use_buefy() # If user just refreshed the page with a reset instruction, issue a # redirect in order to clear out the query string. - if self.request.GET.get('reset-to-default-filters') == 'true': - return self.redirect(self.request.current_route_url(_query=None)) + if self.request.GET.get('reset-view'): + kw = {'_query': None} + hash_ = self.request.GET.get('hash') + if hash_: + kw['_anchor'] = hash_ + return self.redirect(self.request.current_route_url(**kw)) # Stash some grid stats, for possible use when generating URLs. - if grid.pageable and hasattr(grid, 'pager'): + if grid.paginated and hasattr(grid, 'pager'): self.first_visible_grid_index = grid.pager.first_item - # return grid only, if partial page was requested - if self.request.params.get('partial'): - if use_buefy: - # render grid data only, as JSON - return render_to_response('json', grid.get_buefy_data(), - request=self.request) - else: # just do traditional thing, render grid HTML - self.request.response.content_type = str('text/html') - self.request.response.text = grid.render_grid() - return self.request.response + # return grid data only, if partial page was requested + if self.request.GET.get('partial'): + context = grid.get_table_data() + return self.json_response(context) context = { + 'index_url': None, # nb. avoid title link since this *is* the index 'grid': grid, } @@ -383,7 +383,7 @@ class MasterView(View): grid contents etc. """ - def make_grid(self, factory=None, key=None, data=None, columns=None, **kwargs): + def make_grid(self, factory=None, key=None, data=None, columns=None, session=None, **kwargs): """ Creates a new grid instance """ @@ -392,13 +392,12 @@ class MasterView(View): if key is None: key = self.get_grid_key() if data is None: - data = self.get_data(session=kwargs.get('session')) + data = self.get_data(session=session) if columns is None: columns = self.get_grid_columns() - kwargs.setdefault('request', self.request) kwargs = self.make_grid_kwargs(**kwargs) - grid = factory(key, data, columns, **kwargs) + grid = factory(self.request, key=key, data=data, columns=columns, **kwargs) self.configure_grid(grid) grid.load_settings() return grid @@ -411,9 +410,9 @@ class MasterView(View): """ if session is None: session = self.Session() - kwargs.setdefault('pageable', False) + kwargs.setdefault('paginated', False) grid = self.make_grid(session=session, **kwargs) - return grid.make_visible_data() + return grid.get_visible_data() def get_grid_columns(self): """ @@ -444,21 +443,54 @@ class MasterView(View): 'filterable': self.filterable, 'use_byte_string_filters': self.use_byte_string_filters, 'sortable': self.sortable, - 'pageable': self.pageable, + 'sort_multiple': not self.request.use_oruga, + 'paginated': self.pageable, 'extra_row_class': self.grid_extra_class, 'url': lambda obj: self.get_action_url('view', obj), 'checkboxes': checkboxes, 'checked': self.checked, + 'checkable': self.checkbox, 'clicking_row_checks_box': self.clicking_row_checks_box, 'assume_local_times': self.has_local_times, + 'row_uuid_getter': self.get_uuid_for_grid_row, } - if 'main_actions' not in kwargs and 'more_actions' not in kwargs: - main, more = self.get_grid_actions() - defaults['main_actions'] = main - defaults['more_actions'] = more + + if self.sortable or self.pageable or self.filterable: + defaults['expose_direct_link'] = True + + if 'actions' not in kwargs: + + if 'main_actions' in kwargs: + warnings.warn("main_actions param is deprecated for make_grid_kwargs(); " + "please use actions param instead", + DeprecationWarning, stacklevel=2) + main = kwargs.pop('main_actions') + else: + main = self.get_main_actions() + + if 'more_actions' in kwargs: + warnings.warn("more_actions param is deprecated for make_grid_kwargs(); " + "please use actions param instead", + DeprecationWarning, stacklevel=2) + more = kwargs.pop('more_actions') + else: + more = self.get_more_actions() + + defaults['actions'] = main + more + defaults.update(kwargs) return defaults + def get_uuid_for_grid_row(self, obj): + """ + If possible, this should return a "UUID" value to uniquely + identify the given object. Default of course is to use the + actual ``uuid`` attribute of the object, if present. This + value is needed by grids when checkboxes are used. + """ + if hasattr(obj, 'uuid'): + return obj.uuid + def configure_grid(self, grid): """ Perform "final" configuration for the main data grid. @@ -471,17 +503,19 @@ class MasterView(View): grid.remove('local_only') grid.remove_filter('local_only') + self.configure_column_customer_key(grid) + self.configure_column_member_key(grid) self.configure_column_product_key(grid) + for supp in self.iter_view_supplements(): + supp.configure_grid(grid) + def grid_extra_class(self, obj, i): """ Returns string of extra class(es) for the table row corresponding to the given object, or ``None``. """ - def quickie(self): - raise NotImplementedError - def get_quickie_url(self): route_prefix = self.get_route_prefix() return self.request.route_url('{}.quickie'.format(route_prefix)) @@ -493,7 +527,32 @@ class MasterView(View): def get_quickie_placeholder(self): pass - def make_row_grid(self, factory=None, key=None, data=None, columns=None, **kwargs): + def quickie(self): + """ + Quickie search - tries to do a simple lookup based on a key + value. If a record is found, user is redirected to its view. + """ + entry = self.request.params.get('entry', '').strip() + if not entry: + self.request.session.flash("No search criteria specified", 'error') + return self.redirect(self.request.get_referrer()) + + obj = self.do_quickie_lookup(entry) + if not obj: + model_title = self.get_model_title() + self.request.session.flash(f"{model_title} not found: {entry}", 'error') + return self.redirect(self.request.get_referrer()) + + return self.redirect(self.get_quickie_result_url(obj)) + + def do_quickie_lookup(self, entry): + pass + + def get_quickie_result_url(self, obj): + return self.get_action_url('view', obj) + + def make_row_grid(self, factory=None, key=None, data=None, columns=None, + session=None, **kwargs): """ Make and return a new (configured) rows grid instance. """ @@ -510,9 +569,8 @@ class MasterView(View): if columns is None: columns = self.get_row_grid_columns() - kwargs.setdefault('request', self.request) kwargs = self.make_row_grid_kwargs(**kwargs) - grid = factory(key, data, columns, **kwargs) + grid = factory(self.request, key=key, data=data, columns=columns, **kwargs) self.configure_row_grid(grid) grid.load_settings() return grid @@ -531,42 +589,45 @@ class MasterView(View): 'filterable': self.rows_filterable, 'use_byte_string_filters': self.use_byte_string_filters, 'sortable': self.rows_sortable, - 'pageable': self.rows_pageable, + 'sort_multiple': not self.request.use_oruga, + 'paginated': self.rows_pageable, 'extra_row_class': self.row_grid_extra_class, 'url': lambda obj: self.get_row_action_url('view', obj), } if self.rows_default_pagesize: - defaults['default_pagesize'] = self.rows_default_pagesize + defaults['pagesize'] = self.rows_default_pagesize - if self.has_rows and 'main_actions' not in defaults: + if self.has_rows and 'actions' not in defaults: actions = [] - use_buefy = self.get_use_buefy() # view action if self.rows_viewable: - icon = 'eye' if use_buefy else 'zoomin' - actions.append(self.make_action('view', icon=icon, url=self.row_view_action_url)) + actions.append(self.make_action('view', icon='eye', + url=self.row_view_action_url)) # edit action if self.rows_editable and self.has_perm('edit_row'): - icon = 'edit' if use_buefy else 'pencil' - actions.append(self.make_action('edit', icon=icon, url=self.row_edit_action_url)) + actions.append(self.make_action('edit', icon='edit', + url=self.row_edit_action_url)) # delete action if self.rows_deletable and self.has_perm('delete_row'): - actions.append(self.make_action('delete', icon='trash', url=self.row_delete_action_url)) + actions.append(self.make_action('delete', icon='trash', + url=self.row_delete_action_url, + link_class='has-text-danger')) defaults['delete_speedbump'] = self.rows_deletable_speedbump - defaults['main_actions'] = actions + defaults['actions'] = actions defaults.update(kwargs) return defaults def configure_row_grid(self, grid): - # super(MasterView, self).configure_row_grid(grid) self.set_row_labels(grid) + self.configure_column_customer_key(grid) + self.configure_column_member_key(grid) self.configure_column_product_key(grid) def row_grid_extra_class(self, obj, i): @@ -592,9 +653,8 @@ class MasterView(View): if columns is None: columns = self.get_version_grid_columns() - kwargs.setdefault('request', self.request) kwargs = self.make_version_grid_kwargs(**kwargs) - grid = factory(key, data, columns, **kwargs) + grid = factory(self.request, key=key, data=data, columns=columns, **kwargs) self.configure_version_grid(grid) grid.load_settings() return grid @@ -615,19 +675,18 @@ class MasterView(View): Return a dictionary of kwargs to be passed to the factory when constructing a new version grid. """ - use_buefy = self.get_use_buefy() instance = kwargs.get('instance') or self.get_instance() route = '{}.version'.format(self.get_route_prefix()) defaults = { 'model_class': continuum.transaction_class(self.get_model_class()), 'width': 'full', - 'pageable': True, + 'paginated': True, 'url': lambda txn: self.request.route_url(route, uuid=instance.uuid, txnid=txn.id), } - if 'main_actions' not in kwargs: + if 'actions' not in kwargs: url = lambda txn, i: self.request.route_url(route, uuid=instance.uuid, txnid=txn.id) - defaults['main_actions'] = [ - self.make_action('view', icon='eye' if use_buefy else 'zoomin', url=url), + defaults['actions'] = [ + self.make_action('view', icon='eye', url=url), ] defaults.update(kwargs) return defaults @@ -666,7 +725,7 @@ class MasterView(View): return self.render_to_response(template, context) def make_create_form(self): - return self.make_form(self.get_model_class()) + return self.make_form() def save_create_form(self, form): uploads = self.normalize_uploads(form) @@ -680,27 +739,42 @@ class MasterView(View): return obj def normalize_uploads(self, form, skip=None): + app = self.get_rattail_app() uploads = {} + + def normalize(filedict): + tempdir = app.make_temp_dir() + filepath = os.path.join(tempdir, filedict['filename']) + tmpinfo = form.deform_form[node.name].widget.tmpstore.get(filedict['uid']) + tmpdata = tmpinfo['fp'].read() + with open(filepath, 'wb') as f: + f.write(tmpdata) + return {'tempdir': tempdir, + 'temp_path': filepath} + for node in form.schema: - if isinstance(node.typ, deform.FileData): - if skip and node.name in skip: - continue - # TODO: does form ever *not* have 'validated' attr here? - if hasattr(form, 'validated'): - filedict = form.validated.get(node.name) + if skip and node.name in skip: + continue + + value = form.validated.get(node.name) + if not value: + continue + + if isinstance(value, dfwidget.filedict): + uploads[node.name] = normalize(value) + + elif not isinstance(value, dict): + + try: + values = iter(value) + except TypeError: + pass else: - filedict = self.form_deserialized.get(node.name) - if filedict: - tempdir = tempfile.mkdtemp() - filepath = os.path.join(tempdir, filedict['filename']) - tmpinfo = form.deform_form[node.name].widget.tmpstore.get(filedict['uid']) - tmpdata = tmpinfo['fp'].read() - with open(filepath, 'wb') as f: - f.write(tmpdata) - uploads[node.name] = { - 'tempdir': tempdir, - 'temp_path': filepath, - } + for value in values: + if isinstance(value, dfwidget.filedict): + uploads.setdefault(node.name, []).append( + normalize(value)) + return uploads def process_uploads(self, obj, form, uploads): @@ -710,14 +784,13 @@ class MasterView(View): delete=False, schema=None, importer_host_title=None): handler = handler_factory(self.rattail_config) - use_buefy = self.get_use_buefy() if not schema: schema = forms.SimpleFileImport().bind(request=self.request) - form = forms.Form(schema=schema, request=self.request, use_buefy=use_buefy) + form = forms.Form(schema=schema, request=self.request) form.save_label = "Upload" form.cancel_url = self.get_index_url() - if form.validate(newstyle=True): + if form.validate(): uploads = self.normalize_uploads(form) filepath = uploads['filename']['temp_path'] @@ -746,7 +819,7 @@ class MasterView(View): value = getattr(obj, field) if value is None: return "" - value = six.text_type(value) + value = str(value) if len(value) > 100: value = value[:100] + '...' return value @@ -812,11 +885,34 @@ class MasterView(View): url = self.request.route_url('stores.view', uuid=store.uuid) return tags.link_to(text, url) + def render_tax(self, obj, field): + tax = getattr(obj, field) + if not tax: + return + text = str(tax) + url = self.request.route_url('taxes.view', uuid=tax.uuid) + return tags.link_to(text, url) + + def render_tender(self, obj, field): + tender = getattr(obj, field) + if not tender: + return + text = str(tender) + url = self.request.route_url('tenders.view', uuid=tender.uuid) + return tags.link_to(text, url) + + def valid_employee_uuid(self, node, value): + if value: + model = self.app.model + employee = self.Session.get(model.Employee, value) + if not employee: + node.raise_invalid("Employee not found") + def render_product(self, obj, field): product = getattr(obj, field) if not product: return "" - text = six.text_type(product) + text = str(product) url = self.request.route_url('products.view', uuid=product.uuid) return tags.link_to(text, url) @@ -824,7 +920,7 @@ class MasterView(View): pending = getattr(obj, field) if not pending: return - text = six.text_type(pending) + text = str(pending) url = self.request.route_url('pending_products.view', uuid=pending.uuid) return tags.link_to(text, url, class_='has-background-warning') @@ -837,10 +933,25 @@ class MasterView(View): if short: text = "({}) {}".format(short, vendor.name) else: - text = six.text_type(vendor) + text = str(vendor) url = self.request.route_url('vendors.view', uuid=vendor.uuid) return tags.link_to(text, url) + def valid_vendor_uuid(self, node, value): + if value: + model = self.app.model + vendor = self.Session.get(model.Vendor, value) + if not vendor: + node.raise_invalid("Vendor not found") + + def render_tax(self, obj, field): + tax = getattr(obj, field) + if not tax: + return + text = str(tax) + url = self.request.route_url('taxes.view', uuid=tax.uuid) + return tags.link_to(text, url) + def render_department(self, obj, field): department = getattr(obj, field) if not department: @@ -893,7 +1004,7 @@ class MasterView(View): person = getattr(obj, field) if not person: return "" - text = six.text_type(person) + text = str(person) url = self.request.route_url('people.view', uuid=person.uuid) return tags.link_to(text, url) @@ -901,7 +1012,7 @@ class MasterView(View): person = getattr(obj, field) if not person: return "" - text = six.text_type(person) + text = str(person) url = self.request.route_url('people.view_profile', uuid=person.uuid) return tags.link_to(text, url) @@ -909,7 +1020,7 @@ class MasterView(View): user = getattr(obj, field) if not user: return "" - text = six.text_type(user) + text = str(user) url = self.request.route_url('users.view', uuid=user.uuid) return tags.link_to(text, url) @@ -925,14 +1036,30 @@ class MasterView(View): items.append(HTML.tag('li', c=[tags.link_to(text, url)])) return HTML.tag('ul', c=items) + def render_employee(self, obj, field): + employee = getattr(obj, field) + if not employee: + return "" + text = str(employee) + url = self.request.route_url('employees.view', uuid=employee.uuid) + return tags.link_to(text, url) + def render_customer(self, obj, field): customer = getattr(obj, field) if not customer: return "" - text = six.text_type(customer) + text = str(customer) url = self.request.route_url('customers.view', uuid=customer.uuid) return tags.link_to(text, url) + def render_member(self, obj, field): + member = getattr(obj, field) + if not member: + return + text = str(member) + url = self.request.route_url('members.view', uuid=member.uuid) + return tags.link_to(text, url) + def render_email_key(self, obj, field): if hasattr(obj, field): email_key = getattr(obj, field) @@ -947,6 +1074,24 @@ class MasterView(View): return email_key + def make_status_renderer(self, enum): + """ + Creates and returns a function for use with rendering a + "status combo" field(s) for a record. Assumes the record has + both ``status_code`` and ``status_text`` fields, as batches + do. Renders the simple status code text, and if custom status + text is present, it is rendered as a tooltip. + """ + def render_status(obj, field): + value = obj.status_code + if value is None: + return "" + status_code_text = enum.get(value, str(value)) + if obj.status_text: + return HTML.tag('span', title=obj.status_text, c=status_code_text) + return status_code_text + return render_status + def before_create_flush(self, obj, form): pass @@ -990,8 +1135,9 @@ class MasterView(View): Thread target for populating new object with progress indicator. """ # mustn't use tailbone web session here - session = RattailSession() - obj = session.query(self.model_class).get(uuid) + app = self.get_rattail_app() + session = app.make_session() + obj = session.get(self.model_class, uuid) try: self.populate_object(session, obj, progress=progress) except Exception as error: @@ -1029,7 +1175,6 @@ class MasterView(View): View for viewing details of an existing model record. """ self.viewing = True - use_buefy = self.get_use_buefy() if instance is None: instance = self.get_instance() form = self.make_form(instance) @@ -1042,19 +1187,17 @@ class MasterView(View): # If user just refreshed the page with a reset instruction, issue a # redirect in order to clear out the query string. - if self.request.GET.get('reset-to-default-filters') == 'true': - return self.redirect(self.request.current_route_url(_query=None)) + if self.request.GET.get('reset-view'): + kw = {'_query': None} + hash_ = self.request.GET.get('hash') + if hash_: + kw['_anchor'] = hash_ + return self.redirect(self.request.current_route_url(**kw)) # return grid only, if partial page was requested if self.request.params.get('partial'): - if use_buefy: - # render grid data only, as JSON - return render_to_response('json', grid.get_buefy_data(), - request=self.request) - else: # just do traditional thing, render grid HTML - self.request.response.content_type = str('text/html') - self.request.response.text = grid.render_grid() - return self.request.response + # render grid data only, as JSON + return self.json_response(grid.get_table_data()) context = { 'instance': instance, @@ -1069,12 +1212,14 @@ class MasterView(View): context['dform'] = form.make_deform_form() if self.has_rows: - if use_buefy: - context['rows_grid'] = grid - context['rows_grid_tools'] = HTML(self.make_row_grid_tools(instance) or '').strip() - else: - context['rows_grid'] = grid.render_complete(allow_save_defaults=False, - tools=self.make_row_grid_tools(instance)) + context['rows_grid'] = grid + context['rows_grid_tools'] = HTML(self.make_row_grid_tools(instance) or '').strip() + + context['expose_versions'] = (self.has_versions + and self.request.rattail_config.versioning_enabled() + and self.has_perm('versions')) + if context['expose_versions']: + context['versions_grid'] = self.make_revisions_grid(instance, empty_data=True) return self.render_to_response('view', context) @@ -1181,18 +1326,11 @@ class MasterView(View): instance = self.get_instance() instance_title = self.get_instance_title(instance) grid = self.make_version_grid(instance=instance) - use_buefy = self.get_use_buefy() # return grid only, if partial page was requested if self.request.params.get('partial'): - if use_buefy: - # render grid data only, as JSON - return render_to_response('json', grid.get_buefy_data(), - request=self.request) - else: # just do traditional thing, render grid HTML - self.request.response.content_type = str('text/html') - self.request.response.text = grid.render_grid() - return self.request.response + # render grid data only, as JSON + return self.json_response(grid.get_table_data()) return self.render_to_response('versions', { 'instance': instance, @@ -1211,7 +1349,7 @@ class MasterView(View): return cls.version_grid_key return '{}.history'.format(cls.get_route_prefix()) - def get_version_data(self, instance): + def get_version_data(self, instance, order_by=True): """ Generate the base data set for the version grid. """ @@ -1219,14 +1357,19 @@ class MasterView(View): transaction_class = continuum.transaction_class(model_class) query = model_transaction_query(self.Session(), instance, model_class, child_classes=self.normalize_version_child_classes()) - return query.order_by(transaction_class.issued_at.desc()) + if order_by: + query = query.order_by(transaction_class.issued_at.desc()) + return query def get_version_child_classes(self): """ If applicable, should return a list of child classes which should be considered when querying for version history of an object. """ - return [] + classes = [] + for supp in self.iter_view_supplements(): + classes.extend(supp.get_version_child_classes()) + return classes def normalize_version_child_classes(self): classes = [] @@ -1238,10 +1381,120 @@ class MasterView(View): classes.append(cls) return classes + def make_revisions_grid(self, obj, empty_data=False): + model = self.app.model + route_prefix = self.get_route_prefix() + row_url = lambda txn, i: self.request.route_url(f'{route_prefix}.version', + uuid=obj.uuid, + txnid=txn.id) + + kwargs = { + 'vue_tagname': 'versions-grid', + 'ajax_data_url': self.get_action_url('revisions_data', obj), + 'sortable': True, + 'sort_multiple': not self.request.use_oruga, + 'sort_defaults': ('changed', 'desc'), + 'actions': [ + self.make_action('view', icon='eye', url='#', + click_handler='viewRevision(props.row)'), + self.make_action('view_separate', url=row_url, target='_blank', + icon='external-link-alt', ), + ], + } + + if empty_data: + + # TODO: surely there is a better way to have empty initial + # data..? but so much logic depends on a query, can't + # just pass empty list here + txn_class = continuum.transaction_class(self.get_model_class()) + meta_class = continuum.versioning_manager.transaction_meta_cls + kwargs['data'] = self.Session.query(txn_class)\ + .outerjoin(meta_class, + meta_class.transaction_id == txn_class.id)\ + .filter(txn_class.id == -1) + + else: + kwargs['data'] = self.get_version_data(obj, order_by=False) + + grid = self.make_version_grid(**kwargs) + + grid.set_joiner('user', lambda q: q.outerjoin(model.User)) + grid.set_sorter('user', model.User.username) + + grid.set_link('remote_addr') + + grid.append('id') + grid.set_label('id', "TXN ID") + grid.set_link('id') + + return grid + + def revisions_data(self): + """ + AJAX view to fetch revision data for current instance. + """ + txnid = self.request.GET.get('txnid') + if txnid: + # return single txn data + + app = self.get_rattail_app() + obj = self.get_instance() + cls = self.get_model_class() + txn_cls = continuum.transaction_class(cls) + route_prefix = self.get_route_prefix() + + transactions = model_transaction_query( + self.Session(), obj, cls, + child_classes=self.normalize_version_child_classes()) + + txn = transactions.filter(txn_cls.id == txnid).first() + if not txn: + return self.notfound() + + older = transactions.filter(txn_cls.issued_at <= txn.issued_at)\ + .filter(txn_cls.id != txnid)\ + .order_by(txn_cls.issued_at.desc())\ + .first() + newer = transactions.filter(txn_cls.issued_at >= txn.issued_at)\ + .filter(txn_cls.id != txnid)\ + .order_by(txn_cls.issued_at)\ + .first() + + version_diffs = [] + for version in self.get_relevant_versions(txn, obj): + diff = self.make_version_diff(version) + version_diffs.append(diff.as_struct()) + + changed_raw = app.render_datetime(app.localtime(txn.issued_at, from_utc=True)) + changed_ago = app.render_time_ago(app.make_utc() - txn.issued_at) + + changed_by = str(txn.user or '') + if self.request.has_perm('users.view') and txn.user: + changed_by = tags.link_to(changed_by, self.request.route_url('users.view', uuid=txn.user.uuid)) + + return { + 'txnid': txn.id, + 'changed': f"{changed_raw} ({changed_ago})", + 'changed_by': changed_by, + 'remote_addr': txn.remote_addr, + 'comment': txn.meta.get('comment'), + 'versions': version_diffs, + 'url': self.request.route_url(f'{route_prefix}.version', uuid=obj.uuid, txnid=txnid), + 'prev_txnid': older.id if older else None, + 'next_txnid': newer.id if newer else None, + } + + else: # no txnid, return grid data + obj = self.get_instance() + grid = self.make_revisions_grid(obj) + return grid.get_table_data() + def view_version(self): """ View showing diff details of a particular object version. """ + app = self.get_rattail_app() instance = self.get_instance() model_class = self.get_model_class() route_prefix = self.get_route_prefix() @@ -1269,14 +1522,28 @@ class MasterView(View): if newer: next_url = self.request.route_url('{}.version'.format(route_prefix), uuid=instance.uuid, txnid=newer.id) + version_diffs = [] + versions = self.get_relevant_versions(transaction, instance) + for version in versions: + + old_data = {} + new_data = {} + fields = self.fields_for_version(version) + for field in fields: + if version.previous: + old_data[field] = getattr(version.previous, field) + new_data[field] = getattr(version, field) + diff = self.make_version_diff(version, old_data, new_data, fields=fields) + version_diffs.append(diff) + return self.render_to_response('view_version', { 'instance': instance, 'instance_title': "{} (history)".format(instance_title), 'instance_title_normal': instance_title, 'instance_url': self.get_action_url('versions', instance), 'transaction': transaction, - 'changed': localtime(self.rattail_config, transaction.issued_at, from_utc=True), - 'versions': self.get_relevant_versions(transaction, instance), + 'changed': app.localtime(transaction.issued_at, from_utc=True), + 'version_diffs': version_diffs, 'show_prev_next': True, 'prev_url': prev_url, 'next_url': next_url, @@ -1290,6 +1557,15 @@ class MasterView(View): }) def title_for_version(self, version): + """ + Must return the title text for the given version. By default + this will be the :term:`rattail:model title` for the version's + data class. + + :param version: Reference to a Continuum version object. + + :returns: Title text for the version, as string. + """ cls = continuum.parent_class(version.__class__) return cls.get_model_title() @@ -1397,7 +1673,7 @@ class MasterView(View): pass def validate_quick_row_form(self, form): - return form.validate(newstyle=True) + return form.validate() def make_default_row_grid_tools(self, obj): if self.rows_creatable: @@ -1431,10 +1707,10 @@ class MasterView(View): """ if session is None: session = self.Session() - kwargs.setdefault('pageable', False) + kwargs.setdefault('paginated', False) kwargs.setdefault('sortable', sort) grid = self.make_row_grid(session=session, **kwargs) - return grid.make_visible_data() + return grid.get_visible_data() @classmethod def get_row_url_prefix(cls): @@ -1514,24 +1790,13 @@ class MasterView(View): """ obj = self.get_instance() filename = self.request.GET.get('filename', None) - if not filename: - raise self.notfound() path = self.download_path(obj, filename) - response = FileResponse(path, request=self.request) - response.content_length = os.path.getsize(path) + if not path or not os.path.exists(path): + raise self.notfound() + response = self.file_response(path) content_type = self.download_content_type(path, filename) if content_type: - if six.PY3: - response.content_type = content_type - else: - response.content_type = six.binary_type(content_type) - - # content-disposition - filename = os.path.basename(path) - if six.PY2: - filename = filename.encode('ascii', 'replace') - response.content_disposition = str('attachment; filename="{}"'.format(filename)) - + response.content_type = content_type return response def download_content_type(self, path, filename): @@ -1559,6 +1824,26 @@ class MasterView(View): path = os.path.join(basedir, filespec) return self.file_response(path) + def download_output_file_template(self): + """ + View for downloading an output file template. + """ + key = self.request.GET['key'] + filespec = self.request.GET['file'] + + matches = [tmpl for tmpl in self.get_output_file_templates() + if tmpl['key'] == key] + if not matches: + raise self.notfound() + + template = matches[0] + templatesdir = os.path.join(self.rattail_config.datadir(), + 'templates', 'output_files', + self.get_route_prefix()) + basedir = os.path.join(templatesdir, template['key']) + path = os.path.join(basedir, filespec) + return self.file_response(path) + def edit(self): """ View for editing an existing model record. @@ -1608,7 +1893,7 @@ class MasterView(View): View for deleting an existing model record. """ if not self.deletable: - raise httpexceptions.HTTPForbidden() + raise self.forbidden() self.deleting = True instance = self.get_instance() @@ -1620,6 +1905,7 @@ class MasterView(View): return self.redirect(self.get_action_url('view', instance)) form = self.make_form(instance) + form.save_label = "DELETE Forever" # TODO: Add better validation, ideally CSRF etc. if self.request.method == 'POST': @@ -1662,7 +1948,8 @@ class MasterView(View): def bulk_delete_objects(self, session, objects, progress=None): def delete(obj, i): - self.delete_instance(obj) + if self.deletable_instance(obj): + self.delete_instance(obj) if i % 1000 == 0: session.flush() @@ -1712,7 +1999,7 @@ class MasterView(View): if uuids: uuids = uuids.split(',') # TODO: probably need to allow override of fetcher callable - fetcher = lambda uuid: self.Session.query(self.model_class).get(uuid) + fetcher = lambda uuid: self.Session.get(self.model_class, uuid) objects = [] for uuid in uuids: obj = fetcher(uuid) @@ -1762,21 +2049,35 @@ class MasterView(View): self.request.session.flash("Deleted {} {}".format(len(objects), model_title_plural)) return self.redirect(self.get_index_url()) - def oneoff_import(self, importer, host_object=None): + def fetch_grid_totals(self): + return {'totals_display': "TODO: totals go here"} + + def oneoff_import(self, importer, host_object=None, local_object=None): """ Basic helper method, to do a one-off import (or export, depending on perspective) of the "current instance" object. Where the data "goes" depends on the importer you provide. """ - if not host_object: + if host_object is None and local_object is None: host_object = self.get_instance() - host_data = importer.normalize_host_object(host_object) - if not host_data: - return + if host_object is None: + local_data = importer.normalize_local_object(local_object) + key = importer.get_key(local_data) + host_object = importer.get_single_host_object(key) + if not host_object: + return + host_data = importer.normalize_host_object(host_object) + if not host_data: + return + + else: + host_data = importer.normalize_host_object(host_object) + if not host_data: + return + key = importer.get_key(host_data) + local_object = importer.get_local_object(key) - key = importer.get_key(host_data) - local_object = importer.get_local_object(key) if local_object: if importer.allow_update: local_data = importer.normalize_local_object(local_object) @@ -1803,7 +2104,10 @@ class MasterView(View): # caller must explicitly request websocket behavior; otherwise # we will assume traditional behavior for progress - ws = self.request.is_xhr and self.request.json_body.get('ws') + ws = False + if ((self.request.is_xhr or self.request.content_type == 'application/json') + and self.request.json_body.get('ws')): + ws = True # make our progress tracker progress = self.make_execute_progress(obj, ws=ws) @@ -1825,6 +2129,7 @@ class MasterView(View): return self.render_progress(progress, { 'instance': obj, 'initial_msg': self.execute_progress_initial_msg, + 'can_cancel': self.execute_can_cancel, 'cancel_url': self.get_action_url('view', obj), 'cancel_msg': "{} execution was canceled".format(model_title), }, template=self.execute_progress_template) @@ -1840,16 +2145,18 @@ class MasterView(View): model_key = self.get_model_key(as_tuple=True) if len(model_key) == 1 and model_key[0] == 'uuid': uuid = key[0] - return session.query(self.model_class).get(uuid) + return session.get(self.model_class, uuid) raise NotImplementedError def execute_thread(self, key, user_uuid, progress=None, **kwargs): """ Thread target for executing an object. """ - session = RattailSession() + app = self.get_rattail_app() + model = self.app.model + session = app.make_session() obj = self.get_instance_for_key(key, session) - user = session.query(model.User).get(user_uuid) + user = session.get(model.User, user_uuid) try: success_msg = self.execute_instance(obj, user, progress=progress, @@ -1940,8 +2247,7 @@ class MasterView(View): # strip suffix, interpret data as JSON data = data[:-len(suffix)] - if six.PY3: - data = data.decode('utf_8') + data = data.decode('utf_8') data = json.loads(data) if data.get('everything_complete'): @@ -1998,40 +2304,62 @@ class MasterView(View): return [] + def get_merge_objects(self): + """ + Must return 2 objects, obtained somehow from the request, + which are to be (potentially) merged. + + :returns: 2-tuple of ``(object_to_remove, object_to_keep)``, + or ``None``. + """ + uuids = self.request.POST.get('uuids', '').split(',') + if len(uuids) == 2: + cls = self.get_model_class() + object_to_remove = self.Session.get(cls, uuids[0]) + object_to_keep = self.Session.get(cls, uuids[1]) + if object_to_remove and object_to_keep: + return object_to_remove, object_to_keep + def merge(self): """ Preview and execute a merge of two records. """ object_to_remove = object_to_keep = None if self.request.method == 'POST': - uuids = self.request.POST.get('uuids', '').split(',') - if len(uuids) == 2: - object_to_remove = self.Session.query(self.get_model_class()).get(uuids[0]) - object_to_keep = self.Session.query(self.get_model_class()).get(uuids[1]) - + objects = self.get_merge_objects() + if objects: + object_to_remove, object_to_keep = objects if object_to_remove and object_to_keep and self.request.POST.get('commit-merge') == 'yes': - msg = six.text_type(object_to_remove) + msg = str(object_to_remove) try: self.validate_merge(object_to_remove, object_to_keep) except Exception as error: self.request.session.flash("Requested merge cannot proceed (maybe swap kept/removed and try again?): {}".format(error), 'error') else: - self.merge_objects(object_to_remove, object_to_keep) - self.request.session.flash("{} has been merged into {}".format(msg, object_to_keep)) - return self.redirect(self.get_action_url('view', object_to_keep)) + try: + self.merge_objects(object_to_remove, object_to_keep) + self.request.session.flash("{} has been merged into {}".format(msg, object_to_keep)) + return self.redirect(self.get_action_url('view', object_to_keep)) + except Exception as error: + error = simple_error(error) + self.request.session.flash(f"merge failed: {error}", 'error') if not object_to_remove or not object_to_keep or object_to_remove is object_to_keep: return self.redirect(self.get_index_url()) remove = self.get_merge_data(object_to_remove) keep = self.get_merge_data(object_to_keep) - return self.render_to_response('merge', {'object_to_remove': object_to_remove, - 'object_to_keep': object_to_keep, - 'view_url': lambda obj: self.get_action_url('view', obj), - 'merge_fields': self.get_merge_fields(), - 'remove_data': remove, - 'keep_data': keep, - 'resulting_data': self.get_merge_resulting_data(remove, keep)}) + return self.render_to_response('merge', { + 'object_to_remove': object_to_remove, + 'object_to_keep': object_to_keep, + 'removing_uuid': self.get_uuid_for_grid_row(object_to_remove), + 'keeping_uuid': self.get_uuid_for_grid_row(object_to_keep), + 'view_url': lambda obj: self.get_action_url('view', obj), + 'merge_fields': self.get_merge_fields(), + 'remove_data': remove, + 'keep_data': keep, + 'resulting_data': self.get_merge_resulting_data(remove, keep), + }) def validate_merge(self, removing, keeping): """ @@ -2048,7 +2376,8 @@ class MasterView(View): if self.merge_handler: return self.merge_handler.get_merge_preview_data(obj) - raise NotImplementedError("please implement `{}.get_merge_data()`".format(self.__class__.__name__)) + return dict([(f, getattr(obj, f, None)) + for f in self.get_merge_fields()]) def get_merge_resulting_data(self, remove, keep): result = dict(keep) @@ -2112,14 +2441,34 @@ class MasterView(View): @classmethod def get_model_key(cls, as_tuple=False): """ - Returns the primary key(s) for the model class. Note that this will - return a *string* value unless a tuple is requested. If the model has - a composite key then the string result would be a comma-delimited list - of names, e.g. ``foo_id,bar_id``. + Returns the primary model key(s) for the master view. + + Internally, model keys are a sequence of one or more keys. + Most typically it's just one, so e.g. ``('uuid',)``, but + composite keys are possible too, e.g. ``('parent_id', + 'child_id')``. + + Despite that, this method will return a *string* + representation of the keys, unless ``as_tuple=True`` in which + case it returns a tuple. For example:: + + # for model keys: ('uuid',) + + cls.get_model_key() # => 'uuid' + cls.get_model_key(as_tuple=True) # => ('uuid',) + + # for model keys: ('parent_id', 'child_id') + + cls.get_model_key() # => 'parent_id,child_id' + cls.get_model_key(as_tuple=True) # => ('parent_id', 'child_id') + + :param as_tuple: Whether to return a tuple instead of string. + + :returns: Either a string or tuple of model keys. """ if hasattr(cls, 'model_key'): keys = cls.model_key - if isinstance(keys, six.string_types): + if isinstance(keys, str): keys = [keys] else: keys = get_primary_keys(cls.get_model_class()) @@ -2199,9 +2548,12 @@ class MasterView(View): """ Returns the master view's index URL. """ - route = self.get_route_prefix() - return self.request.route_url(route, **kwargs) + if self.listable: + route = self.get_route_prefix() + return self.request.route_url(route, **kwargs) + # TODO: this should not be class method, if possible + # (pretty sure overriding as instance method works fine) @classmethod def get_index_title(cls): """ @@ -2240,6 +2592,17 @@ class MasterView(View): so if you like you can return a different help URL depending on which type of CRUD view is in effect, etc. """ + # nb. self.Session may differ, so use tailbone.db.Session + session = Session() + model = self.app.model + route_prefix = self.get_route_prefix() + + info = session.query(model.TailbonePageHelp)\ + .filter(model.TailbonePageHelp.route_prefix == route_prefix)\ + .first() + if info and info.help_url: + return info.help_url + if self.help_url: return self.help_url @@ -2248,6 +2611,96 @@ class MasterView(View): return global_help_url(self.rattail_config) + def get_help_markdown(self): + """ + Return the markdown help text for current page, if defined. + """ + # nb. self.Session may differ, so use tailbone.db.Session + session = Session() + model = self.app.model + route_prefix = self.get_route_prefix() + + info = session.query(model.TailbonePageHelp)\ + .filter(model.TailbonePageHelp.route_prefix == route_prefix)\ + .first() + if info and info.markdown_text: + return info.markdown_text + + def can_edit_help(self): + if self.has_perm('edit_help'): + return True + if self.request.has_perm('common.edit_help'): + return True + return False + + def edit_help(self): + if not self.can_edit_help(): + raise self.forbidden() + + # nb. self.Session may differ, so use tailbone.db.Session + session = Session() + model = self.app.model + route_prefix = self.get_route_prefix() + schema = colander.Schema() + + schema.add(colander.SchemaNode(colander.String(), + name='help_url', + missing=None)) + + schema.add(colander.SchemaNode(colander.String(), + name='markdown_text', + missing=None)) + + factory = self.get_form_factory() + form = factory(schema=schema, request=self.request) + if not form.validate(): + return {'error': "Form did not validate"} + + info = session.query(model.TailbonePageHelp)\ + .filter(model.TailbonePageHelp.route_prefix == route_prefix)\ + .first() + if not info: + info = model.TailbonePageHelp(route_prefix=route_prefix) + session.add(info) + + info.help_url = form.validated['help_url'] + info.markdown_text = form.validated['markdown_text'] + return {'ok': True} + + def edit_field_help(self): + if not self.can_edit_help(): + raise self.forbidden() + + # nb. self.Session may differ, so use tailbone.db.Session + session = Session() + model = self.app.model + route_prefix = self.get_route_prefix() + schema = colander.Schema() + + schema.add(colander.SchemaNode(colander.String(), + name='field_name')) + + schema.add(colander.SchemaNode(colander.String(), + name='markdown_text', + missing=None)) + + factory = self.get_form_factory() + form = factory(schema=schema, request=self.request) + if not form.validate(): + return {'error': "Form did not validate"} + + info = session.query(model.TailboneFieldInfo)\ + .filter(model.TailboneFieldInfo.route_prefix == route_prefix)\ + .filter(model.TailboneFieldInfo.field_name == form.validated['field_name'])\ + .first() + if not info: + info = model.TailboneFieldInfo(route_prefix=route_prefix, + field_name=form.validated['field_name']) + session.add(info) + + info.markdown_text = form.validated['markdown_text'] + return {'ok': True} + def render_to_response(self, template, data, **kwargs): """ Return a response with the given template rendered with the given data. @@ -2258,7 +2711,6 @@ class MasterView(View): """ context = { 'master': self, - 'use_buefy': self.get_use_buefy(), 'model_title': self.get_model_title(), 'model_title_plural': self.get_model_title_plural(), 'route_prefix': self.get_route_prefix(), @@ -2269,13 +2721,21 @@ class MasterView(View): 'action_url': self.get_action_url, 'grid_index': self.grid_index, 'help_url': self.get_help_url(), + 'help_markdown': self.get_help_markdown(), + 'can_edit_help': self.can_edit_help(), 'quickie': None, } - key = self.rattail_config.product_key() - context['product_key_field'] = self.product_key_fields.get(key, key) + context['customer_key_field'] = self.get_customer_key_field() + context['customer_key_label'] = self.get_customer_key_label() - if self.expose_quickie_search: + context['member_key_field'] = self.get_member_key_field() + context['member_key_label'] = self.get_member_key_label() + + context['product_key_field'] = self.get_product_key_field() + context['product_key_label'] = self.get_product_key_label() + + if self.should_expose_quickie_search(): context['quickie'] = self.get_quickie_context() if self.grid_index: @@ -2290,8 +2750,15 @@ class MasterView(View): context.update(data) context.update(self.template_kwargs(**context)) - if hasattr(self, 'template_kwargs_{}'.format(template)): - context.update(getattr(self, 'template_kwargs_{}'.format(template))(**context)) + + method_name = f'template_kwargs_{template}' + if hasattr(self, method_name): + context.update(getattr(self, method_name)(**context)) + for supp in self.iter_view_supplements(): + if hasattr(supp, 'template_kwargs'): + context.update(getattr(supp, 'template_kwargs')(**context)) + if hasattr(supp, method_name): + context.update(getattr(supp, method_name)(**context)) # First try the template path most specific to the view. mako_path = '{}/{}.mako'.format(self.get_template_prefix(), template) @@ -2389,15 +2856,28 @@ class MasterView(View): # would therefore share the "current" engine) selected = self.get_current_engine_dbkey() kwargs['expose_db_picker'] = True - kwargs['db_picker_options'] = [tags.Option(k) for k in engines] + kwargs['db_picker_options'] = [tags.Option(k, value=k) for k in engines] kwargs['db_picker_selected'] = selected + # context menu + obj = kwargs.get('instance') + items = self.get_context_menu_items(obj) + for supp in self.iter_view_supplements(): + items.extend(supp.get_context_menu_items(obj) or []) + kwargs['context_menu_list_items'] = items + # add info for downloadable input file templates, if any if self.has_input_file_templates: templates = self.normalize_input_file_templates() kwargs['input_file_templates'] = OrderedDict([(tmpl['key'], tmpl) for tmpl in templates]) + # add info for downloadable output file templates, if any + if self.has_output_file_templates: + templates = self.normalize_output_file_templates() + kwargs['output_file_templates'] = OrderedDict([(tmpl['key'], tmpl) + for tmpl in templates]) + return kwargs def get_input_file_templates(self): @@ -2472,6 +2952,81 @@ class MasterView(View): return templates + def get_output_file_templates(self): + return [] + + def normalize_output_file_templates(self, templates=None, + include_file_options=False): + if templates is None: + templates = self.get_output_file_templates() + + route_prefix = self.get_route_prefix() + + if include_file_options: + templatesdir = os.path.join(self.rattail_config.datadir(), + 'templates', 'output_files', + route_prefix) + + for template in templates: + + if 'config_section' not in template: + if hasattr(self, 'output_file_template_config_section'): + template['config_section'] = self.output_file_template_config_section + else: + template['config_section'] = route_prefix + section = template['config_section'] + + if 'config_prefix' not in template: + template['config_prefix'] = '{}.{}'.format( + self.output_file_template_config_prefix, + template['key']) + prefix = template['config_prefix'] + + for key in ('mode', 'file', 'url'): + + if 'option_{}'.format(key) not in template: + template['option_{}'.format(key)] = '{}.{}'.format(prefix, key) + + if 'setting_{}'.format(key) not in template: + template['setting_{}'.format(key)] = '{}.{}'.format( + section, + template['option_{}'.format(key)]) + + if key not in template: + value = self.rattail_config.get( + section, + template['option_{}'.format(key)]) + if value is not None: + template[key] = value + + template.setdefault('mode', 'default') + template.setdefault('file', None) + template.setdefault('url', template['default_url']) + + if include_file_options: + options = [] + basedir = os.path.join(templatesdir, template['key']) + if os.path.exists(basedir): + for name in sorted(os.listdir(basedir)): + if len(name) == 4 and name.isdigit(): + files = os.listdir(os.path.join(basedir, name)) + if len(files) == 1: + options.append(os.path.join(name, files[0])) + template['file_options'] = options + template['file_options_dir'] = basedir + + if template['mode'] == 'external': + template['effective_url'] = template['url'] + elif template['mode'] == 'hosted': + template['effective_url'] = self.request.route_url( + '{}.download_output_file_template'.format(route_prefix), + _query={'key': template['key'], + 'file': template['file']}) + else: + template['effective_url'] = template['default_url'] + + return templates + def template_kwargs_index(self, **kwargs): """ Method stub, so subclass can always invoke super() for it. @@ -2494,8 +3049,148 @@ class MasterView(View): """ Method stub, so subclass can always invoke super() for it. """ + obj = kwargs['instance'] + kwargs['xref_buttons'] = self.get_xref_buttons(obj) + kwargs['xref_links'] = self.get_xref_links(obj) return kwargs + def get_context_menu_items(self, obj=None): + items = [] + route_prefix = self.get_route_prefix() + + if self.listing: + + if self.results_downloadable_csv and self.has_perm('results_csv'): + url = self.request.route_url(f'{route_prefix}.results_csv') + items.append(tags.link_to("Download results as CSV", url)) + + if self.results_downloadable_xlsx and self.has_perm('results_xlsx'): + url = self.request.route_url(f'{route_prefix}.results_xlsx') + items.append(tags.link_to("Download results as XLSX", url)) + + if self.has_input_file_templates and self.has_perm('create'): + templates = self.normalize_input_file_templates() + for template in templates: + items.append(tags.link_to(f"Download {template['label']} Template", + template['effective_url'])) + + if self.has_output_file_templates and self.has_perm('configure'): + templates = self.normalize_output_file_templates() + for template in templates: + items.append(tags.link_to(f"Download {template['label']} Template", + template['effective_url'])) + + # if self.viewing: + + # # # TODO: either make this configurable, or just lose it. + # # # nobody seems to ever find it useful in practice. + # # url = self.get_action_url('view', instance) + # # items.append(tags.link_to(f"Permalink for this {model_title}", url)) + + return items + + def get_xref_buttons(self, obj): + buttons = [] + for supp in self.iter_view_supplements(): + buttons.extend(supp.get_xref_buttons(obj) or []) + buttons = self.normalize_xref_buttons(buttons) + return buttons + + def normalize_xref_buttons(self, buttons): + normal = [] + for button in buttons: + + # build a button if only given the data + if isinstance(button, dict): + button = self.make_xref_button(**button) + + normal.append(button) + return normal + + def make_button(self, label, + type=None, is_primary=False, + url=None, target=None, is_external=False, + icon_left=None, + **kwargs): + """ + Make and return a HTML ``<b-button>`` literal. + """ + btn_kw = kwargs + btn_kw.setdefault('c', label) + btn_kw.setdefault('icon_pack', 'fas') + + if type: + btn_kw['type'] = type + elif is_primary: + btn_kw['type'] = 'is-primary' + + if icon_left: + btn_kw['icon_left'] = icon_left + elif is_external: + btn_kw['icon_left'] = 'external-link-alt' + elif url: + btn_kw['icon_left'] = 'eye' + + if url: + btn_kw['href'] = url + + if target: + btn_kw['target'] = target + elif is_external: + btn_kw['target'] = '_blank' + + button = HTML.tag('b-button', **btn_kw) + + if url: + # nb. unfortunately HTML.tag() calls its first arg 'tag' and + # so we can't pass a kwarg with that name...so instead we + # patch that into place manually + button = str(button) + button = button.replace('<b-button ', + '<b-button tag="a"') + button = HTML.literal(button) + + return button + + def make_xref_button(self, **kwargs): + """ + Make and return a HTML ``<b-button>`` literal, for display in + the cross-reference helper panel. + + :param url: URL for the link. + :param text: Label for the button. + :param internal: Boolean indicating if the link is internal to + the site. This is false by default, meaning the link is + assumed to be external, which affects the icon and causes + button click to open link in a new tab. + """ + # TODO: this should call make_button() + + # nb. unfortunately HTML.tag() calls its first arg 'tag' and + # so we can't pass a kwarg with that name...so instead we + # patch that into place manually + btn_kw = dict(type='is-primary', + href=kwargs['url'], + icon_pack='fas', + c=kwargs['text']) + if kwargs.get('internal'): + btn_kw['icon_left'] = 'eye' + else: + btn_kw['icon_left'] = 'external-link-alt' + btn_kw['target'] = '_blank' + button = HTML.tag('b-button', **btn_kw) + button = str(button) + button = button.replace('<b-button ', + '<b-button tag="a" ') + button = HTML.literal(button) + return button + + def get_xref_links(self, obj): + links = [] + for supp in self.iter_view_supplements(): + links.extend(supp.get_xref_links(obj) or []) + return links + def template_kwargs_edit(self, **kwargs): """ Method stub, so subclass can always invoke super() for it. @@ -2550,6 +3245,11 @@ class MasterView(View): return key def get_grid_actions(self): + """ """ + warnings.warn("get_grid_actions() method is deprecated; " + "please use get_main_actions() or get_more_actions() instead", + DeprecationWarning, stacklevel=2) + main, more = self.get_main_actions(), self.get_more_actions() if len(more) == 1: main, more = main + more, [] @@ -2591,10 +3291,11 @@ class MasterView(View): return actions def make_grid_action_view(self): - use_buefy = self.get_use_buefy() - url = self.get_view_index_url if self.use_index_links else None - icon = 'eye' if use_buefy else 'zoomin' - return self.make_action('view', icon=icon, url=url) + return self.make_action('view', icon='eye', url=self.default_view_url()) + + def default_view_url(self): + if self.use_index_links: + return self.get_view_index_url def get_view_index_url(self, row, i): route = '{}.view_index'.format(self.get_route_prefix()) @@ -2617,25 +3318,35 @@ class MasterView(View): return actions def make_grid_action_edit(self): - use_buefy = self.get_use_buefy() - icon = 'edit' if use_buefy else 'pencil' - return self.make_action('edit', icon=icon, url=self.default_edit_url) + return self.make_action('edit', icon='edit', url=self.default_edit_url) def make_grid_action_clone(self): return self.make_action('clone', icon='object-ungroup', url=self.default_clone_url) def make_grid_action_delete(self): - use_buefy = self.get_use_buefy() - kwargs = {} - if use_buefy and self.delete_confirm == 'simple': + kwargs = {'link_class': 'has-text-danger'} + if self.delete_confirm == 'simple': kwargs['click_handler'] = 'deleteObject' return self.make_action('delete', icon='trash', url=self.default_delete_url, **kwargs) - def default_edit_url(self, row, i=None): - if self.editable_instance(row): + def default_edit_url(self, obj, i=None): + """ + Return the default "edit" URL for the given object, if + applicable. This first checks :meth:`editable_instance()` for + the object, and will only return a URL if the object is deemed + editable. + + :param obj: A top-level record/object, of the type normally + handled by this master view. + + :param i: Optional row index within a grid. + + :returns: The "edit object" URL as string, or ``None``. + """ + if self.editable_instance(obj): return self.request.route_url('{}.edit'.format(self.get_route_prefix()), - **self.get_action_route_kwargs(row)) + **self.get_action_route_kwargs(obj)) def default_clone_url(self, row, i=None): return self.request.route_url('{}.clone'.format(self.get_route_prefix()), @@ -2648,33 +3359,74 @@ class MasterView(View): def make_action(self, key, url=None, factory=None, **kwargs): """ - Make a new :class:`GridAction` instance for the current grid. + Make and return a new :class:`~tailbone.grids.core.GridAction` + instance. + + This can be called to make actions for any grid, not just the + one from :meth:`index()`. """ if url is None: route = '{}.{}'.format(self.get_route_prefix(), key) url = lambda r, i: self.request.route_url(route, **self.get_action_route_kwargs(r)) if not factory: factory = grids.GridAction - return factory(key, url=url, **kwargs) + return factory(self.request, key, url=url, **kwargs) - def get_action_route_kwargs(self, row): + def get_action_route_kwargs(self, obj): """ - Hopefully generic kwarg generator for basic action routes. + Get a dict of route kwargs for the given object. + + This is called from various other "convenience" URL + generators, e.g. :meth:`default_edit_url()`. + + It inspects the given object, as well as the "model key" (as + returned by :meth:`get_model_key()`), and returns a dict of + appropriate route kwargs for the object. + + Most typically, the model key is just ``uuid`` and so this + would effectively return ``{'uuid': obj.uuid}``. + + But composite model keys are supported too, so if the model + key is ``(parent_id, child_id)`` this might instead return + ``{'parent_id': obj.parent_id, 'child_id': obj.child_id}``. + + Such kwargs would then be fed into ``route_url()`` as needed, + for example to get a "view product URL":: + + kw = self.get_action_route_kwargs(product) + url = self.request.route_url('products.view', **kw) + + :param obj: A top-level record/object, of the type normally + handled by this master view. + + :returns: A dict of route kwargs for the object. """ + keys = self.get_model_key(as_tuple=True) + if keys: + try: + return dict([(key, obj[key]) + for key in keys]) + except TypeError: + return dict([(key, getattr(obj, key)) + for key in keys]) + + # TODO: sanity check, is the above all we need..? + log.warning("yes we still do the code below sometimes") + try: - mapper = orm.object_mapper(row) + mapper = orm.object_mapper(obj) except orm.exc.UnmappedInstanceError: try: - if isinstance(self.model_key, six.string_types): - return {self.model_key: row[self.model_key]} - return dict([(key, row[key]) + if isinstance(self.model_key, str): + return {self.model_key: obj[self.model_key]} + return dict([(key, obj[key]) for key in self.model_key]) except TypeError: - return {self.model_key: getattr(row, self.model_key)} + return {self.model_key: getattr(obj, self.model_key)} else: - pkeys = get_primary_keys(row) + pkeys = get_primary_keys(obj) keys = list(pkeys) - values = [getattr(row, k) for k in keys] + values = [getattr(obj, k) for k in keys] return dict(zip(keys, values)) def get_data(self, session=None): @@ -2707,11 +3459,15 @@ class MasterView(View): if not self.has_perm('view_global'): query = query.filter(model_class.local_only == True) + for supp in self.iter_view_supplements(): + query = supp.get_grid_query(query) + return query def get_effective_query(self, session=None, **kwargs): return self.get_effective_data(session=session, **kwargs) + # TODO: should rename to checkable? def checkbox(self, instance): """ Returns a boolean indicating whether ot not a checkbox should be @@ -2878,12 +3634,8 @@ class MasterView(View): """ if fmt == 'csv': - if six.PY2: - csv_file = open(path, 'wb') - writer = UnicodeDictWriter(csv_file, fields, encoding='utf_8') - else: # PY3 - csv_file = open(path, 'wt', encoding='utf_8') - writer = csv.DictWriter(csv_file, fields) + csv_file = open(path, 'wt', encoding='utf_8') + writer = csv.DictWriter(csv_file, fields) writer.writeheader() def write(obj, i): @@ -2947,14 +3699,14 @@ class MasterView(View): Normalize the given object into a data dict, for use when writing to the results file for download. """ + app = self.get_rattail_app() data = {} for field in fields: value = getattr(obj, field, None) # make timestamps zone-aware if isinstance(value, datetime.datetime): - value = localtime(self.rattail_config, value, - from_utc=not self.has_local_times) + value = app.localtime(value, from_utc=not self.has_local_times) data[field] = value @@ -2973,7 +3725,7 @@ class MasterView(View): if value is None: value = '' else: - value = six.text_type(value) + value = str(value) csvrow[field] = value @@ -2984,13 +3736,14 @@ class MasterView(View): Coerce the given data dict record, to a "row" dict suitable for use when writing directly to XLSX file. """ + app = self.get_rattail_app() data = dict(data) for key in data: value = data[key] # make timestamps local, "zone-naive" if isinstance(value, datetime.datetime): - value = localtime(self.rattail_config, value, tzinfo=False) + value = app.localtime(value, tzinfo=False) data[key] = value @@ -3041,13 +3794,8 @@ class MasterView(View): results = results.with_session(session).all() fields = self.get_csv_fields() - if six.PY2: - csv_file = open(path, 'wb') - writer = UnicodeDictWriter(csv_file, fields, encoding='utf_8') - else: # PY3 - csv_file = open(path, 'wt', encoding='utf_8') - writer = csv.DictWriter(csv_file, fields) - + csv_file = open(path, 'wt', encoding='utf_8') + writer = csv.DictWriter(csv_file, fields) writer.writeheader() def write(obj, i): @@ -3407,12 +4155,8 @@ class MasterView(View): if fmt == 'csv': - if six.PY2: - csv_file = open(path, 'wb') - writer = UnicodeDictWriter(csv_file, fields, encoding='utf_8') - else: # PY3 - csv_file = open(path, 'wt', encoding='utf_8') - writer = csv.DictWriter(csv_file, fields) + csv_file = open(path, 'wt', encoding='utf_8') + writer = csv.DictWriter(csv_file, fields) writer.writeheader() def write(obj, i): @@ -3455,14 +4199,14 @@ class MasterView(View): Normalize the given row object into a data dict, for use when writing to the results file for download. """ + app = self.get_rattail_app() data = {} for field in fields: value = getattr(row, field, None) # make timestamps zone-aware if isinstance(value, datetime.datetime): - value = localtime(self.rattail_config, value, - from_utc=not self.has_local_times) + value = app.localtime(value, from_utc=not self.has_local_times) data[field] = value @@ -3481,7 +4225,7 @@ class MasterView(View): if value is None: value = '' else: - value = six.text_type(value) + value = str(value) csvrow[field] = value @@ -3492,6 +4236,7 @@ class MasterView(View): Coerce the given data dict record, to a "row" dict suitable for use when writing directly to XLSX file. """ + app = self.get_rattail_app() data = dict(data) for key in data: value = data[key] @@ -3502,7 +4247,7 @@ class MasterView(View): # make timestamps local, "zone-naive" elif isinstance(value, datetime.datetime): - value = localtime(self.rattail_config, value, tzinfo=False) + value = app.localtime(value, tzinfo=False) data[key] = value @@ -3512,10 +4257,11 @@ class MasterView(View): """ Download current *row* results as XLSX. """ + app = self.get_rattail_app() obj = self.get_instance() results = self.get_effective_row_data(sort=True) fields = self.get_row_xlsx_fields() - path = temp_path(suffix='.xlsx') + path = app.make_temp_file(suffix='.xlsx') writer = ExcelWriter(path, fields, sheet_title=self.get_row_model_title_plural()) writer.write_header() @@ -3553,21 +4299,22 @@ class MasterView(View): """ Return a dict for use when writing the row's data to XLSX download. """ + app = self.get_rattail_app() xlrow = {} for field in fields: value = getattr(row, field, None) if isinstance(value, GPC): - value = six.text_type(value) + value = str(value) elif isinstance(value, datetime.datetime): # datetime values we provide to Excel must *not* have time zone info, # but we should make sure they're in "local" time zone effectively. # note however, this assumes a "naive" time value is in UTC zone! if value.tzinfo: - value = localtime(self.rattail_config, value, tzinfo=False) + value = app.localtime(value, tzinfo=False) else: - value = localtime(self.rattail_config, value, from_utc=True, tzinfo=False) + value = app.localtime(value, from_utc=True, tzinfo=False) xlrow[field] = value return xlrow @@ -3581,21 +4328,16 @@ class MasterView(View): """ obj = self.get_instance() fields = self.get_row_csv_fields() - data = six.StringIO() + data = io.StringIO() writer = UnicodeDictWriter(data, fields) writer.writeheader() for row in self.get_effective_row_data(sort=True): writer.writerow(self.get_row_csv_row(row, fields)) response = self.request.response filename = self.get_row_results_csv_filename(obj) - if six.PY3: - response.text = data.getvalue() - response.content_type = 'text/csv' - response.content_disposition = 'attachment; filename={}'.format(filename) - else: - response.body = data.getvalue() - response.content_type = b'text/csv' - response.content_disposition = b'attachment; filename={}'.format(filename) + response.text = data.getvalue() + response.content_type = 'text/csv' + response.content_disposition = 'attachment; filename={}'.format(filename) data.close() response.content_length = len(response.body) return response @@ -3619,37 +4361,45 @@ class MasterView(View): """ Return the list of row fields to be written to CSV download. """ - fields = [] - mapper = orm.class_mapper(self.model_row_class) - for prop in mapper.iterate_properties: - if isinstance(prop, orm.ColumnProperty): - fields.append(prop.key) + try: + mapper = orm.class_mapper(self.model_row_class) + except: + fields = self.get_row_form_fields() + if not fields: + fields = self.get_row_grid_columns() + else: + fields = [] + for prop in mapper.iterate_properties: + if isinstance(prop, orm.ColumnProperty): + fields.append(prop.key) return fields def get_csv_row(self, obj, fields): """ Return a dict for use when writing the row's data to CSV download. """ + app = self.get_rattail_app() csvrow = {} for field in fields: value = getattr(obj, field, None) if isinstance(value, datetime.datetime): # TODO: this assumes value is *always* naive UTC - value = localtime(self.rattail_config, value, from_utc=True) - csvrow[field] = '' if value is None else six.text_type(value) + value = app.localtime(value, from_utc=True) + csvrow[field] = '' if value is None else str(value) return csvrow def get_row_csv_row(self, row, fields): """ Return a dict for use when writing the row's data to CSV download. """ + app = self.get_rattail_app() csvrow = {} for field in fields: value = getattr(row, field, None) if isinstance(value, datetime.datetime): # TODO: this assumes value is *always* naive UTC - value = localtime(self.rattail_config, value, from_utc=True) - csvrow[field] = '' if value is None else six.text_type(value) + value = app.localtime(value, from_utc=True) + csvrow[field] = '' if value is None else str(value) return csvrow ############################## @@ -3704,7 +4454,7 @@ class MasterView(View): """ Return a "pretty" title for the instance, to be used in the page title etc. """ - return six.text_type(instance) + return str(instance) @classmethod def get_form_factory(cls): @@ -3810,32 +4560,56 @@ class MasterView(View): Return a dictionary of kwargs to be passed to the factory when creating new form instances. """ + route_prefix = self.get_route_prefix() defaults = { 'request': self.request, 'readonly': self.viewing, 'model_class': getattr(self, 'model_class', None), - 'action_url': self.request.current_route_url(_query=None), - 'use_buefy': self.get_use_buefy(), + 'action_url': self.request.path_url, 'assume_local_times': self.has_local_times, + 'route_prefix': route_prefix, + 'can_edit_help': self.can_edit_help(), } + + if defaults['can_edit_help']: + defaults['edit_help_url'] = self.request.route_url( + '{}.edit_field_help'.format(route_prefix)) + if self.creating: kwargs.setdefault('cancel_url', self.get_index_url()) else: instance = kwargs['model_instance'] kwargs.setdefault('cancel_url', self.get_action_url('view', instance)) + defaults.update(kwargs) return defaults + def iter_view_supplements(self): + """ + Iterate over all registered supplements for this master view. + """ + supplements = self.request.registry.settings.get('tailbone_view_supplements', []) + route_prefix = self.get_route_prefix() + if supplements and route_prefix in supplements: + for cls in supplements[route_prefix]: + supp = cls(self) + yield supp + def configure_form(self, form): """ Configure the main "desktop" form for the view's data model. """ self.configure_common_form(form) + self.configure_field_customer_key(form) + self.configure_field_member_key(form) self.configure_field_product_key(form) + for supp in self.iter_view_supplements(): + supp.configure_form(form) + def validate_form(self, form): - if form.validate(newstyle=True): + if form.validate(): self.form_deserialized = form.validated return True return False @@ -3861,6 +4635,9 @@ class MasterView(View): if not self.has_perm('view_global'): obj.local_only = True + for supp in self.iter_view_supplements(): + obj = supp.objectify(obj, form, data) + return obj def objectify_contact(self, contact, data): @@ -3928,11 +4705,16 @@ class MasterView(View): Event hook, called just after a new instance is saved. """ - def editable_instance(self, instance): + def editable_instance(self, obj): """ - Returns boolean indicating whether or not the given instance can be - considered "editable". Returns ``True`` by default; override as - necessary. + Returns boolean indicating whether or not the given object + should be considered "editable". Returns ``True`` by default; + override as necessary. + + :param obj: A top-level record/object, of the type normally + handled by this master view. + + :returns: ``True`` if object is editable, else ``False``. """ return True @@ -4217,7 +4999,7 @@ class MasterView(View): # TODO: is this right..? # key = self.request.matchdict[self.get_model_key()] key = self.request.matchdict['row_uuid'] - instance = self.Session.query(self.model_row_class).get(key) + instance = self.Session.get(self.model_row_class, key) if not instance: raise self.notfound() return instance @@ -4261,7 +5043,6 @@ class MasterView(View): 'readonly': self.viewing, 'model_class': getattr(self, 'model_row_class', None), 'action_url': self.request.current_route_url(_query=None), - 'use_buefy': self.get_use_buefy(), } if self.creating: kwargs.setdefault('cancel_url', self.request.get_referrer()) @@ -4290,31 +5071,87 @@ class MasterView(View): self.set_row_labels(form) + self.configure_field_customer_key(form) + self.configure_field_member_key(form) self.configure_field_product_key(form) def validate_row_form(self, form): - if form.validate(newstyle=True): + if form.validate(): self.form_deserialized = form.validated return True return False + def get_customer_key_field(self): + app = self.get_rattail_app() + key = app.get_customer_key_field() + return self.customer_key_fields.get(key, key) + + def get_customer_key_label(self): + app = self.get_rattail_app() + field = self.get_customer_key_field() + return app.get_customer_key_label(field=field) + + def configure_column_customer_key(self, g): + if '_customer_key_' in g.columns: + field = self.get_customer_key_field() + g.replace('_customer_key_', field) + g.set_label(field, self.get_customer_key_label()) + g.set_link(field) + + def configure_field_customer_key(self, f): + if '_customer_key_' in f: + field = self.get_customer_key_field() + f.replace('_customer_key_', field) + f.set_label(field, self.get_customer_key_label()) + + def get_member_key_field(self): + app = self.get_rattail_app() + key = app.get_member_key_field() + return self.member_key_fields.get(key, key) + + def get_member_key_label(self): + app = self.get_rattail_app() + field = self.get_member_key_field() + return app.get_member_key_label(field=field) + + def configure_column_member_key(self, g): + if '_member_key_' in g.columns: + field = self.get_member_key_field() + g.replace('_member_key_', field) + g.set_label(field, self.get_member_key_label()) + g.set_link(field) + + def configure_field_member_key(self, f): + if '_member_key_' in f: + field = self.get_member_key_field() + f.replace('_member_key_', field) + f.set_label(field, self.get_member_key_label()) + + def get_product_key_field(self): + app = self.get_rattail_app() + key = app.get_product_key_field() + return self.product_key_fields.get(key, key) + + def get_product_key_label(self): + app = self.get_rattail_app() + field = self.get_product_key_field() + return app.get_product_key_label(field=field) + def configure_column_product_key(self, g): if '_product_key_' in g.columns: - key = self.rattail_config.product_key() - field = self.product_key_fields.get(key, key) + field = self.get_product_key_field() g.replace('_product_key_', field) - g.set_label(field, self.rattail_config.product_key_title(key)) + g.set_label(field, self.get_product_key_label()) g.set_link(field) - if key == 'upc': + if field == 'upc': g.set_renderer(field, self.render_upc) def configure_field_product_key(self, f): if '_product_key_' in f: - key = self.rattail_config.product_key() - field = self.product_key_fields.get(key, key) + field = self.get_product_key_field() f.replace('_product_key_', field) - f.set_label(field, self.rattail_config.product_key_title(key)) - if key == 'upc': + f.set_label(field, self.get_product_key_label()) + if field == 'upc': f.set_renderer(field, self.render_upc) def get_row_action_url(self, action, row, **kwargs): @@ -4338,6 +5175,56 @@ class MasterView(View): def make_diff(self, old_data, new_data, **kwargs): return diffs.Diff(old_data, new_data, **kwargs) + def get_version_diff_factory(self, **kwargs): + """ + Must return the factory to be used when creating version diff + objects. + + By default this returns the + :class:`tailbone.diffs.VersionDiff` class, unless + :attr:`version_diff_factory` is set, in which case that is + returned as-is. + + :returns: A factory which can produce + :class:`~tailbone.diffs.VersionDiff` objects. + """ + if hasattr(self, 'version_diff_factory'): + return self.version_diff_factory + return diffs.VersionDiff + + def get_version_diff_enums(self, version): + """ + This can optionally return a dict of field enums, to be passed + to the version diff factory. This method is called as part of + :meth:`make_version_diff()`. + """ + + def make_version_diff(self, version, *args, **kwargs): + """ + Make a version diff object, using the factory returned by + :meth:`get_version_diff_factory()`. + + :param version: Reference to a Continuum version object. + + :param title: If specified, must be as a kwarg. Optional + override for the version title text. If not specified, + :meth:`title_for_version()` is called for the title. + + :param \*args: Additional args to pass to the factory. + + :param \*\*kwargs: Additional kwargs to pass to the factory. + + :returns: A :class:`~tailbone.diffs.VersionDiff` object. + """ + if 'title' not in kwargs: + kwargs['title'] = self.title_for_version(version) + + if 'enums' not in kwargs: + kwargs['enums'] = self.get_version_diff_enums(version) + + factory = self.get_version_diff_factory() + return factory(version, *args, **kwargs) + ############################## # Configuration Views ############################## @@ -4346,6 +5233,8 @@ class MasterView(View): """ Generic view for configuring some aspect of the software. """ + self.configuring = True + app = self.get_rattail_app() if self.request.method == 'POST': if self.request.POST.get('remove_settings'): self.configure_remove_settings() @@ -4358,9 +5247,9 @@ class MasterView(View): # collect any uploaded files uploads = {} - for key, value in six.iteritems(data): + for key, value in data.items(): if isinstance(value, cgi_FieldStorage): - tempdir = tempfile.mkdtemp() + tempdir = app.make_temp_dir() filename = os.path.basename(value.filename) filepath = os.path.join(tempdir, filename) with open(filepath, 'wb') as f: @@ -4426,6 +5315,39 @@ class MasterView(View): data[template['setting_file']] = os.path.join(numdir, info['filename']) + if self.has_output_file_templates: + templatesdir = os.path.join(self.rattail_config.datadir(), + 'templates', 'output_files', + self.get_route_prefix()) + + def get_next_filedir(basedir): + nextid = 1 + while True: + path = os.path.join(basedir, '{:04d}'.format(nextid)) + if not os.path.exists(path): + # this should fail if there happens to be a race + # condition and someone else got to this id first + os.mkdir(path) + return path + nextid += 1 + + for template in self.normalize_output_file_templates(): + key = '{}.upload'.format(template['setting_file']) + if key in uploads: + assert self.request.POST[template['setting_mode']] == 'hosted' + assert not self.request.POST[template['setting_file']] + info = uploads[key] + basedir = os.path.join(templatesdir, template['key']) + if not os.path.exists(basedir): + os.makedirs(basedir) + filedir = get_next_filedir(basedir) + filepath = os.path.join(filedir, info['filename']) + shutil.copyfile(info['filepath'], filepath) + shutil.rmtree(info['filedir']) + numdir = os.path.basename(filedir) + data[template['setting_file']] = os.path.join(numdir, + info['filename']) + def configure_get_simple_settings(self): """ If you have some "simple" settings, each of which basically @@ -4470,7 +5392,8 @@ class MasterView(View): simple['option']) def configure_get_context(self, simple_settings=None, - input_file_templates=True): + input_file_templates=True, + output_file_templates=True): """ Returns the full context dict, for rendering the configure page template. @@ -4502,7 +5425,7 @@ class MasterView(View): elif simple.get('type') is bool: value = config.getbool(simple['section'], simple['option'], - default=False) + default=simple.get('default', False)) else: value = config.get(simple['section'], simple['option']) @@ -4519,7 +5442,7 @@ class MasterView(View): for template in self.normalize_input_file_templates( include_file_options=True): settings[template['setting_mode']] = template['mode'] - settings[template['setting_file']] = template['file'] + settings[template['setting_file']] = template['file'] or '' settings[template['setting_url']] = template['url'] file_options[template['key']] = template['file_options'] file_option_dirs[template['key']] = template['file_options_dir'] @@ -4527,10 +5450,27 @@ class MasterView(View): context['input_file_options'] = file_options context['input_file_option_dirs'] = file_option_dirs + # add settings for output file templates, if any + if output_file_templates and self.has_output_file_templates: + settings = {} + file_options = {} + file_option_dirs = {} + for template in self.normalize_output_file_templates( + include_file_options=True): + settings[template['setting_mode']] = template['mode'] + settings[template['setting_file']] = template['file'] or '' + settings[template['setting_url']] = template['url'] + file_options[template['key']] = template['file_options'] + file_option_dirs[template['key']] = template['file_options_dir'] + context['output_file_template_settings'] = settings + context['output_file_options'] = file_options + context['output_file_option_dirs'] = file_option_dirs + return context def configure_gather_settings(self, data, simple_settings=None, - input_file_templates=True): + input_file_templates=True, + output_file_templates=True): settings = [] # maybe collect "simple" settings @@ -4543,11 +5483,13 @@ class MasterView(View): value = data.get(name) if simple.get('type') is bool: - value = six.text_type(bool(value)).lower() + value = str(bool(value)).lower() elif simple.get('type') is int: - value = six.text_type(int(value or '0')) + value = str(int(value or '0')) + elif value is None: + value = '' else: - value = six.text_type(value) + value = str(value) # only want to save this setting if we received a # value, or if empty values are okay to save @@ -4574,12 +5516,32 @@ class MasterView(View): settings.append({'name': template['setting_url'], 'value': data.get(template['setting_url'])}) + # maybe also collect output file template settings + if output_file_templates and self.has_output_file_templates: + for template in self.normalize_output_file_templates(): + + # mode + settings.append({'name': template['setting_mode'], + 'value': data.get(template['setting_mode'])}) + + # file + value = data.get(template['setting_file']) + if value: + # nb. avoid saving if empty, so can remain "null" + settings.append({'name': template['setting_file'], + 'value': value}) + + # url + settings.append({'name': template['setting_url'], + 'value': data.get(template['setting_url'])}) + return settings def configure_remove_settings(self, simple_settings=None, - input_file_templates=True): + input_file_templates=True, + output_file_templates=True): app = self.get_rattail_app() - model = self.model + model = self.app.model names = [] if simple_settings is None: @@ -4596,6 +5558,14 @@ class MasterView(View): template['setting_url'], ]) + if output_file_templates and self.has_output_file_templates: + for template in self.normalize_output_file_templates(): + names.extend([ + template['setting_mode'], + template['setting_file'], + template['setting_url'], + ]) + if names: # nb. using thread-local session here; we do not use # self.Session b/c it may not point to Rattail @@ -4674,8 +5644,27 @@ class MasterView(View): 'prevent_cache_for_index_views', default=True) + # edit help info + cls._defaults_edit_help(config) + # list/search if cls.listable: + + # master views which represent a typical model class, and + # allow for an index view, are registered specially so the + # admin may browse the full list of such views + modclass = cls.get_model_class(error=False) + if modclass: + config.add_tailbone_model_view(modclass.__name__, + model_title_plural, + route_prefix, + permission_prefix) + + # but regardless we register the index view, for similar reasons + config.add_tailbone_index_page(route_prefix, model_title_plural, + '{}.list'.format(permission_prefix)) + + # index view config.add_tailbone_permission(permission_prefix, '{}.list'.format(permission_prefix), "List / search {}".format(model_title_plural)) config.add_route(route_prefix, '{}/'.format(url_prefix)) @@ -4683,8 +5672,6 @@ class MasterView(View): config.add_view(cls, attr='index', route_name=route_prefix, permission='{}.list'.format(permission_prefix), **kwargs) - config.add_tailbone_index_page(route_prefix, model_title_plural, - '{}.list'.format(permission_prefix)) # download results # this is the "new" more flexible approach, but we only want to @@ -4729,6 +5716,15 @@ class MasterView(View): config.add_view(cls, attr='download_results_rows', route_name='{}.download_results_rows'.format(route_prefix), permission='{}.download_results_rows'.format(permission_prefix)) + # fetch total hours + if cls.supports_grid_totals: + config.add_route(f'{route_prefix}.fetch_grid_totals', + f'{url_prefix}/fetch-grid-totals') + config.add_view(cls, attr='fetch_grid_totals', + route_name=f'{route_prefix}.fetch_grid_totals', + permission=f'{permission_prefix}.list', + renderer='json') + # configure if cls.configurable: config.add_tailbone_permission(permission_prefix, @@ -4832,39 +5828,18 @@ class MasterView(View): route_name='{}.download_input_file_template'.format(route_prefix), permission='{}.create'.format(permission_prefix)) + # download output file template + if cls.has_output_file_templates and cls.configurable: + config.add_route(f'{route_prefix}.download_output_file_template', + f'{url_prefix}/download-output-file-template') + config.add_view(cls, attr='download_output_file_template', + route_name=f'{route_prefix}.download_output_file_template', + # TODO: this is different from input file, should change? + permission=f'{permission_prefix}.configure') + # view if cls.viewable: - config.add_tailbone_permission(permission_prefix, '{}.view'.format(permission_prefix), - "View details for {}".format(model_title)) - if cls.has_pk_fields: - config.add_tailbone_permission(permission_prefix, '{}.view_pk_fields'.format(permission_prefix), - "View all PK-type fields for {}".format(model_title_plural)) - if cls.secure_global_objects: - config.add_tailbone_permission(permission_prefix, '{}.view_global'.format(permission_prefix), - "View *global* {}".format(model_title_plural)) - - # view by grid index - config.add_route('{}.view_index'.format(route_prefix), '{}/view'.format(url_prefix)) - config.add_view(cls, attr='view_index', route_name='{}.view_index'.format(route_prefix), - permission='{}.view'.format(permission_prefix)) - - # view by record key - config.add_route('{}.view'.format(route_prefix), instance_url_prefix) - kwargs = {'http_cache': 0} if prevent_cache and cls.has_rows else {} - config.add_view(cls, attr='view', route_name='{}.view'.format(route_prefix), - permission='{}.view'.format(permission_prefix), - **kwargs) - - # version history - if cls.has_versions and rattail_config and rattail_config.versioning_enabled(): - config.add_tailbone_permission(permission_prefix, '{}.versions'.format(permission_prefix), - "View version history for {}".format(model_title)) - config.add_route('{}.versions'.format(route_prefix), '{}/versions/'.format(instance_url_prefix)) - config.add_view(cls, attr='versions', route_name='{}.versions'.format(route_prefix), - permission='{}.versions'.format(permission_prefix)) - config.add_route('{}.version'.format(route_prefix), '{}/versions/{{txnid}}'.format(instance_url_prefix)) - config.add_view(cls, attr='view_version', route_name='{}.version'.format(route_prefix), - permission='{}.versions'.format(permission_prefix)) + cls._defaults_view(config) # image if cls.has_image: @@ -5009,3 +5984,228 @@ class MasterView(View): '{}/rows/{{row_uuid}}/delete'.format(instance_url_prefix)) config.add_view(cls, attr='delete_row', route_name='{}.delete_row'.format(route_prefix), permission='{}.delete_row'.format(permission_prefix)) + + @classmethod + def _defaults_view(cls, config, **kwargs): + """ + Provide default "view" configuration, i.e. for "viewable" things. + """ + rattail_config = config.registry.settings.get('rattail_config') + route_prefix = cls.get_route_prefix() + url_prefix = cls.get_url_prefix() + instance_url_prefix = cls.get_instance_url_prefix() + model_title = cls.get_model_title() + model_title_plural = cls.get_model_title_plural() + + # on windows/chrome we are seeing some caching when e.g. user + # applies some filters, then views a record, then clicks back + # button, filters no longer are applied. so by default we + # instruct browser to never cache certain pages which contain + # a grid. at this point only /index and /view + # cf. https://docs.pylonsproject.org/projects/pyramid/en/latest/narr/viewconfig.html#non-predicate-arguments + prevent_cache = rattail_config.getbool('tailbone', + 'prevent_cache_for_index_views', + default=True) + + # nb. if caller specifies permission prefix, it's assumed they + # have registered it elsewhere + if 'permission_prefix' in kwargs: + permission_prefix = kwargs['permission_prefix'] + else: + permission_prefix = cls.get_permission_prefix() + config.add_tailbone_permission(permission_prefix, + '{}.view'.format(permission_prefix), + "View details for {}".format(model_title)) + + if cls.has_pk_fields: + config.add_tailbone_permission(permission_prefix, + '{}.view_pk_fields'.format(permission_prefix), + "View all PK-type fields for {}".format(model_title_plural)) + if cls.secure_global_objects: + config.add_tailbone_permission(permission_prefix, + '{}.view_global'.format(permission_prefix), + "View *global* {}".format(model_title_plural)) + + # view by grid index + config.add_route('{}.view_index'.format(route_prefix), + '{}/view'.format(url_prefix)) + config.add_view(cls, attr='view_index', + route_name='{}.view_index'.format(route_prefix), + permission='{}.view'.format(permission_prefix)) + + # view by record key + config.add_route('{}.view'.format(route_prefix), + instance_url_prefix) + kwargs = {'http_cache': 0} if prevent_cache and cls.has_rows else {} + config.add_view(cls, attr='view', route_name='{}.view'.format(route_prefix), + permission='{}.view'.format(permission_prefix), + **kwargs) + + # version history + if cls.has_versions and rattail_config and rattail_config.versioning_enabled(): + config.add_tailbone_permission(permission_prefix, + '{}.versions'.format(permission_prefix), + "View version history for {}".format(model_title)) + config.add_route('{}.versions'.format(route_prefix), + '{}/versions/'.format(instance_url_prefix)) + config.add_view(cls, attr='versions', + route_name='{}.versions'.format(route_prefix), + permission='{}.versions'.format(permission_prefix)) + config.add_route('{}.version'.format(route_prefix), + '{}/versions/{{txnid}}'.format(instance_url_prefix)) + config.add_view(cls, attr='view_version', + route_name='{}.version'.format(route_prefix), + permission='{}.versions'.format(permission_prefix)) + + # revisions data (AJAX) + config.add_route(f'{route_prefix}.revisions_data', + f'{instance_url_prefix}/revisions-data', + request_method='GET') + config.add_view(cls, attr='revisions_data', + route_name=f'{route_prefix}.revisions_data', + permission=f'{permission_prefix}.versions', + renderer='json') + + + @classmethod + def _defaults_edit_help(cls, config, **kwargs): + route_prefix = cls.get_route_prefix() + url_prefix = cls.get_url_prefix() + model_title_plural = cls.get_model_title_plural() + + # nb. if caller specifies permission prefix, it's assumed they + # have registered it elsewhere + if 'permission_prefix' in kwargs: + permission_prefix = kwargs['permission_prefix'] + else: + permission_prefix = cls.get_permission_prefix() + config.add_tailbone_permission(permission_prefix, + '{}.edit_help'.format(permission_prefix), + "Edit help info for {}".format(model_title_plural)) + + # edit page help + config.add_route('{}.edit_help'.format(route_prefix), + '{}/edit-help'.format(url_prefix), + request_method='POST') + config.add_view(cls, attr='edit_help', + route_name='{}.edit_help'.format(route_prefix), + renderer='json') + + # edit field help + config.add_route('{}.edit_field_help'.format(route_prefix), + '{}/edit-field-help'.format(url_prefix), + request_method='POST') + config.add_view(cls, attr='edit_field_help', + route_name='{}.edit_field_help'.format(route_prefix), + renderer='json') + + +class ViewSupplement: + """ + Base class for view "supplements" - which are sort of like plugins + which can "supplement" certain aspects of the view. + + Instead of subclassing a master view and "supplementing" it via + method overrides etc., packages can instead define one or more + ``ViewSupplement`` classes. All such supplements are registered + so they can be located; their logic is then merged into the + appropriate master view at runtime. + + The primary use case for this is within integration packages, such + as tailbone-corepos and the like. A truly custom app might want + supplemental logic from multiple integration packages, in which + case the "subclassing" approach sort of falls apart. + + :attribute:: labels + + This can be a dict of extra field labels to be used by the + master view. Same meaning as for + :attr:`tailbone.views.master.MasterView.labels`. + """ + labels = {} + + def __init__(self, master): + self.master = master + self.request = master.request + self.app = master.app + self.model = master.model + self.rattail_config = master.rattail_config + self.Session = master.Session + + def get_rattail_app(self): + return self.master.get_rattail_app() + + def get_grid_query(self, query): + """ + Return the "base" query for the grid. This is invoked from + within :meth:`tailbone.views.master.MasterView.query()`. + + A typical grid query is + essentially: + + .. code-block:: sql + + SELECT * FROM mytable + + But when a schema extension is in "primary" use, meaning for + instance one of the main grid columns displays extension data, + it may be helpful for the base query to join the extension + table, as opposed to doing a "just in time" join based on + sorting and/or filters: + + .. code-block:: sql + + SELECT * FROM mytable m + LEFT OUTER JOIN myextension e ON e.uuid = m.uuid + + This is accomplished by subjecting the current base query to a + join, e.g. something like:: + + model = self.app.model + query = query.outerjoin(model.MyExtension) + return query + """ + return query + + def configure_grid(self, g): + """ + Configure the grid as needed, e.g. add columns, and set + renderers etc. for them. + """ + + def configure_form(self, f): + """ + Configure the form as needed, e.g. add fields, and set + renderers, default values etc. for them. + """ + + def objectify(self, obj, form, data): + return obj + + def get_xref_buttons(self, obj): + return [] + + def get_xref_links(self, obj): + return [] + + def get_context_menu_items(self, obj): + return [] + + def get_version_child_classes(self): + """ + Return a list of additional "version child classes" which are + to be taken into account when displaying version history for a + given record. + + See also + :meth:`tailbone.views.master.MasterView.get_version_child_classes()`. + """ + return [] + + @classmethod + def defaults(cls, config): + cls._defaults(config) + + @classmethod + def _defaults(cls, config): + config.add_tailbone_view_supplement(cls.route_prefix, cls) diff --git a/tailbone/views/members.py b/tailbone/views/members.py index a0157649..46ed7e4b 100644 --- a/tailbone/views/members.py +++ b/tailbone/views/members.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,25 +24,28 @@ Member Views """ -from __future__ import unicode_literals, absolute_import +from collections import OrderedDict -import six import sqlalchemy as sa +import sqlalchemy_continuum as continuum from rattail.db import model +from rattail.db.model import MembershipType, Member, MemberEquityPayment from deform import widget as dfwidget +from webhelpers2.html import tags -from tailbone import grids +from tailbone import grids, forms from tailbone.views import MasterView -class MemberView(MasterView): +class MembershipTypeView(MasterView): """ - Master view for the Member class. + Master view for Membership Types """ - model_class = model.Member - is_contact = True + model_class = MembershipType + route_prefix = 'membership_types' + url_prefix = '/membership-types' has_versions = True labels = { @@ -51,38 +54,144 @@ class MemberView(MasterView): grid_columns = [ 'number', - 'id', + 'name', + ] + + has_rows = True + model_row_class = Member + rows_title = "Members" + + row_grid_columns = [ + '_member_key_', 'person', - 'customer', - 'email', - 'phone', 'active', 'equity_current', + 'equity_total', 'joined', 'withdrew', ] + def configure_grid(self, g): + """ """ + super().configure_grid(g) + + g.set_sort_defaults('number') + + g.set_link('number') + g.set_link('name') + + def get_row_data(self, memtype): + """ """ + model = self.model + return self.Session.query(model.Member)\ + .filter(model.Member.membership_type == memtype) + + def get_parent(self, member): + return member.membership_type + + def configure_row_grid(self, g): + super().configure_row_grid(g) + + g.filters['active'].default_active = True + g.filters['active'].default_verb = 'is_true' + + g.set_link('person') + + def row_view_action_url(self, member, i): + return self.request.route_url('members.view', uuid=member.uuid) + + +class MemberView(MasterView): + """ + Master view for the Member class. + """ + model_class = Member + is_contact = True + touchable = True + has_versions = True + configurable = True + supports_autocomplete = True + + labels = { + 'id': "ID", + 'person': "Account Holder", + } + + grid_columns = [ + '_member_key_', + 'person', + 'membership_type', + 'active', + 'equity_current', + 'joined', + 'withdrew', + 'equity_total', + ] + form_fields = [ - 'number', - 'id', + '_member_key_', 'person', 'customer', 'default_email', 'default_phone', + 'membership_type', 'active', + 'equity_total', 'equity_current', 'equity_payment_due', 'joined', 'withdrew', ] - def configure_grid(self, g): - super(MemberView, self).configure_grid(g) + has_rows = True + model_row_class = MemberEquityPayment + rows_title = "Equity Payments" + row_grid_columns = [ + 'received', + 'amount', + 'description', + 'source', + 'transaction_identifier', + ] + + def should_expose_quickie_search(self): + if self.expose_quickie_search: + return True + app = self.get_rattail_app() + return app.get_people_handler().should_expose_quickie_search() + + def get_quickie_perm(self): + return 'people.quickie' + + def get_quickie_url(self): + return self.request.route_url('people.quickie') + + def get_quickie_placeholder(self): + app = self.get_rattail_app() + return app.get_people_handler().get_quickie_search_placeholder() + + def configure_grid(self, g): + """ """ + super().configure_grid(g) + route_prefix = self.get_route_prefix() + model = self.model + + # member key + field = self.get_member_key_field() + g.filters[field].default_active = True + g.filters[field].default_verb = 'equal' + g.set_sort_defaults(field) + g.set_link(field) + + # person + g.set_link('person') g.set_joiner('person', lambda q: q.outerjoin(model.Person)) g.set_sorter('person', model.Person.display_name) g.set_filter('person', model.Person.display_name) + # customer + g.set_link('customer') g.set_joiner('customer', lambda q: q.outerjoin(model.Customer)) g.set_sorter('customer', model.Customer.name) g.set_filter('customer', model.Customer.name) @@ -91,42 +200,92 @@ class MemberView(MasterView): g.filters['active'].default_verb = 'is_true' # phone + g.set_label('phone', "Phone Number") g.set_joiner('phone', lambda q: q.outerjoin(model.MemberPhoneNumber, sa.and_( model.MemberPhoneNumber.parent_uuid == model.Member.uuid, model.MemberPhoneNumber.preference == 1))) - g.sorters['phone'] = lambda q, d: q.order_by(getattr(model.MemberPhoneNumber.number, d)()) + g.set_sorter('phone', model.MemberPhoneNumber.number) g.set_filter('phone', model.MemberPhoneNumber.number, factory=grids.filters.AlchemyPhoneNumberFilter) - g.set_label('phone', "Phone Number") # email + g.set_label('email', "Email Address") g.set_joiner('email', lambda q: q.outerjoin(model.MemberEmailAddress, sa.and_( model.MemberEmailAddress.parent_uuid == model.Member.uuid, model.MemberEmailAddress.preference == 1))) - g.sorters['email'] = lambda q, d: q.order_by(getattr(model.MemberEmailAddress.address, d)()) + g.set_sorter('email', model.MemberEmailAddress.address) g.set_filter('email', model.MemberEmailAddress.address) - g.set_label('email', "Email Address") - g.set_sort_defaults('number') + # membership_type + g.set_joiner('membership_type', lambda q: q.outerjoin(model.MembershipType)) + g.set_sorter('membership_type', model.MembershipType.name) + g.set_filter('membership_type', model.MembershipType.name, + label="Membership Type Name") - g.set_link('person') - g.set_link('customer') + if (self.request.has_perm('people.view_profile') + and self.should_link_straight_to_profile()): + + # add View Raw action + url = lambda r, i: self.request.route_url( + f'{route_prefix}.view', **self.get_action_route_kwargs(r)) + # nb. insert to slot 1, just after normal View action + g.actions.insert(1, self.make_action('view_raw', url=url, icon='eye')) + + # equity_total + # TODO: should make this configurable + # g.set_type('equity_total', 'currency') + g.set_renderer('equity_total', self.render_equity_total) + g.remove_sorter('equity_total') + g.remove_filter('equity_total') + + def render_equity_total(self, member, field): + app = self.get_rattail_app() + equity = app.get_membership_handler().get_equity_total(member, cached=False) + return app.render_currency(equity) + + def default_view_url(self): + if (self.request.has_perm('people.view_profile') + and self.should_link_straight_to_profile()): + app = self.get_rattail_app() + + def url(member, i): + person = app.get_person(member) + if person: + return self.request.route_url( + 'people.view_profile', uuid=person.uuid, + _anchor='member') + return self.get_action_url('view', member) + + return url + + return super().default_view_url() + + def should_link_straight_to_profile(self): + return self.rattail_config.getbool('rattail', + 'members.straight_to_profile', + default=False) def grid_extra_class(self, member, i): + """ """ if not member.active: return 'warning' if member.equity_current is False: return 'notice' def configure_form(self, f): - super(MemberView, self).configure_form(f) + """ """ + super().configure_form(f) + model = self.model member = f.model_instance # date fields f.set_type('joined', 'date_jquery') + f.set_type('withdrew', 'date_jquery') + + # equity fields + f.set_renderer('equity_total', self.render_equity_total) f.set_type('equity_payment_due', 'date_jquery') f.set_type('equity_last_paid', 'date_jquery') - f.set_type('withdrew', 'date_jquery') # person if self.creating or self.editing: @@ -134,7 +293,7 @@ class MemberView(MasterView): f.replace('person', 'person_uuid') people = self.Session.query(model.Person)\ .order_by(model.Person.display_name) - values = [(p.uuid, six.text_type(p)) + values = [(p.uuid, str(p)) for p in people] require = False if not require: @@ -151,7 +310,7 @@ class MemberView(MasterView): f.replace('customer', 'customer_uuid') customers = self.Session.query(model.Customer)\ .order_by(model.Customer.name) - values = [(c.uuid, six.text_type(c)) + values = [(c.uuid, str(c)) for c in customers] require = False if not require: @@ -172,6 +331,9 @@ class MemberView(MasterView): if not self.creating and member.phones: f.set_default('default_phone', member.phones[0].number) + # membership_type + f.set_renderer('membership_type', self.render_membership_type) + if self.creating: f.remove_fields( 'equity_total', @@ -180,21 +342,242 @@ class MemberView(MasterView): 'withdrew', ) + def render_equity_total(self, member, field): + app = self.get_rattail_app() + total = sum([payment.amount for payment in member.equity_payments]) + return app.render_currency(total) + + def template_kwargs_view(self, **kwargs): + """ """ + kwargs = super().template_kwargs_view(**kwargs) + app = self.get_rattail_app() + member = kwargs['instance'] + + people = OrderedDict() + person = app.get_person(member) + if person: + people.setdefault(person.uuid, person) + customer = app.get_customer(member) + if customer: + person = app.get_person(customer) + if person: + people.setdefault(person.uuid, person) + kwargs['show_profiles_people'] = list(people.values()) + + return kwargs + def render_default_email(self, member, field): + """ """ if member.emails: return member.emails[0].address def render_default_phone(self, member, field): + """ """ if member.phones: return member.phones[0].number + def render_membership_type(self, member, field): + memtype = getattr(member, field) + if not memtype: + return + text = str(memtype) + url = self.request.route_url('membership_types.view', uuid=memtype.uuid) + return tags.link_to(text, url) + + def get_row_data(self, member): + """ """ + model = self.model + return self.Session.query(model.MemberEquityPayment)\ + .filter(model.MemberEquityPayment.member == member) + + def get_parent(self, payment): + return payment.member + + def configure_row_grid(self, g): + super().configure_row_grid(g) + + g.set_type('amount', 'currency') + + g.set_sort_defaults('received', 'desc') + + def row_view_action_url(self, payment, i): + return self.request.route_url('member_equity_payments.view', + uuid=payment.uuid) + + def configure_get_simple_settings(self): + """ """ + return [ + + # General + {'section': 'rattail', + 'option': 'members.key_field'}, + {'section': 'rattail', + 'option': 'members.key_label'}, + {'section': 'rattail', + 'option': 'members.straight_to_profile', + 'type': bool}, + + # Relationships + {'section': 'rattail', + 'option': 'members.max_one_per_person', + 'type': bool}, + ] + + +class MemberEquityPaymentView(MasterView): + """ + Master view for the MemberEquityPayment class. + """ + model_class = MemberEquityPayment + route_prefix = 'member_equity_payments' + url_prefix = '/member-equity-payments' + supports_grid_totals = True + has_versions = True + + labels = { + 'status_code': "Status", + } + + grid_columns = [ + 'received', + '_member_key_', + 'member', + 'amount', + 'description', + 'source', + 'transaction_identifier', + 'status_code', + ] + + form_fields = [ + '_member_key_', + 'member', + 'amount', + 'received', + 'description', + 'source', + 'transaction_identifier', + 'status_code', + ] + + def query(self, session): + """ """ + query = super().query(session) + model = self.model + + query = query.join(model.Member) + + return query + + def configure_grid(self, g): + """ """ + super().configure_grid(g) + model = self.model + + # member_key + field = self.get_member_key_field() + attr = getattr(model.Member, field) + g.set_renderer(field, self.render_member_key) + g.set_filter(field, attr, + label=self.get_member_key_label(), + default_active=True, + default_verb='equal') + g.set_sorter(field, attr) + + # member (name) + g.set_joiner('member', lambda q: q.outerjoin(model.Person)) + g.set_sorter('member', model.Person.display_name) + g.set_link('member') + g.set_filter('member', model.Person.display_name, + label="Member Name") + + g.set_type('amount', 'currency') + + g.set_sort_defaults('received', 'desc') + g.set_link('received') + + # description + g.set_link('description') + + g.set_link('transaction_identifier') + + # status_code + g.set_enum('status_code', model.MemberEquityPayment.STATUS) + + def render_member_key(self, payment, field): + key = getattr(payment.member, field) + return key + + def fetch_grid_totals(self): + app = self.get_rattail_app() + results = self.get_effective_data() + total = sum([payment.amount for payment in results]) + return {'totals_display': app.render_currency(total)} + + def configure_form(self, f): + """ """ + super().configure_form(f) + model = self.model + payment = f.model_instance + + # member_key + field = self.get_member_key_field() + f.set_renderer(field, self.render_member_key) + f.set_readonly(field) + + # member + if self.creating: + f.replace('member', 'member_uuid') + member_display = "" + if self.request.method == 'POST': + if self.request.POST.get('member_uuid'): + member = self.Session.get(model.Member, + self.request.POST['member_uuid']) + if member: + member_display = str(member) + elif self.editing: + member_display = str(payment.member or '') + members_url = self.request.route_url('members.autocomplete') + f.set_widget('member_uuid', forms.widgets.JQueryAutocompleteWidget( + field_display=member_display, service_url=members_url)) + f.set_label('member_uuid', "Member") + else: + f.set_readonly('member') + f.set_renderer('member', self.render_member) + + # amount + f.set_type('amount', 'currency') + + # received + if self.creating: + f.set_type('received', 'date_jquery') + else: + f.set_readonly('received') + + # status_code + f.set_enum('status_code', model.MemberEquityPayment.STATUS) + + def get_version_diff_enums(self, version): + """ """ + model = self.model + cls = continuum.parent_class(version.__class__) + + if cls is model.MemberEquityPayment: + return {'status_code': model.MemberEquityPayment.STATUS} + def defaults(config, **kwargs): base = globals() + MembershipTypeView = kwargs.get('MembershipTypeView', base['MembershipTypeView']) + MembershipTypeView.defaults(config) + MemberView = kwargs.get('MemberView', base['MemberView']) MemberView.defaults(config) + MemberEquityPaymentView = kwargs.get('MemberEquityPaymentView', base['MemberEquityPaymentView']) + MemberEquityPaymentView.defaults(config) + def includeme(config): defaults(config) diff --git a/tailbone/views/menus.py b/tailbone/views/menus.py index 37c2536c..b606e4e7 100644 --- a/tailbone/views/menus.py +++ b/tailbone/views/menus.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,15 +24,12 @@ Base class for Config Views """ -from __future__ import unicode_literals, absolute_import - import json import sqlalchemy as sa from tailbone.views import View from tailbone.db import Session -from tailbone.menus import make_menu_key class MenuConfigView(View): @@ -62,9 +59,8 @@ class MenuConfigView(View): context = { 'config_title': "Menus", - 'use_buefy': True, - 'index_title': "App Settings", - 'index_url': self.request.route_url('appsettings'), + 'index_title': "App Details", + 'index_url': self.request.route_url('appinfo'), } possible_index_options = sorted( @@ -82,12 +78,16 @@ class MenuConfigView(View): return context def configure_gather_settings(self, data): + app = self.get_rattail_app() + web = app.get_web_handler() + menus = web.get_menu_handler() + settings = [{'name': 'tailbone.menu.from_settings', 'value': 'true'}] main_keys = [] for topitem in json.loads(data['menus']): - key = make_menu_key(self.rattail_config, topitem['title']) + key = menus._make_menu_key(self.rattail_config, topitem['title']) main_keys.append(key) settings.extend([ @@ -102,7 +102,7 @@ class MenuConfigView(View): if item.get('route'): item_key = item['route'] else: - item_key = make_menu_key(self.rattail_config, item['title']) + item_key = menus._make_menu_key(self.rattail_config, item['title']) item_keys.append(item_key) settings.extend([ @@ -173,9 +173,7 @@ class MenuConfigView(View): '/configure-menus') config.add_view(cls, attr='configure', route_name='configure_menus', - # nb. must be root to configure menus! b/c - # otherwise some route options may be hidden - permission='admin', + permission='appinfo.configure', renderer='/configure-menus.mako') config.add_tailbone_config_page('configure_menus', "Menus", 'admin') diff --git a/tailbone/views/messages.py b/tailbone/views/messages.py index f483d03b..9199c025 100644 --- a/tailbone/views/messages.py +++ b/tailbone/views/messages.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,19 +24,13 @@ Message Views """ -from __future__ import unicode_literals, absolute_import - -import six - from rattail.db import model from rattail.time import localtime import colander from deform import widget as dfwidget -from pyramid import httpexceptions from webhelpers2.html import tags, HTML -# from tailbone import forms from tailbone.db import Session from tailbone.views import MasterView from tailbone.util import raw_datetime @@ -52,6 +46,7 @@ class MessageView(MasterView): checkboxes = True replying = False reply_header_sent_format = '%a %d %b %Y at %I:%M %p' + listable = False grid_columns = [ 'subject', @@ -86,15 +81,15 @@ class MessageView(MasterView): def index(self): if not self.request.user: - raise httpexceptions.HTTPForbidden - return super(MessageView, self).index() + raise self.forbidden() + return super().index() def get_instance(self): if not self.request.user: - raise httpexceptions.HTTPForbidden - message = super(MessageView, self).get_instance() + raise self.forbidden() + message = super().get_instance() if not self.associated_with(message): - raise httpexceptions.HTTPForbidden + raise self.forbidden() return message def associated_with(self, message): @@ -111,11 +106,18 @@ class MessageView(MasterView): .filter(model.MessageRecipient.recipient == self.request.user) def configure_grid(self, g): - - g.joiners['sender'] = lambda q: q.join(model.User, model.User.uuid == model.Message.sender_uuid).outerjoin(model.Person) - g.filters['sender'] = g.make_filter('sender', model.Person.display_name, - default_active=True, default_verb='contains') - g.sorters['sender'] = g.make_sorter(model.Person.display_name) + super().configure_grid(g) + model = self.model + + # sender + g.set_joiner('sender', + lambda q: q.join(model.User, + model.User.uuid == model.Message.sender_uuid)\ + .outerjoin(model.Person)) + g.set_sorter('sender', model.Person.display_name) + g.set_filter('sender', model.Person.display_name, + default_active=True, + default_verb='contains') g.filters['subject'].default_active = True g.filters['subject'].default_verb = 'contains' @@ -138,7 +140,7 @@ class MessageView(MasterView): sender = message.sender if sender is self.request.user: return 'you' - return six.text_type(sender) + return str(sender) def render_subject_bold(self, message, field): if not message.subject: @@ -163,8 +165,6 @@ class MessageView(MasterView): if not recipients: return "" - use_buefy = self.get_use_buefy() - # remove current user from displayed list, even if they're a recipient recips = [r for r in recipients if r.recipient is not self.request.user] @@ -181,23 +181,17 @@ class MessageView(MasterView): # client-side JS allowing the user to view all if they want max_display = 5 if len(recips) > max_display: - if use_buefy: - basic = HTML.tag('span', c="{}, ".format(', '.join(recips[:max_display-1]))) - more = tags.link_to("({} more)".format(len(recips[max_display-1:])), '#', **{ - 'v-show': '!showingAllRecipients', - '@click.prevent': 'showMoreRecipients()', - }) - everyone = HTML.tag('span', c=', '.join(recips[max_display-1:]), **{ - 'v-show': 'showingAllRecipients', - '@click': 'hideMoreRecipients()', - 'class_': 'everyone', - }) - return HTML.tag('div', c=[basic, more, everyone]) - else: - basic = HTML.literal("{}, ".format(', '.join(recips[:max_display-1]))) - more = tags.link_to("({} more)".format(len(recips[max_display-1:])), '#', class_='more') - everyone = HTML.tag('span', class_='everyone', c=', '.join(recips[max_display-1:])) - return basic + more + everyone + basic = HTML.tag('span', c="{}, ".format(', '.join(recips[:max_display-1]))) + more = tags.link_to("({} more)".format(len(recips[max_display-1:])), '#', **{ + 'v-show': '!showingAllRecipients', + '@click.prevent': 'showMoreRecipients()', + }) + everyone = HTML.tag('span', c=', '.join(recips[max_display-1:]), **{ + 'v-show': 'showingAllRecipients', + '@click': 'hideMoreRecipients()', + 'class_': 'everyone', + }) + return HTML.tag('div', c=[basic, more, everyone]) # show the full list if there are few enough recipients for that return ', '.join(recips) @@ -212,16 +206,10 @@ class MessageView(MasterView): # return form def configure_form(self, f): - super(MessageView, self).configure_form(f) - use_buefy = self.get_use_buefy() + super().configure_form(f) f.submit_label = "Send Message" - if not use_buefy: - # we have custom logic to disable submit button - f.auto_disable = False - f.auto_disable_save = False - # TODO: A fair amount of this still seems hacky... f.set_renderer('sender', self.render_sender) @@ -234,16 +222,17 @@ class MessageView(MasterView): f.set_label('recipients', "To") # subject - if use_buefy: - f.set_renderer('subject', self.render_subject_bold) - if self.creating: - f.set_widget('subject', dfwidget.TextInputWidget( - placeholder="please enter a subject", - autocomplete='off')) - f.set_required('subject') + f.set_renderer('subject', self.render_subject_bold) + if self.creating: + f.set_widget('subject', dfwidget.TextInputWidget( + placeholder="please enter a subject", + autocomplete='off', + attributes={'@keydown.native': 'subjectKeydown'})) + f.set_required('subject') # body - f.set_widget('body', dfwidget.TextAreaWidget(cols=50, rows=15)) + f.set_widget('body', dfwidget.TextAreaWidget( + cols=50, rows=15, attributes={'ref': 'messageBody'})) if self.creating: f.remove('sender', 'sent') @@ -252,10 +241,7 @@ class MessageView(MasterView): f.insert_after('recipients', 'set_recipients') f.remove('recipients') f.set_node('set_recipients', colander.SchemaNode(colander.Set())) - if use_buefy: - f.set_widget('set_recipients', RecipientsWidgetBuefy()) - else: - f.set_widget('set_recipients', RecipientsWidget()) + f.set_widget('set_recipients', RecipientsWidget()) f.set_label('set_recipients', "To") if self.replying: @@ -277,17 +263,11 @@ class MessageView(MasterView): value = [r[0] for r in value] if old_message.sender is not self.request.user and old_message.sender.active: value.insert(0, old_message.sender_uuid) - if use_buefy: - f.set_default('set_recipients', value) - else: - f.set_default('set_recipients', ','.join(value)) + f.set_default('set_recipients', value) # Just a normal reply, to sender only. elif self.filter_reply_recipient(old_message.sender): - if use_buefy: - f.set_default('set_recipients', [old_message.sender.uuid]) - else: - f.set_default('set_recipients', old_message.sender.uuid) + f.set_default('set_recipients', [old_message.sender.uuid]) # TODO? # # Set focus to message body instead of recipients, when replying. @@ -299,14 +279,14 @@ class MessageView(MasterView): def objectify(self, form, data=None): if data is None: data = form.validated - message = super(MessageView, self).objectify(form, data) + message = super().objectify(form, data) if self.creating: if self.request.user: message.sender = self.request.user for uuid in data['set_recipients']: - user = self.Session.query(model.User).get(uuid) + user = self.Session.get(model.User, uuid) if user: message.add_recipient(user, status=self.enum.MESSAGE_STATUS_INBOX) @@ -346,11 +326,9 @@ class MessageView(MasterView): return recipient def template_kwargs_create(self, **kwargs): - use_buefy = self.get_use_buefy() recips = self.get_available_recipients() - if use_buefy: - kwargs['recipient_display_map'] = recips + kwargs['recipient_display_map'] = recips recips = list(recips.items()) recips.sort(key=self.recipient_sortkey) kwargs['available_recipients'] = recips @@ -358,9 +336,8 @@ class MessageView(MasterView): if self.replying: kwargs['original_message'] = self.get_instance() - if use_buefy: - kwargs['index_url'] = None - kwargs['index_title'] = "New Message" + kwargs['index_url'] = None + kwargs['index_title'] = "New Message" return kwargs def recipient_sortkey(self, recip): @@ -416,7 +393,7 @@ class MessageView(MasterView): message = self.get_instance() recipient = self.get_recipient(message) if not recipient: - raise httpexceptions.HTTPForbidden + raise self.forbidden() dest = self.request.GET.get('dest') if dest not in ('inbox', 'archive'): @@ -491,7 +468,7 @@ class InboxView(MessageView): return self.request.route_url('messages.inbox') def query(self, session): - q = super(InboxView, self).query(session) + q = super().query(session) return q.filter(model.MessageRecipient.status == self.enum.MESSAGE_STATUS_INBOX) @@ -507,7 +484,7 @@ class ArchiveView(MessageView): return self.request.route_url('messages.archive') def query(self, session): - q = super(ArchiveView, self).query(session) + q = super().query(session) return q.filter(model.MessageRecipient.status == self.enum.MESSAGE_STATUS_ARCHIVE) @@ -528,7 +505,7 @@ class SentView(MessageView): .filter(model.Message.sender == self.request.user) def configure_grid(self, g): - super(SentView, self).configure_grid(g) + super().configure_grid(g) g.filters['sender'].default_active = False g.joiners['recipients'] = lambda q: q.join(model.MessageRecipient)\ .join(model.User, model.User.uuid == model.MessageRecipient.recipient_uuid)\ @@ -537,30 +514,16 @@ class SentView(MessageView): default_active=True, default_verb='contains') -class RecipientsWidget(dfwidget.TextInputWidget): - - def deserialize(self, field, pstruct): - if pstruct is colander.null: - return [] - elif not isinstance(pstruct, six.string_types): - raise colander.Invalid(field.schema, "Pstruct is not a string") - if self.strip: - pstruct = pstruct.strip() - if not pstruct: - return [] - return pstruct.split(',') - - -class RecipientsWidgetBuefy(dfwidget.Widget): +class RecipientsWidget(dfwidget.Widget): """ - Custom "message recipients" widget, for use with Buefy / Vue.js themes. + Custom "message recipients" widget, for use with Vue.js themes. """ - template = 'message_recipients_buefy' + template = 'message_recipients' def deserialize(self, field, pstruct): if pstruct is colander.null: return colander.null - if not isinstance(pstruct, six.string_types): + if not isinstance(pstruct, str): raise colander.Invalid(field.schema, "Pstruct is not a string") if not pstruct: return colander.null diff --git a/tailbone/views/people.py b/tailbone/views/people.py index 6d517e3a..405b1ca3 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,26 +24,25 @@ Person Views """ -from __future__ import unicode_literals, absolute_import - import datetime import logging +from collections import OrderedDict -import six import sqlalchemy as sa from sqlalchemy import orm +import sqlalchemy_continuum as continuum -from rattail.db import model, api -from rattail.db.util import maxlen -from rattail.time import localtime -from rattail.util import OrderedDict, simple_error +from rattail.db import api +from rattail.db.model import Person, PersonNote, MergePeopleRequest +from rattail.util import simple_error import colander -from pyramid.httpexceptions import HTTPFound, HTTPNotFound from webhelpers2.html import HTML, tags from tailbone import forms, grids +from tailbone.db import TrainwreckSession from tailbone.views import MasterView +from tailbone.util import raw_datetime log = logging.getLogger(__name__) @@ -53,15 +52,16 @@ class PersonView(MasterView): """ Master view for the Person class. """ - model_class = model.Person + model_class = Person model_title_plural = "People" route_prefix = 'people' touchable = True has_versions = True bulk_deletable = True is_contact = True - manage_notes_from_profile_view = False supports_autocomplete = True + supports_quickie_search = True + configurable = True labels = { 'default_phone': "Phone Number", @@ -94,7 +94,7 @@ class PersonView(MasterView): mergeable = True def __init__(self, request): - super(PersonView, self).__init__(request) + super().__init__(request) app = self.get_rattail_app() # always get a reference to the People Handler @@ -104,7 +104,7 @@ class PersonView(MasterView): self.handler = self.people_handler def make_grid_kwargs(self, **kwargs): - kwargs = super(PersonView, self).make_grid_kwargs(**kwargs) + kwargs = super().make_grid_kwargs(**kwargs) # turn on checkboxes if user can create a merge reqeust if self.mergeable and self.has_perm('request_merge'): @@ -113,21 +113,42 @@ class PersonView(MasterView): return kwargs def configure_grid(self, g): - super(PersonView, self).configure_grid(g) + super().configure_grid(g) + route_prefix = self.get_route_prefix() + model = self.model - g.joiners['email'] = lambda q: q.outerjoin(model.PersonEmailAddress, sa.and_( - model.PersonEmailAddress.parent_uuid == model.Person.uuid, - model.PersonEmailAddress.preference == 1)) - g.joiners['phone'] = lambda q: q.outerjoin(model.PersonPhoneNumber, sa.and_( - model.PersonPhoneNumber.parent_uuid == model.Person.uuid, - model.PersonPhoneNumber.preference == 1)) + # email + g.set_label('email', "Email Address") + g.set_joiner('email', lambda q: q.outerjoin( + model.PersonEmailAddress, + sa.and_( + model.PersonEmailAddress.parent_uuid == model.Person.uuid, + model.PersonEmailAddress.preference == 1))) + g.set_sorter('email', model.PersonEmailAddress.address) + g.set_filter('email', model.PersonEmailAddress.address) - g.filters['email'] = g.make_filter('email', model.PersonEmailAddress.address) + # phone + g.set_label('phone', "Phone Number") + g.set_joiner('phone', lambda q: q.outerjoin( + model.PersonPhoneNumber, + sa.and_( + model.PersonPhoneNumber.parent_uuid == model.Person.uuid, + model.PersonPhoneNumber.preference == 1))) + g.set_sorter('phone', model.PersonPhoneNumber.number) g.set_filter('phone', model.PersonPhoneNumber.number, factory=grids.filters.AlchemyPhoneNumberFilter) - g.joiners['customer_id'] = lambda q: q.outerjoin(model.CustomerPerson).outerjoin(model.Customer) - g.filters['customer_id'] = g.make_filter('customer_id', model.Customer.id) + Customer_ID = orm.aliased(model.Customer) + CustomerPerson_ID = orm.aliased(model.CustomerPerson) + + Customer_Number = orm.aliased(model.Customer) + CustomerPerson_Number = orm.aliased(model.CustomerPerson) + + g.joiners['customer_id'] = lambda q: q.outerjoin(CustomerPerson_ID).outerjoin(Customer_ID) + g.filters['customer_id'] = g.make_filter('customer_id', Customer_ID.id) + + g.joiners['customer_number'] = lambda q: q.outerjoin(CustomerPerson_Number).outerjoin(Customer_Number) + g.filters['customer_number'] = g.make_filter('customer_number', Customer_Number.number) g.filters['first_name'].default_active = True g.filters['first_name'].default_verb = 'contains' @@ -139,23 +160,39 @@ class PersonView(MasterView): g.set_filter('employee_status', model.Employee.status, value_enum=self.enum.EMPLOYEE_STATUS) - g.sorters['email'] = lambda q, d: q.order_by(getattr(model.PersonEmailAddress.address, d)()) - g.sorters['phone'] = lambda q, d: q.order_by(getattr(model.PersonPhoneNumber.number, d)()) - g.set_label('merge_requested', "MR") g.set_renderer('merge_requested', self.render_merge_requested) g.set_sort_defaults('display_name') g.set_label('display_name', "Full Name") - g.set_label('phone', "Phone Number") - g.set_label('email', "Email Address") g.set_label('customer_id', "Customer ID") + if (self.has_perm('view_profile') + and self.should_link_straight_to_profile()): + + # add View Raw action + url = lambda r, i: self.request.route_url( + f'{route_prefix}.view', **self.get_action_route_kwargs(r)) + # nb. insert to slot 1, just after normal View action + g.actions.insert(1, self.make_action('view_raw', url=url, icon='eye')) + g.set_link('display_name') g.set_link('first_name') g.set_link('last_name') + def default_view_url(self): + if (self.has_perm('view_profile') + and self.should_link_straight_to_profile()): + return lambda p, i: self.get_action_url('view_profile', p) + + return super().default_view_url() + + def should_link_straight_to_profile(self): + return self.rattail_config.getbool('rattail', + 'people.straight_to_profile', + default=False) + def render_merge_requested(self, person, field): model = self.model merge_request = self.Session.query(model.MergePeopleRequest)\ @@ -165,25 +202,23 @@ class PersonView(MasterView): .filter(model.MergePeopleRequest.merged == None)\ .first() if merge_request: - use_buefy = self.get_use_buefy() - if use_buefy: - return HTML.tag('span', - class_='has-text-danger has-text-weight-bold', - title="A merge has been requested for this person.", - c="MR") - return "MR" + return HTML.tag('span', + class_='has-text-danger has-text-weight-bold', + title="A merge has been requested for this person.", + c="MR") def get_instance(self): + model = self.model # TODO: I don't recall why this fallback check for a vendor contact # exists here, but leaving it intact for now. key = self.request.matchdict['uuid'] - instance = self.Session.query(model.Person).get(key) + instance = self.Session.get(model.Person, key) if instance: return instance - instance = self.Session.query(model.VendorContact).get(key) + instance = self.Session.get(model.VendorContact, key) if instance: return instance.person - raise HTTPNotFound + raise self.notfound() def is_person_protected(self, person): for user in person.users: @@ -201,17 +236,27 @@ class PersonView(MasterView): return True return not self.is_person_protected(person) + def configure_form(self, f): + super().configure_form(f) + + # preferred_first_name + if self.people_handler.should_use_preferred_first_name(): + f.insert_after('first_name', 'preferred_first_name') + def objectify(self, form, data=None): if data is None: data = form.validated # do normal create/update - person = super(PersonView, self).objectify(form, data) + person = super().objectify(form, data) # collect data from all name fields names = {} if 'first_name' in form: names['first'] = data['first_name'] + if self.people_handler.should_use_preferred_first_name(): + if 'preferred_first_name' in form: + names['preferred_first'] = data['preferred_first_name'] if 'middle_name' in form: names['middle'] = data['middle_name'] if 'last_name' in form: @@ -247,7 +292,7 @@ class PersonView(MasterView): customer._people.reorder() # continue with normal logic - super(PersonView, self).delete_instance(person) + super().delete_instance(person) def touch_instance(self, person): """ @@ -256,8 +301,10 @@ class PersonView(MasterView): In addition to "touching" the person proper, we also "touch" each contact info record associated with them. """ + model = self.model + # touch person, as per usual - super(PersonView, self).touch_instance(person) + super().touch_instance(person) def touch(obj): change = model.Change() @@ -279,7 +326,7 @@ class PersonView(MasterView): touch(address) def configure_common_form(self, f): - super(PersonView, self).configure_common_form(f) + super().configure_common_form(f) person = f.model_instance f.set_label('display_name', "Full Name") @@ -335,24 +382,28 @@ class PersonView(MasterView): employee = person.employee if not employee: return "" - text = six.text_type(employee) + text = str(employee) url = self.request.route_url('employees.view', uuid=employee.uuid) return tags.link_to(text, url) def render_customers(self, person, field): - customers = person._customers + app = self.get_rattail_app() + clientele = app.get_clientele_handler() + + customers = clientele.get_customers_for_account_holder(person) if not customers: - return "" + return + items = [] for customer in customers: - customer = customer.customer - text = six.text_type(customer) + text = str(customer) if customer.number: text = "(#{}) {}".format(customer.number, text) elif customer.id: text = "({}) {}".format(customer.id, text) url = self.request.route_url('customers.view', uuid=customer.uuid) items.append(HTML.tag('li', c=[tags.link_to(text, url)])) + return HTML.tag('ul', c=items) def render_members(self, person, field): @@ -361,7 +412,7 @@ class PersonView(MasterView): return "" items = [] for member in members: - text = six.text_type(member) + text = str(member) if member.number: text = "(#{}) {}".format(member.number, text) elif member.id: @@ -371,7 +422,6 @@ class PersonView(MasterView): return HTML.tag('ul', c=items) def render_users(self, person, field): - use_buefy = self.get_use_buefy() users = person.users items = [] for user in users: @@ -381,15 +431,13 @@ class PersonView(MasterView): if items: return HTML.tag('ul', c=items) elif self.viewing and self.request.has_perm('users.create'): - if use_buefy: - return HTML.tag('b-button', type='is-primary', c="Make User", - **{'@click': 'clickMakeUser()'}) - else: - return HTML.tag('button', type='button', id='make-user', c="Make User") + return HTML.tag('b-button', type='is-primary', c="Make User", + **{'@click': 'clickMakeUser()'}) else: return "" def get_version_child_classes(self): + model = self.model return [ (model.PersonPhoneNumber, 'parent_uuid'), (model.PersonEmailAddress, 'parent_uuid'), @@ -399,36 +447,205 @@ class PersonView(MasterView): (model.VendorContact, 'person_uuid'), ] + def should_expose_quickie_search(self): + if self.expose_quickie_search: + return True + app = self.get_rattail_app() + return app.get_people_handler().should_expose_quickie_search() + + def do_quickie_lookup(self, entry): + app = self.get_rattail_app() + return app.get_people_handler().quickie_lookup(entry, self.Session()) + + def get_quickie_placeholder(self): + app = self.get_rattail_app() + return app.get_people_handler().get_quickie_search_placeholder() + + def get_quickie_result_url(self, person): + return self.get_action_url('view_profile', person) + def view_profile(self): """ View which exposes the "full profile" for a given person, i.e. all related customer, employee, user info etc. """ self.viewing = True + app = self.get_rattail_app() person = self.get_instance() - employee = person.employee + context = { 'person': person, 'instance': person, 'instance_title': self.get_instance_title(person), - 'today': localtime(self.rattail_config).date(), + 'dynamic_content_title': self.get_context_content_title(person), + 'tabchecks': self.get_context_tabchecks(person), 'person_data': self.get_context_person(person), 'phone_type_options': self.get_phone_type_options(), 'email_type_options': self.get_email_type_options(), 'max_lengths': self.get_max_lengths(), - 'customers_data': self.get_context_customers(person), - 'members_data': self.get_context_members(person), - 'employee': employee, - 'employee_data': self.get_context_employee(employee) if employee else {}, - 'employee_view_url': self.request.route_url('employees.view', uuid=employee.uuid) if employee else None, - 'employee_history': employee.get_current_history() if employee else None, - 'employee_history_data': self.get_context_employee_history(employee), - 'dynamic_content_title': self.get_context_content_title(person), + 'expose_customer_people': self.customers_should_expose_people(), + 'expose_customer_shoppers': self.customers_should_expose_shoppers(), + 'max_one_member': app.get_membership_handler().max_one_per_person(), + 'use_preferred_first_name': self.people_handler.should_use_preferred_first_name(), + 'expose_members': self.should_expose_profile_members(), + 'expose_transactions': self.should_expose_profile_transactions(), } - use_buefy = self.get_use_buefy() - template = 'view_profile_buefy' if use_buefy else 'view_profile' - return self.render_to_response(template, context) + if context['expose_transactions']: + context['transactions_grid'] = self.profile_transactions_grid(person, empty=True) + + if self.request.has_perm('people_profile.view_versions'): + context['revisions_grid'] = self.profile_revisions_grid(person) + + return self.render_to_response('view_profile', context) + + def should_expose_profile_members(self): + return self.rattail_config.get_bool('tailbone.people.profile.expose_members', + default=False) + + def should_expose_profile_transactions(self): + return self.rattail_config.get_bool('tailbone.people.profile.expose_transactions', + default=False) + + def profile_transactions_grid(self, person, empty=False): + app = self.get_rattail_app() + trainwreck = app.get_trainwreck_handler() + model = trainwreck.get_model() + route_prefix = self.get_route_prefix() + if empty: + # TODO: surely there is a better way to have empty data..? but so + # much logic depends on a query, can't just pass empty list here + data = TrainwreckSession.query(model.Transaction)\ + .filter(model.Transaction.uuid == 'bogus') + else: + data = self.profile_transactions_query(person) + factory = self.get_grid_factory() + g = factory( + self.request, + key=f'{route_prefix}.profile.transactions.{person.uuid}', + data=data, + model_class=model.Transaction, + ajax_data_url=self.get_action_url('view_profile_transactions', person), + columns=[ + 'start_time', + 'end_time', + 'system', + 'terminal_id', + 'receipt_number', + 'cashier_name', + 'customer_id', + 'customer_name', + 'total', + ], + labels={ + 'terminal_id': "Terminal", + 'customer_id': "Customer " + app.get_customer_key_label(), + }, + filterable=True, + sortable=True, + paginated=True, + default_sortkey='end_time', + default_sortdir='desc', + component='transactions-grid', + ) + if self.request.has_perm('trainwreck.transactions.view'): + url = lambda row, i: self.request.route_url('trainwreck.transactions.view', + uuid=row.uuid) + g.actions.append(self.make_action('view', icon='eye', url=url)) + g.load_settings() + + g.set_enum('system', self.enum.TRAINWRECK_SYSTEM) + g.set_type('total', 'currency') + + return g + + def profile_transactions_query(self, person): + """ + Method which must return the base query for the profile's POS + Transactions grid data. + """ + customer = self.app.get_customer(person) + + if customer: + key_field = self.app.get_customer_key_field() + customer_key = getattr(customer, key_field) + if customer_key is not None: + customer_key = str(customer_key) + else: + # nb. this should *not* match anything, so query returns + # no results.. + customer_key = person.uuid + + trainwreck = self.app.get_trainwreck_handler() + model = trainwreck.get_model() + query = TrainwreckSession.query(model.Transaction)\ + .filter(model.Transaction.customer_id == customer_key) + return query + + def profile_transactions_data(self): + """ + AJAX view to return new sorted, filtered data for transactions + grid within profile view. + """ + person = self.get_instance() + grid = self.profile_transactions_grid(person) + return grid.get_table_data() + + def get_context_tabchecks(self, person): + app = self.get_rattail_app() + clientele = app.get_clientele_handler() + tabchecks = {} + + # TODO: for efficiency, should only calculate checks for tabs + # actually in use by app..(how) should that be configurable? + + # personal + tabchecks['personal'] = True + + # member + if self.should_expose_profile_members(): + membership = app.get_membership_handler() + if membership.max_one_per_person(): + member = app.get_member(person) + tabchecks['member'] = bool(member and member.active) + else: + members = membership.get_members_for_account_holder(person) + tabchecks['member'] = any([m.active for m in members]) + + # customer + customers = clientele.get_customers_for_account_holder(person) + tabchecks['customer'] = bool(customers) + + # shopper + # TODO: what a hack! surely some of this belongs in handler + shoppers = person.customer_shoppers + shoppers = [shopper for shopper in shoppers + if shopper.shopper_number != 1] + tabchecks['shopper'] = bool(shoppers) + + # employee + employee = app.get_employee(person) + tabchecks['employee'] = bool(employee and employee.status == self.enum.EMPLOYEE_STATUS_CURRENT) + + # notes + tabchecks['notes'] = bool(person.notes) + + # user + tabchecks['user'] = bool(person.users) + + return tabchecks + + def profile_changed_response(self, person): + """ + Return common context result for all AJAX views which may + change the profile details. This is enough to update the + page-wide things, and let other tabs know they should be + refreshed when next displayed. + """ + return { + 'person': self.get_context_person(person), + 'tabchecks': self.get_context_tabchecks(person), + } def template_kwargs_view_profile(self, **kwargs): """ @@ -436,26 +653,22 @@ class PersonView(MasterView): """ return kwargs - def template_kwargs_view_profile_buefy(self, **kwargs): - """ - Note that any subclass should not need to define this method. - It by default invokes :meth:`template_kwargs_view_profile()` - and returns that result. - """ - return self.template_kwargs_view_profile(**kwargs) - def get_max_lengths(self): + app = self.get_rattail_app() model = self.model - return { - 'person_first_name': maxlen(model.Person.first_name), - 'person_middle_name': maxlen(model.Person.middle_name), - 'person_last_name': maxlen(model.Person.last_name), - 'address_street': maxlen(model.PersonMailingAddress.street), - 'address_street2': maxlen(model.PersonMailingAddress.street2), - 'address_city': maxlen(model.PersonMailingAddress.city), - 'address_state': maxlen(model.PersonMailingAddress.state), - 'address_zipcode': maxlen(model.PersonMailingAddress.zipcode), + lengths = { + 'person_first_name': app.maxlen(model.Person.first_name), + 'person_middle_name': app.maxlen(model.Person.middle_name), + 'person_last_name': app.maxlen(model.Person.last_name), + 'address_street': app.maxlen(model.PersonMailingAddress.street), + 'address_street2': app.maxlen(model.PersonMailingAddress.street2), + 'address_city': app.maxlen(model.PersonMailingAddress.city), + 'address_state': app.maxlen(model.PersonMailingAddress.state), + 'address_zipcode': app.maxlen(model.PersonMailingAddress.zipcode), } + if self.people_handler.should_use_preferred_first_name(): + lengths['person_preferred_first_name'] = app.maxlen(model.Person.preferred_first_name) + return lengths def get_phone_type_options(self): """ @@ -500,13 +713,53 @@ class PersonView(MasterView): 'dynamic_content_title': self.get_context_content_title(person), } + if self.people_handler.should_use_preferred_first_name(): + context['preferred_first_name'] = person.preferred_first_name + if person.address: context['address'] = self.get_context_address(person.address) return context + def get_context_shoppers(self, shoppers): + data = [] + for shopper in shoppers: + data.append(self.get_context_shopper(shopper)) + return data + + def get_context_shopper(self, shopper): + app = self.get_rattail_app() + customer = shopper.customer + person = shopper.person + customer_key = self.get_customer_key_field() + account_holder = app.get_person(customer) + context = { + 'uuid': shopper.uuid, + 'customer_uuid': customer.uuid, + 'customer_key': getattr(customer, customer_key), + 'customer_name': customer.name, + 'account_holder_uuid': customer.account_holder_uuid, + 'person_uuid': person.uuid, + 'first_name': person.first_name, + 'middle_name': person.middle_name, + 'last_name': person.last_name, + 'display_name': person.display_name, + 'view_profile_url': self.get_action_url('view_profile', person), + 'phones': self.get_context_phones(person), + 'emails': self.get_context_emails(person), + } + + if account_holder: + context.update({ + 'account_holder_name': account_holder.display_name, + 'account_holder_view_profile_url': self.get_action_url( + 'view_profile', account_holder), + }) + + return context + def get_context_content_title(self, person): - return six.text_type(person) + return str(person) def get_context_address(self, address): context = { @@ -516,7 +769,7 @@ class PersonView(MasterView): 'city': address.city, 'state': address.state, 'zipcode': address.zipcode, - 'display': six.text_type(address), + 'display': str(address), } model = self.model @@ -527,59 +780,120 @@ class PersonView(MasterView): return context def get_context_customers(self, person): + app = self.get_rattail_app() + clientele = app.get_clientele_handler() + expose_shoppers = self.customers_should_expose_shoppers() + expose_people = self.customers_should_expose_people() + + customers = clientele.get_customers_for_account_holder(person) + key = self.get_customer_key_field() data = [] - for cp in person._customers: - customer = cp.customer - data.append({ + + for customer in customers: + context = { 'uuid': customer.uuid, - 'ordinal': cp.ordinal, + '_key': getattr(customer, key), 'id': customer.id, 'number': customer.number, 'name': customer.name, 'view_url': self.request.route_url('customers.view', uuid=customer.uuid), - 'people': [self.get_context_person(p) - for p in customer.people], 'addresses': [self.get_context_address(a) for a in customer.addresses], - }) + 'external_links': [], + } + + if customer.account_holder: + context['account_holder'] = self.get_context_person( + customer.account_holder) + + if expose_shoppers: + context['shoppers'] = [self.get_context_shopper(s) + for s in customer.shoppers] + + if expose_people: + context['people'] = [self.get_context_person(p) + for p in customer.people] + + for supp in self.iter_view_supplements(): + if hasattr(supp, 'get_context_for_customer'): + context = supp.get_context_for_customer(customer, context) + + data.append(context) + return data + # TODO: this is duplicated in customers view module + def customers_should_expose_shoppers(self): + return self.rattail_config.getbool('rattail', + 'customers.expose_shoppers', + default=True) + + # TODO: this is duplicated in customers view module + def customers_should_expose_people(self): + return self.rattail_config.getbool('rattail', + 'customers.expose_people', + default=True) + def get_context_members(self, person): + app = self.get_rattail_app() + membership = app.get_membership_handler() + data = OrderedDict() + members = membership.get_members_for_account_holder(person) + for member in members: + context = self.get_context_member(member) - for member in person.members: - data[member.uuid] = self.get_context_member(member) + for supp in self.iter_view_supplements(): + if hasattr(supp, 'get_context_for_member'): + context = supp.get_context_for_member(member, context) - for customer in person.customers: - for member in customer.members: - if member.uuid not in data: - data[member.uuid] = self.get_context_member(member) + data[member.uuid] = context return list(data.values()) def get_context_member(self, member): - profile_url = None - if member.person: - profile_url = self.request.route_url('people.view_profile', - uuid=member.person_uuid) + app = self.get_rattail_app() + person = app.get_person(member) - return { + profile_url = None + if person: + profile_url = self.request.route_url('people.view_profile', + uuid=person.uuid) + + key = self.get_member_key_field() + equity_total = sum([payment.amount for payment in member.equity_payments]) + data = { 'uuid': member.uuid, + '_key': getattr(member, key), 'number': member.number, 'id': member.id, 'active': member.active, - 'joined': six.text_type(member.joined) if member.joined else None, - 'withdrew': six.text_type(member.withdrew) if member.withdrew else None, + 'joined': str(member.joined) if member.joined else None, + 'withdrew': str(member.withdrew) if member.withdrew else None, 'customer_uuid': member.customer_uuid, 'customer_name': member.customer.name if member.customer else None, 'person_uuid': member.person_uuid, - 'display': six.text_type(member), + 'display': str(member), 'person_display_name': member.person.display_name if member.person else None, 'view_url': self.request.route_url('members.view', uuid=member.uuid), 'view_profile_url': profile_url, + 'equity_total_display': app.render_currency(equity_total), + 'external_links': [], } + membership_type = member.membership_type + if membership_type: + data.update({ + 'membership_type_uuid': membership_type.uuid, + 'membership_type_number': membership_type.number, + 'membership_type_name': membership_type.name, + 'view_membership_type_url': self.request.route_url( + 'membership_types.view', uuid=membership_type.uuid), + }) + + return data + def get_context_employee(self, employee): """ Return a dict of context data for the given employee. @@ -587,6 +901,12 @@ class PersonView(MasterView): app = self.get_rattail_app() handler = app.get_employment_handler() context = handler.get_context_employee(employee) + context.setdefault('external_links', []) + + for supp in self.iter_view_supplements(): + if hasattr(supp, 'get_context_for_employee'): + context = supp.get_context_for_employee(employee, context) + context['view_url'] = self.request.route_url('employees.view', uuid=employee.uuid) return context @@ -596,11 +916,52 @@ class PersonView(MasterView): for history in employee.sorted_history(reverse=True): data.append({ 'uuid': history.uuid, - 'start_date': six.text_type(history.start_date), - 'end_date': six.text_type(history.end_date or ''), + 'start_date': str(history.start_date), + 'end_date': str(history.end_date or ''), }) return data + def get_context_notes(self, person): + data = [] + notes = sorted(person.notes, key=lambda n: n.created, reverse=True) + for note in notes: + data.append(self.get_context_note(note)) + return data + + def get_context_note(self, note): + app = self.get_rattail_app() + return { + 'uuid': note.uuid, + 'note_type': note.type, + 'note_type_display': self.enum.PERSON_NOTE_TYPE.get(note.type, note.type), + 'subject': note.subject, + 'text': note.text, + 'created_display': raw_datetime(self.rattail_config, note.created), + 'created_by_display': str(note.created_by), + } + + def get_note_type_options(self): + return [{'value': k, 'label': v} + for k, v in self.enum.PERSON_NOTE_TYPE.items()] + + def get_context_users(self, person): + data = [] + users = person.users + for user in users: + data.append(self.get_context_user(user)) + return data + + def get_context_user(self, user): + app = self.get_rattail_app() + return { + 'uuid': user.uuid, + 'username': user.username, + 'display_name': user.display_name, + 'email_address': app.get_contact_email_address(user), + 'active': user.active, + 'view_url': self.request.route_url('users.view', uuid=user.uuid), + } + def ensure_customer(self, person): """ Return the `Customer` record for the given person, establishing it @@ -611,6 +972,19 @@ class PersonView(MasterView): customer = handler.ensure_customer(person) return customer + def profile_tab_personal(self): + """ + Fetch personal tab data for profile view. + """ + # TODO: no need to return primary person data, since that + # always comes back via normal profile_changed_response() + # ..so for now this is a no-op.. + + # person = self.get_instance() + return { + # 'person': self.get_context_person(person), + } + def profile_edit_name(self): """ View which allows a person's name to be updated. @@ -618,17 +992,19 @@ class PersonView(MasterView): person = self.get_instance() data = dict(self.request.json_body) - self.handler.update_names(person, - first=data['first_name'], - middle=data['middle_name'], - last=data['last_name']) + kw = { + 'first': data['first_name'], + 'middle': data['middle_name'], + 'last': data['last_name'], + } + + if self.people_handler.should_use_preferred_first_name(): + kw['preferred_first'] = data['preferred_first_name'] + + self.handler.update_names(person, **kw) self.Session.flush() - return { - 'success': True, - 'person': self.get_context_person(person), - 'dynamic_content_title': self.get_context_content_title(person), - } + return self.profile_changed_response(person) def get_context_phones(self, person): data = [] @@ -658,19 +1034,17 @@ class PersonView(MasterView): return {'error': simple_error(error)} self.Session.flush() - return { - 'success': True, - 'person': self.get_context_person(person), - } + return self.profile_changed_response(person) def profile_update_phone(self): """ View which updates a phone number for the person. """ + model = self.model person = self.get_instance() data = dict(self.request.json_body) - phone = self.Session.query(model.PersonPhoneNumber).get(data['phone_uuid']) + phone = self.Session.get(model.PersonPhoneNumber, data['phone_uuid']) if not phone: return {'error': "Phone not found."} @@ -688,20 +1062,18 @@ class PersonView(MasterView): return {'error': simple_error(error)} self.Session.flush() - return { - 'success': True, - 'person': self.get_context_person(person), - } + return self.profile_changed_response(person) def profile_delete_phone(self): """ View which allows a person's phone number to be deleted. """ + model = self.model person = self.get_instance() data = dict(self.request.json_body) # validate phone - phone = self.Session.query(model.PersonPhoneNumber).get(data['phone_uuid']) + phone = self.Session.get(model.PersonPhoneNumber, data['phone_uuid']) if not phone: return {'error': "Phone not found."} if phone not in person.phones: @@ -711,20 +1083,18 @@ class PersonView(MasterView): person.remove_phone(phone) self.Session.flush() - return { - 'success': True, - 'person': self.get_context_person(person), - } + return self.profile_changed_response(person) def profile_set_preferred_phone(self): """ View which allows a person's "preferred" phone to be set. """ + model = self.model person = self.get_instance() data = dict(self.request.json_body) # validate phone - phone = self.Session.query(model.PersonPhoneNumber).get(data['phone_uuid']) + phone = self.Session.get(model.PersonPhoneNumber, data['phone_uuid']) if not phone: return {'error': "Phone not found."} if phone not in person.phones: @@ -734,10 +1104,7 @@ class PersonView(MasterView): person.set_primary_phone(phone) self.Session.flush() - return { - 'success': True, - 'person': self.get_context_person(person), - } + return self.profile_changed_response(person) def get_context_emails(self, person): data = [] @@ -773,19 +1140,17 @@ class PersonView(MasterView): return {'error': simple_error(error)} self.Session.flush() - return { - 'success': True, - 'person': self.get_context_person(person), - } + return self.profile_changed_response(person) def profile_update_email(self): """ View which updates an email address for the person. """ + model = self.model person = self.get_instance() data = dict(self.request.json_body) - email = self.Session.query(model.PersonEmailAddress).get(data['email_uuid']) + email = self.Session.get(model.PersonEmailAddress, data['email_uuid']) if not email: return {'error': "Email not found."} @@ -799,20 +1164,18 @@ class PersonView(MasterView): return {'error': simple_error(error)} self.Session.flush() - return { - 'success': True, - 'person': self.get_context_person(person), - } + return self.profile_changed_response(person) def profile_delete_email(self): """ View which allows a person's email address to be deleted. """ + model = self.model person = self.get_instance() data = dict(self.request.json_body) # validate email - email = self.Session.query(model.PersonEmailAddress).get(data['email_uuid']) + email = self.Session.get(model.PersonEmailAddress, data['email_uuid']) if not email: return {'error': "Email not found."} if email not in person.emails: @@ -822,21 +1185,18 @@ class PersonView(MasterView): person.remove_email(email) self.Session.flush() - - return { - 'success': True, - 'person': self.get_context_person(person), - } + return self.profile_changed_response(person) def profile_set_preferred_email(self): """ View which allows a person's "preferred" email to be set. """ + model = self.model person = self.get_instance() data = dict(self.request.json_body) # validate email - email = self.Session.query(model.PersonEmailAddress).get(data['email_uuid']) + email = self.Session.get(model.PersonEmailAddress, data['email_uuid']) if not email: return {'error': "Email not found."} if email not in person.emails: @@ -846,10 +1206,7 @@ class PersonView(MasterView): person.set_primary_email(email) self.Session.flush() - return { - 'success': True, - 'person': self.get_context_person(person), - } + return self.profile_changed_response(person) def profile_edit_address(self): """ @@ -863,9 +1220,66 @@ class PersonView(MasterView): self.people_handler.update_address(person, address, **data) self.Session.flush() + return self.profile_changed_response(person) + + def profile_tab_member(self): + """ + Fetch member tab data for profile view. + """ + app = self.get_rattail_app() + membership = app.get_membership_handler() + person = self.get_instance() + + max_one_member = membership.max_one_per_person() + + context = { + 'max_one_member': max_one_member, + } + + if max_one_member: + member = app.get_member(person) + context['member'] = {'exists': bool(member)} + if member: + context['member'].update(self.get_context_member(member)) + else: + context['members'] = self.get_context_members(person) + + return context + + def profile_tab_customer(self): + """ + Fetch customer tab data for profile view. + """ + person = self.get_instance() return { - 'success': True, - 'person': self.get_context_person(person), + 'customers': self.get_context_customers(person), + } + + def profile_tab_shopper(self): + """ + Fetch shopper tab data for profile view. + """ + person = self.get_instance() + + # TODO: what a hack! surely some of this belongs in handler + shoppers = person.customer_shoppers + shoppers = [shopper for shopper in shoppers + if shopper.shopper_number != 1] + + return { + 'shoppers': self.get_context_shoppers(shoppers), + } + + def profile_tab_employee(self): + """ + Fetch employee tab data for profile view. + """ + app = self.get_rattail_app() + person = self.get_instance() + employee = app.get_employee(person) + return { + 'employee': self.get_context_employee(employee) if employee else {}, + 'employee_history': self.get_context_employee_history(employee), } def profile_start_employee(self): @@ -885,16 +1299,7 @@ class PersonView(MasterView): employee = handler.begin_employment(person, start_date, employee_id=data['id']) self.Session.flush() - return self.profile_start_employee_result(employee, start_date) - - def profile_start_employee_result(self, employee, start_date): - return { - 'success': True, - 'employee': self.get_context_employee(employee), - 'employee_view_url': self.request.route_url('employees.view', uuid=employee.uuid), - 'start_date': six.text_type(start_date), - 'employee_history_data': self.get_context_employee_history(employee), - } + return self.profile_changed_response(person) def profile_end_employee(self): """ @@ -914,26 +1319,18 @@ class PersonView(MasterView): handler.end_employment(employee, end_date, revoke_access=data.get('revoke_access')) self.Session.flush() - return self.profile_end_employee_result(employee, end_date) - - def profile_end_employee_result(self, employee, end_date): - return { - 'success': True, - 'employee': self.get_context_employee(employee), - 'employee_view_url': self.request.route_url('employees.view', uuid=employee.uuid), - 'end_date': six.text_type(end_date), - 'employee_history_data': self.get_context_employee_history(employee), - } + return self.profile_changed_response(person) def profile_edit_employee_history(self): """ AJAX view for updating an employee history record. """ + model = self.model person = self.get_instance() employee = person.employee uuid = self.request.json_body['uuid'] - history = self.Session.query(model.EmployeeHistory).get(uuid) + history = self.Session.get(model.EmployeeHistory, uuid) if not history or history not in employee.history: return {'error': "Must specify a valid Employee History record for this Person."} @@ -949,14 +1346,7 @@ class PersonView(MasterView): history.end_date = end_date self.Session.flush() - current_history = employee.get_current_history() - return { - 'success': True, - 'employee': self.get_context_employee(employee), - 'start_date': six.text_type(current_history.start_date), - 'end_date': six.text_type(current_history.end_date or ''), - 'employee_history_data': self.get_context_employee_history(employee), - } + return self.profile_changed_response(person) def profile_update_employee_id(self): """ @@ -970,32 +1360,269 @@ class PersonView(MasterView): data = self.request.json_body employee.id = data['employee_id'] - self.Session.flush() + self.Session.flush() + return self.profile_changed_response(person) + + def profile_tab_notes(self): + """ + Fetch notes tab data for profile view. + """ + person = self.get_instance() return { - 'success': True, - 'employee': self.get_context_employee(employee), + 'notes': self.get_context_notes(person), + 'note_types': self.get_note_type_options(), } + def profile_tab_user(self): + """ + Fetch user tab data for profile view. + """ + app = self.get_rattail_app() + auth = app.get_auth_handler() + person = self.get_instance() + context = { + 'users': self.get_context_users(person), + } + + if not context['users']: + context['suggested_username'] = auth.make_unique_username(self.Session(), + person=person) + + return context + + def profile_make_user(self): + """ + Create a new user account, presumably from the profile view. + """ + app = self.get_rattail_app() + model = self.model + auth = app.get_auth_handler() + + person = self.get_instance() + if person.users: + return {'error': f"This person already has {len(person.users)} user accounts."} + + data = self.request.json_body + user = auth.make_user(session=self.Session(), + person=person, + username=data['username'], + active=data['active']) + + self.Session.flush() + return self.profile_changed_response(person) + + def profile_revisions_grid(self, person): + route_prefix = self.get_route_prefix() + factory = self.get_grid_factory() + g = factory( + self.request, + key=f'{route_prefix}.profile.revisions', + data=[], # start with empty data! + columns=[ + 'changed', + 'changed_by', + 'remote_addr', + 'comment', + ], + labels={ + 'remote_addr': "IP Address", + }, + linked_columns=[ + 'changed', + 'changed_by', + 'comment', + ], + actions=[ + self.make_action('view', icon='eye', url='#', + click_handler='viewRevision(props.row)'), + ], + ) + return g + + def profile_revisions_collect(self, person, versions=None): + model = self.model + versions = versions or [] + + # Person + cls = continuum.version_class(model.Person) + query = self.Session.query(cls)\ + .filter(cls.uuid == person.uuid) + versions.extend(query.all()) + + # User + cls = continuum.version_class(model.User) + query = self.Session.query(cls)\ + .filter(cls.person_uuid == person.uuid) + versions.extend(query.all()) + + # Member + cls = continuum.version_class(model.Member) + query = self.Session.query(cls)\ + .filter(cls.person_uuid == person.uuid) + versions.extend(query.all()) + + # Employee + cls = continuum.version_class(model.Employee) + query = self.Session.query(cls)\ + .filter(cls.person_uuid == person.uuid) + versions.extend(query.all()) + + # EmployeeHistory + cls = continuum.version_class(model.EmployeeHistory) + query = self.Session.query(cls)\ + .join(model.Employee, + model.Employee.uuid == cls.employee_uuid)\ + .filter(model.Employee.person_uuid == person.uuid) + versions.extend(query.all()) + + # PersonPhoneNumber + cls = continuum.version_class(model.PersonPhoneNumber) + query = self.Session.query(cls)\ + .filter(cls.parent_uuid == person.uuid) + versions.extend(query.all()) + + # PersonEmailAddress + cls = continuum.version_class(model.PersonEmailAddress) + query = self.Session.query(cls)\ + .filter(cls.parent_uuid == person.uuid) + versions.extend(query.all()) + + # PersonMailingAddress + cls = continuum.version_class(model.PersonMailingAddress) + query = self.Session.query(cls)\ + .filter(cls.parent_uuid == person.uuid) + versions.extend(query.all()) + + # Customer (account_holder) + cls = continuum.version_class(model.Customer) + query = self.Session.query(cls)\ + .filter(cls.account_holder_uuid == person.uuid) + versions.extend(query.all()) + + # CustomerShopper (from Customer perspective) + cls = continuum.version_class(model.CustomerShopper) + query = self.Session.query(cls)\ + .join(model.Customer, model.Customer.uuid == cls.customer_uuid)\ + .filter(model.Customer.account_holder_uuid == person.uuid) + versions.extend(query.all()) + + # CustomerShopperHistory (from Customer perspective) + cls = continuum.version_class(model.CustomerShopperHistory) + standin = continuum.version_class(model.CustomerShopper) + query = self.Session.query(cls)\ + .join(standin, standin.uuid == cls.shopper_uuid)\ + .join(model.Customer, model.Customer.uuid == standin.customer_uuid)\ + .filter(model.Customer.account_holder_uuid == person.uuid) + versions.extend(query.all()) + + # CustomerShopper (from Shopper perspective) + cls = continuum.version_class(model.CustomerShopper) + query = self.Session.query(cls)\ + .filter(cls.person_uuid == person.uuid) + versions.extend(query.all()) + + # CustomerShopperHistory (from Shopper perspective) + cls = continuum.version_class(model.CustomerShopperHistory) + standin = continuum.version_class(model.CustomerShopper) + query = self.Session.query(cls)\ + .join(standin, standin.uuid == cls.shopper_uuid)\ + .filter(standin.person_uuid == person.uuid) + versions.extend(query.all()) + + # PersonNote + cls = continuum.version_class(model.PersonNote) + query = self.Session.query(cls)\ + .filter(cls.parent_uuid == person.uuid) + versions.extend(query.all()) + + return versions + + def profile_revisions_data(self): + """ + View which locates and organizes all relevant "transaction" + (version) history data for a given Person. Returns JSON, for + use with the table element on the full profile view. + """ + person = self.get_instance() + versions = self.profile_revisions_collect(person) + + # organize final table data + data = [] + all_txns = set([v.transaction for v in versions]) + for i, txn in enumerate( + sorted(all_txns, key=lambda txn: txn.issued_at, reverse=True), + 1): + data.append({ + 'txnid': txn.id, + 'changed': raw_datetime(self.rattail_config, txn.issued_at), + 'changed_by': str(txn.user or '') or None, + 'remote_addr': txn.remote_addr, + 'comment': txn.meta.get('comment'), + }) + # also stash the sequential index for this transaction, for use later + txn._sequential_index = i + + # also organize final transaction/versions (diff) map + vmap = {} + for version in versions: + fields = self.fields_for_version(version) + + old_data = {} + new_data = {} + for field in fields: + if version.previous: + old_data[field] = getattr(version.previous, field) + new_data[field] = getattr(version, field) + diff = self.make_version_diff(version, old_data, new_data, fields=fields) + + if version.transaction_id not in vmap: + txn = version.transaction + prev_txnid = None + next_txnid = None + if txn._sequential_index < len(data): + prev_txnid = data[txn._sequential_index]['txnid'] + if txn._sequential_index > 1: + next_txnid = data[txn._sequential_index - 2]['txnid'] + vmap[txn.id] = { + 'index': txn._sequential_index, + 'txnid': txn.id, + 'prev_txnid': prev_txnid, + 'next_txnid': next_txnid, + 'changed': raw_datetime(self.rattail_config, txn.issued_at, + verbose=True), + 'changed_by': str(txn.user or '') or None, + 'remote_addr': txn.remote_addr, + 'comment': txn.meta.get('comment'), + 'versions': [], + } + + vmap[version.transaction_id]['versions'].append(diff.as_struct()) + + return {'data': data, 'vmap': vmap} + def make_note_form(self, mode, person): schema = NoteSchema().bind(session=self.Session(), person_uuid=person.uuid) if mode == 'create': del schema['uuid'] form = forms.Form(schema=schema, request=self.request) + if mode != 'delete': + form.set_validator('note_type', colander.OneOf(self.enum.PERSON_NOTE_TYPE)) return form def profile_add_note(self): person = self.get_instance() form = self.make_note_form('create', person) - if form.validate(newstyle=True): - note = self.create_note(person, form) - self.Session.flush() - return self.profile_add_note_success(note) - else: - return self.profile_add_note_failure(person, form) + if not form.validate(): + return {'error': str(form.make_deform_form().error)} + + note = self.create_note(person, form) + self.Session.flush() + return self.profile_changed_response(person) def create_note(self, person, form): + model = self.model note = model.PersonNote() note.type = form.validated['note_type'] note.subject = form.validated['note_subject'] @@ -1004,57 +1631,42 @@ class PersonView(MasterView): person.notes.append(note) return note - def profile_add_note_success(self, note): - return self.redirect(self.get_action_url('view_profile', person)) - - def profile_add_note_failure(self, person, form): - return self.redirect(self.get_action_url('view_profile', person)) - def profile_edit_note(self): person = self.get_instance() form = self.make_note_form('edit', person) - if form.validate(newstyle=True): - note = self.update_note(person, form) - self.Session.flush() - return self.profile_edit_note_success(note) - else: - return self.profile_edit_note_failure(person, form) + if not form.validate(): + return {'error': str(form.make_deform_form().error)} + + note = self.update_note(person, form) + self.Session.flush() + return self.profile_changed_response(person) def update_note(self, person, form): - note = self.Session.query(model.PersonNote).get(form.validated['uuid']) + model = self.model + note = self.Session.get(model.PersonNote, form.validated['uuid']) note.subject = form.validated['note_subject'] note.text = form.validated['note_text'] return note - def profile_edit_note_success(self, note): - return self.redirect(self.get_action_url('view_profile', person)) - - def profile_edit_note_failure(self, person, form): - return self.redirect(self.get_action_url('view_profile', person)) - def profile_delete_note(self): person = self.get_instance() form = self.make_note_form('delete', person) - if form.validate(newstyle=True): - self.delete_note(person, form) - self.Session.flush() - return self.profile_delete_note_success(person) - else: - return self.profile_delete_note_failure(person, form) + if not form.validate(): + return {'error': str(form.make_deform_form().error)} + + self.delete_note(person, form) + self.Session.flush() + return self.profile_changed_response(person) def delete_note(self, person, form): - note = self.Session.query(model.PersonNote).get(form.validated['uuid']) + model = self.model + note = self.Session.get(model.PersonNote, form.validated['uuid']) self.Session.delete(note) - def profile_delete_note_success(self, person): - return self.redirect(self.get_action_url('view_profile', person)) - - def profile_delete_note_failure(self, person, form): - return self.redirect(self.get_action_url('view_profile', person)) - def make_user(self): + model = self.model uuid = self.request.POST['person_uuid'] - person = self.Session.query(model.Person).get(uuid) + person = self.Session.get(model.Person, uuid) if not person: return self.notfound() if person.users: @@ -1078,6 +1690,29 @@ class PersonView(MasterView): self.request.POST['keeping_uuid']) return self.redirect(self.get_index_url()) + def configure_get_simple_settings(self): + return [ + + # General + {'section': 'rattail', + 'option': 'people.straight_to_profile', + 'type': bool}, + {'section': 'rattail', + 'option': 'people.expose_quickie_search', + 'type': bool}, + {'section': 'rattail', + 'option': 'people.handler'}, + + + # Profile View + {'section': 'tailbone', + 'option': 'people.profile.expose_members', + 'type': bool}, + {'section': 'tailbone', + 'option': 'people.profile.expose_transactions', + 'type': bool}, + ] + @classmethod def defaults(cls, config): cls._people_defaults(config) @@ -1109,6 +1744,14 @@ class PersonView(MasterView): config.add_view(cls, attr='view_profile', route_name='{}.view_profile'.format(route_prefix), permission='{}.view_profile'.format(permission_prefix)) + # profile - refresh personal tab + config.add_route(f'{route_prefix}.profile_tab_personal', + f'{instance_url_prefix}/profile/tab-personal', + request_method='GET') + config.add_view(cls, attr='profile_tab_personal', + route_name=f'{route_prefix}.profile_tab_personal', + renderer='json') + # profile - edit personal details config.add_tailbone_permission('people_profile', 'people_profile.edit_person', @@ -1204,6 +1847,38 @@ class PersonView(MasterView): renderer='json', permission='people_profile.edit_person') + # profile - refresh member tab + config.add_route(f'{route_prefix}.profile_tab_member', + f'{instance_url_prefix}/profile/tab-member', + request_method='GET') + config.add_view(cls, attr='profile_tab_member', + route_name=f'{route_prefix}.profile_tab_member', + renderer='json') + + # profile - refresh customer tab + config.add_route(f'{route_prefix}.profile_tab_customer', + f'{instance_url_prefix}/profile/tab-customer', + request_method='GET') + config.add_view(cls, attr='profile_tab_customer', + route_name=f'{route_prefix}.profile_tab_customer', + renderer='json') + + # profile - refresh shopper tab + config.add_route(f'{route_prefix}.profile_tab_shopper', + f'{instance_url_prefix}/profile/tab-shopper', + request_method='GET') + config.add_view(cls, attr='profile_tab_shopper', + route_name=f'{route_prefix}.profile_tab_shopper', + renderer='json') + + # profile - refresh employee tab + config.add_route(f'{route_prefix}.profile_tab_employee', + f'{instance_url_prefix}/profile/tab-employee', + request_method='GET') + config.add_view(cls, attr='profile_tab_employee', + route_name=f'{route_prefix}.profile_tab_employee', + renderer='json') + # profile - start employee config.add_route('{}.profile_start_employee'.format(route_prefix), '{}/profile/start-employee'.format(instance_url_prefix), request_method='POST') @@ -1231,32 +1906,87 @@ class PersonView(MasterView): renderer='json', permission='employees.edit') - # manage notes from profile view - if cls.manage_notes_from_profile_view: + # profile - refresh notes tab + config.add_route(f'{route_prefix}.profile_tab_notes', + f'{instance_url_prefix}/profile/tab-notes', + request_method='GET') + config.add_view(cls, attr='profile_tab_notes', + route_name=f'{route_prefix}.profile_tab_notes', + renderer='json') - # add note - config.add_tailbone_permission('people_profile', 'people_profile.add_note', - "Add new {} Note records".format(model_title)) - config.add_route('{}.profile_add_note'.format(route_prefix), '{}/{{{}}}/profile/new-note'.format(url_prefix, model_key), - request_method='POST') - config.add_view(cls, attr='profile_add_note', route_name='{}.profile_add_note'.format(route_prefix), - permission='people_profile.add_note') + # profile - refresh user tab + config.add_route(f'{route_prefix}.profile_tab_user', + f'{instance_url_prefix}/profile/tab-user', + request_method='GET') + config.add_view(cls, attr='profile_tab_user', + route_name=f'{route_prefix}.profile_tab_user', + renderer='json') - # edit note - config.add_tailbone_permission('people_profile', 'people_profile.edit_note', - "Edit {} Note records".format(model_title)) - config.add_route('{}.profile_edit_note'.format(route_prefix), '{}/{{{}}}/profile/edit-note'.format(url_prefix, model_key), - request_method='POST') - config.add_view(cls, attr='profile_edit_note', route_name='{}.profile_edit_note'.format(route_prefix), - permission='people_profile.edit_note') + # profile - make user + config.add_route(f'{route_prefix}.profile_make_user', + f'{instance_url_prefix}/make-user', + request_method='POST') + config.add_view(cls, attr='profile_make_user', + route_name=f'{route_prefix}.profile_make_user', + permission='users.create', + renderer='json') - # delete note - config.add_tailbone_permission('people_profile', 'people_profile.delete_note', - "Delete {} Note records".format(model_title)) - config.add_route('{}.profile_delete_note'.format(route_prefix), '{}/{{{}}}/profile/delete-note'.format(url_prefix, model_key), - request_method='POST') - config.add_view(cls, attr='profile_delete_note', route_name='{}.profile_delete_note'.format(route_prefix), - permission='people_profile.delete_note') + # profile - revisions data + config.add_tailbone_permission('people_profile', + 'people_profile.view_versions', + "View full version history for a profile") + config.add_route(f'{route_prefix}.view_profile_revisions', + f'{instance_url_prefix}/profile/revisions', + request_method='GET') + config.add_view(cls, attr='profile_revisions_data', + route_name=f'{route_prefix}.view_profile_revisions', + permission='people_profile.view_versions', + renderer='json') + + # profile - add note + config.add_tailbone_permission('people_profile', + 'people_profile.add_note', + "Add new Note records") + config.add_route(f'{route_prefix}.profile_add_note', + f'{instance_url_prefix}/profile/new-note', + request_method='POST') + config.add_view(cls, attr='profile_add_note', + route_name=f'{route_prefix}.profile_add_note', + permission='people_profile.add_note', + renderer='json') + + # profile - edit note + config.add_tailbone_permission('people_profile', + 'people_profile.edit_note', + "Edit Note records") + config.add_route(f'{route_prefix}.profile_edit_note', + f'{instance_url_prefix}/profile/edit-note', + request_method='POST') + config.add_view(cls, attr='profile_edit_note', + route_name=f'{route_prefix}.profile_edit_note', + permission='people_profile.edit_note', + renderer='json') + + # profile - delete note + config.add_tailbone_permission('people_profile', + 'people_profile.delete_note', + "Delete Note records") + config.add_route(f'{route_prefix}.profile_delete_note', + f'{instance_url_prefix}/profile/delete-note', + request_method='POST') + config.add_view(cls, attr='profile_delete_note', + route_name=f'{route_prefix}.profile_delete_note', + permission='people_profile.delete_note', + renderer='json') + + # profile - transactions data + config.add_route(f'{route_prefix}.view_profile_transactions', + f'{instance_url_prefix}/profile/transactions', + request_method='GET') + config.add_view(cls, attr='profile_transactions_data', + route_name=f'{route_prefix}.view_profile_transactions', + permission=f'{permission_prefix}.view_profile', + renderer='json') # make user for person config.add_route('{}.make_user'.format(route_prefix), '{}/make-user'.format(url_prefix), @@ -1278,7 +2008,7 @@ class PersonNoteView(MasterView): """ Master view for the PersonNote class. """ - model_class = model.PersonNote + model_class = PersonNote route_prefix = 'person_notes' url_prefix = '/people/notes' has_versions = True @@ -1304,7 +2034,8 @@ class PersonNoteView(MasterView): return note.subject or "(no subject)" def configure_grid(self, g): - super(PersonNoteView, self).configure_grid(g) + super().configure_grid(g) + model = self.model # person g.set_joiner('person', lambda q: q.join(model.Person, @@ -1325,7 +2056,7 @@ class PersonNoteView(MasterView): g.set_link('created') def configure_form(self, f): - super(PersonNoteView, self).configure_form(f) + super().configure_form(f) # person f.set_readonly('person') @@ -1344,7 +2075,7 @@ def valid_note_uuid(node, kw): session = kw['session'] person_uuid = kw['person_uuid'] def validate(node, value): - note = session.query(model.PersonNote).get(value) + note = session.get(PersonNote, value) if not note: raise colander.Invalid(node, "Note not found") if note.person.uuid != person_uuid: @@ -1369,7 +2100,7 @@ class MergePeopleRequestView(MasterView): """ Master view for the MergePeopleRequest class. """ - model_class = model.MergePeopleRequest + model_class = MergePeopleRequest route_prefix = 'people_merge_requests' url_prefix = '/people/merge-requests' creatable = False @@ -1399,7 +2130,7 @@ class MergePeopleRequestView(MasterView): ] def configure_grid(self, g): - super(MergePeopleRequestView, self).configure_grid(g) + super().configure_grid(g) g.set_renderer('removing_uuid', self.render_referenced_person_name) g.set_renderer('keeping_uuid', self.render_referenced_person_name) @@ -1413,31 +2144,33 @@ class MergePeopleRequestView(MasterView): g.set_link('keeping_uuid') def render_referenced_person_name(self, merge_request, field): + model = self.model uuid = getattr(merge_request, field) - person = self.Session.query(self.model.Person).get(uuid) + person = self.Session.get(model.Person, uuid) if person: - return six.text_type(person) + return str(person) return "(person not found)" def get_instance_title(self, merge_request): model = self.model - removing = self.Session.query(model.Person).get(merge_request.removing_uuid) - keeping = self.Session.query(model.Person).get(merge_request.keeping_uuid) + removing = self.Session.get(model.Person, merge_request.removing_uuid) + keeping = self.Session.get(model.Person, merge_request.keeping_uuid) return "{} -> {}".format( removing or "(not found)", keeping or "(not found)") def configure_form(self, f): - super(MergePeopleRequestView, self).configure_form(f) + super().configure_form(f) f.set_renderer('removing_uuid', self.render_referenced_person) f.set_renderer('keeping_uuid', self.render_referenced_person) def render_referenced_person(self, merge_request, field): + model = self.model uuid = getattr(merge_request, field) - person = self.Session.query(self.model.Person).get(uuid) + person = self.Session.get(model.Person, uuid) if person: - text = six.text_type(person) + text = str(person) url = self.request.route_url('people.view', uuid=person.uuid) return tags.link_to(text, url) return "(person not found)" @@ -1457,4 +2190,8 @@ def defaults(config, **kwargs): def includeme(config): - defaults(config) + wutta_config = config.registry.settings['wutta_config'] + if wutta_config.get_bool('tailbone.use_wutta_views', default=False, usedb=False): + config.include('tailbone.views.wutta.people') + else: + defaults(config) diff --git a/tailbone/views/poser/reports.py b/tailbone/views/poser/reports.py index 43ba211d..ded80b18 100644 --- a/tailbone/views/poser/reports.py +++ b/tailbone/views/poser/reports.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,12 +24,8 @@ Poser Report Views """ -from __future__ import unicode_literals, absolute_import - import os -import six - from rattail.util import simple_error import colander @@ -95,7 +91,7 @@ class PoserReportView(PoserMasterView): return self.poser_handler.get_all_reports(ignore_errors=False) def configure_grid(self, g): - super(PoserReportView, self).configure_grid(g) + super().configure_grid(g) g.sorters['report_key'] = g.make_simple_sorter('report_key', foldcase=True) g.sorters['report_name'] = g.make_simple_sorter('report_name', foldcase=True) @@ -114,7 +110,7 @@ class PoserReportView(PoserMasterView): g.set_searchable('description') if self.request.has_perm('report_output.create'): - g.more_actions.append(self.make_action( + g.actions.append(self.make_action( 'generate', icon='arrow-circle-right', url=self.get_generate_url)) @@ -157,7 +153,7 @@ class PoserReportView(PoserMasterView): return report def configure_form(self, f): - super(PoserReportView, self).configure_form(f) + super().configure_form(f) report = f.model_instance # report_key @@ -179,7 +175,7 @@ class PoserReportView(PoserMasterView): f.set_helptext('flavor', "Determines the type of sample code to generate.") flavors = self.poser_handler.get_supported_report_flavors() values = [(key, flavor['description']) - for key, flavor in six.iteritems(flavors)] + for key, flavor in flavors.items()] f.set_widget('flavor', dfwidget.SelectWidget(values=values)) f.set_validator('flavor', colander.OneOf(flavors)) if flavors: @@ -231,7 +227,7 @@ class PoserReportView(PoserMasterView): return report def configure_row_grid(self, g): - super(PoserReportView, self).configure_row_grid(g) + super().configure_row_grid(g) g.set_renderer('id', self.render_id_str) diff --git a/tailbone/views/poser/views.py b/tailbone/views/poser/views.py index 14c97a61..27efd549 100644 --- a/tailbone/views/poser/views.py +++ b/tailbone/views/poser/views.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,10 +24,6 @@ Poser Views for Views... """ -from __future__ import unicode_literals, absolute_import - -import six - import colander from .master import PoserMasterView @@ -68,7 +64,7 @@ class PoserViewView(PoserMasterView): return self.make_form({}) def configure_form(self, f): - super(PoserViewView, self).configure_form(f) + super().configure_form(f) view = f.model_instance # key @@ -224,28 +220,28 @@ class PoserViewView(PoserMasterView): }, }} - for key, views in six.iteritems(everything['rattail']): - for vkey, view in six.iteritems(views): + for key, views in everything['rattail'].items(): + for vkey, view in views.items(): view['options'] = [vkey] providers = get_all_providers(self.rattail_config) - for provider in six.itervalues(providers): + for provider in providers.values(): # loop thru provider top-level groups - for topkey, groups in six.iteritems(provider.get_provided_views()): + for topkey, groups in provider.get_provided_views().items(): # get or create top group topgroup = everything.setdefault(topkey, {}) # loop thru provider view groups - for key, views in six.iteritems(groups): + for key, views in groups.items(): # add group to top group, if it's new if key not in topgroup: topgroup[key] = views # also must init the options for group - for vkey, view in six.iteritems(views): + for vkey, view in views.items(): view['options'] = [vkey] else: # otherwise must "update" existing group @@ -254,7 +250,7 @@ class PoserViewView(PoserMasterView): stdgroup = topgroup[key] # loop thru views within provider group - for vkey, view in six.iteritems(views): + for vkey, view in views.items(): # add view to group if it's new if vkey not in stdgroup: @@ -270,8 +266,8 @@ class PoserViewView(PoserMasterView): settings = [] view_settings = self.collect_available_view_settings() - for topgroup in six.itervalues(view_settings): - for view_section, section_settings in six.iteritems(topgroup): + for topgroup in view_settings.values(): + for view_section, section_settings in topgroup.items(): for key in section_settings: settings.append({'section': 'tailbone.includes', 'option': key}) @@ -282,25 +278,25 @@ class PoserViewView(PoserMasterView): input_file_templates=True): # first get normal context - context = super(PoserViewView, self).configure_get_context( + context = super().configure_get_context( simple_settings=simple_settings, input_file_templates=input_file_templates) # first add available options view_settings = self.collect_available_view_settings() view_options = {} - for topgroup in six.itervalues(view_settings): - for key, views in six.iteritems(topgroup): - for vkey, view in six.iteritems(views): + for topgroup in view_settings.values(): + for key, views in topgroup.items(): + for vkey, view in views.items(): view_options[vkey] = view['options'] context['view_options'] = view_options # then add all available settings as sorted (key, label) options - for topkey, topgroup in six.iteritems(view_settings): + for topkey, topgroup in view_settings.items(): for key in list(topgroup): settings = topgroup[key] settings = [(key, setting.get('label', key)) - for key, setting in six.iteritems(settings)] + for key, setting in settings.items()] settings.sort(key=lambda itm: itm[1]) topgroup[key] = settings context['view_settings'] = view_settings @@ -308,7 +304,7 @@ class PoserViewView(PoserMasterView): return context def configure_flash_settings_saved(self): - super(PoserViewView, self).configure_flash_settings_saved() + super().configure_flash_settings_saved() self.request.session.flash("Please restart the web app!", 'warning') diff --git a/tailbone/views/principal.py b/tailbone/views/principal.py index 0012adc8..3986f8b0 100644 --- a/tailbone/views/principal.py +++ b/tailbone/views/principal.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2021 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,14 +24,11 @@ "Principal" master view """ -from __future__ import unicode_literals, absolute_import - import copy +from collections import OrderedDict from rattail.core import Object -from rattail.util import OrderedDict -import wtforms from webhelpers2.html import HTML from tailbone.db import Session @@ -46,7 +43,7 @@ class PrincipalMasterView(MasterView): def get_fallback_templates(self, template, **kwargs): return [ '/principal/{}.mako'.format(template), - ] + super(PrincipalMasterView, self).get_fallback_templates(template, **kwargs) + ] + super().get_fallback_templates(template, **kwargs) def perm_sortkey(self, item): key, value = item @@ -56,45 +53,56 @@ class PrincipalMasterView(MasterView): """ View for finding all users who have been granted a given permission """ - permissions = copy.deepcopy(self.request.registry.settings.get('tailbone_permissions', {})) + permissions = copy.deepcopy( + self.request.registry.settings.get('wutta_permissions', {})) # sort groups, and permissions for each group, for UI's sake sorted_perms = sorted(permissions.items(), key=self.perm_sortkey) for key, group in sorted_perms: group['perms'] = sorted(group['perms'].items(), key=self.perm_sortkey) - # group options are stable, permission options may depend on submitted group - group_choices = [(gkey, group['label']) for gkey, group in sorted_perms] - permission_choices = [('_any_', "(any)")] - if self.request.method == 'POST': - if self.request.POST.get('permission_group') in permissions: - permission_choices.extend([ - (pkey, perm['label']) - for pkey, perm in permissions[self.request.POST['permission_group']]['perms'] - ]) - - class PermissionForm(wtforms.Form): - permission_group = wtforms.SelectField(choices=group_choices) - permission = wtforms.SelectField(choices=permission_choices) - + # if both field values are in query string, do lookup principals = None - form = PermissionForm(self.request.POST) - if self.request.method == 'POST' and form.validate(): - permission = form.permission.data - principals = self.find_principals_with_permission(self.Session(), permission) + permission_group = self.request.GET.get('permission_group') + permission = self.request.GET.get('permission') + grid = None + if permission_group and permission: + principals = self.find_principals_with_permission(self.Session(), + permission) + grid = self.find_by_perm_make_results_grid(principals) + else: # otherwise clear both values + permission_group = None + permission = None - context = {'form': form, 'permissions': sorted_perms, 'principals': principals} + context = { + 'permissions': sorted_perms, + 'principals': principals, + 'principals_data': self.find_by_perm_results_data(principals), + 'grid': grid, + } - if self.get_use_buefy(): - perms = self.get_buefy_perms_data(sorted_perms) - context['buefy_perms'] = perms - context['buefy_sorted_groups'] = list(perms) - context['selected_group'] = self.request.POST.get('permission_group', 'common') - context['selected_permission'] = self.request.POST.get('permission', None) + perms = self.get_perms_data(sorted_perms) + context['perms_data'] = perms + context['sorted_groups_data'] = list(perms) + + if permission_group and permission_group not in perms: + permission_group = None + if permission: + if permission_group: + group = dict([(p['permkey'], p) for p in perms[permission_group]['permissions']]) + if permission in group: + context['selected_permission_label'] = group[permission]['label'] + else: + permission = None + else: + permission = None + + context['selected_group'] = permission_group + context['selected_permission'] = permission return self.render_to_response('find_by_perm', context) - def get_buefy_perms_data(self, sorted_perms): + def get_perms_data(self, sorted_perms): data = OrderedDict() for gkey, group in sorted_perms: @@ -113,6 +121,35 @@ class PrincipalMasterView(MasterView): return data + def find_by_perm_make_results_grid(self, principals): + route_prefix = self.get_route_prefix() + factory = self.get_grid_factory() + g = factory(self.request, + key=f'{route_prefix}.results', + data=[], + columns=[], + actions=[ + self.make_action('view', icon='eye', + click_handler='navigateTo(props.row._url)'), + ]) + self.find_by_perm_configure_results_grid(g) + return g + + def find_by_perm_configure_results_grid(self, g): + pass + + def find_by_perm_results_data(self, principals): + data = [] + for principal in principals or []: + data.append(self.find_by_perm_normalize(principal)) + return data + + def find_by_perm_normalize(self, principal): + return { + 'uuid': principal.uuid, + '_url': self.get_action_url('view', principal), + } + @classmethod def defaults(cls, config): cls._principal_defaults(config) @@ -126,8 +163,11 @@ class PrincipalMasterView(MasterView): model_title_plural = cls.get_model_title_plural() # find principal by permission - config.add_route('{}.find_by_perm'.format(route_prefix), '{}/find-by-perm'.format(url_prefix)) - config.add_view(cls, attr='find_by_perm', route_name='{}.find_by_perm'.format(route_prefix), + config.add_route('{}.find_by_perm'.format(route_prefix), + '{}/find-by-perm'.format(url_prefix), + request_method='GET') + config.add_view(cls, attr='find_by_perm', + route_name='{}.find_by_perm'.format(route_prefix), permission='{}.find_by_perm'.format(permission_prefix)) config.add_tailbone_permission(permission_prefix, '{}.find_by_perm'.format(permission_prefix), "Find all {} with permission X".format(model_title_plural)) @@ -154,7 +194,7 @@ class PermissionsRenderer(Object): rendered = False for key in sorted(perms, key=lambda p: perms[p]['label'].lower()): checked = auth.has_permission(Session(), principal, key, - include_guest=self.include_guest, + include_anonymous=self.include_guest, include_authenticated=self.include_authenticated) if checked: label = perms[key]['label'] diff --git a/tailbone/views/products.py b/tailbone/views/products.py index ab9f55c6..8461ae03 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,28 +24,24 @@ Product Views """ -from __future__ import unicode_literals, absolute_import - import re import logging - -import six +from collections import OrderedDict import humanize import sqlalchemy as sa from sqlalchemy import orm import sqlalchemy_continuum as continuum from rattail import enum, pod, sil -from rattail.db import model, api, auth, Session as RattailSession +from rattail.db import api, auth, Session as RattailSession +from rattail.db.model import Product, PendingProduct, ProductCost, CustomerOrderItem from rattail.gpc import GPC from rattail.threads import Thread from rattail.exceptions import LabelPrintingError -from rattail.util import load_object, pretty_quantity, OrderedDict, simple_error -from rattail.time import localtime, make_utc +from rattail.util import simple_error import colander from deform import widget as dfwidget -from pyramid import httpexceptions from webhelpers2.html import tags, HTML from tailbone import forms, grids @@ -79,16 +75,20 @@ class ProductView(MasterView): """ Master view for the Product class. """ - model_class = model.Product + model_class = Product has_versions = True results_downloadable_xlsx = True supports_autocomplete = True + bulk_deletable = True mergeable = True + touchable = True configurable = True labels = { 'item_id': "Item ID", 'upc': "UPC", + 'vendor': "Vendor (preferred)", + 'vendor_any': "Vendor (any)", 'status_code': "Status", 'tax1': "Tax 1", 'tax2': "Tax 2", @@ -161,21 +161,8 @@ class ProductView(MasterView): 'inventory_on_order', ] - # These aliases enable the grid queries to filter products which may be - # purchased from *any* vendor, and yet sort by only the "preferred" vendor - # (since that's what shows up in the grid column). - ProductVendorCost = orm.aliased(model.ProductCost) - ProductVendorCostAny = orm.aliased(model.ProductCost) - VendorAny = orm.aliased(model.Vendor) - - # same, but for prices - RegularPrice = orm.aliased(model.ProductPrice) - CurrentPrice = orm.aliased(model.ProductPrice) - SalePrice = orm.aliased(model.ProductPrice) - TPRPrice = orm.aliased(model.ProductPrice) - def __init__(self, request): - super(ProductView, self).__init__(request) + super().__init__(request) self.expose_label_printing = self.rattail_config.getbool( 'tailbone', 'products.print_labels', default=False) @@ -187,29 +174,12 @@ class ProductView(MasterView): self.handler = self.products_handler def query(self, session): - user = self.request.user - if user and user not in session: - user = session.merge(user) + query = super().query(session) + model = self.model - query = session.query(model.Product) - # TODO: was this old `has_permission()` call here for a reason..? hope not.. - # if not auth.has_permission(session, user, 'products.view_deleted'): - if not self.request.has_perm('products.view_deleted'): + if not self.has_perm('view_deleted'): query = query.filter(model.Product.deleted == False) - # TODO: This used to be a good idea I thought...but in dev it didn't - # seem to make much difference, except with a larger (50K) data set it - # totally bogged things down instead of helping... - # query = query\ - # .options(orm.joinedload(model.Product.brand))\ - # .options(orm.joinedload(model.Product.department))\ - # .options(orm.joinedload(model.Product.subdepartment))\ - # .options(orm.joinedload(model.Product.regular_price))\ - # .options(orm.joinedload(model.Product.current_price))\ - # .options(orm.joinedload(model.Product.vendor)) - - query = query.outerjoin(model.ProductInventory) - return query def get_departments(self): @@ -225,23 +195,9 @@ class ProductView(MasterView): .all() def configure_grid(self, g): - super(ProductView, self).configure_grid(g) + super().configure_grid(g) app = self.get_rattail_app() model = self.model - use_buefy = self.get_use_buefy() - - def join_vendor(q): - return q.outerjoin(self.ProductVendorCost, - sa.and_( - self.ProductVendorCost.product_uuid == model.Product.uuid, - self.ProductVendorCost.preference == 1))\ - .outerjoin(model.Vendor) - - def join_vendor_any(q): - return q.outerjoin(self.ProductVendorCostAny, - self.ProductVendorCostAny.product_uuid == model.Product.uuid)\ - .outerjoin(self.VendorAny, - self.VendorAny.uuid == self.ProductVendorCostAny.vendor_uuid) ProductCostCode = orm.aliased(model.ProductCost) ProductCostCodeAny = orm.aliased(model.ProductCost) @@ -257,15 +213,17 @@ class ProductView(MasterView): ProductCostCodeAny.product_uuid == model.Product.uuid) # product key - key = self.rattail_config.product_key() - field = self.product_key_fields.get(key, key) + field = self.get_product_key_field() g.filters[field].default_active = True g.filters[field].default_verb = 'equal' g.set_sort_defaults(field) g.set_link(field) # brand - g.joiners['brand'] = lambda q: q.outerjoin(model.Brand) + g.set_joiner('brand', lambda q: q.outerjoin(model.Brand)) + g.set_sorter('brand', model.Brand.name) + g.set_filter('brand', model.Brand.name, + default_active=True, default_verb='contains') # department g.set_joiner('department', lambda q: q.outerjoin(model.Department)) @@ -273,23 +231,43 @@ class ProductView(MasterView): departments = self.get_departments() department_choices = OrderedDict([('', "(any)")] + [(d.uuid, d.name) for d in departments]) - if not use_buefy: - department_choices = [tags.Option(name, uuid) - for uuid, name in six.iteritems(department_choices)] g.set_filter('department', model.Department.uuid, value_enum=department_choices, verbs=['equal', 'not_equal', 'is_null', 'is_not_null', 'is_any'], default_active=True, default_verb='equal') - g.joiners['subdepartment'] = lambda q: q.outerjoin(model.Subdepartment, - model.Subdepartment.uuid == model.Product.subdepartment_uuid) - g.joiners['code'] = lambda q: q.outerjoin(model.ProductCode) - g.joiners['vendor'] = join_vendor - g.joiners['vendor_any'] = join_vendor_any + # subdepartment + g.set_joiner('subdepartment', lambda q: q.outerjoin( + model.Subdepartment, + model.Subdepartment.uuid == model.Product.subdepartment_uuid)) + g.set_sorter('subdepartment', model.Subdepartment.name) + g.set_filter('subdepartment', model.Subdepartment.name) - g.sorters['brand'] = g.make_sorter(model.Brand.name) - g.sorters['subdepartment'] = g.make_sorter(model.Subdepartment.name) - g.sorters['vendor'] = g.make_sorter(model.Vendor.name) + g.joiners['code'] = lambda q: q.outerjoin(model.ProductCode) + + # vendor + ProductVendorCost = orm.aliased(model.ProductCost) + def join_vendor(q): + return q.outerjoin(ProductVendorCost, + sa.and_( + ProductVendorCost.product_uuid == model.Product.uuid, + ProductVendorCost.preference == 1))\ + .outerjoin(model.Vendor) + g.set_joiner('vendor', join_vendor) + g.set_sorter('vendor', model.Vendor.name) + g.set_filter('vendor', model.Vendor.name) + + # vendor_any + ProductVendorCostAny = orm.aliased(model.ProductCost) + VendorAny = orm.aliased(model.Vendor) + def join_vendor_any(q): + return q.outerjoin(ProductVendorCostAny, + ProductVendorCostAny.product_uuid == model.Product.uuid)\ + .outerjoin(VendorAny, + VendorAny.uuid == ProductVendorCostAny.vendor_uuid) + g.set_joiner('vendor_any', join_vendor_any) + g.set_filter('vendor_any', VendorAny.name) + # factory=VendorAnyFilter, joiner=join_vendor_any) ProductTrueCost = orm.aliased(model.ProductVolatile) ProductTrueMargin = orm.aliased(model.ProductVolatile) @@ -307,22 +285,19 @@ class ProductView(MasterView): g.set_renderer('true_margin', self.render_true_margin) # on_hand - g.set_sorter('on_hand', model.ProductInventory.on_hand) - g.set_filter('on_hand', model.ProductInventory.on_hand) + InventoryOnHand = orm.aliased(model.ProductInventory) + g.set_joiner('on_hand', lambda q: q.outerjoin(InventoryOnHand)) + g.set_sorter('on_hand', InventoryOnHand.on_hand) + g.set_filter('on_hand', InventoryOnHand.on_hand) # on_order - g.set_sorter('on_order', model.ProductInventory.on_order) - g.set_filter('on_order', model.ProductInventory.on_order) + InventoryOnOrder = orm.aliased(model.ProductInventory) + g.set_sorter('on_order', InventoryOnOrder.on_order) + g.set_filter('on_order', InventoryOnOrder.on_order) g.filters['description'].default_active = True g.filters['description'].default_verb = 'contains' - g.filters['brand'] = g.make_filter('brand', model.Brand.name, - default_active=True, default_verb='contains') - g.filters['subdepartment'] = g.make_filter('subdepartment', model.Subdepartment.name) g.filters['code'] = g.make_filter('code', model.ProductCode.code) - g.filters['vendor'] = g.make_filter('vendor', model.Vendor.name) - g.filters['vendor_any'] = g.make_filter('vendor_any', self.VendorAny.name) - # factory=VendorAnyFilter, joiner=join_vendor_any) # g.joiners['vendor_code_any'] = join_vendor_code_any # g.filters['vendor_code_any'] = g.make_filter('vendor_code_any', ProductCostCodeAny.code) @@ -353,28 +328,34 @@ class ProductView(MasterView): g.set_joiner('family', lambda q: q.outerjoin(model.Family)) g.set_filter('family', model.Family.name) + # regular_price g.set_label('regular_price', "Reg. Price") + RegularPrice = orm.aliased(model.ProductPrice) g.set_joiner('regular_price', lambda q: q.outerjoin( - self.RegularPrice, self.RegularPrice.uuid == model.Product.regular_price_uuid)) - g.set_sorter('regular_price', self.RegularPrice.price) - g.set_filter('regular_price', self.RegularPrice.price, label="Regular Price") + RegularPrice, RegularPrice.uuid == model.Product.regular_price_uuid)) + g.set_sorter('regular_price', RegularPrice.price) + g.set_filter('regular_price', RegularPrice.price, label="Regular Price") + # current_price g.set_label('current_price', "Cur. Price") g.set_renderer('current_price', self.render_current_price_for_grid) + CurrentPrice = orm.aliased(model.ProductPrice) g.set_joiner('current_price', lambda q: q.outerjoin( - self.CurrentPrice, self.CurrentPrice.uuid == model.Product.current_price_uuid)) - g.set_sorter('current_price', self.CurrentPrice.price) - g.set_filter('current_price', self.CurrentPrice.price, label="Current Price") + CurrentPrice, CurrentPrice.uuid == model.Product.current_price_uuid)) + g.set_sorter('current_price', CurrentPrice.price) + g.set_filter('current_price', CurrentPrice.price, label="Current Price") # tpr_price + TPRPrice = orm.aliased(model.ProductPrice) g.set_joiner('tpr_price', lambda q: q.outerjoin( - self.TPRPrice, self.TPRPrice.uuid == model.Product.tpr_price_uuid)) - g.set_filter('tpr_price', self.TPRPrice.price) + TPRPrice, TPRPrice.uuid == model.Product.tpr_price_uuid)) + g.set_filter('tpr_price', TPRPrice.price) # sale_price + SalePrice = orm.aliased(model.ProductPrice) g.set_joiner('sale_price', lambda q: q.outerjoin( - self.SalePrice, self.SalePrice.uuid == model.Product.sale_price_uuid)) - g.set_filter('sale_price', self.SalePrice.price) + SalePrice, SalePrice.uuid == model.Product.sale_price_uuid)) + g.set_filter('sale_price', SalePrice.price) # suggested_price g.set_renderer('suggested_price', self.render_grid_suggested_price) @@ -389,17 +370,23 @@ class ProductView(MasterView): g.set_renderer('cost', self.render_cost) g.set_label('cost', "Unit Cost") + # tax + g.set_joiner('tax', lambda q: q.outerjoin(model.Tax)) + taxes = self.Session.query(model.Tax)\ + .order_by(model.Tax.code)\ + .all() + taxes = OrderedDict([(tax.uuid, tax.description) + for tax in taxes]) + g.set_filter('tax', model.Tax.uuid, value_enum=taxes) + # report_code_name g.set_joiner('report_code_name', lambda q: q.outerjoin(model.ReportCode)) g.set_filter('report_code_name', model.ReportCode.name) if self.expose_label_printing and self.has_perm('print_labels'): - if use_buefy: - g.more_actions.append(self.make_action( - 'print_label', icon='print', url='#', - click_handler='quickLabelPrint(props.row)')) - else: - g.more_actions.append(grids.GridAction('print_label', icon='print')) + g.actions.append(self.make_action( + 'print_label', icon='print', url='#', + click_handler='quickLabelPrint(props.row)')) g.set_renderer('regular_price', self.render_price) g.set_renderer('on_hand', self.render_on_hand) @@ -408,12 +395,315 @@ class ProductView(MasterView): g.set_link('item_id') g.set_link('description') - g.set_label('vendor', "Vendor (preferred)") - g.set_label('vendor_any', "Vendor (any)") - g.set_label('vendor', "Vendor (preferred)") + def render_cost(self, product, field): + cost = getattr(product, field) + if not cost: + return "" + if cost.unit_cost is None: + return "" + return "${:0.2f}".format(cost.unit_cost) - def configure_common_form(self, f): - super(ProductView, self).configure_common_form(f) + def render_price(self, product, field): + # TODO: previously this rendered null (empty string) if + # product was marked "not for sale" - but why? important? + #if not product.not_for_sale: + price = product[field] + if price: + return self.products_handler.render_price(price) + + def render_current_price_for_grid(self, product, field): + text = self.render_price(product, field) or "" + + price = product.current_price + if price: + app = self.get_rattail_app() + + if price.starts: + starts = app.localtime(price.starts, from_utc=True) + starts = app.render_date(starts.date()) + else: + starts = "??" + + if price.ends: + ends = app.localtime(price.ends, from_utc=True) + ends = app.render_date(ends.date()) + else: + ends = "??" + + return HTML.tag('span', c=text, + title="{} thru {}".format(starts, ends)) + + return text + + def add_price_history_link(self, text, typ): + if not self.rattail_config.versioning_enabled(): + return text + if not self.has_perm('versions'): + return text + + kwargs = {'@click.prevent': 'showPriceHistory_{}()'.format(typ)} + history = tags.link_to("(view history)", '#', **kwargs) + if not text: + return history + + text = HTML.tag('p', c=[text]) + history = HTML.tag('p', c=[history]) + div = HTML.tag('div', c=[text, history]) + # nb. for some reason we must wrap once more for oruga, + # otherwise it splits up the field?! + return HTML.tag('div', c=[div]) + + def show_price_effective_dates(self): + if not self.rattail_config.versioning_enabled(): + return False + return self.rattail_config.getbool( + 'tailbone', 'products.show_effective_price_dates', + default=True) + + def render_regular_price(self, product, field): + app = self.get_rattail_app() + text = self.render_price(product, field) + + if text and self.show_price_effective_dates(): + history = self.get_regular_price_history(product) + if history: + date = app.localtime(history[0]['changed'], from_utc=True).date() + text = "{} (as of {})".format(text, date) + + return self.add_price_history_link(text, 'regular') + + def render_current_price(self, product, field): + app = self.get_rattail_app() + text = self.render_price(product, field) + + if text and self.show_price_effective_dates(): + history = self.get_current_price_history(product) + if history: + date = app.localtime(history[0]['changed'], from_utc=True).date() + text = "{} (as of {})".format(text, date) + + return self.add_price_history_link(text, 'current') + + def warn_if_regprice_more_than_srp(self, product, text): + sugprice = product.suggested_price.price if product.suggested_price else None + regprice = product.regular_price.price if product.regular_price else None + if sugprice and regprice and sugprice < regprice: + return HTML.tag('span', style='color: red;', c=text) + return text + + def render_suggested_price(self, product, column): + text = self.render_price(product, column) + if not text: + return + + app = self.get_rattail_app() + if self.show_price_effective_dates(): + history = self.get_suggested_price_history(product) + if history: + date = app.localtime(history[0]['changed'], from_utc=True).date() + text = "{} (as of {})".format(text, date) + + text = self.warn_if_regprice_more_than_srp(product, text) + return self.add_price_history_link(text, 'suggested') + + def render_grid_suggested_price(self, product, field): + text = self.render_price(product, field) + if not text: + return "" + + text = self.warn_if_regprice_more_than_srp(product, text) + return text + + def render_true_cost(self, product, field): + if not product.volatile: + return "" + if product.volatile.true_cost is None: + return "" + return "${:0.3f}".format(product.volatile.true_cost) + + def render_true_margin(self, product, field): + if not product.volatile: + return "" + if product.volatile.true_margin is None: + return "" + app = self.get_rattail_app() + return app.render_percent(product.volatile.true_margin, + places=3) + + def render_on_hand(self, product, column): + inventory = product.inventory + if not inventory: + return "" + app = self.get_rattail_app() + return app.render_quantity(inventory.on_hand) + + def render_on_order(self, product, column): + inventory = product.inventory + if not inventory: + return "" + app = self.get_rattail_app() + return app.render_quantity(inventory.on_order) + + def template_kwargs_index(self, **kwargs): + kwargs = super().template_kwargs_index(**kwargs) + app = self.get_rattail_app() + label_handler = app.get_label_handler() + model = self.model + + if self.expose_label_printing: + kwargs['label_profiles'] = label_handler.get_label_profiles(self.Session()) + kwargs['quick_label_speedbump_threshold'] = self.rattail_config.getint( + 'tailbone', 'products.quick_labels.speedbump_threshold') + + return kwargs + + def grid_extra_class(self, product, i): + classes = [] + if product.not_for_sale: + classes.append('not-for-sale') + if product.discontinued: + classes.append('discontinued') + if product.deleted: + classes.append('deleted') + if classes: + return ' '.join(classes) + + def get_xlsx_fields(self): + fields = super().get_xlsx_fields() + + i = fields.index('department_uuid') + fields.insert(i + 1, 'department_number') + fields.insert(i + 2, 'department_name') + + i = fields.index('subdepartment_uuid') + fields.insert(i + 1, 'subdepartment_number') + fields.insert(i + 2, 'subdepartment_name') + + i = fields.index('category_uuid') + fields.insert(i + 1, 'category_code') + + i = fields.index('family_uuid') + fields.insert(i + 1, 'family_code') + + i = fields.index('report_code_uuid') + fields.insert(i + 1, 'report_code') + + i = fields.index('deposit_link_uuid') + fields.insert(i + 1, 'deposit_link_code') + + i = fields.index('tax_uuid') + fields.insert(i + 1, 'tax_code') + + i = fields.index('brand_uuid') + fields.insert(i + 1, 'brand_name') + + i = fields.index('suggested_price_uuid') + fields.insert(i + 1, 'suggested_price') + + i = fields.index('regular_price_uuid') + fields.insert(i + 1, 'regular_price') + + i = fields.index('current_price_uuid') + fields.insert(i + 1, 'current_price') + + fields.append('vendor_uuid') + fields.append('vendor_id') + fields.append('vendor_name') + fields.append('vendor_item_code') + fields.append('unit_cost') + fields.append('true_margin') + + return fields + + def get_xlsx_row(self, product, fields): + row = super().get_xlsx_row(product, fields) + + if 'upc' in fields and isinstance(row['upc'], GPC): + row['upc'] = row['upc'].pretty() + + if 'department_number' in fields: + row['department_number'] = product.department.number if product.department else None + if 'department_name' in fields: + row['department_name'] = product.department.name if product.department else None + + if 'subdepartment_number' in fields: + row['subdepartment_number'] = product.subdepartment.number if product.subdepartment else None + if 'subdepartment_name' in fields: + row['subdepartment_name'] = product.subdepartment.name if product.subdepartment else None + + if 'category_code' in fields: + row['category_code'] = product.category.code if product.category else None + + if 'family_code' in fields: + row['family_code'] = product.family.code if product.family else None + + if 'report_code' in fields: + row['report_code'] = product.report_code.code if product.report_code else None + + if 'deposit_link_code' in fields: + row['deposit_link_code'] = product.deposit_link.code if product.deposit_link else None + + if 'tax_code' in fields: + row['tax_code'] = product.tax.code if product.tax else None + + if 'brand_name' in fields: + row['brand_name'] = product.brand.name if product.brand else None + + if 'suggested_price' in fields: + row['suggested_price'] = product.suggested_price.price if product.suggested_price else None + + if 'regular_price' in fields: + row['regular_price'] = product.regular_price.price if product.regular_price else None + + if 'current_price' in fields: + row['current_price'] = product.current_price.price if product.current_price else None + + if 'vendor_uuid' in fields: + row['vendor_uuid'] = product.cost.vendor.uuid if product.cost else None + + if 'vendor_id' in fields: + row['vendor_id'] = product.cost.vendor.id if product.cost else None + + if 'vendor_name' in fields: + row['vendor_name'] = product.cost.vendor.name if product.cost else None + + if 'vendor_item_code' in fields: + row['vendor_item_code'] = product.cost.code if product.cost else None + + if 'unit_cost' in fields: + row['unit_cost'] = product.cost.unit_cost if product.cost else None + + if 'true_margin' in fields: + row['true_margin'] = None + if product.volatile and product.volatile.true_margin: + row['true_margin'] = product.volatile.true_margin + + return row + + def download_results_normalize(self, product, fields, **kwargs): + data = super().download_results_normalize( + product, fields, **kwargs) + + if 'upc' in data: + if isinstance(data['upc'], GPC): + data['upc'] = str(data['upc']) + + return data + + def get_instance(self): + model = self.model + key = self.request.matchdict['uuid'] + product = self.Session.get(model.Product, key) + if product: + return product + price = self.Session.get(model.ProductPrice, key) + if price: + return price.product + raise self.notfound() + + def configure_form(self, f): + super().configure_form(f) + model = self.model product = f.model_instance # unit_size @@ -421,6 +711,7 @@ class ProductView(MasterView): # unit_of_measure f.set_enum('unit_of_measure', self.enum.UNIT_OF_MEASURE) + f.set_renderer('unit_of_measure', self.render_unit_of_measure) f.set_label('unit_of_measure', "Unit of Measure") # packs @@ -544,311 +835,6 @@ class ProductView(MasterView): f.set_renderer('inventory_on_order', self.render_inventory_on_order) f.set_label('inventory_on_order', "On Order") - def render_cost(self, product, field): - cost = getattr(product, field) - if not cost: - return "" - if cost.unit_cost is None: - return "" - return "${:0.2f}".format(cost.unit_cost) - - def render_price(self, product, field): - if not product.not_for_sale: - price = product[field] - if price: - return self.products_handler.render_price(price) - - def render_current_price_for_grid(self, product, field): - text = self.render_price(product, field) or "" - - price = product.current_price - if price: - app = self.get_rattail_app() - - if price.starts: - starts = localtime(self.rattail_config, price.starts, from_utc=True) - starts = app.render_date(starts.date()) - else: - starts = "??" - - if price.ends: - ends = localtime(self.rattail_config, price.ends, from_utc=True) - ends = app.render_date(ends.date()) - else: - ends = "??" - - return HTML.tag('span', c=text, - title="{} thru {}".format(starts, ends)) - - return text - - def add_price_history_link(self, text, typ): - if not self.rattail_config.versioning_enabled(): - return text - if not self.has_perm('versions'): - return text - - if self.get_use_buefy(): - kwargs = {'@click.prevent': 'showPriceHistory_{}()'.format(typ)} - else: - kwargs = {'id': 'view-{}-price-history'.format(typ)} - history = tags.link_to("(view history)", '#', **kwargs) - if not text: - return history - - text = HTML.tag('span', c=[text]) - br = HTML.tag('br') - return HTML.tag('div', c=[text, br, history]) - - def show_price_effective_dates(self): - if not self.rattail_config.versioning_enabled(): - return False - return self.rattail_config.getbool( - 'tailbone', 'products.show_effective_price_dates', - default=True) - - def render_regular_price(self, product, field): - text = self.render_price(product, field) - - if text and self.show_price_effective_dates(): - history = self.get_regular_price_history(product) - if history: - date = localtime(self.rattail_config, history[0]['changed'], from_utc=True).date() - text = "{} (as of {})".format(text, date) - - return self.add_price_history_link(text, 'regular') - - def render_current_price(self, product, field): - text = self.render_price(product, field) - - if text and self.show_price_effective_dates(): - history = self.get_current_price_history(product) - if history: - date = localtime(self.rattail_config, history[0]['changed'], from_utc=True).date() - text = "{} (as of {})".format(text, date) - - return self.add_price_history_link(text, 'current') - - def warn_if_regprice_more_than_srp(self, product, text): - sugprice = product.suggested_price.price if product.suggested_price else None - regprice = product.regular_price.price if product.regular_price else None - if sugprice and regprice and sugprice < regprice: - return HTML.tag('span', style='color: red;', c=text) - return text - - def render_suggested_price(self, product, column): - text = self.render_price(product, column) - if not text: - return - - if self.show_price_effective_dates(): - history = self.get_suggested_price_history(product) - if history: - date = localtime(self.rattail_config, history[0]['changed'], from_utc=True).date() - text = "{} (as of {})".format(text, date) - - text = self.warn_if_regprice_more_than_srp(product, text) - return self.add_price_history_link(text, 'suggested') - - def render_grid_suggested_price(self, product, field): - text = self.render_price(product, field) - if not text: - return "" - - text = self.warn_if_regprice_more_than_srp(product, text) - return text - - def render_true_cost(self, product, field): - if not product.volatile: - return "" - if product.volatile.true_cost is None: - return "" - return "${:0.3f}".format(product.volatile.true_cost) - - def render_true_margin(self, product, field): - if not product.volatile: - return "" - if product.volatile.true_margin is None: - return "" - app = self.get_rattail_app() - return app.render_percent(product.volatile.true_margin, - places=3) - - def render_on_hand(self, product, column): - inventory = product.inventory - if not inventory: - return "" - return pretty_quantity(inventory.on_hand) - - def render_on_order(self, product, column): - inventory = product.inventory - if not inventory: - return "" - return pretty_quantity(inventory.on_order) - - def template_kwargs_index(self, **kwargs): - kwargs = super(ProductView, self).template_kwargs_index(**kwargs) - model = self.model - - if self.expose_label_printing: - - kwargs['label_profiles'] = self.Session.query(model.LabelProfile)\ - .filter(model.LabelProfile.visible == True)\ - .order_by(model.LabelProfile.ordinal)\ - .all() - - kwargs['quick_label_speedbump_threshold'] = self.rattail_config.getint( - 'tailbone', 'products.quick_labels.speedbump_threshold') - - return kwargs - - def grid_extra_class(self, product, i): - classes = [] - if product.not_for_sale: - classes.append('not-for-sale') - if product.discontinued: - classes.append('discontinued') - if product.deleted: - classes.append('deleted') - if classes: - return ' '.join(classes) - - def get_xlsx_fields(self): - fields = super(ProductView, self).get_xlsx_fields() - - i = fields.index('department_uuid') - fields.insert(i + 1, 'department_number') - fields.insert(i + 2, 'department_name') - - i = fields.index('subdepartment_uuid') - fields.insert(i + 1, 'subdepartment_number') - fields.insert(i + 2, 'subdepartment_name') - - i = fields.index('category_uuid') - fields.insert(i + 1, 'category_code') - - i = fields.index('family_uuid') - fields.insert(i + 1, 'family_code') - - i = fields.index('report_code_uuid') - fields.insert(i + 1, 'report_code') - - i = fields.index('deposit_link_uuid') - fields.insert(i + 1, 'deposit_link_code') - - i = fields.index('tax_uuid') - fields.insert(i + 1, 'tax_code') - - i = fields.index('brand_uuid') - fields.insert(i + 1, 'brand_name') - - i = fields.index('suggested_price_uuid') - fields.insert(i + 1, 'suggested_price') - - i = fields.index('regular_price_uuid') - fields.insert(i + 1, 'regular_price') - - i = fields.index('current_price_uuid') - fields.insert(i + 1, 'current_price') - - fields.append('vendor_uuid') - fields.append('vendor_id') - fields.append('vendor_name') - fields.append('vendor_item_code') - fields.append('unit_cost') - fields.append('true_margin') - - return fields - - def get_xlsx_row(self, product, fields): - row = super(ProductView, self).get_xlsx_row(product, fields) - - if 'upc' in fields and isinstance(row['upc'], GPC): - row['upc'] = row['upc'].pretty() - - if 'department_number' in fields: - row['department_number'] = product.department.number if product.department else None - if 'department_name' in fields: - row['department_name'] = product.department.name if product.department else None - - if 'subdepartment_number' in fields: - row['subdepartment_number'] = product.subdepartment.number if product.subdepartment else None - if 'subdepartment_name' in fields: - row['subdepartment_name'] = product.subdepartment.name if product.subdepartment else None - - if 'category_code' in fields: - row['category_code'] = product.category.code if product.category else None - - if 'family_code' in fields: - row['family_code'] = product.family.code if product.family else None - - if 'report_code' in fields: - row['report_code'] = product.report_code.code if product.report_code else None - - if 'deposit_link_code' in fields: - row['deposit_link_code'] = product.deposit_link.code if product.deposit_link else None - - if 'tax_code' in fields: - row['tax_code'] = product.tax.code if product.tax else None - - if 'brand_name' in fields: - row['brand_name'] = product.brand.name if product.brand else None - - if 'suggested_price' in fields: - row['suggested_price'] = product.suggested_price.price if product.suggested_price else None - - if 'regular_price' in fields: - row['regular_price'] = product.regular_price.price if product.regular_price else None - - if 'current_price' in fields: - row['current_price'] = product.current_price.price if product.current_price else None - - if 'vendor_uuid' in fields: - row['vendor_uuid'] = product.cost.vendor.uuid if product.cost else None - - if 'vendor_id' in fields: - row['vendor_id'] = product.cost.vendor.id if product.cost else None - - if 'vendor_name' in fields: - row['vendor_name'] = product.cost.vendor.name if product.cost else None - - if 'vendor_item_code' in fields: - row['vendor_item_code'] = product.cost.code if product.cost else None - - if 'unit_cost' in fields: - row['unit_cost'] = product.cost.unit_cost if product.cost else None - - if 'true_margin' in fields: - row['true_margin'] = None - if product.volatile and product.volatile.true_margin: - row['true_margin'] = product.volatile.true_margin - - return row - - def download_results_normalize(self, product, fields, **kwargs): - data = super(ProductView, self).download_results_normalize( - product, fields, **kwargs) - - if 'upc' in data: - if isinstance(data['upc'], GPC): - data['upc'] = six.text_type(data['upc']) - - return data - - def get_instance(self): - key = self.request.matchdict['uuid'] - product = self.Session.query(model.Product).get(key) - if product: - return product - price = self.Session.query(model.ProductPrice).get(key) - if price: - return price.product - raise httpexceptions.HTTPNotFound() - - def configure_form(self, f): - super(ProductView, self).configure_form(f) - product = f.model_instance - # department if self.creating or self.editing: if 'department' in f.fields: @@ -970,7 +956,7 @@ class ProductView(MasterView): f.set_label('tax_uuid', "Tax") else: f.set_readonly('tax') - # f.set_renderer('tax', self.render_tax) + f.set_renderer('tax', self.render_tax) # tax1/2/3 f.set_readonly('tax1') @@ -985,11 +971,11 @@ class ProductView(MasterView): brand_display = "" if self.request.method == 'POST': if self.request.POST.get('brand_uuid'): - brand = self.Session.query(model.Brand).get(self.request.POST['brand_uuid']) + brand = self.Session.get(model.Brand, self.request.POST['brand_uuid']) if brand: - brand_display = six.text_type(brand) + brand_display = str(brand) elif self.editing: - brand_display = six.text_type(product.brand or '') + brand_display = str(product.brand or '') brands_url = self.request.route_url('brands.autocomplete') f.set_widget('brand_uuid', forms.widgets.JQueryAutocompleteWidget( field_display=brand_display, service_url=brands_url)) @@ -1015,7 +1001,7 @@ class ProductView(MasterView): def objectify(self, form, data=None): if data is None: data = form.validated - product = super(ProductView, self).objectify(form, data=data) + product = super().objectify(form, data=data) # regular_price_amount if (self.creating or self.editing) and 'regular_price_amount' in form.fields: @@ -1023,6 +1009,14 @@ class ProductView(MasterView): return product + def render_unit_of_measure(self, product, field): + uom = getattr(product, field) + if uom is None: + return + if uom == self.enum.UNIT_OF_MEASURE_NONE: + return + return self.enum.UNIT_OF_MEASURE.get(uom, uom) + def render_department(self, product, field): department = product.department if not department: @@ -1123,7 +1117,8 @@ class ProductView(MasterView): value = product.inventory.on_hand if not value: return "" - return pretty_quantity(value) + app = self.get_rattail_app() + return app.render_quantity(value) def render_inventory_on_order(self, product, field): if not product.inventory: @@ -1131,7 +1126,8 @@ class ProductView(MasterView): value = product.inventory.on_order if not value: return "" - return pretty_quantity(value) + app = self.get_rattail_app() + return app.render_quantity(value) def price_history(self): """ @@ -1154,12 +1150,12 @@ class ProductView(MasterView): if price is not None: history['price'] = float(price) history['price_display'] = app.render_currency(price) - changed = localtime(self.rattail_config, history['changed'], from_utc=True) - history['changed'] = six.text_type(changed) + changed = app.localtime(history['changed'], from_utc=True) + history['changed'] = str(changed) history['changed_display_html'] = raw_datetime(self.rattail_config, changed) user = history.pop('changed_by') history['changed_by_uuid'] = user.uuid if user else None - history['changed_by_display'] = six.text_type(user or "??") + history['changed_by_display'] = str(user or "??") jsdata.append(history) return jsdata @@ -1167,6 +1163,7 @@ class ProductView(MasterView): """ AJAX view for fetching cost history for a product. """ + app = self.get_rattail_app() product = self.get_instance() data = self.get_cost_history(product) @@ -1180,18 +1177,18 @@ class ProductView(MasterView): history['cost_display'] = "${:0.2f}".format(cost) else: history['cost_display'] = None - changed = localtime(self.rattail_config, history['changed'], from_utc=True) - history['changed'] = six.text_type(changed) + changed = app.localtime(history['changed'], from_utc=True) + history['changed'] = str(changed) history['changed_display_html'] = raw_datetime(self.rattail_config, changed) user = history.pop('changed_by') history['changed_by_uuid'] = user.uuid - history['changed_by_display'] = six.text_type(user) + history['changed_by_display'] = str(user) jsdata.append(history) return jsdata def template_kwargs_view(self, **kwargs): + kwargs = super().template_kwargs_view(**kwargs) product = kwargs['instance'] - use_buefy = self.get_use_buefy() kwargs['image_url'] = self.products_handler.get_image_url(product) @@ -1199,12 +1196,10 @@ class ProductView(MasterView): if self.rattail_config.versioning_enabled() and self.has_perm('versions'): # regular price - if use_buefy: - data = [] # defer fetching until user asks for it - else: - data = self.get_regular_price_history(product) - grid = grids.Grid('products.regular_price_history', data, - request=self.request, + data = [] # defer fetching until user asks for it + grid = grids.Grid(self.request, + key='products.regular_price_history', + data=data, columns=[ 'price', 'since', @@ -1216,12 +1211,10 @@ class ProductView(MasterView): kwargs['regular_price_history_grid'] = grid # current price - if use_buefy: - data = [] # defer fetching until user asks for it - else: - data = self.get_current_price_history(product) - grid = grids.Grid('products.current_price_history', data, - request=self.request, + data = [] # defer fetching until user asks for it + grid = grids.Grid(self.request, + key='products.current_price_history', + data=data, columns=[ 'price', 'price_type', @@ -1237,12 +1230,10 @@ class ProductView(MasterView): kwargs['current_price_history_grid'] = grid # suggested price - if use_buefy: - data = [] # defer fetching until user asks for it - else: - data = self.get_suggested_price_history(product) - grid = grids.Grid('products.suggested_price_history', data, - request=self.request, + data = [] # defer fetching until user asks for it + grid = grids.Grid(self.request, + key='products.suggested_price_history', + data=data, columns=[ 'price', 'since', @@ -1254,12 +1245,10 @@ class ProductView(MasterView): kwargs['suggested_price_history_grid'] = grid # cost history - if use_buefy: - data = [] # defer fetching until user asks for it - else: - data = self.get_cost_history(product) - grid = grids.Grid('products.cost_history', data, - request=self.request, + data = [] # defer fetching until user asks for it + grid = grids.Grid(self.request, + key='products.cost_history', + data=data, columns=[ 'cost', 'vendor', @@ -1279,29 +1268,81 @@ class ProductView(MasterView): kwargs['costs_label_code'] = "Order Code" kwargs['costs_label_case_size'] = "Case Size" - if use_buefy: - kwargs['vendor_sources'] = self.get_context_vendor_sources(product) - kwargs['lookup_codes'] = self.get_context_lookup_codes(product) + kwargs['vendor_sources'] = self.get_context_vendor_sources(product) + kwargs['lookup_codes'] = self.get_context_lookup_codes(product) + + kwargs['panel_fields'] = self.get_panel_fields(product) return kwargs + def get_panel_fields(self, product): + return { + 'main': self.get_panel_fields_main(product), + 'flag': self.get_panel_fields_flag(product), + } + + def get_panel_fields_main(self, product): + product_key_field = self.get_product_key_field() + fields = [ + product_key_field, + 'brand', + 'description', + 'size', + 'unit_size', + 'unit_of_measure', + 'average_weight', + 'case_size', + ] + if product.is_pack_item(): + fields.extend([ + 'pack_size', + 'unit', + 'default_pack', + ]) + elif product.packs: + fields.append('packs') + + for supp in self.iter_view_supplements(): + if hasattr(supp, 'get_panel_fields_main'): + fields.extend(supp.get_panel_fields_main(product)) + + return fields + + def get_panel_fields_flag(self, product): + return [ + 'weighed', + 'discountable', + 'special_order', + 'organic', + 'not_for_sale', + 'discontinued', + 'deleted', + ] + def get_context_vendor_sources(self, product): app = self.get_rattail_app() route_prefix = self.get_route_prefix() + units_only = self.products_handler.units_only() + + columns = [ + 'preferred', + 'vendor', + 'vendor_item_code', + 'case_size', + 'case_cost', + 'unit_cost', + 'status', + ] + if units_only: + columns.remove('case_size') + columns.remove('case_cost') factory = self.get_grid_factory() g = factory( - key='{}.vendor_sources'.format(route_prefix), + self.request, + key=f'{route_prefix}.vendor_sources', data=[], - columns=[ - 'preferred', - 'vendor', - 'vendor_item_code', - 'case_size', - 'case_cost', - 'unit_cost', - 'status', - ], + columns=columns, labels={ 'preferred': "Pref.", 'vendor_item_code': "Order Code", @@ -1316,13 +1357,15 @@ class ProductView(MasterView): 'uuid': cost.uuid, 'preferred': "X" if cost.preference == 1 else None, 'vendor_item_code': cost.code, - 'case_size': app.render_quantity(cost.case_size), - 'case_cost': app.render_currency(cost.case_cost), 'unit_cost': app.render_currency(cost.unit_cost, scale=4), 'status': "discontinued" if cost.discontinued else "available", } - text = six.text_type(cost.vendor) + if not units_only: + source['case_size'] = app.render_quantity(cost.case_size) + source['case_cost'] = app.render_currency(cost.case_cost) + + text = str(cost.vendor) if link_vendor: url = self.request.route_url('vendors.view', uuid=cost.vendor.uuid) source['vendor'] = tags.link_to(text, url) @@ -1338,7 +1381,8 @@ class ProductView(MasterView): factory = self.get_grid_factory() g = factory( - key='{}.lookup_codes'.format(route_prefix), + self.request, + key=f'{route_prefix}.lookup_codes', data=[], columns=[ 'sequence', @@ -1365,10 +1409,12 @@ class ProductView(MasterView): Returns a sequence of "records" which corresponds to the given product's regular price history. """ + app = self.get_rattail_app() + model = self.model Transaction = continuum.transaction_class(model.Product) ProductVersion = continuum.version_class(model.Product) ProductPriceVersion = continuum.version_class(model.ProductPrice) - now = make_utc() + now = app.make_utc() history = [] # first we find all relevant ProductVersion records @@ -1434,10 +1480,12 @@ class ProductView(MasterView): Returns a sequence of "records" which corresponds to the given product's current price history. """ + app = self.get_rattail_app() + model = self.model Transaction = continuum.transaction_class(model.Product) ProductVersion = continuum.version_class(model.Product) ProductPriceVersion = continuum.version_class(model.ProductPrice) - now = make_utc() + now = app.make_utc() history = [] # first we find all relevant ProductVersion records @@ -1576,10 +1624,12 @@ class ProductView(MasterView): Returns a sequence of "records" which corresponds to the given product's SRP history. """ + app = self.get_rattail_app() + model = self.model Transaction = continuum.transaction_class(model.Product) ProductVersion = continuum.version_class(model.Product) ProductPriceVersion = continuum.version_class(model.ProductPrice) - now = make_utc() + now = app.make_utc() history = [] # first we find all relevant ProductVersion records @@ -1645,10 +1695,12 @@ class ProductView(MasterView): Returns a sequence of "records" which corresponds to the given product's cost history. """ + app = self.get_rattail_app() + model = self.model Transaction = continuum.transaction_class(model.Product) ProductVersion = continuum.version_class(model.Product) ProductCostVersion = continuum.version_class(model.ProductCost) - now = make_utc() + now = app.make_utc() history = [] # we just find all relevant (preferred!) ProductCostVersion records @@ -1712,6 +1764,7 @@ class ProductView(MasterView): 'form': form}) def get_version_child_classes(self): + model = self.model return [ (model.ProductCode, 'product_uuid'), (model.ProductCost, 'product_uuid'), @@ -1724,10 +1777,11 @@ class ProductView(MasterView): """ product = self.get_instance() if not product.image: - raise httpexceptions.HTTPNotFound() + raise self.notfound() # TODO: how to properly detect image type? - # self.request.response.content_type = six.binary_type('image/png') - self.request.response.content_type = six.binary_type('image/jpeg') + # content_type = 'image/png' + content_type = 'image/jpeg' + self.request.response.content_type = content_type self.request.response.body = product.image.bytes return self.request.response @@ -1737,12 +1791,12 @@ class ProductView(MasterView): model = self.model profile = self.request.params.get('profile') - profile = self.Session.query(model.LabelProfile).get(profile) if profile else None + profile = self.Session.get(model.LabelProfile, profile) if profile else None if not profile: return {'error': "Label profile not found"} product = self.request.params.get('product') - product = self.Session.query(model.Product).get(product) if product else None + product = self.Session.get(model.Product, product) if product else None if not product: return {'error': "Product not found"} @@ -1759,14 +1813,14 @@ class ProductView(MasterView): printer.print_labels([({'product': product}, quantity)]) except Exception as error: log.warning("error occurred while printing labels", exc_info=True) - return {'error': six.text_type(error)} + return {'error': str(error)} return {'ok': True} def search(self): """ Perform a product search across multiple fields, and return - the results as JSON suitable for row data for a Buefy - ``<b-table>`` component. + the results as JSON suitable for row data for a table + component. """ if 'term' not in self.request.GET: # TODO: deprecate / remove this? not sure if/where it is used @@ -1803,7 +1857,8 @@ class ProductView(MasterView): lookup_fields.append('alt_code') if lookup_fields: product = self.products_handler.locate_product_for_entry( - session, term, lookup_fields=lookup_fields) + session, term, lookup_fields=lookup_fields, + first_if_multiple=True) if product: final_results.append(self.search_normalize_result(product)) @@ -1858,9 +1913,11 @@ class ProductView(MasterView): 'case_price', 'case_price_display', 'uom_choices', + 'organic', ]) # TODO: deprecate / remove this? not sure if/where it is used + # (hm, still used by the old Instacart -> Configure page..) def search_v1(self): """ Locate a product(s) by UPC. @@ -1868,6 +1925,7 @@ class ProductView(MasterView): Eventually this should be more generic, or at least offer more fields for search. For now it operates only on the ``Product.upc`` field. """ + model = self.model data = None upc = self.request.GET.get('upc', '').strip() upc = re.sub(r'\D', '', upc) @@ -1880,14 +1938,15 @@ class ProductView(MasterView): if product and (not product.deleted or self.request.has_perm('products.view_deleted')): data = { 'uuid': product.uuid, - 'upc': six.text_type(product.upc), + 'upc': str(product.upc), 'upc_pretty': product.upc.pretty(), 'full_description': product.full_description, - 'image_url': pod.get_image_url(self.rattail_config, product.upc), + 'image_url': pod.get_image_url(self.rattail_config, product.upc, + require=False), } uuid = self.request.GET.get('with_vendor_cost') if uuid: - vendor = self.Session.query(model.Vendor).get(uuid) + vendor = self.Session.get(model.Vendor, uuid) if not vendor: return {'error': "Vendor not found"} cost = product.cost_for_vendor(vendor) @@ -1922,10 +1981,11 @@ class ProductView(MasterView): """ View for making a new batch from current product grid query. """ + app = self.get_rattail_app() supported = self.get_supported_batches() batch_options = [] for key, info in list(supported.items()): - handler = load_object(info['spec'])(self.rattail_config) + handler = app.load_object(info['spec'])(self.rattail_config) handler.spec = info['spec'] handler.option_key = key handler.option_title = info.get('title', handler.get_model_title()) @@ -1957,7 +2017,7 @@ class ProductView(MasterView): params_forms[key] = forms.Form(schema=schema, request=self.request) if self.request.method == 'POST': - if form.validate(newstyle=True): + if form.validate(): data = form.validated fully_validated = True @@ -1970,7 +2030,7 @@ class ProductView(MasterView): # collect batch-type-specific params pform = params_forms.get(batch_key) if pform: - if pform.validate(newstyle=True): + if pform.validate(): pdata = pform.validated for field in pform.schema: param_name = pform.schema[field.name].param_name @@ -2053,8 +2113,9 @@ class ProductView(MasterView): """ Threat target for making a batch from current products query. """ + model = self.model session = RattailSession() - user = session.query(model.User).get(user_uuid) + user = session.get(model.User, user_uuid) assert user params['created_by'] = user try: @@ -2105,11 +2166,16 @@ class ProductView(MasterView): {'section': 'tailbone', 'option': 'products.show_pod_image', 'type': bool}, + {'section': 'rattail.pod', + 'option': 'pictures.gtin.root_url'}, # handling {'section': 'rattail', 'option': 'products.convert_type2_for_gpc_lookup', 'type': bool}, + {'section': 'rattail', + 'option': 'products.units_only', + 'type': bool}, # labels {'section': 'tailbone', @@ -2188,7 +2254,7 @@ class PendingProductView(MasterView): """ Master view for the Pending Product class. """ - model_class = model.PendingProduct + model_class = PendingProduct route_prefix = 'pending_products' url_prefix = '/products/pending' bulk_deletable = True @@ -2212,6 +2278,7 @@ class PendingProductView(MasterView): form_fields = [ '_product_key_', + 'product', 'brand_name', 'brand', 'description', @@ -2220,33 +2287,76 @@ class PendingProductView(MasterView): 'department', 'vendor_name', 'vendor', + 'vendor_item_code', 'unit_cost', 'case_size', 'regular_price_amount', 'special_order', 'notes', + 'status_code', 'created', 'user', + 'resolved', + 'resolved_by', + ] + + has_rows = True + model_row_class = CustomerOrderItem + rows_title = "Customer Orders" + # TODO: add support for this someday + rows_viewable = False + + row_labels = { + 'order_id': "Order ID", + 'product_brand': "Brand", + 'product_description': "Description", + 'product_size': "Size", + 'order_created': "Ordered", + 'status_code': "Status", + } + + row_grid_columns = [ + 'order_id', + 'customer', + 'person', + '_product_key_', + 'product_brand', + 'product_description', + 'product_size', + 'order_quantity', + 'total_price', + 'order_created', 'status_code', + 'flagged', ] def configure_grid(self, g): - super(PendingProductView, self).configure_grid(g) - - g.set_enum('status_code', self.enum.PENDING_PRODUCT_STATUS) - g.filters['status_code'].default_active = True - g.filters['status_code'].default_verb = 'not_equal' - g.filters['status_code'].default_value = six.text_type(self.enum.PENDING_PRODUCT_STATUS_RESOLVED) - - g.set_sort_defaults('created', 'desc') + super().configure_grid(g) + model = self.model + # description g.set_link('description') + # status_code + g.set_enum('status_code', self.enum.PENDING_PRODUCT_STATUS) + g.set_filter('status_code', model.PendingProduct.status_code, + value_enum=self.enum.PENDING_PRODUCT_STATUS, + default_active=True, + default_verb='equal', + default_value=str(self.enum.PENDING_PRODUCT_STATUS_PENDING)) + + # created + g.set_sort_defaults('created', 'desc') + def configure_form(self, f): - super(PendingProductView, self).configure_form(f) + super().configure_form(f) model = self.model pending = f.model_instance + # product + f.set_readonly('product') # TODO + f.set_renderer('product', self.render_product) + # department if self.creating or self.editing: if 'department' in f: @@ -2269,11 +2379,11 @@ class PendingProductView(MasterView): brand_display = "" if self.request.method == 'POST': if self.request.POST.get('brand_uuid'): - brand = self.Session.query(model.Brand).get(self.request.POST['brand_uuid']) + brand = self.Session.get(model.Brand, self.request.POST['brand_uuid']) if brand: - brand_display = six.text_type(brand) + brand_display = str(brand) elif self.editing: - brand_display = six.text_type(pending.brand or '') + brand_display = str(pending.brand or '') brands_url = self.request.route_url('brands.autocomplete') f.set_widget('brand_uuid', forms.widgets.JQueryAutocompleteWidget( field_display=brand_display, service_url=brands_url)) @@ -2296,9 +2406,9 @@ class PendingProductView(MasterView): vendor_display = "" if self.request.method == 'POST': if self.request.POST.get('vendor_uuid'): - vendor = self.Session.query(model.Vendor).get(self.request.POST['vendor_uuid']) + vendor = self.Session.get(model.Vendor, self.request.POST['vendor_uuid']) if vendor: - vendor_display = six.text_type(vendor) + vendor_display = str(vendor) f.set_widget('vendor_uuid', forms.widgets.JQueryAutocompleteWidget( field_display=vendor_display, service_url=self.request.route_url('vendors.autocomplete'))) @@ -2325,6 +2435,25 @@ class PendingProductView(MasterView): else: f.set_readonly('created') + # status_code + if self.creating: + f.remove('status_code') + else: + f.set_enum('status_code', self.enum.PENDING_PRODUCT_STATUS) + if self.viewing: + f.set_renderer('status_code', self.render_status_code) + + if (self.has_perm('ignore_product') + and pending.status_code in (self.enum.PENDING_PRODUCT_STATUS_PENDING, + self.enum.PENDING_PRODUCT_STATUS_READY)): + f.set_vuejs_component_kwargs(**{'@ignore-product': 'ignoreProductInit'}) + + if (self.has_perm('resolve_product') + and pending.status_code in (self.enum.PENDING_PRODUCT_STATUS_PENDING, + self.enum.PENDING_PRODUCT_STATUS_READY, + self.enum.PENDING_PRODUCT_STATUS_IGNORED)): + f.set_vuejs_component_kwargs(**{'@resolve-product': 'resolveProductInit'}) + # user if self.creating: f.remove('user') @@ -2332,14 +2461,53 @@ class PendingProductView(MasterView): f.set_readonly('user') f.set_renderer('user', self.render_user) - # status_code + # resolved* if self.creating: - f.remove('status_code') + f.remove('resolved', 'resolved_by') + elif pending.resolved: + f.set_renderer('resolved_by', self.render_user) else: - # f.set_readonly('status_code') - f.set_enum('status_code', self.enum.PENDING_PRODUCT_STATUS) + f.remove('resolved', 'resolved_by') + + def render_status_code(self, pending, field): + status = pending.status_code + if not status: + return + + # will just show status text by default + text = self.enum.PENDING_PRODUCT_STATUS.get(status, str(status)) + html = text + + # but maybe also show buttons to change status + buttons = [] + + if (self.has_perm('ignore_product') + and status in (self.enum.PENDING_PRODUCT_STATUS_PENDING, + self.enum.PENDING_PRODUCT_STATUS_READY)): + buttons.append(self.make_button("Ignore Product", + type='is-warning', + icon_left='ban', + **{'@click': "$emit('ignore-product')"})) + + if (self.has_perm('resolve_product') + and status in (self.enum.PENDING_PRODUCT_STATUS_PENDING, + self.enum.PENDING_PRODUCT_STATUS_READY, + self.enum.PENDING_PRODUCT_STATUS_IGNORED)): + buttons.append(self.make_button("Resolve Product", + is_primary=True, + icon_left='object-ungroup', + **{'@click': "$emit('resolve-product')"})) + + if buttons: + text = HTML.tag('span', class_='control', c=[text]) + buttons = HTML.tag('div', class_='buttons', c=buttons) + html = HTML.tag('b-field', grouped='grouped', c=[text, buttons]) + + return html def editable_instance(self, pending): + if self.request.is_root: + return True if pending.status_code == self.enum.PENDING_PRODUCT_STATUS_RESOLVED: return False return True @@ -2348,7 +2516,7 @@ class PendingProductView(MasterView): if data is None: data = form.validated - pending = super(PendingProductView, self).objectify(form, data) + pending = super().objectify(form, data) if not pending.user: pending.user = self.request.user @@ -2395,16 +2563,77 @@ class PendingProductView(MasterView): redirect = self.redirect(self.get_action_url('view', pending)) uuid = self.request.POST['product_uuid'] - product = self.Session.query(model.Product).get(uuid) + product = self.Session.get(model.Product, uuid) if not product: self.request.session.flash("Product not found!", 'error') return redirect app = self.get_rattail_app() products_handler = app.get_products_handler() - products_handler.resolve_product(pending, product, self.request.user) + kwargs = self.get_resolve_product_kwargs() + + try: + products_handler.resolve_product(pending, product, self.request.user, **kwargs) + except Exception as error: + log.warning("failed to resolve product", exc_info=True) + self.request.session.flash(f"Resolve failed: {simple_error(error)}", 'error') + return redirect + return redirect + def get_resolve_product_kwargs(self, **kwargs): + return kwargs + + def ignore_product(self): + model = self.model + pending = self.get_instance() + pending.status_code = self.enum.PENDING_PRODUCT_STATUS_IGNORED + return self.redirect(self.get_action_url('view', pending)) + + def get_row_data(self, pending): + model = self.model + return self.Session.query(model.CustomerOrderItem)\ + .filter(model.CustomerOrderItem.pending_product == pending) + + def get_parent(self, item): + return item.pending_product + + def configure_row_grid(self, g): + super().configure_row_grid(g) + app = self.get_rattail_app() + + # order_id + g.set_renderer('order_id', lambda item, field: item.order.id) + + # contact + handler = app.get_batch_handler('custorder') + if handler.new_order_requires_customer(): + g.remove('person') + g.set_renderer('customer', lambda item, field: item.order.customer) + else: + g.remove('customer') + g.set_renderer('person', lambda item, field: item.order.person) + + # product_key + field = self.get_product_key_field() + if not self.rows_viewable: + g.set_link(field, False) + g.set_renderer(field, lambda item, field: getattr(item, f'product_{field}')) + + # "numbers" + g.set_type('order_quantity', 'quantity') + g.set_type('total_price', 'currency') + + # order_created + g.set_renderer('order_created', + lambda item, field: raw_datetime(self.rattail_config, + app.localtime(item.order.created, + from_utc=True), + as_date=True)) + + # status_code + g.set_enum('status_code', self.enum.CUSTORDER_ITEM_STATUS) + @classmethod def defaults(cls, config): cls._defaults(config) @@ -2428,6 +2657,89 @@ class PendingProductView(MasterView): route_name='{}.resolve_product'.format(route_prefix), permission='{}.resolve_product'.format(permission_prefix)) + # ignore product + config.add_tailbone_permission(permission_prefix, + f'{permission_prefix}.ignore_product', + f"Mark {model_title} as ignored") + config.add_route(f'{route_prefix}.ignore_product', + f'{instance_url_prefix}/ignore-product', + request_method='POST') + config.add_view(cls, attr='ignore_product', + route_name=f'{route_prefix}.ignore_product', + permission=f'{permission_prefix}.ignore_product') + + +class ProductCostView(MasterView): + """ + Master view for Product Costs + """ + model_class = ProductCost + route_prefix = 'product_costs' + url_prefix = '/products/costs' + has_versions = True + + grid_columns = [ + '_product_key_', + 'vendor', + 'preference', + 'code', + 'case_size', + 'case_cost', + 'pack_size', + 'pack_cost', + 'unit_cost', + ] + + def query(self, session): + """ """ + query = super().query(session) + model = self.app.model + + # always join on Product + return query.join(model.Product) + + def configure_grid(self, g): + """ """ + super().configure_grid(g) + model = self.app.model + + # product key + field = self.get_product_key_field() + g.set_renderer(field, self.render_product_key) + g.set_sorter(field, getattr(model.Product, field)) + g.set_sort_defaults(field) + g.set_filter(field, getattr(model.Product, field)) + + # vendor + g.set_joiner('vendor', lambda q: q.join(model.Vendor)) + g.set_sorter('vendor', model.Vendor.name) + g.set_filter('vendor', model.Vendor.name, label="Vendor Name") + + def render_product_key(self, cost, field): + """ """ + handler = self.app.get_products_handler() + return handler.render_product_key(cost.product) + + def configure_form(self, f): + """ """ + super().configure_form(f) + + # product + f.set_renderer('product', self.render_product) + if 'product_uuid' in f and 'product' in f: + f.remove('product') + f.replace('product_uuid', 'product') + + # vendor + f.set_renderer('vendor', self.render_vendor) + if 'vendor_uuid' in f and 'vendor' in f: + f.remove('vendor') + f.replace('vendor_uuid', 'vendor') + + # futures + # TODO: should eventually show a subgrid here? + f.remove('futures') + def defaults(config, **kwargs): base = globals() @@ -2438,6 +2750,9 @@ def defaults(config, **kwargs): PendingProductView = kwargs.get('PendingProductView', base['PendingProductView']) PendingProductView.defaults(config) + ProductCostView = kwargs.get('ProductCostView', base['ProductCostView']) + ProductCostView.defaults(config) + def includeme(config): defaults(config) diff --git a/tailbone/views/progress.py b/tailbone/views/progress.py index 169f324e..3f47ba3e 100644 --- a/tailbone/views/progress.py +++ b/tailbone/views/progress.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,10 +24,6 @@ Progress Views """ -from __future__ import unicode_literals, absolute_import - -import six - from tailbone.progress import get_progress_session @@ -44,7 +40,7 @@ def progress(request): bits = session.get('extra_session_bits') if bits: - for key, value in six.iteritems(bits): + for key, value in bits.items(): request.session[key] = value elif session.get('error'): diff --git a/tailbone/views/projects.py b/tailbone/views/projects.py index 9a6633f4..bcc4cb5d 100644 --- a/tailbone/views/projects.py +++ b/tailbone/views/projects.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,210 +24,428 @@ Project views """ -from __future__ import unicode_literals, absolute_import - -import os -import zipfile -# from collections import OrderedDict +from collections import OrderedDict import colander +from deform import widget as dfwidget + +from rattail.projects import (PythonProjectGenerator, + PoserProjectGenerator, + RattailAdjacentProjectGenerator) from tailbone import forms -from tailbone.views import View +from tailbone.views import MasterView -class GenerateProject(colander.MappingSchema): - """ - Base schema for the "generate project" form - """ - name = colander.SchemaNode(colander.String()) - - slug = colander.SchemaNode(colander.String()) - - organization = colander.SchemaNode(colander.String()) - - python_project_name = colander.SchemaNode(colander.String()) - - python_name = colander.SchemaNode(colander.String()) - - has_db = colander.SchemaNode(colander.Boolean()) - - extends_db = colander.SchemaNode(colander.Boolean()) - - has_batch_schema = colander.SchemaNode(colander.Boolean()) - - has_web = colander.SchemaNode(colander.Boolean()) - - has_web_api = colander.SchemaNode(colander.Boolean()) - - has_datasync = colander.SchemaNode(colander.Boolean()) - - # has_filemon = colander.SchemaNode(colander.Boolean()) - - # has_tempmon = colander.SchemaNode(colander.Boolean()) - - # has_bouncer = colander.SchemaNode(colander.Boolean()) - - integrates_catapult = colander.SchemaNode(colander.Boolean()) - - integrates_corepos = colander.SchemaNode(colander.Boolean()) - - # integrates_instacart = colander.SchemaNode(colander.Boolean()) - - integrates_locsms = colander.SchemaNode(colander.Boolean()) - - # integrates_mailchimp = colander.SchemaNode(colander.Boolean()) - - uses_fabric = colander.SchemaNode(colander.Boolean()) - - -class GenerateRattailIntegrationProject(colander.MappingSchema): - """ - Schema to generate new rattail-integration project - """ - integration_name = colander.SchemaNode(colander.String()) - - integration_url = colander.SchemaNode(colander.String()) - - slug = colander.SchemaNode(colander.String()) - - python_project_name = colander.SchemaNode(colander.String()) - - python_name = colander.SchemaNode(colander.String()) - - extends_config = colander.SchemaNode(colander.Boolean()) - - extends_db = colander.SchemaNode(colander.Boolean()) - - -class GenerateTailboneIntegrationProject(colander.MappingSchema): - """ - Schema to generate new tailbone-integration project - """ - integration_name = colander.SchemaNode(colander.String()) - - integration_url = colander.SchemaNode(colander.String()) - - slug = colander.SchemaNode(colander.String()) - - python_project_name = colander.SchemaNode(colander.String()) - - python_name = colander.SchemaNode(colander.String()) - - -class GenerateByjoveProject(colander.MappingSchema): - """ - Schema for generating a new 'byjove' project - """ - name = colander.SchemaNode(colander.String()) - - slug = colander.SchemaNode(colander.String()) - - -class GenerateFabricProject(colander.MappingSchema): - """ - Schema for generating a new 'fabric' project - """ - name = colander.SchemaNode(colander.String()) - - slug = colander.SchemaNode(colander.String()) - - organization = colander.SchemaNode(colander.String()) - - python_project_name = colander.SchemaNode(colander.String()) - - python_name = colander.SchemaNode(colander.String()) - - integrates_with = colander.SchemaNode(colander.String(), - missing=colander.null) - - -class GenerateProjectView(View): +class GeneratedProjectView(MasterView): """ View for generating new project source code """ + model_title = "Generated Project" + model_key = 'folder' + route_prefix = 'generated_projects' + url_prefix = '/generated-projects' + listable = False + viewable = False + editable = False + deletable = False def __init__(self, request): - super(GenerateProjectView, self).__init__(request) - self.project_handler = self.get_handler() - # TODO: deprecate / remove this - self.handler = self.project_handler + super(GeneratedProjectView, self).__init__(request) + self.project_handler = self.get_project_handler() - def get_handler(self): - from rattail.projects.handler import RattailProjectHandler - return RattailProjectHandler(self.rattail_config) + def get_project_handler(self): + app = self.get_rattail_app() + return app.get_project_handler() - def __call__(self): - use_buefy = self.get_use_buefy() + def create(self): + supported = self.project_handler.get_supported_project_generators() + supported_keys = list(supported) - # choices = OrderedDict([ - # ('has_db', {'prompt': "Does project need its own Rattail DB?", - # 'type': 'bool'}), - # ]) + project_type = self.request.matchdict.get('project_type') + if project_type: + form = self.make_project_form(project_type) + if form.validate(): + zipped = self.generate_project(project_type, form) + return self.file_response(zipped) - project_type = 'rattail' - if self.request.method == 'POST': - project_type = self.request.POST.get('project_type', 'rattail') - if project_type not in self.project_handler.get_supported_project_types(): - raise ValueError("Unknown project type: {}".format(project_type)) + else: # no project_type - if project_type == 'byjove': - schema = GenerateByjoveProject - elif project_type == 'fabric': - schema = GenerateFabricProject - elif project_type == 'rattail_integration': - schema = GenerateRattailIntegrationProject - elif project_type == 'tailbone_integration': - schema = GenerateTailboneIntegrationProject - else: - schema = GenerateProject - form = forms.Form(schema=schema(), request=self.request, - use_buefy=use_buefy) - if form.validate(newstyle=True): - zipped = self.generate_project(project_type, form) - return self.file_response(zipped) - # self.request.session.flash("New project was generated: {}".format(form.validated['name'])) - # return self.redirect(self.request.current_route_url()) + # make form to accept user choice of report type + schema = colander.Schema() + values = [(typ, typ) for typ in supported_keys] + schema.add(colander.SchemaNode(name='project_type', + typ=colander.String(), + validator=colander.OneOf(supported_keys), + widget=dfwidget.SelectWidget(values=values))) + form = forms.Form(schema=schema, request=self.request) + form.submit_label = "Continue" - return { + # if form validates, then user has chosen a project type, so + # we redirect to the appropriate "generate project" page + if form.validate(): + raise self.redirect(self.request.route_url( + 'generate_specific_project', + project_type=form.validated['project_type'])) + + return self.render_to_response('create', { 'index_title': "Generate Project", - 'handler': self.handler, - # 'choices': choices, - 'use_buefy': use_buefy, - } + 'project_type': project_type, + 'form': form, + }) def generate_project(self, project_type, form): - options = form.validated - slug = options['slug'] - path = self.handler.generate_project(project_type, slug, options) + context = dict(form.validated) + output = self.project_handler.generate_project(project_type, + context=context) + return self.project_handler.zip_output(output) - zipped = '{}.zip'.format(path) - with zipfile.ZipFile(zipped, 'w', zipfile.ZIP_DEFLATED) as z: - self.zipdir(z, path, slug) - return zipped + def make_project_form(self, project_type): - def zipdir(self, zipf, path, slug): - for root, dirs, files in os.walk(path): - relative_root = os.path.join(slug, root[len(path)+1:]) - for fname in files: - zipf.write(os.path.join(root, fname), - arcname=os.path.join(relative_root, fname)) + # make form + schema = self.project_handler.make_project_schema(project_type) + form = forms.Form(schema=schema, request=self.request) + form.auto_disable = False + form.auto_disable_save = False + form.submit_label = "Generate Project" + form.cancel_url = self.request.route_url('generated_projects.create') + + # apply normal config + self.configure_form_common(form, project_type) + + # let supplemental views further configure form + for supp in self.iter_view_supplements(): + configure = getattr(supp, 'configure_form_{}'.format(project_type), None) + if configure: + configure(form) + + # if master view has more configure logic, do that too + configure = getattr(self, 'configure_form_{}'.format(project_type), None) + if configure: + configure(form) + + return form + + def configure_form_common(self, form, project_type): + generator = self.project_handler.get_project_generator(project_type, + require=True) + + # python-based projects + if isinstance(generator, PythonProjectGenerator): + self.configure_form_python(form) + + # rattail-adjacent projects + if isinstance(generator, RattailAdjacentProjectGenerator): + self.configure_form_rattail_adjacent(form) + + # poser-based projects + if isinstance(generator, PoserProjectGenerator): + self.configure_form_poser(form) + + def configure_form_python(self, f): + + f.set_grouping([ + ("Naming", [ + 'name', + 'pkg_name', + 'pypi_name', + ]), + ]) + + # name + f.set_label('name', "Project Name") + f.set_helptext('name', "Human-friendly name generally used to refer to this project.") + f.set_default('name', "Poser Plus") + + # pkg_name + f.set_label('pkg_name', "Package Name in Python") + f.set_helptext('pkg_name', "`For example, ~/src/${field_model_pkg_name.replace(/_/g, '-')}/${field_model_pkg_name}/__init__.py`", + dynamic=True) + f.set_default('pkg_name', "poser_plus") + + # pypi_name + f.set_label('pypi_name', "Package Name for PyPI") + f.set_helptext('pypi_name', "It's a good idea to use org name as namespace prefix here") + f.set_default('pypi_name', "Acme-Poser-Plus") + + def configure_form_rattail_adjacent(self, f): + + # extends_config + f.set_label('extends_config', "Extend Config") + f.set_helptext('extends_config', "Needed to customize default config values etc.") + f.set_default('extends_config', True) + + # has_cli + f.set_label('has_cli', "Use Separate CLI") + f.set_helptext('has_cli', "`Needed for e.g. '${field_model_pkg_name} install' command.`", + dynamic=True) + f.set_default('has_cli', True) + + # extends_db + f.set_label('extends_db', "Extend DB Schema") + f.set_helptext('extends_db', "For adding custom tables/columns to the core schema") + f.set_default('extends_db', True) + + def configure_form_poser(self, f): + + # organization + f.set_helptext('organization', 'For use with branding etc.') + f.set_default('organization', "Acme Foods") + + # has_db + f.set_label('has_db', "Use Rattail DB") + f.set_helptext('has_db', "Note that a DB is required for the Web App") + f.set_default('has_db', True) + + # has_batch_schema + f.set_label('has_batch_schema', "Add Batch Schema") + f.set_helptext('has_batch_schema', 'Usually not needed - it\'s for "dynamic" (e.g. import/export) batches') + + # has_web + f.set_label('has_web', "Use Tailbone Web App") + f.set_default('has_web', True) + + # has_web_api + f.set_label('has_web_api', "Use Tailbone Web API") + f.set_helptext('has_web_api', "Needed for e.g. Vue.js SPA mobile apps") + + # has_datasync + f.set_label('has_datasync', "Use DataSync Service") + + # uses_fabric + f.set_label('uses_fabric', "Use Fabric") + f.set_default('uses_fabric', True) + + def configure_form_rattail(self, f): + + f.set_grouping([ + ("Naming", [ + 'name', + 'pkg_name', + 'pypi_name', + 'organization', + ]), + ("Core", [ + 'extends_config', + 'has_cli', + ]), + ("Database", [ + 'has_db', + 'extends_db', + 'has_batch_schema', + ]), + ("Web", [ + 'has_web', + 'has_web_api', + ]), + ("Integrations", [ + # 'integrates_catapult', + # 'integrates_corepos', + # 'integrates_locsms', + 'has_datasync', + ]), + ("Deployment", [ + 'uses_fabric', + ]), + ]) + + # # integrates_catapult + # f.set_label('integrates_catapult', "Integrate w/ Catapult") + # f.set_helptext('integrates_catapult', "Add schema, import/export logic etc. for ECRS Catapult") + + # # integrates_corepos + # f.set_label('integrates_corepos', "Integrate w/ CORE-POS") + # f.set_helptext('integrates_corepos', "Add schema, import/export logic etc. for CORE-POS") + + # # integrates_locsms + # f.set_label('integrates_locsms', "Integrate w/ LOC SMS") + # f.set_helptext('integrates_locsms', "Add schema, import/export logic etc. for LOC SMS") + + def configure_form_rattail_integration(self, f): + + f.set_grouping([ + ("Naming", [ + 'integration_name', + 'integration_url', + 'name', + 'pkg_name', + 'pypi_name', + ]), + ("Options", [ + 'extends_config', + 'extends_db', + 'has_cli', + ]), + ]) + + # default settings + f.set_default('name', 'rattail-foo') + f.set_default('pkg_name', 'rattail_foo') + f.set_default('pypi_name', 'rattail-foo') + f.set_default('has_cli', False) + + # integration_name + f.set_helptext('integration_name', "Name of the system to be integrated") + f.set_default('integration_name', "Foo") + + # integration_url + f.set_label('integration_url', "Integration URL") + f.set_helptext('integration_url', "Reference URL for the system to be integrated") + f.set_default('integration_url', "https://www.example.com/") + + def configure_form_rattail_shopfoo(self, f): + + # first do normal integration setup + self.configure_form_rattail_integration(f) + + f.set_grouping([ + ("Naming", [ + 'integration_name', + 'integration_url', + 'name', + 'pkg_name', + 'pypi_name', + ]), + ("Options", [ + 'has_cli', + ]), + ]) + + # default settings + f.set_default('integration_name', 'Shopfoo') + f.set_default('name', 'rattail-shopfoo') + f.set_default('pkg_name', 'rattail_shopfoo') + f.set_default('pypi_name', 'rattail-shopfoo') + f.set_default('has_cli', False) + + def configure_form_tailbone_integration(self, f): + + f.set_grouping([ + ("Naming", [ + 'integration_name', + 'integration_url', + 'name', + 'pkg_name', + 'pypi_name', + ]), + ("Options", [ + 'has_static_files', + ]), + ]) + + # integration_name + f.set_helptext('integration_name', "Name of the system to be integrated") + f.set_default('integration_name', "Foo") + + # integration_url + f.set_label('integration_url', "Integration URL") + f.set_helptext('integration_url', "Reference URL for the system to be integrated") + f.set_default('integration_url', "https://www.example.com/") + + # has_static_files + f.set_helptext('has_static_files', "Register a subfolder for static files (images etc.)") + + def configure_form_tailbone_shopfoo(self, f): + + # first do normal integration setup + self.configure_form_tailbone_integration(f) + + f.set_grouping([ + ("Naming", [ + 'integration_name', + 'integration_url', + 'name', + 'pkg_name', + 'pypi_name', + ]), + ]) + + # default settings + f.set_default('integration_name', 'Shopfoo') + f.set_default('name', 'tailbone-shopfoo') + f.set_default('pkg_name', 'tailbone_shopfoo') + f.set_default('pypi_name', 'tailbone-shopfoo') + + def configure_form_byjove(self, f): + + f.set_grouping([ + ("Naming", [ + 'system_name', + 'name', + 'slug', + ]), + ]) + + # system_name + f.set_default('system_name', "Okay Then") + f.set_helptext('system_name', + "Name of overall system to which mobile app belongs.") + + # name + f.set_label('name', "Mobile App Name") + f.set_default('name', "Okay Then Mobile") + f.set_helptext('name', "Display name for the mobile app.") + + # slug + f.set_default('slug', "okay-then-mobile") + f.set_helptext('slug', "Used for NPM-compatible project name etc.") + + def configure_form_fabric(self, f): + + f.set_grouping([ + ("Naming", [ + 'name', + 'pkg_name', + 'pypi_name', + 'organization', + ]), + ("Theo", [ + 'integrates_with', + ]), + ]) + + # naming defaults + f.set_default('name', "Acme Fabric") + f.set_default('pkg_name', "acmefab") + f.set_default('pypi_name', "Acme-Fabric") + + # organization + f.set_helptext('organization', 'For use with branding etc.') + f.set_default('organization', "Acme Foods") + + # integrates_with + f.set_helptext('integrates_with', "Which POS system should Theo integrate with, if any") + f.set_enum('integrates_with', OrderedDict([ + ('', "(nothing)"), + ('catapult', "ECRS Catapult"), + ('corepos', "CORE-POS"), + ('locsms', "LOC SMS") + ])) + f.set_default('integrates_with', '') @classmethod def defaults(cls, config): - config.add_tailbone_permission('common', 'common.generate_project', - "Generate new project source code") - config.add_route('generate_project', '/generate-project') - config.add_view(cls, route_name='generate_project', - permission='common.generate_project', - renderer='/generate_project.mako') + cls._defaults(config) + cls._generated_project_defaults(config) + + @classmethod + def _generated_project_defaults(cls, config): + url_prefix = cls.get_url_prefix() + permission_prefix = cls.get_permission_prefix() + + # generate project (accept custom params, truly create) + config.add_route('generate_specific_project', + '{}/new/{{project_type}}'.format(url_prefix)) + config.add_view(cls, attr='create', + route_name='generate_specific_project', + permission='{}.create'.format(permission_prefix)) def defaults(config, **kwargs): base = globals() - GenerateProjectView = kwargs.get('GenerateProjectView', base['GenerateProjectView']) - GenerateProjectView.defaults(config) + GeneratedProjectView = kwargs.get('GeneratedProjectView', base['GeneratedProjectView']) + GeneratedProjectView.defaults(config) def includeme(config): diff --git a/tailbone/views/purchases/core.py b/tailbone/views/purchases/core.py index 77b02501..e7bebdff 100644 --- a/tailbone/views/purchases/core.py +++ b/tailbone/views/purchases/core.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,10 +24,6 @@ Views for "true" purchase orders """ -from __future__ import unicode_literals, absolute_import - -import six - from rattail.db import model from webhelpers2.html import HTML, tags @@ -143,28 +139,35 @@ class PurchaseView(MasterView): if purchase.date_ordered: return "{} (ordered {})".format(purchase.vendor, purchase.date_ordered.strftime('%Y-%m-%d')) return "{} (ordered)".format(purchase.vendor) - return six.text_type(purchase) + return str(purchase) def configure_grid(self, g): - super(PurchaseView, self).configure_grid(g) + super().configure_grid(g) + model = self.model - g.joiners['store'] = lambda q: q.join(model.Store) - g.filters['store'] = g.make_filter('store', model.Store.name) - g.sorters['store'] = g.make_sorter(model.Store.name) + # store + g.set_joiner('store', lambda q: q.join(model.Store)) + g.set_sorter('store', model.Store.name) + g.set_filter('store', model.Store.name) - g.joiners['vendor'] = lambda q: q.join(model.Vendor) - g.filters['vendor'] = g.make_filter('vendor', model.Vendor.name, - default_active=True, default_verb='contains') - g.sorters['vendor'] = g.make_sorter(model.Vendor.name) + # vendor + g.set_joiner('vendor', lambda q: q.join(model.Vendor)) + g.set_sorter('vendor', model.Vendor.name) + g.set_filter('vendor', model.Vendor.name, + default_active=True, + default_verb='contains') - g.joiners['department'] = lambda q: q.join(model.Department) - g.filters['department'] = g.make_filter('department', model.Department.name) - g.sorters['department'] = g.make_sorter(model.Department.name) + # department + g.set_joiner('department', lambda q: q.join(model.Department)) + g.set_sorter('department', model.Department.name) + g.set_filter('department', model.Department.name) - g.joiners['buyer'] = lambda q: q.join(model.Employee).join(model.Person) - g.filters['buyer'] = g.make_filter('buyer', model.Person.display_name, - default_active=True, default_verb='contains') - g.sorters['buyer'] = g.make_sorter(model.Person.display_name) + # buyer + g.set_joiner('buyer', lambda q: q.join(model.Employee).join(model.Person)) + g.set_sorter('buyer', model.Person.display_name) + g.set_filter('buyer', model.Person.display_name, + default_active=True, + default_verb='contains') # id g.set_renderer('id', self.render_id_str) @@ -198,7 +201,7 @@ class PurchaseView(MasterView): g.set_link('invoice_total') def configure_form(self, f): - super(PurchaseView, self).configure_form(f) + super().configure_form(f) # id f.set_renderer('id', self.render_id_str) @@ -322,7 +325,7 @@ class PurchaseView(MasterView): .filter(model.PurchaseItem.purchase == purchase) def configure_row_grid(self, g): - super(PurchaseView, self).configure_row_grid(g) + super().configure_row_grid(g) g.set_sort_defaults('sequence') @@ -353,7 +356,7 @@ class PurchaseView(MasterView): g.remove('po_total') def configure_row_form(self, f): - super(PurchaseView, self).configure_row_form(f) + super().configure_row_form(f) # quantity fields f.set_type('case_quantity', 'quantity') diff --git a/tailbone/views/purchases/credits.py b/tailbone/views/purchases/credits.py index 71902426..7da096eb 100644 --- a/tailbone/views/purchases/credits.py +++ b/tailbone/views/purchases/credits.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,10 +24,6 @@ Views for "true" purchase credits """ -from __future__ import unicode_literals, absolute_import - -import six - from rattail.db import model from webhelpers2.html import tags @@ -100,10 +96,12 @@ class PurchaseCreditView(MasterView): ] def configure_grid(self, g): - super(PurchaseCreditView, self).configure_grid(g) + super().configure_grid(g) + # vendor g.set_joiner('vendor', lambda q: q.outerjoin(model.Vendor)) g.set_sorter('vendor', model.Vendor.name) + g.set_filter('vendor', model.Vendor.name, label="Vendor Name") g.set_sort_defaults('date_received', 'desc') @@ -112,7 +110,7 @@ class PurchaseCreditView(MasterView): g.filters['status'].default_active = True g.filters['status'].default_verb = 'not_equal' # TODO: should not have to convert value to string! - g.filters['status'].default_value = six.text_type(self.enum.PURCHASE_CREDIT_STATUS_SATISFIED) + g.filters['status'].default_value = str(self.enum.PURCHASE_CREDIT_STATUS_SATISFIED) # g.set_type('upc', 'gpc') g.set_type('cases_shorted', 'quantity') @@ -154,7 +152,7 @@ class PurchaseCreditView(MasterView): for uuid in self.request.POST.get('uuids', '').split(','): uuid = uuid.strip() if uuid: - credit = self.Session.query(model.PurchaseCredit).get(uuid) + credit = self.Session.get(model.PurchaseCredit, uuid) if credit: credits_.append(credit) if not credits_: @@ -175,7 +173,9 @@ class PurchaseCreditView(MasterView): def status_options(self): options = [] for value in sorted(self.enum.PURCHASE_CREDIT_STATUS): - options.append(tags.Option(self.enum.PURCHASE_CREDIT_STATUS[value], value)) + options.append({ + 'value': value, + 'label': self.enum.PURCHASE_CREDIT_STATUS[value]}) return options @classmethod diff --git a/tailbone/views/purchasing/batch.py b/tailbone/views/purchasing/batch.py index ee460192..5e00704e 100644 --- a/tailbone/views/purchasing/batch.py +++ b/tailbone/views/purchasing/batch.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,18 +24,15 @@ Base class for purchasing batch views """ -from __future__ import unicode_literals, absolute_import +import warnings -import six - -from rattail.db import model, api +from rattail.db.model import PurchaseBatch, PurchaseBatchRow import colander from deform import widget as dfwidget -from pyramid import httpexceptions from webhelpers2.html import tags, HTML -from tailbone import forms, grids +from tailbone import forms from tailbone.views.batch import BatchMasterView @@ -44,8 +41,8 @@ class PurchasingBatchView(BatchMasterView): Master view base class, for purchase batches. The views for both "ordering" and "receiving" batches will inherit from this. """ - model_class = model.PurchaseBatch - model_row_class = model.PurchaseBatchRow + model_class = PurchaseBatch + model_row_class = PurchaseBatchRow default_handler_spec = 'rattail.batch.purchase:PurchaseBatchHandler' supports_new_product = False cloneable = True @@ -72,6 +69,8 @@ class PurchasingBatchView(BatchMasterView): 'store', 'buyer', 'vendor', + 'description', + 'workflow', 'department', 'purchase', 'vendor_email', @@ -163,21 +162,194 @@ class PurchasingBatchView(BatchMasterView): def batch_mode(self): raise NotImplementedError("Please define `batch_mode` for your purchasing batch view") + def get_supported_workflows(self): + """ + Return the supported "create batch" workflows. + """ + enum = self.app.enum + if self.batch_mode == enum.PURCHASE_BATCH_MODE_ORDERING: + return self.batch_handler.supported_ordering_workflows() + elif self.batch_mode == enum.PURCHASE_BATCH_MODE_RECEIVING: + return self.batch_handler.supported_receiving_workflows() + elif self.batch_mode == enum.PURCHASE_BATCH_MODE_COSTING: + return self.batch_handler.supported_costing_workflows() + raise ValueError("unknown batch mode") + + def allow_any_vendor(self): + """ + Return boolean indicating whether creating a batch for "any" + vendor is allowed, vs. only supported vendors. + """ + enum = self.app.enum + + if self.batch_mode == enum.PURCHASE_BATCH_MODE_ORDERING: + return self.batch_handler.allow_ordering_any_vendor() + + elif self.batch_mode == enum.PURCHASE_BATCH_MODE_RECEIVING: + value = self.config.get_bool('rattail.batch.purchase.allow_receiving_any_vendor') + if value is not None: + return value + value = self.config.get_bool('rattail.batch.purchase.supported_vendors_only') + if value is not None: + warnings.warn("setting rattail.batch.purchase.supported_vendors_only is deprecated; " + "please use rattail.batch.purchase.allow_receiving_any_vendor instead", + DeprecationWarning) + # nb. must negate this setting + return not value + return False + + raise ValueError("unknown batch mode") + + def get_supported_vendors(self): + """ + Return the supported vendors for creating a batch. + """ + return [] + + def create(self, form=None, **kwargs): + """ + Custom view for creating a new batch. We split the process + into two steps, 1) choose workflow and 2) create batch. This + is because the specific form details for creating a batch will + depend on which "type" of batch creation is to be done, and + it's much easier to keep conditional logic for that in the + server instead of client-side etc. + """ + model = self.app.model + enum = self.app.enum + route_prefix = self.get_route_prefix() + + workflows = self.get_supported_workflows() + valid_workflows = [workflow['workflow_key'] + for workflow in workflows] + + # if user has already identified their desired workflow, then + # we can just farm out to the default logic. we will of + # course configure our form differently, based on workflow, + # but this create() method at least will not need + # customization for that. + if self.request.matched_route.name.endswith('create_workflow'): + + redirect = self.redirect(self.request.route_url(f'{route_prefix}.create')) + + # however we do have one more thing to check - the workflow + # requested must of course be valid! + workflow_key = self.request.matchdict['workflow_key'] + if workflow_key not in valid_workflows: + self.request.session.flash(f"Not a supported workflow: {workflow_key}", 'error') + raise redirect + + # also, we require vendor to be correctly identified. if + # someone e.g. navigates to a URL by accident etc. we want + # to gracefully handle and redirect + uuid = self.request.matchdict['vendor_uuid'] + vendor = self.Session.get(model.Vendor, uuid) + if not vendor: + self.request.session.flash("Invalid vendor selection. " + "Please choose an existing vendor.", + 'warning') + raise redirect + + # okay now do the normal thing, per workflow + return super().create(**kwargs) + + # on the other hand, if caller provided a form, that means we are in + # the middle of some other custom workflow, e.g. "add child to truck + # dump parent" or some such. in which case we also defer to the normal + # logic, so as to not interfere with that. + if form: + return super().create(form=form, **kwargs) + + # okay, at this point we need the user to select a vendor and workflow + self.creating = True + context = {} + + # form to accept user choice of vendor/workflow + schema = colander.Schema() + schema.add(colander.SchemaNode(colander.String(), name='vendor')) + schema.add(colander.SchemaNode(colander.String(), name='workflow', + validator=colander.OneOf(valid_workflows))) + factory = self.get_form_factory() + form = factory(schema=schema, request=self.request) + + # configure vendor field + vendor_handler = self.app.get_vendor_handler() + if self.allow_any_vendor(): + # user may choose *any* available vendor + use_dropdown = vendor_handler.choice_uses_dropdown() + if use_dropdown: + vendors = self.Session.query(model.Vendor)\ + .order_by(model.Vendor.id)\ + .all() + vendor_values = [(vendor.uuid, f"({vendor.id}) {vendor.name}") + for vendor in vendors] + form.set_widget('vendor', dfwidget.SelectWidget(values=vendor_values)) + if len(vendors) == 1: + form.set_default('vendor', vendors[0].uuid) + else: + vendor_display = "" + if self.request.method == 'POST': + if self.request.POST.get('vendor'): + vendor = self.Session.get(model.Vendor, self.request.POST['vendor']) + if vendor: + vendor_display = str(vendor) + vendors_url = self.request.route_url('vendors.autocomplete') + form.set_widget('vendor', forms.widgets.JQueryAutocompleteWidget( + field_display=vendor_display, service_url=vendors_url)) + else: # only "supported" vendors allowed + vendors = self.get_supported_vendors() + vendor_values = [(vendor.uuid, vendor_handler.render_vendor(vendor)) + for vendor in vendors] + form.set_widget('vendor', dfwidget.SelectWidget(values=vendor_values)) + form.set_validator('vendor', self.valid_vendor_uuid) + + # configure workflow field + values = [(workflow['workflow_key'], workflow['display']) + for workflow in workflows] + form.set_widget('workflow', + dfwidget.SelectWidget(values=values)) + if len(workflows) == 1: + form.set_default('workflow', workflows[0]['workflow_key']) + + form.submit_label = "Continue" + form.cancel_url = self.get_index_url() + + # if form validates, that means user has chosen a creation + # type, so we just redirect to the appropriate "new batch of + # type X" page + if form.validate(): + workflow_key = form.validated['workflow'] + vendor_uuid = form.validated['vendor'] + url = self.request.route_url(f'{route_prefix}.create_workflow', + workflow_key=workflow_key, + vendor_uuid=vendor_uuid) + raise self.redirect(url) + + context['form'] = form + if hasattr(form, 'make_deform_form'): + context['dform'] = form.make_deform_form() + return self.render_to_response('create', context) + def query(self, session): + model = self.model return session.query(model.PurchaseBatch)\ .filter(model.PurchaseBatch.mode == self.batch_mode) def configure_grid(self, g): - super(PurchasingBatchView, self).configure_grid(g) + super().configure_grid(g) + model = self.model - g.joiners['vendor'] = lambda q: q.join(model.Vendor) - g.filters['vendor'] = g.make_filter('vendor', model.Vendor.name, - default_active=True, default_verb='contains') - g.sorters['vendor'] = g.make_sorter(model.Vendor.name) + # vendor + g.set_link('vendor') + g.set_joiner('vendor', lambda q: q.join(model.Vendor)) + g.set_sorter('vendor', model.Vendor.name) + g.set_filter('vendor', model.Vendor.name, + default_active=True, default_verb='contains') - g.joiners['department'] = lambda q: q.join(model.Department) - g.filters['department'] = g.make_filter('department', model.Department.name) - g.sorters['department'] = g.make_sorter(model.Department.name) + # department + g.set_joiner('department', lambda q: q.join(model.Department)) + g.set_filter('department', model.Department.name) + g.set_sorter('department', model.Department.name) g.set_joiner('buyer', lambda q: q.join(model.Employee).join(model.Person)) g.set_filter('buyer', model.Person.display_name) @@ -212,7 +384,7 @@ class PurchasingBatchView(BatchMasterView): # return form def configure_common_form(self, f): - super(PurchasingBatchView, self).configure_common_form(f) + super().configure_common_form(f) # po_total if self.creating: @@ -225,22 +397,41 @@ class PurchasingBatchView(BatchMasterView): f.set_type('po_total_calculated', 'currency') def configure_form(self, f): - super(PurchasingBatchView, self).configure_form(f) - model = self.model + super().configure_form(f) + model = self.app.model + enum = self.app.enum + route_prefix = self.get_route_prefix() + + today = self.app.today() batch = f.model_instance - app = self.get_rattail_app() - today = app.localtime().date() - use_buefy = self.get_use_buefy() + workflow = self.request.matchdict.get('workflow_key') + vendor_handler = self.app.get_vendor_handler() # mode - f.set_enum('mode', self.enum.PURCHASE_BATCH_MODE) + f.set_enum('mode', enum.PURCHASE_BATCH_MODE) + + # workflow + if self.creating: + if workflow: + f.set_widget('workflow', dfwidget.HiddenWidget()) + f.set_default('workflow', workflow) + f.set_hidden('workflow') + # nb. show readonly '_workflow' + f.insert_after('workflow', '_workflow') + f.set_readonly('_workflow') + f.set_renderer('_workflow', self.render_workflow) + else: + f.set_readonly('workflow') + f.set_renderer('workflow', self.render_workflow) + else: + f.remove('workflow') # store - single_store = self.rattail_config.single_store() + single_store = self.config.single_store() if self.creating: f.replace('store', 'store_uuid') if single_store: - store = self.rattail_config.get_store(self.Session()) + store = self.config.get_store(self.Session()) f.set_widget('store_uuid', dfwidget.HiddenWidget()) f.set_default('store_uuid', store.uuid) f.set_hidden('store_uuid') @@ -264,7 +455,6 @@ class PurchasingBatchView(BatchMasterView): if self.creating: f.replace('vendor', 'vendor_uuid') f.set_label('vendor_uuid', "Vendor") - vendor_handler = app.get_vendor_handler() use_dropdown = vendor_handler.choice_uses_dropdown() if use_dropdown: vendors = self.Session.query(model.Vendor)\ @@ -276,12 +466,13 @@ class PurchasingBatchView(BatchMasterView): vendor_display = "" if self.request.method == 'POST': if self.request.POST.get('vendor_uuid'): - vendor = self.Session.query(model.Vendor).get(self.request.POST['vendor_uuid']) + vendor = self.Session.get(model.Vendor, self.request.POST['vendor_uuid']) if vendor: - vendor_display = six.text_type(vendor) + vendor_display = str(vendor) vendors_url = self.request.route_url('vendors.autocomplete') f.set_widget('vendor_uuid', forms.widgets.JQueryAutocompleteWidget( field_display=vendor_display, service_url=vendors_url)) + f.set_validator('vendor_uuid', self.valid_vendor_uuid) elif self.editing: f.set_readonly('vendor') @@ -309,21 +500,45 @@ class PurchasingBatchView(BatchMasterView): buyer_display = "" if self.request.method == 'POST': if self.request.POST.get('buyer_uuid'): - buyer = self.Session.query(model.Employee).get(self.request.POST['buyer_uuid']) + buyer = self.Session.get(model.Employee, self.request.POST['buyer_uuid']) if buyer: - buyer_display = six.text_type(buyer) + buyer_display = str(buyer) elif self.creating: - buyer = self.request.user.employee + buyer = self.app.get_employee(self.request.user) if buyer: - buyer_display = six.text_type(buyer) + buyer_display = str(buyer) f.set_default('buyer_uuid', buyer.uuid) elif self.editing: - buyer_display = six.text_type(batch.buyer or '') + buyer_display = str(batch.buyer or '') buyers_url = self.request.route_url('employees.autocomplete') f.set_widget('buyer_uuid', forms.widgets.JQueryAutocompleteWidget( field_display=buyer_display, service_url=buyers_url)) f.set_label('buyer_uuid', "Buyer") + # order_file + if self.creating: + f.set_type('order_file', 'file', required=False) + else: + f.set_readonly('order_file') + f.set_renderer('order_file', self.render_downloadable_file) + + # order_parser_key + if self.creating: + kwargs = {} + if 'vendor_uuid' in self.request.matchdict: + vendor = self.Session.get(model.Vendor, + self.request.matchdict['vendor_uuid']) + if vendor: + kwargs['vendor'] = vendor + parsers = vendor_handler.get_supported_order_parsers(**kwargs) + parser_values = [(p.key, p.title) for p in parsers] + if len(parsers) == 1: + f.set_default('order_parser_key', parsers[0].key) + f.set_widget('order_parser_key', dfwidget.SelectWidget(values=parser_values)) + f.set_label('order_parser_key', "Order Parser") + else: + f.remove_field('order_parser_key') + # invoice_file if self.creating: f.set_type('invoice_file', 'file', required=False) @@ -336,21 +551,17 @@ class PurchasingBatchView(BatchMasterView): kwargs = {} if 'vendor_uuid' in self.request.matchdict: - vendor = self.Session.query(model.Vendor).get( - self.request.matchdict['vendor_uuid']) + vendor = self.Session.get(model.Vendor, + self.request.matchdict['vendor_uuid']) if vendor: kwargs['vendor'] = vendor - parsers = self.handler.get_supported_invoice_parsers(**kwargs) + parsers = self.batch_handler.get_supported_invoice_parsers(**kwargs) parser_values = [(p.key, p.display) for p in parsers] if len(parsers) == 1: f.set_default('invoice_parser_key', parsers[0].key) - if use_buefy: - f.set_widget('invoice_parser_key', dfwidget.SelectWidget(values=parser_values)) - else: - parser_values.insert(0, ('', "(please choose)")) - f.set_widget('invoice_parser_key', forms.widgets.JQuerySelectWidget(values=parser_values)) + f.set_widget('invoice_parser_key', dfwidget.SelectWidget(values=parser_values)) else: f.remove_field('invoice_parser_key') @@ -404,11 +615,34 @@ class PurchasingBatchView(BatchMasterView): 'vendor_contact', 'status_code') - def valid_vendor_uuid(self, node, value): - model = self.model - vendor = self.Session.query(model.Vendor).get(value) - if not vendor: - raise colander.Invalid(node, "Invalid vendor selection") + # tweak some things if we are in "step 2" of creating new batch + if self.creating and workflow: + + # display vendor but do not allow changing + vendor = self.Session.get(model.Vendor, self.request.matchdict['vendor_uuid']) + if not vendor: + raise ValueError(f"vendor not found: {self.request.matchdict['vendor_uuid']}") + f.set_readonly('vendor_uuid') + f.set_default('vendor_uuid', str(vendor)) + + # cancel should take us back to choosing a workflow + f.cancel_url = self.request.route_url(f'{route_prefix}.create') + + def render_workflow(self, batch, field): + key = self.request.matchdict['workflow_key'] + info = self.get_workflow_info(key) + if info: + return info['display'] + + def get_workflow_info(self, key): + enum = self.app.enum + if self.batch_mode == enum.PURCHASE_BATCH_MODE_ORDERING: + return self.batch_handler.ordering_workflow_info(key) + elif self.batch_mode == enum.PURCHASE_BATCH_MODE_RECEIVING: + return self.batch_handler.receiving_workflow_info(key) + elif self.batch_mode == enum.PURCHASE_BATCH_MODE_COSTING: + return self.batch_handler.costing_workflow_info(key) + raise ValueError("unknown batch mode") def render_store(self, batch, field): store = batch.store @@ -419,12 +653,30 @@ class PurchasingBatchView(BatchMasterView): return tags.link_to(text, url) def render_purchase(self, batch, field): - purchase = batch.purchase + model = self.model + + # default logic can only render the "normal" (built-in) + # purchase field; anything else must be handled by view + # supplement if possible + if field != 'purchase': + for supp in self.iter_view_supplements(): + renderer = getattr(supp, f'render_purchase_{field}', None) + if renderer: + return renderer(batch) + + # nothing to render if no purchase found + purchase = getattr(batch, field) if not purchase: - return "" - text = six.text_type(purchase) - url = self.request.route_url('purchases.view', uuid=purchase.uuid) - return tags.link_to(text, url) + return + + # render link to native purchase, if possible + text = str(purchase) + if isinstance(purchase, model.Purchase): + url = self.request.route_url('purchases.view', uuid=purchase.uuid) + return tags.link_to(text, url) + + # otherwise just render purchase as-is + return text def render_vendor_email(self, batch, field): if batch.vendor.email: @@ -435,7 +687,7 @@ class PurchasingBatchView(BatchMasterView): def render_vendor_contact(self, batch, field): if batch.vendor.contact: - return six.text_type(batch.vendor.contact) + return str(batch.vendor.contact) def render_vendor_phone(self, batch, field): return self.get_vendor_phone_number(batch) @@ -455,19 +707,21 @@ class PurchasingBatchView(BatchMasterView): employee = batch.buyer if not employee: return "" - text = six.text_type(employee) + text = str(employee) if self.request.has_perm('employees.view'): url = self.request.route_url('employees.view', uuid=employee.uuid) return tags.link_to(text, url) return text def get_store_values(self): + model = self.model stores = self.Session.query(model.Store)\ .order_by(model.Store.id) return [(s.uuid, "({}) {}".format(s.id, s.name)) for s in stores] def get_vendors(self): + model = self.model return self.Session.query(model.Vendor)\ .order_by(model.Vendor.name) @@ -477,6 +731,7 @@ class PurchasingBatchView(BatchMasterView): for v in vendors] def get_buyers(self): + model = self.model return self.Session.query(model.Employee)\ .join(model.Person)\ .filter(model.Employee.status == self.enum.EMPLOYEE_STATUS_CURRENT)\ @@ -484,10 +739,11 @@ class PurchasingBatchView(BatchMasterView): def get_buyer_values(self): buyers = self.get_buyers() - return [(b.uuid, six.text_type(b)) + return [(b.uuid, str(b)) for b in buyers] def get_department_options(self): + model = self.model departments = self.Session.query(model.Department).order_by(model.Department.number) return [('{} {}'.format(d.number, d.name), d.uuid) for d in departments] @@ -501,41 +757,14 @@ class PurchasingBatchView(BatchMasterView): if phone.type == 'Fax': return phone.number - def eligible_purchases(self, vendor_uuid=None, mode=None): - if not vendor_uuid: - vendor_uuid = self.request.GET.get('vendor_uuid') - vendor = self.Session.query(model.Vendor).get(vendor_uuid) if vendor_uuid else None - if not vendor: - return {'error': "Must specify a vendor."} - - if mode is None: - mode = self.request.GET.get('mode') - mode = int(mode) if mode and mode.isdigit() else None - if not mode or mode not in self.enum.PURCHASE_BATCH_MODE: - return {'error': "Unknown mode: {}".format(mode)} - - purchases = self.handler.get_eligible_purchases(vendor, mode) - return self.get_eligible_purchases_data(purchases) - - def get_eligible_purchases_data(self, purchases): - return {'purchases': [{'key': p.uuid, - 'department_uuid': p.department_uuid or '', - 'display': self.render_eligible_purchase(p)} - for p in purchases]} - - def render_eligible_purchase(self, purchase): - if purchase.status == self.enum.PURCHASE_STATUS_ORDERED: - date = purchase.date_ordered - total = purchase.po_total - elif purchase.status == self.enum.PURCHASE_STATUS_RECEIVED: - date = purchase.date_received - total = purchase.invoice_total - return '{} for ${:0,.2f} ({})'.format(date, total or 0, purchase.department or purchase.buyer) - def get_batch_kwargs(self, batch, **kwargs): - kwargs = super(PurchasingBatchView, self).get_batch_kwargs(batch, **kwargs) + kwargs = super().get_batch_kwargs(batch, **kwargs) + model = self.app.model + kwargs['mode'] = self.batch_mode + kwargs['workflow'] = self.request.POST['workflow'] kwargs['truck_dump'] = batch.truck_dump + kwargs['order_parser_key'] = batch.order_parser_key kwargs['invoice_parser_key'] = batch.invoice_parser_key if batch.store: @@ -553,6 +782,11 @@ class PurchasingBatchView(BatchMasterView): elif batch.vendor_uuid: kwargs['vendor_uuid'] = batch.vendor_uuid + # must pull vendor from URL if it was not in form data + if 'vendor_uuid' not in kwargs and 'vendor' not in kwargs: + if 'vendor_uuid' in self.request.matchdict: + kwargs['vendor_uuid'] = self.request.matchdict['vendor_uuid'] + if batch.department: kwargs['department'] = batch.department elif batch.department_uuid: @@ -579,16 +813,20 @@ class PurchasingBatchView(BatchMasterView): if self.batch_mode in (self.enum.PURCHASE_BATCH_MODE_RECEIVING, self.enum.PURCHASE_BATCH_MODE_COSTING): - purchase = batch.purchase - if not purchase and batch.purchase_uuid: - purchase = self.Session.query(model.Purchase).get(batch.purchase_uuid) - assert purchase - if purchase: - kwargs['purchase'] = purchase - kwargs['buyer'] = purchase.buyer - kwargs['buyer_uuid'] = purchase.buyer_uuid - kwargs['date_ordered'] = purchase.date_ordered - kwargs['po_total'] = purchase.po_total + field = self.batch_handler.get_purchase_order_fieldname() + if field == 'purchase': + purchase = batch.purchase + if not purchase and batch.purchase_uuid: + purchase = self.Session.get(model.Purchase, batch.purchase_uuid) + assert purchase + if purchase: + kwargs['purchase'] = purchase + kwargs['buyer'] = purchase.buyer + kwargs['buyer_uuid'] = purchase.buyer_uuid + kwargs['date_ordered'] = purchase.date_ordered + kwargs['po_total'] = purchase.po_total + elif hasattr(batch, field): + kwargs[field] = getattr(batch, field) return kwargs @@ -611,7 +849,7 @@ class PurchasingBatchView(BatchMasterView): # return query.options(orm.joinedload(model.PurchaseBatchRow.credits)) def configure_row_grid(self, g): - super(PurchasingBatchView, self).configure_row_grid(g) + super().configure_row_grid(g) g.set_type('upc', 'gpc') g.set_type('cases_ordered', 'quantity') @@ -698,7 +936,7 @@ class PurchasingBatchView(BatchMasterView): return 'notice' def configure_row_form(self, f): - super(PurchasingBatchView, self).configure_row_form(f) + super().configure_row_form(f) row = f.model_instance if self.creating: batch = self.get_instance() @@ -802,13 +1040,13 @@ class PurchasingBatchView(BatchMasterView): return app.render_cases_units(cases, units) def make_row_credits_grid(self, row): - use_buefy = self.get_use_buefy() route_prefix = self.get_route_prefix() factory = self.get_grid_factory() g = factory( - key='{}.row_credits'.format(route_prefix), - data=[] if use_buefy else row.credits, + self.request, + key=f'{route_prefix}.row_credits', + data=[], columns=[ 'credit_type', 'shorted', @@ -837,36 +1075,9 @@ class PurchasingBatchView(BatchMasterView): return g def render_row_credits(self, row, field): - use_buefy = self.get_use_buefy() - if not use_buefy and not row.credits: - return - g = self.make_row_credits_grid(row) - - if use_buefy: - return HTML.literal( - g.render_buefy_table_element(data_prop='rowData.credits')) - else: - return HTML.literal(g.render_grid()) - -# def item_lookup(self, value, field=None): -# """ -# Try to locate a single product using ``value`` as a lookup code. -# """ -# batch = self.get_instance() -# product = api.get_product_by_vendor_code(Session(), value, vendor=batch.vendor) -# if product: -# return product.uuid -# if value.isdigit(): -# product = api.get_product_by_upc(Session(), GPC(value)) -# if not product: -# product = api.get_product_by_upc(Session(), GPC(value, calc_check_digit='upc')) -# if product: -# if not product.cost_for_vendor(batch.vendor): -# raise fa.ValidationError("Product {} exists but has no cost for vendor {}".format( -# product.upc.pretty(), batch.vendor)) -# return product.uuid -# raise fa.ValidationError("Product not found") + return HTML.literal( + g.render_table_element(data_prop='rowData.credits')) # def before_create_row(self, form): # row = form.fieldset.model @@ -935,7 +1146,7 @@ class PurchasingBatchView(BatchMasterView): batch.invoice_total -= row.invoice_total # do the "normal" save logic... - row = super(PurchasingBatchView, self).save_edit_row_form(form) + row = super().save_edit_row_form(form) # TODO: is this needed? # self.handler.refresh_row(row) @@ -959,27 +1170,24 @@ class PurchasingBatchView(BatchMasterView): # # otherwise just view batch again # return self.get_action_url('view', batch) + @classmethod + def defaults(cls, config): + cls._purchase_batch_defaults(config) + cls._batch_defaults(config) + cls._defaults(config) @classmethod - def _purchasing_defaults(cls, config): - rattail_config = config.registry.settings.get('rattail_config') + def _purchase_batch_defaults(cls, config): route_prefix = cls.get_route_prefix() url_prefix = cls.get_url_prefix() permission_prefix = cls.get_permission_prefix() - model_key = cls.get_model_key() - model_title = cls.get_model_title() - # eligible purchases (AJAX) - config.add_route('{}.eligible_purchases'.format(route_prefix), '{}/eligible-purchases'.format(url_prefix)) - config.add_view(cls, attr='eligible_purchases', route_name='{}.eligible_purchases'.format(route_prefix), - renderer='json', permission='{}.view'.format(permission_prefix)) - - - @classmethod - def defaults(cls, config): - cls._purchasing_defaults(config) - cls._batch_defaults(config) - cls._defaults(config) + # new batch using workflow X + config.add_route(f'{route_prefix}.create_workflow', + f'{url_prefix}/new/{{workflow_key}}/{{vendor_uuid}}') + config.add_view(cls, attr='create', + route_name=f'{route_prefix}.create_workflow', + permission=f'{permission_prefix}.create') class NewProduct(colander.Schema): diff --git a/tailbone/views/purchasing/costing.py b/tailbone/views/purchasing/costing.py index 2d62d6e1..ec4e3ee3 100644 --- a/tailbone/views/purchasing/costing.py +++ b/tailbone/views/purchasing/costing.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,10 +24,6 @@ Views for 'costing' (purchasing) batches """ -from __future__ import unicode_literals, absolute_import - -import six - import colander from deform import widget as dfwidget @@ -47,8 +43,6 @@ class CostingBatchView(PurchasingBatchView): downloadable = True bulk_deletable = True - purchase_order_fieldname = 'purchase' - labels = { 'invoice_parser_key': "Invoice Parser", } @@ -188,14 +182,12 @@ class CostingBatchView(PurchasingBatchView): # okay, at this point we need the user to select a vendor and workflow self.creating = True - use_buefy = self.get_use_buefy() model = self.model context = {} # form to accept user choice of vendor/workflow schema = NewCostingBatch().bind(valid_workflows=valid_workflows) - form = forms.Form(schema=schema, request=self.request, - use_buefy=use_buefy) + form = forms.Form(schema=schema, request=self.request) if len(valid_workflows) == 1: form.set_default('workflow', valid_workflows[0]) @@ -208,17 +200,14 @@ class CostingBatchView(PurchasingBatchView): .order_by(model.Vendor.id) vendor_values = [(vendor.uuid, "({}) {}".format(vendor.id, vendor.name)) for vendor in vendors] - if use_buefy: - form.set_widget('vendor', dfwidget.SelectWidget(values=vendor_values)) - else: - form.set_widget('vendor', forms.widgets.JQuerySelectWidget(values=vendor_values)) + form.set_widget('vendor', dfwidget.SelectWidget(values=vendor_values)) else: vendor_display = "" if self.request.method == 'POST': if self.request.POST.get('vendor'): - vendor = self.Session.query(model.Vendor).get(self.request.POST['vendor']) + vendor = self.Session.get(model.Vendor, self.request.POST['vendor']) if vendor: - vendor_display = six.text_type(vendor) + vendor_display = str(vendor) vendors_url = self.request.route_url('vendors.autocomplete') form.set_widget('vendor', forms.widgets.JQueryAutocompleteWidget( field_display=vendor_display, service_url=vendors_url)) @@ -226,19 +215,15 @@ class CostingBatchView(PurchasingBatchView): # configure workflow field values = [(workflow['workflow_key'], workflow['display']) for workflow in workflows] - if use_buefy: - form.set_widget('workflow', - dfwidget.SelectWidget(values=values)) - else: - form.set_widget('workflow', - forms.widgets.JQuerySelectWidget(values=values)) + form.set_widget('workflow', + dfwidget.SelectWidget(values=values)) form.submit_label = "Continue" form.cancel_url = self.get_index_url() # if form validates, that means user has chosen a creation type, so we # just redirect to the appropriate "new batch of type X" page - if form.validate(newstyle=True): + if form.validate(): workflow_key = form.validated['workflow'] vendor_uuid = form.validated['vendor'] url = self.request.route_url('{}.create_workflow'.format(route_prefix), @@ -254,7 +239,6 @@ class CostingBatchView(PurchasingBatchView): def configure_form(self, f): super(CostingBatchView, self).configure_form(f) route_prefix = self.get_route_prefix() - use_buefy = self.get_use_buefy() model = self.model workflow = self.request.matchdict.get('workflow_key') @@ -272,8 +256,8 @@ class CostingBatchView(PurchasingBatchView): if self.creating and workflow: # display vendor but do not allow changing - vendor = self.Session.query(model.Vendor).get( - self.request.matchdict['vendor_uuid']) + vendor = self.Session.get(model.Vendor, + self.request.matchdict['vendor_uuid']) assert vendor f.set_hidden('vendor_uuid') @@ -304,17 +288,17 @@ class CostingBatchView(PurchasingBatchView): f.remove_field('batch_type') # purchase + field = self.batch_handler.get_purchase_order_fieldname() if (self.creating and workflow == 'invoice_with_po' - and self.purchase_order_fieldname == 'purchase'): - if use_buefy: - f.replace('purchase', 'purchase_uuid') - purchases = self.handler.get_eligible_purchases( - vendor, self.enum.PURCHASE_BATCH_MODE_COSTING) - values = [(p.uuid, self.handler.render_eligible_purchase(p)) - for p in purchases] - f.set_widget('purchase_uuid', dfwidget.SelectWidget(values=values)) - f.set_label('purchase_uuid', "Purchase Order") - f.set_required('purchase_uuid') + and field == 'purchase'): + f.replace('purchase', 'purchase_uuid') + purchases = self.handler.get_eligible_purchases( + vendor, self.enum.PURCHASE_BATCH_MODE_COSTING) + values = [(p.uuid, self.handler.render_eligible_purchase(p)) + for p in purchases] + f.set_widget('purchase_uuid', dfwidget.SelectWidget(values=values)) + f.set_label('purchase_uuid', "Purchase Order") + f.set_required('purchase_uuid') def render_costing_workflow(self, batch, field): key = self.request.matchdict['workflow_key'] @@ -332,7 +316,6 @@ class CostingBatchView(PurchasingBatchView): @classmethod def defaults(cls, config): cls._costing_defaults(config) - cls._purchasing_defaults(config) cls._batch_defaults(config) cls._defaults(config) diff --git a/tailbone/views/purchasing/ordering.py b/tailbone/views/purchasing/ordering.py index f4820783..c7cc7bfc 100644 --- a/tailbone/views/purchasing/ordering.py +++ b/tailbone/views/purchasing/ordering.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,21 +24,14 @@ Views for 'ordering' (purchasing) batches """ -from __future__ import unicode_literals, absolute_import - import os import json -import six import openpyxl -from sqlalchemy import orm -from rattail.db import model, api from rattail.core import Object -from rattail.time import localtime - -from webhelpers2.html import tags +from tailbone.db import Session from tailbone.views.purchasing import PurchasingBatchView @@ -54,6 +47,8 @@ class OrderingBatchView(PurchasingBatchView): rows_editable = True has_worksheet = True default_help_url = 'https://rattailproject.org/docs/rattail-manual/features/purchasing/ordering/index.html' + downloadable = True + configurable = True labels = { 'po_total_calculated': "PO Total", @@ -62,9 +57,14 @@ class OrderingBatchView(PurchasingBatchView): form_fields = [ 'id', 'store', - 'buyer', 'vendor', + 'description', + 'workflow', + 'order_file', + 'order_parser_key', + 'buyer', 'department', + 'params', 'purchase', 'vendor_email', 'vendor_fax', @@ -135,15 +135,26 @@ class OrderingBatchView(PurchasingBatchView): return self.enum.PURCHASE_BATCH_MODE_ORDERING def configure_form(self, f): - super(OrderingBatchView, self).configure_form(f) + super().configure_form(f) batch = f.model_instance + workflow = self.request.matchdict.get('workflow_key') # purchase if self.creating or not batch.executed or not batch.purchase: f.remove_field('purchase') + # now that all fields are setup, some final tweaks based on workflow + if self.creating and workflow: + + if workflow == 'from_scratch': + f.remove('order_file', + 'order_parser_key') + + elif workflow == 'from_file': + f.set_required('order_file') + def get_batch_kwargs(self, batch, **kwargs): - kwargs = super(OrderingBatchView, self).get_batch_kwargs(batch, **kwargs) + kwargs = super().get_batch_kwargs(batch, **kwargs) kwargs['ship_method'] = batch.ship_method kwargs['notes_to_vendor'] = batch.notes_to_vendor return kwargs @@ -158,7 +169,7 @@ class OrderingBatchView(PurchasingBatchView): * ``cases_ordered`` * ``units_ordered`` """ - super(OrderingBatchView, self).configure_row_form(f) + super().configure_row_form(f) # when editing, only certain fields should allow changes if self.editing: @@ -244,7 +255,7 @@ class OrderingBatchView(PurchasingBatchView): assert not (batch.executed or batch.complete) uuid = data.get('row_uuid') - row = self.Session.query(self.model_row_class).get(uuid) if uuid else None + row = self.Session.get(self.model_row_class, uuid) if uuid else None if not row: return {'error': "Row not found"} if row.batch is not batch or row.removed: @@ -311,11 +322,7 @@ class OrderingBatchView(PurchasingBatchView): title = self.get_instance_title(batch) order_date = batch.date_ordered if not order_date: - order_date = localtime(self.rattail_config).date() - - buefy_data = None - if self.get_use_buefy(): - buefy_data = self.get_worksheet_buefy_data(departments) + order_date = self.app.today() return self.render_to_response('worksheet', { 'batch': batch, @@ -329,13 +336,13 @@ class OrderingBatchView(PurchasingBatchView): 'get_upc': lambda p: p.upc.pretty() if p.upc else '', 'header_columns': self.order_form_header_columns, 'ignore_cases': not self.handler.allow_cases(), - 'worksheet_data': buefy_data, + 'worksheet_data': self.get_worksheet_data(departments), }) - def get_worksheet_buefy_data(self, departments): + def get_worksheet_data(self, departments): data = {} - for department in six.itervalues(departments): - for subdepartment in six.itervalues(department._order_subdepartments): + for department in departments.values(): + for subdepartment in department._order_subdepartments.values(): for i, cost in enumerate(subdepartment._order_costs, 1): cases = int(cost._batchrow.cases_ordered or 0) if cost._batchrow else None units = int(cost._batchrow.units_ordered or 0) if cost._batchrow else None @@ -376,6 +383,7 @@ class OrderingBatchView(PurchasingBatchView): of being updated. If a matching row is not found, it will not be created. """ + model = self.app.model batch = self.get_instance() try: @@ -406,7 +414,7 @@ class OrderingBatchView(PurchasingBatchView): return {'error': "Invalid value for units ordered: {}".format(units_ordered)} uuid = data.get('product_uuid') - product = self.Session.query(model.Product).get(uuid) if uuid else None + product = self.Session.get(model.Product, uuid) if uuid else None if not product: return {'error': "Product not found"} @@ -433,7 +441,7 @@ class OrderingBatchView(PurchasingBatchView): self.handler.update_row_quantity(row, cases_ordered=cases_ordered, units_ordered=units_ordered) except Exception as error: - return {'error': six.text_type(error)} + return {'error': str(error)} else: # empty order quantities @@ -465,11 +473,12 @@ class OrderingBatchView(PurchasingBatchView): worksheet = workbook.active worksheet.title = "Purchase Order" worksheet.append(["Store", "Vendor", "Date ordered"]) - worksheet.append([batch.store.name, batch.vendor.name, batch.date_ordered.strftime('%m/%d/%Y')]) + date_ordered = batch.date_ordered.strftime('%m/%d/%Y') if batch.date_ordered else None + worksheet.append([batch.store.name, batch.vendor.name, date_ordered]) worksheet.append([]) worksheet.append(['vendor_code', 'upc', 'brand_name', 'description', 'cases_ordered', 'units_ordered']) for row in batch.active_rows(): - worksheet.append([row.vendor_code, six.text_type(row.upc), row.brand_name, + worksheet.append([row.vendor_code, str(row.upc), row.brand_name, '{} {}'.format(row.description, row.size), row.cases_ordered, row.units_ordered]) @@ -484,14 +493,75 @@ class OrderingBatchView(PurchasingBatchView): return self.file_response(path) def get_execute_success_url(self, batch, result, **kwargs): + model = self.app.model if isinstance(result, model.Purchase): return self.request.route_url('purchases.view', uuid=result.uuid) - return super(OrderingBatchView, self).get_execute_success_url(batch, result, **kwargs) + return super().get_execute_success_url(batch, result, **kwargs) + + def configure_get_simple_settings(self): + return [ + + # workflows + {'section': 'rattail.batch', + 'option': 'purchase.allow_ordering_from_scratch', + 'type': bool, + 'default': True}, + {'section': 'rattail.batch', + 'option': 'purchase.allow_ordering_from_file', + 'type': bool, + 'default': True}, + + # vendors + {'section': 'rattail.batch', + 'option': 'purchase.allow_ordering_any_vendor', + 'type': bool, + 'default': True, + }, + ] + + def configure_get_context(self): + context = super().configure_get_context() + vendor_handler = self.app.get_vendor_handler() + + Parsers = vendor_handler.get_all_order_parsers() + Supported = vendor_handler.get_supported_order_parsers() + context['order_parsers'] = Parsers + context['order_parsers_data'] = dict([(Parser.key, Parser in Supported) + for Parser in Parsers]) + + return context + + def configure_gather_settings(self, data): + settings = super().configure_gather_settings(data) + vendor_handler = self.app.get_vendor_handler() + + supported = [] + for Parser in vendor_handler.get_all_order_parsers(): + name = f'order_parser_{Parser.key}' + if data.get(name) == 'true': + supported.append(Parser.key) + settings.append({'name': 'rattail.vendors.supported_order_parsers', + 'value': ', '.join(supported)}) + + return settings + + def configure_remove_settings(self): + super().configure_remove_settings() + + names = [ + 'rattail.vendors.supported_order_parsers', + ] + + # nb. using thread-local session here; we do not use + # self.Session b/c it may not point to Rattail + session = Session() + for name in names: + self.app.delete_setting(session, name) @classmethod def defaults(cls, config): cls._ordering_defaults(config) - cls._purchasing_defaults(config) + cls._purchase_batch_defaults(config) cls._batch_defaults(config) cls._defaults(config) diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index 7b668dc5..01858c98 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,29 +24,23 @@ Views for 'receiving' (purchasing) batches """ -from __future__ import unicode_literals, absolute_import - -import re +import os import decimal import logging +from collections import OrderedDict -import six -import humanize -import sqlalchemy as sa +# import humanize from rattail import pod -from rattail.db import model, Session as RattailSession -from rattail.time import localtime, make_utc -from rattail.util import pretty_quantity, prettify, OrderedDict, simple_error -from rattail.threads import Thread +from rattail.util import simple_error import colander from deform import widget as dfwidget -from pyramid import httpexceptions from webhelpers2.html import tags, HTML -from tailbone import forms, grids -from tailbone.util import get_form_data +from wuttaweb.util import get_form_data + +from tailbone import forms from tailbone.views.purchasing import PurchasingBatchView @@ -85,12 +79,9 @@ class ReceivingBatchView(PurchasingBatchView): rows_editable = False rows_editable_but_not_directly = True - rows_deletable = True default_uom_is_case = True - purchase_order_fieldname = 'purchase' - labels = { 'truck_dump_batch': "Truck Dump Parent", 'invoice_parser_key': "Invoice Parser", @@ -117,7 +108,7 @@ class ReceivingBatchView(PurchasingBatchView): 'store', 'vendor', 'description', - 'receiving_workflow', + 'workflow', 'truck_dump', 'truck_dump_children_first', 'truck_dump_children', @@ -168,7 +159,6 @@ class ReceivingBatchView(PurchasingBatchView): 'cases_received', 'units_received', 'catalog_unit_cost', - 'po_unit_cost', 'invoice_unit_cost', 'invoice_total_calculated', 'credits', @@ -212,6 +202,7 @@ class ReceivingBatchView(PurchasingBatchView): 'po_unit_cost', 'po_case_size', 'po_total', + 'invoice_number', 'invoice_line_number', 'invoice_unit_cost', 'invoice_cost_confirmed', @@ -239,180 +230,36 @@ class ReceivingBatchView(PurchasingBatchView): return self.enum.PURCHASE_BATCH_MODE_RECEIVING def configure_grid(self, g): - super(ReceivingBatchView, self).configure_grid(g) + super().configure_grid(g) if not self.handler.allow_truck_dump_receiving(): g.remove('truck_dump') - def create(self, form=None, **kwargs): - """ - Custom view for creating a new receiving batch. We split the process - into two steps, 1) choose and 2) create. This is because the specific - form details for creating a batch will depend on which "type" of batch - creation is to be done, and it's much easier to keep conditional logic - for that in the server instead of client-side etc. - - See also - :meth:`tailbone.views.purchasing.costing:CostingBatchView.create()` - which uses similar logic. - """ - route_prefix = self.get_route_prefix() - workflows = self.handler.supported_receiving_workflows() - valid_workflows = [workflow['workflow_key'] - for workflow in workflows] - - # if user has already identified their desired workflow, then we can - # just farm out to the default logic. we will of course configure our - # form differently, based on workflow, but this create() method at - # least will not need customization for that. - if self.request.matched_route.name.endswith('create_workflow'): - - redirect = self.redirect(self.request.route_url('{}.create'.format(route_prefix))) - - # however we do have one more thing to check - the workflow - # requested must of course be valid! - workflow_key = self.request.matchdict['workflow_key'] - if workflow_key not in valid_workflows: - self.request.session.flash( - "Not a supported workflow: {}".format(workflow_key), - 'error') - raise redirect - - # also, we require vendor to be correctly identified. if - # someone e.g. navigates to a URL by accident etc. we want - # to gracefully handle and redirect - uuid = self.request.matchdict['vendor_uuid'] - vendor = self.Session.query(model.Vendor).get(uuid) - if not vendor: - self.request.session.flash("Invalid vendor selection. " - "Please choose an existing vendor.", - 'warning') - raise redirect - - # okay now do the normal thing, per workflow - return super(ReceivingBatchView, self).create(**kwargs) - - # on the other hand, if caller provided a form, that means we are in - # the middle of some other custom workflow, e.g. "add child to truck - # dump parent" or some such. in which case we also defer to the normal - # logic, so as to not interfere with that. - if form: - return super(ReceivingBatchView, self).create(form=form, **kwargs) - - # okay, at this point we need the user to select a vendor and workflow - self.creating = True - use_buefy = self.get_use_buefy() - context = {} - - # form to accept user choice of vendor/workflow - schema = NewReceivingBatch().bind(valid_workflows=valid_workflows) - form = forms.Form(schema=schema, request=self.request, - use_buefy=use_buefy) - - # configure vendor field - app = self.get_rattail_app() - vendor_handler = app.get_vendor_handler() - if self.rattail_config.getbool('rattail.batch', 'purchase.supported_vendors_only'): - # only show vendors for which we have dedicated invoice parsers - vendors = {} - for parser in self.batch_handler.get_supported_invoice_parsers(): - if parser.vendor_key: - vendor = vendor_handler.get_vendor(self.Session(), - parser.vendor_key) - if vendor: - vendors[vendor.uuid] = vendor - vendors = sorted(vendors.values(), key=lambda v: v.name) - vendor_values = [(vendor.uuid, vendor_handler.render_vendor(vendor)) - for vendor in vendors] - if use_buefy: - form.set_widget('vendor', dfwidget.SelectWidget(values=vendor_values)) - else: - form.set_widget('vendor', forms.widgets.JQuerySelectWidget(values=vendor_values)) - else: - # user may choose *any* available vendor - use_dropdown = vendor_handler.choice_uses_dropdown() - if use_dropdown: - vendors = self.Session.query(model.Vendor)\ - .order_by(model.Vendor.id)\ - .all() - vendor_values = [(vendor.uuid, "({}) {}".format(vendor.id, vendor.name)) - for vendor in vendors] - if use_buefy: - form.set_widget('vendor', dfwidget.SelectWidget(values=vendor_values)) - else: - form.set_widget('vendor', forms.widgets.JQuerySelectWidget(values=vendor_values)) - if len(vendors) == 1: - form.set_default('vendor', vendors[0].uuid) - else: - vendor_display = "" - if self.request.method == 'POST': - if self.request.POST.get('vendor'): - vendor = self.Session.query(model.Vendor).get(self.request.POST['vendor']) - if vendor: - vendor_display = six.text_type(vendor) - vendors_url = self.request.route_url('vendors.autocomplete') - form.set_widget('vendor', forms.widgets.JQueryAutocompleteWidget( - field_display=vendor_display, service_url=vendors_url)) - form.set_validator('vendor', self.valid_vendor_uuid) - - # configure workflow field - values = [(workflow['workflow_key'], workflow['display']) - for workflow in workflows] - if use_buefy: - form.set_widget('workflow', - dfwidget.SelectWidget(values=values)) - else: - form.set_widget('workflow', - forms.widgets.JQuerySelectWidget(values=values)) - if len(workflows) == 1: - form.set_default('workflow', workflows[0]['workflow_key']) - - form.submit_label = "Continue" - form.cancel_url = self.get_index_url() - - # if form validates, that means user has chosen a creation type, so we - # just redirect to the appropriate "new batch of type X" page - if form.validate(newstyle=True): - workflow_key = form.validated['workflow'] - vendor_uuid = form.validated['vendor'] - url = self.request.route_url('{}.create_workflow'.format(route_prefix), - workflow_key=workflow_key, - vendor_uuid=vendor_uuid) - raise self.redirect(url) - - context['form'] = form - if hasattr(form, 'make_deform_form'): - context['dform'] = form.make_deform_form() - return self.render_to_response('create', context) + def get_supported_vendors(self): + """ """ + vendor_handler = self.app.get_vendor_handler() + vendors = {} + for parser in self.batch_handler.get_supported_invoice_parsers(): + if parser.vendor_key: + vendor = vendor_handler.get_vendor(self.Session(), + parser.vendor_key) + if vendor: + vendors[vendor.uuid] = vendor + vendors = sorted(vendors.values(), key=lambda v: v.name) + return vendors def row_deletable(self, row): # first run it through the normal logic, if that doesn't like # it then we won't either - if not super(ReceivingBatchView, self).row_deletable(row): + if not super().row_deletable(row): return False - batch = row.batch - - # can always delete rows from truck dump parent - if batch.is_truck_dump_parent(): - return True - - # can always delete rows from truck dump child - elif batch.is_truck_dump_child(): - return True - - else: # okay, normal batch - if batch.order_quantities_known: - return False - else: # allow delete if receiving rom scratch - return True - - # cannot delete row by default - return False + # otherwise let handler decide + return self.batch_handler.is_row_deletable(row) def get_instance_title(self, batch): - title = super(ReceivingBatchView, self).get_instance_title(batch) + title = super().get_instance_title(batch) if batch.is_truck_dump_parent(): title = "{} (TRUCK DUMP PARENT)".format(title) elif batch.is_truck_dump_child(): @@ -420,34 +267,27 @@ class ReceivingBatchView(PurchasingBatchView): return title def configure_form(self, f): - super(ReceivingBatchView, self).configure_form(f) + super().configure_form(f) model = self.model batch = f.model_instance allow_truck_dump = self.batch_handler.allow_truck_dump_receiving() workflow = self.request.matchdict.get('workflow_key') route_prefix = self.get_route_prefix() - use_buefy = self.get_use_buefy() # tweak some things if we are in "step 2" of creating new batch if self.creating and workflow: # display vendor but do not allow changing - vendor = self.Session.query(model.Vendor).get( - self.request.matchdict['vendor_uuid']) + vendor = self.Session.get(model.Vendor, + self.request.matchdict['vendor_uuid']) assert vendor f.set_readonly('vendor_uuid') - f.set_default('vendor_uuid', six.text_type(vendor)) + f.set_default('vendor_uuid', str(vendor)) # cancel should take us back to choosing a workflow f.cancel_url = self.request.route_url('{}.create'.format(route_prefix)) - # receiving_workflow - if self.creating and workflow: - f.set_readonly('receiving_workflow') - f.set_renderer('receiving_workflow', self.render_receiving_workflow) - else: - f.remove('receiving_workflow') - + # TODO: remove this # batch_type if self.creating: f.set_widget('batch_type', dfwidget.HiddenWidget()) @@ -529,19 +369,28 @@ class ReceivingBatchView(PurchasingBatchView): f.set_widget('store_uuid', dfwidget.HiddenWidget()) # purchase - if (self.creating and workflow in ('from_po', 'from_po_with_invoice') - and self.purchase_order_fieldname == 'purchase'): - if use_buefy: - f.replace('purchase', 'purchase_uuid') - purchases = self.batch_handler.get_eligible_purchases( - vendor, self.enum.PURCHASE_BATCH_MODE_RECEIVING) - values = [(p.uuid, self.batch_handler.render_eligible_purchase(p)) - for p in purchases] - f.set_widget('purchase_uuid', dfwidget.SelectWidget(values=values)) - f.set_label('purchase_uuid', "Purchase Order") - f.set_required('purchase_uuid') - elif self.creating or not batch.purchase: + field = self.batch_handler.get_purchase_order_fieldname() + if field == 'purchase': + field = 'purchase_uuid' + # TODO: workflow "invoice_with_po" is for costing mode, should rename? + if self.creating and workflow in ( + 'from_po', 'from_po_with_invoice', 'invoice_with_po'): + f.replace('purchase', field) + purchases = self.batch_handler.get_eligible_purchases( + vendor, self.batch_mode) + values = [(self.batch_handler.get_eligible_purchase_key(p), + self.batch_handler.render_eligible_purchase(p)) + for p in purchases] + f.set_widget(field, dfwidget.SelectWidget(values=values)) + if field == 'purchase_uuid': + f.set_label(field, "Purchase Order") + f.set_required(field) + elif self.creating: f.remove_field('purchase') + else: # not creating + if field != 'purchase_uuid': + f.replace('purchase', field) + f.set_renderer(field, self.render_purchase) # department if self.creating: @@ -551,6 +400,16 @@ class ReceivingBatchView(PurchasingBatchView): if not self.editing: f.remove_field('order_quantities_known') + # multiple invoice files (if applicable) + if (not self.creating + and batch.get_param('workflow') == 'from_multi_invoice'): + + if 'invoice_files' not in f: + f.insert_before('invoice_file', 'invoice_files') + f.set_renderer('invoice_files', self.render_invoice_files) + f.set_readonly('invoice_files', True) + f.remove('invoice_file') + # invoice totals f.set_label('invoice_total', "Invoice Total (Orig.)") f.set_label('invoice_total_calculated', "Invoice Total (Calc.)") @@ -584,6 +443,17 @@ class ReceivingBatchView(PurchasingBatchView): 'invoice_date', 'invoice_number') + elif workflow == 'from_multi_invoice': + if 'invoice_files' not in f: + f.insert_before('invoice_file', 'invoice_files') + f.set_type('invoice_files', 'multi_file', validate_unique=True) + f.set_required('invoice_parser_key') + f.remove('truck_dump_batch_uuid', + 'po_number', + 'invoice_file', + 'invoice_date', + 'invoice_number') + elif workflow == 'from_po': f.remove('truck_dump_batch_uuid', 'date_ordered', @@ -620,14 +490,28 @@ class ReceivingBatchView(PurchasingBatchView): 'invoice_date', 'invoice_number') - def render_receiving_workflow(self, batch, field): - key = self.request.matchdict['workflow_key'] - info = self.handler.receiving_workflow_info(key) - if info: - return info['display'] + def render_invoice_files(self, batch, field): + datadir = self.batch_handler.datadir(batch) + items = [] + for filename in batch.get_param('invoice_files', []): + path = os.path.join(datadir, filename) + url = self.get_action_url('download', batch, + _query={'filename': filename}) + link = self.render_file_field(path, url) + items.append(HTML.tag('li', c=[link])) + return HTML.tag('ul', c=items) + + def get_visible_params(self, batch): + params = super().get_visible_params(batch) + + # remove this since we show it separately + params.pop('invoice_files', None) + + return params def template_kwargs_create(self, **kwargs): - kwargs = super(ReceivingBatchView, self).template_kwargs_create(**kwargs) + kwargs = super().template_kwargs_create(**kwargs) + model = self.model if self.handler.allow_truck_dump_receiving(): vmap = {} batches = self.Session.query(model.PurchaseBatch)\ @@ -640,41 +524,41 @@ class ReceivingBatchView(PurchasingBatchView): return kwargs def get_batch_kwargs(self, batch, **kwargs): - kwargs = super(ReceivingBatchView, self).get_batch_kwargs(batch, **kwargs) - batch_type = self.request.POST['batch_type'] + kwargs = super().get_batch_kwargs(batch, **kwargs) # must pull vendor from URL if it was not in form data if 'vendor_uuid' not in kwargs and 'vendor' not in kwargs: if 'vendor_uuid' in self.request.matchdict: kwargs['vendor_uuid'] = self.request.matchdict['vendor_uuid'] - # TODO: ugh should just have workflow and no batch_type - kwargs['receiving_workflow'] = batch_type - if batch_type == 'from_scratch': + workflow = kwargs['workflow'] + if workflow == 'from_scratch': kwargs.pop('truck_dump_batch', None) kwargs.pop('truck_dump_batch_uuid', None) - elif batch_type == 'from_invoice': + elif workflow == 'from_invoice': pass - elif batch_type == 'from_po': + elif workflow == 'from_multi_invoice': + pass + elif workflow == 'from_po': # TODO: how to best handle this field? this doesn't seem flexible kwargs['purchase_key'] = batch.purchase_uuid - elif batch_type == 'from_po_with_invoice': + elif workflow == 'from_po_with_invoice': # TODO: how to best handle this field? this doesn't seem flexible kwargs['purchase_key'] = batch.purchase_uuid - elif batch_type == 'truck_dump_children_first': + elif workflow == 'truck_dump_children_first': kwargs['truck_dump'] = True kwargs['truck_dump_children_first'] = True kwargs['order_quantities_known'] = True # TODO: this makes sense in some cases, but all? # (should just omit that field when not relevant) kwargs['date_ordered'] = None - elif batch_type == 'truck_dump_children_last': + elif workflow == 'truck_dump_children_last': kwargs['truck_dump'] = True kwargs['truck_dump_ready'] = True # TODO: this makes sense in some cases, but all? # (should just omit that field when not relevant) kwargs['date_ordered'] = None - elif batch_type.startswith('truck_dump_child'): + elif workflow.startswith('truck_dump_child'): truck_dump = self.get_instance() kwargs['store'] = truck_dump.store kwargs['vendor'] = truck_dump.vendor @@ -720,42 +604,65 @@ class ReceivingBatchView(PurchasingBatchView): return breakdown def allow_edit_catalog_unit_cost(self, batch): - return (not batch.executed - and self.has_perm('edit_row') - and self.batch_handler.allow_receiving_edit_catalog_unit_cost()) + + # batch must not yet be frozen + if batch.executed or batch.complete: + return False + + # user must have edit_row perm + if not self.has_perm('edit_row'): + return False + + # config must allow this generally + if not self.batch_handler.allow_receiving_edit_catalog_unit_cost(): + return False + + return True def allow_edit_invoice_unit_cost(self, batch): - return (not batch.executed - and self.has_perm('edit_row') - and self.batch_handler.allow_receiving_edit_invoice_unit_cost()) + + # batch must not yet be frozen + if batch.executed or batch.complete: + return False + + # user must have edit_row perm + if not self.has_perm('edit_row'): + return False + + # config must allow this generally + if not self.batch_handler.allow_receiving_edit_invoice_unit_cost(): + return False + + return True def template_kwargs_view(self, **kwargs): - kwargs = super(ReceivingBatchView, self).template_kwargs_view(**kwargs) + kwargs = super().template_kwargs_view(**kwargs) batch = kwargs['instance'] - use_buefy = self.get_use_buefy() if self.handler.has_purchase_order(batch) and self.handler.has_invoice_file(batch): breakdown = self.make_po_vs_invoice_breakdown(batch) factory = self.get_grid_factory() - if use_buefy: - g = factory('batch_po_vs_invoice_breakdown', [], - columns=['title', 'count']) - g.set_click_handler('title', "autoFilterPoVsInvoice(props.row)") - kwargs['po_vs_invoice_breakdown_data'] = breakdown - kwargs['po_vs_invoice_breakdown_grid'] = HTML.literal( - g.render_buefy_table_element(data_prop='poVsInvoiceBreakdownData', - empty_labels=True)) - - else: - kwargs['po_vs_invoice_breakdown_grid'] = factory( - 'batch_po_vs_invoice_breakdown', - data=breakdown, - columns=['title', 'count']) + g = factory(self.request, + key='batch_po_vs_invoice_breakdown', + data=[], + columns=['title', 'count']) + g.set_click_handler('title', "autoFilterPoVsInvoice(props.row)") + kwargs['po_vs_invoice_breakdown_data'] = breakdown + kwargs['po_vs_invoice_breakdown_grid'] = HTML.literal( + g.render_table_element(data_prop='poVsInvoiceBreakdownData', + empty_labels=True)) kwargs['allow_edit_catalog_unit_cost'] = self.allow_edit_catalog_unit_cost(batch) kwargs['allow_edit_invoice_unit_cost'] = self.allow_edit_invoice_unit_cost(batch) + if (kwargs['allow_edit_catalog_unit_cost'] + and kwargs['allow_edit_invoice_unit_cost'] + and not batch.get_param('confirmed_all_costs')): + kwargs['allow_confirm_all_costs'] = True + else: + kwargs['allow_confirm_all_costs'] = False + return kwargs def get_context_credits(self, row): @@ -765,7 +672,7 @@ class ReceivingBatchView(PurchasingBatchView): credits_data.append({ 'uuid': credit.uuid, 'credit_type': credit.credit_type, - 'expiration_date': six.text_type(credit.expiration_date) if credit.expiration_date else None, + 'expiration_date': str(credit.expiration_date) if credit.expiration_date else None, 'cases_shorted': app.render_quantity(credit.cases_shorted), 'units_shorted': app.render_quantity(credit.units_shorted), 'shorted': app.render_cases_units(credit.cases_shorted, @@ -779,8 +686,7 @@ class ReceivingBatchView(PurchasingBatchView): return credits_data def template_kwargs_view_row(self, **kwargs): - kwargs = super(ReceivingBatchView, self).template_kwargs_view_row(**kwargs) - use_buefy = self.get_use_buefy() + kwargs = super().template_kwargs_view_row(**kwargs) app = self.get_rattail_app() products_handler = app.get_products_handler() row = kwargs['instance'] @@ -792,18 +698,17 @@ class ReceivingBatchView(PurchasingBatchView): elif row.upc: kwargs['image_url'] = products_handler.get_image_url(upc=row.upc) - if use_buefy: - kwargs['row_context'] = self.get_context_row(row) + kwargs['row_context'] = self.get_context_row(row) - modes = list(POSSIBLE_RECEIVING_MODES) - types = list(POSSIBLE_CREDIT_TYPES) - if not self.batch_handler.allow_expired_credits(): - if 'expired' in modes: - modes.remove('expired') - if 'expired' in types: - types.remove('expired') - kwargs['possible_receiving_modes'] = modes - kwargs['possible_credit_types'] = types + modes = list(POSSIBLE_RECEIVING_MODES) + types = list(POSSIBLE_CREDIT_TYPES) + if not self.batch_handler.allow_expired_credits(): + if 'expired' in modes: + modes.remove('expired') + if 'expired' in types: + types.remove('expired') + kwargs['possible_receiving_modes'] = modes + kwargs['possible_credit_types'] = types return kwargs @@ -818,7 +723,7 @@ class ReceivingBatchView(PurchasingBatchView): if batch.is_truck_dump_parent(): for child in batch.truck_dump_children: self.delete_instance(child) - super(ReceivingBatchView, self).delete_instance(batch) + super().delete_instance(batch) if truck_dump: self.handler.refresh(truck_dump) @@ -920,28 +825,30 @@ class ReceivingBatchView(PurchasingBatchView): def render_truck_dump_parent(self, batch, field): truck_dump = self.get_instance() - text = six.text_type(truck_dump) + text = str(truck_dump) url = self.request.route_url('receiving.view', uuid=truck_dump.uuid) return tags.link_to(text, url) - @staticmethod - @colander.deferred - def validate_purchase(node, kw): - session = kw['session'] - def validate(node, value): - purchase = session.query(model.Purchase).get(value) - if not purchase: - raise colander.Invalid(node, "Purchase not found") - return purchase.uuid - return validate + # TODO: is this actually used? wait to see if something breaks.. + # @staticmethod + # @colander.deferred + # def validate_purchase(node, kw): + # session = kw['session'] + # def validate(node, value): + # purchase = session.get(model.Purchase, value) + # if not purchase: + # raise colander.Invalid(node, "Purchase not found") + # return purchase.uuid + # return validate def assign_purchase_order(self, batch, po_form): """ Assign the original purchase order to the given batch. Default behavior assumes a Rattail Purchase object is what we're after. """ + field = self.batch_handler.get_purchase_order_fieldname() purchase = self.handler.assign_purchase_order( - batch, po_form.validated[self.purchase_order_fieldname], + batch, po_form.validated[field], session=self.Session()) department = self.department_for_purchase(purchase) @@ -949,8 +856,8 @@ class ReceivingBatchView(PurchasingBatchView): batch.department_uuid = department.uuid def configure_row_grid(self, g): - super(ReceivingBatchView, self).configure_row_grid(g) - use_buefy = self.get_use_buefy() + super().configure_row_grid(g) + model = self.model batch = self.get_instance() # vendor_code @@ -958,37 +865,18 @@ class ReceivingBatchView(PurchasingBatchView): g.filters['vendor_code'].default_verb = 'contains' # catalog_unit_cost - if (self.handler.has_purchase_order(batch) - or self.handler.has_invoice_file(batch)): - g.remove('catalog_unit_cost') - elif use_buefy and self.allow_edit_catalog_unit_cost(batch): + g.set_renderer('catalog_unit_cost', self.render_simple_unit_cost) + if self.allow_edit_catalog_unit_cost(batch): g.set_raw_renderer('catalog_unit_cost', self.render_catalog_unit_cost) g.set_click_handler('catalog_unit_cost', - 'catalogUnitCostClicked(props.row)') + 'this.catalogUnitCostClicked') # invoice_unit_cost - if use_buefy and self.allow_edit_invoice_unit_cost(batch): + g.set_renderer('invoice_unit_cost', self.render_simple_unit_cost) + if self.allow_edit_invoice_unit_cost(batch): g.set_raw_renderer('invoice_unit_cost', self.render_invoice_unit_cost) g.set_click_handler('invoice_unit_cost', - 'invoiceUnitCostClicked(props.row)') - - # nb. only show PO *or* invoice cost; prefer the latter unless - # we have a PO and no invoice - if (self.batch_handler.has_purchase_order(batch) - and not self.batch_handler.has_invoice_file(batch)): - g.remove('invoice_unit_cost') - else: - g.remove('po_unit_cost') - - # credits - # note that sorting by credits involves a subquery with group by clause. - # seems likely there may be a better way? but this seems to work fine - Credits = self.Session.query(model.PurchaseBatchCredit.row_uuid, - sa.func.count().label('credit_count'))\ - .group_by(model.PurchaseBatchCredit.row_uuid)\ - .subquery() - g.set_joiner('credits', lambda q: q.outerjoin(Credits)) - g.sorters['credits'] = lambda q, d: q.order_by(getattr(Credits.c.credit_count, d)()) + 'this.invoiceUnitCostClicked') show_ordered = self.rattail_config.getbool( 'rattail.batch', 'purchase.receiving.show_ordered_column_in_grid', @@ -1014,14 +902,16 @@ class ReceivingBatchView(PurchasingBatchView): if batch.is_truck_dump_parent(): permission_prefix = self.get_permission_prefix() if self.request.has_perm('{}.edit_row'.format(permission_prefix)): - transform = grids.GridAction('transform', + transform = self.make_action('transform', icon='shuffle', label="Transform to Unit", url=self.transform_unit_url) - g.more_actions.append(transform) - if g.main_actions and g.main_actions[-1].key == 'delete': - delete = g.main_actions.pop() - g.more_actions.append(delete) + if g.actions and g.actions[-1].key == 'delete': + delete = g.actions.pop() + g.actions.append(transform) + g.actions.append(delete) + else: + g.actions.append(transform) # truck_dump_status if not batch.is_truck_dump_parent(): @@ -1029,6 +919,19 @@ class ReceivingBatchView(PurchasingBatchView): else: g.set_enum('truck_dump_status', model.PurchaseBatchRow.STATUS) + def render_simple_unit_cost(self, row, field): + value = getattr(row, field) + if value is None: + return + + # TODO: if anyone ever wants to see "raw" costs displayed, + # should make this configurable, b/c some folks already wanted + # the shorter 2-decimal display + #return str(value) + + app = self.get_rattail_app() + return app.render_currency(value) + def render_catalog_unit_cost(self): return HTML.tag('receiving-cost-editor', **{ 'field': 'catalog_unit_cost', @@ -1048,7 +951,7 @@ class ReceivingBatchView(PurchasingBatchView): }) def row_grid_extra_class(self, row, i): - css_class = super(ReceivingBatchView, self).row_grid_extra_class(row, i) + css_class = super().row_grid_extra_class(row, i) if row.catalog_cost_confirmed: css_class = '{} catalog_cost_confirmed'.format(css_class or '') @@ -1060,10 +963,10 @@ class ReceivingBatchView(PurchasingBatchView): def get_row_instance_title(self, row): if row.product: - return six.text_type(row.product) + return str(row.product) if row.upc: return row.upc.pretty() - return super(ReceivingBatchView, self).get_row_instance_title(row) + return super().get_row_instance_title(row) def transform_unit_url(self, row, i): # grid action is shown only when we return a URL here @@ -1075,14 +978,13 @@ class ReceivingBatchView(PurchasingBatchView): def make_row_credits_grid(self, row): # first make grid like normal - g = super(ReceivingBatchView, self).make_row_credits_grid(row) + g = super().make_row_credits_grid(row) - if (self.get_use_buefy() - and self.has_perm('edit_row') + if (self.has_perm('edit_row') and self.row_editable(row)): # add the Un-Declare action - g.main_actions.append(self.make_action( + g.actions.append(self.make_action( 'remove', label="Un-Declare", url='#', icon='trash', link_class='has-text-danger', @@ -1106,61 +1008,58 @@ class ReceivingBatchView(PurchasingBatchView): """ Primary desktop view for row-level receiving. """ + app = self.get_rattail_app() # TODO: this code was largely copied from mobile_receive_row() but it # tries to pave the way for shared logic, i.e. where the latter would # simply invoke this method and return the result. however we're not # there yet...for now it's only tested for desktop self.viewing = True - use_buefy = self.get_use_buefy() row = self.get_row_instance() - # things are a bit different now w/ buefy support.. - if use_buefy: + # don't even bother showing this page if that's all the + # request was about + if self.request.method == 'GET': + return self.redirect(self.get_row_action_url('view', row)) - # don't even bother showing this page if that's all the - # request was about - if self.request.method == 'GET': - return self.redirect(self.get_row_action_url('view', row)) + # make sure edit is allowed + if not (self.has_perm('edit_row') and self.row_editable(row)): + raise self.forbidden() - # make sure edit is allowed - if not (self.has_perm('edit_row') and self.row_editable(row)): - raise self.forbidden() + # check for JSON POST, which is submitted via AJAX from + # the "view row" page + if self.request.method == 'POST' and not self.request.POST: + data = self.request.json_body + kwargs = dict(data) - # check for JSON POST, which is submitted via AJAX from - # the "view row" page - if self.request.method == 'POST' and not self.request.POST: - data = self.request.json_body - kwargs = dict(data) + # TODO: for some reason quantities can come through as strings? + cases = kwargs['quantity']['cases'] + if cases is not None: + if cases == '': + cases = None + else: + cases = decimal.Decimal(cases) + kwargs['cases'] = cases + units = kwargs['quantity']['units'] + if units is not None: + if units == '': + units = None + else: + units = decimal.Decimal(units) + kwargs['units'] = units + del kwargs['quantity'] - # TODO: for some reason quantities can come through as strings? - cases = kwargs['quantity']['cases'] - if cases is not None: - if cases == '': - cases = None - else: - cases = decimal.Decimal(cases) - kwargs['cases'] = cases - units = kwargs['quantity']['units'] - if units is not None: - if units == '': - units = None - else: - units = decimal.Decimal(units) - kwargs['units'] = units - del kwargs['quantity'] + # handler takes care of the receiving logic for us + try: + self.batch_handler.receive_row(row, **kwargs) - # handler takes care of the receiving logic for us - try: - self.batch_handler.receive_row(row, **kwargs) + except Exception as error: + return self.json_response({'error': str(error)}) - except Exception as error: - return self.json_response({'error': six.text_type(error)}) - - self.Session.flush() - self.Session.refresh(row) - return self.json_response({ - 'ok': True, - 'row': self.get_context_row(row)}) + self.Session.flush() + self.Session.refresh(row) + return self.json_response({ + 'ok': True, + 'row': self.get_context_row(row)}) batch = row.batch permission_prefix = self.get_permission_prefix() @@ -1184,15 +1083,12 @@ class ReceivingBatchView(PurchasingBatchView): } schema = ReceiveRowForm().bind(session=self.Session()) - form = forms.Form(schema=schema, request=self.request, use_buefy=use_buefy) + form = forms.Form(schema=schema, request=self.request) form.cancel_url = self.get_row_action_url('view', row) # mode mode_values = [(mode, mode) for mode in possible_modes] - if use_buefy: - mode_widget = dfwidget.SelectWidget(values=mode_values) - else: - mode_widget = forms.widgets.JQuerySelectWidget(values=mode_values) + mode_widget = dfwidget.SelectWidget(values=mode_values) form.set_widget('mode', mode_widget) # quantity @@ -1206,7 +1102,7 @@ class ReceivingBatchView(PurchasingBatchView): # TODO: what is this one about again? form.remove_field('quick_receive') - if form.validate(newstyle=True): + if form.validate(): # handler takes care of the row receiving logic for us kwargs = dict(form.validated) @@ -1246,7 +1142,7 @@ class ReceivingBatchView(PurchasingBatchView): if accounted_for: # some product accounted for; button should receive "remainder" only if remainder: - remainder = pretty_quantity(remainder) + remainder = app.render_quantity(remainder) context['quick_receive_quantity'] = remainder context['quick_receive_text'] = "Receive Remainder ({} {})".format(remainder, context['unit_uom']) else: @@ -1256,7 +1152,7 @@ class ReceivingBatchView(PurchasingBatchView): else: # nothing yet accounted for, button should receive "all" if not remainder: raise ValueError("why is remainder empty?") - remainder = pretty_quantity(remainder) + remainder = app.render_quantity(remainder) context['quick_receive_quantity'] = remainder context['quick_receive_text'] = "Receive ALL ({} {})".format(remainder, context['unit_uom']) @@ -1312,59 +1208,55 @@ class ReceivingBatchView(PurchasingBatchView): View for declaring a credit, i.e. converting some "received" or similar quantity, to a credit of some sort. """ - use_buefy = self.get_use_buefy() row = self.get_row_instance() - # things are a bit different now w/ buefy support.. - if use_buefy: + # don't even bother showing this page if that's all the + # request was about + if self.request.method == 'GET': + return self.redirect(self.get_row_action_url('view', row)) - # don't even bother showing this page if that's all the - # request was about - if self.request.method == 'GET': - return self.redirect(self.get_row_action_url('view', row)) + # make sure edit is allowed + if not (self.has_perm('edit_row') and self.row_editable(row)): + raise self.forbidden() - # make sure edit is allowed - if not (self.has_perm('edit_row') and self.row_editable(row)): - raise self.forbidden() + # check for JSON POST, which is submitted via AJAX from + # the "view row" page + if self.request.method == 'POST' and not self.request.POST: + data = self.request.json_body + kwargs = dict(data) - # check for JSON POST, which is submitted via AJAX from - # the "view row" page - if self.request.method == 'POST' and not self.request.POST: - data = self.request.json_body - kwargs = dict(data) + # TODO: for some reason quantities can come through as strings? + if kwargs['cases'] is not None: + if kwargs['cases'] == '': + kwargs['cases'] = None + else: + kwargs['cases'] = decimal.Decimal(kwargs['cases']) + if kwargs['units'] is not None: + if kwargs['units'] == '': + kwargs['units'] = None + else: + kwargs['units'] = decimal.Decimal(kwargs['units']) - # TODO: for some reason quantities can come through as strings? - if kwargs['cases'] is not None: - if kwargs['cases'] == '': - kwargs['cases'] = None - else: - kwargs['cases'] = decimal.Decimal(kwargs['cases']) - if kwargs['units'] is not None: - if kwargs['units'] == '': - kwargs['units'] = None - else: - kwargs['units'] = decimal.Decimal(kwargs['units']) + try: + result = self.handler.can_declare_credit(row, **kwargs) - try: - result = self.handler.can_declare_credit(row, **kwargs) + except Exception as error: + return self.json_response({'error': str(error)}) - except Exception as error: - return self.json_response({'error': six.text_type(error)}) + else: + if result: + self.handler.declare_credit(row, **kwargs) else: - if result: - self.handler.declare_credit(row, **kwargs) + return self.json_response({ + 'error': "Handler says you can't declare that credit; " + "not sure why"}) - else: - return self.json_response({ - 'error': "Handler says you can't declare that credit; " - "not sure why"}) - - self.Session.flush() - self.Session.refresh(row) - return self.json_response({ - 'ok': True, - 'row': self.get_context_row(row)}) + self.Session.flush() + self.Session.refresh(row) + return self.json_response({ + 'ok': True, + 'row': self.get_context_row(row)}) batch = row.batch context = { @@ -1380,16 +1272,12 @@ class ReceivingBatchView(PurchasingBatchView): } schema = DeclareCreditForm() - form = forms.Form(schema=schema, request=self.request, - use_buefy=use_buefy) + form = forms.Form(schema=schema, request=self.request) form.cancel_url = self.get_row_action_url('view', row) # credit_type values = [(m, m) for m in POSSIBLE_CREDIT_TYPES] - if use_buefy: - widget = dfwidget.SelectWidget(values=values) - else: - widget = forms.widgets.JQuerySelectWidget(values=values) + widget = dfwidget.SelectWidget(values=values) form.set_widget('credit_type', widget) # quantity @@ -1400,7 +1288,7 @@ class ReceivingBatchView(PurchasingBatchView): # expiration_date form.set_type('expiration_date', 'date_jquery') - if form.validate(newstyle=True): + if form.validate(): # handler takes care of the row receiving logic for us kwargs = dict(form.validated) @@ -1441,7 +1329,7 @@ class ReceivingBatchView(PurchasingBatchView): credit = None uuid = data.get('uuid') if uuid: - credit = self.Session.query(model.PurchaseBatchCredit).get(uuid) + credit = self.Session.get(model.PurchaseBatchCredit, uuid) if not credit: return {'error': "Credit not found"} @@ -1478,10 +1366,11 @@ class ReceivingBatchView(PurchasingBatchView): a "pack" item, such that it instead associates with the "unit" item, with quantities adjusted accordingly. """ + model = self.model batch = self.get_instance() row_uuid = self.request.params.get('row_uuid') - row = self.Session.query(model.PurchaseBatchRow).get(row_uuid) if row_uuid else None + row = self.Session.get(model.PurchaseBatchRow, row_uuid) if row_uuid else None if row and row.batch is batch and not row.removed: pass # we're good else: @@ -1522,7 +1411,8 @@ class ReceivingBatchView(PurchasingBatchView): }) def configure_row_form(self, f): - super(ReceivingBatchView, self).configure_row_form(f) + super().configure_row_form(f) + model = self.model batch = self.get_instance() # when viewing a row which has no product reference, enable @@ -1595,7 +1485,7 @@ class ReceivingBatchView(PurchasingBatchView): def validate_row_form(self, form): # if normal validation fails, stop there - if not super(ReceivingBatchView, self).validate_row_form(form): + if not super().validate_row_form(form): return False # if user is editing row from truck dump child, then we must further @@ -1699,6 +1589,7 @@ class ReceivingBatchView(PurchasingBatchView): return True def save_edit_row_form(self, form): + model = self.model batch = self.get_instance() row = self.objectify(form) @@ -1838,12 +1729,14 @@ class ReceivingBatchView(PurchasingBatchView): """ AJAX view for updating various cost fields in a data row. """ + app = self.get_rattail_app() + model = self.model batch = self.get_instance() data = dict(get_form_data(self.request)) # validate row uuid = data.get('row_uuid') - row = self.Session.query(model.PurchaseBatchRow).get(uuid) if uuid else None + row = self.Session.get(model.PurchaseBatchRow, uuid) if uuid else None if not row or row.batch is not batch: return {'error': "Row not found"} @@ -1854,7 +1747,7 @@ class ReceivingBatchView(PurchasingBatchView): if cost == '': return {'error': "You must specify a cost"} try: - cost = decimal.Decimal(six.text_type(cost)) + cost = decimal.Decimal(str(cost)) except decimal.InvalidOperation: return {'error': "Cost is not valid!"} else: @@ -1863,16 +1756,18 @@ class ReceivingBatchView(PurchasingBatchView): # okay, update our row self.handler.update_row_cost(row, **data) + self.Session.flush() + self.Session.refresh(row) return { 'row': { - 'catalog_unit_cost': '{:0.3f}'.format(row.catalog_unit_cost), + 'catalog_unit_cost': self.render_simple_unit_cost(row, 'catalog_unit_cost'), 'catalog_cost_confirmed': row.catalog_cost_confirmed, - 'invoice_unit_cost': '{:0.3f}'.format(row.invoice_unit_cost), + 'invoice_unit_cost': self.render_simple_unit_cost(row, 'invoice_unit_cost'), 'invoice_cost_confirmed': row.invoice_cost_confirmed, - 'invoice_total_calculated': '{:0.2f}'.format(row.invoice_total_calculated), + 'invoice_total_calculated': app.render_currency(row.invoice_total_calculated), }, 'batch': { - 'invoice_total_calculated': '{:0.2f}'.format(batch.invoice_total_calculated), + 'invoice_total_calculated': app.render_currency(batch.invoice_total_calculated), }, } @@ -1891,45 +1786,39 @@ class ReceivingBatchView(PurchasingBatchView): def auto_receive(self): """ - View which can "auto-receive" all items in the batch. Meant only as a - convenience for developers. + View which can "auto-receive" all items in the batch. """ batch = self.get_instance() - key = '{}.receive_all'.format(self.get_grid_key()) - progress = self.make_progress(key) - kwargs = {'progress': progress} - thread = Thread(target=self.auto_receive_thread, args=(batch.uuid, self.request.user.uuid), kwargs=kwargs) - thread.start() + return self.handler_action(batch, 'auto_receive') - return self.render_progress(progress, { - 'instance': batch, - 'cancel_url': self.get_action_url('view', batch), - 'cancel_msg': "Auto-receive was canceled", - }) + def confirm_all_costs(self): + """ + View which can "confirm all costs" for the batch. + """ + batch = self.get_instance() + return self.handler_action(batch, 'confirm_all_receiving_costs') - def auto_receive_thread(self, uuid, user_uuid, progress=None): - """ - Thread target for receiving all items on the given batch. - """ - session = RattailSession() - batch = session.query(model.PurchaseBatch).get(uuid) + def confirm_all_receiving_costs_thread(self, uuid, user_uuid, progress=None): + app = self.get_rattail_app() + model = self.model + session = app.make_session() + + batch = session.get(model.PurchaseBatch, uuid) # user = session.query(model.User).get(user_uuid) try: - self.handler.auto_receive_all_items(batch, progress=progress) + self.handler.confirm_all_receiving_costs(batch, progress=progress) # if anything goes wrong, rollback and log the error etc. except Exception as error: session.rollback() - log.exception("auto-receive failed for: %s".format(batch)) + log.exception("failed to confirm all costs for batch: %s", batch) session.close() if progress: progress.session.load() progress.session['error'] = True - progress.session['error_msg'] = "Auto-receive failed: {}".format( - simple_error(error)) + progress.session['error_msg'] = f"Failed to confirm costs: {simple_error(error)}" progress.session.save() - # if no error, check result flag (false means user canceled) else: session.commit() session.refresh(batch) @@ -1952,6 +1841,9 @@ class ReceivingBatchView(PurchasingBatchView): {'section': 'rattail.batch', 'option': 'purchase.allow_receiving_from_invoice', 'type': bool}, + {'section': 'rattail.batch', + 'option': 'purchase.allow_receiving_from_multi_invoice', + 'type': bool}, {'section': 'rattail.batch', 'option': 'purchase.allow_receiving_from_purchase_order', 'type': bool}, @@ -1963,6 +1855,12 @@ class ReceivingBatchView(PurchasingBatchView): 'type': bool}, # vendors + {'section': 'rattail.batch', + 'option': 'purchase.allow_receiving_any_vendor', + 'type': bool}, + # TODO: deprecated; can remove this once all live config + # is updated. but for now it remains so this setting is + # auto-deleted {'section': 'rattail.batch', 'option': 'purchase.supported_vendors_only', 'type': bool}, @@ -1979,6 +1877,9 @@ class ReceivingBatchView(PurchasingBatchView): {'section': 'rattail.batch', 'option': 'purchase.allow_cases', 'type': bool}, + {'section': 'rattail.batch', + 'option': 'purchase.allow_decimal_quantities', + 'type': bool}, {'section': 'rattail.batch', 'option': 'purchase.allow_expired_credits', 'type': bool}, @@ -1991,6 +1892,9 @@ class ReceivingBatchView(PurchasingBatchView): {'section': 'rattail.batch', 'option': 'purchase.receiving.allow_edit_invoice_unit_cost', 'type': bool}, + {'section': 'rattail.batch', + 'option': 'purchase.receiving.auto_missing_credits', + 'type': bool}, # mobile interface {'section': 'rattail.batch', @@ -2007,7 +1911,7 @@ class ReceivingBatchView(PurchasingBatchView): @classmethod def defaults(cls, config): cls._receiving_defaults(config) - cls._purchasing_defaults(config) + cls._purchase_batch_defaults(config) cls._batch_defaults(config) cls._defaults(config) @@ -2015,17 +1919,11 @@ class ReceivingBatchView(PurchasingBatchView): def _receiving_defaults(cls, config): rattail_config = config.registry.settings.get('rattail_config') route_prefix = cls.get_route_prefix() - url_prefix = cls.get_url_prefix() instance_url_prefix = cls.get_instance_url_prefix() model_key = cls.get_model_key() model_title = cls.get_model_title() permission_prefix = cls.get_permission_prefix() - # new receiving batch using workflow X - config.add_route('{}.create_workflow'.format(route_prefix), '{}/new/{{workflow_key}}/{{vendor_uuid}}'.format(url_prefix)) - config.add_view(cls, attr='create', route_name='{}.create_workflow'.format(route_prefix), - permission='{}.create'.format(permission_prefix)) - # row-level receiving config.add_route('{}.receive_row'.format(route_prefix), '{}/rows/{{row_uuid}}/receive'.format(instance_url_prefix)) config.add_view(cls, attr='receive_row', route_name='{}.receive_row'.format(route_prefix), @@ -2060,6 +1958,14 @@ class ReceivingBatchView(PurchasingBatchView): config.add_view(cls, attr='transform_unit_row', route_name='{}.transform_unit_row'.format(route_prefix), permission='{}.edit_row'.format(permission_prefix), renderer='json') + # confirm all costs + config.add_route(f'{route_prefix}.confirm_all_costs', + f'{instance_url_prefix}/confirm-all-costs', + request_method='POST') + config.add_view(cls, attr='confirm_all_costs', + route_name=f'{route_prefix}.confirm_all_costs', + permission=f'{permission_prefix}.edit_row') + # auto-receive all items config.add_tailbone_permission(permission_prefix, '{}.auto_receive'.format(permission_prefix), @@ -2070,33 +1976,6 @@ class ReceivingBatchView(PurchasingBatchView): permission='{}.auto_receive'.format(permission_prefix)) -@colander.deferred -def valid_workflow(node, kw): - """ - Deferred validator for ``workflow`` field, for new batches. - """ - valid_workflows = kw['valid_workflows'] - - def validate(node, value): - # we just need to provide possible values, and let stock validator - # handle the rest - oneof = colander.OneOf(valid_workflows) - return oneof(node, value) - - return validate - - -class NewReceivingBatch(colander.Schema): - """ - Schema for choosing which "type" of new receiving batch should be created. - """ - vendor = colander.SchemaNode(colander.String(), - label="Vendor") - - workflow = colander.SchemaNode(colander.String(), - validator=valid_workflow) - - class ReceiveRowForm(colander.MappingSchema): mode = colander.SchemaNode(colander.String(), @@ -2112,7 +1991,7 @@ class ReceiveRowForm(colander.MappingSchema): quick_receive = colander.SchemaNode(colander.Boolean()) def deserialize(self, *args): - result = super(ReceiveRowForm, self).deserialize(*args) + result = super().deserialize(*args) if result['mode'] == 'expired' and not result['expiration_date']: msg = "Expiration date is required for items with 'expired' mode." diff --git a/tailbone/views/reports.py b/tailbone/views/reports.py index 640dc6a9..099224be 100644 --- a/tailbone/views/reports.py +++ b/tailbone/views/reports.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,22 +24,18 @@ Reporting views """ -from __future__ import unicode_literals, absolute_import - import calendar import json import re import datetime import logging - -import six +from collections import OrderedDict import rattail -from rattail.db import model, Session as RattailSession +from rattail.db.model import ReportOutput from rattail.files import resource_path -from rattail.time import localtime from rattail.threads import Thread -from rattail.util import simple_error, OrderedDict +from rattail.util import simple_error import colander from deform import widget as dfwidget @@ -64,13 +60,13 @@ def get_upc(product): UPC formatter. Strips PLUs to bare number, and adds "minus check digit" for non-PLU UPCs. """ - upc = six.text_type(product.upc) + upc = str(product.upc) m = plu_upc_pattern.match(upc) if m: - return six.text_type(int(m.group(1))) + return str(int(m.group(1))) m = weighted_upc_pattern.match(upc) if m: - return six.text_type(int(m.group(1))) + return str(int(m.group(1))) return '{0}-{1}'.format(upc[:-1], upc[-1]) @@ -84,14 +80,15 @@ class OrderingWorksheet(View): upc_getter = staticmethod(get_upc) def __call__(self): + model = self.model if self.request.params.get('vendor'): - vendor = Session.query(model.Vendor).get(self.request.params['vendor']) + vendor = Session.get(model.Vendor, self.request.params['vendor']) if vendor: departments = [] uuids = self.request.params.get('departments') if uuids: for uuid in uuids.split(','): - dept = Session.query(model.Department).get(uuid) + dept = Session.get(model.Department, uuid) if dept: departments.append(dept) preferred_only = self.request.params.get('preferred_only') == '1' @@ -107,7 +104,8 @@ class OrderingWorksheet(View): """ Rendering engine for the ordering worksheet report. """ - + app = self.get_rattail_app() + model = self.model q = Session.query(model.ProductCost) q = q.join(model.Product) q = q.filter(model.Product.deleted == False) @@ -130,7 +128,7 @@ class OrderingWorksheet(View): key = '{0} {1}'.format(brand, product.description) return key - now = localtime(self.request.rattail_config) + now = app.localtime() data = dict( vendor=vendor, costs=costs, @@ -139,7 +137,8 @@ class OrderingWorksheet(View): time=now.strftime('%I:%M %p'), get_upc=self.upc_getter, rattail=rattail, - ) + app=self.get_rattail_app(), + ) template_path = resource_path(self.report_template_path) template = Template(filename=template_path) @@ -159,7 +158,7 @@ class InventoryWorksheet(View): """ This is the "Inventory Worksheet" report. """ - + model = self.model departments = Session.query(model.Department) if self.request.params.get('department'): @@ -180,6 +179,8 @@ class InventoryWorksheet(View): """ Generates the Inventory Worksheet report. """ + app = self.get_rattail_app() + model = self.model def get_products(subdepartment): q = Session.query(model.Product) @@ -193,7 +194,7 @@ class InventoryWorksheet(View): q = q.order_by(model.Brand.name, model.Product.description) return q.all() - now = localtime(self.request.rattail_config) + now = app.localtime() data = dict( date=now.strftime('%a %d %b %Y'), time=now.strftime('%I:%M %p'), @@ -211,7 +212,7 @@ class ReportOutputView(ExportMasterView): """ Master view for report output """ - model_class = model.ReportOutput + model_class = ReportOutput route_prefix = 'report_output' url_prefix = '/reports/generated' creatable = True @@ -240,7 +241,7 @@ class ReportOutputView(ExportMasterView): ] def __init__(self, request): - super(ReportOutputView, self).__init__(request) + super().__init__(request) self.report_handler = self.get_report_handler() def get_report_handler(self): @@ -248,7 +249,7 @@ class ReportOutputView(ExportMasterView): return app.get_report_handler() def configure_grid(self, g): - super(ReportOutputView, self).configure_grid(g) + super().configure_grid(g) g.filters['report_name'].default_active = True g.filters['report_name'].default_verb = 'contains' @@ -256,7 +257,7 @@ class ReportOutputView(ExportMasterView): g.set_link('filename') def configure_form(self, f): - super(ReportOutputView, self).configure_form(f) + super().configure_form(f) # report_type f.set_renderer('report_type', self.render_report_type) @@ -267,6 +268,9 @@ class ReportOutputView(ExportMasterView): def render_report_type(self, output, field): type_key = getattr(output, field) + # just show type key by default + rendered = type_key + # (try to) show link to poser report if applicable if type_key and type_key.startswith('poser_'): app = self.get_rattail_app() @@ -276,10 +280,20 @@ class ReportOutputView(ExportMasterView): if not report.get('error'): url = self.request.route_url('poser_reports.view', report_key=poser_key) - return tags.link_to(type_key, url) + rendered = tags.link_to(type_key, url) - # fallback to showing value as-is - return type_key + # add help button if report has a link + report = self.report_handler.get_report(type_key) + if report and report.help_url: + button = self.make_button("Help for this report", + url=report.help_url, + is_external=True, + icon_left='question-circle') + button = HTML.tag('div', class_='level-item', c=[button]) + rendered = HTML.tag('div', class_='level-item', c=[rendered]) + rendered = HTML.tag('div', class_='level-left', c=[rendered, button]) + + return rendered def render_params(self, report, field): params = report.params @@ -294,16 +308,14 @@ class ReportOutputView(ExportMasterView): route_prefix = self.get_route_prefix() factory = self.get_grid_factory() g = factory( - key='{}.params'.format(route_prefix), + self.request, + key=f'{route_prefix}.params', data=params, columns=['key', 'value'], labels={'key': "Name"}, ) - if self.get_use_buefy(): - return HTML.literal( - g.render_buefy_table_element(data_prop='paramsData')) - else: - return HTML.literal(g.render_grid()) + return HTML.literal( + g.render_table_element(data_prop='paramsData')) def get_params_context(self, report): params_data = [] @@ -315,11 +327,10 @@ class ReportOutputView(ExportMasterView): return params_data def template_kwargs_view(self, **kwargs): - kwargs = super(ReportOutputView, self).template_kwargs_view(**kwargs) + kwargs = super().template_kwargs_view(**kwargs) output = kwargs['instance'] - if self.get_use_buefy(): - kwargs['params_data'] = self.get_params_context(output) + kwargs['params_data'] = self.get_params_context(output) # build custom URL to re-build this report url = None @@ -332,11 +343,10 @@ class ReportOutputView(ExportMasterView): return kwargs def template_kwargs_delete(self, **kwargs): - kwargs = super(ReportOutputView, self).template_kwargs_delete(**kwargs) + kwargs = super().template_kwargs_delete(**kwargs) - if self.get_use_buefy(): - report = kwargs['instance'] - kwargs['params_data'] = self.get_params_context(report) + report = kwargs['instance'] + kwargs['params_data'] = self.get_params_context(report) return kwargs @@ -345,8 +355,6 @@ class ReportOutputView(ExportMasterView): View which allows user to choose which type of report they wish to generate. """ - use_buefy = self.get_use_buefy() - # handler is responsible for determining which report types are valid reports = self.report_handler.get_reports() if isinstance(reports, OrderedDict): @@ -356,7 +364,7 @@ class ReportOutputView(ExportMasterView): # make form to accept user choice of report type schema = NewReport().bind(valid_report_types=sorted_reports) - form = forms.Form(schema=schema, request=self.request, use_buefy=use_buefy) + form = forms.Form(schema=schema, request=self.request) form.submit_label = "Continue" form.cancel_url = self.request.route_url('report_output') @@ -364,15 +372,12 @@ class ReportOutputView(ExportMasterView): # e.g. some for customers/membership, others for product movement etc. values = [(r.type_key, r.name) for r in reports.values()] values.sort(key=lambda r: r[1]) - if use_buefy: - form.set_widget('report_type', forms.widgets.CustomSelectWidget(values=values)) - form.widgets['report_type'].set_template_values(input_handler='reportTypeChanged') - else: - form.set_widget('report_type', forms.widgets.PlainSelectWidget(values=values, size=10)) + form.set_widget('report_type', forms.widgets.CustomSelectWidget(values=values)) + form.widgets['report_type'].set_template_values(input_handler='reportTypeChanged') # if form validates, that means user has chosen a report type, so we # just redirect to the appropriate "new report" page - if form.validate(newstyle=True): + if form.validate(): raise self.redirect(self.request.route_url('generate_specific_report', type_key=form.validated['report_type'])) @@ -395,7 +400,6 @@ class ReportOutputView(ExportMasterView): and redirects user to view the output. """ app = self.get_rattail_app() - use_buefy = self.get_use_buefy() type_key = self.request.matchdict['type_key'] report = self.report_handler.get_report(type_key) if not report: @@ -431,12 +435,12 @@ class ReportOutputView(ExportMasterView): node.default = param.default # set docstring - helptext[param.name] = param.helptext + # nb. must avoid newlines, they cause some weird "blank page" error?! + helptext[param.name] = param.helptext.replace('\n', ' ') schema.add(node) - form = forms.Form(schema=schema, request=self.request, - use_buefy=use_buefy, helptext=helptext) + form = forms.Form(schema=schema, request=self.request, helptext=helptext) form.submit_label = "Generate this Report" form.cancel_url = self.request.get_referrer( default=self.request.route_url('{}.create'.format(route_prefix))) @@ -461,10 +465,12 @@ class ReportOutputView(ExportMasterView): value = self.request.GET[param.name] if param.type is datetime.date: value = app.parse_date(value) + elif param.type is bool: + value = self.rattail_config.parse_bool(value) form.set_default(param.name, value) # if form validates, start generating new report output; show progress page - if form.validate(newstyle=True): + if form.validate(): key = 'report_output.generate' progress = self.make_progress(key) kwargs = {'progress': progress} @@ -494,8 +500,10 @@ class ReportOutputView(ExportMasterView): resulting :class:`rattail:~rattail.db.model.reports.ReportOutput` object. """ - session = RattailSession() - user = session.query(model.User).get(user_uuid) + app = self.get_rattail_app() + model = self.model + session = app.make_session() + user = session.get(model.User, user_uuid) try: output = self.report_handler.generate_output(session, report, params, user, progress=progress) @@ -601,7 +609,7 @@ class ProblemReportView(MasterView): ] def __init__(self, request): - super(ProblemReportView, self).__init__(request) + super().__init__(request) app = self.get_rattail_app() self.problem_handler = app.get_problem_report_handler() @@ -621,14 +629,16 @@ class ProblemReportView(MasterView): reports = self.handler.get_all_problem_reports() organized = self.handler.organize_problem_reports(reports) - for system_key, reports in six.iteritems(organized): - for report in six.itervalues(reports): + for system_key, reports in organized.items(): + for report in reports.values(): data.append(self.normalize(report)) return data def configure_grid(self, g): - super(ProblemReportView, self).configure_grid(g) + super().configure_grid(g) + + g.set_searchable('system_key') g.set_renderer('email_recipients', self.render_email_recipients) @@ -656,7 +666,7 @@ class ProblemReportView(MasterView): return ProblemReportSchema() def configure_form(self, f): - super(ProblemReportView, self).configure_form(f) + super().configure_form(f) # email_* if self.editing: @@ -696,13 +706,16 @@ class ProblemReportView(MasterView): return ', '.join(recips) def render_days(self, report_info, field): - g = self.get_grid_factory()('days', [], - columns=['weekday_name', 'enabled'], - labels={'weekday_name': "Weekday"}) - return HTML.literal(g.render_buefy_table_element(data_prop='weekdaysData')) + factory = self.get_grid_factory() + g = factory(self.request, + key='days', + data=[], + columns=['weekday_name', 'enabled'], + labels={'weekday_name': "Weekday"}) + return HTML.literal(g.render_table_element(data_prop='weekdaysData')) def template_kwargs_view(self, **kwargs): - kwargs = super(ProblemReportView, self).template_kwargs_view(**kwargs) + kwargs = super().template_kwargs_view(**kwargs) report_info = kwargs['instance'] data = [] @@ -725,12 +738,12 @@ class ProblemReportView(MasterView): report['problem_key']) app.save_setting(session, 'rattail.problems.{}.enabled'.format(key), - six.text_type(data['enabled']).lower()) + str(data['enabled']).lower()) for i in range(7): daykey = 'day{}'.format(i) app.save_setting(session, 'rattail.problems.{}.{}'.format(key, daykey), - six.text_type(data['days'][daykey]).lower()) + str(data['days'][daykey]).lower()) def execute_instance(self, report_info, user, progress=None, **kwargs): report = report_info['_report'] diff --git a/tailbone/views/roles.py b/tailbone/views/roles.py index 29bb2ef4..e8a6d8a2 100644 --- a/tailbone/views/roles.py +++ b/tailbone/views/roles.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,16 +24,12 @@ Role Views """ -from __future__ import unicode_literals, absolute_import - import os -import six from sqlalchemy import orm from openpyxl.styles import Font, PatternFill -from rattail.db import model -from rattail.db.auth import administrator_role, guest_role, authenticated_role +from rattail.db.model import Role from rattail.excel import ExcelWriter import colander @@ -49,7 +45,7 @@ class RoleView(PrincipalMasterView): """ Master view for the Role model. """ - model_class = model.Role + model_class = Role has_versions = True touchable = True @@ -80,7 +76,7 @@ class RoleView(PrincipalMasterView): ] def configure_grid(self, g): - super(RoleView, self).configure_grid(g) + super().configure_grid(g) # name g.filters['name'].default_active = True @@ -110,8 +106,11 @@ class RoleView(PrincipalMasterView): if role.node_type and role.node_type != self.rattail_config.node_type(): return False + app = self.get_rattail_app() + auth = app.get_auth_handler() + # only "root" can edit Administrator - if role is administrator_role(self.Session()): + if role is auth.get_role_administrator(self.Session()): return self.request.is_root # only "admin" can edit "admin-ish" roles @@ -119,11 +118,11 @@ class RoleView(PrincipalMasterView): return self.request.is_admin # can edit Authenticated only if user has permission - if role is authenticated_role(self.Session()): + if role is auth.get_role_authenticated(self.Session()): return self.has_perm('edit_authenticated') # can edit Guest only if user has permission - if role is guest_role(self.Session()): + if role is auth.get_role_anonymous(self.Session()): return self.has_perm('edit_guest') # current user can edit their own roles, only if they have permission @@ -142,11 +141,14 @@ class RoleView(PrincipalMasterView): if role.node_type and role.node_type != self.rattail_config.node_type(): return False - if role is administrator_role(self.Session()): + app = self.get_rattail_app() + auth = app.get_auth_handler() + + if role is auth.get_role_administrator(self.Session()): return False - if role is authenticated_role(self.Session()): + if role is auth.get_role_authenticated(self.Session()): return False - if role is guest_role(self.Session()): + if role is auth.get_role_anonymous(self.Session()): return False # only "admin" can delete "admin-ish" roles @@ -161,6 +163,7 @@ class RoleView(PrincipalMasterView): return True def unique_name(self, node, value): + model = self.model query = self.Session.query(model.Role)\ .filter(model.Role.name == value) if self.editing: @@ -170,9 +173,8 @@ class RoleView(PrincipalMasterView): raise colander.Invalid(node, "Name must be unique") def configure_form(self, f): - super(RoleView, self).configure_form(f) + super().configure_form(f) role = f.model_instance - use_buefy = self.get_use_buefy() app = self.get_rattail_app() auth = app.get_auth_handler() @@ -189,17 +191,17 @@ class RoleView(PrincipalMasterView): # session_timeout f.set_renderer('session_timeout', self.render_session_timeout) - if self.editing and role is guest_role(self.Session()): + if self.editing and role is auth.get_role_anonymous(self.Session()): f.set_readonly('session_timeout') # sync_me, node_type if not self.creating: include = True - if role is administrator_role(self.Session()): + if role is auth.get_role_administrator(self.Session()): include = False - elif role is authenticated_role(self.Session()): + elif role is auth.get_role_authenticated(self.Session()): include = False - elif role is guest_role(self.Session()): + elif role is auth.get_role_anonymous(self.Session()): include = False if not include: f.remove('sync_me', 'sync_users', 'node_type') @@ -213,7 +215,7 @@ class RoleView(PrincipalMasterView): f.set_type('notes', 'text_wrapped') # users - if use_buefy and self.viewing: + if self.viewing: f.set_renderer('users', self.render_users) else: f.remove('users') @@ -224,14 +226,13 @@ class RoleView(PrincipalMasterView): permissions=self.tailbone_permissions)) f.set_node('permissions', colander.Set()) f.set_widget('permissions', PermissionsWidget( - permissions=self.tailbone_permissions, - use_buefy=use_buefy)) + permissions=self.tailbone_permissions)) if self.editing: granted = [] for groupkey in self.tailbone_permissions: for key in self.tailbone_permissions[groupkey]['perms']: if auth.has_permission(self.Session(), role, key, - include_guest=False, + include_anonymous=False, include_authenticated=False): granted.append(key) f.set_default('permissions', granted) @@ -239,12 +240,14 @@ class RoleView(PrincipalMasterView): f.remove_field('permissions') def render_users(self, role, field): + app = self.get_rattail_app() + auth = app.get_auth_handler() - if role is guest_role(self.Session()): + if role is auth.get_role_anonymous(self.Session()): return ("The guest role is implied for all anonymous users, " "i.e. when not logged in.") - if role is authenticated_role(self.Session()): + if role is auth.get_role_authenticated(self.Session()): return ("The authenticated role is implied for all users, " "but only when logged in.") @@ -252,7 +255,8 @@ class RoleView(PrincipalMasterView): permission_prefix = self.get_permission_prefix() factory = self.get_grid_factory() g = factory( - key='{}.users'.format(route_prefix), + self.request, + key=f'{route_prefix}.users', data=[], columns=[ 'full_name', @@ -265,12 +269,12 @@ class RoleView(PrincipalMasterView): ) if self.request.has_perm('users.view'): - g.main_actions.append(self.make_action('view', icon='eye')) + g.actions.append(self.make_action('view', icon='eye')) if self.request.has_perm('users.edit'): - g.main_actions.append(self.make_action('edit', icon='edit')) + g.actions.append(self.make_action('edit', icon='edit')) return HTML.literal( - g.render_buefy_table_element(data_prop='usersData')) + g.render_table_element(data_prop='usersData')) def get_available_permissions(self): """ @@ -283,8 +287,8 @@ class RoleView(PrincipalMasterView): if the current user is an admin; otherwise it will be the "subset" of permissions which the current user has been granted. """ - # fetch full set of permissions registered in the app - permissions = self.request.registry.settings.get('tailbone_permissions', {}) + # get all known permissions from settings cache + permissions = self.request.registry.settings.get('wutta_permissions', {}) # admin user gets to manage all permissions if self.request.is_admin: @@ -298,8 +302,8 @@ class RoleView(PrincipalMasterView): # TODO: it seems a bit ugly, to "rebuild" permission groups like this, # but not sure if there's a better way? available = {} - for gkey, group in six.iteritems(permissions): - for pkey, perm in six.iteritems(group['perms']): + for gkey, group in permissions.items(): + for pkey, perm in group['perms'].items(): if self.request.has_perm(pkey): if gkey not in available: available[gkey] = { @@ -311,11 +315,13 @@ class RoleView(PrincipalMasterView): return available def render_session_timeout(self, role, field): - if role is guest_role(self.Session()): + app = self.get_rattail_app() + auth = app.get_auth_handler() + if role is auth.get_role_anonymous(self.Session()): return "(not applicable)" if role.session_timeout is None: return "" - return six.text_type(role.session_timeout) + return str(role.session_timeout) def objectify(self, form, data=None): """ @@ -327,7 +333,7 @@ class RoleView(PrincipalMasterView): """ if data is None: data = form.validated - role = super(RoleView, self).objectify(form, data) + role = super().objectify(form, data) self.update_permissions(role, data['permissions']) return role @@ -342,61 +348,66 @@ class RoleView(PrincipalMasterView): auth = app.get_auth_handler() available = self.tailbone_permissions - for gkey, group in six.iteritems(available): - for pkey, perm in six.iteritems(group['perms']): + for gkey, group in available.items(): + for pkey, perm in group['perms'].items(): if pkey in permissions: auth.grant_permission(role, pkey) else: auth.revoke_permission(role, pkey) def template_kwargs_view(self, **kwargs): + app = self.get_rattail_app() + auth = app.get_auth_handler() + model = self.model role = kwargs['instance'] if role.users: users = sorted(role.users, key=lambda u: u.username) actions = [ - grids.GridAction('view', icon='zoomin', + self.make_action('view', icon='zoomin', url=lambda r, i: self.request.route_url('users.view', uuid=r.uuid)) ] - kwargs['users'] = grids.Grid(None, users, ['username', 'active'], - request=self.request, + kwargs['users'] = grids.Grid(self.request, + data=users, + columns=['username', 'active'], model_class=model.User, - main_actions=actions) + actions=actions) else: kwargs['users'] = None - kwargs['guest_role'] = guest_role(self.Session()) - kwargs['authenticated_role'] = authenticated_role(self.Session()) + kwargs['guest_role'] = auth.get_role_anonymous(self.Session()) + kwargs['authenticated_role'] = auth.get_role_authenticated(self.Session()) - use_buefy = self.get_use_buefy() - if use_buefy: - role = kwargs['instance'] - if role not in (kwargs['guest_role'], kwargs['authenticated_role']): - users_data = [] - for user in role.users: - users_data.append({ - 'uuid': user.uuid, - 'full_name': user.display_name, - 'username': user.username, - 'active': "Yes" if user.active else "No", - '_action_url_view': self.request.route_url('users.view', - uuid=user.uuid), - '_action_url_edit': self.request.route_url('users.edit', - uuid=user.uuid), - }) - kwargs['users_data'] = users_data + role = kwargs['instance'] + if role not in (kwargs['guest_role'], kwargs['authenticated_role']): + users_data = [] + for user in role.users: + users_data.append({ + 'uuid': user.uuid, + 'full_name': user.display_name, + 'username': user.username, + 'active': "Yes" if user.active else "No", + '_action_url_view': self.request.route_url('users.view', + uuid=user.uuid), + '_action_url_edit': self.request.route_url('users.edit', + uuid=user.uuid), + }) + kwargs['users_data'] = users_data return kwargs def before_delete(self, role): - admin = administrator_role(self.Session()) - guest = guest_role(self.Session()) - authenticated = authenticated_role(self.Session()) + app = self.get_rattail_app() + auth = app.get_auth_handler() + admin = auth.get_role_administrator(self.Session()) + guest = auth.get_role_anonymous(self.Session()) + authenticated = auth.get_role_authenticated(self.Session()) if role in (admin, guest, authenticated): self.request.session.flash("You may not delete the {} role.".format(role.name), 'error') return self.redirect(self.request.get_referrer(default=self.request.route_url('roles'))) def find_principals_with_permission(self, session, permission): app = self.get_rattail_app() + model = self.model auth = app.get_auth_handler() # TODO: this should search Permission table instead, and work backward to Role? @@ -405,16 +416,28 @@ class RoleView(PrincipalMasterView): .options(orm.joinedload(model.Role._permissions)) roles = [] for role in all_roles: - if auth.has_permission(session, role, permission, include_guest=False): + if auth.has_permission(session, role, permission, include_anonymous=False): roles.append(role) return roles + def find_by_perm_configure_results_grid(self, g): + g.append('name') + g.set_link('name') + + def find_by_perm_normalize(self, role): + data = super().find_by_perm_normalize(role) + + data['name'] = role.name + + return data + def download_permissions_matrix(self): """ View which renders the complete role / permissions matrix data into an Excel spreadsheet, and returns that file. """ app = self.get_rattail_app() + model = self.model auth = app.get_auth_handler() roles = self.Session.query(model.Role)\ @@ -466,7 +489,7 @@ class RoleView(PrincipalMasterView): # and show an 'X' for any role which has this perm for col, role in enumerate(roles, 2): if auth.has_permission(self.Session(), role, key, - include_guest=False): + include_anonymous=False): sheet.cell(row=writing_row, column=col, value="X") writing_row += 1 diff --git a/tailbone/views/settings.py b/tailbone/views/settings.py index c38e3136..10a0c2eb 100644 --- a/tailbone/views/settings.py +++ b/tailbone/views/settings.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,30 +24,174 @@ Settings Views """ -from __future__ import unicode_literals, absolute_import - +import json import re -import json -import six +import colander -from rattail.db import model -from rattail.settings import Setting +from rattail.db.model import Setting +from rattail.settings import Setting as AppSetting from rattail.util import import_module_path -import colander -from webhelpers2.html import tags - -from tailbone import forms +from tailbone import forms, grids from tailbone.db import Session from tailbone.views import MasterView, View +from wuttaweb.util import get_libver, get_liburl +from wuttaweb.views.settings import AppInfoView as WuttaAppInfoView + + +class AppInfoView(WuttaAppInfoView): + """ """ + Session = Session + weblib_config_prefix = 'tailbone' + + # TODO: for now we override to get tailbone searchable grid + def make_grid(self, **kwargs): + """ """ + return grids.Grid(self.request, **kwargs) + + def configure_grid(self, g): + """ """ + super().configure_grid(g) + + # name + g.set_searchable('name') + + # editable_project_location + g.set_searchable('editable_project_location') + + def configure_get_context(self, **kwargs): + """ """ + context = super().configure_get_context(**kwargs) + simple_settings = context['simple_settings'] + weblibs = context['weblibs'] + + for weblib in weblibs: + key = weblib['key'] + + # TODO: this is only needed to migrate legacy settings to + # use the newer wuttaweb setting names + url = simple_settings[f'wuttaweb.liburl.{key}'] + if not url and weblib['configured_url']: + simple_settings[f'wuttaweb.liburl.{key}'] = weblib['configured_url'] + + return context + + # nb. these email settings require special handling below + configure_profile_key_mismatches = [ + 'default.subject', + 'default.to', + 'default.cc', + 'default.bcc', + 'feedback.subject', + 'feedback.to', + ] + + def configure_get_simple_settings(self): + """ """ + simple_settings = super().configure_get_simple_settings() + + # TODO: + # there are several email config keys which differ between + # wuttjamaican and rattail. basically all of the "profile" keys + # have a different prefix. + + # after wuttaweb has declared its settings, we examine each and + # overwrite the value if one is defined with rattail config key. + # (nb. this happens even if wuttjamaican key has a value!) + + # note that we *do* declare the profile mismatch keys for + # rattail, as part of simple settings. this ensures the + # parent logic will always remove them when saving. however + # we must also include them in gather_settings() to ensure + # they are saved to match wuttjamaican values. + + # there are also a couple of flags where rattail's default is the + # opposite of wuttjamaican. so we overwrite those too as needed. + + for setting in simple_settings: + + # nb. the update home page redirect setting is off by + # default for wuttaweb, but on for tailbone + if setting['name'] == 'wuttaweb.home_redirect_to_login': + value = self.config.get_bool('wuttaweb.home_redirect_to_login') + if value is None: + value = self.config.get_bool('tailbone.login_is_home', default=True) + setting['value'] = value + + # nb. sending email is off by default for wuttjamaican, + # but on for rattail + elif setting['name'] == 'rattail.mail.send_emails': + value = self.config.get_bool('rattail.mail.send_emails', default=True) + setting['value'] = value + + # nb. this one is even more special, key is entirely different + elif setting['name'] == 'rattail.email.default.sender': + value = self.config.get('rattail.email.default.sender') + if value is None: + value = self.config.get('rattail.mail.default.from') + setting['value'] = value + + else: + + # nb. fetch alternate value for profile key mismatch + for key in self.configure_profile_key_mismatches: + if setting['name'] == f'rattail.email.{key}': + value = self.config.get(f'rattail.email.{key}') + if value is None: + value = self.config.get(f'rattail.mail.{key}') + setting['value'] = value + break + + # nb. these are no longer used (deprecated), but we keep + # them defined here so the tool auto-deletes them + + simple_settings.extend([ + {'name': 'tailbone.login_is_home'}, + {'name': 'tailbone.buefy_version'}, + {'name': 'tailbone.vue_version'}, + ]) + + simple_settings.append({'name': 'rattail.mail.default.from'}) + for key in self.configure_profile_key_mismatches: + simple_settings.append({'name': f'rattail.mail.{key}'}) + + for key in self.get_weblibs(): + simple_settings.extend([ + {'name': f'tailbone.libver.{key}'}, + {'name': f'tailbone.liburl.{key}'}, + ]) + + return simple_settings + + def configure_gather_settings(self, data, simple_settings=None): + """ """ + settings = super().configure_gather_settings(data, simple_settings=simple_settings) + + # nb. must add legacy rattail profile settings to match new ones + for setting in list(settings): + + if setting['name'] == 'rattail.email.default.sender': + value = setting['value'] + settings.append({'name': 'rattail.mail.default.from', + 'value': value}) + + else: + for key in self.configure_profile_key_mismatches: + if setting['name'] == f'rattail.email.{key}': + value = setting['value'] + settings.append({'name': f'rattail.mail.{key}', + 'value': value}) + break + + return settings class SettingView(MasterView): """ Master view for the settings model. """ - model_class = model.Setting + model_class = Setting model_title = "Raw Setting" model_title_plural = "Raw Settings" bulk_deletable = True @@ -59,19 +203,20 @@ class SettingView(MasterView): ] def configure_grid(self, g): - super(SettingView, self).configure_grid(g) + super().configure_grid(g) g.filters['name'].default_active = True g.filters['name'].default_verb = 'contains' g.set_sort_defaults('name') g.set_link('name') def configure_form(self, f): - super(SettingView, self).configure_form(f) + super().configure_form(f) if self.creating: f.set_validator('name', self.unique_name) def unique_name(self, node, value): - setting = self.Session.query(model.Setting).get(value) + model = self.model + setting = self.Session.get(model.Setting, value) if setting: raise colander.Invalid(node, "Setting name must be unique") @@ -97,7 +242,7 @@ class SettingView(MasterView): self.rattail_config.beaker_invalidate_setting(setting.name) # otherwise delete like normal - super(SettingView, self).delete_instance(setting) + super().delete_instance(setting) # TODO: deprecate / remove this @@ -126,7 +271,7 @@ class AppSettingsView(View): form = self.make_form(settings) form.cancel_url = self.request.current_route_url() - if form.validate(newstyle=True): + if form.validate(): self.save_form(form) group = self.request.POST.get('settings-group') if group is not None: @@ -151,29 +296,22 @@ class AppSettingsView(View): option['url'] = self.request.route_url(option['route']) config_options.append(option) - use_buefy = self.get_use_buefy() context = { 'index_title': "App Settings", 'form': form, 'dform': form.make_deform_form(), 'groups': groups, 'settings': settings, - 'use_buefy': use_buefy, 'config_options': config_options, } - if use_buefy: - context['buefy_data'] = self.get_buefy_data(form, groups, settings) - # TODO: this seems hacky, and probably only needed if theme changes? - if current_group == '(All)': - current_group = '' - else: - group_options = [tags.Option(group, group) for group in groups] - group_options.insert(0, tags.Option("(All)", "(All)")) - context['group_options'] = group_options + context['settings_data'] = self.get_settings_data(form, groups, settings) + # TODO: this seems hacky, and probably only needed if theme changes? + if current_group == '(All)': + current_group = '' context['current_group'] = current_group return context - def get_buefy_data(self, form, groups, settings): + def get_settings_data(self, form, groups, settings): dform = form.make_deform_form() grouped = dict([(label, []) for label in groups]) @@ -199,8 +337,7 @@ class AppSettingsView(View): # specify error / message if applicable # TODO: not entirely clear to me why some field errors are - # represented differently? presumably it depends on - # whether Buefy is used by the theme. + # represented differently? if field.error: s['error'] = True if isinstance(field.error, colander.Invalid): @@ -255,11 +392,20 @@ class AppSettingsView(View): """ Iterate over all known settings. """ - for module in self.rattail_config.getlist('rattail', 'settings', default=['rattail.settings']): + modules = self.rattail_config.getlist('rattail', 'settings') + if modules: + core_only = False + else: + modules = ['rattail.settings'] + core_only = True + + for module in modules: module = import_module_path(module) for name in dir(module): obj = getattr(module, name) - if isinstance(obj, type) and issubclass(obj, Setting) and obj is not Setting: + if isinstance(obj, type) and issubclass(obj, AppSetting) and obj is not AppSetting: + if core_only and not obj.core: + continue # NOTE: we set this here, and reference it elsewhere obj.node_name = self.get_node_name(obj) yield obj @@ -287,7 +433,7 @@ class AppSettingsView(View): for entry in value.split('\n')] value = ', '.join(entries) else: - value = six.text_type(value) + value = str(value) app = self.get_rattail_app() app.save_setting(Session(), legacy_name, value) @@ -313,6 +459,9 @@ class AppSettingsView(View): def defaults(config, **kwargs): base = globals() + AppInfoView = kwargs.get('AppInfoView', base['AppInfoView']) + AppInfoView.defaults(config) + AppSettingsView = kwargs.get('AppSettingsView', base['AppSettingsView']) AppSettingsView.defaults(config) diff --git a/tailbone/views/shifts/core.py b/tailbone/views/shifts/core.py index b6d9aadf..53bfc446 100644 --- a/tailbone/views/shifts/core.py +++ b/tailbone/views/shifts/core.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,31 +24,32 @@ Views for employee shifts """ -from __future__ import unicode_literals, absolute_import - import datetime -import six - from rattail.db import model from rattail.time import localtime -from rattail.util import pretty_hours, hours_as_decimal +from rattail.util import hours_as_decimal from webhelpers2.html import tags, HTML from tailbone.views import MasterView -def render_shift_length(shift, field): - if not shift.start_time or not shift.end_time: - return "" - if shift.end_time < shift.start_time: - return "??" - length = shift.end_time - shift.start_time - return HTML.tag('span', title="{} hrs".format(hours_as_decimal(length)), c=[pretty_hours(length)]) +class ShiftViewMixin: + + def render_shift_length(self, shift, field): + if not shift.start_time or not shift.end_time: + return "" + if shift.end_time < shift.start_time: + return "??" + app = self.get_rattail_app() + length = shift.end_time - shift.start_time + return HTML.tag('span', + title="{} hrs".format(hours_as_decimal(length)), + c=[app.render_duration(delta=length)]) -class ScheduledShiftView(MasterView): +class ScheduledShiftView(MasterView, ShiftViewMixin): """ Master view for employee scheduled shifts. """ @@ -78,20 +79,20 @@ class ScheduledShiftView(MasterView): g.set_sort_defaults('start_time', 'desc') - g.set_renderer('length', render_shift_length) + g.set_renderer('length', self.render_shift_length) g.set_label('employee', "Employee Name") def configure_form(self, f): - super(ScheduledShiftView, self).configure_form(f) + super().configure_form(f) - f.set_renderer('length', render_shift_length) + f.set_renderer('length', self.render_shift_length) # TODO: deprecate / remove this ScheduledShiftsView = ScheduledShiftView -class WorkedShiftView(MasterView): +class WorkedShiftView(MasterView, ShiftViewMixin): """ Master view for employee worked shifts. """ @@ -117,26 +118,29 @@ class WorkedShiftView(MasterView): ] def configure_grid(self, g): - super(WorkedShiftView, self).configure_grid(g) + super().configure_grid(g) + model = self.model - g.joiners['employee'] = lambda q: q.join(model.Employee).join(model.Person) - g.filters['employee'] = g.make_filter('employee', model.Person.display_name) - g.sorters['employee'] = g.make_sorter(model.Person.display_name) + # employee + g.set_joiner('employee', lambda q: q.join(model.Employee).join(model.Person)) + g.set_sorter('employee', model.Person.display_name) + g.set_filter('employee', model.Person.display_name) - g.joiners['store'] = lambda q: q.join(model.Store) - g.filters['store'] = g.make_filter('store', model.Store.name) - g.sorters['store'] = g.make_sorter(model.Store.name) + # store + g.set_joiner('store', lambda q: q.join(model.Store)) + g.set_sorter('store', model.Store.name) + g.set_filter('store', model.Store.name) # TODO: these sorters should be automatic once we fix the schema - g.sorters['start_time'] = g.make_sorter(model.WorkedShift.punch_in) - g.sorters['end_time'] = g.make_sorter(model.WorkedShift.punch_out) + g.set_sorter('start_time', model.WorkedShift.punch_in) + g.set_sorter('end_time', model.WorkedShift.punch_out) # TODO: same goes for these renderers g.set_type('start_time', 'datetime') g.set_type('end_time', 'datetime') # (but we'll still have to set this) g.set_sort_defaults('start_time', 'desc') - g.set_renderer('length', render_shift_length) + g.set_renderer('length', self.render_shift_length) g.set_label('employee', "Employee Name") g.set_label('store', "Store Name") @@ -149,12 +153,12 @@ class WorkedShiftView(MasterView): return "WorkedShift: {}, {}".format(shift.employee, date) def configure_form(self, f): - super(WorkedShiftView, self).configure_form(f) + super().configure_form(f) f.set_readonly('employee') f.set_renderer('employee', self.render_employee) - f.set_renderer('length', render_shift_length) + f.set_renderer('length', self.render_shift_length) if self.editing: f.remove('length') @@ -162,12 +166,12 @@ class WorkedShiftView(MasterView): employee = shift.employee if not employee: return "" - text = six.text_type(employee) + text = str(employee) url = self.request.route_url('employees.view', uuid=employee.uuid) return tags.link_to(text, url) def get_xlsx_fields(self): - fields = super(WorkedShiftView, self).get_xlsx_fields() + fields = super().get_xlsx_fields() # add employee name i = fields.index('employee_uuid') @@ -179,7 +183,7 @@ class WorkedShiftView(MasterView): return fields def get_xlsx_row(self, shift, fields): - row = super(WorkedShiftView, self).get_xlsx_row(shift, fields) + row = super().get_xlsx_row(shift, fields) # localize start and end times (Excel requires time with no zone) if shift.punch_in: diff --git a/tailbone/views/shifts/lib.py b/tailbone/views/shifts/lib.py index 73d9603a..1827bee0 100644 --- a/tailbone/views/shifts/lib.py +++ b/tailbone/views/shifts/lib.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2018 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,17 +24,13 @@ Base views for time sheets """ -from __future__ import unicode_literals, absolute_import - import datetime -import six import sqlalchemy as sa -from rattail import enum -from rattail.db import model, api -from rattail.time import localtime, make_utc, get_sunday -from rattail.util import pretty_hours, hours_as_decimal +from rattail.db import api +from rattail.time import get_sunday +from rattail.util import hours_as_decimal import colander from deform import widget as dfwidget @@ -86,6 +82,8 @@ class TimeSheetView(View): """ Determine date/store/dept context from user's session and/or defaults. """ + app = self.get_rattail_app() + model = self.model date = None date_key = 'timesheet.{}.date'.format(self.key) if date_key in self.request.session: @@ -96,7 +94,7 @@ class TimeSheetView(View): except ValueError: pass if not date: - date = localtime(self.rattail_config).date() + date = app.today() store = None department = None @@ -105,10 +103,10 @@ class TimeSheetView(View): if store_key in self.request.session or department_key in self.request.session: store_uuid = self.request.session.get(store_key) if store_uuid: - store = Session.query(model.Store).get(store_uuid) if store_uuid else None + store = Session.get(model.Store, store_uuid) if store_uuid else None department_uuid = self.request.session.get(department_key) if department_uuid: - department = Session.query(model.Department).get(department_uuid) + department = Session.get(model.Department, department_uuid) else: # no store/department in session if self.default_filter_store: store = self.rattail_config.get('rattail', 'store') @@ -116,7 +114,7 @@ class TimeSheetView(View): store = api.get_store(Session(), store) employees = Session.query(model.Employee)\ - .filter(model.Employee.status == enum.EMPLOYEE_STATUS_CURRENT) + .filter(model.Employee.status == self.enum.EMPLOYEE_STATUS_CURRENT) if store: employees = employees.join(model.EmployeeStore)\ .filter(model.EmployeeStore.store == store) @@ -135,6 +133,8 @@ class TimeSheetView(View): """ Determine employee/date context from user's session and/or defaults """ + app = self.get_rattail_app() + model = self.model date = None date_key = 'timesheet.{}.employee.date'.format(self.key) if date_key in self.request.session: @@ -145,13 +145,13 @@ class TimeSheetView(View): except ValueError: pass if not date: - date = localtime(self.rattail_config).date() + date = app.today() employee = None employee_key = 'timesheet.{}.employee'.format(self.key) if employee_key in self.request.session: employee_uuid = self.request.session[employee_key] - employee = Session.query(model.Employee).get(employee_uuid) if employee_uuid else None + employee = Session.get(model.Employee, employee_uuid) if employee_uuid else None if not employee: employee = self.request.user.employee @@ -167,7 +167,7 @@ class TimeSheetView(View): Process a "shift filter" form if one was in fact POST'ed. If it was then we store new context in session and redirect to display as normal. """ - if form.validate(newstyle=True): + if form.validate(): store = form.validated['store'] self.request.session['timesheet.{}.store'.format(self.key)] = store.uuid if store else None department = form.validated['department'] @@ -181,7 +181,7 @@ class TimeSheetView(View): Process an "employee shift filter" form if one was in fact POST'ed. If it was then we store new context in session and redirect to display as normal. """ - if form.validate(newstyle=True): + if form.validate(): employee = form.validated['employee'] self.request.session['timesheet.{}.employee'.format(self.key)] = employee.uuid if employee else None date = form.validated['date'] @@ -194,7 +194,7 @@ class TimeSheetView(View): stores = self.get_stores() store_values = [(s.uuid, "{} - {}".format(s.id, s.name)) for s in stores] store_values.insert(0, ('', "(all)")) - form.set_widget('store', forms.widgets.PlainSelectWidget(values=store_values)) + form.set_widget('store', dfwidget.SelectWidget(values=store_values)) if context['store']: form.set_default('store', context['store'].uuid) else: @@ -206,7 +206,7 @@ class TimeSheetView(View): departments = self.get_departments() department_values = [(d.uuid, d.name) for d in departments] department_values.insert(0, ('', "(all)")) - form.set_widget('department', forms.widgets.PlainSelectWidget(values=department_values)) + form.set_widget('department', dfwidget.SelectWidget(values=department_values)) if context['department']: form.set_default('department', context['department'].uuid) else: @@ -238,7 +238,7 @@ class TimeSheetView(View): form = forms.Form(schema=EmployeeShiftFilter(), request=self.request) if self.request.has_perm('{}.viewall'.format(permission_prefix)): - employee_display = six.text_type(context['employee'] or '') + employee_display = str(context['employee'] or '') employees_url = self.request.route_url('employees.autocomplete') form.set_widget('employee', forms.widgets.JQueryAutocompleteWidget( field_display=employee_display, service_url=employees_url)) @@ -295,6 +295,7 @@ class TimeSheetView(View): self.request.session['timesheet.{}.{}'.format(mainkey, key)] = value def get_stores(self): + model = self.model return Session.query(model.Store).order_by(model.Store.id).all() def get_store_options(self, stores): @@ -302,6 +303,7 @@ class TimeSheetView(View): return tags.Options(options, prompt="(all)") def get_departments(self): + model = self.model return Session.query(model.Department).order_by(model.Department.name).all() def get_department_options(self, departments): @@ -404,6 +406,9 @@ class TimeSheetView(View): Fetch all shift data of the given model class (``cls``), according to the given params. The cached shift data is attached to each employee. """ + app = self.get_rattail_app() + model = self.model + # TODO: a bit hacky, this? display hours as HH:MM by default, but # check config in order to display as HH.HH for certain users hours_style = 'pretty' @@ -414,19 +419,19 @@ class TimeSheetView(View): hours_style = 'pretty' shift_type = 'scheduled' if cls is model.ScheduledShift else 'worked' - min_time = localtime(self.rattail_config, datetime.datetime.combine(weekdays[0], datetime.time(0))) - max_time = localtime(self.rattail_config, datetime.datetime.combine(weekdays[-1] + datetime.timedelta(days=1), datetime.time(0))) + min_time = app.localtime(datetime.datetime.combine(weekdays[0], datetime.time(0))) + max_time = app.localtime(datetime.datetime.combine(weekdays[-1] + datetime.timedelta(days=1), datetime.time(0))) shifts = Session.query(cls)\ .filter(cls.employee_uuid.in_([e.uuid for e in employees]))\ .filter(sa.or_( sa.and_( - cls.start_time >= make_utc(min_time), - cls.start_time < make_utc(max_time), + cls.start_time >= app.make_utc(min_time), + cls.start_time < app.make_utc(max_time), ), sa.and_( cls.start_time == None, - cls.end_time >= make_utc(min_time), - cls.end_time < make_utc(max_time), + cls.end_time >= app.make_utc(min_time), + cls.end_time < app.make_utc(max_time), )))\ .all() @@ -468,9 +473,9 @@ class TimeSheetView(View): hours = empday['{}_hours'.format(shift_type)] if hours: if hours_style == 'pretty': - display = pretty_hours(hours) + display = app.render_duration(hours=hours) else: # decimal - display = six.text_type(hours_as_decimal(hours)) + display = str(hours_as_decimal(hours)) if empday['hours_incomplete']: display = '{} ?'.format(display) empday['{}_hours_display'.format(shift_type)] = display @@ -479,9 +484,9 @@ class TimeSheetView(View): hours = getattr(employee, '{}_hours'.format(shift_type)) if hours: if hours_style == 'pretty': - display = pretty_hours(hours) + display = app.render_duration(hours=hours) else: # decimal - display = six.text_type(hours_as_decimal(hours)) + display = str(hours_as_decimal(hours)) if hours_incomplete: display = '{} ?'.format(display) setattr(employee, '{}_hours_display'.format(shift_type), display) diff --git a/tailbone/views/shifts/schedule.py b/tailbone/views/shifts/schedule.py index 7a1ccbe5..c8b82724 100644 --- a/tailbone/views/shifts/schedule.py +++ b/tailbone/views/shifts/schedule.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,8 +24,6 @@ Views for employee schedules """ -from __future__ import unicode_literals, absolute_import - import datetime from rattail.db import model @@ -81,7 +79,7 @@ class ScheduleView(TimeSheetView): deleted = [] for uuid, value in data['delete'].items(): if value == 'delete': - shift = Session.query(model.ScheduledShift).get(uuid) + shift = Session.get(model.ScheduledShift, uuid) if shift: Session.delete(shift) deleted.append(uuid) @@ -103,7 +101,7 @@ class ScheduleView(TimeSheetView): Session.add(shift) created[uuid] = shift else: - shift = Session.query(model.ScheduledShift).get(uuid) + shift = Session.get(model.ScheduledShift, uuid) assert shift updated[uuid] = shift start_time = datetime.datetime.strptime(data['start_time'][uuid], time_format) diff --git a/tailbone/views/shifts/timesheet.py b/tailbone/views/shifts/timesheet.py index 9898cd04..a8874127 100644 --- a/tailbone/views/shifts/timesheet.py +++ b/tailbone/views/shifts/timesheet.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,8 +24,6 @@ Views for employee time sheets """ -from __future__ import unicode_literals, absolute_import - import datetime from rattail.db import model @@ -74,7 +72,7 @@ class TimeSheetView(BaseTimeSheetView): deleted = [] for uuid, value in list(data['delete'].items()): assert value == 'delete' - shift = Session.query(model.WorkedShift).get(uuid) + shift = Session.get(model.WorkedShift, uuid) assert shift Session.delete(shift) deleted.append(uuid) @@ -93,7 +91,7 @@ class TimeSheetView(BaseTimeSheetView): Session.add(shift) created[uuid] = shift else: - shift = Session.query(model.WorkedShift).get(uuid) + shift = Session.get(model.WorkedShift, uuid) assert shift updated[uuid] = shift diff --git a/tailbone/views/subdepartments.py b/tailbone/views/subdepartments.py index d2a337cc..43648ea6 100644 --- a/tailbone/views/subdepartments.py +++ b/tailbone/views/subdepartments.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,10 +24,12 @@ Subdepartment Views """ -from __future__ import unicode_literals, absolute_import +import sqlalchemy as sa from rattail.db import model +from deform import widget as dfwidget + from tailbone.db import Session from tailbone.views import MasterView @@ -85,6 +87,9 @@ class SubdepartmentView(MasterView): def configure_grid(self, g): super(SubdepartmentView, self).configure_grid(g) + # number + g.set_link('number') + # name g.filters['name'].default_active = True g.filters['name'].default_verb = 'contains' @@ -95,16 +100,40 @@ class SubdepartmentView(MasterView): g.set_sorter('department', model.Department.name) g.set_filter('department', model.Department.name) - g.set_link('number') g.set_link('name') def configure_form(self, f): super(SubdepartmentView, self).configure_form(f) f.remove_field('products') - # TODO: figure out this dang department situation.. - f.remove_field('department_uuid') - f.set_readonly('department') + # department + if self.creating or self.editing: + if 'department' in f.fields: + f.replace('department', 'department_uuid') + departments = self.get_departments() + dept_values = [(d.uuid, "{} {}".format(d.number, d.name)) + for d in departments] + require_department = False + if not require_department: + dept_values.insert(0, ('', "(none)")) + f.set_widget('department_uuid', + dfwidget.SelectWidget(values=dept_values)) + f.set_label('department_uuid', "Department") + else: + f.set_readonly('department') + f.set_renderer('department', self.render_department) + + def get_departments(self): + """ + Returns the list of departments to be exposed in a drop-down. + """ + model = self.model + return self.Session.query(model.Department)\ + .filter(sa.or_( + model.Department.product == True, + model.Department.product == None))\ + .order_by(model.Department.name)\ + .all() def get_merge_data(self, subdept): return { diff --git a/tailbone/views/tables.py b/tailbone/views/tables.py index 5d4f7d95..bfd52f2b 100644 --- a/tailbone/views/tables.py +++ b/tailbone/views/tables.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,7 +24,18 @@ Views with info about the underlying Rattail tables """ -from __future__ import unicode_literals, absolute_import +import os +import sys +import warnings + +import sqlalchemy as sa +from sqlalchemy_utils import get_mapper + +from rattail.util import simple_error + +import colander +from deform import widget as dfwidget +from webhelpers2.html import HTML from tailbone.views import MasterView @@ -34,20 +45,45 @@ class TableView(MasterView): Master view for tables """ normalized_model_name = 'table' - model_key = 'name' + model_key = 'table_name' model_title = "Table" creatable = False editable = False deletable = False - viewable = False filterable = False pageable = False + labels = { + 'branch_name': "Schema Branch", + 'model_name': "Model Class", + 'module_name': "Module", + 'module_file': "File", + } + grid_columns = [ - 'name', + 'table_name', 'row_count', ] + has_rows = True + rows_title = "Columns" + rows_pageable = False + rows_filterable = False + rows_viewable = False + + row_grid_columns = [ + 'sequence', + 'column_name', + 'data_type', + 'nullable', + 'description', + ] + + def __init__(self, request): + super().__init__(request) + app = self.get_rattail_app() + self.db_handler = app.get_db_handler() + def get_data(self, **kwargs): """ Fetch existing table names and estimate row counts via PG SQL @@ -61,19 +97,355 @@ class TableView(MasterView): where schemaname = 'public' order by n_live_tup desc; """ - result = self.Session.execute(sql) - return [dict(name=row['relname'], row_count=row['n_live_tup']) + result = self.Session.execute(sa.text(sql)) + return [dict(table_name=row.relname, row_count=row.n_live_tup) for row in result] def configure_grid(self, g): - g.sorters['name'] = g.make_simple_sorter('name', foldcase=True) + super().configure_grid(g) + + # table_name + g.sorters['table_name'] = g.make_simple_sorter('table_name', foldcase=True) + g.set_sort_defaults('table_name') + g.set_searchable('table_name') + g.set_link('table_name') + + # row_count g.sorters['row_count'] = g.make_simple_sorter('row_count') - g.set_sort_defaults('name') - g.set_searchable('name') + def configure_form(self, f): + super().configure_form(f) -# TODO: deprecate / remove this -TablesView = TableView + # TODO: should render this instead, by inspecting table + if not self.creating: + f.remove('versioned') + + def get_instance(self): + model = self.model + table_name = self.request.matchdict['table_name'] + + sql = """ + select n_live_tup + from pg_stat_user_tables + where schemaname = 'public' and relname = :table_name + order by n_live_tup desc; + """ + result = self.Session.execute(sql, {'table_name': table_name}) + row = result.fetchone() + if not row: + raise self.notfound() + + data = { + 'table_name': table_name, + 'row_count': row['n_live_tup'], + } + + table = model.Base.metadata.tables.get(table_name) + data['table'] = table + if table is not None: + try: + mapper = get_mapper(table) + except ValueError: + pass + else: + data['model_name'] = mapper.class_.__name__ + data['model_title'] = mapper.class_.get_model_title() + data['model_title_plural'] = mapper.class_.get_model_title_plural() + data['description'] = mapper.class_.__doc__ + + # TODO: how to reliably get branch? must walk all revisions? + module_parts = mapper.class_.__module__.split('.') + data['branch_name'] = module_parts[0] + + data['module_name'] = mapper.class_.__module__ + data['module_file'] = sys.modules[mapper.class_.__module__].__file__ + + return data + + def get_instance_title(self, table): + return table['table_name'] + + def make_form_schema(self): + return TableSchema() + + def get_xref_buttons(self, table): + buttons = super().get_xref_buttons(table) + + if table.get('model_name'): + all_views = self.request.registry.settings['tailbone_model_views'] + model_views = all_views.get(table['model_name'], []) + for view in model_views: + url = self.request.route_url(view['route_prefix']) + buttons.append(self.make_xref_button(url=url, text=view['label'], + internal=True)) + + if self.request.has_perm('model_views.create'): + url = self.request.route_url('model_views.create', + _query={'model_name': table['model_name']}) + buttons.append(self.make_button("New View", + is_primary=True, + url=url, + icon_left='plus')) + + return buttons + + def template_kwargs_create(self, **kwargs): + kwargs = super().template_kwargs_create(**kwargs) + app = self.get_rattail_app() + model = self.model + + kwargs['alembic_current_head'] = self.db_handler.check_alembic_current_head() + + kwargs['branch_name_options'] = self.db_handler.get_alembic_branch_names() + + branch_name = app.get_table_prefix() + if branch_name not in kwargs['branch_name_options']: + branch_name = None + kwargs['branch_name'] = branch_name + + kwargs['existing_tables'] = [{'name': table} + for table in sorted(model.Base.metadata.tables)] + + kwargs['model_dir'] = (os.path.dirname(model.__file__) + + os.sep) + + return kwargs + + def write_model_file(self): + data = self.request.json_body + path = data['module_file'] + model = self.model + + if os.path.exists(path): + if data['overwrite']: + os.remove(path) + else: + return {'error': "File already exists"} + + for column in data['columns']: + if column['data_type']['type'] == '_fk_uuid_' and column['relationship']: + name = column['relationship'] + + table = model.Base.metadata.tables[column['data_type']['reference']] + try: + mapper = get_mapper(table) + except ValueError: + reference_model = table.name.capitalize() + else: + reference_model = mapper.class_.__name__ + + column['relationship'] = { + 'name': name, + 'reference_model': reference_model, + } + + self.db_handler.write_table_model(data, path) + return {'ok': True} + + def check_model(self): + model = self.model + data = self.request.json_body + model_name = data['model_name'] + + if not hasattr(model, model_name): + return {'ok': True, + 'problem': "class not found in primary model contents", + 'model': self.model.__name__} + + # TODO: probably should inspect closer before assuming ok..? + + return {'ok': True} + + def write_revision_script(self): + data = self.request.json_body + script = self.db_handler.generate_revision_script(data['branch'], + message=data['message']) + return {'ok': True, + 'script': script.path} + + def upgrade_db(self): + self.db_handler.upgrade_db() + return {'ok': True} + + def check_table(self): + model = self.model + data = self.request.json_body + table_name = data['table_name'] + + table = model.Base.metadata.tables.get(table_name) + if table is None: + return {'ok': True, + 'problem': "Table does not exist in model metadata!"} + + try: + count = self.Session.query(table).count() + except Exception as error: + return {'ok': True, + 'problem': simple_error(error)} + + url = self.request.route_url('{}.view'.format(self.get_route_prefix()), + table_name=table_name) + return {'ok': True, 'url': url} + + def get_row_data(self, table): + data = [] + for i, column in enumerate(table['table'].columns, 1): + data.append({ + 'column': column, + 'sequence': i, + 'column_name': column.name, + 'data_type': str(repr(column.type)), + 'nullable': column.nullable, + 'description': column.doc, + }) + return data + + def configure_row_grid(self, g): + super().configure_row_grid(g) + + g.sorters['sequence'] = g.make_simple_sorter('sequence') + g.set_sort_defaults('sequence') + g.set_label('sequence', "Seq.") + + g.sorters['column_name'] = g.make_simple_sorter('column_name', + foldcase=True) + g.set_searchable('column_name') + + g.sorters['data_type'] = g.make_simple_sorter('data_type', + foldcase=True) + g.set_searchable('data_type') + + g.set_type('nullable', 'boolean') + g.sorters['nullable'] = g.make_simple_sorter('nullable') + + g.set_renderer('description', self.render_column_description) + g.set_searchable('description') + + def render_column_description(self, column, field): + text = column[field] + if not text: + return + + max_length = 80 + + if len(text) < max_length: + return text + + return HTML.tag('span', title=text, c="{} ...".format(text[:max_length])) + + def migrations(self): + # TODO: allow alembic upgrade on POST + # TODO: pass current revisions to page context + return self.render_to_response('migrations', {}) + + @classmethod + def defaults(cls, config): + rattail_config = config.registry.settings.get('rattail_config') + + # allow creating tables only if *not* production + if not rattail_config.production(): + cls.creatable = True + + cls._table_defaults(config) + cls._defaults(config) + + @classmethod + def _table_defaults(cls, config): + route_prefix = cls.get_route_prefix() + url_prefix = cls.get_url_prefix() + permission_prefix = cls.get_permission_prefix() + + # migrations + config.add_tailbone_permission(permission_prefix, + '{}.migrations'.format(permission_prefix), + "View / apply Alembic migrations") + config.add_route('{}.migrations'.format(route_prefix), + '{}/migrations'.format(url_prefix)) + config.add_view(cls, attr='migrations', + route_name='{}.migrations'.format(route_prefix), + renderer='json', + permission='{}.migrations'.format(permission_prefix)) + + if cls.creatable: + + # write model class to file + config.add_route('{}.write_model_file'.format(route_prefix), + '{}/write-model-file'.format(url_prefix), + request_method='POST') + config.add_view(cls, attr='write_model_file', + route_name='{}.write_model_file'.format(route_prefix), + renderer='json', + permission='{}.create'.format(permission_prefix)) + + # check model + config.add_route('{}.check_model'.format(route_prefix), + '{}/check-model'.format(url_prefix), + request_method='POST') + config.add_view(cls, attr='check_model', + route_name='{}.check_model'.format(route_prefix), + renderer='json', + permission='{}.create'.format(permission_prefix)) + + # generate revision script + config.add_route('{}.write_revision_script'.format(route_prefix), + '{}/write-revision-script'.format(url_prefix), + request_method='POST') + config.add_view(cls, attr='write_revision_script', + route_name='{}.write_revision_script'.format(route_prefix), + renderer='json', + permission='{}.create'.format(permission_prefix)) + + # upgrade db + config.add_route('{}.upgrade_db'.format(route_prefix), + '{}/upgrade-db'.format(url_prefix), + request_method='POST') + config.add_view(cls, attr='upgrade_db', + route_name='{}.upgrade_db'.format(route_prefix), + renderer='json', + permission='{}.create'.format(permission_prefix)) + + # check table + config.add_route('{}.check_table'.format(route_prefix), + '{}/check-table'.format(url_prefix), + request_method='POST') + config.add_view(cls, attr='check_table', + route_name='{}.check_table'.format(route_prefix), + renderer='json', + permission='{}.create'.format(permission_prefix)) + + +class TablesView(TableView): + + def __init__(self, request): + warnings.warn("TablesView is deprecated; please use TableView instead", + DeprecationWarning, stacklevel=2) + super().__init__(request) + + +class TableSchema(colander.Schema): + + table_name = colander.SchemaNode(colander.String()) + + row_count = colander.SchemaNode(colander.Integer(), + missing=colander.null) + + model_name = colander.SchemaNode(colander.String()) + + model_title = colander.SchemaNode(colander.String()) + + model_title_plural = colander.SchemaNode(colander.String()) + + description = colander.SchemaNode(colander.String()) + + branch_name = colander.SchemaNode(colander.String()) + + module_name = colander.SchemaNode(colander.String(), + missing=colander.null) + + module_file = colander.SchemaNode(colander.String(), + missing=colander.null) + + versioned = colander.SchemaNode(colander.Bool()) def defaults(config, **kwargs): diff --git a/tailbone/views/taxes.py b/tailbone/views/taxes.py index 19a385ba..b2afaeb9 100644 --- a/tailbone/views/taxes.py +++ b/tailbone/views/taxes.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,8 +24,6 @@ Tax Views """ -from __future__ import unicode_literals, absolute_import - from rattail.db import model from tailbone.views import MasterView @@ -53,12 +51,26 @@ class TaxView(MasterView): ] def configure_grid(self, g): - super(TaxView, self).configure_grid(g) - g.filters['description'].default_active = True - g.filters['description'].default_verb = 'contains' + super().configure_grid(g) + + # code g.set_sort_defaults('code') g.set_link('code') + + # description g.set_link('description') + g.filters['description'].default_active = True + g.filters['description'].default_verb = 'contains' + + # rate + g.set_type('rate', 'percent') + + def configure_form(self, f): + super().configure_form(f) + + # rate + f.set_type('rate', 'percent') + # TODO: deprecate / remove this TaxesView = TaxView diff --git a/tailbone/views/tempmon/appliances.py b/tailbone/views/tempmon/appliances.py index 6b8ee036..4ce52009 100644 --- a/tailbone/views/tempmon/appliances.py +++ b/tailbone/views/tempmon/appliances.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,11 +24,9 @@ Views for tempmon appliances """ -from __future__ import unicode_literals, absolute_import - +import io import os -import six from PIL import Image from rattail_tempmon.db import model as tempmon @@ -68,7 +66,7 @@ class TempmonApplianceView(MasterView): ] def configure_grid(self, g): - super(TempmonApplianceView, self).configure_grid(g) + super().configure_grid(g) # name g.set_sort_defaults('name') @@ -94,7 +92,7 @@ class TempmonApplianceView(MasterView): return HTML.tag('div', class_='image-frame', c=[helper, image]) def configure_form(self, f): - super(TempmonApplianceView, self).configure_form(f) + super().configure_form(f) # name f.set_validator('name', self.unique_name) @@ -121,6 +119,14 @@ class TempmonApplianceView(MasterView): elif self.creating or self.editing: f.remove_field('probes') + def template_kwargs_view(self, **kwargs): + kwargs = super().template_kwargs_view(**kwargs) + appliance = kwargs['instance'] + + kwargs['probes_data'] = self.normalize_probes(appliance.probes) + + return kwargs + def unique_name(self, node, value): query = self.Session.query(tempmon.Appliance)\ .filter(tempmon.Appliance.name == value) @@ -168,13 +174,13 @@ class TempmonApplianceView(MasterView): im = Image.open(f) im.thumbnail((600, 600), Image.ANTIALIAS) - data = six.BytesIO() + data = io.BytesIO() im.save(data, 'JPEG') appliance.image_normal = data.getvalue() data.close() im.thumbnail((150, 150), Image.ANTIALIAS) - data = six.BytesIO() + data = io.BytesIO() im.save(data, 'JPEG') appliance.image_thumbnail = data.getvalue() data.close() diff --git a/tailbone/views/tempmon/clients.py b/tailbone/views/tempmon/clients.py index a3fdb31b..1b2d49d8 100644 --- a/tailbone/views/tempmon/clients.py +++ b/tailbone/views/tempmon/clients.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,8 +24,6 @@ Views for tempmon clients """ -from __future__ import unicode_literals, absolute_import - import subprocess from rattail.config import parse_list @@ -51,6 +49,7 @@ class TempmonClientView(MasterView): has_rows = True model_row_class = tempmon.Reading + rows_title = "Readings" grid_columns = [ 'config_key', @@ -83,7 +82,7 @@ class TempmonClientView(MasterView): ] def configure_grid(self, g): - super(TempmonClientView, self).configure_grid(g) + super().configure_grid(g) # config_key g.set_label('config_key', "Key") @@ -116,7 +115,7 @@ class TempmonClientView(MasterView): return "No" def configure_form(self, f): - super(TempmonClientView, self).configure_form(f) + super().configure_form(f) # config_key f.set_validator('config_key', self.unique_config_key) @@ -159,6 +158,14 @@ class TempmonClientView(MasterView): # archived f.set_helptext('archived', tempmon.Client.archived.__doc__) + def template_kwargs_view(self, **kwargs): + kwargs = super().template_kwargs_view(**kwargs) + client = kwargs['instance'] + + kwargs['probes_data'] = self.normalize_probes(client.probes) + + return kwargs + def objectify(self, form, data=None): # this is a hack to prevent updates to the 'enabled' timestamp, when @@ -169,7 +176,7 @@ class TempmonClientView(MasterView): if data['enabled'] and form.model_instance.enabled: data['enabled'] = form.model_instance.enabled - return super(TempmonClientView, self).objectify(form, data=data) + return super().objectify(form, data=data) def unique_config_key(self, node, value): query = self.Session.query(tempmon.Client)\ @@ -222,7 +229,7 @@ class TempmonClientView(MasterView): return reading.client def configure_row_grid(self, g): - super(TempmonClientView, self).configure_row_grid(g) + super().configure_row_grid(g) # probe g.set_filter('probe', tempmon.Probe.description) diff --git a/tailbone/views/tempmon/core.py b/tailbone/views/tempmon/core.py index 6665f50e..7540abbe 100644 --- a/tailbone/views/tempmon/core.py +++ b/tailbone/views/tempmon/core.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2020 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,8 +24,6 @@ Common stuff for tempmon views """ -from __future__ import unicode_literals, absolute_import - from webhelpers2.html import HTML from tailbone import views, grids @@ -42,6 +40,28 @@ class MasterView(views.MasterView): from rattail_tempmon.db import Session return Session() + def normalize_probes(self, probes): + data = [] + for probe in probes: + view_url = self.request.route_url('tempmon.probes.view', uuid=probe.uuid) + edit_url = self.request.route_url('tempmon.probes.edit', uuid=probe.uuid) + data.append({ + 'uuid': probe.uuid, + 'url': view_url, + '_action_url_view': view_url, + '_action_url_edit': edit_url, + 'description': probe.description, + 'critical_temp_min': probe.critical_temp_min, + 'good_temp_min': probe.good_temp_min, + 'good_temp_max': probe.good_temp_max, + 'critical_temp_max': probe.critical_temp_max, + 'status': self.enum.TEMPMON_PROBE_STATUS.get(probe.status, '??'), + 'enabled': "Yes" if probe.enabled else "No", + }) + app = self.get_rattail_app() + data = app.json_friendly(data) + return data + def render_probes(self, obj, field): """ This method is used by Appliance and Client views. @@ -50,17 +70,16 @@ class MasterView(views.MasterView): return "" route_prefix = self.get_route_prefix() - view_url = lambda p, i: self.request.route_url('tempmon.probes.view', uuid=p.uuid) - actions = [ - grids.GridAction('view', icon='zoomin', url=view_url), - ] - if self.request.has_perm('tempmon.probes.edit'): - url = lambda p, i: self.request.route_url('tempmon.probes.edit', uuid=p.uuid) - actions.append(grids.GridAction('edit', icon='pencil', url=url)) - g = grids.Grid( - key='{}.probes'.format(route_prefix), - data=obj.probes, + actions = [self.make_grid_action_view()] + if self.request.has_perm('tempmon.probes.edit'): + actions.append(self.make_grid_action_edit()) + + factory = self.get_grid_factory() + g = factory( + self.request, + key=f'{route_prefix}.probes', + data=[], columns=[ 'description', 'critical_temp_min', @@ -76,10 +95,8 @@ class MasterView(views.MasterView): 'good_temp_max': "Good Max", 'critical_temp_max': "Crit. Max", }, - url=lambda p: self.request.route_url('tempmon.probes.view', uuid=p.uuid), linked_columns=['description'], - main_actions=actions, + actions=actions, ) - g.set_enum('status', self.enum.TEMPMON_PROBE_STATUS) - g.set_type('enabled', 'boolean') - return HTML.literal(g.render_grid()) + return HTML.literal( + g.render_table_element(data_prop='probesData')) diff --git a/tailbone/views/tempmon/dashboard.py b/tailbone/views/tempmon/dashboard.py index 954acf94..515eabc9 100644 --- a/tailbone/views/tempmon/dashboard.py +++ b/tailbone/views/tempmon/dashboard.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,15 +24,11 @@ Tempmon "Dashboard" View """ -from __future__ import unicode_literals, absolute_import - import datetime from rattail.time import localtime, make_utc from rattail_tempmon.db import model as tempmon -from webhelpers2.html import tags - from tailbone.views import View from tailbone.db import TempmonSession @@ -44,13 +40,12 @@ class TempmonDashboardView(View): session_key = 'tempmon.dashboard.appliance_uuid' def dashboard(self): - use_buefy = self.get_use_buefy() if self.request.method == 'POST': appliance = None uuid = self.request.POST.get('appliance_uuid') if uuid: - appliance = TempmonSession.query(tempmon.Appliance).get(uuid) + appliance = TempmonSession.get(tempmon.Appliance, uuid) if appliance: self.request.session[self.session_key] = appliance.uuid if not appliance: @@ -71,29 +66,23 @@ class TempmonDashboardView(View): self.request.session[self.session_key] = selected_uuid if not selected_appliance and selected_uuid: - selected_appliance = TempmonSession.query(tempmon.Appliance)\ - .get(selected_uuid) + selected_appliance = TempmonSession.get(tempmon.Appliance, selected_uuid) + + context = { + 'index_url': self.request.route_url('tempmon.appliances'), + 'index_title': "TempMon Appliances", + 'appliance': selected_appliance, + } appliances = TempmonSession.query(tempmon.Appliance)\ .order_by(tempmon.Appliance.name)\ .all() - appliance_options = tags.Options([ - tags.Option(appliance.name, appliance.uuid) - for appliance in appliances]) - if use_buefy: - appliance_select = None - raise NotImplementedError - else: - appliance_select = tags.select('appliance_uuid', selected_uuid, appliance_options) + context['appliances_data'] = [{'uuid': a.uuid, + 'name': a.name} + for a in appliances] - return { - 'index_url': self.request.route_url('tempmon.appliances'), - 'index_title': "TempMon Appliances", - 'use_buefy': use_buefy, - 'appliance_select': appliance_select, - 'appliance': selected_appliance, - } + return context def readings(self): @@ -101,7 +90,7 @@ class TempmonDashboardView(View): uuid = self.request.params.get('appliance_uuid') if not uuid: return {'error': "Must specify valid appliance_uuid"} - appliance = TempmonSession.query(tempmon.Appliance).get(uuid) + appliance = TempmonSession.get(tempmon.Appliance, uuid) if not appliance: return {'error': "Must specify valid appliance_uuid"} diff --git a/tailbone/views/tempmon/probes.py b/tailbone/views/tempmon/probes.py index 218caafa..573f9a2d 100644 --- a/tailbone/views/tempmon/probes.py +++ b/tailbone/views/tempmon/probes.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,18 +24,13 @@ Views for tempmon probes """ -from __future__ import unicode_literals, absolute_import - import datetime -import six - -from rattail.time import make_utc, localtime from rattail_tempmon.db import model as tempmon import colander from deform import widget as dfwidget -from webhelpers2.html import tags, HTML +from webhelpers2.html import tags from tailbone import forms, grids from tailbone.views.tempmon import MasterView @@ -54,6 +49,7 @@ class TempmonProbeView(MasterView): has_rows = True model_row_class = tempmon.Reading + rows_title = "Readings" labels = { 'critical_max_timeout': "Critical High Timeout", @@ -103,10 +99,11 @@ class TempmonProbeView(MasterView): ] def configure_grid(self, g): - super(TempmonProbeView, self).configure_grid(g) + super().configure_grid(g) - g.joiners['client'] = lambda q: q.join(tempmon.Client) - g.sorters['client'] = g.make_sorter(tempmon.Client.config_key) + # client + g.set_joiner('client', lambda q: q.join(tempmon.Client)) + g.set_sorter('client', tempmon.Client.config_key) g.set_sort_defaults('client') g.set_enum('appliance_type', self.enum.TEMPMON_APPLIANCE_TYPE) @@ -126,7 +123,7 @@ class TempmonProbeView(MasterView): return "No" def configure_form(self, f): - super(TempmonProbeView, self).configure_form(f) + super().configure_form(f) # config_key f.set_validator('config_key', self.unique_config_key) @@ -191,7 +188,7 @@ class TempmonProbeView(MasterView): if data['enabled'] and form.model_instance.enabled: data['enabled'] = form.model_instance.enabled - return super(TempmonProbeView, self).objectify(form, data=data) + return super().objectify(form, data=data) def unique_config_key(self, node, value): query = self.Session.query(tempmon.Probe)\ @@ -206,7 +203,7 @@ class TempmonProbeView(MasterView): client = probe.client if not client: return "" - text = six.text_type(client) + text = str(client) url = self.request.route_url('tempmon.clients.view', uuid=client.uuid) return tags.link_to(text, url) @@ -214,7 +211,7 @@ class TempmonProbeView(MasterView): appliance = probe.appliance if not appliance: return "" - text = six.text_type(appliance) + text = str(appliance) url = self.request.route_url('tempmon.appliances.view', uuid=appliance.uuid) return tags.link_to(text, url) @@ -245,7 +242,7 @@ class TempmonProbeView(MasterView): return reading.client def configure_row_grid(self, g): - super(TempmonProbeView, self).configure_row_grid(g) + super().configure_row_grid(g) # # probe # g.set_filter('probe', tempmon.Probe.description) @@ -255,7 +252,6 @@ class TempmonProbeView(MasterView): def graph(self): probe = self.get_instance() - use_buefy = self.get_use_buefy() key = 'tempmon.probe.{}.graph_time_range'.format(probe.uuid) selected = self.request.params.get('time-range') @@ -263,30 +259,16 @@ class TempmonProbeView(MasterView): selected = self.request.session.get(key, 'last hour') self.request.session[key] = selected - range_options = tags.Options([ - tags.Option("Last Hour", 'last hour'), - tags.Option("Last 6 Hours", 'last 6 hours'), - tags.Option("Last Day", 'last day'), - tags.Option("Last Week", 'last week'), - ]) - - if use_buefy: - time_range = HTML.tag('b-select', c=[range_options.render()], - **{'v-model': 'currentTimeRange', - '@input': 'timeRangeChanged'}) - else: - time_range = tags.select('time-range', selected, range_options) - context = { 'probe': probe, - 'parent_title': six.text_type(probe), + 'parent_title': str(probe), 'parent_url': self.get_action_url('view', probe), - 'time_range': time_range, 'current_time_range': selected, } return self.render_to_response('graph', context) def graph_readings(self): + app = self.get_rattail_app() probe = self.get_instance() key = 'tempmon.probe.{}.graph_time_range'.format(probe.uuid) @@ -307,7 +289,7 @@ class TempmonProbeView(MasterView): raise NotImplementedError("Unknown time range: {}".format(selected)) # figure out which readings we need to graph - cutoff = make_utc() - datetime.timedelta(seconds=cutoff) + cutoff = app.make_utc() - datetime.timedelta(seconds=cutoff) readings = self.Session.query(tempmon.Reading)\ .filter(tempmon.Reading.probe == probe)\ .filter(tempmon.Reading.taken >= cutoff)\ @@ -316,7 +298,7 @@ class TempmonProbeView(MasterView): # convert readings to data for scatter plot data = [{ - 'x': localtime(self.rattail_config, reading.taken, from_utc=True).isoformat(), + 'x': app.localtime(reading.taken, from_utc=True).isoformat(), 'y': float(reading.degrees_f), } for reading in readings] return data diff --git a/tailbone/views/tempmon/readings.py b/tailbone/views/tempmon/readings.py index a8223dd2..02e3fc51 100644 --- a/tailbone/views/tempmon/readings.py +++ b/tailbone/views/tempmon/readings.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,9 +24,6 @@ Views for tempmon readings """ -from __future__ import unicode_literals, absolute_import - -import six from sqlalchemy import orm from rattail_tempmon.db import model as tempmon @@ -70,17 +67,21 @@ class TempmonReadingView(MasterView): .options(orm.joinedload(tempmon.Reading.client)) def configure_grid(self, g): - super(TempmonReadingView, self).configure_grid(g) + super().configure_grid(g) - g.sorters['client_key'] = g.make_sorter(tempmon.Client.config_key) - g.filters['client_key'] = g.make_filter('client_key', tempmon.Client.config_key) + # client_key + g.set_sorter('client_key', tempmon.Client.config_key) + g.set_filter('client_key', tempmon.Client.config_key) - g.sorters['client_host'] = g.make_sorter(tempmon.Client.hostname) - g.filters['client_host'] = g.make_filter('client_host', tempmon.Client.hostname) + # client_host + g.set_sorter('client_host', tempmon.Client.hostname) + g.set_filter('client_host', tempmon.Client.hostname) - g.joiners['probe'] = lambda q: q.join(tempmon.Probe, tempmon.Probe.uuid == tempmon.Reading.probe_uuid) - g.sorters['probe'] = g.make_sorter(tempmon.Probe.description) - g.filters['probe'] = g.make_filter('probe', tempmon.Probe.description) + # probe + g.set_joiner('probe', lambda q: q.join(tempmon.Probe, + tempmon.Probe.uuid == tempmon.Reading.probe_uuid)) + g.set_sorter('probe', tempmon.Probe.description) + g.set_filter('probe', tempmon.Probe.description) g.set_sort_defaults('taken', 'desc') g.set_type('taken', 'datetime') @@ -98,7 +99,7 @@ class TempmonReadingView(MasterView): return reading.client.hostname def configure_form(self, f): - super(TempmonReadingView, self).configure_form(f) + super().configure_form(f) # client f.set_renderer('client', self.render_client) @@ -112,7 +113,7 @@ class TempmonReadingView(MasterView): client = reading.client if not client: return "" - text = six.text_type(client) + text = str(client) url = self.request.route_url('tempmon.clients.view', uuid=client.uuid) return tags.link_to(text, url) @@ -120,7 +121,7 @@ class TempmonReadingView(MasterView): probe = reading.probe if not probe: return "" - text = six.text_type(probe) + text = str(probe) url = self.request.route_url('tempmon.probes.view', uuid=probe.uuid) return tags.link_to(text, url) diff --git a/tailbone/views/tenders.py b/tailbone/views/tenders.py new file mode 100644 index 00000000..46f51c83 --- /dev/null +++ b/tailbone/views/tenders.py @@ -0,0 +1,85 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2023 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. +# +################################################################################ +""" +Views for tenders +""" + +from rattail.db.model import Tender + +from tailbone.views import MasterView + + +class TenderView(MasterView): + """ + Master view for the Tender class. + """ + model_class = Tender + has_versions = True + + grid_columns = [ + 'code', + 'name', + 'is_cash', + 'is_foodstamp', + 'allow_cash_back', + 'kick_drawer', + ] + + form_fields = [ + 'code', + 'name', + 'is_cash', + 'is_foodstamp', + 'allow_cash_back', + 'kick_drawer', + 'notes', + 'disabled', + ] + + def configure_grid(self, g): + super().configure_grid(g) + + g.set_link('code') + + g.set_link('name') + g.set_sort_defaults('name') + + def grid_extra_class(self, tender, i): + if tender.disabled: + return 'warning' + + def configure_form(self, f): + super().configure_form(f) + + f.set_type('notes', 'text') + + +def defaults(config, **kwargs): + base = globals() + + TenderView = kwargs.get('TenderView', base['TenderView']) + TenderView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/trainwreck/__init__.py b/tailbone/views/trainwreck/__init__.py index 33662c67..b5eea351 100644 --- a/tailbone/views/trainwreck/__init__.py +++ b/tailbone/views/trainwreck/__init__.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2021 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -27,3 +27,7 @@ Trainwreck Views from __future__ import unicode_literals, absolute_import from .base import TransactionView + + +def includeme(config): + config.include('tailbone.views.trainwreck.defaults') diff --git a/tailbone/views/trainwreck/base.py b/tailbone/views/trainwreck/base.py index 163d17b0..d5f077aa 100644 --- a/tailbone/views/trainwreck/base.py +++ b/tailbone/views/trainwreck/base.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,12 +24,6 @@ Trainwreck views """ -from __future__ import unicode_literals, absolute_import - -import six - -from rattail.time import localtime - from webhelpers2.html import HTML, tags from tailbone.db import Session, TrainwreckSession, ExtraTrainwreckSessions @@ -48,6 +42,7 @@ class TransactionView(MasterView): creatable = False editable = False deletable = False + results_downloadable = True supports_multiple_engines = True engine_type_key = 'trainwreck' @@ -156,15 +151,48 @@ class TransactionView(MasterView): trainwreck_handler = app.get_trainwreck_handler() return trainwreck_handler.get_trainwreck_engines(include_hidden=False) + def make_isolated_session(self): + from rattail.trainwreck.db import Session as TrainwreckSession + + dbkey = self.get_current_engine_dbkey() + if dbkey != 'default': + app = self.get_rattail_app() + trainwreck_handler = app.get_trainwreck_handler() + trainwreck_engines = trainwreck_handler.get_trainwreck_engines() + if dbkey in trainwreck_engines: + return TrainwreckSesssion(bind=trainwreck_engines[dbkey]) + + return TrainwreckSession() + + def get_context_menu_items(self, txn=None): + items = super().get_context_menu_items(txn) + route_prefix = self.get_route_prefix() + + if self.listing: + + if self.has_perm('rollover'): + url = self.request.route_url(f'{route_prefix}.rollover') + items.append(tags.link_to("Yearly Rollover", url)) + + return items + def configure_grid(self, g): - super(TransactionView, self).configure_grid(g) + super().configure_grid(g) + app = self.get_rattail_app() + g.filters['receipt_number'].default_active = True g.filters['receipt_number'].default_verb = 'equal' + # end_time + g.set_sort_defaults('end_time', 'desc') g.filters['end_time'].default_active = True g.filters['end_time'].default_verb = 'equal' - g.filters['end_time'].default_value = six.text_type(localtime(self.rattail_config).date()) - g.set_sort_defaults('end_time', 'desc') + # TODO: should expose this setting somewhere + if self.rattail_config.getbool('trainwreck', 'show_yesterday_first'): + date = app.yesterday() + else: + date = app.today() + g.filters['end_time'].default_value = str(date) g.set_enum('system', self.enum.TRAINWRECK_SYSTEM) g.set_type('total', 'currency') @@ -186,7 +214,7 @@ class TransactionView(MasterView): return 'warning' def configure_form(self, f): - super(TransactionView, self).configure_form(f) + super().configure_form(f) # system f.set_enum('system', self.enum.TRAINWRECK_SYSTEM) @@ -216,38 +244,57 @@ class TransactionView(MasterView): route_prefix = self.get_route_prefix() factory = self.get_grid_factory() - use_buefy = self.get_use_buefy() g = factory( - key='{}.custorder_xref_markers'.format(route_prefix), - data=[] if use_buefy else txn.custorder_xref_markers, - columns=['custorder_xref', 'custorder_item_xref'], - request=self.request) + self.request, + key=f'{route_prefix}.custorder_xref_markers', + data=[], + columns=['custorder_xref', 'custorder_item_xref']) - if use_buefy: - return HTML.literal( - g.render_buefy_table_element(data_prop='custorderXrefMarkersData')) - else: - return HTML.literal(g.render_grid()) + return HTML.literal( + g.render_table_element(data_prop='custorderXrefMarkersData')) def template_kwargs_view(self, **kwargs): - kwargs = super(TransactionView, self).template_kwargs_view(**kwargs) + kwargs = super().template_kwargs_view(**kwargs) + config = self.rattail_config - use_buefy = self.get_use_buefy() - if use_buefy: - form = kwargs['form'] - if 'custorder_xref_markers' in form: - txn = kwargs['instance'] - markers = [] - for marker in txn.custorder_xref_markers: - markers.append({ - 'custorder_xref': marker.custorder_xref, - 'custorder_item_xref': marker.custorder_item_xref, - }) - kwargs['custorder_xref_markers_data'] = markers + form = kwargs['form'] + if 'custorder_xref_markers' in form: + txn = kwargs['instance'] + markers = [] + for marker in txn.custorder_xref_markers: + markers.append({ + 'custorder_xref': marker.custorder_xref, + 'custorder_item_xref': marker.custorder_item_xref, + }) + kwargs['custorder_xref_markers_data'] = markers + + # collapse header + kwargs['main_form_title'] = "Transaction Header" + kwargs['main_form_collapsible'] = True + kwargs['main_form_autocollapse'] = config.get_bool( + 'tailbone.trainwreck.view_txn.autocollapse_header', + default=False) return kwargs + def get_xref_buttons(self, txn): + app = self.get_rattail_app() + clientele = app.get_clientele_handler() + buttons = super().get_xref_buttons(txn) + + if txn.customer_id: + customer = clientele.locate_customer_for_key(Session(), txn.customer_id) + if customer: + person = app.get_person(customer) + if person: + url = self.request.route_url('people.view_profile', uuid=person.uuid) + buttons.append(self.make_xref_button(text=str(person), + url=url, + internal=True)) + + return buttons + def get_row_data(self, transaction): return self.Session.query(self.model_row_class)\ .filter(self.model_row_class.transaction == transaction) @@ -256,7 +303,7 @@ class TransactionView(MasterView): return item.transaction def configure_row_grid(self, g): - super(TransactionView, self).configure_row_grid(g) + super().configure_row_grid(g) g.set_sort_defaults('sequence') g.set_type('unit_quantity', 'quantity') @@ -276,7 +323,7 @@ class TransactionView(MasterView): return "Trainwreck Line Item" def configure_row_form(self, f): - super(TransactionView, self).configure_row_form(f) + super().configure_row_form(f) # transaction f.set_renderer('transaction', self.render_transaction) @@ -296,7 +343,7 @@ class TransactionView(MasterView): def render_transaction(self, item, field): txn = getattr(item, field) - text = six.text_type(txn) + text = str(txn) url = self.get_action_url('view', txn) return tags.link_to(text, url) @@ -306,38 +353,31 @@ class TransactionView(MasterView): route_prefix = self.get_route_prefix() factory = self.get_grid_factory() - use_buefy = self.get_use_buefy() g = factory( - key='{}.discounts'.format(route_prefix), - data=[] if use_buefy else item.discounts, + self.request, + key=f'{route_prefix}.discounts', + data=[], columns=['discount_type', 'description', 'amount'], - labels={'discount_type': "Type"}, - request=self.request) + labels={'discount_type': "Type"}) - if use_buefy: - return HTML.literal( - g.render_buefy_table_element(data_prop='discountsData')) - else: - g.set_type('amount', 'currency') - return HTML.literal(g.render_grid()) + return HTML.literal( + g.render_table_element(data_prop='discountsData')) def template_kwargs_view_row(self, **kwargs): - use_buefy = self.get_use_buefy() - if use_buefy: - form = kwargs['form'] - if 'discounts' in form: + form = kwargs['form'] + if 'discounts' in form: - app = self.get_rattail_app() - item = kwargs['instance'] - discounts_data = [] - for discount in item.discounts: - discounts_data.append({ - 'discount_type': discount.discount_type, - 'description': discount.description, - 'amount': app.render_currency(discount.amount), - }) - kwargs['discounts_data'] = discounts_data + app = self.get_rattail_app() + item = kwargs['instance'] + discounts_data = [] + for discount in item.discounts: + discounts_data.append({ + 'discount_type': discount.discount_type, + 'description': discount.description, + 'amount': app.render_currency(discount.amount), + }) + kwargs['discounts_data'] = discounts_data return kwargs @@ -352,7 +392,7 @@ class TransactionView(MasterView): # find oldest and newest dates for each database engines_data = [] - for key, engine in six.iteritems(trainwreck_engines): + for key, engine in trainwreck_engines.items(): if key == 'default': session = self.Session() @@ -384,8 +424,26 @@ class TransactionView(MasterView): 'engines_data': engines_data, }) + def configure_get_simple_settings(self): + return [ + + # display + {'section': 'tailbone', + 'option': 'trainwreck.view_txn.autocollapse_header', + 'type': bool}, + + # rotation + {'section': 'trainwreck', + 'option': 'use_rotation', + 'type': bool}, + {'section': 'trainwreck', + 'option': 'current_years', + 'type': int}, + + ] + def configure_get_context(self): - context = super(TransactionView, self).configure_get_context() + context = super().configure_get_context() app = self.get_rattail_app() trainwreck_handler = app.get_trainwreck_handler() @@ -399,7 +457,7 @@ class TransactionView(MasterView): return context def configure_gather_settings(self, data): - settings = super(TransactionView, self).configure_gather_settings(data) + settings = super().configure_gather_settings(data) app = self.get_rattail_app() trainwreck_handler = app.get_trainwreck_handler() @@ -416,7 +474,7 @@ class TransactionView(MasterView): return settings def configure_remove_settings(self): - super(TransactionView, self).configure_remove_settings() + super().configure_remove_settings() app = self.get_rattail_app() names = [ diff --git a/tailbone/views/typical.py b/tailbone/views/typical.py new file mode 100644 index 00000000..35259a14 --- /dev/null +++ b/tailbone/views/typical.py @@ -0,0 +1,61 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2023 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. +# +################################################################################ +""" +Typical views for convenient includes +""" + + +def defaults(config, **kwargs): + mod = lambda spec: kwargs.get(spec, spec) + + # main tables + config.include(mod('tailbone.views.brands')) + config.include(mod('tailbone.views.categories')) + config.include(mod('tailbone.views.customergroups')) + config.include(mod('tailbone.views.customers')) + config.include(mod('tailbone.views.custorders')) + config.include(mod('tailbone.views.departments')) + config.include(mod('tailbone.views.employees')) + config.include(mod('tailbone.views.families')) + config.include(mod('tailbone.views.members')) + config.include(mod('tailbone.views.products')) + config.include(mod('tailbone.views.purchases')) + config.include(mod('tailbone.views.reportcodes')) + config.include(mod('tailbone.views.stores')) + config.include(mod('tailbone.views.subdepartments')) + config.include(mod('tailbone.views.taxes')) + config.include(mod('tailbone.views.tenders')) + config.include(mod('tailbone.views.uoms')) + config.include(mod('tailbone.views.vendors')) + + # batches + config.include(mod('tailbone.views.batch.handheld')) + config.include(mod('tailbone.views.batch.importer')) + config.include(mod('tailbone.views.batch.inventory')) + config.include(mod('tailbone.views.batch.pos')) + config.include(mod('tailbone.views.batch.vendorcatalog')) + config.include(mod('tailbone.views.purchasing')) + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/upgrades.py b/tailbone/views/upgrades.py index 0b5e4b87..ffa88032 100644 --- a/tailbone/views/upgrades.py +++ b/tailbone/views/upgrades.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,22 +24,17 @@ Views for app upgrades """ -from __future__ import unicode_literals, absolute_import - import json import os import re import logging import warnings +from collections import OrderedDict -import six import sqlalchemy as sa -from rattail.core import Object -from rattail.db import model, Session as RattailSession -from rattail.time import make_utc +from rattail.db.model import Upgrade from rattail.threads import Thread -from rattail.util import OrderedDict from deform import widget as dfwidget from webhelpers2.html import tags, HTML @@ -56,13 +51,14 @@ class UpgradeView(MasterView): """ Master view for all user events """ - model_class = model.Upgrade + model_class = Upgrade downloadable = True cloneable = True configurable = True executable = True execute_progress_template = '/upgrade.mako' execute_progress_initial_msg = "Upgrading" + execute_can_cancel = False labels = { 'executed_by': "Executed by", @@ -102,7 +98,7 @@ class UpgradeView(MasterView): ] def __init__(self, request): - super(UpgradeView, self).__init__(request) + super().__init__(request) if hasattr(self, 'get_handler'): warnings.warn("defining get_handler() is deprecated. please " @@ -122,7 +118,8 @@ class UpgradeView(MasterView): return self.upgrade_handler def configure_grid(self, g): - super(UpgradeView, self).configure_grid(g) + super().configure_grid(g) + model = self.model # system systems = self.upgrade_handler.get_all_systems() @@ -149,8 +146,17 @@ class UpgradeView(MasterView): return 'notice' def template_kwargs_view(self, **kwargs): + kwargs = super().template_kwargs_view(**kwargs) + app = self.get_rattail_app() + model = self.model upgrade = kwargs['instance'] + kwargs['system_title'] = app.get_title() + if upgrade.system: + system = self.upgrade_handler.get_system(upgrade.system) + if system: + kwargs['system_title'] = system['label'] + kwargs['show_prev_next'] = True kwargs['prev_url'] = None kwargs['next_url'] = None @@ -172,7 +178,7 @@ class UpgradeView(MasterView): return kwargs def configure_form(self, f): - super(UpgradeView, self).configure_form(f) + super().configure_form(f) upgrade = f.model_instance # system @@ -244,25 +250,24 @@ class UpgradeView(MasterView): code = getattr(upgrade, field) text = self.enum.UPGRADE_STATUS[code] - if self.get_use_buefy(): - if code == self.enum.UPGRADE_STATUS_EXECUTING: + if code == self.enum.UPGRADE_STATUS_EXECUTING: - text = HTML.tag('span', c=[text]) + text = HTML.tag('span', c=[text]) - button = HTML.tag('b-button', - type='is-warning', - icon_pack='fas', - icon_left='sad-tear', - c=['{{ declareFailureSubmitting ? "Working, please wait..." : "Declare Failure" }}'], - **{':disabled': 'declareFailureSubmitting', - '@click': 'declareFailureClick'}) + button = HTML.tag('b-button', + type='is-warning', + icon_pack='fas', + icon_left='sad-tear', + c=['{{ declareFailureSubmitting ? "Working, please wait..." : "Declare Failure" }}'], + **{':disabled': 'declareFailureSubmitting', + '@click': 'declareFailureClick'}) - return HTML.tag('div', class_='level', c=[ - HTML.tag('div', class_='level-left', c=[ - HTML.tag('div', class_='level-item', c=[text]), - HTML.tag('div', class_='level-item', c=[button]), - ]), - ]) + return HTML.tag('div', class_='level', c=[ + HTML.tag('div', class_='level-left', c=[ + HTML.tag('div', class_='level-item', c=[text]), + HTML.tag('div', class_='level-item', c=[button]), + ]), + ]) # just show status per normal return text @@ -271,9 +276,10 @@ class UpgradeView(MasterView): f.fields = ['system', 'description', 'notes', 'enabled'] def clone_instance(self, original): + app = self.get_rattail_app() cloned = self.model_class() cloned.system = original.system - cloned.created = make_utc() + cloned.created = app.make_utc() cloned.created_by = self.request.user cloned.description = original.description cloned.notes = original.notes @@ -295,14 +301,12 @@ class UpgradeView(MasterView): return filename def render_package_diff(self, upgrade, fieldname): - use_buefy = self.get_use_buefy() try: before = self.parse_requirements(upgrade, 'before') after = self.parse_requirements(upgrade, 'after') kwargs = {} - if use_buefy: - kwargs['extra_row_attrs'] = self.get_extra_diff_row_attrs + kwargs['extra_row_attrs'] = self.get_extra_diff_row_attrs diff = self.make_diff(before, after, columns=["package", "old version", "new version"], render_field=self.render_diff_field, @@ -310,24 +314,16 @@ class UpgradeView(MasterView): **kwargs) kwargs = {} - if use_buefy: - kwargs['@click.prevent'] = "showingPackages = 'all'" - kwargs[':style'] = "{'font-weight': showingPackages == 'all' ? 'bold' : null}" - else: - kwargs['class_'] = 'all' + kwargs['@click.prevent'] = "showingPackages = 'all'" + kwargs[':style'] = "{'font-weight': showingPackages == 'all' ? 'bold' : null}" all_link = tags.link_to("all", '#', **kwargs) kwargs = {} - if use_buefy: - kwargs['@click.prevent'] = "showingPackages = 'diffs'" - kwargs[':style'] = "{'font-weight': showingPackages == 'diffs' ? 'bold' : null}" - else: - kwargs['class_'] = 'diffs' + kwargs['@click.prevent'] = "showingPackages = 'diffs'" + kwargs[':style'] = "{'font-weight': showingPackages == 'diffs' ? 'bold' : null}" diffs_link = tags.link_to("diffs only", '#', **kwargs) kwargs = {} - if not use_buefy: - kwargs['class_'] = 'showing' showing = HTML.tag('div', c=["showing: " + all_link + " / " @@ -341,7 +337,6 @@ class UpgradeView(MasterView): return HTML.tag('div', c="(not available for this upgrade)") def get_extra_diff_row_attrs(self, field, attrs): - # note, this is only needed/used with Buefy extra = {} if attrs.get('class') != 'diff': extra['v-show'] = "showingPackages == 'all'" @@ -353,59 +348,34 @@ class UpgradeView(MasterView): commit_hash_pattern = re.compile(r'^.{40}$') def get_changelog_projects(self): - projects = { - 'rattail': { - 'commit_url': 'https://kallithea.rattailproject.org/rattail-project/rattail/changelog/{new_version}/?size=10', - 'release_url': 'https://kallithea.rattailproject.org/rattail-project/rattail/files/v{new_version}/CHANGES.rst', - }, - 'Tailbone': { - 'commit_url': 'https://kallithea.rattailproject.org/rattail-project/tailbone/changelog/{new_version}/?size=10', - 'release_url': 'https://kallithea.rattailproject.org/rattail-project/tailbone/files/v{new_version}/CHANGES.rst', - }, - 'pyCOREPOS': { - 'commit_url': 'https://kallithea.rattailproject.org/rattail-project/pycorepos/changelog/{new_version}/?size=10', - 'release_url': 'https://kallithea.rattailproject.org/rattail-project/pycorepos/files/v{new_version}/CHANGES.rst', - }, - 'rattail_corepos': { - 'commit_url': 'https://kallithea.rattailproject.org/rattail-project/rattail-corepos/changelog/{new_version}/?size=10', - 'release_url': 'https://kallithea.rattailproject.org/rattail-project/rattail-corepos/files/v{new_version}/CHANGES.rst', - }, - 'tailbone_corepos': { - 'commit_url': 'https://kallithea.rattailproject.org/rattail-project/tailbone-corepos/changelog/{new_version}/?size=10', - 'release_url': 'https://kallithea.rattailproject.org/rattail-project/tailbone-corepos/files/v{new_version}/CHANGES.rst', - }, - 'onager': { - 'commit_url': 'https://kallithea.rattailproject.org/rattail-restricted/onager/changelog/{new_version}/?size=10', - 'release_url': 'https://kallithea.rattailproject.org/rattail-restricted/onager/files/v{new_version}/CHANGES.rst', - }, - 'rattail-onager': { - 'commit_url': 'https://kallithea.rattailproject.org/rattail-restricted/rattail-onager/changelog/{new_version}/?size=10', - 'release_url': 'https://kallithea.rattailproject.org/rattail-restricted/rattail-onager/files/v{new_version}/CHANGELOG.md', - }, - 'rattail_tempmon': { - 'commit_url': 'https://kallithea.rattailproject.org/rattail-project/rattail-tempmon/changelog/{new_version}/?size=10', - 'release_url': 'https://kallithea.rattailproject.org/rattail-project/rattail-tempmon/files/v{new_version}/CHANGES.rst', - }, - 'tailbone-onager': { - 'commit_url': 'https://kallithea.rattailproject.org/rattail-restricted/tailbone-onager/changelog/{new_version}/?size=10', - 'release_url': 'https://kallithea.rattailproject.org/rattail-restricted/tailbone-onager/files/v{new_version}/CHANGELOG.md', - }, - 'rattail_woocommerce': { - 'commit_url': 'https://kallithea.rattailproject.org/rattail-project/rattail-woocommerce/changelog/{new_version}/?size=10', - 'release_url': 'https://kallithea.rattailproject.org/rattail-project/rattail-woocommerce/files/v{new_version}/CHANGES.rst', - }, - 'tailbone_woocommerce': { - 'commit_url': 'https://kallithea.rattailproject.org/rattail-project/tailbone-woocommerce/changelog/{new_version}/?size=10', - 'release_url': 'https://kallithea.rattailproject.org/rattail-project/tailbone-woocommerce/files/v{new_version}/CHANGES.rst', - }, - 'tailbone_theo': { - 'commit_url': 'https://kallithea.rattailproject.org/rattail-project/theo/changelog/{new_version}/?size=10', - 'release_url': 'https://kallithea.rattailproject.org/rattail-project/theo/files/v{new_version}/CHANGES.rst', - }, + project_map = { + 'onager': 'onager', + 'pyCOREPOS': 'pycorepos', + 'rattail': 'rattail', + 'rattail_corepos': 'rattail-corepos', + 'rattail-onager': 'rattail-onager', + 'rattail_tempmon': 'rattail-tempmon', + 'rattail_woocommerce': 'rattail-woocommerce', + 'Tailbone': 'tailbone', + 'tailbone_corepos': 'tailbone-corepos', + 'tailbone-onager': 'tailbone-onager', + 'tailbone_theo': 'theo', + 'tailbone_woocommerce': 'tailbone-woocommerce', } + + projects = {} + for name, repo in project_map.items(): + projects[name] = { + 'commit_url': f'https://forgejo.wuttaproject.org/rattail/{repo}/compare/{{old_version}}...{{new_version}}', + 'release_url': f'https://forgejo.wuttaproject.org/rattail/{repo}/src/tag/v{{new_version}}/CHANGELOG.md', + } return projects def get_changelog_url(self, project, old_version, new_version): + # cannot generate URL if new version is unknown + if not new_version: + return + projects = self.get_changelog_projects() project_name = project @@ -451,13 +421,14 @@ class UpgradeView(MasterView): return packages def parse_requirement(self, line): + app = self.get_rattail_app() match = re.match(r'^.*@(.*)#egg=(.*)$', line) if match: - return Object(name=match.group(2), version=match.group(1)) + return app.make_object(name=match.group(2), version=match.group(1)) match = re.match(r'^(.*)==(.*)$', line) if match: - return Object(name=match.group(1), version=match.group(2)) + return app.make_object(name=match.group(1), version=match.group(2)) def download_path(self, upgrade, filename): return self.rattail_config.upgrade_filepath(upgrade.uuid, filename=filename) @@ -539,17 +510,17 @@ class UpgradeView(MasterView): def delete_instance(self, upgrade): self.handler.delete_files(upgrade) - super(UpgradeView, self).delete_instance(upgrade) + super().delete_instance(upgrade) def configure_get_context(self, **kwargs): - context = super(UpgradeView, self).configure_get_context(**kwargs) + context = super().configure_get_context(**kwargs) context['upgrade_systems'] = self.upgrade_handler.get_all_systems() return context def configure_gather_settings(self, data): - settings = super(UpgradeView, self).configure_gather_settings(data) + settings = super().configure_gather_settings(data) keys = [] for system in json.loads(data['upgrade_systems']): @@ -570,7 +541,7 @@ class UpgradeView(MasterView): return settings def configure_remove_settings(self): - super(UpgradeView, self).configure_remove_settings() + super().configure_remove_settings() app = self.get_rattail_app() model = self.model diff --git a/tailbone/views/users.py b/tailbone/views/users.py index 31842d0b..dfed0a11 100644 --- a/tailbone/views/users.py +++ b/tailbone/views/users.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,15 +24,10 @@ User Views """ -from __future__ import unicode_literals, absolute_import - -import six import sqlalchemy as sa from sqlalchemy import orm from rattail.db.model import User, UserEvent -from rattail.db.auth import (administrator_role, guest_role, - authenticated_role, set_user_password) import colander from deform import widget as dfwidget @@ -41,6 +36,7 @@ from webhelpers2.html import HTML, tags from tailbone import forms from tailbone.views import MasterView, View from tailbone.views.principal import PrincipalMasterView, PermissionsRenderer +from tailbone.util import raw_datetime class UserView(PrincipalMasterView): @@ -48,12 +44,14 @@ class UserView(PrincipalMasterView): Master view for the User model. """ model_class = User - has_rows = True - model_row_class = UserEvent has_versions = True touchable = True mergeable = True + labels = { + 'api_tokens': "API Tokens", + } + grid_columns = [ 'username', 'person', @@ -70,25 +68,43 @@ class UserView(PrincipalMasterView): 'active', 'active_sticky', 'set_password', + 'prevent_password_change', + 'api_tokens', 'roles', 'permissions', ] + has_rows = True + model_row_class = UserEvent + rows_title = "User Events" + rows_viewable = False + row_grid_columns = [ 'type_code', 'occurred', ] def __init__(self, request): - super(UserView, self).__init__(request) + super().__init__(request) app = self.get_rattail_app() # always get a reference to the auth/merge handler self.auth_handler = app.get_auth_handler() self.merge_handler = self.auth_handler + def get_context_menu_items(self, user=None): + items = super().get_context_menu_items(user) + + if self.viewing: + + if self.has_perm('preferences'): + url = self.get_action_url('preferences', user) + items.append(tags.link_to("Edit User Preferences", url)) + + return items + def query(self, session): - query = super(UserView, self).query(session) + query = super().query(session) model = self.model # bring in the related Person(s) @@ -98,7 +114,7 @@ class UserView(PrincipalMasterView): return query def configure_grid(self, g): - super(UserView, self).configure_grid(g) + super().configure_grid(g) model = self.model del g.filters['salt'] @@ -168,12 +184,12 @@ class UserView(PrincipalMasterView): """ if value: model = self.model - person = self.Session.query(model.Person).get(value) + person = self.Session.get(model.Person, value) if not person: raise colander.Invalid(node, "Person not found (you must *select* a record)") def configure_form(self, f): - super(UserView, self).configure_form(f) + super().configure_form(f) model = self.model user = f.model_instance @@ -189,14 +205,18 @@ class UserView(PrincipalMasterView): person_display = "" if self.request.method == 'POST': if self.request.POST.get('person_uuid'): - person = self.Session.query(model.Person).get(self.request.POST['person_uuid']) + person = self.Session.get(model.Person, self.request.POST['person_uuid']) if person: - person_display = six.text_type(person) + person_display = str(person) elif self.editing: - person_display = six.text_type(user.person or '') - people_url = self.request.route_url('people.autocomplete') - f.set_widget('person_uuid', forms.widgets.JQueryAutocompleteWidget( - field_display=person_display, service_url=people_url)) + person_display = str(user.person or '') + try: + people_url = self.request.route_url('people.autocomplete') + except KeyError: + pass # TODO: wutta compat + else: + f.set_widget('person_uuid', forms.widgets.JQueryAutocompleteWidget( + field_display=person_display, service_url=people_url)) f.set_validator('person_uuid', self.valid_person) f.set_label('person_uuid', "Person") @@ -213,10 +233,24 @@ class UserView(PrincipalMasterView): f.set_renderer('display_name_', self.render_person_name) # set_password - f.set_widget('set_password', dfwidget.CheckedPasswordWidget()) + if self.editing and user.prevent_password_change and not self.request.is_root: + f.remove('set_password') + else: + f.set_widget('set_password', dfwidget.CheckedPasswordWidget()) # if self.creating: # f.set_required('password') + # api_tokens + if self.creating or self.editing or self.deleting: + f.remove('api_tokens') + elif self.has_perm('manage_api_tokens'): + f.set_renderer('api_tokens', self.render_api_tokens) + f.set_vuejs_component_kwargs(**{':apiTokens': 'apiTokens', + '@api-new-token': 'apiNewToken', + '@api-token-delete': 'apiTokenDelete'}) + else: + f.remove('api_tokens') + # roles f.set_renderer('roles', self.render_roles) if self.creating or self.editing: @@ -224,7 +258,7 @@ class UserView(PrincipalMasterView): f.remove_field('roles') else: roles = self.get_possible_roles().all() - role_values = [(s.uuid, six.text_type(s)) for s in roles] + role_values = [(s.uuid, str(s)) for s in roles] f.set_node('roles', colander.Set()) size = len(roles) if size < 3: @@ -248,10 +282,10 @@ class UserView(PrincipalMasterView): # fs.confirm_password.attrs(autocomplete='new-password') if self.viewing: - permissions = self.request.registry.settings.get('tailbone_permissions', {}) + permissions = self.request.registry.settings.get('wutta_permissions', {}) f.set_renderer('permissions', PermissionsRenderer(request=self.request, permissions=permissions, - include_guest=True, + include_anonymous=True, include_authenticated=True)) else: f.remove('permissions') @@ -259,18 +293,90 @@ class UserView(PrincipalMasterView): if self.viewing or self.deleting: f.remove('set_password') - def get_possible_roles(self): + def render_api_tokens(self, user, field): + route_prefix = self.get_route_prefix() + permission_prefix = self.get_permission_prefix() + + factory = self.get_grid_factory() + g = factory( + self.request, + key=f'{route_prefix}.api_tokens', + data=[], + columns=['description', 'created'], + actions=[ + self.make_action('delete', icon='trash', + click_handler="$emit('api-token-delete', props.row)")]) + + button = self.make_button("New", is_primary=True, + icon_left='plus', + **{'@click': "$emit('api-new-token')"}) + + table = HTML.literal( + g.render_table_element(data_prop='apiTokens')) + + return HTML.tag('div', c=[button, table]) + + def add_api_token(self): + user = self.get_instance() + data = self.request.json_body + + token = self.auth_handler.add_api_token(user, data['description']) + self.Session.flush() + + return {'ok': True, + 'raw_token': token.token_string, + 'tokens': self.get_api_tokens(user)} + + def delete_api_token(self): model = self.model + user = self.get_instance() + data = self.request.json_body + + token = self.Session.get(model.UserAPIToken, data['uuid']) + if not token: + return {'error': "API token not found"} + + if token.user is not user: + return {'error': "API token not found"} + + self.auth_handler.delete_api_token(token) + self.Session.flush() + + return {'ok': True, + 'tokens': self.get_api_tokens(user)} + + def template_kwargs_view(self, **kwargs): + kwargs = super().template_kwargs_view(**kwargs) + user = kwargs['instance'] + + kwargs['api_tokens_data'] = self.get_api_tokens(user) + + return kwargs + + def get_api_tokens(self, user): + tokens = [] + for token in reversed(user.api_tokens): + tokens.append({ + 'uuid': token.uuid, + 'description': token.description, + 'created': raw_datetime(self.rattail_config, token.created), + }) + return tokens + + def get_possible_roles(self): + app = self.get_rattail_app() + auth = app.get_auth_handler() + model = app.model # some roles should never have users "belong" to them excluded = [ - guest_role(self.Session()).uuid, - authenticated_role(self.Session()).uuid, + auth.get_role_anonymous(self.Session()).uuid, + auth.get_role_authenticated(self.Session()).uuid, ] # only allow "root" user to change true admin role membership if not self.request.is_root: - excluded.append(administrator_role(self.Session()).uuid) + excluded.append(auth.get_role_administrator(self.Session()).uuid) # basic list, minus exclusions so far roles = self.Session.query(model.Role)\ @@ -285,12 +391,14 @@ class UserView(PrincipalMasterView): return roles.order_by(model.Role.name) def objectify(self, form, data=None): - model = self.model + app = self.get_rattail_app() + auth = app.get_auth_handler() + model = app.model # create/update user as per normal if data is None: data = form.validated - user = super(UserView, self).objectify(form, data) + user = super().objectify(form, data) # create/update person as needed names = {} @@ -319,8 +427,8 @@ class UserView(PrincipalMasterView): user.person.local_only = True # maybe set user password - if data['set_password']: - set_user_password(user, data['set_password']) + if 'set_password' in form and data['set_password']: + auth.set_user_password(user, data['set_password']) # update roles for user self.update_roles(user, data) @@ -333,10 +441,12 @@ class UserView(PrincipalMasterView): if 'roles' not in data: return - model = self.model + app = self.get_rattail_app() + auth = app.get_auth_handler() + model = app.model old_roles = set([r.uuid for r in user.roles]) new_roles = data['roles'] - admin = administrator_role(self.Session()) + admin = auth.get_role_administrator(self.Session()) # add any new roles for the user, taking care not to add the admin role # unless acting as root @@ -358,7 +468,7 @@ class UserView(PrincipalMasterView): for uuid in old_roles: if uuid not in new_roles: if self.request.is_root or uuid != admin.uuid: - role = self.Session.query(model.Role).get(uuid) + role = self.Session.get(model.Role, uuid) user.roles.remove(role) # also record a change to the role, for datasync. @@ -373,7 +483,7 @@ class UserView(PrincipalMasterView): person = user.person if not person: return "" - text = six.text_type(person) + text = str(person) url = self.request.route_url('people.view', uuid=person.uuid) return tags.link_to(person, url) @@ -383,7 +493,7 @@ class UserView(PrincipalMasterView): name = getattr(user, field[:-1], None) if not name: return "" - return six.text_type(name) + return str(name) def render_roles(self, user, field): roles = sorted(user.roles, key=lambda r: r.name) @@ -400,13 +510,12 @@ class UserView(PrincipalMasterView): .filter(model.UserEvent.user == user) def configure_row_grid(self, g): - super(UserView, self).configure_row_grid(g) + super().configure_row_grid(g) g.width = 'half' g.filterable = False g.set_sort_defaults('occurred', 'desc') g.set_enum('type_code', self.enum.USER_EVENT) g.set_label('type_code', "Event Type") - g.main_actions = [] def get_version_child_classes(self): model = self.model @@ -432,6 +541,21 @@ class UserView(PrincipalMasterView): users.append(user) return users + def find_by_perm_configure_results_grid(self, g): + g.append('username') + g.set_link('username') + + g.append('person') + g.set_link('person') + + def find_by_perm_normalize(self, user): + data = super().find_by_perm_normalize(user) + + data['username'] = user.username + data['person'] = str(user.person or '') + + return data + def preferences(self, user=None): """ View to modify preferences for a particular user. @@ -497,11 +621,14 @@ class UserView(PrincipalMasterView): styles = self.rattail_config.getlist('tailbone', 'themes.styles', default=[]) for name in styles: - css = self.rattail_config.get('tailbone', - 'themes.style.{}'.format(name)) + css = None + if self.request.use_oruga: + css = self.rattail_config.get(f'tailbone.themes.bb_style.{name}') + if not css: + css = self.rattail_config.get(f'tailbone.themes.style.{name}') if css: options.append({'value': css, 'label': name}) - context['buefy_css_options'] = options + context['theme_style_options'] = options return context @@ -514,22 +641,42 @@ class UserView(PrincipalMasterView): The only difference here is that we are given a user account, so the settings involved should only pertain to that user. """ + # TODO: can stop pre-fetching this value only once we are + # confident all settings have been updated in the wild + user_css = self.rattail_config.get(f'tailbone.{user.uuid}', 'user_css') + if not user_css: + user_css = self.rattail_config.get(f'tailbone.{user.uuid}', 'buefy_css') + return [ # display - {'section': 'tailbone.{}'.format(user.uuid), - 'option': 'buefy_css'}, + {'section': f'tailbone.{user.uuid}', + 'option': 'user_css', + 'value': user_css, + 'save_if_empty': False}, ] def preferences_gather_settings(self, data, user): simple_settings = self.preferences_get_simple_settings(user) - return self.configure_gather_settings( + settings = self.configure_gather_settings( data, simple_settings=simple_settings, input_file_templates=False) + # TODO: ugh why does user_css come back as 'default' instead of None? + final_settings = [] + for setting in settings: + if setting['name'].endswith('.user_css'): + if setting['value'] == 'default': + continue + final_settings.append(setting) + + return final_settings + def preferences_remove_settings(self, user): + app = self.get_rattail_app() simple_settings = self.preferences_get_simple_settings(user) self.configure_remove_settings(simple_settings=simple_settings, input_file_templates=False) + app.delete_setting(self.Session(), f'tailbone.{user.uuid}.buefy_css') @classmethod def defaults(cls, config): @@ -553,6 +700,25 @@ class UserView(PrincipalMasterView): config.add_tailbone_permission(permission_prefix, '{}.edit_roles'.format(permission_prefix), "Edit the Roles to which a {} belongs".format(model_title)) + # manage API tokens + config.add_tailbone_permission(permission_prefix, + '{}.manage_api_tokens'.format(permission_prefix), + "Manage API tokens for any {}".format(model_title)) + config.add_route('{}.add_api_token'.format(route_prefix), + '{}/add-api-token'.format(instance_url_prefix), + request_method='POST') + config.add_view(cls, attr='add_api_token', + route_name='{}.add_api_token'.format(route_prefix), + permission='{}.manage_api_tokens'.format(permission_prefix), + renderer='json') + config.add_route('{}.delete_api_token'.format(route_prefix), + '{}/delete-api-token'.format(instance_url_prefix), + request_method='POST') + config.add_view(cls, attr='delete_api_token', + route_name='{}.delete_api_token'.format(route_prefix), + permission='{}.manage_api_tokens'.format(permission_prefix), + renderer='json') + # edit preferences for any user config.add_tailbone_permission(permission_prefix, '{}.preferences'.format(permission_prefix), @@ -593,12 +759,12 @@ class UserEventView(MasterView): ] def get_data(self, session=None): - query = super(UserEventView, self).get_data(session=session) + query = super().get_data(session=session) model = self.model return query.join(model.User) def configure_grid(self, g): - super(UserEventView, self).configure_grid(g) + super().configure_grid(g) model = self.model g.set_joiner('person', lambda q: q.outerjoin(model.Person)) g.set_sorter('user', model.User.username) @@ -635,4 +801,8 @@ def defaults(config, **kwargs): def includeme(config): - defaults(config) + wutta_config = config.registry.settings['wutta_config'] + if wutta_config.get_bool('tailbone.use_wutta_views', default=False, usedb=False): + config.include('tailbone.views.wutta.users') + else: + defaults(config) diff --git a/tailbone/views/vendors/__init__.py b/tailbone/views/vendors/__init__.py index 7d35780e..210df39e 100644 --- a/tailbone/views/vendors/__init__.py +++ b/tailbone/views/vendors/__init__.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,8 +24,6 @@ Views pertaining to vendors """ -from __future__ import unicode_literals, absolute_import - from .core import VendorView @@ -36,3 +34,4 @@ def defaults(config, **kwargs): def includeme(config): config.include('tailbone.views.vendors.core') + config.include('tailbone.views.vendors.samplefiles') diff --git a/tailbone/views/vendors/catalogs.py b/tailbone/views/vendors/catalogs.py index e021a88a..2471ad47 100644 --- a/tailbone/views/vendors/catalogs.py +++ b/tailbone/views/vendors/catalogs.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2019 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -26,13 +26,11 @@ Please use `tailbone.views.batch.vendorcatalog` instead. """ -from __future__ import unicode_literals, absolute_import - import warnings def includeme(config): warnings.warn("The `tailbone.views.vendors.catalogs` module is deprecated, " "please use `tailbone.views.batch.vendorcatalog` instead.", - DeprecationWarning) + DeprecationWarning, stacklevel=2) config.include('tailbone.views.batch.vendorcatalog') diff --git a/tailbone/views/vendors/core.py b/tailbone/views/vendors/core.py index 87b2de75..addf153c 100644 --- a/tailbone/views/vendors/core.py +++ b/tailbone/views/vendors/core.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,10 +24,6 @@ Vendor Views """ -from __future__ import unicode_literals, absolute_import - -import six - from rattail.db import model from webhelpers2.html import tags @@ -60,6 +56,7 @@ class VendorView(MasterView): 'phone', 'email', 'contact', + 'terms', ] form_fields = [ @@ -73,10 +70,11 @@ class VendorView(MasterView): 'default_email', 'orders_email', 'contact', + 'terms', ] def configure_grid(self, g): - super(VendorView, self).configure_grid(g) + super().configure_grid(g) g.filters['name'].default_active = True g.filters['name'].default_verb = 'contains' @@ -90,7 +88,8 @@ class VendorView(MasterView): g.set_link('abbreviation') def configure_form(self, f): - super(VendorView, self).configure_form(f) + super().configure_form(f) + app = self.get_rattail_app() vendor = f.model_instance f.set_type('lead_time_days', 'quantity') @@ -109,7 +108,7 @@ class VendorView(MasterView): # orders_email f.set_renderer('orders_email', self.render_orders_email) if not self.creating and vendor.emails: - f.set_default('orders_email', vendor.get_email_address(type_='Orders') or '') + f.set_default('orders_email', app.get_contact_email_address(vendor, type_='Orders') or '') # contact if self.creating: @@ -121,12 +120,13 @@ class VendorView(MasterView): def objectify(self, form, data=None): if data is None: data = form.validated - vendor = super(VendorView, self).objectify(form, data) + vendor = super().objectify(form, data) vendor = self.objectify_contact(vendor, data) + app = self.get_rattail_app() if 'orders_email' in data: address = data['orders_email'] - email = vendor.get_email(type_='Orders') + email = app.get_contact_email(vendor, type_='Orders') if address: if email: if email.address != address: @@ -143,7 +143,8 @@ class VendorView(MasterView): return vendor.emails[0].address def render_orders_email(self, vendor, field): - return vendor.get_email_address(type_='Orders') + app = self.get_rattail_app() + return app.get_contact_email_address(vendor, type_='Orders') def render_default_phone(self, vendor, field): if vendor.phones: @@ -153,7 +154,7 @@ class VendorView(MasterView): person = vendor.contact if not person: return "" - text = six.text_type(person) + text = str(person) url = self.request.route_url('people.view', uuid=person.uuid) return tags.link_to(text, url) @@ -165,7 +166,7 @@ class VendorView(MasterView): self.Session.delete(cost) def get_version_child_classes(self): - return [ + return super().get_version_child_classes() + [ (model.VendorPhoneNumber, 'parent_uuid'), (model.VendorEmailAddress, 'parent_uuid'), (model.VendorContact, 'vendor_uuid'), @@ -182,18 +183,18 @@ class VendorView(MasterView): ] def configure_get_context(self, **kwargs): - context = super(VendorView, self).configure_get_context(**kwargs) + context = super().configure_get_context(**kwargs) context['supported_vendor_settings'] = self.configure_get_supported_vendor_settings() return context def configure_gather_settings(self, data, **kwargs): - settings = super(VendorView, self).configure_gather_settings( + settings = super().configure_gather_settings( data, **kwargs) supported_vendor_settings = self.configure_get_supported_vendor_settings() - for setting in six.itervalues(supported_vendor_settings): + for setting in supported_vendor_settings.values(): name = 'rattail.vendor.{}'.format(setting['key']) settings.append({'name': name, 'value': data[name]}) @@ -201,12 +202,12 @@ class VendorView(MasterView): return settings def configure_remove_settings(self, **kwargs): - super(VendorView, self).configure_remove_settings(**kwargs) + super().configure_remove_settings(**kwargs) app = self.get_rattail_app() names = [] supported_vendor_settings = self.configure_get_supported_vendor_settings() - for setting in six.itervalues(supported_vendor_settings): + for setting in supported_vendor_settings.values(): names.append('rattail.vendor.{}'.format(setting['key'])) if names: @@ -231,7 +232,7 @@ class VendorView(MasterView): settings[key] = { 'key': key, 'value': vendor.uuid if vendor else None, - 'label': six.text_type(vendor) if vendor else None, + 'label': str(vendor) if vendor else None, } return settings diff --git a/tailbone/views/vendors/invoices.py b/tailbone/views/vendors/invoices.py index e61329f6..40fe0365 100644 --- a/tailbone/views/vendors/invoices.py +++ b/tailbone/views/vendors/invoices.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2019 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -26,13 +26,11 @@ Please use `tailbone.views.batch.vendorinvoice` instead. """ -from __future__ import unicode_literals, absolute_import - import warnings def includeme(config): warnings.warn("The `tailbone.views.vendors.invoices` module is deprecated, " "please use `tailbone.views.batch.vendorinvoice` instead.", - DeprecationWarning) + DeprecationWarning, stacklevel=2) config.include('tailbone.views.batch.vendorinvoice') diff --git a/tailbone/views/vendors/samplefiles.py b/tailbone/views/vendors/samplefiles.py new file mode 100644 index 00000000..a75bc1fb --- /dev/null +++ b/tailbone/views/vendors/samplefiles.py @@ -0,0 +1,162 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2023 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. +# +################################################################################ +""" +Model View for Vendor Sample Files +""" + +from rattail.db.model import VendorSampleFile + +from webhelpers2.html import tags + +from tailbone import forms +from tailbone.views import MasterView + + +class VendorSampleFileView(MasterView): + """ + Master model view for Vendor Sample Files + """ + model_class = VendorSampleFile + route_prefix = 'vendorsamplefiles' + url_prefix = '/vendors/sample-files' + downloadable = True + has_versions = True + + grid_columns = [ + 'vendor', + 'file_type', + 'effective_date', + 'filename', + 'created_by', + ] + + form_fields = [ + 'vendor', + 'file_type', + 'filename', + 'effective_date', + 'notes', + 'created_by', + ] + + def configure_grid(self, g): + super(VendorSampleFileView, self).configure_grid(g) + model = self.model + + # vendor + g.set_joiner('vendor', lambda q: q.join(model.Vendor)) + g.set_sorter('vendor', model.Vendor.name) + g.set_filter('vendor', model.Vendor.name, + default_active=True, default_verb='contains') + g.set_link('vendor') + + # filename + g.set_link('filename') + + # effective_date + g.set_sort_defaults('effective_date', 'desc') + + def configure_form(self, f): + super(VendorSampleFileView, self).configure_form(f) + + # vendor + f.set_renderer('vendor', self.render_vendor) + if self.creating: + f.replace('vendor', 'vendor_uuid') + f.set_label('vendor_uuid', "Vendor") + f.set_widget('vendor_uuid', + forms.widgets.make_vendor_widget(self.request)) + else: + f.set_readonly('vendor') + + # filename + if self.creating: + f.replace('filename', 'file') + f.set_type('file', 'file') + else: + f.set_readonly('filename') + f.set_renderer('filename', self.render_filename) + + # effective_date + f.set_type('effective_date', 'date_jquery') + + # notes + f.set_type('notes', 'text') + + # created_by + if self.creating or self.editing: + f.remove('created_by') + else: + f.set_readonly('created_by') + f.set_renderer('created_by', self.render_user) + + def objectify(self, form, data=None): + if data is None: + data = form.validated + + sample = super(VendorSampleFileView, self).objectify(form, data=data) + + if self.creating: + sample.filename = data['file']['filename'] + data['file']['fp'].seek(0) + sample.bytes = data['file']['fp'].read() + sample.created_by = self.request.user + + return sample + + def render_filename(self, sample, field): + filename = getattr(sample, field) + if not filename: + return + + size = self.readable_size(None, size=len(sample.bytes)) + text = "{} ({})".format(filename, size) + url = self.get_action_url('download', sample) + return tags.link_to(text, url) + + def download(self): + """ + View for downloading a sample file. + + We override default logic to send raw bytes from DB, and avoid + writing file to disk. + """ + sample = self.get_instance() + + response = self.request.response + response.content_length = len(sample.bytes) + response.content_disposition = 'attachment; filename="{}"'.format( + sample.filename) + response.body = sample.bytes + return response + + +def defaults(config, **kwargs): + base = globals() + + VendorSampleFileView = kwargs.get('VendorSampleFileView', base['VendorSampleFileView']) + VendorSampleFileView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/views.py b/tailbone/views/views.py new file mode 100644 index 00000000..67cba2e2 --- /dev/null +++ b/tailbone/views/views.py @@ -0,0 +1,220 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2023 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. +# +################################################################################ +""" +Views for views +""" + +import os +import sys + +from rattail.db.util import get_fieldnames +from rattail.util import simple_error + +import colander +from deform import widget as dfwidget + +from tailbone.views import MasterView + + +class ModelViewView(MasterView): + """ + Master view for views + """ + normalized_model_name = 'model_view' + model_key = 'route_prefix' + model_title = "Model View" + url_prefix = '/views/model' + viewable = True + creatable = True + editable = False + deletable = False + filterable = False + pageable = False + + grid_columns = [ + 'label', + 'model_name', + 'route_prefix', + 'permission_prefix', + ] + + def get_data(self, **kwargs): + """ + Fetch existing model views from app registry + """ + data = [] + + all_views = self.request.registry.settings['tailbone_model_views'] + for model_name in sorted(all_views): + model_views = all_views[model_name] + for view in model_views: + data.append({ + 'model_name': model_name, + 'label': view['label'], + 'route_prefix': view['route_prefix'], + 'permission_prefix': view['permission_prefix'], + }) + + return data + + def configure_grid(self, g): + super().configure_grid(g) + + # label + g.sorters['label'] = g.make_simple_sorter('label') + g.set_sort_defaults('label') + g.set_link('label') + g.set_searchable('label') + + # model_name + g.sorters['model_name'] = g.make_simple_sorter('model_name', foldcase=True) + g.set_searchable('model_name') + + # route + g.sorters['route'] = g.make_simple_sorter('route') + g.set_searchable('route') + + # permission + g.sorters['permission'] = g.make_simple_sorter('permission') + g.set_searchable('permission') + + def default_view_url(self): + return lambda view, i: self.request.route_url(view['route_prefix']) + + def make_form_schema(self): + return ModelViewSchema() + + def template_kwargs_create(self, **kwargs): + kwargs = super().template_kwargs_create(**kwargs) + app = self.get_rattail_app() + db_handler = app.get_db_handler() + + model_classes = db_handler.get_model_classes() + kwargs['model_names'] = [cls.__name__ for cls in model_classes] + + pkg = self.rattail_config.get('rattail', 'running_from_source.rootpkg') + if pkg: + kwargs['pkgroot'] = pkg + pkg = sys.modules[pkg] + pkgdir = os.path.dirname(pkg.__file__) + kwargs['view_dir'] = os.path.join(pkgdir, 'web', 'views') + os.sep + else: + kwargs['pkgroot'] = 'poser' + kwargs['view_dir'] = '??' + os.sep + + return kwargs + + def write_view_file(self): + data = self.request.json_body + path = data['view_file'] + + if os.path.exists(path): + if data['overwrite']: + os.remove(path) + else: + return {'error': "File already exists"} + + app = self.get_rattail_app() + tb = app.get_tailbone_handler() + model_class = getattr(self.model, data['model_name']) + + data['model_module_name'] = self.model.__name__ + data['model_title_plural'] = getattr(model_class, + 'model_title_plural', + # TODO + model_class.__name__) + + data['model_versioned'] = hasattr(model_class, '__versioned__') + + fieldnames = get_fieldnames(self.rattail_config, + model_class) + fieldnames.remove('uuid') + data['model_fieldnames'] = fieldnames + + tb.write_model_view(data, path) + + return {'ok': True} + + def check_view(self): + data = self.request.json_body + + try: + url = self.request.route_url(data['route_prefix']) + except Exception as error: + return {'ok': True, + 'problem': simple_error(error)} + + return {'ok': True, 'url': url} + + @classmethod + def defaults(cls, config): + rattail_config = config.registry.settings.get('rattail_config') + + # allow creating views only if *not* production + if not rattail_config.production(): + cls.creatable = True + + cls._model_view_defaults(config) + cls._defaults(config) + + @classmethod + def _model_view_defaults(cls, config): + route_prefix = cls.get_route_prefix() + url_prefix = cls.get_url_prefix() + permission_prefix = cls.get_permission_prefix() + + if cls.creatable: + + # write view class to file + config.add_route('{}.write_view_file'.format(route_prefix), + '{}/write-view-file'.format(url_prefix), + request_method='POST') + config.add_view(cls, attr='write_view_file', + route_name='{}.write_view_file'.format(route_prefix), + renderer='json', + permission='{}.create'.format(permission_prefix)) + + # check view + config.add_route('{}.check_view'.format(route_prefix), + '{}/check-view'.format(url_prefix), + request_method='POST') + config.add_view(cls, attr='check_view', + route_name='{}.check_view'.format(route_prefix), + renderer='json', + permission='{}.create'.format(permission_prefix)) + + +class ModelViewSchema(colander.Schema): + + model_name = colander.SchemaNode(colander.String()) + + +def defaults(config, **kwargs): + base = globals() + + ModelViewView = kwargs.get('ModelViewView', base['ModelViewView']) + ModelViewView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/workorders.py b/tailbone/views/workorders.py index dff57e96..d8094e4b 100644 --- a/tailbone/views/workorders.py +++ b/tailbone/views/workorders.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,8 +24,6 @@ Work Order Views """ -from __future__ import unicode_literals, absolute_import - import sqlalchemy as sa from rattail.db.model import WorkOrder, WorkOrderEvent @@ -85,12 +83,12 @@ class WorkOrderView(MasterView): ] def __init__(self, request): - super(WorkOrderView, self).__init__(request) + super().__init__(request) app = self.get_rattail_app() self.workorder_handler = app.get_workorder_handler() def configure_grid(self, g): - super(WorkOrderView, self).configure_grid(g) + super().configure_grid(g) model = self.model # customer @@ -115,9 +113,8 @@ class WorkOrderView(MasterView): return 'warning' def configure_form(self, f): - super(WorkOrderView, self).configure_form(f) + super().configure_form(f) model = self.model - use_buefy = self.get_use_buefy() SelectWidget = forms.widgets.JQuerySelectWidget # id @@ -198,11 +195,7 @@ class WorkOrderView(MasterView): if status_code in self.enum.WORKORDER_STATUS: text = self.enum.WORKORDER_STATUS[status_code] if status_code == self.enum.WORKORDER_STATUS_CANCELED: - use_buefy = self.get_use_buefy() - if use_buefy: - return HTML.tag('span', class_='has-text-danger', c=text) - else: - return HTML.tag('span', style='color: red;', c=text) + return HTML.tag('span', class_='has-text-danger', c=text) return text return str(status_code) @@ -215,7 +208,7 @@ class WorkOrderView(MasterView): return event.workorder def configure_row_grid(self, g): - super(WorkOrderView, self).configure_row_grid(g) + super().configure_row_grid(g) g.set_enum('type_code', self.enum.WORKORDER_EVENT) g.set_sort_defaults('occurred') @@ -360,7 +353,7 @@ class WorkOrderView(MasterView): class StatusFilter(grids.filters.AlchemyIntegerFilter): def __init__(self, *args, **kwargs): - super(StatusFilter, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) from drild import enum @@ -376,14 +369,14 @@ class StatusFilter(grids.filters.AlchemyIntegerFilter): @property def verb_labels(self): - labels = dict(super(StatusFilter, self).verb_labels) + labels = dict(super().verb_labels) labels['is_active'] = "Is Active" labels['not_active'] = "Is Not Active" return labels @property def valueless_verbs(self): - verbs = list(super(StatusFilter, self).valueless_verbs) + verbs = list(super().valueless_verbs) verbs.extend([ 'is_active', 'not_active', @@ -392,7 +385,11 @@ class StatusFilter(grids.filters.AlchemyIntegerFilter): @property def default_verbs(self): - verbs = list(super(StatusFilter, self).default_verbs) + verbs = super().default_verbs + if callable(verbs): + verbs = verbs() + + verbs = list(verbs or []) verbs.insert(0, 'is_active') verbs.insert(1, 'not_active') return verbs diff --git a/tailbone/views/wutta/__init__.py b/tailbone/views/wutta/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tailbone/views/wutta/people.py b/tailbone/views/wutta/people.py new file mode 100644 index 00000000..bd96bd4d --- /dev/null +++ b/tailbone/views/wutta/people.py @@ -0,0 +1,143 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2024 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. +# +################################################################################ +""" +Person Views +""" + +import colander +import sqlalchemy as sa +from webhelpers2.html import HTML + +from wuttaweb.views import people as wutta +from tailbone.views import people as tailbone +from tailbone.db import Session +from rattail.db.model import Person +from tailbone.grids import Grid + + +class PersonView(wutta.PersonView): + """ + This is the first attempt at blending newer Wutta views with + legacy Tailbone config. + + So, this is a Wutta-based view but it should be included by a + Tailbone app configurator. + """ + model_class = Person + Session = Session + + labels = { + 'display_name': "Full Name", + } + + grid_columns = [ + 'display_name', + 'first_name', + 'last_name', + 'phone', + 'email', + 'merge_requested', + ] + + filter_defaults = { + 'display_name': {'active': True, 'verb': 'contains'}, + } + sort_defaults = 'display_name' + + form_fields = [ + 'first_name', + 'middle_name', + 'last_name', + 'display_name', + 'phone', + 'email', + # TODO + # 'address', + ] + + ############################## + # CRUD methods + ############################## + + # TODO: must use older grid for now, to render filters correctly + def make_grid(self, **kwargs): + """ """ + return Grid(self.request, **kwargs) + + def configure_grid(self, g): + """ """ + super().configure_grid(g) + + # display_name + g.set_link('display_name') + + # merge_requested + g.set_label('merge_requested', "MR") + g.set_renderer('merge_requested', self.render_merge_requested) + + def configure_form(self, f): + """ """ + super().configure_form(f) + + # email + if self.creating or self.editing: + f.remove('email') + else: + # nb. avoid colanderalchemy + f.set_node('email', colander.String()) + + # phone + if self.creating or self.editing: + f.remove('phone') + else: + # nb. avoid colanderalchemy + f.set_node('phone', colander.String()) + + ############################## + # support methods + ############################## + + def render_merge_requested(self, person, key, value, session=None): + """ """ + model = self.app.model + session = session or self.Session() + merge_request = session.query(model.MergePeopleRequest)\ + .filter(sa.or_( + model.MergePeopleRequest.removing_uuid == person.uuid, + model.MergePeopleRequest.keeping_uuid == person.uuid))\ + .filter(model.MergePeopleRequest.merged == None)\ + .first() + if merge_request: + return HTML.tag('span', + class_='has-text-danger has-text-weight-bold', + title="A merge has been requested for this person.", + c="MR") + + +def defaults(config, **kwargs): + kwargs.setdefault('PersonView', PersonView) + tailbone.defaults(config, **kwargs) + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/wutta/users.py b/tailbone/views/wutta/users.py new file mode 100644 index 00000000..3c3f8d52 --- /dev/null +++ b/tailbone/views/wutta/users.py @@ -0,0 +1,57 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2024 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. +# +################################################################################ +""" +User Views +""" + +from wuttaweb.views import users as wutta +from tailbone.views import users as tailbone +from tailbone.db import Session +from rattail.db.model import User +from tailbone.grids import Grid + + +class UserView(wutta.UserView): + """ + This is the first attempt at blending newer Wutta views with + legacy Tailbone config. + + So, this is a Wutta-based view but it should be included by a + Tailbone app configurator. + """ + model_class = User + Session = Session + + # TODO: must use older grid for now, to render filters correctly + def make_grid(self, **kwargs): + """ """ + return Grid(self.request, **kwargs) + + +def defaults(config, **kwargs): + kwargs.setdefault('UserView', UserView) + tailbone.defaults(config, **kwargs) + + +def includeme(config): + defaults(config) diff --git a/tailbone/webapi.py b/tailbone/webapi.py index 10c3460b..d0edb412 100644 --- a/tailbone/webapi.py +++ b/tailbone/webapi.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2021 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,13 +24,14 @@ Tailbone Web API """ -from __future__ import unicode_literals, absolute_import +import simplejson +from cornice.renderer import CorniceRenderer from pyramid.config import Configurator -from pyramid.authentication import SessionAuthenticationPolicy from tailbone import app -from tailbone.auth import TailboneAuthorizationPolicy +from tailbone.auth import TailboneSecurityPolicy +from tailbone.providers import get_all_providers def make_rattail_config(settings): @@ -45,11 +46,11 @@ def make_pyramid_config(settings): """ Make a Pyramid config object from the given settings. """ + rattail_config = settings['rattail_config'] pyramid_config = Configurator(settings=settings, root_factory=app.Root) # configure user authorization / authentication - pyramid_config.set_authorization_policy(TailboneAuthorizationPolicy()) - pyramid_config.set_authentication_policy(SessionAuthenticationPolicy()) + pyramid_config.set_security_policy(TailboneSecurityPolicy(api_mode=True)) # always require CSRF token protection pyramid_config.set_default_csrf_options(require_csrf=True, @@ -61,6 +62,12 @@ def make_pyramid_config(settings): pyramid_config.include('pyramid_tm') pyramid_config.include('cornice') + # use simplejson to serialize cornice view context; cf. + # https://cornice.readthedocs.io/en/latest/upgrading.html#x-to-5-x + # https://docs.pylonsproject.org/projects/pyramid/en/latest/narr/renderers.html + json_renderer = CorniceRenderer(serializer=simplejson.dumps) + pyramid_config.add_renderer('cornicejson', json_renderer) + # bring in the pyramid_retry logic, if available # TODO: pretty soon we can require this package, hopefully.. try: @@ -70,22 +77,42 @@ def make_pyramid_config(settings): else: pyramid_config.include('pyramid_retry') + # fetch all tailbone providers + providers = get_all_providers(rattail_config) + for provider in providers.values(): + + # configure DB sessions associated with transaction manager + provider.configure_db_sessions(rattail_config, pyramid_config) + # add some permissions magic - pyramid_config.add_directive('add_tailbone_permission_group', 'tailbone.auth.add_permission_group') - pyramid_config.add_directive('add_tailbone_permission', 'tailbone.auth.add_permission') + pyramid_config.add_directive('add_wutta_permission_group', + 'wuttaweb.auth.add_permission_group') + pyramid_config.add_directive('add_wutta_permission', + 'wuttaweb.auth.add_permission') + # TODO: deprecate / remove these + pyramid_config.add_directive('add_tailbone_permission_group', + 'wuttaweb.auth.add_permission_group') + pyramid_config.add_directive('add_tailbone_permission', + 'wuttaweb.auth.add_permission') return pyramid_config -def main(global_config, **settings): +def main(global_config, views='tailbone.api', **settings): """ This function returns a Pyramid WSGI application. """ rattail_config = make_rattail_config(settings) pyramid_config = make_pyramid_config(settings) - # bring in some Tailbone - pyramid_config.include('tailbone.subscribers') - pyramid_config.include('tailbone.api') + # event hooks + pyramid_config.add_subscriber('tailbone.subscribers.new_request', + 'pyramid.events.NewRequest') + # TODO: is this really needed? + pyramid_config.add_subscriber('tailbone.subscribers.context_found', + 'pyramid.events.ContextFound') + + # views + pyramid_config.include(views) return pyramid_config.make_wsgi_app() diff --git a/tasks.py b/tasks.py index 48b51b39..6983dbea 100644 --- a/tasks.py +++ b/tasks.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,27 +24,25 @@ Tasks for Tailbone """ -from __future__ import unicode_literals, absolute_import - import os import shutil from invoke import task -here = os.path.abspath(os.path.dirname(__file__)) -exec(open(os.path.join(here, 'tailbone', '_version.py')).read()) - - @task -def release(c, tests=False): +def release(c, skip_tests=False): """ Release a new version of 'Tailbone'. """ - if tests: - c.run('tox') + if not skip_tests: + c.run('pytest') + if os.path.exists('dist'): + shutil.rmtree('dist') if os.path.exists('Tailbone.egg-info'): shutil.rmtree('Tailbone.egg-info') + c.run('python -m build --sdist') - c.run('twine upload dist/Tailbone-{}.tar.gz'.format(__version__)) + + c.run('twine upload dist/*') diff --git a/tests/__init__.py b/tests/__init__.py index 7dec63f0..40d8071f 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -12,9 +12,6 @@ class TestCase(unittest.TestCase): def setUp(self): self.config = testing.setUp() - # TODO: this probably shouldn't (need to) be here - self.config.add_directive('add_tailbone_permission_group', 'tailbone.auth.add_permission_group') - self.config.add_directive('add_tailbone_permission', 'tailbone.auth.add_permission') def tearDown(self): testing.tearDown() diff --git a/tests/fixtures.py b/tests/fixtures.py deleted file mode 100644 index a07825fd..00000000 --- a/tests/fixtures.py +++ /dev/null @@ -1,28 +0,0 @@ - -import fixture - -from rattail.db import model - - -class DepartmentData(fixture.DataSet): - - class grocery: - number = 1 - name = 'Grocery' - - class supplements: - number = 2 - name = 'Supplements' - - -def load_fixtures(engine): - - dbfixture = fixture.SQLAlchemyFixture( - env={ - 'DepartmentData': model.Department, - }, - engine=engine) - - data = dbfixture.data(DepartmentData) - - data.setup() diff --git a/tests/forms/__init__.py b/tests/forms/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/forms/test_core.py b/tests/forms/test_core.py new file mode 100644 index 00000000..894d2302 --- /dev/null +++ b/tests/forms/test_core.py @@ -0,0 +1,153 @@ +# -*- coding: utf-8; -*- + +from unittest.mock import patch + +import deform +from pyramid import testing + +from tailbone.forms import core as mod +from tests.util import WebTestCase + + +class TestForm(WebTestCase): + + def setUp(self): + self.setup_web() + self.config.setdefault('rattail.web.menus.handler_spec', 'tests.util:NullMenuHandler') + + def make_form(self, **kwargs): + kwargs.setdefault('request', self.request) + return mod.Form(**kwargs) + + def test_basic(self): + form = self.make_form() + self.assertIsInstance(form, mod.Form) + + def test_vue_tagname(self): + + # default + form = self.make_form() + self.assertEqual(form.vue_tagname, 'tailbone-form') + + # can override with param + form = self.make_form(vue_tagname='something-else') + self.assertEqual(form.vue_tagname, 'something-else') + + # can still pass old param + form = self.make_form(component='legacy-name') + self.assertEqual(form.vue_tagname, 'legacy-name') + + def test_vue_component(self): + + # default + form = self.make_form() + self.assertEqual(form.vue_component, 'TailboneForm') + + # can override with param + form = self.make_form(vue_tagname='something-else') + self.assertEqual(form.vue_component, 'SomethingElse') + + # can still pass old param + form = self.make_form(component='legacy-name') + self.assertEqual(form.vue_component, 'LegacyName') + + def test_component(self): + + # default + form = self.make_form() + self.assertEqual(form.component, 'tailbone-form') + + # can override with param + form = self.make_form(vue_tagname='something-else') + self.assertEqual(form.component, 'something-else') + + # can still pass old param + form = self.make_form(component='legacy-name') + self.assertEqual(form.component, 'legacy-name') + + def test_component_studly(self): + + # default + form = self.make_form() + self.assertEqual(form.component_studly, 'TailboneForm') + + # can override with param + form = self.make_form(vue_tagname='something-else') + self.assertEqual(form.component_studly, 'SomethingElse') + + # can still pass old param + form = self.make_form(component='legacy-name') + self.assertEqual(form.component_studly, 'LegacyName') + + def test_button_label_submit(self): + form = self.make_form() + + # default + self.assertEqual(form.button_label_submit, "Submit") + + # can set submit_label + with patch.object(form, 'submit_label', new="Submit Label", create=True): + self.assertEqual(form.button_label_submit, "Submit Label") + + # can set save_label + with patch.object(form, 'save_label', new="Save Label"): + self.assertEqual(form.button_label_submit, "Save Label") + + # can set button_label_submit + form.button_label_submit = "New Label" + self.assertEqual(form.button_label_submit, "New Label") + + def test_get_deform(self): + model = self.app.model + + # sanity check + form = self.make_form(model_class=model.Setting) + dform = form.get_deform() + self.assertIsInstance(dform, deform.Form) + + def test_render_vue_tag(self): + model = self.app.model + + # sanity check + form = self.make_form(model_class=model.Setting) + html = form.render_vue_tag() + self.assertIn('<tailbone-form', html) + + def test_render_vue_template(self): + self.pyramid_config.include('tailbone.views.common') + model = self.app.model + + # sanity check + form = self.make_form(model_class=model.Setting) + html = form.render_vue_template(session=self.session) + self.assertIn('<form ', html) + + def test_get_vue_field_value(self): + model = self.app.model + form = self.make_form(model_class=model.Setting) + + # TODO: yikes what a hack (?) + dform = form.get_deform() + dform.set_appstruct({'name': 'foo', 'value': 'bar'}) + + # null for missing field + value = form.get_vue_field_value('doesnotexist') + self.assertIsNone(value) + + # normal value is returned + value = form.get_vue_field_value('name') + self.assertEqual(value, 'foo') + + # but not if we remove field from deform + # TODO: what is the use case here again? + dform.children.remove(dform['name']) + value = form.get_vue_field_value('name') + self.assertIsNone(value) + + def test_render_vue_field(self): + model = self.app.model + + # sanity check + form = self.make_form(model_class=model.Setting) + html = form.render_vue_field('name', session=self.session) + self.assertIn('<b-field ', html) diff --git a/tests/grids/__init__.py b/tests/grids/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/grids/test_core.py b/tests/grids/test_core.py new file mode 100644 index 00000000..4d143c85 --- /dev/null +++ b/tests/grids/test_core.py @@ -0,0 +1,579 @@ +# -*- coding: utf-8; -*- + +from unittest.mock import MagicMock, patch + +from sqlalchemy import orm + +from tailbone.grids import core as mod +from tests.util import WebTestCase + + +class TestGrid(WebTestCase): + + def setUp(self): + self.setup_web() + self.config.setdefault('rattail.web.menus.handler_spec', 'tests.util:NullMenuHandler') + + def make_grid(self, key=None, data=[], **kwargs): + return mod.Grid(self.request, key=key, data=data, **kwargs) + + def test_basic(self): + grid = self.make_grid('foo') + self.assertIsInstance(grid, mod.Grid) + + def test_deprecated_params(self): + + # component + grid = self.make_grid() + self.assertEqual(grid.vue_tagname, 'tailbone-grid') + grid = self.make_grid(component='blarg') + self.assertEqual(grid.vue_tagname, 'blarg') + + # default_sortkey, default_sortdir + grid = self.make_grid() + self.assertEqual(grid.sort_defaults, []) + grid = self.make_grid(default_sortkey='name') + self.assertEqual(grid.sort_defaults, [mod.SortInfo('name', 'asc')]) + grid = self.make_grid(default_sortdir='desc') + self.assertEqual(grid.sort_defaults, []) + grid = self.make_grid(default_sortkey='name', default_sortdir='desc') + self.assertEqual(grid.sort_defaults, [mod.SortInfo('name', 'desc')]) + + # pageable + grid = self.make_grid() + self.assertFalse(grid.paginated) + grid = self.make_grid(pageable=True) + self.assertTrue(grid.paginated) + + # default_pagesize + grid = self.make_grid() + self.assertEqual(grid.pagesize, 20) + grid = self.make_grid(default_pagesize=15) + self.assertEqual(grid.pagesize, 15) + + # default_page + grid = self.make_grid() + self.assertEqual(grid.page, 1) + grid = self.make_grid(default_page=42) + self.assertEqual(grid.page, 42) + + # searchable + grid = self.make_grid() + self.assertEqual(grid.searchable_columns, set()) + grid = self.make_grid(searchable={'foo': True}) + self.assertEqual(grid.searchable_columns, {'foo'}) + + def test_vue_tagname(self): + + # default + grid = self.make_grid('foo') + self.assertEqual(grid.vue_tagname, 'tailbone-grid') + + # can override with param + grid = self.make_grid('foo', vue_tagname='something-else') + self.assertEqual(grid.vue_tagname, 'something-else') + + # can still pass old param + grid = self.make_grid('foo', component='legacy-name') + self.assertEqual(grid.vue_tagname, 'legacy-name') + + def test_vue_component(self): + + # default + grid = self.make_grid('foo') + self.assertEqual(grid.vue_component, 'TailboneGrid') + + # can override with param + grid = self.make_grid('foo', vue_tagname='something-else') + self.assertEqual(grid.vue_component, 'SomethingElse') + + # can still pass old param + grid = self.make_grid('foo', component='legacy-name') + self.assertEqual(grid.vue_component, 'LegacyName') + + def test_component(self): + + # default + grid = self.make_grid('foo') + self.assertEqual(grid.component, 'tailbone-grid') + + # can override with param + grid = self.make_grid('foo', vue_tagname='something-else') + self.assertEqual(grid.component, 'something-else') + + # can still pass old param + grid = self.make_grid('foo', component='legacy-name') + self.assertEqual(grid.component, 'legacy-name') + + def test_component_studly(self): + + # default + grid = self.make_grid('foo') + self.assertEqual(grid.component_studly, 'TailboneGrid') + + # can override with param + grid = self.make_grid('foo', vue_tagname='something-else') + self.assertEqual(grid.component_studly, 'SomethingElse') + + # can still pass old param + grid = self.make_grid('foo', component='legacy-name') + self.assertEqual(grid.component_studly, 'LegacyName') + + def test_actions(self): + + # default + grid = self.make_grid('foo') + self.assertEqual(grid.actions, []) + + # main actions + grid = self.make_grid('foo', main_actions=['foo']) + self.assertEqual(grid.actions, ['foo']) + + # more actions + grid = self.make_grid('foo', main_actions=['foo'], more_actions=['bar']) + self.assertEqual(grid.actions, ['foo', 'bar']) + + def test_set_label(self): + model = self.app.model + grid = self.make_grid(model_class=model.Setting, filterable=True) + self.assertEqual(grid.labels, {}) + + # basic + grid.set_label('name', "NAME COL") + self.assertEqual(grid.labels['name'], "NAME COL") + + # can replace label + grid.set_label('name', "Different") + self.assertEqual(grid.labels['name'], "Different") + self.assertEqual(grid.get_label('name'), "Different") + + # can update only column, not filter + self.assertEqual(grid.labels, {'name': "Different"}) + self.assertIn('name', grid.filters) + self.assertEqual(grid.filters['name'].label, "Different") + grid.set_label('name', "COLUMN ONLY", column_only=True) + self.assertEqual(grid.get_label('name'), "COLUMN ONLY") + self.assertEqual(grid.filters['name'].label, "Different") + + def test_get_view_click_handler(self): + model = self.app.model + grid = self.make_grid(model_class=model.Setting) + + grid.actions.append( + mod.GridAction(self.request, 'view', + click_handler='clickHandler(props.row)')) + + handler = grid.get_view_click_handler() + self.assertEqual(handler, 'clickHandler(props.row)') + + def test_set_action_urls(self): + model = self.app.model + grid = self.make_grid(model_class=model.Setting) + + grid.actions.append( + mod.GridAction(self.request, 'view', url='/blarg')) + + setting = {'name': 'foo', 'value': 'bar'} + grid.set_action_urls(setting, setting, 0) + self.assertEqual(setting['_action_url_view'], '/blarg') + + def test_default_sortkey(self): + grid = self.make_grid() + self.assertEqual(grid.sort_defaults, []) + self.assertIsNone(grid.default_sortkey) + grid.default_sortkey = 'name' + self.assertEqual(grid.sort_defaults, [mod.SortInfo('name', 'asc')]) + self.assertEqual(grid.default_sortkey, 'name') + grid.default_sortkey = 'value' + self.assertEqual(grid.sort_defaults, [mod.SortInfo('value', 'asc')]) + self.assertEqual(grid.default_sortkey, 'value') + + def test_default_sortdir(self): + grid = self.make_grid() + self.assertEqual(grid.sort_defaults, []) + self.assertIsNone(grid.default_sortdir) + self.assertRaises(ValueError, setattr, grid, 'default_sortdir', 'asc') + grid.sort_defaults = [mod.SortInfo('name', 'asc')] + grid.default_sortdir = 'desc' + self.assertEqual(grid.sort_defaults, [mod.SortInfo('name', 'desc')]) + self.assertEqual(grid.default_sortdir, 'desc') + + def test_pageable(self): + grid = self.make_grid() + self.assertFalse(grid.paginated) + grid.pageable = True + self.assertTrue(grid.paginated) + grid.paginated = False + self.assertFalse(grid.pageable) + + def test_get_pagesize_options(self): + grid = self.make_grid() + + # default + options = grid.get_pagesize_options() + self.assertEqual(options, [5, 10, 20, 50, 100, 200]) + + # override default + options = grid.get_pagesize_options(default=[42]) + self.assertEqual(options, [42]) + + # from legacy config + self.config.setdefault('tailbone.grid.pagesize_options', '1 2 3') + grid = self.make_grid() + options = grid.get_pagesize_options() + self.assertEqual(options, [1, 2, 3]) + + # from new config + self.config.setdefault('wuttaweb.grids.default_pagesize_options', '4, 5, 6') + grid = self.make_grid() + options = grid.get_pagesize_options() + self.assertEqual(options, [4, 5, 6]) + + def test_get_pagesize(self): + grid = self.make_grid() + + # default + size = grid.get_pagesize() + self.assertEqual(size, 20) + + # override default + size = grid.get_pagesize(default=42) + self.assertEqual(size, 42) + + # override default options + self.config.setdefault('wuttaweb.grids.default_pagesize_options', '10 15 30') + grid = self.make_grid() + size = grid.get_pagesize() + self.assertEqual(size, 10) + + # from legacy config + self.config.setdefault('tailbone.grid.default_pagesize', '12') + grid = self.make_grid() + size = grid.get_pagesize() + self.assertEqual(size, 12) + + # from new config + self.config.setdefault('wuttaweb.grids.default_pagesize', '15') + grid = self.make_grid() + size = grid.get_pagesize() + self.assertEqual(size, 15) + + def test_set_sorter(self): + model = self.app.model + grid = self.make_grid(model_class=model.Setting, + sortable=True, sort_on_backend=True) + + # passing None will remove sorter + self.assertIn('name', grid.sorters) + grid.set_sorter('name', None) + self.assertNotIn('name', grid.sorters) + + # can recreate sorter with just column name + grid.set_sorter('name') + self.assertIn('name', grid.sorters) + grid.remove_sorter('name') + self.assertNotIn('name', grid.sorters) + grid.set_sorter('name', 'name') + self.assertIn('name', grid.sorters) + + # can recreate sorter with model property + grid.remove_sorter('name') + self.assertNotIn('name', grid.sorters) + grid.set_sorter('name', model.Setting.name) + self.assertIn('name', grid.sorters) + + # extra kwargs are ignored + grid.remove_sorter('name') + self.assertNotIn('name', grid.sorters) + grid.set_sorter('name', model.Setting.name, foo='bar') + self.assertIn('name', grid.sorters) + + # passing multiple args will invoke make_filter() directly + grid.remove_sorter('name') + self.assertNotIn('name', grid.sorters) + with patch.object(grid, 'make_sorter') as make_sorter: + make_sorter.return_value = 42 + grid.set_sorter('name', 'foo', 'bar') + make_sorter.assert_called_once_with('foo', 'bar') + self.assertEqual(grid.sorters['name'], 42) + + def test_make_simple_sorter(self): + model = self.app.model + grid = self.make_grid(model_class=model.Setting, + sortable=True, sort_on_backend=True) + + # delegates to grid.make_sorter() + with patch.object(grid, 'make_sorter') as make_sorter: + make_sorter.return_value = 42 + sorter = grid.make_simple_sorter('name', foldcase=True) + make_sorter.assert_called_once_with('name', foldcase=True) + self.assertEqual(sorter, 42) + + def test_load_settings(self): + model = self.app.model + + # nb. first use a paging grid + grid = self.make_grid(key='foo', paginated=True, paginate_on_backend=True, + pagesize=20, page=1) + + # settings are loaded, applied, saved + self.assertEqual(grid.page, 1) + self.assertNotIn('grid.foo.page', self.request.session) + self.request.GET = {'pagesize': '10', 'page': '2'} + grid.load_settings() + self.assertEqual(grid.page, 2) + self.assertEqual(self.request.session['grid.foo.page'], 2) + + # can skip the saving step + self.request.GET = {'pagesize': '10', 'page': '3'} + grid.load_settings(store=False) + self.assertEqual(grid.page, 3) + self.assertEqual(self.request.session['grid.foo.page'], 2) + + # no error for non-paginated grid + grid = self.make_grid(key='foo', paginated=False) + grid.load_settings() + self.assertFalse(grid.paginated) + + # nb. next use a sorting grid + grid = self.make_grid(key='settings', model_class=model.Setting, + sortable=True, sort_on_backend=True) + + # settings are loaded, applied, saved + self.assertEqual(grid.sort_defaults, []) + self.assertFalse(hasattr(grid, 'active_sorters')) + self.request.GET = {'sort1key': 'name', 'sort1dir': 'desc'} + grid.load_settings() + self.assertEqual(grid.active_sorters, [{'key': 'name', 'dir': 'desc'}]) + self.assertEqual(self.request.session['grid.settings.sorters.length'], 1) + self.assertEqual(self.request.session['grid.settings.sorters.1.key'], 'name') + self.assertEqual(self.request.session['grid.settings.sorters.1.dir'], 'desc') + + # can skip the saving step + self.request.GET = {'sort1key': 'name', 'sort1dir': 'asc'} + grid.load_settings(store=False) + self.assertEqual(grid.active_sorters, [{'key': 'name', 'dir': 'asc'}]) + self.assertEqual(self.request.session['grid.settings.sorters.length'], 1) + self.assertEqual(self.request.session['grid.settings.sorters.1.key'], 'name') + self.assertEqual(self.request.session['grid.settings.sorters.1.dir'], 'desc') + + # no error for non-sortable grid + grid = self.make_grid(key='foo', sortable=False) + grid.load_settings() + self.assertFalse(grid.sortable) + + # with sort defaults + grid = self.make_grid(model_class=model.Setting, sortable=True, + sort_on_backend=True, sort_defaults='name') + self.assertFalse(hasattr(grid, 'active_sorters')) + grid.load_settings() + self.assertEqual(grid.active_sorters, [{'key': 'name', 'dir': 'asc'}]) + + # with multi-column sort defaults + grid = self.make_grid(model_class=model.Setting, sortable=True, + sort_on_backend=True) + grid.sort_defaults = [ + mod.SortInfo('name', 'asc'), + mod.SortInfo('value', 'desc'), + ] + self.assertFalse(hasattr(grid, 'active_sorters')) + grid.load_settings() + self.assertEqual(grid.active_sorters, [{'key': 'name', 'dir': 'asc'}]) + + # load settings from session when nothing is in request + self.request.GET = {} + self.request.session.invalidate() + self.assertNotIn('grid.settings.sorters.length', self.request.session) + self.request.session['grid.settings.sorters.length'] = 1 + self.request.session['grid.settings.sorters.1.key'] = 'name' + self.request.session['grid.settings.sorters.1.dir'] = 'desc' + grid = self.make_grid(key='settings', model_class=model.Setting, + sortable=True, sort_on_backend=True, + paginated=True, paginate_on_backend=True) + self.assertFalse(hasattr(grid, 'active_sorters')) + grid.load_settings() + self.assertEqual(grid.active_sorters, [{'key': 'name', 'dir': 'desc'}]) + + def test_persist_settings(self): + model = self.app.model + + # nb. start out with paginated-only grid + grid = self.make_grid(key='foo', paginated=True, paginate_on_backend=True) + + # invalid dest + self.assertRaises(ValueError, grid.persist_settings, {}, dest='doesnotexist') + + # nb. no error if empty settings, but it saves null values + grid.persist_settings({}, dest='session') + self.assertIsNone(self.request.session['grid.foo.page']) + + # provided values are saved + grid.persist_settings({'pagesize': 15, 'page': 3}, dest='session') + self.assertEqual(self.request.session['grid.foo.page'], 3) + + # nb. now switch to sortable-only grid + grid = self.make_grid(key='settings', model_class=model.Setting, + sortable=True, sort_on_backend=True) + + # no error if empty settings; does not save values + grid.persist_settings({}, dest='session') + self.assertNotIn('grid.settings.sorters.length', self.request.session) + + # provided values are saved + grid.persist_settings({'sorters.length': 2, + 'sorters.1.key': 'name', + 'sorters.1.dir': 'desc', + 'sorters.2.key': 'value', + 'sorters.2.dir': 'asc'}, + dest='session') + self.assertEqual(self.request.session['grid.settings.sorters.length'], 2) + self.assertEqual(self.request.session['grid.settings.sorters.1.key'], 'name') + self.assertEqual(self.request.session['grid.settings.sorters.1.dir'], 'desc') + self.assertEqual(self.request.session['grid.settings.sorters.2.key'], 'value') + self.assertEqual(self.request.session['grid.settings.sorters.2.dir'], 'asc') + + # old values removed when new are saved + grid.persist_settings({'sorters.length': 1, + 'sorters.1.key': 'name', + 'sorters.1.dir': 'desc'}, + dest='session') + self.assertEqual(self.request.session['grid.settings.sorters.length'], 1) + self.assertEqual(self.request.session['grid.settings.sorters.1.key'], 'name') + self.assertEqual(self.request.session['grid.settings.sorters.1.dir'], 'desc') + self.assertNotIn('grid.settings.sorters.2.key', self.request.session) + self.assertNotIn('grid.settings.sorters.2.dir', self.request.session) + + def test_sort_data(self): + model = self.app.model + sample_data = [ + {'name': 'foo1', 'value': 'ONE'}, + {'name': 'foo2', 'value': 'two'}, + {'name': 'foo3', 'value': 'ggg'}, + {'name': 'foo4', 'value': 'ggg'}, + {'name': 'foo5', 'value': 'ggg'}, + {'name': 'foo6', 'value': 'six'}, + {'name': 'foo7', 'value': 'seven'}, + {'name': 'foo8', 'value': 'eight'}, + {'name': 'foo9', 'value': 'nine'}, + ] + for setting in sample_data: + self.app.save_setting(self.session, setting['name'], setting['value']) + self.session.commit() + sample_query = self.session.query(model.Setting) + + grid = self.make_grid(model_class=model.Setting, + sortable=True, sort_on_backend=True, + sort_defaults=('name', 'desc')) + grid.load_settings() + + # can sort a simple list of data + sorted_data = grid.sort_data(sample_data) + self.assertIsInstance(sorted_data, list) + self.assertEqual(len(sorted_data), 9) + self.assertEqual(sorted_data[0]['name'], 'foo9') + self.assertEqual(sorted_data[-1]['name'], 'foo1') + + # can also sort a data query + sorted_query = grid.sort_data(sample_query) + self.assertIsInstance(sorted_query, orm.Query) + sorted_data = sorted_query.all() + self.assertEqual(len(sorted_data), 9) + self.assertEqual(sorted_data[0]['name'], 'foo9') + self.assertEqual(sorted_data[-1]['name'], 'foo1') + + # cannot sort data if sorter missing in overrides + sorted_data = grid.sort_data(sample_data, sorters=[]) + # nb. sorted data is in same order as original sample (not sorted) + self.assertEqual(sorted_data[0]['name'], 'foo1') + self.assertEqual(sorted_data[-1]['name'], 'foo9') + + # multi-column sorting for list data + sorted_data = grid.sort_data(sample_data, sorters=[{'key': 'value', 'dir': 'asc'}, + {'key': 'name', 'dir': 'asc'}]) + self.assertEqual(dict(sorted_data[0]), {'name': 'foo8', 'value': 'eight'}) + self.assertEqual(dict(sorted_data[1]), {'name': 'foo3', 'value': 'ggg'}) + self.assertEqual(dict(sorted_data[3]), {'name': 'foo5', 'value': 'ggg'}) + self.assertEqual(dict(sorted_data[-1]), {'name': 'foo2', 'value': 'two'}) + + # multi-column sorting for query + sorted_query = grid.sort_data(sample_query, sorters=[{'key': 'value', 'dir': 'asc'}, + {'key': 'name', 'dir': 'asc'}]) + self.assertEqual(dict(sorted_data[0]), {'name': 'foo8', 'value': 'eight'}) + self.assertEqual(dict(sorted_data[1]), {'name': 'foo3', 'value': 'ggg'}) + self.assertEqual(dict(sorted_data[3]), {'name': 'foo5', 'value': 'ggg'}) + self.assertEqual(dict(sorted_data[-1]), {'name': 'foo2', 'value': 'two'}) + + # cannot sort data if sortfunc is missing for column + grid.remove_sorter('name') + sorted_data = grid.sort_data(sample_data, sorters=[{'key': 'value', 'dir': 'asc'}, + {'key': 'name', 'dir': 'asc'}]) + # nb. sorted data is in same order as original sample (not sorted) + self.assertEqual(sorted_data[0]['name'], 'foo1') + self.assertEqual(sorted_data[-1]['name'], 'foo9') + + def test_render_vue_tag(self): + model = self.app.model + + # standard + grid = self.make_grid('settings', model_class=model.Setting) + html = grid.render_vue_tag() + self.assertIn('<tailbone-grid', html) + self.assertNotIn('@deleteActionClicked', html) + + # with delete hook + master = MagicMock(deletable=True, delete_confirm='simple') + master.has_perm.return_value = True + grid = self.make_grid('settings', model_class=model.Setting) + html = grid.render_vue_tag(master=master) + self.assertIn('<tailbone-grid', html) + self.assertIn('@deleteActionClicked', html) + + def test_render_vue_template(self): + # self.pyramid_config.include('tailbone.views.common') + model = self.app.model + + # sanity check + grid = self.make_grid('settings', model_class=model.Setting) + html = grid.render_vue_template(session=self.session) + self.assertIn('<b-table', html) + + def test_get_vue_columns(self): + model = self.app.model + + # sanity check + grid = self.make_grid('settings', model_class=model.Setting, sortable=True) + columns = grid.get_vue_columns() + self.assertEqual(len(columns), 2) + self.assertEqual(columns[0]['field'], 'name') + self.assertTrue(columns[0]['sortable']) + self.assertEqual(columns[1]['field'], 'value') + self.assertTrue(columns[1]['sortable']) + + def test_get_vue_data(self): + model = self.app.model + + # sanity check + grid = self.make_grid('settings', model_class=model.Setting) + data = grid.get_vue_data() + self.assertEqual(data, []) + + # calling again returns same data + data2 = grid.get_vue_data() + self.assertIs(data2, data) + + +class TestGridAction(WebTestCase): + + def test_constructor(self): + + # null by default + action = mod.GridAction(self.request, 'view') + self.assertIsNone(action.target) + self.assertIsNone(action.click_handler) + + # but can set them + action = mod.GridAction(self.request, 'view', + target='_blank', + click_handler='doSomething(props.row)') + self.assertEqual(action.target, '_blank') + self.assertEqual(action.click_handler, 'doSomething(props.row)') diff --git a/tests/test_app.py b/tests/test_app.py index 6434aa0e..f49f6b13 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -1,18 +1,13 @@ -# -*- coding: utf-8 -*- - -from __future__ import unicode_literals, absolute_import +# -*- coding: utf-8; -*- import os from unittest import TestCase -from sqlalchemy import create_engine +from pyramid.config import Configurator -from rattail.config import RattailConfig from rattail.exceptions import ConfigurationError -from rattail.db import Session as RattailSession - -from tailbone import app -from tailbone.db import Session as TailboneSession +from rattail.testing import DataTestCase +from tailbone import app as mod class TestRattailConfig(TestCase): @@ -20,13 +15,34 @@ class TestRattailConfig(TestCase): config_path = os.path.abspath( os.path.join(os.path.dirname(__file__), 'data', 'tailbone.conf')) - def tearDown(self): - # may or may not be necessary depending on test - TailboneSession.remove() - def test_settings_arg_must_include_config_path_by_default(self): # error raised if path not provided - self.assertRaises(ConfigurationError, app.make_rattail_config, {}) + self.assertRaises(ConfigurationError, mod.make_rattail_config, {}) # get a config object if path provided - result = app.make_rattail_config({'rattail.config': self.config_path}) - self.assertTrue(isinstance(result, RattailConfig)) + result = mod.make_rattail_config({'rattail.config': self.config_path}) + # nb. cannot test isinstance(RattailConfig) b/c now uses wrapper! + self.assertIsNotNone(result) + self.assertTrue(hasattr(result, 'get')) + + +class TestMakePyramidConfig(DataTestCase): + + def make_config(self, **kwargs): + myconf = self.write_file('web.conf', """ +[rattail.db] +default.url = sqlite:// +""") + + self.settings = { + 'rattail.config': myconf, + 'mako.directories': 'tailbone:templates', + } + return mod.make_rattail_config(self.settings) + + def test_basic(self): + model = self.app.model + model.Base.metadata.create_all(bind=self.config.appdb_engine) + + # sanity check + pyramid_config = mod.make_pyramid_config(self.settings) + self.assertIsInstance(pyramid_config, Configurator) diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 00000000..4519e152 --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8; -*- + +from tailbone import auth as mod diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 00000000..0cd1938c --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,12 @@ +# -*- coding: utf-8; -*- + +from tailbone import config as mod +from tests.util import DataTestCase + + +class TestConfigExtension(DataTestCase): + + def test_basic(self): + # sanity / coverage check + ext = mod.ConfigExtension() + ext.configure(self.config) diff --git a/tests/test_db.py b/tests/test_db.py new file mode 100644 index 00000000..88cb9d41 --- /dev/null +++ b/tests/test_db.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8; -*- + +# TODO: add real tests at some point but this at least gives us basic +# coverage when running this "test" module alone + +from tailbone import db + diff --git a/tests/test_subscribers.py b/tests/test_subscribers.py new file mode 100644 index 00000000..81bc2869 --- /dev/null +++ b/tests/test_subscribers.py @@ -0,0 +1,58 @@ +# -*- coding: utf-8; -*- + +from unittest.mock import MagicMock + +from pyramid import testing + +from tailbone import subscribers as mod +from tests.util import DataTestCase + + +class TestNewRequest(DataTestCase): + + def setUp(self): + self.setup_db() + self.request = self.make_request() + self.pyramid_config = testing.setUp(request=self.request, settings={ + 'wutta_config': self.config, + }) + + def tearDown(self): + self.teardown_db() + testing.tearDown() + + def make_request(self, **kwargs): + return testing.DummyRequest(**kwargs) + + def make_event(self): + return MagicMock(request=self.request) + + def test_continuum_remote_addr(self): + event = self.make_event() + + # nothing happens + mod.new_request(event, session=self.session) + self.assertFalse(hasattr(self.session, 'continuum_remote_addr')) + + # unless request has client_addr + self.request.client_addr = '127.0.0.1' + mod.new_request(event, session=self.session) + self.assertEqual(self.session.continuum_remote_addr, '127.0.0.1') + + def test_register_component(self): + event = self.make_event() + + # function added + self.assertFalse(hasattr(self.request, 'register_component')) + mod.new_request(event, session=self.session) + self.assertTrue(callable(self.request.register_component)) + + # call function + self.request.register_component('tailbone-datepicker', 'TailboneDatepicker') + self.assertEqual(self.request._tailbone_registered_components, + {'tailbone-datepicker': 'TailboneDatepicker'}) + + # duplicate registration ignored + self.request.register_component('tailbone-datepicker', 'TailboneDatepicker') + self.assertEqual(self.request._tailbone_registered_components, + {'tailbone-datepicker': 'TailboneDatepicker'}) diff --git a/tests/test_util.py b/tests/test_util.py new file mode 100644 index 00000000..46684f0c --- /dev/null +++ b/tests/test_util.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8; -*- + +from unittest import TestCase + +from pyramid import testing + +from rattail.config import RattailConfig + +from tailbone import util + + +class TestGetFormData(TestCase): + + def setUp(self): + self.config = RattailConfig() + + def make_request(self, **kwargs): + kwargs.setdefault('wutta_config', self.config) + kwargs.setdefault('rattail_config', self.config) + kwargs.setdefault('is_xhr', None) + kwargs.setdefault('content_type', None) + kwargs.setdefault('POST', {'foo1': 'bar'}) + kwargs.setdefault('json_body', {'foo2': 'baz'}) + return testing.DummyRequest(**kwargs) + + def test_default(self): + request = self.make_request() + data = util.get_form_data(request) + self.assertEqual(data, {'foo1': 'bar'}) + + def test_is_xhr(self): + request = self.make_request(POST=None, is_xhr=True) + data = util.get_form_data(request) + self.assertEqual(data, {'foo2': 'baz'}) + + def test_content_type(self): + request = self.make_request(POST=None, content_type='application/json') + data = util.get_form_data(request) + self.assertEqual(data, {'foo2': 'baz'}) diff --git a/tests/util.py b/tests/util.py new file mode 100644 index 00000000..4277a7c3 --- /dev/null +++ b/tests/util.py @@ -0,0 +1,77 @@ +# -*- coding: utf-8; -*- + +from unittest.mock import MagicMock + +from pyramid import testing + +from tailbone import subscribers +from wuttaweb.menus import MenuHandler +# from wuttaweb.subscribers import new_request_set_user +from rattail.testing import DataTestCase + + +class WebTestCase(DataTestCase): + """ + Base class for test suites requiring a full (typical) web app. + """ + + def setUp(self): + self.setup_web() + + def setup_web(self): + self.setup_db() + self.request = self.make_request() + self.pyramid_config = testing.setUp(request=self.request, settings={ + 'wutta_config': self.config, + 'rattail_config': self.config, + 'mako.directories': ['tailbone:templates', 'wuttaweb:templates'], + # 'pyramid_deform.template_search_path': 'wuttaweb:templates/deform', + }) + + # init web + # self.pyramid_config.include('pyramid_deform') + self.pyramid_config.include('pyramid_mako') + self.pyramid_config.add_directive('add_wutta_permission_group', + 'wuttaweb.auth.add_permission_group') + self.pyramid_config.add_directive('add_wutta_permission', + 'wuttaweb.auth.add_permission') + self.pyramid_config.add_directive('add_tailbone_permission_group', + 'wuttaweb.auth.add_permission_group') + self.pyramid_config.add_directive('add_tailbone_permission', + 'wuttaweb.auth.add_permission') + self.pyramid_config.add_directive('add_tailbone_index_page', + 'tailbone.app.add_index_page') + self.pyramid_config.add_directive('add_tailbone_model_view', + 'tailbone.app.add_model_view') + self.pyramid_config.add_directive('add_tailbone_config_page', + 'tailbone.app.add_config_page') + self.pyramid_config.add_subscriber('tailbone.subscribers.before_render', + 'pyramid.events.BeforeRender') + self.pyramid_config.include('tailbone.static') + + # setup new request w/ anonymous user + event = MagicMock(request=self.request) + subscribers.new_request(event, session=self.session) + # def user_getter(request, **kwargs): pass + # new_request_set_user(event, db_session=self.session, + # user_getter=user_getter) + + def tearDown(self): + self.teardown_web() + + def teardown_web(self): + testing.tearDown() + self.teardown_db() + + def make_request(self, **kwargs): + kwargs.setdefault('rattail_config', self.config) + # kwargs.setdefault('wutta_config', self.config) + return testing.DummyRequest(**kwargs) + + +class NullMenuHandler(MenuHandler): + """ + Dummy menu handler for testing. + """ + def make_menus(self, request, **kwargs): + return [] diff --git a/tests/views/test_master.py b/tests/views/test_master.py new file mode 100644 index 00000000..0e459e7d --- /dev/null +++ b/tests/views/test_master.py @@ -0,0 +1,66 @@ +# -*- coding: utf-8; -*- + +from unittest.mock import patch, MagicMock + +from tailbone.views import master as mod +from wuttaweb.grids import GridAction +from tests.util import WebTestCase + + +class TestMasterView(WebTestCase): + + def make_view(self): + return mod.MasterView(self.request) + + def test_make_form_kwargs(self): + self.pyramid_config.add_route('settings.view', '/settings/{name}') + model = self.app.model + setting = model.Setting(name='foo', value='bar') + self.session.add(setting) + self.session.commit() + with patch.multiple(mod.MasterView, create=True, + model_class=model.Setting): + view = self.make_view() + + # sanity / coverage check + kw = view.make_form_kwargs(model_instance=setting) + self.assertIsNotNone(kw['action_url']) + + def test_make_action(self): + model = self.app.model + with patch.multiple(mod.MasterView, create=True, + model_class=model.Setting): + view = self.make_view() + action = view.make_action('view') + self.assertIsInstance(action, GridAction) + + def test_index(self): + self.pyramid_config.include('tailbone.views.common') + self.pyramid_config.include('tailbone.views.auth') + model = self.app.model + + # mimic view for /settings + with patch.object(mod, 'Session', return_value=self.session): + with patch.multiple(mod.MasterView, create=True, + model_class=model.Setting, + Session=MagicMock(return_value=self.session), + get_index_url=MagicMock(return_value='/settings/'), + get_help_url=MagicMock(return_value=None)): + + # basic + view = self.make_view() + response = view.index() + self.assertEqual(response.status_code, 200) + + # then again with data, to include view action url + data = [{'name': 'foo', 'value': 'bar'}] + with patch.object(view, 'get_data', return_value=data): + response = view.index() + self.assertEqual(response.status_code, 200) + self.assertEqual(response.content_type, 'text/html') + + # then once more as 'partial' - aka. data only + self.request.GET = {'partial': '1'} + response = view.index() + self.assertEqual(response.status_code, 200) + self.assertEqual(response.content_type, 'application/json') diff --git a/tests/views/test_people.py b/tests/views/test_people.py new file mode 100644 index 00000000..f85577e7 --- /dev/null +++ b/tests/views/test_people.py @@ -0,0 +1,17 @@ +# -*- coding: utf-8; -*- + +from tailbone.views import users as mod +from tests.util import WebTestCase + + +class TestPersonView(WebTestCase): + + def make_view(self): + return mod.PersonView(self.request) + + def test_includeme(self): + self.pyramid_config.include('tailbone.views.people') + + def test_includeme_wutta(self): + self.config.setdefault('tailbone.use_wutta_views', 'true') + self.pyramid_config.include('tailbone.views.people') diff --git a/tests/views/test_principal.py b/tests/views/test_principal.py new file mode 100644 index 00000000..2b31531c --- /dev/null +++ b/tests/views/test_principal.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8; -*- + +from unittest.mock import patch, MagicMock + +from tailbone.views import principal as mod +from tests.util import WebTestCase + + +class TestPrincipalMasterView(WebTestCase): + + def make_view(self): + return mod.PrincipalMasterView(self.request) + + def test_find_by_perm(self): + model = self.app.model + self.config.setdefault('rattail.web.menus.handler_spec', 'tests.util:NullMenuHandler') + self.pyramid_config.include('tailbone.views.common') + self.pyramid_config.include('tailbone.views.auth') + self.pyramid_config.add_route('roles', '/roles/') + with patch.multiple(mod.PrincipalMasterView, create=True, + model_class=model.Role, + get_help_url=MagicMock(return_value=None), + get_help_markdown=MagicMock(return_value=None), + can_edit_help=MagicMock(return_value=False)): + + # sanity / coverage check + view = self.make_view() + response = view.find_by_perm() + self.assertEqual(response.status_code, 200) diff --git a/tests/views/test_roles.py b/tests/views/test_roles.py new file mode 100644 index 00000000..0cdc724e --- /dev/null +++ b/tests/views/test_roles.py @@ -0,0 +1,80 @@ +# -*- coding: utf-8; -*- + +from unittest.mock import patch + +from tailbone.views import roles as mod +from tests.util import WebTestCase + + +class TestRoleView(WebTestCase): + + def make_view(self): + return mod.RoleView(self.request) + + def test_includeme(self): + self.pyramid_config.include('tailbone.views.roles') + + def get_permissions(self): + return { + 'widgets': { + 'label': "Widgets", + 'perms': { + 'widgets.list': { + 'label': "List widgets", + }, + 'widgets.polish': { + 'label': "Polish the widgets", + }, + 'widgets.view': { + 'label': "View widget", + }, + }, + }, + } + + def test_get_available_permissions(self): + model = self.app.model + auth = self.app.get_auth_handler() + blokes = model.Role(name="Blokes") + auth.grant_permission(blokes, 'widgets.list') + self.session.add(blokes) + barney = model.User(username='barney') + barney.roles.append(blokes) + self.session.add(barney) + self.session.commit() + view = self.make_view() + all_perms = self.get_permissions() + self.request.registry.settings['wutta_permissions'] = all_perms + + def has_perm(perm): + if perm == 'widgets.list': + return True + return False + + with patch.object(self.request, 'has_perm', new=has_perm, create=True): + + # sanity check; current request has 1 perm + self.assertTrue(self.request.has_perm('widgets.list')) + self.assertFalse(self.request.has_perm('widgets.polish')) + self.assertFalse(self.request.has_perm('widgets.view')) + + # when editing, user sees only the 1 perm + with patch.object(view, 'editing', new=True): + perms = view.get_available_permissions() + self.assertEqual(list(perms), ['widgets']) + self.assertEqual(list(perms['widgets']['perms']), ['widgets.list']) + + # but when viewing, same user sees all perms + with patch.object(view, 'viewing', new=True): + perms = view.get_available_permissions() + self.assertEqual(list(perms), ['widgets']) + self.assertEqual(list(perms['widgets']['perms']), + ['widgets.list', 'widgets.polish', 'widgets.view']) + + # also, when admin user is editing, sees all perms + self.request.is_admin = True + with patch.object(view, 'editing', new=True): + perms = view.get_available_permissions() + self.assertEqual(list(perms), ['widgets']) + self.assertEqual(list(perms['widgets']['perms']), + ['widgets.list', 'widgets.polish', 'widgets.view']) diff --git a/tests/views/test_settings.py b/tests/views/test_settings.py new file mode 100644 index 00000000..b8523729 --- /dev/null +++ b/tests/views/test_settings.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8; -*- + +from tailbone.views import settings as mod +from tests.util import WebTestCase + + +class TestSettingView(WebTestCase): + + def test_includeme(self): + self.pyramid_config.include('tailbone.views.settings') diff --git a/tests/views/test_users.py b/tests/views/test_users.py new file mode 100644 index 00000000..4b94caf2 --- /dev/null +++ b/tests/views/test_users.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8; -*- + +from unittest.mock import patch, MagicMock + +from tailbone.views import users as mod +from tailbone.views.principal import PermissionsRenderer +from tests.util import WebTestCase + + +class TestUserView(WebTestCase): + + def make_view(self): + return mod.UserView(self.request) + + def test_includeme(self): + self.pyramid_config.include('tailbone.views.users') + + def test_configure_form(self): + self.pyramid_config.include('tailbone.views.users') + model = self.app.model + barney = model.User(username='barney') + self.session.add(barney) + self.session.commit() + view = self.make_view() + + # must use mock configure when making form + def configure(form): pass + form = view.make_form(instance=barney, configure=configure) + + with patch.object(view, 'viewing', new=True): + self.assertNotIn('permissions', form.renderers) + view.configure_form(form) + self.assertIsInstance(form.renderers['permissions'], PermissionsRenderer) diff --git a/tests/views/wutta/__init__.py b/tests/views/wutta/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/views/wutta/test_people.py b/tests/views/wutta/test_people.py new file mode 100644 index 00000000..31aeb501 --- /dev/null +++ b/tests/views/wutta/test_people.py @@ -0,0 +1,87 @@ +# -*- coding: utf-8; -*- + +from unittest.mock import patch + +from sqlalchemy import orm + +from tailbone.views.wutta import people as mod +from tests.util import WebTestCase + + +class TestPersonView(WebTestCase): + + def make_view(self): + return mod.PersonView(self.request) + + def test_includeme(self): + self.pyramid_config.include('tailbone.views.wutta.people') + + def test_get_query(self): + view = self.make_view() + + # sanity / coverage check + query = view.get_query(session=self.session) + self.assertIsInstance(query, orm.Query) + + def test_configure_grid(self): + model = self.app.model + barney = model.User(username='barney') + self.session.add(barney) + self.session.commit() + view = self.make_view() + + # sanity / coverage check + grid = view.make_grid(model_class=model.Person) + self.assertNotIn('first_name', grid.linked_columns) + view.configure_grid(grid) + self.assertIn('first_name', grid.linked_columns) + + def test_configure_form(self): + model = self.app.model + barney = model.Person(display_name="Barney Rubble") + self.session.add(barney) + self.session.commit() + view = self.make_view() + + # email field remains when viewing + with patch.object(view, 'viewing', new=True): + form = view.make_form(model_instance=barney, + fields=view.get_form_fields()) + self.assertIn('email', form.fields) + view.configure_form(form) + self.assertIn('email', form) + + # email field removed when editing + with patch.object(view, 'editing', new=True): + form = view.make_form(model_instance=barney, + fields=view.get_form_fields()) + self.assertIn('email', form.fields) + view.configure_form(form) + self.assertNotIn('email', form) + + def test_render_merge_requested(self): + model = self.app.model + barney = model.Person(display_name="Barney Rubble") + self.session.add(barney) + user = model.User(username='user') + self.session.add(user) + self.session.commit() + view = self.make_view() + + # null by default + html = view.render_merge_requested(barney, 'merge_requested', None, + session=self.session) + self.assertIsNone(html) + + # unless a merge request exists + barney2 = model.Person(display_name="Barney Rubble") + self.session.add(barney2) + self.session.commit() + mr = model.MergePeopleRequest(removing_uuid=barney2.uuid, + keeping_uuid=barney.uuid, + requested_by=user) + self.session.add(mr) + self.session.commit() + html = view.render_merge_requested(barney, 'merge_requested', None, + session=self.session) + self.assertIn('<span ', html) diff --git a/tox.ini b/tox.ini index 401b5e62..3896befb 100644 --- a/tox.ini +++ b/tox.ini @@ -1,32 +1,19 @@ [tox] -envlist = py27, py35, py37 +envlist = py38, py39, py310, py311 [testenv] -commands = - pip install --upgrade pip - pip install --upgrade setuptools wheel - pip install --upgrade --upgrade-strategy eager Tailbone[tests] rattail[auth,bouncer,db] rattail-tempmon - pytest {posargs} - -[testenv:py27] -commands = - pip install --upgrade pip - pip install --upgrade setuptools wheel - pip install --upgrade --upgrade-strategy eager Tailbone[tests] rattail[auth,bouncer,db] rattail-tempmon SQLAlchemy-Utils<0.36.7 SQLAlchemy-Continuum<1.3.12 - pytest {posargs} +deps = rattail-tempmon +extras = tests +commands = pytest {posargs} [testenv:coverage] basepython = python3 -commands = - pip install --upgrade pip - pip install --upgrade --upgrade-strategy eager Tailbone[tests] rattail[auth,bouncer,db] rattail-tempmon - pytest --cov=tailbone --cov-report=html +extras = tests +commands = pytest --cov=tailbone --cov-report=html [testenv:docs] basepython = python3 changedir = docs -commands = - pip install --upgrade pip - pip install --upgrade --upgrade-strategy eager Tailbone[docs] rattail[auth,bouncer,db] rattail-tempmon - sphinx-build -b html -d {envtmpdir}/doctrees -W -T . {envtmpdir}/docs +extras = docs +commands = sphinx-build -b html -d {envtmpdir}/doctrees -W -T . {envtmpdir}/docs