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/MANIFEST.in b/MANIFEST.in index 0114904a..a3d57f93 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -11,6 +11,8 @@ recursive-include tailbone/static *.jpg recursive-include tailbone/static *.gif recursive-include tailbone/static *.ico +recursive-include tailbone/static/files * + recursive-include tailbone/templates *.mako recursive-include tailbone/templates *.pt recursive-include tailbone/reports *.mako 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 72% rename from CHANGES.rst rename to docs/OLDCHANGES.rst index 50f39f37..0a802f40 100644 --- a/CHANGES.rst +++ b/docs/OLDCHANGES.rst @@ -2,6 +2,2175 @@ 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) +-------------------- + +* Fix bug when viewing certain receiving batches. + + +0.8.266 (2022-12-06) +-------------------- + +* Add simple template hook for "before object helpers". + +* Include email address for current API user info. + +* Add support for editing catalog cost in receiving batch, per new theme. + +* Add receiving workflow as param when making receiving batch. + +* Show invoice cost in receiving batch, if "from scratch". + +* Add support for editing invoice cost in receiving batch, per new theme. + +* Add helptext for "Admin-ish" field when editing Role. + + +0.8.265 (2022-12-01) +-------------------- + +* Add way to quickly re-run "any" report. + +* Avoid web config when launching overnight task. + + +0.8.264 (2022-11-28) +-------------------- + +* Add prompt dialog when launching overnight task. + +* Fix page title for datasync status. + +* Use newer config strategy for all views. + +* Auto-format phone number when saving for contact records. + + +0.8.263 (2022-11-21) +-------------------- + +* Update 'testing' watermark for dev background. + +* Let the Luigi handler take care of removing some DB settings. + + +0.8.262 (2022-11-20) +-------------------- + +* Add luigi module/class awareness for overnight tasks. + + +0.8.261 (2022-11-20) +-------------------- + +* Allow disabling, or per-day scheduling, of problem reports. + +* Fix how keys are stored for luigi overnight/backfill tasks. + + +0.8.260 (2022-11-18) +-------------------- + +* Turn on download results feature for Employees. + + +0.8.259 (2022-11-17) +-------------------- + +* Add "between" verb for numeric grid filters. + + +0.8.258 (2022-11-15) +-------------------- + +* Let the auth handler manage user merge. + + +0.8.257 (2022-11-03) +-------------------- + +* Add template method for rendering row grid component. + +* Use people handler to update address. + +* Fix start_date param for pricing batch upload. + +* Use shared logic for rendering percentage values. + +* Log a warning to troubleshoot luigi restart failure. + +* Show UPC for receiving line item if no product reference. + + +0.8.256 (2022-09-09) +-------------------- + +* Add basic per-item discount support for custorders. + +* Make past item lookup optional for custorders. + +* Do not convert date if already a date (for grid filters). + +* Avoid use of ``self.handler`` within batch API views. + + +0.8.255 (2022-09-06) +-------------------- + +* Include ``WorkOrder.estimated_total`` for API. + +* Add default normalize logic for API views. + +* Disable "Delete Results" button if no results, for row grid. + +* Move logic for "bulk-delete row objects" into MasterView. + +* Convert value for more date filters; only add condition if valid. + + +0.8.254 (2022-08-30) +-------------------- + +* Improve parsing of purchase order quantities. + +* Expose more attrs for new product batch rows. + + +0.8.253 (2022-08-30) +-------------------- + +* Convert value for date filter; only add condition if valid. + +* Add 'warning' flash messages to old jquery base template. + +* Add uom fields, configurable template for newproduct batch. + + +0.8.252 (2022-08-25) +-------------------- + +* Avoid error when no datasync profiles configured. + +* Add max lengths when editing person name via profile view. + + +0.8.251 (2022-08-24) +-------------------- + +* Fix index title for datasync configure page. + +* Add basic support for backfill Luigi tasks. + + +0.8.250 (2022-08-21) +-------------------- + +* Add ``render_person_profile()`` method to MasterView. + +* Add way to declare failure for an upgrade. + +* Add websockets progress, "multi-system" support for upgrades. + +* Add global context from handler, for email previews. + +* Allow configuring datasync watcher kwargs. + +* Expose, honor "admin-ish" flag for roles. + + +0.8.249 (2022-08-18) +-------------------- + +* Add brief delay before declaring websocket broken. + +* Add basic views for Luigi / overnight tasks. + +* Expose setting for auto-correct when receiving from invoice. + + +0.8.248 (2022-08-17) +-------------------- + +* Redirect to custom index URL when user cancels new custorder entry. + +* Add ``get_next_url_after_submit_new_order()`` for customer orders. + +* Add first experiment with websockets, for datasync status page. + +* Allow user feedback to request email reply back. + + +0.8.247 (2022-08-14) +-------------------- + +* Avoid double-quotes in field error messages JS code. + +* Add the FormPosterMixin to ProfileInfo component. + +* Fix default help URLs for ordering, receiving. + +* Move handheld batch view module to appropriate location. + +* Refactor usage of ``get_vendor()`` lookup. + +* Consolidate master API view logic. + + +0.8.246 (2022-08-12) +-------------------- + +* Couple of API tweaks for work orders. + +* Standardize merge logic when a handler is defined for it. + + +0.8.245 (2022-08-10) +-------------------- + +* Add convenience wrapper to make customer field widget, etc.. + +* Some API tweaks to support a byjove app. + +* Tweak flash msg, logging when batch population fails. + +* Log traceback output when batch action subprocess fails. + +* Add initial views for work orders. + +* Fix sequence of events re: grid component creation. + +* Allow download results for Customers grid. + + +0.8.244 (2022-08-08) +-------------------- + +* Add separate product grid filters for Category Code, Category Name. + + +0.8.243 (2022-08-08) +-------------------- + +* Add button to raise bogus error, for testing email alerts. + +* Make sure "configure" pages use AppHandler to save/delete settings. + +* Expose setting for sendmail failure alerts. + + +0.8.242 (2022-08-07) +-------------------- + +* Always show "all" email settings if user has config perm. + + +0.8.241 (2022-08-06) +-------------------- + +* Add support for toggling visibility of email profile settings. + + +0.8.240 (2022-08-05) +-------------------- + +* Clean up URL routes for row CRUD. + + +0.8.239 (2022-08-04) +-------------------- + +* Invalidate config cache when raw setting is deleted. + + +0.8.238 (2022-08-03) +-------------------- + +* Improve "touch" logic for employees. + +* Stop using the old ``rattail.db.api.settings`` module. + +* Force cache invalidation when Raw Setting is edited. + + +0.8.237 (2022-07-27) +-------------------- + +* Add some more views to potentially include via poser. + +* Misc. improvements for desktop receiving views. + + +0.8.236 (2022-07-25) +-------------------- + +* Add setting to expose/hide "active in POS" customer flag. + +* Allow optional row grid title for master view. + +* Add basic/minimal merge support for customers. + +* Assume default vendor for new receiving batch. + +* Add basic edit support for Purchases. + +* Add ``iter(Form)`` logic, to loop through fields. + +* Add "auto-receive all items" support for receiving batch API. + + +0.8.235 (2022-07-22) +-------------------- + +* Split out rendering of ``this-page`` component in falafel theme. + +* Allow download of results for common product-related tables. + +* Make caching products optional, when creating vendor catalog batch. + +* Expose the ``complete`` flag for pricing batch. + +* Add ``template_kwargs_clone()`` stub for master view. + +* Misc deform template improvements. + + +0.8.234 (2022-07-18) +-------------------- + +* Fix form validation for app settings page w/ buefy theme. + +* Honor default pagesize for all grids, per setting. + +* Add basic "download results" for Subdepartments grid. + +* Add new-style config defaults for BrandView. + + +0.8.233 (2022-06-24) +-------------------- + +* Add minimal buefy support for 'percentinput' field widget. + +* Add autocomplete support for subdepartments. + + +0.8.232 (2022-06-14) +-------------------- + +* Let default grid page size correspond to first option. + +* Add start date support for "future" pricing batch. + + +0.8.231 (2022-05-15) +-------------------- + +* Expose config for identifying supported vendors. + +* Allow restricting to supported vendors only, for Receiving. + + +0.8.230 (2022-05-10) +-------------------- + +* Sort roles list when viewing a user. + +* Add grid workarounds when data is list instead of query. + + +0.8.229 (2022-05-03) +-------------------- + +* Tweak how family data is displayed. + + +0.8.228 (2022-04-13) +-------------------- + +* Fix quotes for field helptext. + +* Flush early when populating batch, to ensure error is shown. + + +0.8.227 (2022-04-04) +-------------------- + +* Add touch for report codes. + +* Raise 404 if report not found. + +* Add template kwargs stub for ``view_row()``. + +* Log error when failing to submit new custorder batch. + +* Honor case vs. unit restrictions for new custorder. + +* Tweak where description field is shown for receiving batch. + +* Fix "touch" url for non-standard record types. + + +0.8.226 (2022-03-29) +-------------------- + +* Let errors raise when showing poser reports. + + +0.8.225 (2022-03-29) +-------------------- + +* Force session flush within try/catch, for batch refresh. + + +0.8.224 (2022-03-25) +-------------------- + +* Improve vendor validation for new receiving batch. + +* Use common logic for fetching batch handler. + + +0.8.223 (2022-03-21) +-------------------- + +* Show link to txn as field when viewing trainwreck item. + + +0.8.222 (2022-03-17) +-------------------- + +* Expose custorder xref markers for trainwreck. + + +0.8.221 (2022-03-16) +-------------------- + +* Always show batch params by default when viewing. + +* Show helptext when applicable for "new batch from product query". + +* Make problem report titles searchable in grid. + + +0.8.220 (2022-03-15) +-------------------- + +* Log error instead of warning, when batch population fails. + +* Add default help link for Receiving feature. + + +0.8.219 (2022-03-10) +-------------------- + +* Cleanup grid filters for vendor catalog batches. + +* Cleanup view config syntax for vendor catalog batch. + +* Add workaround when inserting new fields to form field list. + +* Add ``Form.insert()`` method, to insert field based on index. + +* Default behavior for report chooser should *not* be form/dropdown. + + +0.8.218 (2022-03-08) +-------------------- + +* Log warning/traceback when failing to include a configured view. + +* Fix gotcha when defining new provider views. + +* Bump the default Buefy version to 0.8.13. + + +0.8.217 (2022-03-07) +-------------------- + +* Add the "provider" concept, let them configure db sessions. + +* Let providers add extra views, options for includes config. + +* Let tailbone providers include static views. + +* Link to email settings profile when viewing email attempt. + + +0.8.216 (2022-03-05) +-------------------- + +* Show list of generated reports when viewing Poser Report. + +* Show link back to Poser Report when viewing Generated Report. + +* Always include ``app_title`` in global template rendering context. + +* Update some more view config syntax. + +* Make common web view a bit more common. + +* Improve the Poser Setup page; allow poser dir refresh. + +* Add initial/basic support for configuring "included views". + +* Add ``tailbone.views.essentials`` to include common / "core" views. + +* Add flash message when upgrade execution completes (pass or fail). + + +0.8.215 (2022-03-02) +-------------------- + +* Show toast msg instead of alert after sending feedback. + +* Add basic support for Poser reports, list/create. + + +0.8.214 (2022-03-01) +-------------------- + +* Params should be readonly when editing batch. + +* Tweak styles for links in object helper panel. + + +0.8.213 (2022-03-01) +-------------------- + +* Add simple searchable column support for non-AJAX grids. + +* Fix stdout/stderr fields for upgrade view. + +* Pass query along for download results, so subclass can modify. + +* Avoid making discounts data if missing field, for trainwreck item view. + + +0.8.212 (2022-02-26) +-------------------- + +* Add page/way to configure main menus. + + +0.8.211 (2022-02-25) +-------------------- + +* Add view template stub for trainwreck transaction. + +* Add auto-filter hyperlinks for batch row status breakdown. + +* Auto-filter hyperlinks for PO vs. invoice breakdown in Receiving. + +* Add grid hyperlinks for trainwreck transaction line items. + +* Use dict instead of custom object to represent menus. + +* Expose "discount type" for Trainwreck line items. + + +0.8.210 (2022-02-20) +-------------------- + +* Only show DB picker for permissioned users. + +* Expose some new trainwreck fields; per-item discounts. + +* Show SRP as currency for vendor catalog batch. + + +0.8.209 (2022-02-16) +-------------------- + +* Fix progress bar when running problem report. + + +0.8.208 (2022-02-15) +-------------------- + +* Allow override of navbar-end element in falafel theme header. + +* Add initial support for editing user preferences. + +* Add FormPosterMixin to WholePage class. + + +0.8.207 (2022-02-13) +-------------------- + +* Try out new config defaults function for some views (user, customer). + +* Add highlight for non-active users, customers in grid. + +* Prevent cache for index pages by default, unless configured not to. + +* Cleanup labels for Vendor/Code "preferred" vs. "any" in products grid. + +* Add config for showing ordered vs. shipped amounts when receiving. + +* Tweak how "duration" fields are rendered for grids, forms. + +* New upgrades should be enabled by default. + + +0.8.206 (2022-02-08) +-------------------- + +* Add "full lookup" product search modal for new custorder page. + + +0.8.205 (2022-02-05) +-------------------- + +* Tweak how product key field is handled for product views. + +* Add some autocomplete workarounds for new vendor catalog batch. + + +0.8.204 (2022-02-04) +-------------------- + +* Add ``CustomerGroupAssignment`` to customer version history. + + +0.8.203 (2022-02-01) +-------------------- + +* Expose batch params for vendor catalogs. + + +0.8.202 (2022-01-31) +-------------------- + +* Make "generate report" the same as "create new generated report". + + +0.8.201 (2022-01-31) +-------------------- + +* Show helptext for params when generating new report. + +* Tweak handling of empty params when generating report. + + +0.8.200 (2022-01-31) +-------------------- + +* Improve profile link helper for buefy themes. + +* Add project generator support for rattail-integration, tailbone-integration. + + +0.8.199 (2022-01-26) +-------------------- + +* Tweak the "auto-receive all" tool for Chrome browser. + + +0.8.198 (2022-01-25) +-------------------- + +* Only expose "product" departments within product view dropdowns. + + +0.8.197 (2022-01-19) +-------------------- + +* Use buefy input for quickie search. + + +0.8.196 (2022-01-15) +-------------------- + +* Use the new label handler. + + +0.8.195 (2022-01-13) +-------------------- + +* Strip whitespace for new customer fields, in new custorder page. + + +0.8.194 (2022-01-12) +-------------------- + +* Include all static files in manifest. + +* Update usage of ``app.get_email_handler()`` to avoid warnings. + + +0.8.193 (2022-01-10) +-------------------- + +* Add buefy support for quick-printing product labels; also speed bump. + +* Add way to set form-wide schema validator. + +* Add progress support when deleting a batch. + +* Expose the Sale, TPR, Current price fields for label batch. + + +0.8.192 (2022-01-08) +-------------------- + +* Add configurable template file for vendor catalog batch. + +* Some aesthetic improvements for vendor catalog batch. + +* Several disparate changes needed for vendor catalog improvements. + +* Expose, honor "allow future" setting for vendor catalog batch. + +* Add config for supported vendor catalog parsers. + +* Update some method calls to avoid deprecation warnings. + + +0.8.191 (2022-01-03) +-------------------- + +* Fix permission check for input file template links. + +* Remove usage of ``app.get_designated_import_handler()``. + +* Add basic configure page for Trainwreck. + +* Use ``AuthHandler.get_permissions()``. + + +0.8.190 (2021-12-29) +-------------------- + +* Show create button on "most" pages for a master view. + +* Expose products setting for type 2 UPC lookup. + +* Add basic "resolve" support for person, product from new custorder. + + +0.8.189 (2021-12-23) +-------------------- + +* Add basic "pending product" support for new custorder batch. + +* Improve email bounce view per buefy theme. + + +0.8.188 (2021-12-20) +-------------------- + +* Flag discontinued items for main Products grid. + + +0.8.187 (2021-12-20) +-------------------- + +* Add common configuration logic for "input file templates". + +* Add some standard CRUD buttons for buefy themes. + + +0.8.186 (2021-12-17) +-------------------- + +* Render "pretty" UPC by default, for batch row form fields. + +* Let config decide which versions of vue.js and buefy to use. + + +0.8.185 (2021-12-15) +-------------------- + +* Allow for null price when showing price history. + +* Overhaul desktop views for receiving, for efficiency. + +* Add some basic "config" views, to obviate some App Settings. + +* Add "jump to" chooser in App Settings, for various "configure" pages. + +* Fix params field when deleting a report. + +* Add some smarts when making batch execution form schema. + + +0.8.184 (2021-12-09) +-------------------- + +* Refactor "receive row" and "declare credit" tools per buefy theme. + +* Allow "auto-receive all items" batch feature in production. + +* Make "view row" prettier for receiving batch, for buefy themes. + +* Add buttons to edit, confirm cost for receiving batch row view. + + +0.8.183 (2021-12-08) +-------------------- + +* Add basic views to expose Problem Reports, and run them. + +* Only include ``--runas`` arg if we have a value, for import jobs. + +* Assume default receiving workflow if there is only one. + +* Fix bug when report has no params dict. + + +0.8.182 (2021-12-07) +-------------------- + +* Fix form ref bug, for batch execution. + + +0.8.181 (2021-12-07) +-------------------- + +* Bugfix. + + +0.8.180 (2021-12-07) +-------------------- + +* Add basic import/export handler views, tool to run jobs. + +* Overhaul import handler config etc.: + * add ``MasterView.configurable`` concept, ``/configure.mako`` template + * add new master view for DataSync Threads (needs content) + * tweak view config for DataSync Changes accordingly + * update the Configure DataSync page per ``configurable`` concept + * add new Configure Import/Export page, per ``configurable`` + * add basic views for Raw Permissions + +* Honor "safe for web app" flags for import/export handlers. + +* When viewing report output, show params as proper buefy table. + + +0.8.179 (2021-12-03) +-------------------- + +* Expose the Sale Price and TPR Price for product views. + + +0.8.178 (2021-11-29) +-------------------- + +* Add page for configuring datasync. + + +0.8.177 (2021-11-28) +-------------------- + +* Show current/sale pricing for products in new custorder page. + +* Add simple search filters for past items dialog in new custorder. + + +0.8.176 (2021-11-25) +-------------------- + +* Add basic support for receiving from PO with invoice. + +* Don't use multi-select for new report in buefy themes. + + +0.8.175 (2021-11-17) +-------------------- + +* Fix bug when product has empty suggested price. + +* Show ordered quantity when viewing costing batch row. + + +0.8.174 (2021-11-14) +-------------------- + +* Expose the "sync users" flag for Roles. + + +0.8.173 (2021-11-11) +-------------------- + +* Improve error handling when executing a custorder batch. + +* Fix "download results" support for Products. + + +0.8.172 (2021-11-11) +-------------------- + +* Add permission for viewing "all" employees. + + +0.8.171 (2021-11-11) +-------------------- + +* Add "true margin" to products XLSX export. + +* Add initial ``VersionMasterView`` base class. + +* Add views for ``PendingProduct`` model; also ``DepartmentWidget``. + + +0.8.170 (2021-11-09) +-------------------- + +* Fix dynamic content title for "view profile" page. + + +0.8.169 (2021-11-08) +-------------------- + +* Use products handler to get image URL. + +* Show some more product attributes in custorder item selection popup. + +* Auto-select Quantity tab when editing item for new custorder. + +* Let user "add past product" when making new custorder. + +* Let handler restrict available invoice parser options. + +* Cleanup grid columns for receiving batches. + +* Fall back to empty string for product regular price. + + +0.8.168 (2021-11-05) +-------------------- + +* Make separate method for writing results XLSX file. + +* Add ``render_brand()`` method for MasterView. + +* Add link to download generic template for vendor catalog batch. + + +0.8.167 (2021-11-04) +-------------------- + +* Try to prevent caching for any /index (grid) page. + +* Fix product view page when user cannot view version history. + +* Move some custorder logic to handler; allow force-swap of product selection. + +* Honor the "product price may be questionable" flag for new custorder. + +* Show unit price in line items grid for new custorder. + +* Avoid exposing batch params when creating a batch. + + +0.8.166 (2021-11-03) +-------------------- + +* Fix the Department filter for Products grid, for jquery themes. + + +0.8.165 (2021-11-02) +-------------------- + +* Optionally set the ``sticky-header`` attribute for main buefy grids. + +* Show case qty by default for costing batch rows. + +* Highlight the "did not receive" rows for purchase batch. + +* Improve validation for Person field of User form. + +* Omit "edit" link unless user has perm, for Customer "people" subgrid. + +* Highlight "cannot calculate price" rows for new product batch. + + +0.8.164 (2021-10-20) +-------------------- + +* Give custorder batch handler a couple ways to affect adding new items. + +* Refactor to leverage all existing methods of auth handler. + +* Overhaul the autocomplete component, for sake of new custorder. + +* Improve "refresh contact", show new fields in green for custorder. + +* Invoke handler when adding new item to custorder batch. + +* Add basic "price needs confirmation" support for custorder. + +* Clean up the product selection UI for new custorder. + + +0.8.163 (2021-10-14) +-------------------- + +* Misc. tweaks for users, roles. + + 0.8.162 (2021-10-14) -------------------- @@ -2823,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) @@ -3156,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 @@ -3170,7 +5339,7 @@ and related technologies. 0.6.11 (2017-07-18) ------------------- +------------------- * Tweak some basic styles for forms/grids @@ -3178,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/grids.core.rst b/docs/api/grids.core.rst new file mode 100644 index 00000000..60155cb2 --- /dev/null +++ b/docs/api/grids.core.rst @@ -0,0 +1,6 @@ + +``tailbone.grids.core`` +======================= + +.. automodule:: tailbone.grids.core + :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 ffa516e9..d964086f 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -44,18 +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 e24e3f98..00000000 --- a/setup.py +++ /dev/null @@ -1,185 +0,0 @@ -# -*- coding: utf-8; -*- -################################################################################ -# -# Rattail -- Retail Software Framework -# Copyright © 2010-2021 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 - - '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 - - 'Sphinx', # 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 - ], -} - - -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 8acdddff..7095f6c8 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,9 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.162' +try: + from importlib.metadata import version +except ImportError: + from importlib_metadata import version + + +__version__ = version('Tailbone') diff --git a/tailbone/api/__init__.py b/tailbone/api/__init__.py index 0b669b6c..1fae059f 100644 --- a/tailbone/api/__init__.py +++ b/tailbone/api/__init__.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2020 Lance Edgar +# Copyright © 2010-2022 Lance Edgar # # This file is part of Rattail. # @@ -28,6 +28,7 @@ from __future__ import unicode_literals, absolute_import from .core import APIView, api from .master import APIMasterView, SortColumn +# TODO: remove this from .master2 import APIMasterView2 diff --git a/tailbone/api/auth.py b/tailbone/api/auth.py index 16e48e82..a710e30d 100644 --- a/tailbone/api/auth.py +++ b/tailbone/api/auth.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 Web API - Auth Views """ -from __future__ import unicode_literals, absolute_import - -from rattail.db.auth import authenticate_user, set_user_password, cache_permissions - 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: @@ -57,6 +52,16 @@ class AuthenticationView(APIView): data['background_color'] = self.rattail_config.get( 'tailbone', 'background_color') + # TODO: this seems the best place to return some global app + # settings, but maybe not desirable in all cases..in which + # case should caller need to ask for these explicitly? or + # make a different call altogether to get them..? + app = self.get_rattail_app() + customer_handler = app.get_clientele_handler() + data['settings'] = { + 'customer_field_dropdown': customer_handler.choice_uses_dropdown(), + } + return data @api @@ -82,15 +87,20 @@ class AuthenticationView(APIView): if error: return {'error': error} + app = self.get_rattail_app() + auth = app.get_auth_handler() + login_user(self.request, user) return { 'ok': True, 'user': self.get_user_info(user), - 'permissions': list(cache_permissions(Session(), user)), + 'permissions': list(auth.get_permissions(Session(), user)), } def authenticate_user(self, username, password): - return authenticate_user(Session(), username, password) + app = self.get_rattail_app() + auth = app.get_auth_handler() + return auth.authenticate_user(Session(), username, password) def why_cant_user_login(self, user): """ @@ -153,14 +163,18 @@ 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 - if not authenticate_user(Session(), self.request.user, data['current_password']): + if not self.authenticate_user(self.request.user, data['current_password']): return {'error': "The current/old password you provided is incorrect"} # okay then, set new password - 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), @@ -204,5 +218,12 @@ class AuthenticationView(APIView): config.add_cornice_service(change_password) -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + AuthenticationView = kwargs.get('AuthenticationView', base['AuthenticationView']) AuthenticationView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/api/batch/core.py b/tailbone/api/batch/core.py index a2f44596..f7bc9333 100644 --- a/tailbone/api/batch/core.py +++ b/tailbone/api/batch/core.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,18 +24,12 @@ Tailbone Web API - Batch Views """ -from __future__ import unicode_literals, absolute_import - import logging +import warnings -import six +from cornice import Service -from rattail.time import localtime -from rattail.util import load_object - -from cornice import resource, Service - -from tailbone.api import APIMasterView2 as APIMasterView +from tailbone.api import APIMasterView log = logging.getLogger(__name__) @@ -70,10 +64,9 @@ class APIBatchMixin(object): table name, although technically it is whatever value returns from the ``batch_key`` attribute of the main batch model class. """ + app = self.get_rattail_app() key = self.get_batch_class().batch_key - spec = self.rattail_config.get('rattail.batch', '{}.handler'.format(key), - default=self.default_handler_spec) - return load_object(spec)(self.rattail_config) + return app.get_batch_handler(key, default=self.default_handler_spec) class APIBatchView(APIBatchMixin, APIMasterView): @@ -86,38 +79,45 @@ class APIBatchView(APIBatchMixin, APIMasterView): def __init__(self, request, **kwargs): super(APIBatchView, self).__init__(request, **kwargs) - self.handler = self.get_handler() + self.batch_handler = self.get_handler() + + @property + def handler(self): + warnings.warn("the `handler` property is deprecated; " + "please use `batch_handler` instead", + DeprecationWarning, stacklevel=2) + return self.batch_handler def normalize(self, batch): - - created = localtime(self.rattail_config, batch.created, from_utc=True) + app = self.get_rattail_app() + created = app.localtime(batch.created, from_utc=True) executed = None if batch.executed: - executed = localtime(self.rattail_config, batch.executed, from_utc=True) + executed = app.localtime(batch.executed, from_utc=True) 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 ''), - 'mutable': self.handler.is_mutable(batch), + 'executed_by_display': str(batch.executed_by or ''), + 'mutable': self.batch_handler.is_mutable(batch), } def create_object(self, data): @@ -130,9 +130,9 @@ class APIBatchView(APIBatchMixin, APIMasterView): user = self.request.user kwargs = dict(data) kwargs['user'] = user - batch = self.handler.make_batch(self.Session(), **kwargs) - if self.handler.should_populate(batch): - self.handler.do_populate(batch, user) + batch = self.batch_handler.make_batch(self.Session(), **kwargs) + if self.batch_handler.should_populate(batch): + self.batch_handler.do_populate(batch, user) return batch def update_object(self, batch, data): @@ -200,7 +200,7 @@ class APIBatchView(APIBatchMixin, APIMasterView): kwargs = dict(self.request.json_body) kwargs.pop('user', None) kwargs.pop('progress', None) - result = self.handler.do_execute(batch, self.request.user, **kwargs) + result = self.batch_handler.do_execute(batch, self.request.user, **kwargs) return {'ok': bool(result), 'batch': self.normalize(batch)} @classmethod @@ -254,14 +254,21 @@ class APIBatchRowView(APIBatchMixin, APIMasterView): def __init__(self, request, **kwargs): super(APIBatchRowView, self).__init__(request, **kwargs) - self.handler = self.get_handler() + self.batch_handler = self.get_handler() + + @property + def handler(self): + warnings.warn("the `handler` property is deprecated; " + "please use `batch_handler` instead", + DeprecationWarning, stacklevel=2) + return self.batch_handler def normalize(self, row): batch = row.batch return { 'uuid': row.uuid, - '_str': 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, @@ -269,10 +276,10 @@ class APIBatchRowView(APIBatchMixin, APIMasterView): 'batch_description': batch.description, 'batch_complete': batch.complete, 'batch_executed': bool(batch.executed), - 'batch_mutable': self.handler.is_mutable(batch), + '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): @@ -282,14 +289,14 @@ class APIBatchRowView(APIBatchMixin, APIMasterView): Invokes the batch handler's ``refresh_row()`` method after updating the row's field data per usual. """ - if not self.handler.is_mutable(row.batch): + if not self.batch_handler.is_mutable(row.batch): return {'error': "Batch is not mutable"} # update row per usual row = super(APIBatchRowView, self).update_object(row, data) # okay now we apply handler refresh logic - self.handler.refresh_row(row) + self.batch_handler.refresh_row(row) return row def delete_object(self, row): @@ -298,7 +305,7 @@ class APIBatchRowView(APIBatchMixin, APIMasterView): Delegates deletion of the row to the batch handler. """ - self.handler.do_remove_row(row) + self.batch_handler.do_remove_row(row) def quick_entry(self): """ @@ -307,23 +314,26 @@ 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() entry = data['quick_entry'] try: - row = self.handler.quick_entry(self.Session(), batch, entry) + row = self.batch_handler.quick_entry(self.Session(), batch, entry) except Exception as error: log.warning("quick entry failed for '%s' batch %s: %s", - self.handler.batch_key, batch.id_str, entry, + 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 @@ -339,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 a798c58e..22b67e54 100644 --- a/tailbone/api/batch/inventory.py +++ b/tailbone/api/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,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 @@ -67,9 +64,9 @@ class InventoryBatchViews(APIBatchView): """ permission_prefix = self.get_permission_prefix() if self.request.is_root: - modes = self.handler.get_count_modes() + modes = self.batch_handler.get_count_modes() else: - modes = self.handler.get_allowed_count_modes( + modes = self.batch_handler.get_allowed_count_modes( self.Session(), self.request.user, permission_prefix=permission_prefix) return modes @@ -79,7 +76,7 @@ class InventoryBatchViews(APIBatchView): Retrieve info about the available "reasons" for inventory adjustment batches. """ - raw_reasons = self.handler.get_adjustment_reasons(self.Session()) + raw_reasons = self.batch_handler.get_adjustment_reasons(self.Session()) reasons = [] for reason in raw_reasons: reasons.append({ @@ -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,26 +127,27 @@ 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.handler.allow_cases(batch) + data['allow_cases'] = self.batch_handler.allow_cases(batch) return data @@ -174,10 +172,29 @@ 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 -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + InventoryBatchViews = kwargs.get('InventoryBatchViews', base['InventoryBatchViews']) InventoryBatchViews.defaults(config) + + InventoryBatchRowViews = kwargs.get('InventoryBatchRowViews', base['InventoryBatchRowViews']) InventoryBatchRowViews.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/api/batch/labels.py b/tailbone/api/batch/labels.py index 11a3d20d..4f154b21 100644 --- a/tailbone/api/batch/labels.py +++ b/tailbone/api/batch/labels.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,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 @@ -68,6 +64,15 @@ class LabelBatchRowViews(APIBatchRowView): return data -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + LabelBatchViews = kwargs.get('LabelBatchViews', base['LabelBatchViews']) LabelBatchViews.defaults(config) + + LabelBatchRowViews = kwargs.get('LabelBatchRowViews', base['LabelBatchRowViews']) LabelBatchRowViews.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/api/batch/ordering.py b/tailbone/api/batch/ordering.py index 21de8da0..204be8ad 100644 --- a/tailbone/api/batch/ordering.py +++ b/tailbone/api/batch/ordering.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. # @@ -27,22 +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.core import Object -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' @@ -58,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 @@ -83,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): @@ -95,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? @@ -105,10 +112,10 @@ class OrderingBatchViews(APIBatchView): # organize vendor catalog costs by dept / subdept departments = {} - costs = self.handler.get_order_form_costs(self.Session(), batch.vendor) - costs = self.handler.sort_order_form_costs(costs) + costs = self.batch_handler.get_order_form_costs(self.Session(), batch.vendor) + costs = self.batch_handler.sort_order_form_costs(costs) costs = list(costs) # we must have a stable list for the rest of this - self.handler.decorate_order_form_costs(batch, costs) + self.batch_handler.decorate_order_form_costs(batch, costs) for cost in costs: department = cost.product.department @@ -149,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, @@ -170,16 +177,28 @@ 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) # fetch recent purchase history, sort/pad for template convenience - history = self.handler.get_order_form_history(batch, costs, 6) + history = self.batch_handler.get_order_form_history(batch, costs, 6) for i in range(6 - len(history)): history.append(None) history = list(reversed(history)) + # must convert some date objects to string, for JSON sake + for h in history: + if not h: + continue + purchase = h.get('purchase') + if purchase: + dt = purchase.get('date_ordered') + if dt and isinstance(dt, datetime.date): + purchase['date_ordered'] = app.render_date(dt) + dt = purchase.get('date_received') + if dt and isinstance(dt, datetime.date): + purchase['date_received'] = app.render_date(dt) return { 'batch': self.normalize(batch), @@ -210,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' @@ -220,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 @@ -241,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 @@ -267,13 +287,32 @@ class OrderingBatchRowViews(APIBatchRowView): Note that the "normal" logic for this method is not invoked at all. """ - if not self.handler.is_mutable(row.batch): + if not self.batch_handler.is_mutable(row.batch): return {'error': "Batch is not mutable"} - self.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 -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + OrderingBatchViews = kwargs.get('OrderingBatchViews', base['OrderingBatchViews']) OrderingBatchViews.defaults(config) + + OrderingBatchRowViews = kwargs.get('OrderingBatchRowViews', base['OrderingBatchRowViews']) OrderingBatchRowViews.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/api/batch/receiving.py b/tailbone/api/batch/receiving.py index 37bb00b5..b23bff55 100644 --- a/tailbone/api/batch/receiving.py +++ b/tailbone/api/batch/receiving.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,17 +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.time import make_utc -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 @@ -47,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' @@ -57,30 +54,49 @@ 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 + data['can_auto_receive'] = self.batch_handler.can_auto_receive(batch) + return data def create_object(self, data): data = dict(data) + + # all about receiving mode here data['mode'] = self.enum.PURCHASE_BATCH_MODE_RECEIVING - 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): + """ + View which handles auto-marking as received, all items within + a pending batch. + """ + batch = self.get_object() + self.batch_handler.auto_receive_all_items(batch) + return self._get(obj=batch) def mark_receiving_complete(self): """ @@ -104,12 +120,13 @@ 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"} - purchases = self.handler.get_eligible_purchases( + purchases = self.batch_handler.get_eligible_purchases( vendor, self.enum.PURCHASE_BATCH_MODE_RECEIVING) purchases = [self.normalize_eligible_purchase(p) @@ -118,14 +135,10 @@ class ReceivingBatchViews(APIBatchView): return {'purchases': purchases} def normalize_eligible_purchase(self, purchase): - return { - 'key': purchase.uuid, - 'department_uuid': purchase.department_uuid, - 'display': self.render_eligible_purchase(purchase), - } + return self.batch_handler.normalize_eligible_purchase(purchase) def render_eligible_purchase(self, purchase): - return self.handler.render_eligible_purchase(purchase) + return self.batch_handler.render_eligible_purchase(purchase) @classmethod def defaults(cls, config): @@ -140,23 +153,31 @@ class ReceivingBatchViews(APIBatchView): collection_url_prefix = cls.get_collection_url_prefix() object_url_prefix = cls.get_object_url_prefix() - # 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') + # auto_receive + auto_receive = Service(name='{}.auto_receive'.format(route_prefix), + path='{}/{{uuid}}/auto-receive'.format(object_url_prefix)) + auto_receive.add_view('GET', 'auto_receive', klass=cls, + permission='{}.auto_receive'.format(permission_prefix)) + config.add_cornice_service(auto_receive) + + # mark_receiving_complete + mark_receiving_complete = Service(name='{}.mark_receiving_complete'.format(route_prefix), + path='{}/{{uuid}}/mark-receiving-complete'.format(object_url_prefix)) + mark_receiving_complete.add_view('POST', 'mark_receiving_complete', klass=cls, + permission='{}.edit'.format(permission_prefix)) + config.add_cornice_service(mark_receiving_complete) # eligible purchases - 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' @@ -165,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 @@ -261,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 @@ -307,14 +338,22 @@ 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 + data['po_unit_cost'] = row.po_unit_cost data['po_total'] = row.po_total + data['invoice_number'] = row.invoice_number data['invoice_unit_cost'] = row.invoice_unit_cost data['invoice_total'] = row.invoice_total data['invoice_total_calculated'] = row.invoice_total_calculated - data['allow_cases'] = self.handler.allow_cases() + data['allow_cases'] = self.batch_handler.allow_cases() data['quick_receive'] = self.rattail_config.getbool( 'rattail.batch', 'purchase.mobile_quick_receive', @@ -332,13 +371,13 @@ class ReceivingBatchRowViews(APIBatchRowView): raise NotImplementedError("TODO: add CS support for quick_receive_all") else: data['quick_receive_uom'] = data['unit_uom'] - accounted_for = self.handler.get_units_accounted_for(row) - remainder = self.handler.get_units_ordered(row) - accounted_for + accounted_for = self.batch_handler.get_units_accounted_for(row) + remainder = self.batch_handler.get_units_ordered(row) - accounted_for if accounted_for: # some product accounted for; button should receive "remainder" only if remainder: - remainder = 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']) @@ -349,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']) @@ -375,9 +414,9 @@ class ReceivingBatchRowViews(APIBatchRowView): default=False) if alert_received: data['received_alert'] = None - if self.handler.get_units_confirmed(row): + if self.batch_handler.get_units_confirmed(row): msg = "You have already received some of this product; last update was {}.".format( - humanize.naturaltime(make_utc() - row.modified)) + humanize.naturaltime(self.app.make_utc() - row.modified)) data['received_alert'] = msg return data @@ -386,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.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 @@ -422,13 +471,22 @@ 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): + base = globals() + + ReceivingBatchViews = kwargs.get('ReceivingBatchViews', base['ReceivingBatchViews']) + ReceivingBatchViews.defaults(config) + + ReceivingBatchRowViews = kwargs.get('ReceivingBatchRowViews', base['ReceivingBatchRowViews']) + ReceivingBatchRowViews.defaults(config) def includeme(config): - ReceivingBatchViews.defaults(config) - ReceivingBatchRowViews.defaults(config) + defaults(config) diff --git a/tailbone/api/common.py b/tailbone/api/common.py index 81458c01..6cacfb06 100644 --- a/tailbone/api/common.py +++ b/tailbone/api/common.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,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,21 @@ 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() + + CommonView = kwargs.get('CommonView', base['CommonView']) + CommonView.defaults(config) + def includeme(config): - CommonView.defaults(config) + defaults(config) diff --git a/tailbone/api/core.py b/tailbone/api/core.py index 65aa9699..0d8eec32 100644 --- a/tailbone/api/core.py +++ b/tailbone/api/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,10 +24,6 @@ Tailbone Web API - Core Views """ -from __future__ import unicode_literals, absolute_import - -from rattail.util import load_object - from tailbone.views import View @@ -102,24 +98,28 @@ class APIView(View): info.pop('short_name', None) return info """ + app = self.get_rattail_app() + 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': app.get_contact_email_address(user), } # maybe get/use "extra" info extra = self.rattail_config.get('tailbone.api', 'extra_user_info', usedb=False) if extra: - extra = load_object(extra) + extra = app.load_object(extra) info = extra(self.request, user, **info) return info diff --git a/tailbone/api/customers.py b/tailbone/api/customers.py index fdd2c18e..85d28c24 100644 --- a/tailbone/api/customers.py +++ b/tailbone/api/customers.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,13 +24,9 @@ Tailbone Web API - Customer Views """ -from __future__ import unicode_literals, absolute_import - -import six - from rattail.db import model -from tailbone.api import APIMasterView2 as APIMasterView +from tailbone.api import APIMasterView class CustomerView(APIMasterView): @@ -40,16 +36,25 @@ class CustomerView(APIMasterView): model_class = model.Customer collection_url_prefix = '/customers' object_url_prefix = '/customer' + supports_autocomplete = True + autocomplete_fieldname = 'name' def normalize(self, customer): return { 'uuid': customer.uuid, - '_str': six.text_type(customer), + '_str': str(customer), 'id': customer.id, 'number': customer.number, 'name': customer.name, } -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + CustomerView = kwargs.get('CustomerView', base['CustomerView']) CustomerView.defaults(config) + + +def includeme(config): + defaults(config) 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 27030f5b..551d6428 100644 --- a/tailbone/api/master.py +++ b/tailbone/api/master.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,28 +24,29 @@ 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 tailbone.api import APIView, api +from cornice import resource, Service + +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): """ Base class for data model REST API views. """ + listable = True + creatable = True + viewable = True + editable = True + deletable = True + supports_autocomplete = False + supports_download = False + supports_rawbytes = False @property def Session(self): @@ -120,6 +121,34 @@ class APIMasterView(APIView): return cls.collection_key return '{}s'.format(cls.get_object_key()) + @classmethod + def establish_method(cls, method_name): + """ + Establish the given HTTP method for this Cornice Resource. + + Cornice will auto-register any class methods for a resource, if they + are named according to what it expects (i.e. 'get', 'collection_get' + etc.). Tailbone API tries to make things automagical for the sake of + e.g. Poser logic, but in this case if we predefine all of these methods + and then some subclass view wants to *not* allow one, it's not clear + how to "undefine" it per se. Or at least, the more straightforward + thing (I think) is to not define such a method in the first place, if + it was not wanted. + + Enter ``establish_method()``, which is what finally "defines" each + resource method according to what the subclass has declared via its + various attributes (:attr:`creatable`, :attr:`deletable` etc.). + + Note that you will not likely have any need to use this + ``establish_method()`` yourself! But we describe its purpose here, for + clarity. + """ + def method(self): + internal_method = getattr(self, '_{}'.format(method_name)) + return internal_method() + + setattr(cls, method_name, method) + def make_filter_spec(self): if not self.request.GET.has_key('filters'): return [] @@ -155,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 @@ -177,17 +206,14 @@ class APIMasterView(APIView): """ return self.sortcol(order_by) - def sortcol(self, *args): + def sortcol(self, field_name, model_name=None): """ Return a simple ``SortColumn`` object which denotes the field and optionally, the model, to be used when sorting. """ - if len(args) == 1: - return SortColumn(args[0]) - elif len(args) == 2: - return SortColumn(args[1], args[0]) - else: - raise ValueError("must pass 1 arg (field_name) or 2 args (model_name, field_name)") + if not model_name: + model_name = self.model_class.__name__ + return SortColumn(field_name, model_name) def join_for_sort_spec(self, query, sort_spec): """ @@ -234,8 +260,23 @@ class APIMasterView(APIView): query = self.Session.query(cls) return query + def get_fieldnames(self): + if not hasattr(self, '_fieldnames'): + self._fieldnames = get_fieldnames( + self.rattail_config, self.model_class, + columns=True, proxies=True, relations=False) + return self._fieldnames + + def normalize(self, obj): + data = {'_str': str(obj)} + + for field in self.get_fieldnames(): + data[field] = getattr(obj, field) + + return data + def _collection_get(self): - from sqlalchemy_filters import apply_filters, apply_sort, apply_pagination + from sa_filters import apply_filters, apply_sort, apply_pagination query = self.base_query() context = {} @@ -291,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 @@ -313,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): """ @@ -342,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() @@ -374,6 +419,70 @@ class APIMasterView(APIView): # that's all we can do here, subclass must override if more needed return obj + ############################## + # delete + ############################## + + def _delete(self): + """ + View to handle DELETE action for an existing record/object. + """ + obj = self.get_object() + self.delete_object(obj) + + def delete_object(self, obj): + """ + Delete the object, or mark it as deleted, or whatever you need to do. + """ + # flush immediately to force any pending integrity errors etc. + self.Session.delete(obj) + self.Session.flush() + + ############################## + # download + ############################## + + def download(self): + """ + GET view allowing for download of a single file, which is attached to a + given record. + """ + obj = self.get_object() + + filename = self.request.GET.get('filename', None) + if not filename: + raise self.notfound() + path = self.download_path(obj, filename) + + response = self.file_response(path) + return response + + def download_path(self, obj, filename): + """ + Should return absolute path on disk, for the given object and filename. + Result will be used to return a file response to client. + """ + raise NotImplementedError + + def rawbytes(self): + """ + GET view allowing for direct access to the raw bytes of a file, which + is attached to a given record. Basically the same as 'download' except + this does not come as an attachment. + """ + obj = self.get_object() + + # TODO: is this really needed? + # filename = self.request.GET.get('filename', None) + # if filename: + # path = self.download_path(obj, filename) + # return self.file_response(path, attachment=False) + + return self.rawbytes_response(obj) + + def rawbytes_response(self, obj): + raise NotImplementedError + ############################## # autocomplete ############################## @@ -429,3 +538,81 @@ class APIMasterView(APIView): autocomplete query. """ return term + + @classmethod + def defaults(cls, config): + cls._defaults(config) + + @classmethod + def _defaults(cls, config): + route_prefix = cls.get_route_prefix() + permission_prefix = cls.get_permission_prefix() + collection_url_prefix = cls.get_collection_url_prefix() + object_url_prefix = cls.get_object_url_prefix() + + # first, the primary resource API + + # list/search + if cls.listable: + cls.establish_method('collection_get') + resource.add_view(cls.collection_get, permission='{}.list'.format(permission_prefix)) + + # create + if cls.creatable: + cls.establish_method('collection_post') + if hasattr(cls, 'permission_to_create'): + permission = cls.permission_to_create + else: + permission = '{}.create'.format(permission_prefix) + resource.add_view(cls.collection_post, permission=permission) + + # view + if cls.viewable: + cls.establish_method('get') + resource.add_view(cls.get, permission='{}.view'.format(permission_prefix)) + + # edit + if cls.editable: + cls.establish_method('post') + resource.add_view(cls.post, permission='{}.edit'.format(permission_prefix)) + + # delete + if cls.deletable: + cls.establish_method('delete') + resource.add_view(cls.delete, permission='{}.delete'.format(permission_prefix)) + + # register primary resource API via cornice + object_resource = resource.add_resource( + cls, + collection_path=collection_url_prefix, + # TODO: probably should allow for other (composite?) key fields + path='{}/{{uuid}}'.format(object_url_prefix)) + config.add_cornice_resource(object_resource) + + # now for some more "custom" things, which are still somewhat generic + + # autocomplete + if cls.supports_autocomplete: + autocomplete = Service(name='{}.autocomplete'.format(route_prefix), + path='{}/autocomplete'.format(collection_url_prefix)) + autocomplete.add_view('GET', 'autocomplete', klass=cls, + permission='{}.list'.format(permission_prefix)) + config.add_cornice_service(autocomplete) + + # download + if cls.supports_download: + download = Service(name='{}.download'.format(route_prefix), + # TODO: probably should allow for other (composite?) key fields + path='{}/{{uuid}}/download'.format(object_url_prefix)) + download.add_view('GET', 'download', klass=cls, + permission='{}.download'.format(permission_prefix)) + config.add_cornice_service(download) + + # rawbytes + if cls.supports_rawbytes: + rawbytes = Service(name='{}.rawbytes'.format(route_prefix), + # TODO: probably should allow for other (composite?) key fields + path='{}/{{uuid}}/rawbytes'.format(object_url_prefix)) + rawbytes.add_view('GET', 'rawbytes', klass=cls, + permission='{}.download'.format(permission_prefix)) + config.add_cornice_service(rawbytes) diff --git a/tailbone/api/master2.py b/tailbone/api/master2.py index 7f62489e..4a5abb3e 100644 --- a/tailbone/api/master2.py +++ b/tailbone/api/master2.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. # @@ -26,8 +26,7 @@ Tailbone Web API - Master View (v2) from __future__ import unicode_literals, absolute_import -from pyramid.response import FileResponse -from cornice import resource, Service +import warnings from tailbone.api import APIMasterView @@ -36,174 +35,9 @@ class APIMasterView2(APIMasterView): """ Base class for data model REST API views. """ - listable = True - creatable = True - viewable = True - editable = True - deletable = True - supports_autocomplete = False - supports_download = False - supports_rawbytes = False - @classmethod - def establish_method(cls, method_name): - """ - Establish the given HTTP method for this Cornice Resource. - - Cornice will auto-register any class methods for a resource, if they - are named according to what it expects (i.e. 'get', 'collection_get' - etc.). Tailbone API tries to make things automagical for the sake of - e.g. Poser logic, but in this case if we predefine all of these methods - and then some subclass view wants to *not* allow one, it's not clear - how to "undefine" it per se. Or at least, the more straightforward - thing (I think) is to not define such a method in the first place, if - it was not wanted. - - Enter ``establish_method()``, which is what finally "defines" each - resource method according to what the subclass has declared via its - various attributes (:attr:`creatable`, :attr:`deletable` etc.). - - Note that you will not likely have any need to use this - ``establish_method()`` yourself! But we describe its purpose here, for - clarity. - """ - def method(self): - internal_method = getattr(self, '_{}'.format(method_name)) - return internal_method() - - setattr(cls, method_name, method) - - def _delete(self): - """ - View to handle DELETE action for an existing record/object. - """ - obj = self.get_object() - self.delete_object(obj) - - def delete_object(self, obj): - """ - Delete the object, or mark it as deleted, or whatever you need to do. - """ - # flush immediately to force any pending integrity errors etc. - self.Session.delete(obj) - self.Session.flush() - - ############################## - # download - ############################## - - def download(self): - """ - GET view allowing for download of a single file, which is attached to a - given record. - """ - obj = self.get_object() - - filename = self.request.GET.get('filename', None) - if not filename: - raise self.notfound() - path = self.download_path(obj, filename) - - response = self.file_response(path) - return response - - def download_path(self, obj, filename): - """ - Should return absolute path on disk, for the given object and filename. - Result will be used to return a file response to client. - """ - raise NotImplementedError - - def rawbytes(self): - """ - GET view allowing for direct access to the raw bytes of a file, which - is attached to a given record. Basically the same as 'download' except - this does not come as an attachment. - """ - obj = self.get_object() - - filename = self.request.GET.get('filename', None) - if not filename: - raise self.notfound() - path = self.download_path(obj, filename) - - response = self.file_response(path, attachment=False) - return response - - @classmethod - def defaults(cls, config): - cls._defaults(config) - - @classmethod - def _defaults(cls, config): - route_prefix = cls.get_route_prefix() - permission_prefix = cls.get_permission_prefix() - collection_url_prefix = cls.get_collection_url_prefix() - object_url_prefix = cls.get_object_url_prefix() - - # first, the primary resource API - - # list/search - if cls.listable: - cls.establish_method('collection_get') - resource.add_view(cls.collection_get, permission='{}.list'.format(permission_prefix)) - - # create - if cls.creatable: - cls.establish_method('collection_post') - if hasattr(cls, 'permission_to_create'): - permission = cls.permission_to_create - else: - permission = '{}.create'.format(permission_prefix) - resource.add_view(cls.collection_post, permission=permission) - - # view - if cls.viewable: - cls.establish_method('get') - resource.add_view(cls.get, permission='{}.view'.format(permission_prefix)) - - # edit - if cls.editable: - cls.establish_method('post') - resource.add_view(cls.post, permission='{}.edit'.format(permission_prefix)) - - # delete - if cls.deletable: - cls.establish_method('delete') - resource.add_view(cls.delete, permission='{}.delete'.format(permission_prefix)) - - # register primary resource API via cornice - object_resource = resource.add_resource( - cls, - collection_path=collection_url_prefix, - # TODO: probably should allow for other (composite?) key fields - path='{}/{{uuid}}'.format(object_url_prefix)) - config.add_cornice_resource(object_resource) - - # now for some more "custom" things, which are still somewhat generic - - # autocomplete - if cls.supports_autocomplete: - autocomplete = Service(name='{}.autocomplete'.format(route_prefix), - path='{}/autocomplete'.format(collection_url_prefix)) - autocomplete.add_view('GET', 'autocomplete', klass=cls, - permission='{}.list'.format(permission_prefix)) - config.add_cornice_service(autocomplete) - - # download - if cls.supports_download: - download = Service(name='{}.download'.format(route_prefix), - # TODO: probably should allow for other (composite?) key fields - path='{}/{{uuid}}/download'.format(object_url_prefix)) - download.add_view('GET', 'download', klass=cls, - permission='{}.download'.format(permission_prefix)) - config.add_cornice_service(download) - - # rawbytes - if cls.supports_rawbytes: - rawbytes = Service(name='{}.rawbytes'.format(route_prefix), - # TODO: probably should allow for other (composite?) key fields - path='{}/{{uuid}}/rawbytes'.format(object_url_prefix)) - rawbytes.add_view('GET', 'rawbytes', klass=cls, - permission='{}.download'.format(permission_prefix)) - config.add_cornice_service(rawbytes) + def __init__(self, request, context=None): + warnings.warn("APIMasterView2 class is deprecated; please use " + "APIMasterView instead", + DeprecationWarning, stacklevel=2) + super(APIMasterView2, self).__init__(request, context=context) diff --git a/tailbone/api/people.py b/tailbone/api/people.py index bb8dd883..f7c08dfa 100644 --- a/tailbone/api/people.py +++ b/tailbone/api/people.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,9 @@ Tailbone Web API - Person Views """ -from __future__ import unicode_literals, absolute_import - -import six - from rattail.db import model -from tailbone.api import APIMasterView2 as APIMasterView +from tailbone.api import APIMasterView class PersonView(APIMasterView): @@ -45,12 +41,19 @@ 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, } -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + PersonView = kwargs.get('PersonView', base['PersonView']) PersonView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/api/products.py b/tailbone/api/products.py index d7aeabcd..3f29ff54 100644 --- a/tailbone/api/products.py +++ b/tailbone/api/products.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2020 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,15 +24,19 @@ 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 APIMasterView2 as APIMasterView +from tailbone.api import APIMasterView + + +log = logging.getLogger(__name__) class ProductView(APIMasterView): @@ -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,111 @@ 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() + + ProductView = kwargs.get('ProductView', base['ProductView']) + ProductView.defaults(config) + def includeme(config): - ProductView.defaults(config) + defaults(config) diff --git a/tailbone/api/upgrades.py b/tailbone/api/upgrades.py index 85e4a91e..467c8a0d 100644 --- a/tailbone/api/upgrades.py +++ b/tailbone/api/upgrades.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,13 +24,9 @@ Tailbone Web API - Upgrade Views """ -from __future__ import unicode_literals, absolute_import - -import six - from rattail.db import model -from tailbone.api import APIMasterView2 as APIMasterView +from tailbone.api import APIMasterView class UpgradeView(APIMasterView): @@ -53,9 +49,16 @@ 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 -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + UpgradeView = kwargs.get('UpgradeView', base['UpgradeView']) UpgradeView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/api/users.py b/tailbone/api/users.py index 8474fd97..a6bcad57 100644 --- a/tailbone/api/users.py +++ b/tailbone/api/users.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2020 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,13 +24,9 @@ Tailbone Web API - User Views """ -from __future__ import unicode_literals, absolute_import - -import six - from rattail.db import model -from tailbone.api import APIMasterView2 as APIMasterView +from tailbone.api import APIMasterView class UserView(APIMasterView): @@ -59,6 +55,17 @@ 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() + + UserView = kwargs.get('UserView', base['UserView']) + UserView.defaults(config) + def includeme(config): - UserView.defaults(config) + defaults(config) diff --git a/tailbone/api/vendors.py b/tailbone/api/vendors.py index ce885e07..64311b1b 100644 --- a/tailbone/api/vendors.py +++ b/tailbone/api/vendors.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,13 +24,9 @@ Tailbone Web API - Vendor Views """ -from __future__ import unicode_literals, absolute_import - -import six - from rattail.db import model -from tailbone.api import APIMasterView2 as APIMasterView +from tailbone.api import APIMasterView class VendorView(APIMasterView): @@ -44,11 +40,18 @@ 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, } -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + VendorView = kwargs.get('VendorView', base['VendorView']) VendorView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/api/workorders.py b/tailbone/api/workorders.py new file mode 100644 index 00000000..19def6c4 --- /dev/null +++ b/tailbone/api/workorders.py @@ -0,0 +1,234 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2024 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. +# +################################################################################ +""" +Tailbone Web API - Work Order Views +""" + +import datetime + +from rattail.db.model import WorkOrder + +from cornice import Service + +from tailbone.api import APIMasterView + + +class WorkOrderView(APIMasterView): + + model_class = WorkOrder + collection_url_prefix = '/workorders' + object_url_prefix = '/workorder' + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + app = self.get_rattail_app() + self.workorder_handler = app.get_workorder_handler() + + def normalize(self, workorder): + data = super().normalize(workorder) + data.update({ + 'customer_name': workorder.customer.name, + 'status_label': self.enum.WORKORDER_STATUS[workorder.status_code], + 'date_submitted': str(workorder.date_submitted or ''), + 'date_received': str(workorder.date_received or ''), + 'date_released': str(workorder.date_released or ''), + 'date_delivered': str(workorder.date_delivered or ''), + }) + return data + + def create_object(self, data): + + # invoke the handler instead of normal API CRUD logic + workorder = self.workorder_handler.make_workorder(self.Session(), **data) + return workorder + + def update_object(self, workorder, data): + date_fields = [ + 'date_submitted', + 'date_received', + 'date_released', + 'date_delivered', + ] + + # coerce date field values to proper datetime.date objects + for field in date_fields: + if field in data: + if data[field] == '': + data[field] = None + elif not isinstance(data[field], datetime.date): + date = datetime.datetime.strptime(data[field], '%Y-%m-%d').date() + data[field] = date + + # coerce status code value to proper integer + if 'status_code' in data: + data['status_code'] = int(data['status_code']) + + return super().update_object(workorder, data) + + def status_codes(self): + """ + Retrieve all info about possible work order status codes. + """ + return self.workorder_handler.status_codes() + + def receive(self): + """ + Sets work order status to "received". + """ + workorder = self.get_object() + self.workorder_handler.receive(workorder) + self.Session.flush() + return self.normalize(workorder) + + def await_estimate(self): + """ + Sets work order status to "awaiting estimate confirmation". + """ + workorder = self.get_object() + self.workorder_handler.await_estimate(workorder) + self.Session.flush() + return self.normalize(workorder) + + def await_parts(self): + """ + Sets work order status to "awaiting parts". + """ + workorder = self.get_object() + self.workorder_handler.await_parts(workorder) + self.Session.flush() + return self.normalize(workorder) + + def work_on_it(self): + """ + Sets work order status to "working on it". + """ + workorder = self.get_object() + self.workorder_handler.work_on_it(workorder) + self.Session.flush() + return self.normalize(workorder) + + def release(self): + """ + Sets work order status to "released". + """ + workorder = self.get_object() + self.workorder_handler.release(workorder) + self.Session.flush() + return self.normalize(workorder) + + def deliver(self): + """ + Sets work order status to "delivered". + """ + workorder = self.get_object() + self.workorder_handler.deliver(workorder) + self.Session.flush() + return self.normalize(workorder) + + def cancel(self): + """ + Sets work order status to "canceled". + """ + workorder = self.get_object() + self.workorder_handler.cancel(workorder) + self.Session.flush() + return self.normalize(workorder) + + @classmethod + def defaults(cls, config): + cls._defaults(config) + cls._workorder_defaults(config) + + @classmethod + def _workorder_defaults(cls, config): + route_prefix = cls.get_route_prefix() + permission_prefix = cls.get_permission_prefix() + collection_url_prefix = cls.get_collection_url_prefix() + object_url_prefix = cls.get_object_url_prefix() + + # status codes + status_codes = Service(name='{}.status_codes'.format(route_prefix), + path='{}/status-codes'.format(collection_url_prefix)) + status_codes.add_view('GET', 'status_codes', klass=cls, + permission='{}.list'.format(permission_prefix)) + config.add_cornice_service(status_codes) + + # receive + receive = Service(name='{}.receive'.format(route_prefix), + path='{}/{{uuid}}/receive'.format(object_url_prefix)) + receive.add_view('POST', 'receive', klass=cls, + permission='{}.edit'.format(permission_prefix)) + config.add_cornice_service(receive) + + # await estimate confirmation + await_estimate = Service(name='{}.await_estimate'.format(route_prefix), + path='{}/{{uuid}}/await-estimate'.format(object_url_prefix)) + await_estimate.add_view('POST', 'await_estimate', klass=cls, + permission='{}.edit'.format(permission_prefix)) + config.add_cornice_service(await_estimate) + + # await parts + await_parts = Service(name='{}.await_parts'.format(route_prefix), + path='{}/{{uuid}}/await-parts'.format(object_url_prefix)) + await_parts.add_view('POST', 'await_parts', klass=cls, + permission='{}.edit'.format(permission_prefix)) + config.add_cornice_service(await_parts) + + # work on it + work_on_it = Service(name='{}.work_on_it'.format(route_prefix), + path='{}/{{uuid}}/work-on-it'.format(object_url_prefix)) + work_on_it.add_view('POST', 'work_on_it', klass=cls, + permission='{}.edit'.format(permission_prefix)) + config.add_cornice_service(work_on_it) + + # release + release = Service(name='{}.release'.format(route_prefix), + path='{}/{{uuid}}/release'.format(object_url_prefix)) + release.add_view('POST', 'release', klass=cls, + permission='{}.edit'.format(permission_prefix)) + config.add_cornice_service(release) + + # deliver + deliver = Service(name='{}.deliver'.format(route_prefix), + path='{}/{{uuid}}/deliver'.format(object_url_prefix)) + deliver.add_view('POST', 'deliver', klass=cls, + permission='{}.edit'.format(permission_prefix)) + config.add_cornice_service(deliver) + + # cancel + cancel = Service(name='{}.cancel'.format(route_prefix), + path='{}/{{uuid}}/cancel'.format(object_url_prefix)) + cancel.add_view('POST', 'cancel', klass=cls, + permission='{}.edit'.format(permission_prefix)) + config.add_cornice_service(cancel) + + +def defaults(config, **kwargs): + base = globals() + + WorkOrderView = kwargs.get('WorkOrderView', base['WorkOrderView']) + WorkOrderView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/app.py b/tailbone/app.py index bbb6d295..d2d0c5ef 100644 --- a/tailbone/app.py +++ b/tailbone/app.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,27 +24,23 @@ 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 def make_rattail_config(settings): @@ -62,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': @@ -115,24 +128,31 @@ def make_pyramid_config(settings, configure_csrf=True): """ Make a Pyramid config object from the given settings. """ + rattail_config = settings['rattail_config'] + config = settings.pop('pyramid_config', None) if config: config.set_root_factory(Root) else: + # declare this web app of the "classic" variety + settings.setdefault('tailbone.classic', 'true') + # we want the new themes feature! establish_theme(settings) + settings.setdefault('fanstatic.versioning', 'true') settings.setdefault('pyramid_deform.template_search_path', 'tailbone:templates/deform') config = Configurator(settings=settings, root_factory=Root) + # add rattail config directly to registry, for access throughout the app + 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: - rattail_config = settings['rattail_config'] config.set_default_csrf_options(require_csrf=True, token=csrf_token_name(rattail_config), header=csrf_header_name(rattail_config)) @@ -140,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: @@ -152,13 +183,127 @@ def make_pyramid_config(settings, configure_csrf=True): else: config.include('pyramid_retry') - # 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') + # 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, config) + + # add any static includes + includes = provider.get_static_includes() + if includes: + for spec in includes: + config.include(spec) + + # add some permissions magic + config.add_directive('add_wutta_permission_group', + 'wuttaweb.auth.add_permission_group') + config.add_directive('add_wutta_permission', + 'wuttaweb.auth.add_permission') + # TODO: deprecate / remove these + config.add_directive('add_tailbone_permission_group', + 'wuttaweb.auth.add_permission_group') + config.add_directive('add_tailbone_permission', + 'wuttaweb.auth.add_permission') + + # and some similar magic for certain master views + config.add_directive('add_tailbone_index_page', 'tailbone.app.add_index_page') + config.add_directive('add_tailbone_config_page', 'tailbone.app.add_config_page') + config.add_directive('add_tailbone_model_view', 'tailbone.app.add_model_view') + config.add_directive('add_tailbone_view_supplement', 'tailbone.app.add_view_supplement') + + config.add_directive('add_tailbone_websocket', 'tailbone.app.add_websocket') return config +def add_websocket(config, name, view, attr=None): + """ + Register a websocket entry point for the app. + """ + def action(): + rattail_config = config.registry.settings['rattail_config'] + rattail_app = rattail_config.get_app() + + if isinstance(view, str): + view_callable = rattail_app.load_object(view) + else: + view_callable = view + view_callable = view_callable(config) + if attr: + view_callable = getattr(view_callable, attr) + + # register route + path = '/ws/{}'.format(name) + route_name = 'ws.{}'.format(name) + config.add_route(route_name, path, static=True) + + # register view callable + websockets = config.registry.setdefault('tailbone_websockets', {}) + websockets[path] = view_callable + + config.action('tailbone-add-websocket-{}'.format(name), action, + # nb. since this action adds routes, it must happen + # sooner in the order than it normally would, hence + # we declare that + order=-20) + + +def add_index_page(config, route_name, label, permission): + """ + Register a config page for the app. + """ + def action(): + pages = config.get_settings().get('tailbone_index_pages', []) + pages.append({'label': label, 'route': route_name, + 'permission': permission}) + config.add_settings({'tailbone_index_pages': pages}) + config.action(None, action) + + +def add_config_page(config, route_name, label, permission): + """ + Register a config page for the app. + """ + def action(): + pages = config.get_settings().get('tailbone_config_pages', []) + pages.append({'label': label, 'route': route_name, + 'permission': permission}) + config.add_settings({'tailbone_config_pages': pages}) + config.action(None, action) + + +def add_model_view(config, model_name, label, route_prefix, permission_prefix): + """ + Register a model view for the app. + """ + def action(): + all_views = config.get_settings().get('tailbone_model_views', {}) + + model_views = all_views.setdefault(model_name, []) + model_views.append({ + 'label': label, + 'route_prefix': route_prefix, + 'permission_prefix': permission_prefix, + }) + + config.add_settings({'tailbone_model_views': all_views}) + + config.action(None, action) + + +def add_view_supplement(config, route_prefix, cls): + """ + Register a master view supplement for the app. + """ + def action(): + supplements = config.get_settings().get('tailbone_view_supplements', {}) + supplements.setdefault(route_prefix, []).append(cls) + config.add_settings({'tailbone_view_supplements': supplements}) + config.action(None, action) + + def establish_theme(settings): rattail_config = settings['rattail_config'] @@ -166,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) @@ -187,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 new file mode 100644 index 00000000..1afbe12a --- /dev/null +++ b/tailbone/asgi.py @@ -0,0 +1,110 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2024 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. +# +################################################################################ +""" +ASGI App Utilities +""" + +import os +import configparser +import logging + +from rattail.util import load_object + +from asgiref.wsgi import WsgiToAsgi + + +log = logging.getLogger(__name__) + + +class TailboneWsgiToAsgi(WsgiToAsgi): + """ + Custom WSGI -> ASGI wrapper, to add routing for websockets. + """ + + async def __call__(self, scope, *args, **kwargs): + protocol = scope['type'] + path = scope['path'] + + # strip off the root path, if non-empty. needed for serving + # under /poser or anything other than true site root + root_path = scope['root_path'] + if root_path and path.startswith(root_path): + path = path[len(root_path):] + + if protocol == 'websocket': + websockets = self.wsgi_application.registry.get( + 'tailbone_websockets', {}) + if path in websockets: + await websockets[path](scope, *args, **kwargs) + + try: + await super().__call__(scope, *args, **kwargs) + except ValueError as e: + # The developer may wish to improve handling of this exception. + # See https://github.com/Pylons/pyramid_cookbook/issues/225 and + # https://asgi.readthedocs.io/en/latest/specs/www.html#websocket + pass + except Exception as e: + raise e + + +def make_asgi_app(main_app=None): + """ + This function returns an ASGI application. + """ + path = os.environ.get('TAILBONE_ASGI_CONFIG') + if not path: + raise RuntimeError("You must define TAILBONE_ASGI_CONFIG env variable.") + + # make a config parser good enough to load pyramid settings + configdir = os.path.dirname(path) + parser = configparser.ConfigParser(defaults={'__file__': path, + 'here': configdir}) + + # read the config file + parser.read(path) + + # parse the settings needed for pyramid app + settings = dict(parser.items('app:main')) + + if isinstance(main_app, str): + make_wsgi_app = load_object(main_app) + elif callable(main_app): + make_wsgi_app = main_app + else: + if main_app: + log.warning("specified main app of unknown type: %s", main_app) + make_wsgi_app = load_object('tailbone.app:main') + + # construct a pyramid app "per usual" + app = make_wsgi_app({}, **settings) + + # then wrap it with ASGI + return TailboneWsgiToAsgi(app) + + +def asgi_main(): + """ + This function returns an ASGI application. + """ + return make_asgi_app() diff --git a/tailbone/auth.py b/tailbone/auth.py index deda1ab7..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,57 +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): - 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. - config = context.request.rattail_config - model = config.get_model() - app = config.get_app() + 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() + user = None + + if self.api_mode: + + # 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() - # 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 has_permission(Session(), None, permission) - return False + user = auth.authenticate_user_token(self.db_session, token) - def principals_allowed_by_permission(self, context, permission): - raise NotImplementedError + if not user: + # fetch user uuid from current session + uuid = self.session_helper.authenticated_userid(request) + if not uuid: + return -def add_permission_group(config, key, label=None, overwrite=True): - """ - Add a permission group to the app configuration. - """ - def action(): - perms = config.get_settings().get('tailbone_permissions', {}) - if key not in perms or overwrite: - group = perms.setdefault(key, {'key': key}) - group['label'] = label or prettify(key) - config.add_settings({'tailbone_permissions': perms}) - config.action(None, action) + # fetch user object from db + model = app.model + user = self.db_session.get(model.User, uuid) + if not user: + return - -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 90799016..8392ba0a 100644 --- a/tailbone/config.py +++ b/tailbone/config.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,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') @@ -67,3 +71,8 @@ def global_help_url(config): def protected_usernames(config): return config.getlist('tailbone', 'protected_usernames') + + +def should_expose_websockets(config): + return config.getbool('tailbone', 'expose_websockets', + usedb=False, default=False) 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 d4031b1f..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,16 +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, monospace=False, - extra_row_attrs=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): 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 @@ -69,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" @@ -105,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 26934479..6183d17f 100644 --- a/tailbone/forms/common.py +++ b/tailbone/forms/common.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,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 @@ -58,4 +56,7 @@ class Feedback(colander.Schema): user_name = colander.SchemaNode(colander.String(), missing=colander.null) + please_reply_to = colander.SchemaNode(colander.String(), + missing=colander.null) + message = colander.SchemaNode(colander.String()) diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index 2267b8dc..4024557b 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.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,20 +24,19 @@ Forms Core """ -from __future__ import unicode_literals, absolute_import - +import hashlib import json -import datetime 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_hours, pretty_quantity -from rattail.core import UNSPECIFIED +from rattail.util import pretty_boolean +from rattail.db.util import get_fieldnames import colander import deform @@ -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 -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 @@ -112,22 +116,14 @@ class CustomSchemaNode(SQLAlchemySchemaNode): for the given association proxy field name. Typically this will refer to the "extension" model class. """ - proxy = self.association_proxy(field) - if proxy: - proxy_target = self.inspector.get_property(proxy.target_collection) - if isinstance(proxy_target, orm.RelationshipProperty) and not proxy_target.uselist: - return proxy_target + return get_association_proxy_target(self.inspector, field) def association_proxy_column(self, field): """ Returns the property on the proxy target class, for the column which is reflected by the proxy. """ - proxy_target = self.association_proxy_target(field) - if proxy_target: - prop = proxy_target.mapper.get_property(field) - if isinstance(prop, orm.ColumnProperty) and isinstance(prop.columns[0], sa.Column): - return prop + return get_association_proxy_column(self.inspector, field) def supported_association_proxy(self, field): """ @@ -230,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. @@ -239,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 @@ -332,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 @@ -341,16 +337,22 @@ class Form(object): def __init__(self, fields=None, schema=None, request=None, readonly=False, readonly_fields=[], model_instance=None, model_class=None, appstruct=UNSPECIFIED, nodes={}, enums={}, labels={}, - assume_local_times=False, renderers=None, + 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'): - + 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: self.set_fields(fields) 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,22 +371,89 @@ class Form(object): self.renderers = self.make_renderers() else: self.renderers = renderers or {} + self.renderer_kwargs = renderer_kwargs or {} self.hidden = hidden or {} self.widgets = widgets or {} self.defaults = defaults or {} 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 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): - words = self.component.split('-') - return ''.join([word.capitalize() for word in words]) + """ + 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 @@ -399,19 +468,11 @@ class Form(object): if not self.model_class: raise ValueError("Must define model_class to use make_fields()") - mapper = orm.class_mapper(self.model_class) + return get_fieldnames(self.request.rattail_config, self.model_class, + columns=True, proxies=True, relations=True) - # first add primary column fields - fields = FieldList([prop.key for prop in mapper.iterate_properties - if not prop.key.startswith('_') - and prop.key != 'versions']) - - # then add association proxy fields - for key, desc in sa.inspect(self.model_class).all_orm_descriptors.items(): - if desc.extension_type == ASSOCIATION_PROXY: - fields.append(key) - - return fields + def set_grouping(self, items): + self.grouping = OrderedDict(items) def make_renderers(self): """ @@ -454,6 +515,9 @@ class Form(object): def append(self, field): self.fields.append(field) + def insert(self, index, field): + self.fields.insert(index, field) + def insert_before(self, field, newfield): self.fields.insert_before(field, newfield) @@ -540,7 +604,10 @@ class Form(object): # apply any validators for key, validator in self.validators.items(): - if key in schema: + if key is None: + # this one is form-wide + schema.validator = validator + elif key in schema: schema[key].validator = validator # apply required flags @@ -563,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: @@ -580,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': @@ -591,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': @@ -620,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 @@ -658,6 +769,22 @@ class Form(object): else: self.renderers[key] = renderer + def add_renderer_kwargs(self, key, kwargs): + self.renderer_kwargs.setdefault(key, {}).update(kwargs) + + def get_renderer_kwargs(self, key): + return self.renderer_kwargs.get(key, {}) + + def set_renderer_kwargs(self, key, kwargs): + self.renderer_kwargs[key] = kwargs + + def set_input_handler(self, key, value): + """ + Convenience method to assign "input handler" callback code for + the given field. + """ + self.add_renderer_kwargs(key, {'input_handler': value}) + def set_hidden(self, key, hidden=True): self.hidden[key] = hidden @@ -669,8 +796,26 @@ class Form(object): self.schema[key].widget = widget def set_validator(self, key, validator): + """ + Set the validator for the schema node represented by the given + key. + + :param key: Normally this the name of one of the fields + contained in the form. It can also be ``None`` in which + case the validator pertains to the form at large instead of + one of the fields. + + :param validator: Callable which accepts ``(node, value)`` + args. + """ self.validators[key] = validator + # we normally apply the validator when creating the schema, so + # if this form already has a schema, then go ahead and apply + # the validator to it + if self.schema and key in self.schema: + self.schema[key].validator = validator + def set_required(self, key, required=True): """ Set whether or not value is required for a given field. @@ -683,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): """ @@ -700,17 +850,22 @@ class Form(object): """ Render the help text for the given field. """ - return self.helptext[key] + text = self.helptext[key] + text = text.replace('"', '"') + return HTML.literal(text) - 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 set_vuejs_field_converter(self, field, converter): + self.vuejs_field_converters[field] = converter + + def render(self, **kwargs): + warnings.warn("Form.render() is deprecated (for now?); " + "please use Form.render_deform() instead", + DeprecationWarning, stacklevel=2) + return self.render_deform(**kwargs) + + def get_deform(self): + """ """ + return self.make_deform_form() def make_deform_form(self): if not hasattr(self, 'deform_form'): @@ -750,30 +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']['@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 @@ -781,31 +941,89 @@ 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 model value for the given field. This JS will be written as part of the overall response, to be interpreted on the client side. """ - 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 field.name in self.vuejs_field_converters: + convert = self.vuejs_field_converters[field.name] + value = convert(field.cstruct) + return json.dumps(value) 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() + + error = self.make_deform_form().error + if error: + if isinstance(error, colander.Invalid): + if error.node.name == field.name: + return error.messages() + def messages_json(self, messages): dump = json.dumps(messages) dump = dump.replace("'", ''') @@ -816,6 +1034,208 @@ class Form(object): return False return True + def set_vuejs_component_kwargs(self, **kwargs): + self.vuejs_component_kwargs.update(kwargs) + + def render_vue_tag(self, **kwargs): + """ """ + return self.render_vuejs_component(**kwargs) + + def render_vuejs_component(self, **kwargs): + """ + 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] 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', + } + + # add some magic for file input fields + if field and isinstance(field.schema.typ, deform.FileData): + attrs['class_'] = 'file' + + # next we will build array of messages to display..some + # fields always show a "helptext" msg, and some may have + # validation errors.. + field_type = None + messages = [] + + # show errors if present + error_messages = self.get_error_messages(field) if field else None + if error_messages: + field_type = 'is-danger' + messages.extend(error_messages) + + # show helptext if present + # TODO: older logic did this only if field was *not* + # readonly, perhaps should add that back.. + if self.has_helptext(fieldname): + messages.append(self.render_helptext(fieldname)) + + # ..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 + 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) + + 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. @@ -826,17 +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 = HTML.tag('label', self.get_label(field_name), for_=field_name) - field = self.render_field_value(field_name) or '' - field_div = HTML.tag('div', class_='field', c=[field]) - contents = [label, field_div] + label = kwargs.get('label') + if not label: + label = self.get_label(field_name) - 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 @@ -848,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) @@ -860,14 +1293,16 @@ 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): - value = self.obtain_value(record, field_name) - if value is None: + seconds = self.obtain_value(record, field_name) + if seconds is None: return "" - return pretty_hours(datetime.timedelta(seconds=value)) + app = self.request.rattail_config.get_app() + return app.render_duration(seconds=seconds) def render_boolean(self, record, field_name): value = self.obtain_value(record, field_name) @@ -882,19 +1317,19 @@ 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() value = self.obtain_value(obj, field) - if value is None: - return "" - return "{:0.3f} %".format(value * 100) + return app.render_percent(value, places=3) def render_gpc(self, obj, field): value = self.obtain_value(obj, field) @@ -908,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) @@ -940,78 +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. - # use POST or JSON body, whichever is present - # TODO: per docs, some JS libraries may not set this flag? - # https://docs.pylonsproject.org/projects/pyramid/en/latest/api/request.html#pyramid.request.Request.is_xhr - if self.request.is_xhr and not self.request.POST: - controls = self.request.json_body.items() + 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 the JSON body we - # just parsed, 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 - 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) - else: - controls = self.request.POST.items() + if hasattr(self, 'validated'): + del self.validated + if self.request.method != 'POST': + return False - dform = self.make_deform_form() - try: - self.validated = dform.validate(controls) - return True - except deform.ValidationFailure: - return False + controls = get_form_data(self.request).items() - else: # legacy behavior - raise_error = kwargs.pop('raise_error', True) - dform = self.make_deform_form() - try: - return dform.validate(*args, **kwargs) - except deform.ValidationFailure: - if raise_error: - raise + # unfortunately the normal form logic (i.e. peppercorn) is + # expecting all values to be strings, whereas if our data + # came from JSON body, may have given us some Pythonic + # objects. so here we must convert them *back* to strings + # TODO: this seems like a hack, i must be missing something + # TODO: also this uses same "JSON" check as get_form_data() + if self.request.is_xhr and not self.request.POST: + controls = [[key, val] for key, val in controls] + for i in range(len(controls)): + key, value = controls[i] + if value is None: + controls[i][1] = '' + elif value is True: + controls[i][1] = 'true' + elif value is False: + controls[i][1] = 'false' + elif not isinstance(value, str): + controls[i][1] = str(value) - -class FieldList(list): - """ - Convenience wrapper for a form's field list. - """ - - def insert_before(self, field, newfield): - i = self.index(field) - self.insert(i, newfield) - - def insert_after(self, field, newfield): - i = self.index(field) - self.insert(i + 1, 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 d8976337..8c16726d 100644 --- a/tailbone/forms/widgets.py +++ b/tailbone/forms/widgets.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,19 +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.forms.types import ProductQuantity +from tailbone.db import Session class ReadonlyWidget(dfwidget.HiddenWidget): @@ -44,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? @@ -60,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 @@ -81,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" @@ -99,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): @@ -112,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) @@ -122,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 @@ -150,6 +154,7 @@ class DynamicCheckboxWidget(dfwidget.CheckboxWidget): template = 'checkbox_dynamic' +# TODO: deprecate / remove this class PlainSelectWidget(dfwidget.SelectWidget): template = 'select_plain' @@ -168,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 @@ -211,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) @@ -238,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 @@ -246,9 +294,13 @@ class JQueryAutocompleteWidget(dfwidget.AutocompleteInputWidget): template = 'autocomplete_jquery' requirements = None field_display = "" + assigned_label = None service_url = None cleared_callback = None selected_callback = None + input_callback = None + new_label_callback = None + ref = None default_options = ( ('autoFocus', True), @@ -256,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 ' @@ -274,7 +327,333 @@ class JQueryAutocompleteWidget(dfwidget.AutocompleteInputWidget): kw['options'] = json.dumps(options) kw['field_display'] = self.field_display kw['cleared_callback'] = self.cleared_callback + kw['assigned_label'] = self.assigned_label + kw['input_callback'] = self.input_callback + kw['new_label_callback'] = self.new_label_callback + kw['ref'] = self.ref kw.setdefault('selected_callback', self.selected_callback) tmpl_values = self.get_template_values(field, cstruct, kw) template = readonly and self.readonly_template or self.template return field.renderer(template, **tmpl_values) + + +class FileUploadWidget(dfwidget.FileUploadWidget): + """ + Widget to handle file upload. Must override to add ``use_oruga`` + to field template context. + """ + + def __init__(self, *args, **kwargs): + self.request = kwargs.pop('request') + super().__init__(*args, **kwargs) + + def get_template_values(self, field, cstruct, kw): + values = super().get_template_values(field, cstruct, kw) + if self.request: + values['use_oruga'] = self.request.use_oruga + return values + + +class MultiFileUploadWidget(dfwidget.FileUploadWidget): + """ + Widget to handle multiple (arbitrary number) of file uploads. + """ + template = 'multi_file_upload' + requirements = () + + def serialize(self, field, cstruct, **kw): + """ """ + if cstruct in (colander.null, None): + cstruct = [] + + if cstruct: + for fileinfo in cstruct: + uid = fileinfo['uid'] + if uid not in self.tmpstore: + self.tmpstore[uid] = fileinfo + + readonly = kw.get("readonly", self.readonly) + template = readonly and self.readonly_template or self.template + values = self.get_template_values(field, cstruct, kw) + return field.renderer(template, **values) + + def deserialize(self, field, pstruct): + """ """ + if pstruct is colander.null: + return colander.null + + # TODO: why is this a thing? pstruct == [b''] + if len(pstruct) == 1 and pstruct[0] == b'': + return colander.null + + files_data = [] + for upload in pstruct: + + data = self.deserialize_upload(upload) + if data: + files_data.append(data) + + if not files_data: + return colander.null + + return files_data + + def deserialize_upload(self, upload): + """ """ + # nb. this logic was copied from parent class and adapted + # to allow for multiple files. needs some more love. + + uid = None # TODO? + + if hasattr(upload, "file"): + # the upload control had a file selected + data = dfwidget.filedict() + data["fp"] = upload.file + filename = upload.filename + # sanitize IE whole-path filenames + filename = filename[filename.rfind("\\") + 1 :].strip() + data["filename"] = filename + data["mimetype"] = upload.type + data["size"] = upload.length + if uid is None: + # no previous file exists + while 1: + uid = self.random_id() + if self.tmpstore.get(uid) is None: + data["uid"] = uid + self.tmpstore[uid] = data + preview_url = self.tmpstore.preview_url(uid) + self.tmpstore[uid]["preview_url"] = preview_url + break + else: + # a previous file exists + data["uid"] = uid + self.tmpstore[uid] = data + preview_url = self.tmpstore.preview_url(uid) + self.tmpstore[uid]["preview_url"] = preview_url + else: + # the upload control had no file selected + if uid is None: + # no previous file exists + return colander.null + else: + # a previous file should exist + data = self.tmpstore.get(uid) + # but if it doesn't, don't blow up + if data is None: + return colander.null + return data + + +def make_customer_widget(request, **kwargs): + """ + Make a customer widget; will be either autocomplete or dropdown + depending on config. + """ + # use autocomplete widget by default + factory = CustomerAutocompleteWidget + + # caller may request dropdown widget + if kwargs.pop('dropdown', False): + factory = CustomerDropdownWidget + + else: # or, config may say to use dropdown + if request.rattail_config.getbool( + 'rattail', 'customers.choice_uses_dropdown', + default=False): + factory = CustomerDropdownWidget + + # instantiate whichever + return factory(request, **kwargs) + + +class CustomerAutocompleteWidget(JQueryAutocompleteWidget): + """ + Autocomplete widget for a + :class:`~rattail:rattail.db.model.customers.Customer` reference + field. + """ + + def __init__(self, request, *args, **kwargs): + super().__init__(*args, **kwargs) + self.request = request + app = self.request.rattail_config.get_app() + model = app.model + + # must figure out URL providing autocomplete service + if 'service_url' not in kwargs: + + # caller can just pass 'url' instead of 'service_url' + if 'url' in kwargs: + self.service_url = kwargs['url'] + + else: # use default url + self.service_url = self.request.route_url('customers.autocomplete') + + # TODO + if 'input_callback' not in kwargs: + if 'input_handler' in kwargs: + self.input_callback = input_handler + + def serialize(self, field, cstruct, **kw): + """ """ + # fetch customer to provide button label, if we have a value + if cstruct: + app = self.request.rattail_config.get_app() + model = app.model + customer = Session.get(model.Customer, cstruct) + if customer: + self.field_display = str(customer) + + return super().serialize( + field, cstruct, **kw) + + +class CustomerDropdownWidget(dfwidget.SelectWidget): + """ + Dropdown widget for a + :class:`~rattail:rattail.db.model.customers.Customer` reference + field. + """ + + def __init__(self, request, *args, **kwargs): + super().__init__(*args, **kwargs) + self.request = request + app = self.request.rattail_config.get_app() + + # must figure out dropdown values, if they weren't given + if 'values' not in kwargs: + + # use what caller gave us, if they did + if 'customers' in kwargs: + customers = kwargs['customers'] + if callable(customers): + customers = customers() + + else: # default customer list + customers = app.get_clientele_handler()\ + .get_all_customers(Session()) + + # convert customer list to option values + self.values = [(c.uuid, c.name) + for c in customers] + + +class DepartmentWidget(dfwidget.SelectWidget): + """ + Custom select widget for a Department reference field. + + Constructor accepts the normal ``values`` kwarg but if not + provided then the widget will fetch department list from Rattail + DB. + + Constructor also accepts ``required`` kwarg, which defaults to + true unless specified. + """ + + def __init__(self, request, **kwargs): + + if 'values' not in kwargs: + app = request.rattail_config.get_app() + model = app.model + departments = Session.query(model.Department)\ + .order_by(model.Department.number) + values = [(dept.uuid, str(dept)) + for dept in departments] + if not kwargs.pop('required', True): + values.insert(0, ('', "(none)")) + kwargs['values'] = values + + super().__init__(**kwargs) + + +def make_vendor_widget(request, **kwargs): + """ + Make a vendor widget; will be either autocomplete or dropdown + depending on config. + """ + # use autocomplete widget by default + factory = VendorAutocompleteWidget + + # caller may request dropdown widget + if kwargs.pop('dropdown', False): + factory = VendorDropdownWidget + + else: # or, config may say to use dropdown + app = request.rattail_config.get_app() + vendor_handler = app.get_vendor_handler() + if vendor_handler.choice_uses_dropdown(): + factory = VendorDropdownWidget + + # instantiate whichever + return factory(request, **kwargs) + + +class VendorAutocompleteWidget(JQueryAutocompleteWidget): + """ + Autocomplete widget for a Vendor reference field. + """ + + def __init__(self, request, *args, **kwargs): + super().__init__(*args, **kwargs) + self.request = request + app = self.request.rattail_config.get_app() + model = app.model + + # must figure out URL providing autocomplete service + if 'service_url' not in kwargs: + + # caller can just pass 'url' instead of 'service_url' + if 'url' in kwargs: + self.service_url = kwargs['url'] + + else: # use default url + self.service_url = self.request.route_url('vendors.autocomplete') + + # # TODO + # if 'input_callback' not in kwargs: + # if 'input_handler' in kwargs: + # self.input_callback = input_handler + + def serialize(self, field, cstruct, **kw): + """ """ + # fetch vendor to provide button label, if we have a value + if cstruct: + app = self.request.rattail_config.get_app() + model = app.model + vendor = Session.get(model.Vendor, cstruct) + if vendor: + self.field_display = str(vendor) + + return super().serialize( + field, cstruct, **kw) + + +class VendorDropdownWidget(dfwidget.SelectWidget): + """ + Dropdown widget for a Vendor reference field. + """ + + def __init__(self, request, *args, **kwargs): + super().__init__(*args, **kwargs) + self.request = request + + # must figure out dropdown values, if they weren't given + if 'values' not in kwargs: + + # use what caller gave us, if they did + if 'vendors' in kwargs: + vendors = kwargs['vendors'] + if callable(vendors): + vendors = vendors() + + else: # default vendor list + app = self.request.rattail_config.get_app() + model = app.model + vendors = Session.query(model.Vendor)\ + .order_by(model.Vendor.name)\ + .all() + + # convert vendor list to option values + self.values = [(c.uuid, c.name) + for c in vendors] diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index f918fad4..56b97b86 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.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,27 +24,24 @@ Core Grid Classes """ -from __future__ import unicode_literals, absolute_import - -import datetime -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 rattail.db import api +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 @@ -53,46 +50,225 @@ from tailbone.util import raw_datetime log = logging.getLogger(__name__) -class FieldList(list): +class Grid(WuttaGrid): """ - Convenience wrapper for a field list. + Base class for all grids. + + This is now a subclass of + :class:`wuttaweb:wuttaweb.grids.base.Grid`, and exists to add + customizations which have traditionally been part of Tailbone. + + 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/ + + .. 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 + + Dict of "raw" field renderers. See also + :meth:`set_raw_renderer()`. + + When present, these are rendered "as-is" into the grid + template, whereas the more typical scenario involves rendering + each field "into" a span element, like: + + .. code-block:: html + + <span v-html="RENDERED-FIELD"></span> + + So instead of injecting into a span, any "raw" fields defined + via this dict, will be injected as-is, like: + + .. code-block:: html + + RENDERED-FIELD + + Note that each raw renderer is called only once, and *without* + any arguments. Likely the only use case for this, is to inject + a Vue component into the field. A basic example:: + + from webhelpers2.html import HTML + + def myrender(): + return HTML.tag('my-component', **{'v-model': 'props.row.myfield'}) + + grid = Grid( + # ..normal constructor args here.. + + raw_renderers={ + '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 insert_before(self, field, newfield): - i = self.index(field) - self.insert(i, newfield) + 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')) - def insert_after(self, field, newfield): - i = self.index(field) - self.insert(i + 1, newfield) + 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')) -class Grid(object): - """ - Core grid class. In sore need of documentation. - """ + 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')) - 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=[], - extra_row_class=None, linked_columns=[], url='#', - joiners={}, filterable=False, filters={}, use_byte_string_filters=False, - sortable=False, sorters={}, default_sortkey=None, default_sortdir='asc', - pageable=False, default_pagesize=20, default_page=1, - checkboxes=False, checked=None, check_handler=None, check_all_handler=None, - clicking_row_checks_box=False, - main_actions=[], more_actions=[], delete_speedbump=False, - ajax_data_url=None, component='tailbone-grid', - **kwargs): + 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')) - 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 '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'): @@ -105,29 +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.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 @@ -135,44 +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.main_actions = main_actions or [] - self.more_actions = more_actions or [] + self.click_handlers = click_handlers 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): """ @@ -182,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): @@ -197,7 +423,7 @@ class Grid(object): """ Mark the given column as "invisible" (but do not remove it). - Use :meth:`hide_column()` if you actually want to remove it. + Use :meth:`remove()` if you actually want to remove it. """ if invisible: if key not in self.invisible: @@ -206,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) @@ -217,53 +440,81 @@ class Grid(object): def replace(self, oldfield, newfield): self.insert_after(oldfield, newfield) - self.hide_column(oldfield) + 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) + """ """ + 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 + + # 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: + self.click_handlers[key] = handler 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) + self.click_handlers.pop(key, None) - def remove_filter(self, key): - self.filters.pop(key, None) + def has_click_handler(self, key): + return key in self.click_handlers - 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): + def set_raw_renderer(self, key, renderer): """ - Returns the label text for given field key. + Set or remove the "raw" renderer for the given field. + + See :attr:`raw_renderers` for more about these. + + :param key: Field name. + + :param renderer: Either a renderer callable, or ``None``. """ - 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) - - def set_renderer(self, key, renderer): - self.renderers[key] = renderer + if renderer: + self.raw_renderers[key] = renderer + else: + self.raw_renderers.pop(key, None) def set_type(self, key, type_): if type_ == 'boolean': @@ -306,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) @@ -329,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): @@ -338,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) @@ -348,26 +619,28 @@ class Grid(object): return value.pretty() def render_percent(self, obj, column_name): + app = self.request.rattail_config.get_app() value = self.obtain_value(obj, column_name) - if value is None: - return "" - return "{:0.3f} %".format(value * 100) + return app.render_percent(value, places=3) 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): - value = self.obtain_value(obj, column_name) - if value is None: + seconds = self.obtain_value(obj, column_name) + if seconds is None: return "" - return pretty_hours(datetime.timedelta(seconds=value)) + app = self.request.rattail_config.get_app() + return app.render_duration(seconds=seconds) def render_duration_hours(self, obj, field): 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 @@ -377,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. @@ -463,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): """ @@ -500,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. @@ -521,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): @@ -529,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): """ @@ -550,66 +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) - return lambda q, d: q.order_by(getattr(column, d)()) - 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 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. - """ + 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) + + if self.default_pagesize: + return self.default_pagesize + + return self.get_pagesize() + + 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')) + + 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.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(): @@ -617,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 @@ -645,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: @@ -674,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'] @@ -694,19 +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) - return api.get_setting(session, key) is not None + app = self.request.rattail_config.get_app() + + # 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) - value = api.get_setting(Session(), skey) + value = app.get_setting(session, f'{prefix}.{key}') settings[key] = normalize(value) if self.filterable: @@ -716,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): @@ -753,153 +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) - api.save_setting(Session(), skey, value(key)) - else: # to == session + app.save_setting(Session(), skey, value(key)) + 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') @@ -923,86 +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): - 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() @@ -1013,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): """ @@ -1085,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(): @@ -1115,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, @@ -1123,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} @@ -1198,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" @@ -1221,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 = [] @@ -1255,9 +1443,18 @@ 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] - row = {} + + # nb. cache 0-based index on the row, in case client-side + # 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 @@ -1265,26 +1462,43 @@ class Grid(object): # 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) @@ -1304,6 +1518,8 @@ class Grid(object): results = { 'data': data, + 'row_classes': status_map, + # TODO: deprecate / remove this 'row_status_map': status_map, } @@ -1311,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 @@ -1325,127 +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_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): """ @@ -1460,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 06c4e7db..7e52bb8d 100644 --- a/tailbone/grids/filters.py +++ b/tailbone/grids/filters.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,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 @@ -76,8 +74,7 @@ class NumericValueRenderer(FilterValueRenderer): """ Input renderer for numeric values. """ - # TODO - # data_type = 'number' + data_type = 'number' def render(self, value=None, **kwargs): kwargs.setdefault('step', '0.001') @@ -118,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): @@ -137,6 +134,7 @@ class GridFilter(object): 'less_equal': "less than or equal to", 'is_empty': "is empty", 'is_not_empty': "is not empty", + 'between': "between", 'is_null': "is null", 'is_not_null': "is not null", 'is_true': "is true", @@ -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 @@ -378,6 +416,47 @@ class AlchemyGridFilter(GridFilter): return query return query.filter(self.column <= self.encode_value(value)) + def filter_between(self, query, value): + """ + Filter data with a "between" query. Really this uses ">=" and + "<=" (inclusive) logic instead of SQL "between" keyword. + """ + if value is None or value == '': + return query + + if '|' not in value: + return query + + values = value.split('|') + if len(values) != 2: + return query + + start_value, end_value = values + + # we'll only filter if we have start and/or end value + if not start_value and not end_value: + return query + + return self.filter_for_range(query, start_value, end_value) + + def filter_for_range(self, query, start_value, end_value): + """ + This method should actually apply filter(s) to the query, + according to the given value range. Subclasses may override + this logic. + """ + if start_value: + if self.value_invalid(start_value): + return query + 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 <= self.encode_value(end_value)) + + return query + class AlchemyStringFilter(AlchemyGridFilter): """ @@ -388,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', @@ -402,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): """ @@ -413,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): """ @@ -449,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 @@ -476,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): @@ -494,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 @@ -505,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): """ @@ -515,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): @@ -530,9 +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', '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 @@ -541,43 +645,69 @@ class AlchemyNumericFilter(AlchemyGridFilter): # term for integer field... def value_invalid(self, value): - return bool(value and len(six.text_type(value)) > 8) + + # first just make sure it's somewhat numeric + try: + self.parse_decimal(value) + except decimal.InvalidOperation: + return True + + return bool(value and len(str(value)) > 8) + + def parse_decimal(self, value): + if value: + value = value.replace(',', '') + return decimal.Decimal(value) + + def encode_value(self, value): + if value: + value = str(self.parse_decimal(value)) + return super().encode_value(value) def filter_equal(self, query, value): if self.value_invalid(value): return query - return super(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: @@ -585,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 @@ -597,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. @@ -675,6 +813,9 @@ class AlchemyDateFilter(AlchemyGridFilter): Convert user input to a proper ``datetime.date`` object. """ if value: + if isinstance(value, datetime.date): + return value + try: dt = datetime.datetime.strptime(value, '%Y-%m-%d') except ValueError: @@ -682,6 +823,48 @@ class AlchemyDateFilter(AlchemyGridFilter): else: return dt.date() + def filter_equal(self, query, value): + date = self.make_date(value) + if not date: + return query + + return query.filter(self.column == self.encode_value(date)) + + def filter_not_equal(self, query, value): + date = self.make_date(value) + if not date: + return query + + return query.filter(sa.or_( + self.column == None, + self.column != self.encode_value(date), + )) + + def filter_greater_than(self, query, value): + date = self.make_date(value) + if not date: + return query + return query.filter(self.column > self.encode_value(date)) + + def filter_greater_equal(self, query, value): + date = self.make_date(value) + if not date: + return query + return query.filter(self.column >= self.encode_value(date)) + + def filter_less_than(self, query, value): + date = self.make_date(value) + if not date: + return query + return query.filter(self.column < self.encode_value(date)) + + def filter_less_equal(self, query, value): + date = self.make_date(value) + if not date: + return query + return query.filter(self.column <= self.encode_value(date)) + + # TODO: this should be merged into parent class def filter_between(self, query, value): """ Filter data with a "between" query. Really this uses ">=" and "<=" @@ -709,6 +892,7 @@ class AlchemyDateFilter(AlchemyGridFilter): return self.filter_date_range(query, start_date, end_date) + # TODO: this should be merged into parent class def filter_date_range(self, query, start_date, end_date): """ This method should actually apply filter(s) to the query, according to @@ -1039,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): """ @@ -1047,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): @@ -1091,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 2402e768..09d6f3f0 100644 --- a/tailbone/menus.py +++ b/tailbone/menus.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,155 +24,749 @@ App Menus """ -from __future__ import unicode_literals, absolute_import +import logging +import warnings -from rattail.core import Object -from rattail.util import import_module_path +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 -class MenuGroup(Object): - title = None - items = None - is_menu = True - is_link = False +log = logging.getLogger(__name__) -class MenuItem(Object): - title = None - url = None - target = None - is_link = True - is_menu = False - is_sep = False - - -class MenuItemMenu(Object): - title = None - items = None - is_menu = True - is_sep = False - - -class MenuSeparator(object): - is_menu = False - is_sep = True - - -def make_simple_menus(request): +class TailboneMenuHandler(WuttaMenuHandler): """ - Build the main menu list for the app. + Base class and default implementation for menu handler. """ - 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)) + ############################## + # internal methods + ############################## - # collect "simple" menus definition, but must refine that somewhat to - # produce our final menus - raw_menus = menus_module.simple_menus(request) - mark_allowed(request, raw_menus) - final_menus = [] - for topitem in raw_menus: + 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 - if topitem['allowed']: + 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.get('type') == 'link': - final_menus.append(make_menu_entry(topitem)) + # 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') - else: # assuming 'menu' type + # okay, no config, so menus will be built from code + return self.make_menus(request, **kwargs) - menu_items = [] - for item in topitem['items']: - if not item['allowed']: - continue + def _make_menus_from_config(self, request, **kwargs): + """ + Try to build a complete menu set from config/settings. - # nested submenu - if item.get('type') == 'menu': - submenu_items = [] - for subitem in item['items']: - if subitem['allowed']: - submenu_items.append(make_menu_entry(subitem)) - menu_items.append(MenuItemMenu( - title=item['title'], - items=submenu_items)) + 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 - 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(item)) + model = self.app.model + menus = [] - else: # standard menu item - menu_items.append(make_menu_entry(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): - # remove final separator if present - if menu_items and menu_items[-1].is_sep: - menu_items.pop() + # 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)) - # only add if we wound up with something - assert menu_items - if menu_items: - final_menus.append(MenuGroup( - title=topitem['title'], - items=menu_items)) + else: # read from config file only + for key in main_keys: + menus.append(self._make_single_menu_from_config(request, key)) - return final_menus + return menus + 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': [], + } -def make_menu_entry(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 MenuSeparator() + # title + title = self.config.get('tailbone.menu', + 'menu.{}.label'.format(key), + usedb=False) + menu['title'] = title or prettify(key) - # standard menu item - return MenuItem( - title=item['title'], - url=item['url'], - target=item.get('target')) + # items + item_keys = self.config.getlist('tailbone.menu', + 'menu.{}.items'.format(key), + usedb=False) + for item_key in item_keys: + item = {} + if item_key == 'SEP': + item['type'] = 'sep' -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 + else: + item['type'] = 'item' + item['key'] = item_key + # title + title = self.config.get('tailbone.menu', + 'menu.{}.item.{}.label'.format(key, item_key), + usedb=False) + item['title'] = title or prettify(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: - - 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/progress.py b/tailbone/progress.py index 90fa21be..5c45f390 100644 --- a/tailbone/progress.py +++ b/tailbone/progress.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2018 Lance Edgar +# Copyright © 2010-2022 Lance Edgar # # This file is part of Rattail. # @@ -27,22 +27,33 @@ Progress Indicator from __future__ import unicode_literals, absolute_import import os +import warnings from rattail.progress import ProgressBase from beaker.session import Session +def get_basic_session(config, request={}, **kwargs): + """ + Create/get a "basic" Beaker session object. + """ + kwargs['use_cookies'] = False + session = Session(request, **kwargs) + return session + + def get_progress_session(request, key, **kwargs): """ Create/get a Beaker session object, to be used for progress. """ - id = '{}.progress.{}'.format(request.session.id, key) - kwargs['use_cookies'] = False + kwargs['id'] = '{}.progress.{}'.format(request.session.id, key) if kwargs.get('type') == 'file': + warnings.warn("Passing a 'type' kwarg to get_progress_session() " + "is deprecated...i think", + DeprecationWarning, stacklevel=2) kwargs['data_dir'] = os.path.join(request.rattail_config.appdir(), 'sessions') - session = Session(request, id, **kwargs) - return session + return get_basic_session(request.rattail_config, request, **kwargs) class SessionProgress(ProgressBase): @@ -52,11 +63,20 @@ class SessionProgress(ProgressBase): This class is only responsible for keeping the progress *data* current. It is the responsibility of some client-side AJAX (etc.) to consume the data for display to the user. + + :param ws: If true, then websockets are assumed, and the progress will + behave accordingly. The default is false, "traditional" behavior. """ - def __init__(self, request, key, session_type=None): + def __init__(self, request, key, session_type=None, ws=False): self.key = key - self.session = get_progress_session(request, key, type=session_type) + self.ws = ws + + if self.ws: + self.session = get_basic_session(request.rattail_config, id=key) + else: + self.session = get_progress_session(request, key, type=session_type) + self.canceled = False self.clear() diff --git a/tailbone/providers.py b/tailbone/providers.py new file mode 100644 index 00000000..a538fa73 --- /dev/null +++ b/tailbone/providers.py @@ -0,0 +1,62 @@ +# -*- 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/>. +# +################################################################################ +""" +Providers for Tailbone features +""" + +from __future__ import unicode_literals, absolute_import + +from rattail.util import load_entry_points + + +class TailboneProvider(object): + """ + Base class for Tailbone providers. These are responsible for + declaring which things a given project makes available to the app. + (Or at least the things which should be easily configurable.) + """ + + def __init__(self, config): + self.config = config + + def configure_db_sessions(self, rattail_config, pyramid_config): + pass + + def get_static_includes(self): + pass + + def get_provided_views(self): + return {} + + def make_integration_menu(self, request, **kwargs): + pass + + +def get_all_providers(config): + """ + Returns a dict of all registered providers. + """ + providers = load_entry_points('tailbone.providers') + for key in list(providers): + providers[key] = providers[key](config) + return providers 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/files/newproduct_template.xlsx b/tailbone/static/files/newproduct_template.xlsx new file mode 100644 index 00000000..82ce5ff1 Binary files /dev/null and b/tailbone/static/files/newproduct_template.xlsx differ diff --git a/tailbone/static/files/vendor_catalog_template.xlsx b/tailbone/static/files/vendor_catalog_template.xlsx new file mode 100644 index 00000000..f68be31d Binary files /dev/null and b/tailbone/static/files/vendor_catalog_template.xlsx differ diff --git a/tailbone/static/img/testing.png b/tailbone/static/img/testing.png index 281ec8cf..7228b334 100644 Binary files a/tailbone/static/img/testing.png and b/tailbone/static/img/testing.png differ 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.autocomplete.js b/tailbone/static/js/tailbone.buefy.autocomplete.js index 9b28f6b3..b4070fab 100644 --- a/tailbone/static/js/tailbone.buefy.autocomplete.js +++ b/tailbone/static/js/tailbone.buefy.autocomplete.js @@ -4,16 +4,61 @@ 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 buefy + // autocomplete component `name` prop name: String, + + // 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 value: 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, - assignedValue: 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, + + // TODO: pretty sure this can be ignored..? + // (should deprecate / remove if so) + assignedValue: String, }, data() { + + // 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.value) { selected = { @@ -21,89 +66,71 @@ const TailboneAutocomplete = { label: this.initialLabel, } } + 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 data: [], + + // this tracks our "currently selected option" - per above selected: selected, - isFetching: false, + + // since we are wrapping a component which also makes use + // of the "value" paradigm, we must separate the concerns. + // so we use our own `value` prop to interact with the + // caller, but then we use this `buefyValue` data point to + // communicate with the buefy autocomplete component. + // note that `this.value` will always be either a uuid or + // null, whereas `this.buefyValue` may be raw text as + // entered by the user. + buefyValue: this.value, + + // // TODO: we are "setting" this at the appropriate time, + // // but not clear if that actually affects anything. + // // should we just remove it? + // isFetching: false, } }, - watch: { - value(to, from) { - if (from && !to) { - this.clearSelection(false) - } - }, - }, + // watch: { + // // TODO: yikes this feels hacky. what happens is, when the + // // caller explicitly assigns a new UUID value to the tailbone + // // autocomplate component, the underlying buefy autocomplete + // // component was not getting the new value. so here we are + // // explicitly making sure it is in sync. this issue was + // // discovered on the "new vendor catalog batch" page + // value(val) { + // this.$nextTick(() => { + // if (this.buefyValue != val) { + // this.buefyValue = val + // } + // }) + // }, + // }, methods: { - clearSelection(focus) { - if (focus === undefined) { - focus = true - } - this.selected = null - this.value = null - if (focus) { - this.$nextTick(function() { - this.focus() - }) - } - - // TODO: should emit event for caller logic (can they cancel?) - // $('#' + oid + '-textbox').trigger('autocompletevaluecleared'); - }, - - focus() { - this.$refs.autocomplete.focus() - }, - - getDisplayText() { - if (this.assignedLabel) { - return this.assignedLabel - } - if (this.selected) { - return this.selected.display || this.selected.label - } - return "" - }, - - // TODO: should we allow custom callback? or is event enough? - // function (oid) { - // $('#' + oid + '-textbox').on('autocompletevaluecleared', function() { - // ${cleared_callback}(); - // }); - // } - - selectionMade(option) { - this.selected = option - - // TODO: should emit event for caller logic (can they cancel?) - // $('#' + oid + '-textbox').trigger('autocompletevalueselected', - // [ui.item.value, ui.item.label]); - }, - - // TODO: should we allow custom callback? or is event enough? - // function (oid) { - // $('#' + oid + '-textbox').on('autocompletevalueselected', function(event, uuid, label) { - // ${selected_callback}(uuid, label); - // }); - // } - - itemSelected(value) { - if (this.selected || !value) { - this.$emit('input', value) - } - }, - - // TODO: buefy example uses `debounce()` here and perhaps we should too? - // https://buefy.org/documentation/autocomplete + // fetch new search results from the server. this is invoked + // via the `@typing` event from buefy autocomplete component. + // the doc at https://buefy.org/documentation/autocomplete + // mentions `debounce` as being optional. at one point i + // thought it would fix a performance bug; not sure `debounce` + // helped but figured might as well leave it getAsyncData: debounce(function (entry) { + + // 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 } - this.isFetching = true + + // and perform the search this.$http.get(this.serviceUrl + '?term=' + encodeURIComponent(entry)) .then(({ data }) => { this.data = data @@ -112,10 +139,96 @@ const TailboneAutocomplete = { this.data = [] throw error }) - .finally(() => { - this.isFetching = false - }) }), + + // this method is invoked via the `@select` event of the buefy + // autocomplete component. the `option` received will either + // be `null` or else a simple object with (at least) `value` + // and `label` properties + selectionMade(option) { + + // we want to keep track of the "currently selected + // option" so we can display its label etc. also this + // helps control the visibility of the autocomplete input + // field vs. the button which indicates the field has a + // value + this.selected = option + + // reset the internal value for buefy 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.buefyValue = null + + // here is where we alert callers to the new value + if (option) { + this.$emit('new-label', option.label) + } + this.$emit('input', option ? option.value : null) + }, + + // 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) { + + // clear selection for the buefy autocomplete component + this.$refs.autocomplete.setSelected(null) + + // maybe set focus to our (autocomplete) component + if (focus) { + this.$nextTick(function() { + this.focus() + }) + } + }, + + // set focus to this component, which will just set focus to + // the buefy autocomplete component + focus() { + this.$refs.autocomplete.focus() + }, + + // this determines the "display text" for the button, which is + // shown when a selection has been made (or rather, when the + // field actually has a value) + getDisplayText() { + + // always use the "assigned" label if we have one + // TODO: where is this used? what is the use case? + if (this.assignedLabel) { + return this.assignedLabel + } + + // if we have a "currently selected option" then use its + // label. all search results / options have a `label` + // property as that is shown directly in the autocomplete + // dropdown. but if the option also has a `display` + // property then that is what we will show in the button. + // this way search results can show one thing in the + // search dropdown, and another in the button. + if (this.selected) { + return this.selected.display || this.selected.label + } + + // we have nothing to go on here.. + return "" + }, + + // returns the "raw" user input from the underlying buefy + // autocomplete component + getUserInput() { + return this.buefyValue + }, }, } 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 a4139bc6..00000000 --- a/tailbone/static/js/tailbone.buefy.grid.js +++ /dev/null @@ -1,107 +0,0 @@ - -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.numericinput.js b/tailbone/static/js/tailbone.buefy.numericinput.js index 3fc0d74f..b2f2ac0c 100644 --- a/tailbone/static/js/tailbone.buefy.numericinput.js +++ b/tailbone/static/js/tailbone.buefy.numericinput.js @@ -20,7 +20,7 @@ const NumericInput = { props: { name: String, - value: String, + value: [Number, String], placeholder: String, iconPack: String, icon: String, @@ -53,6 +53,10 @@ const NumericInput = { } }, + select() { + this.$el.children[0].select() + }, + valueChanged(value) { this.$emit('input', value) } 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 20fcf36e..00000000 --- a/tailbone/static/themes/falafel/css/layout.css +++ /dev/null @@ -1,134 +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 .level #current-context, -header .level-left #current-context { - font-size: 2em; - font-weight: bold; -} - -header .level #current-context span, -header .level-left #current-context span { - 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-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 e83b59ed..00000000 --- a/tailbone/static/themes/falafel/js/tailbone.feedback.js +++ /dev/null @@ -1,39 +0,0 @@ - -let FeedbackForm = { - props: ['action', 'message'], - template: '#feedback-template', - mixins: [FormPosterMixin], - methods: { - - showFeedback() { - this.showDialog = true - this.$nextTick(function() { - this.$refs.textarea.focus() - }) - }, - - sendFeedback() { - - let params = { - referrer: this.referrer, - user: this.userUUID, - user_name: this.userName, - message: this.message.trim(), - } - - this.submitForm(this.action, params, response => { - alert("Message successfully sent.\n\nThank you for your feedback.") - this.showDialog = false - // clear out message, in case they need to send another - this.message = "" - }) - }, - } -} - -let FeedbackFormData = { - referrer: null, - userUUID: null, - userName: null, - showDialog: false, -} diff --git a/tailbone/subscribers.py b/tailbone/subscribers.py index 5468df7f..268d4818 100644 --- a/tailbone/subscribers.py +++ b/tailbone/subscribers.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,150 +24,181 @@ 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 -from rattail.db import model import colander 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 -from tailbone.menus import make_simple_menus -from tailbone.util import should_use_buefy +from tailbone.config import csrf_header_name, should_expose_websockets +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 - request.user = None - uuid = request.authenticated_userid - if uuid: - request.user = Session.query(model.User).get(uuid) - if request.user: - # assign user to the session, for sake of versioning - Session().set_continuum_user(request.user) + # invoke main upstream logic + # nb. this sets request.wutta_config + base.new_request(event) + + 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.cache_permissions( - Session(), request.user) - else: - # TODO: not sure why this would really work, or even be - # needed, if there was no rattail config? - from rattail.db.auth import cache_permissions - request.tailbone_cached_permissions = cache_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() + config = request.wutta_config + app = config.get_app() renderer_globals = event + + # 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') - # maybe set custom stylesheet for Buefy themes - if should_use_buefy(request): - 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: @@ -177,6 +208,9 @@ def before_render(event): renderer_globals['filter_fieldname_width'] = widths[0] renderer_globals['filter_verb_width'] = widths[1] + # declare global support for websockets, or lack thereof + renderer_globals['expose_websockets'] = should_expose_websockets(config) + def add_inbox_count(event): """ @@ -189,6 +223,8 @@ def add_inbox_count(event): request = event.get('request') or threadlocal.get_current_request() if request.user: renderer_globals = event + app = request.rattail_config.get_app() + model = app.model enum = request.rattail_config.get_enum() renderer_globals['inbox_count'] = Session.query(model.Message)\ .outerjoin(model.MessageRecipient)\ @@ -199,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 79b2d952..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"> @@ -52,31 +24,50 @@ ${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> + <b-notification type="is-warning"> + Please see errors below. + </b-notification> + <b-notification type="is-warning"> + ${dform.error} + </b-notification> % endif <div class="app-wrapper"> - <div class="field-wrapper"> - <label for="settings-group">Showing Group</label> - <b-select name="settings-group" - v-model="showingGroup"> - <option value="">(All)</option> - <option v-for="group in groups" - :key="group.label" - :value="group.label"> - {{ group.label }} - </option> - </b-select> + <div class="level"> + + <div class="level-left"> + <div class="level-item"> + <b-field label="Showing Group"> + <b-select name="settings-group" + v-model="showingGroup"> + <option value="">(All)</option> + <option v-for="group in groups" + :key="group.label" + :value="group.label"> + {{ group.label }} + </option> + </b-select> + </b-field> + </div> + </div> + + <div class="level-right" + v-if="configOptions.length"> + <div class="level-item"> + <b-field label="Go To Configure..."> + <b-select v-model="gotoConfigureURL" + @input="gotoConfigure()"> + <option v-for="option in configOptions" + :key="option.url" + :value="option.url"> + {{ option.label }} + </option> + </b-select> + </b-field> + </div> + </div> + </div> <div v-for="group in groups" @@ -92,17 +83,13 @@ ## :class="'field-wrapper' + (setting.error ? ' with-error' : '')" > - <div v-if="setting.error" class="field-error"> - <span v-for="msg in setting.error_messages" - class="error-msg"> - {{ msg }} - </span> - </div> - <div style="margin-bottom: 2rem;"> <b-field horizontal - :label="setting.label"> + :label="setting.label" + :type="setting.error ? 'is-danger' : null" + ## TODO: what if there are multiple error messages? + :message="setting.error ? setting.error_messages[0] : null"> <b-checkbox v-if="setting.data_type == 'bool'" :name="setting.field_name" @@ -135,8 +122,9 @@ </b-field> - <span v-if="setting.helptext" class="instructions"> - {{ setting.helptext }} + <span v-if="setting.helptext" + v-html="setting.helptext" + class="instructions"> </span> </div> @@ -145,14 +133,14 @@ </div><!-- card --> <div class="buttons"> + <once-button tag="a" href="${form.cancel_url}" + text="Cancel"> + </once-button> <b-button type="is-primary" native-type="submit" :disabled="formSubmitting"> {{ formButtonText }} </b-button> - <once-button tag="a" href="${form.cancel_url}" - text="Cancel"> - </once-button> </div> </div><!-- app-wrapper --> @@ -162,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', @@ -186,90 +173,22 @@ return { formSubmitting: false, formButtonText: ${json.dumps(getattr(form, 'submit_label', getattr(form, 'save_label', "Submit")))|n}, + configOptions: ${json.dumps(config_options)|n}, + gotoConfigureURL: null, } }, methods: { submitForm() { this.formSubmitting = true this.formButtonText = "Working, please wait..." - } + }, + gotoConfigure() { + if (this.gotoConfigureURL) { + location.href = this.gotoConfigureURL + } + }, } }) </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 e7aad900..4c413757 100644 --- a/tailbone/templates/autocomplete.mako +++ b/tailbone/templates/autocomplete.mako @@ -1,85 +1,26 @@ ## -*- 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> <b-autocomplete ref="autocomplete" :name="name" - v-show="!assignedValue && !selected" - v-model="value" + v-show="!value && !selected" + v-model="buefyValue" :placeholder="placeholder" :data="data" @typing="getAsyncData" @select="selectionMade" - @input="itemSelected" keep-first> <template slot-scope="props"> {{ props.option.label }} </template> </b-autocomplete> - <b-button v-if="assignedValue || selected" + <b-button v-if="value || selected" style="width: 100%; justify-content: left;" - @click="clearSelection()"> + @click="clearSelection(true)"> {{ getDisplayText() }} (click to change) </b-button> diff --git a/tailbone/templates/base.mako b/tailbone/templates/base.mako index daa60e2d..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,143 +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(): - <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> @@ -174,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> @@ -258,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/newproduct/configure.mako b/tailbone/templates/batch/newproduct/configure.mako new file mode 100644 index 00000000..e4fa346a --- /dev/null +++ b/tailbone/templates/batch/newproduct/configure.mako @@ -0,0 +1,9 @@ +## -*- coding: utf-8; -*- +<%inherit file="/configure.mako" /> + +<%def name="form_content()"> + ${self.input_file_templates_section()} +</%def> + + +${parent.body()} 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/pricing/configure.mako b/tailbone/templates/batch/pricing/configure.mako new file mode 100644 index 00000000..8b5a90bb --- /dev/null +++ b/tailbone/templates/batch/pricing/configure.mako @@ -0,0 +1,22 @@ +## -*- coding: utf-8; -*- +<%inherit file="/configure.mako" /> + +<%def name="form_content()"> + + <h3 class="block is-size-3">Options</h3> + <div class="block" style="padding-left: 2rem;"> + + <b-field> + <b-checkbox name="rattail.batch.pricing.allow_future" + v-model="simpleSettings['rattail.batch.pricing.allow_future']" + native-value="true" + @input="settingsNeedSaved = true"> + Allow "future" pricing + </b-checkbox> + </b-field> + + </div> +</%def> + + +${parent.body()} diff --git a/tailbone/templates/batch/vendorcatalog/configure.mako b/tailbone/templates/batch/vendorcatalog/configure.mako new file mode 100644 index 00000000..4f91cb02 --- /dev/null +++ b/tailbone/templates/batch/vendorcatalog/configure.mako @@ -0,0 +1,47 @@ +## -*- coding: utf-8; -*- +<%inherit file="/configure.mako" /> + +<%def name="form_content()"> + ${self.input_file_templates_section()} + + <h3 class="block is-size-3">Options</h3> + <div class="block" style="padding-left: 2rem;"> + + <b-field> + <b-checkbox name="rattail.batch.vendor_catalog.allow_future" + v-model="simpleSettings['rattail.batch.vendor_catalog.allow_future']" + native-value="true" + @input="settingsNeedSaved = true"> + Allow "future" cost changes + </b-checkbox> + </b-field> + + </div> + + <h3 class="block is-size-3">Catalog Parsers</h3> + <div class="block" style="padding-left: 2rem;"> + + <p class="block"> + Only the selected parsers will be exposed to users. + </p> + + % for Parser in catalog_parsers: + <b-field message="${Parser.key}"> + <b-checkbox name="catalog_parser_${Parser.key}" + v-model="catalogParsers['${Parser.key}']" + native-value="true" + @input="settingsNeedSaved = true"> + ${Parser.display} + </b-checkbox> + </b-field> + % endfor + + </div> +</%def> + +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> + ThisPageData.catalogParsers = ${json.dumps(catalog_parsers_data)|n} + </script> +</%def> diff --git a/tailbone/templates/batch/vendorcatalog/create.mako b/tailbone/templates/batch/vendorcatalog/create.mako index 87d65c54..d9d62bd1 100644 --- a/tailbone/templates/batch/vendorcatalog/create.mako +++ b/tailbone/templates/batch/vendorcatalog/create.mako @@ -1,55 +1,39 @@ ## -*- coding: utf-8; -*- <%inherit file="/batch/create.mako" /> -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - <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(); - } + ${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) { + // this.field_model_vendor_uuid = parser.vendor_uuid + // this.vendorName = parser.vendor_name + this.$refs.vendorAutocomplete.setSelection({ + value: parser.vendor_uuid, + label: parser.vendor_name, + }) } - }); + } + } + + ${form.vue_component}.methods.vendorLabelChanging = function(label) { + this.vendorNameReplacement = label + } + + ${form.vue_component}.methods.vendorChanged = function(uuid) { + if (uuid) { + this.vendorName = this.vendorNameReplacement + this.vendorNameReplacement = null + } + } - }); </script> </%def> - -${parent.body()} diff --git a/tailbone/templates/batch/vendorcatalog/view_row.mako b/tailbone/templates/batch/vendorcatalog/view_row.mako new file mode 100644 index 00000000..0128e3b3 --- /dev/null +++ b/tailbone/templates/batch/vendorcatalog/view_row.mako @@ -0,0 +1,13 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/view_row.mako" /> + +<%def name="render_form()"> + <div class="form"> + <tailbone-form></tailbone-form> + <br /> + ${catalog_entry_diff.render_html()} + </div> +</%def> + + +${parent.body()} diff --git a/tailbone/templates/batch/view.mako b/tailbone/templates/batch/view.mako index 36b9b633..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,60 +68,42 @@ </%def> <%def name="render_status_breakdown()"> - % if 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 use_buefy: - ${status_breakdown_grid.render_buefy_table_element(data_prop='statusBreakdownData', empty_labels=True)|n} - % elif 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> + <nav class="panel"> + <p class="panel-heading">Row Status</p> + <div class="panel-block"> + <div style="width: 100%;"> + ${status_breakdown_grid} </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"> @@ -223,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"> @@ -240,122 +135,158 @@ </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}> </div> </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="render_row_grid_tools()"> + ${parent.render_row_grid_tools()} + % if master.rows_bulk_deletable and not batch.executed and master.has_perm('delete_rows'): + <b-button type="is-danger" + @click="deleteResultsInit()" + :disabled="!total" + icon-pack="fas" + icon-left="trash"> + Delete Results + </b-button> + <b-modal has-modal-card + :active.sync="deleteResultsShowDialog"> + <div class="modal-card"> - ThisPageData.statusBreakdownData = ${json.dumps(status_breakdown_grid.get_buefy_data()['data'])|n} + <header class="modal-card-head"> + <p class="modal-card-title">Delete Results</p> + </header> + + <section class="modal-card-body"> + <p class="block"> + This batch has + <span class="has-text-weight-bold">${batch.rowcount}</span> + total rows. + </p> + <p class="block"> + Your current filters have returned + <span class="has-text-weight-bold">{{ total }}</span> + results. + </p> + <p class="block"> + Would you like to + <span class="has-text-danger has-text-weight-bold"> + delete all {{ total }} + </span> + results? + </p> + </section> + + <footer class="modal-card-foot"> + <b-button @click="deleteResultsShowDialog = false"> + Cancel + </b-button> + <once-button type="is-danger" + tag="a" href="${url('{}.delete_rows'.format(route_prefix), uuid=batch.uuid)}" + icon-left="trash" + text="Delete Results"> + </once-button> + </footer> + </div> + </b-modal> + % endif +</%def> + +<%def name="render_vue_templates()"> + ${parent.render_vue_templates()} + % if master.has_worksheet_file and master.allow_worksheet(batch) and master.has_perm('worksheet'): + ${upload_worksheet_form.render_vue_template(buttons=False, form_kwargs={'ref': 'actualUploadForm'})} + % endif + % if master.handler.executable(batch) and master.has_perm('execute'): + ${execute_form.render_vue_template(form_kwargs={'ref': 'actualExecuteForm'}, buttons=False)} + % endif +</%def> + +## DEPRECATED; remains for back-compat +## nb. this is called by parent template, /form.mako +<%def name="render_form_template()"> + ## TODO: should use self.render_form_buttons() + ## ${form.render_deform(form_id='batch-form', buttons=capture(self.render_form_buttons))|n} + ${form.render_deform(form_id='batch-form', buttons=capture(buttons))|n} +</%def> + +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> + + ThisPageData.statusBreakdownData = ${json.dumps(status_breakdown_data)|n} + + ThisPage.methods.autoFilterStatus = function(row) { + this.$refs.rowGrid.setFilters([ + {key: 'status_code', + verb: 'equal', + value: row.code}, + ]) + document.getElementById('rowGrid').scrollIntoView({ + behavior: 'smooth', + }) + } + + % 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'): @@ -375,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() } @@ -390,36 +321,31 @@ this.$refs.executeBatchForm.submit() } - ${execute_form.component_studly}.methods.submit = function() { + ${execute_form.vue_component}.methods.submit = function() { this.$refs.actualExecuteForm.submit() } % endif + + % if master.rows_bulk_deletable and not batch.executed and master.has_perm('delete_rows'): + + ${rows_grid.vue_component}Data.deleteResultsShowDialog = false + + ${rows_grid.vue_component}.methods.deleteResultsInit = function() { + this.deleteResultsShowDialog = true + } + + % endif + </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 new file mode 100644 index 00000000..c7f46d21 --- /dev/null +++ b/tailbone/templates/configure-menus.mako @@ -0,0 +1,445 @@ +## -*- coding: utf-8; -*- +<%inherit file="/configure.mako" /> + +<%def name="extra_styles()"> + ${parent.extra_styles()} + <style type="text/css"> + .topmenu-dropper { + min-width: 0.8rem; + } + .topmenu-dropper:-moz-drag-over { + background-color: blue; + } + </style> +</%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> + <p class="block">Click on a menu to edit. Drag things around to rearrange.</p> + + <b-field grouped> + + <b-field grouped v-for="key in menuSequence" + :key="key"> + <span class="topmenu-dropper control" + @dragover.prevent + @dragenter.prevent + @drop="dropMenu($event, key)"> + + </span> + <b-button :type="editingMenu && editingMenu.key == key ? 'is-primary' : null" + class="control" + @click="editMenu(key)" + :disabled="editingMenu && editingMenu.key != key" + :draggable="!editingMenu" + @dragstart.native="topMenuStartDrag($event, key)"> + {{ allMenus[key].title }} + </b-button> + </b-field> + + <div class="topmenu-dropper control" + @dragover.prevent + @dragenter.prevent + @drop="dropMenu($event, '_last_')"> + + </div> + <b-button v-show="!editingMenu" + type="is-primary" + icon-pack="fas" + icon-left="plus" + @click="editMenuNew()"> + Add + </b-button> + + </b-field> + + <div v-if="editingMenu" + style="max-width: 40%;"> + + <b-field grouped> + + <b-field label="Label"> + <b-input v-model="editingMenu.title" + ref="editingMenuTitleInput"> + </b-input> + </b-field> + + <b-field label="Actions"> + <div class="buttons"> + <b-button icon-pack="fas" + icon-left="redo" + @click="editMenuCancel()"> + Revert / Cancel + </b-button> + <b-button type="is-primary" + icon-pack="fas" + icon-left="save" + @click="editMenuSave()"> + Save + </b-button> + <b-button type="is-danger" + icon-pack="fas" + icon-left="trash" + @click="editMenuDelete()"> + Delete + </b-button> + </div> + </b-field> + + </b-field> + + <b-field> + <template #label> + <span style="margin-right: 2rem;">Menu Items</span> + <b-button type="is-primary" + icon-pack="fas" + icon-left="plus" + @click="editMenuItemInitDialog()"> + Add + </b-button> + </template> + <ul class="list"> + <li v-for="item in editingMenu.items" + class="list-item" + draggable + @dragstart="menuItemStartDrag($event, item)" + @dragover.prevent + @dragenter.prevent + @drop="menuItemDrop($event, item)"> + <span :class="item.type == 'sep' ? 'has-text-info' : null"> + {{ item.type == 'sep' ? "-- separator --" : item.title }} + </span> + <span class="is-pulled-right grid-action"> + <a href="#" @click.prevent="editMenuItemInitDialog(item)"> + <i class="fas fa-edit"></i> + Edit + </a> + + <a href="#" class="has-text-danger" + @click.prevent="editMenuItemDelete(item)"> + <i class="fas fa-trash"></i> + Delete + </a> + + </span> + </li> + </ul> + </b-field> + + <b-modal has-modal-card + :active.sync="editMenuItemShowDialog"> + <div class="modal-card"> + + <header class="modal-card-head"> + <p class="modal-card-title">{{ editingMenuItem.isNew ? "Add" : "Edit" }} Item</p> + </header> + + <section class="modal-card-body"> + + <b-field label="Item Type"> + <b-select v-model="editingMenuItem.type"> + <option value="item">Route Link</option> + <option value="sep">Separator</option> + </b-select> + </b-field> + + <b-field label="Route" + v-show="editingMenuItem.type == 'item'"> + <b-select v-model="editingMenuItem.route" + @input="editingMenuItemRouteChanged"> + <option v-for="route in editMenuIndexRoutes" + :key="route.route" + :value="route.route"> + {{ route.label }} + </option> + </b-select> + </b-field> + + <b-field label="Label" + v-show="editingMenuItem.type == 'item'"> + <b-input v-model="editingMenuItem.title"> + </b-input> + </b-field> + + </section> + + <footer class="modal-card-foot"> + <b-button @click="editMenuItemShowDialog = false"> + Cancel + </b-button> + <b-button type="is-primary" + icon-pack="fas" + icon-left="save" + :disabled="editMenuItemSaveDisabled" + @click="editMenuSaveItem()"> + Save + </b-button> + </footer> + </div> + </b-modal> + + </div> + + % else: + ## not root! + + <b-notification type="is-warning"> + You must become root to configure menus! + </b-notification> + + % endif + +</%def> + +## 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} + + ThisPageData.allMenus = {} + % for topitem in menus: + ThisPageData.allMenus['${topitem['key']}'] = ${json.dumps(topitem)|n} + % endfor + + ThisPageData.editMenuIndexRoutes = ${json.dumps(index_route_options)|n} + + ThisPageData.editingMenu = null + ThisPageData.editingMenuItem = {isNew: true} + ThisPageData.editingMenuItemIndex = null + + ThisPageData.editMenuItemShowDialog = false + + // nb. this value is sent on form submit + ThisPage.computed.allMenuData = function() { + let menus = [] + for (key of this.menuSequence) { + menus.push(this.allMenus[key]) + } + return menus + } + + ThisPage.methods.editMenu = function(key) { + if (this.editingMenu) { + return + } + + // copy existing (original) menu to be edited + let original = this.allMenus[key] + this.editingMenu = { + key: key, + title: original.title, + items: [], + } + + // and copy each item separately + for (let item of original.items) { + this.editingMenu.items.push({ + key: item.key, + title: item.title, + route: item.route, + url: item.url, + perm: item.perm, + type: item.type, + }) + } + } + + ThisPage.methods.editMenuNew = function() { + + // editing brand new menu + this.editingMenu = {items: []} + + // focus title input + this.$nextTick(() => { + this.$refs.editingMenuTitleInput.focus() + }) + } + + ThisPage.methods.editMenuCancel = function(key) { + this.editingMenu = null + } + + ThisPage.methods.editMenuSave = function() { + + let key = this.editingMenu.key + if (key) { + + // update existing (original) menu with user edits + this.allMenus[key] = this.editingMenu + + } else { + + // generate makeshift key + key = this.editingMenu.title.replace(/\W/g, '') + + // add new menu to data set + this.allMenus[key] = this.editingMenu + this.menuSequence.push(key) + } + + // no longer editing + this.editingMenu = null + this.settingsNeedSaved = true + } + + ThisPage.methods.editMenuDelete = function() { + + if (confirm("Really delete this menu?")) { + let key = this.editingMenu.key + + // remove references from primary collections + let i = this.menuSequence.indexOf(key) + this.menuSequence.splice(i, 1) + delete this.allMenus[key] + + // no longer editing + this.editingMenu = null + this.settingsNeedSaved = true + } + } + + ## TODO: see also https://learnvue.co/2020/01/how-to-add-drag-and-drop-to-your-vuejs-project/#adding-drag-and-drop-functionality + + ## TODO: see also https://developer.mozilla.org/en-US/docs/Web/API/HTML_Drag_and_Drop_API + + ## TODO: maybe try out https://www.npmjs.com/package/vue-drag-drop + + ThisPage.methods.topMenuStartDrag = function(event, key) { + event.dataTransfer.setData('key', key) + } + + ThisPage.methods.dropMenu = function(event, target) { + let key = event.dataTransfer.getData('key') + if (target == key) { + return // same target + } + + let i = this.menuSequence.indexOf(key) + let j = this.menuSequence.indexOf(target) + if (i + 1 == j) { + return // same target + } + + if (target == '_last_') { + if (this.menuSequence[this.menuSequence.length-1] != key) { + this.menuSequence.splice(i, 1) + this.menuSequence.push(key) + this.settingsNeedSaved = true + } + } else { + this.menuSequence.splice(i, 1) + j = this.menuSequence.indexOf(target) + this.menuSequence.splice(j, 0, key) + this.settingsNeedSaved = true + } + } + + ThisPage.methods.menuItemStartDrag = function(event, item) { + let i = this.editingMenu.items.indexOf(item) + event.dataTransfer.setData('itemIndex', i) + } + + ThisPage.methods.menuItemDrop = function(event, item) { + let oldIndex = event.dataTransfer.getData('itemIndex') + let pruned = this.editingMenu.items.splice(oldIndex, 1) + let newIndex = this.editingMenu.items.indexOf(item) + this.editingMenu.items.splice(newIndex, 0, pruned[0]) + } + + ThisPage.methods.editMenuItemInitDialog = function(item) { + + if (item === undefined) { + this.editingMenuItemIndex = null + + // create new item to edit + this.editingMenuItem = { + isNew: true, + route: null, + title: null, + perm: null, + type: 'item', + } + + } else { + this.editingMenuItemIndex = this.editingMenu.items.indexOf(item) + + // copy existing (original item to be edited + this.editingMenuItem = { + key: item.key, + title: item.title, + route: item.route, + url: item.url, + perm: item.perm, + type: item.type, + } + } + + this.editMenuItemShowDialog = true + } + + ThisPage.methods.editingMenuItemRouteChanged = function(routeName) { + for (let route of this.editMenuIndexRoutes) { + if (route.route == routeName) { + this.editingMenuItem.title = route.label + this.editingMenuItem.perm = route.perm + break + } + } + } + + ThisPage.computed.editMenuItemSaveDisabled = function() { + if (this.editingMenuItem.type == 'item') { + if (!this.editingMenuItem.route) { + return true + } + if (!this.editingMenuItem.title) { + return true + } + } + return false + } + + ThisPage.methods.editMenuSaveItem = function() { + + if (this.editingMenuItem.isNew) { + this.editingMenu.items.push(this.editingMenuItem) + + } else { + this.editingMenu.items.splice(this.editingMenuItemIndex, + 1, + this.editingMenuItem) + } + + this.editMenuItemShowDialog = false + } + + ThisPage.methods.editMenuItemDelete = function(item) { + + if (confirm("Really delete this item?")) { + + // remove item from editing menu + let i = this.editingMenu.items.indexOf(item) + this.editingMenu.items.splice(i, 1) + } + } + + </script> +</%def> diff --git a/tailbone/templates/configure.mako b/tailbone/templates/configure.mako new file mode 100644 index 00000000..e6b128fc --- /dev/null +++ b/tailbone/templates/configure.mako @@ -0,0 +1,431 @@ +## -*- coding: utf-8; -*- +<%inherit file="/page.mako" /> + +<%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"> + <b-button type="is-primary" + @click="saveSettings" + :disabled="savingSettings" + icon-pack="fas" + icon-left="save"> + {{ savingSettings ? "Working, please wait..." : "Save All Settings" }} + </b-button> + <once-button tag="a" href="${request.current_route_url()}" + @click="undoChanges = true" + icon-left="undo" + text="Undo All Changes"> + </once-button> + </div> +</%def> + +<%def name="purge_button()"> + <b-button type="is-danger" + @click="purgeSettingsInit()" + icon-pack="fas" + icon-left="trash"> + Remove All Settings + </b-button> +</%def> + +<%def name="intro_message()"> + <p class="block"> + This page lets you modify the + % if config_preferences is not Undefined and config_preferences: + preferences + % else: + configuration + % endif + for ${config_title}. + </p> +</%def> + +<%def name="buttons_row()"> + <div class="level"> + <div class="level-left"> + + <div class="level-item"> + ${self.intro_message()} + </div> + + <div class="level-item"> + ${self.save_undo_buttons()} + </div> + </div> + + <div class="level-right"> + <div class="level-item"> + ${self.purge_button()} + </div> + </div> + </div> +</%def> + +<%def name="input_file_template_field(key)"> + <% tmpl = input_file_templates[key] %> + <b-field grouped> + + <b-field label="${tmpl['label']}"> + <b-select name="${tmpl['setting_mode']}" + v-model="inputFileTemplateSettings['${tmpl['setting_mode']}']" + @input="settingsNeedSaved = true"> + <option value="default">use default</option> + <option value="hosted">use uploaded file</option> + <option value="external">use other URL</option> + </b-select> + </b-field> + + <b-field label="File" + v-show="inputFileTemplateSettings['${tmpl['setting_mode']}'] == 'hosted'" + :message="inputFileTemplateSettings['${tmpl['setting_file']}'] ? 'This file lives on disk at: ${input_file_option_dirs[tmpl['key']]}' : null"> + <b-select name="${tmpl['setting_file']}" + v-model="inputFileTemplateSettings['${tmpl['setting_file']}']" + @input="settingsNeedSaved = true"> + <option value="">-new-</option> + <option v-for="option in inputFileTemplateFileOptions['${tmpl['key']}']" + :key="option" + :value="option"> + {{ option }} + </option> + </b-select> + </b-field> + + <b-field label="Upload" + v-show="inputFileTemplateSettings['${tmpl['setting_mode']}'] == 'hosted' && !inputFileTemplateSettings['${tmpl['setting_file']}']"> + + % if request.use_oruga: + <o-field class="file"> + <o-upload name="${tmpl['setting_file']}.upload" + v-model="inputFileTemplateUploads['${tmpl['key']}']" + v-slot="{ onclick }" + @input="settingsNeedSaved = true"> + <o-button variant="primary" + @click="onclick"> + <o-icon icon="upload" /> + <span>Click to upload</span> + </o-button> + <span class="file-name" v-if="inputFileTemplateUploads['${tmpl['key']}']"> + {{ inputFileTemplateUploads['${tmpl['key']}'].name }} + </span> + </o-upload> + </o-field> + % else: + <b-field class="file is-primary" + :class="{'has-name': !!inputFileTemplateSettings['${tmpl['setting_file']}']}"> + <b-upload name="${tmpl['setting_file']}.upload" + v-model="inputFileTemplateUploads['${tmpl['key']}']" + class="file-label" + @input="settingsNeedSaved = true"> + <span class="file-cta"> + <b-icon class="file-icon" pack="fas" icon="upload"></b-icon> + <span class="file-label">Click to upload</span> + </span> + </b-upload> + <span v-if="inputFileTemplateUploads['${tmpl['key']}']" + class="file-name"> + {{ inputFileTemplateUploads['${tmpl['key']}'].name }} + </span> + </b-field> + % endif + + </b-field> + + <b-field label="URL" expanded + v-show="inputFileTemplateSettings['${tmpl['setting_mode']}'] == 'external'"> + <b-input name="${tmpl['setting_url']}" + v-model="inputFileTemplateSettings['${tmpl['setting_url']}']" + @input="settingsNeedSaved = true"> + </b-input> + </b-field> + + </b-field> +</%def> + +<%def name="input_file_templates_section()"> + <h3 class="block is-size-3">Input File Templates</h3> + <div class="block" style="padding-left: 2rem;"> + % for key in input_file_templates: + ${self.input_file_template_field(key)} + % endfor + </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()"> + ${parent.page_content()} + + <br /> + + ${self.buttons_row()} + + <b-modal has-modal-card + :active.sync="purgeSettingsShowDialog"> + <div class="modal-card"> + + <header class="modal-card-head"> + <p class="modal-card-title">Remove All Settings</p> + </header> + + <section class="modal-card-body"> + <p class="block"> + If you like we can remove all settings for ${config_title} + from the DB. + </p> + <p class="block"> + Note that the tool normally removes all settings first, + every time you click "Save Settings" - here though you can + "just remove and not save" the settings. + </p> + <p class="block"> + Note also that this will of course + <span class="is-italic">not</span> remove any settings from + your config files, so after removing from DB, + <span class="is-italic">only</span> your config file + settings should be in effect. + </p> + </section> + + <footer class="modal-card-foot"> + <b-button @click="purgeSettingsShowDialog = false"> + Cancel + </b-button> + ${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"> + {{ purgingSettings ? "Working, please wait..." : "Remove All Settings" }} + </b-button> + ${h.end_form()} + </footer> + </div> + </b-modal> + + ${h.form(request.current_route_url(), enctype='multipart/form-data', ref='saveSettingsForm', **{'@submit': 'saveSettingsFormSubmit'})} + ${h.csrf_token(request)} + ${self.form_content()} + ${h.end_form()} +</%def> + +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> + + % if simple_settings is not Undefined: + ThisPageData.simpleSettings = ${json.dumps(simple_settings)|n} + % endif + + ThisPageData.purgeSettingsShowDialog = false + ThisPageData.purgingSettings = false + + ThisPageData.settingsNeedSaved = false + ThisPageData.undoChanges = false + ThisPageData.savingSettings = false + ThisPageData.validators = [] + + ThisPage.methods.purgeSettingsInit = function() { + this.purgeSettingsShowDialog = true + } + + ThisPage.methods.validateSettings = function() {} + + ThisPage.methods.saveSettings = function() { + 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 + } + + this.savingSettings = true + this.settingsNeedSaved = false + this.$refs.saveSettingsForm.submit() + } + + // nb. this is here to avoid auto-submitting form when user + // presses ENTER while some random input field has focus + ThisPage.methods.saveSettingsFormSubmit = function(event) { + if (!this.savingSettings) { + event.preventDefault() + } + } + + // cf. https://stackoverflow.com/a/56551646 + ThisPage.methods.beforeWindowUnload = function(e) { + if (this.settingsNeedSaved && !this.undoChanges) { + e.preventDefault() + e.returnValue = '' + } + } + + ThisPage.created = function() { + 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> diff --git a/tailbone/templates/customers/configure.mako b/tailbone/templates/customers/configure.mako new file mode 100644 index 00000000..1a6dca8b --- /dev/null +++ b/tailbone/templates/customers/configure.mako @@ -0,0 +1,113 @@ +## -*- 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.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"> + 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> + <div class="block" style="padding-left: 2rem;"> + + <b-field> + <b-checkbox name="rattail.customers.active_in_pos" + v-model="simpleSettings['rattail.customers.active_in_pos']" + native-value="true" + @input="settingsNeedSaved = true"> + Expose/track the "Active in POS" flag for customers. + </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.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 new file mode 100644 index 00000000..1cea9d1f --- /dev/null +++ b/tailbone/templates/customers/pending/view.mako @@ -0,0 +1,141 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/view.mako" /> +## <%namespace file="/util.mako" import="view_profiles_helper" /> + +<%def name="object_helpers()"> + ${parent.object_helpers()} + + % if instance.custorder_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 Orders: + </p> + <ul class="list"> + % for order in instance.custorder_records: + <li class="list-item"> + ${h.link_to(order, url('custorders.view', uuid=order.uuid))} + </li> + % endfor + </ul> + </div> + </div> + </nav> + % endif + + ## % if instance.status_code == enum.PENDING_CUSTOMER_STATUS_PENDING and master.has_any_perm('resolve_person', 'resolve_customer'): + % if instance.status_code == enum.PENDING_CUSTOMER_STATUS_PENDING and master.has_perm('resolve_person'): + <nav class="panel"> + <p class="panel-heading">Tools</p> + <div class="panel-block"> + <div style="display: flex; flex-direction: column;"> + % if master.has_perm('resolve_person'): + <div class="buttons"> + <b-button type="is-primary" + @click="resolvePersonInit()" + icon-pack="fas" + icon-left="object-ungroup"> + Resolve Person + </b-button> + </div> + % endif +## % if master.has_perm('resolve_customer'): +## <div class="buttons"> +## <b-button type="is-primary" +## icon-pack="fas" +## icon-left="object-ungroup"> +## Resolve Customer +## </b-button> +## </div> +## % endif + </div> + </div> + </nav> + + <b-modal has-modal-card + :active.sync="resolvePersonShowDialog"> + <div class="modal-card"> + ${h.form(url('{}.resolve_person'.format(route_prefix), uuid=instance.uuid), ref='resolvePersonForm')} + ${h.csrf_token(request)} + + <header class="modal-card-head"> + <p class="modal-card-title">Resolve Person</p> + </header> + + <section class="modal-card-body"> + <p class="block"> + If this Person 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.display_name}</span> + </b-field> + <b-field label="Actual Person" expanded> + <tailbone-autocomplete name="person_uuid" + v-model="resolvePersonUUID" + ref="resolvePersonAutocomplete" + service-url="${url('people.autocomplete')}"> + </tailbone-autocomplete> + </b-field> + </b-field> + </section> + + <footer class="modal-card-foot"> + <b-button @click="resolvePersonShowDialog = false"> + Cancel + </b-button> + <b-button type="is-primary" + :disabled="resolvePersonSubmitDisabled" + @click="resolvePersonSubmit()" + icon-pack="fas" + icon-left="object-ungroup"> + {{ resolvePersonSubmitting ? "Working, please wait..." : "I declare these are the same" }} + </b-button> + </footer> + ${h.end_form()} + </div> + </b-modal> + % endif +</%def> + +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> + + ThisPageData.resolvePersonShowDialog = false + ThisPageData.resolvePersonUUID = null + ThisPageData.resolvePersonSubmitting = false + + ThisPage.computed.resolvePersonSubmitDisabled = function() { + if (this.resolvePersonSubmitting) { + return true + } + if (!this.resolvePersonUUID) { + return true + } + return false + } + + ThisPage.methods.resolvePersonInit = function() { + this.resolvePersonUUID = null + this.resolvePersonShowDialog = true + this.$nextTick(() => { + this.$refs.resolvePersonAutocomplete.focus() + }) + } + + ThisPage.methods.resolvePersonSubmit = function() { + this.resolvePersonSubmitting = true + this.$refs.resolvePersonForm.submit() + } + + </script> +</%def> 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 new file mode 100644 index 00000000..16d26d21 --- /dev/null +++ b/tailbone/templates/custorders/configure.mako @@ -0,0 +1,181 @@ +## -*- coding: utf-8; -*- +<%inherit file="/configure.mako" /> + +<%def name="form_content()"> + + <h3 class="block is-size-3">Customer Handling</h3> + <div class="block" style="padding-left: 2rem;"> + + <b-field message="If not set, only a Person is required."> + <b-checkbox name="rattail.custorders.new_order_requires_customer" + v-model="simpleSettings['rattail.custorders.new_order_requires_customer']" + native-value="true" + @input="settingsNeedSaved = true"> + Require a Customer account + </b-checkbox> + </b-field> + + <b-field message="If not set, default contact info is always assumed."> + <b-checkbox name="rattail.custorders.new_orders.allow_contact_info_choice" + v-model="simpleSettings['rattail.custorders.new_orders.allow_contact_info_choice']" + native-value="true" + @input="settingsNeedSaved = true"> + Allow user to choose contact info + </b-checkbox> + </b-field> + + <div v-show="simpleSettings['rattail.custorders.new_orders.allow_contact_info_choice']" + style="padding-left: 2rem;"> + + <b-field message="Only applies if user is allowed to choose contact info."> + <b-checkbox name="rattail.custorders.new_orders.allow_contact_info_create" + v-model="simpleSettings['rattail.custorders.new_orders.allow_contact_info_create']" + native-value="true" + @input="settingsNeedSaved = true"> + Allow user to enter new contact info + </b-checkbox> + </b-field> + + <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> + <div class="block" style="padding-left: 2rem;"> + + <b-field> + <b-checkbox name="rattail.custorders.allow_case_orders" + v-model="simpleSettings['rattail.custorders.allow_case_orders']" + native-value="true" + @input="settingsNeedSaved = true"> + Allow "case" orders + </b-checkbox> + </b-field> + + <b-field> + <b-checkbox name="rattail.custorders.allow_unit_orders" + v-model="simpleSettings['rattail.custorders.allow_unit_orders']" + native-value="true" + @input="settingsNeedSaved = true"> + Allow "unit" orders + </b-checkbox> + </b-field> + + <b-field> + <b-checkbox name="rattail.custorders.product_price_may_be_questionable" + v-model="simpleSettings['rattail.custorders.product_price_may_be_questionable']" + native-value="true" + @input="settingsNeedSaved = true"> + Allow prices to be flagged as "questionable" + </b-checkbox> + </b-field> + + <b-field> + <b-checkbox name="rattail.custorders.allow_item_discounts" + v-model="simpleSettings['rattail.custorders.allow_item_discounts']" + native-value="true" + @input="settingsNeedSaved = true"> + Allow per-item discounts + </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']" + native-value="true" + @input="settingsNeedSaved = true"> + Allow re-order via past item lookup + </b-checkbox> + </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> + + +${parent.body()} diff --git a/tailbone/templates/custorders/create.mako b/tailbone/templates/custorders/create.mako index f353c8fc..382a121f 100644 --- a/tailbone/templates/custorders/create.mako +++ b/tailbone/templates/custorders/create.mako @@ -1,24 +1,19 @@ ## -*- coding: utf-8; -*- <%inherit file="/master/create.mako" /> +<%namespace name="product_lookup" file="/products/lookup.mako" /> <%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()"> @@ -32,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> @@ -52,43 +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()" --> @@ -112,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" @@ -136,11 +175,12 @@ 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"> - Refresh + icon-left="redo" + :disabled="refreshingContact"> + {{ refreshingContact ? "Refreshig" : "Refresh" }} </b-button> </div> </b-field> @@ -154,11 +194,11 @@ <div class="level-left"> <div class="level-item"> <div v-if="orderPhoneNumber"> - <p> + <p :class="addOtherPhoneNumber ? 'has-text-success': null"> {{ orderPhoneNumber }} </p> <p v-if="addOtherPhoneNumber" - class="is-size-7 is-italic"> + class="is-size-7 is-italic has-text-success"> will be added to customer record </p> </div> @@ -169,7 +209,7 @@ </div> % if allow_contact_info_choice: <div class="level-item" - % if restrict_contact_info: + % if not allow_contact_info_create: v-if="contactPhones.length > 1" % endif > @@ -180,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"> @@ -202,7 +247,7 @@ </b-radio> </b-field> - % if not restrict_contact_info: + % if allow_contact_info_create: <b-field> <b-radio v-model="existingPhoneUUID" :native-value="null"> @@ -235,7 +280,7 @@ </b-button> </footer> </div> - </b-modal> + </${b}-modal> </div> % endif @@ -248,11 +293,11 @@ <div class="level-left"> <div class="level-item"> <div v-if="orderEmailAddress"> - <p> + <p :class="addOtherEmailAddress ? 'has-text-success' : null"> {{ orderEmailAddress }} </p> <p v-if="addOtherEmailAddress" - class="is-size-7 is-italic"> + class="is-size-7 is-italic has-text-success"> will be added to customer record </p> </div> @@ -263,7 +308,7 @@ </div> % if allow_contact_info_choice: <div class="level-item" - % if restrict_contact_info: + % if not allow_contact_info_create: v-if="contactEmails.length > 1" % endif > @@ -273,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"> @@ -295,7 +345,7 @@ </b-radio> </b-field> - % if not restrict_contact_info: + % if allow_contact_info_create: <b-field> <b-radio v-model="existingEmailUUID" :native-value="null"> @@ -328,7 +378,7 @@ </b-button> </footer> </div> - </b-modal> + </${b}-modal> </div> % endif </div> @@ -362,18 +412,26 @@ <div> <b-field grouped> <b-field label="First Name"> - <span>{{ newCustomerFirstName }}</span> + <span class="has-text-success"> + {{ newCustomerFirstName }} + </span> </b-field> <b-field label="Last Name"> - <span>{{ newCustomerLastName }}</span> + <span class="has-text-success"> + {{ newCustomerLastName }} + </span> </b-field> </b-field> <b-field grouped> <b-field label="Phone Number"> - <span>{{ newCustomerPhone }}</span> + <span class="has-text-success"> + {{ newCustomerPhone }} + </span> </b-field> <b-field label="Email Address"> - <span>{{ newCustomerEmail }}</span> + <span class="has-text-success"> + {{ newCustomerEmail }} + </span> </b-field> </b-field> </div> @@ -395,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"> @@ -406,21 +469,21 @@ <section class="modal-card-body"> <b-field grouped> <b-field label="First Name"> - <b-input v-model="editNewCustomerFirstName" + <b-input v-model.trim="editNewCustomerFirstName" ref="editNewCustomerInput"> </b-input> </b-field> <b-field label="Last Name"> - <b-input v-model="editNewCustomerLastName"> + <b-input v-model.trim="editNewCustomerLastName"> </b-input> </b-field> </b-field> <b-field grouped> <b-field label="Phone Number"> - <b-input v-model="editNewCustomerPhone"></b-input> + <b-input v-model.trim="editNewCustomerPhone"></b-input> </b-field> <b-field label="Email Address"> - <b-input v-model="editNewCustomerEmail"></b-input> + <b-input v-model.trim="editNewCustomerEmail"></b-input> </b-field> </b-field> </section> @@ -438,51 +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="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" - :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" @@ -491,153 +588,672 @@ </b-radio> </div> - <div v-show="productIsKnown"> + <div v-show="productIsKnown" + style="padding-left: 3rem; display: flex; gap: 1rem;"> - <b-field grouped> - <b-field label="Description" horizontal expanded> - <tailbone-autocomplete - ref="productDescriptionAutocomplete" - v-model="productUUID" - :assigned-value="productUUID" - :assigned-label="productDisplay" - serviceUrl="${product_autocomplete_url}" - @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-field> - <b-field grouped> - <b-field label="UPC" horizontal expanded> - <b-input v-if="!productUUID" - v-model="productUPC" - ref="productUPCInput" - @keydown.native="productUPCKeyDown"> - </b-input> - <b-button v-if="!productUUID" - type="is-primary" - icon-pack="fas" - icon-left="search" - @click="fetchProductByUPC()"> - Fetch - </b-button> - <b-button v-if="productUUID" - @click="clearProduct(true)"> - {{ productUPC }} (click to change) - </b-button> - </b-field> - <b-button v-if="productUUID" - type="is-primary" - tag="a" target="_blank" - :href="'${request.route_url('products')}/' + productUUID" - icon-pack="fas" - icon-left="external-link-alt"> - View Product - </b-button> - </b-field> + <div v-if="productUUID"> + + <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> + </div> + + <img v-if="productUUID" + :src="productImageURL" + style="max-height: 150px; max-width: 150px; "/> </div> + <br /> <div class="field"> - <b-radio v-model="productIsKnown" disabled + <b-radio v-model="productIsKnown" + % if not allow_unknown_product: + disabled + % endif :native-value="false"> Product is not yet in the system. </b-radio> </div> - </b-tab-item> - <b-tab-item label="Quantity"> + <div v-show="!productIsKnown" + style="padding-left: 5rem;"> - <b-field grouped> + <b-field grouped> + + <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" + % 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" + % if 'size' in pending_product_required_fields: + :type="pendingProduct.size ? null : 'is-danger'" + % endif + > + <b-input v-model="pendingProduct.size"> + </b-input> + </b-field> - <b-field label="Quantity" horizontal> - <b-input v-model="productQuantity"></b-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 grouped> - </b-field> - </b-tab-item> - </b-tabs> + <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" + % 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" + :key="option.value" + :value="option.value"> + {{ option.label }} + </option> + </b-select> + </b-field> + + </b-field> + + <b-field grouped> + + <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" + % 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="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;"> + </b-input> + </b-field> + + </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" + expanded /> + </b-field> + + </div> + </${b}-tab-item> + <${b}-tab-item label="Quantity" + value="quantity"> + + <div style="display: flex; gap: 1rem; white-space: nowrap;"> + + <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> + </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 ? '$' + 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"> Cancel </b-button> <b-button type="is-primary" + @click="itemDialogSave()" + :disabled="itemDialogSaveDisabled" icon-pack="fas" - icon-left="fas fa-save" - @click="itemDialogSave()"> - {{ itemDialogSaveButtonText }} + icon-left="save"> + {{ itemDialogSaving ? "Working, please wait..." : (this.editingItem ? "Update Item" : "Add Item") }} </b-button> </div> </div> </div> - </b-modal> + </${b}-modal> - <b-table v-if="items.length" - :data="items"> - <template slot-scope="props"> + % 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"> - <b-table-column field="product_upc_pretty" label="UPC"> - {{ props.row.product_upc_pretty }} - </b-table-column> + <header class="modal-card-head"> + <p class="modal-card-title">Confirm Price</p> + </header> - <b-table-column field="product_brand" label="Brand"> - {{ props.row.product_brand }} - </b-table-column> + <section class="modal-card-body"> + <p class="block"> + Please confirm the price info before proceeding. + </p> - <b-table-column field="product_description" label="Description"> - {{ props.row.product_description }} - </b-table-column> + <div style="white-space: nowrap;"> - <b-table-column field="product_size" label="Size"> - {{ props.row.product_size }} - </b-table-column> + <b-field label="Unit Cost" horizontal> + <span>{{ pendingProduct.unit_cost }}</span> + </b-field> - <b-table-column field="department_display" label="Department"> - {{ props.row.department_display }} - </b-table-column> + <b-field label="Unit Reg. Price" horizontal> + <span>{{ pendingProduct.regular_price_amount }}</span> + </b-field> - <b-table-column field="order_quantity_display" label="Quantity"> - <span v-html="props.row.order_quantity_display"></span> - </b-table-column> + <b-field label="Gross Margin" horizontal> + <span>{{ pendingProductGrossMargin }}</span> + </b-field> - <b-table-column field="total_price_display" label="Total"> + </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 + % 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" + % 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" + v-slot="props" + sortable> + {{ props.row.key }} + </${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" + v-slot="props" + sortable + searchable> + {{ props.row.description }} + {{ props.row.size }} + </${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" + 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" + 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" + v-slot="props" + sortable + searchable> + {{ props.row.department_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 #empty> + <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> + </template> + </${b}-table> + + <div class="buttons"> + <b-button @click="pastItemsShowDialog = false"> + Cancel + </b-button> + <b-button type="is-primary" + icon-pack="fas" + icon-left="plus" + @click="pastItemsAddSelected()" + :disabled="!pastItemsSelected"> + Add Selected Item + </b-button> + </div> + + </div> + </div> + </${b}-modal> + % endif + + <${b}-table v-if="items.length" + :data="items" + :row-class="(row, i) => row.product_uuid ? null : 'has-text-success'"> + + <${b}-table-column :label="productKeyLabel" + v-slot="props"> + {{ props.row.product_key }} + </${b}-table-column> + + <${b}-table-column label="Brand" + v-slot="props"> + {{ props.row.product_brand }} + </${b}-table-column> + + <${b}-table-column label="Description" + v-slot="props"> + {{ props.row.product_description }} + </${b}-table-column> + + <${b}-table-column label="Size" + v-slot="props"> + {{ props.row.product_size }} + </${b}-table-column> + + <${b}-table-column label="Department" + v-slot="props"> + {{ props.row.department_display }} + </${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" + 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" + v-slot="props"> + {{ props.row.discount_percent }}{{ props.row.discount_percent ? " %" : "" }} + </${b}-table-column> + % endif + + <${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 }} - </b-table-column> + </span> + </${b}-table-column> - <b-table-column field="vendor_display" 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.index)"> - <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()} @@ -648,23 +1264,15 @@ </div> </script> -</%def> - -<%def name="make_this_page_component()"> - ${parent.make_this_page_component()} - <script type="text/javascript"> + <script> const CustomerOrderCreator = { template: '#customer-order-creator-template', + mixins: [SimpleRequestMixin], data() { - ## TODO: these should come from handler - let defaultUnitChoices = [ - {key: '${enum.UNIT_OF_MEASURE_EACH}', value: "Each"}, - {key: '${enum.UNIT_OF_MEASURE_POUND}', value: "Pound"}, - {key: '${enum.UNIT_OF_MEASURE_CASE}', value: "Case"}, - ] - let defaultUOM = '${enum.UNIT_OF_MEASURE_CASE}' + let defaultUnitChoices = ${json.dumps(default_uom_choices)|n} + let defaultUOM = ${json.dumps(default_uom)|n} return { batchAction: null, @@ -680,6 +1288,7 @@ contactDisplay: ${json.dumps(contact_display)|n}, customerEntry: null, contactProfileURL: ${json.dumps(contact_profile_url)|n}, + refreshingContact: false, orderPhoneNumber: ${json.dumps(batch.phone_number)|n}, contactPhones: ${json.dumps(contact_phones)|n}, @@ -722,10 +1331,37 @@ items: ${json.dumps(order_items)|n}, editingItem: null, showingItemDialog: false, + itemDialogSaving: false, + % if request.use_oruga: + itemDialogTab: 'product', + % else: + itemDialogTabIndex: 0, + % endif + % if allow_past_item_reorder: + pastItemsShowDialog: false, + pastItemsLoading: false, + pastItems: [], + pastItemsSelected: null, + % endif productIsKnown: true, + selectedProduct: null, productUUID: null, productDisplay: null, - productUPC: null, + productKey: null, + productKeyField: ${json.dumps(product_key_field)|n}, + productKeyLabel: ${json.dumps(product_key_label)|n}, + productSize: null, + productCaseQuantity: null, + productUnitPrice: null, + productUnitPriceDisplay: null, + productUnitRegularPriceDisplay: null, + productCasePrice: null, + productCasePriceDisplay: null, + productSalePrice: null, + productSalePriceDisplay: null, + productSaleEndsDisplay: null, + productURL: null, + productImageURL: null, productQuantity: null, defaultUnitChoices: defaultUnitChoices, productUnitChoices: defaultUnitChoices, @@ -733,8 +1369,25 @@ productUOM: defaultUOM, productCaseSize: null, - ## 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}, + % if product_price_may_be_questionable: + productPriceNeedsConfirmation: false, + % endif + + % if allow_item_discounts: + 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 + + // nb. hack to force refresh for vue3 + refreshProductDescription: 1, + refreshTotalPrice: 1, submittingOrder: false, } @@ -766,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' } } }, @@ -910,15 +1561,52 @@ return text }, - itemDialogSaveButtonText() { - return this.editingItem ? "Update Item" : "Add Item" + % 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() + " %" + } }, - submitOrderButtonText() { - if (this.submittingOrder) { - return "Working, please wait..." + itemDialogSaveDisabled() { + + if (this.itemDialogSaving) { + return true } - return "Submit this Order" + + if (this.productIsKnown) { + if (!this.productUUID) { + return true + } + + } else { + for (let field of this.pendingProductRequiredFields) { + if (!this.pendingProduct[field]) { + return true + } + } + } + + if (!this.productUOM) { + return true + } + + return false }, }, mounted() { @@ -927,12 +1615,36 @@ } }, watch: { + contactIsKnown: function(val) { - // if user has already specified a proper contact, then - // clicks the "contact is unknown" button, then we want - // to *clear out* the existing contact - if (!val && this.contactUUID) { - this.contactChanged(null) + + // when user clicks "contact is known" then we want to + // set focus to the autocomplete component + if (val) { + this.$nextTick(() => { + this.$refs.contactAutocomplete.focus() + }) + + // if user has already specified a proper contact, + // i.e. `contactUUID` is not null, *and* user has + // clicked the "contact is not yet in the system" + // button, i.e. `val` is false, then we want to *clear + // out* the existing contact selection. this is + // primarily to avoid any ambiguity. + } else if (this.contactUUID) { + 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() } }, }, @@ -987,25 +1699,17 @@ }) }, - submitBatchData(params, callback) { + 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 (callback) { - callback(response) + 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) + } }) }, @@ -1017,24 +1721,24 @@ } this.submitBatchData(params, response => { - if (response.data.error) { - this.$buefy.toast.open({ - message: "Submit failed: " + response.data.error, - type: 'is-danger', - duration: 2000, // 2 seconds - }) - this.submittingOrder = false + if (response.data.next_url) { + location.href = response.data.next_url } else { - if (response.data.next_url) { - location.href = response.data.next_url - } else { - location.reload() - } + location.reload() } + }, response => { + this.submittingOrder = false }) }, - contactChanged(uuid) { + contactChanged(uuid, callback) { + + % if allow_past_item_reorder: + // clear out the past items cache + this.pastItemsSelected = null + this.pastItems = [] + % endif + let params if (!uuid) { params = { @@ -1046,26 +1750,37 @@ 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.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() + } }) }, refreshContact() { - this.contactChanged(this.contactUUID) + this.refreshingContact = true + this.contactChanged(this.contactUUID, () => { + this.refreshingContact = false + this.$buefy.toast.open({ + message: "Contact info has been refreshed.", + type: 'is-success', + duration: 3000, // 3 seconds + }) + }) }, % if allow_contact_info_choice: @@ -1227,33 +1942,252 @@ }, + getCasePriceDisplay() { + if (this.productIsKnown) { + return this.productCasePriceDisplay + } + + let casePrice = this.getItemCasePrice() + if (casePrice) { + return "$" + casePrice + } + }, + + getItemUnitPrice() { + if (this.productIsKnown) { + return this.productSalePrice || this.productUnitPrice + } + return this.pendingProduct.regular_price_amount + }, + + getItemCasePrice() { + if (this.productIsKnown) { + return this.productCasePrice + } + + if (this.pendingProduct.regular_price_amount) { + if (this.pendingProduct.case_size) { + let casePrice = this.pendingProduct.regular_price_amount * this.pendingProduct.case_size + casePrice = casePrice.toFixed(2) + return casePrice + } + } + }, + + getItemTotalPriceDisplay() { + let basePrice = null + if (this.productUOM == '${enum.UNIT_OF_MEASURE_CASE}') { + basePrice = this.getItemCasePrice() + } else { + basePrice = this.getItemUnitPrice() + } + + if (basePrice) { + let totalPrice = basePrice * this.productQuantity + if (totalPrice) { + % if allow_item_discounts: + if (this.productDiscountPercent) { + totalPrice *= (100 - this.productDiscountPercent) / 100 + } + % endif + totalPrice = totalPrice.toFixed(2) + return "$" + totalPrice + } + } + }, + + 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) + }, + + copyPendingProductAttrs(from, to) { + to.upc = from.upc + to.item_id = from.item_id + to.scancode = from.scancode + to.brand_name = from.brand_name + to.description = from.description + to.size = from.size + to.department_uuid = from.department_uuid + to.regular_price_amount = from.regular_price_amount + to.vendor_name = from.vendor_name + to.vendor_item_code = from.vendor_item_code + to.unit_cost = from.unit_cost + to.case_size = from.case_size + to.notes = from.notes + }, + showAddItemDialog() { this.customerPanelOpen = false this.editingItem = null this.productIsKnown = true + this.selectedProduct = null this.productUUID = null this.productDisplay = null - this.productUPC = null + this.productKey = null + this.productSize = null + this.productCaseQuantity = null + this.productUnitPrice = null + this.productUnitPriceDisplay = null + this.productUnitRegularPriceDisplay = null + this.productCasePrice = null + this.productCasePriceDisplay = null + this.productSalePrice = null + this.productSalePriceDisplay = null + this.productSaleEndsDisplay = null + this.productImageURL = '${request.static_url('tailbone:static/img/product.png')}' + + this.pendingProduct = {} + this.productQuantity = 1 this.productUnitChoices = this.defaultUnitChoices this.productUOM = this.defaultUOM + + % if product_price_may_be_questionable: + this.productPriceNeedsConfirmation = false + % endif + + % if allow_item_discounts: + this.productDiscountPercent = ${json.dumps(default_item_discount)|n} + % endif + + % if request.use_oruga: + this.itemDialogTab = 'product' + % else: + this.itemDialogTabIndex = 0 + % endif this.showingItemDialog = true this.$nextTick(() => { - this.$refs.productDescriptionAutocomplete.focus() + this.$refs.productLookup.focus() }) }, - showEditItemDialog(index) { - row = this.items[index] + % if allow_past_item_reorder: + + showAddPastItem() { + this.pastItemsSelected = null + + if (!this.pastItems.length) { + this.pastItemsLoading = true + let params = { + action: 'get_past_items', + } + this.submitBatchData(params, response => { + this.pastItems = response.data.past_items + this.pastItemsLoading = false + }) + } + + this.pastItemsShowDialog = true + }, + + pastItemsAddSelected() { + this.pastItemsShowDialog = false + + let selected = this.pastItemsSelected + this.editingItem = null + this.productIsKnown = true + this.productUUID = selected.uuid + this.productDisplay = selected.full_description + this.productKey = selected.key + this.productSize = selected.size + this.productCaseQuantity = selected.case_quantity + this.productUnitPrice = selected.unit_price + this.productUnitPriceDisplay = selected.unit_price_display + this.productUnitRegularPriceDisplay = selected.unit_price_display + this.productCasePrice = selected.case_price + this.productCasePriceDisplay = selected.case_price_display + this.productSalePrice = selected.sale_price + this.productSalePriceDisplay = selected.sale_price_display + this.productSaleEndsDisplay = selected.sale_ends_display + this.productImageURL = selected.image_url + this.productURL = selected.url + this.productQuantity = 1 + this.productUnitChoices = selected.uom_choices + // TODO: seems like the default should not be so generic? + this.productUOM = this.defaultUOM + + % if product_price_may_be_questionable: + this.productPriceNeedsConfirmation = false + % endif + + // 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 + }, + + % endif + + showEditItemDialog(row) { this.editingItem = row - this.productIsKnown = true // TODO + + this.productIsKnown = !!row.product_uuid this.productUUID = row.product_uuid + + 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.productUPC = row.product_upc_pretty || row.product_upc + this.productKey = row.product_key + this.productSize = row.product_size + this.productCaseQuantity = row.case_quantity + this.productURL = row.product_url + this.productUnitPrice = row.unit_price + this.productUnitPriceDisplay = row.unit_price_display + this.productUnitRegularPriceDisplay = row.unit_regular_price_display + this.productCasePrice = row.case_price + this.productCasePriceDisplay = row.case_price_display + this.productSalePrice = row.sale_price + this.productSalePriceDisplay = row.unit_sale_price_display + this.productSaleEndsDisplay = row.sale_ends_display + this.productImageURL = row.product_image_url || '${request.static_url('tailbone:static/img/product.png')}' + + % if product_price_may_be_questionable: + this.productPriceNeedsConfirmation = row.price_needs_confirmation + % endif + this.productQuantity = row.order_quantity this.productUnitChoices = row.order_uom_choices this.productUOM = row.order_uom + % if allow_item_discounts: + this.productDiscountPercent = row.discount_percent + % endif + + // 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 }, @@ -1280,16 +2214,31 @@ }) }, - clearProduct(autofocus) { + clearProduct() { this.productUUID = null this.productDisplay = null - this.productUPC = null + this.productKey = null + this.productSize = null + this.productCaseQuantity = null + this.productUnitPrice = null + this.productUnitPriceDisplay = null + this.productUnitRegularPriceDisplay = null + this.productCasePrice = null + this.productCasePriceDisplay = null + this.productSalePrice = null + this.productSalePriceDisplay = null + this.productSaleEndsDisplay = null + this.productURL = null + this.productImageURL = null this.productUnitChoices = this.defaultUnitChoices - if (autofocus) { - this.$nextTick(() => { - this.$refs.productUPCInput.focus() - }) - } + + % if allow_item_discounts: + this.productDiscountPercent = ${json.dumps(default_item_discount)|n} + % endif + + % if product_price_may_be_questionable: + this.productPriceNeedsConfirmation = false + % endif }, setProductUnitChoices(choices) { @@ -1307,59 +2256,87 @@ } }, - fetchProductByUPC() { - let params = { - action: 'find_product_by_upc', - upc: this.productUPC, - } - this.submitBatchData(params, response => { - if (response.data.error) { - this.$buefy.toast.open({ - message: "Fetch failed: " + response.data.error, - type: 'is-warning', - duration: 2000, // 2 seconds - }) - } else { - this.productUUID = response.data.uuid - this.productUPC = response.data.upc_pretty - this.productDisplay = response.data.full_description - this.setProductUnitChoices(response.data.uom_choices) - } - }) - }, - - productUPCKeyDown(event) { - if (event.which == 13) { // Enter - this.fetchProductByUPC() - } - }, - - productChanged(uuid) { - if (uuid) { - this.productUUID = uuid + productChanged(product) { + if (product) { let params = { action: 'get_product_info', - uuid: this.productUUID, + uuid: product.uuid, } + // nb. it is possible for the handler to "swap" + // the product selection, i.e. user chooses a "per + // LB" item but the handler only allows selling by + // the "case" item. so we do not assume the uuid + // received above is the correct one, but just use + // whatever came back from handler this.submitBatchData(params, response => { - this.productUPC = response.data.upc_pretty + this.selectedProduct = response.data + + this.productUUID = response.data.uuid + this.productKey = response.data.key this.productDisplay = response.data.full_description + this.productSize = response.data.size + this.productCaseQuantity = response.data.case_quantity + this.productUnitPrice = response.data.unit_price + this.productUnitPriceDisplay = response.data.unit_price_display + this.productUnitRegularPriceDisplay = response.data.unit_price_display + this.productCasePrice = response.data.case_price + this.productCasePriceDisplay = response.data.case_price_display + 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) + + % if product_price_may_be_questionable: + this.productPriceNeedsConfirmation = false + % endif + + % 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() }) } else { this.clearProduct() } }, - itemDialogSave() { + itemDialogAttemptSave() { + this.itemDialogSaving = true let params = { product_is_known: this.productIsKnown, - product_uuid: this.productUUID, + % if product_price_may_be_questionable: + price_needs_confirmation: this.productPriceNeedsConfirmation, + % endif order_quantity: this.productQuantity, order_uom: this.productUOM, } + % if allow_item_discounts: + params.discount_percent = this.productDiscountPercent + % endif + + if (this.productIsKnown) { + params.product_uuid = this.productUUID + } else { + params.pending_product = this.pendingProduct + } + if (this.editingItem) { params.action = 'update_item' params.uuid = this.editingItem.uuid @@ -1384,16 +2361,46 @@ // also update the batch total price this.batchTotalPriceDisplay = response.data.batch.total_price_display + 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 533d8f18..4cc92bbf 100644 --- a/tailbone/templates/custorders/items/view.mako +++ b/tailbone/templates/custorders/items/view.mako @@ -1,11 +1,15 @@ ## -*- 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'): + @confirm-price="showConfirmPrice" + % endif % if master.has_perm('change_status'): @change-status="showChangeStatus" + @mark-received="markReceivedInit" % endif % if master.has_perm('add_note'): @add-note="showAddNote" @@ -18,7 +22,107 @@ <%def name="page_content()"> ${parent.page_content()} + % if master.has_perm('confirm_price'): + <b-modal has-modal-card + :active.sync="confirmPriceShowDialog"> + <div class="modal-card"> + + <header class="modal-card-head"> + <p class="modal-card-title">Confirm Price</p> + </header> + + <section class="modal-card-body"> + <p> + Please provide a note</span>: + </p> + <b-input v-model="confirmPriceNote" + ref="confirmPriceNoteField" + type="textarea" rows="2"> + </b-input> + </section> + + <footer class="modal-card-foot"> + <b-button type="is-primary" + @click="confirmPriceSave()" + :disabled="confirmPriceSaveDisabled" + icon-pack="fas" + icon-left="check"> + {{ confirmPriceSubmitText }} + </b-button> + <b-button @click="confirmPriceShowDialog = false"> + Cancel + </b-button> + </footer> + </div> + </b-modal> + ${h.form(master.get_action_url('confirm_price', instance), ref='confirmPriceForm')} + ${h.csrf_token(request)} + ${h.hidden('note', **{':value': 'confirmPriceNote'})} + ${h.end_form()} + % 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"> @@ -64,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 /> @@ -173,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 @@ -184,16 +291,63 @@ % 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'): + + ThisPageData.confirmPriceShowDialog = false + ThisPageData.confirmPriceNote = null + ThisPageData.confirmPriceSubmitting = false + + ThisPage.computed.confirmPriceSaveDisabled = function() { + if (this.confirmPriceSubmitting) { + return true + } + return false + } + + ThisPage.computed.confirmPriceSubmitText = function() { + if (this.confirmPriceSubmitting) { + return "Working, please wait..." + } + return "Confirm Price" + } + + ThisPage.methods.showConfirmPrice = function() { + this.confirmPriceNote = null + this.confirmPriceShowDialog = true + this.$nextTick(() => { + this.$refs.confirmPriceNoteField.focus() + }) + } + + ThisPage.methods.confirmPriceSave = function() { + this.confirmPriceSubmitting = true + this.$refs.confirmPriceForm.submit() + } + + % endif % 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} @@ -238,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'): @@ -246,7 +406,6 @@ ThisPageData.newNoteText = null ThisPageData.newNoteApplyAll = false ThisPageData.addNoteSubmitting = false - ThisPageData.addNoteSubmitText = "Save Note" ThisPage.computed.addNoteSaveDisabled = function() { if (!this.newNoteText) { @@ -269,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" }) } @@ -313,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 cea3c969..86f5c121 100644 --- a/tailbone/templates/datasync/changes/index.mako +++ b/tailbone/templates/datasync/changes/index.mako @@ -5,46 +5,30 @@ ${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 @@ -66,6 +50,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/datasync/configure.mako b/tailbone/templates/datasync/configure.mako new file mode 100644 index 00000000..2e444fb5 --- /dev/null +++ b/tailbone/templates/datasync/configure.mako @@ -0,0 +1,989 @@ +## -*- 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"> + + <div class="level-item"> + <p class="block"> + This tool lets you modify the DataSync configuration. + Before using it, + <a href="#" class="has-background-warning" + @click.prevent="showConfigFilesNote = !showConfigFilesNote"> + please see these notes. + </a> + </p> + </div> + + <div class="level-item"> + ${self.save_undo_buttons()} + </div> + </div> + + <div class="level-right"> + + <div class="level-item"> + ${h.form(url('datasync.restart'), **{'@submit': 'submitRestartDatasyncForm'})} + ${h.csrf_token(request)} + <b-button type="is-primary" + native-type="submit" + @click="restartDatasync" + :disabled="restartingDatasync" + icon-pack="fas" + icon-left="redo"> + {{ restartDatasyncFormButtonText }} + </b-button> + ${h.end_form()} + </div> + + <div class="level-item"> + ${self.purge_button()} + </div> + </div> + </div> +</%def> + +<%def name="form_content()"> + ${h.hidden('profiles', **{':value': 'JSON.stringify(profilesData)'})} + + <b-notification type="is-warning" + % if request.use_oruga: + v-model:active="showConfigFilesNote" + % else: + :active.sync="showConfigFilesNote" + % endif + > + ## TODO: should link to some ratman page here, yes? + <p class="block"> + This tool works by modifying settings in the DB. It + does <span class="is-italic">not</span> modify any config + files. If you intend to manage datasync watcher/consumer + config via files only then you should be sure to UNCHECK the + "Use these Settings.." checkbox near the top of page. + </p> + <p class="block"> + If you have managed config via files thus far, and want to + start using this tool to manage via DB settings instead, + that's fine - but after saving the settings via this tool + you should probably remove all + <span class="is-family-code">[rattail.datasync]</span> entries + from your config file (and restart apps) so as to avoid + confusion. + </p> + </b-notification> + + <b-field> + <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 + </b-checkbox> + </b-field> + + <div class="level"> + <div class="level-left"> + <div class="level-item"> + <h3 class="is-size-3">Watcher Profiles</h3> + </div> + </div> + <div class="level-right"> + <div class="level-item" + v-show="simpleSettings['rattail.datasync.use_profile_settings']"> + <b-button type="is-primary" + @click="newProfile()" + icon-pack="fas" + icon-left="plus"> + New Profile + </b-button> + </div> + <div class="level-item"> + <b-button @click="toggleDisabledProfiles()"> + {{ showDisabledProfiles ? "Hide" : "Show" }} Disabled + </b-button> + </div> + </div> + </div> + + <${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" + 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="sad-tear" + size="is-large"> + </b-icon> + </p> + <p>Nothing here.</p> + </div> + </section> + </template> + </${b}-table> + + <b-modal :active.sync="editProfileShowDialog"> + <div class="card"> + <div class="card-content"> + + <b-field grouped> + + <b-field label="Watcher Key" + :type="editingProfileKey ? null : 'is-danger'"> + <b-input v-model="editingProfileKey" + ref="watcherKeyInput"> + </b-input> + </b-field> + + <b-field label="Default Runas User"> + <b-input v-model="editingProfileWatcherDefaultRunas"> + </b-input> + </b-field> + + </b-field> + + <b-field grouped expanded> + + <b-field label="Watcher Spec" + :type="editingProfileWatcherSpec ? null : 'is-danger'" + expanded> + <b-input v-model="editingProfileWatcherSpec" expanded> + </b-input> + </b-field> + + <b-field label="DB Key"> + <b-input v-model="editingProfileWatcherDBKey"> + </b-input> + </b-field> + + </b-field> + + <b-field grouped> + + <b-field label="Loop Delay (seconds)"> + <b-input v-model="editingProfileWatcherDelay"> + </b-input> + </b-field> + + <b-field label="Attempts"> + <b-input v-model="editingProfileWatcherRetryAttempts"> + </b-input> + </b-field> + + <b-field label="Retry Delay (seconds)"> + <b-input v-model="editingProfileWatcherRetryDelay"> + </b-input> + </b-field> + + <b-field :label="`Kwargs (${'$'}{editingProfilePendingWatcherKwargs.length})`"> + <b-button type="is-primary" + icon-pack="fas" + icon-left="edit" + :disabled="editingWatcherKwarg" + @click="editingWatcherKwargs = !editingWatcherKwargs"> + {{ editingWatcherKwargs ? "Stop Editing" : "Edit Kwargs" }} + </b-button> + </b-field> + + </b-field> + + <div v-show="editingWatcherKwargs" + style="display: flex; justify-content: end;"> + + <b-button type="is-primary" + v-show="!editingWatcherKwarg" + icon-pack="fas" + icon-left="plus" + @click="newWatcherKwarg()"> + New Watcher Kwarg + </b-button> + + <div v-show="editingWatcherKwarg"> + + <b-field grouped> + + <b-field label="Key" + :type="editingWatcherKwargKey ? null : 'is-danger'"> + <b-input v-model="editingWatcherKwargKey" + ref="watcherKwargKey"> + </b-input> + </b-field> + + <b-field label="Value" + :type="editingWatcherKwargValue ? null : 'is-danger'"> + <b-input v-model="editingWatcherKwargValue"> + </b-input> + </b-field> + + </b-field> + + <b-field grouped> + + <b-button @click="editingWatcherKwarg = null" + class="control" + > + Cancel + </b-button> + + <b-button type="is-primary" + @click="updateWatcherKwarg()" + class="control"> + Update Kwarg + </b-button> + + </b-field> + + </div> + + + <${b}-table :data="editingProfilePendingWatcherKwargs" + style="margin-left: 1rem;"> + <${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="sad-tear" + size="is-large"> + </b-icon> + </p> + <p>Nothing here.</p> + </div> + </section> + </template> + </${b}-table> + + </div> + + <div v-show="!editingWatcherKwargs" + style="display: flex;"> + + <div style="width: 40%;"> + + <b-field label="Watcher consumes its own changes" + v-if="!editingProfilePendingConsumers.length"> + <b-checkbox v-model="editingProfileWatcherConsumesSelf"> + {{ editingProfileWatcherConsumesSelf ? "Yes" : "No" }} + </b-checkbox> + </b-field> + + <${b}-table :data="editingProfilePendingConsumers" + v-if="!editingProfileWatcherConsumesSelf" + :row-class="(row, i) => row.enabled ? null : 'has-background-warning'"> + <${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="sad-tear" + size="is-large"> + </b-icon> + </p> + <p>Nothing here.</p> + </div> + </section> + </template> + </${b}-table> + + </div> + + <div v-show="!editingConsumer && !editingProfileWatcherConsumesSelf" + style="padding-left: 1rem;"> + <b-button type="is-primary" + @click="newConsumer()" + icon-pack="fas" + icon-left="plus"> + New Consumer + </b-button> + </div> + + <div v-show="editingConsumer" + style="flex-grow: 1; padding-left: 1rem; padding-right: 1rem;"> + + <b-field grouped> + + <b-field label="Consumer Key" + :type="editingConsumerKey ? null : 'is-danger'"> + <b-input v-model="editingConsumerKey" + ref="consumerKeyInput"> + </b-input> + </b-field> + + <b-field label="Runas User"> + <b-input v-model="editingConsumerRunas"> + </b-input> + </b-field> + + </b-field> + + <b-field grouped> + + <b-field label="Consumer Spec" + expanded + :type="editingConsumerSpec ? null : 'is-danger'" + > + <b-input v-model="editingConsumerSpec"> + </b-input> + </b-field> + + <b-field label="DB Key"> + <b-input v-model="editingConsumerDBKey"> + </b-input> + </b-field> + + </b-field> + + <b-field grouped> + + <b-field label="Loop Delay"> + <b-input v-model="editingConsumerDelay" + style="width: 8rem;"> + </b-input> + </b-field> + + <b-field label="Attempts"> + <b-input v-model="editingConsumerRetryAttempts" + style="width: 8rem;"> + </b-input> + </b-field> + + <b-field label="Retry Delay"> + <b-input v-model="editingConsumerRetryDelay" + style="width: 8rem;"> + </b-input> + </b-field> + + </b-field> + + <b-field grouped> + + <b-button @click="editingConsumer = null" + class="control"> + Cancel + </b-button> + + <b-button type="is-primary" + @click="updateConsumer()" + :disabled="updateConsumerDisabled" + class="control"> + Update Consumer + </b-button> + + <b-field label="Enabled" horizontal + style="margin-left: 2rem;"> + <b-checkbox v-model="editingConsumerEnabled"> + {{ editingConsumerEnabled ? "Yes" : "No" }} + </b-checkbox> + </b-field> + + </b-field> + </div> + </div> + + <br /> + <b-field grouped> + + <b-button @click="editProfileShowDialog = false" + class="control"> + Cancel + </b-button> + + <b-button type="is-primary" + class="control" + @click="updateProfile()" + :disabled="updateProfileDisabled"> + Update Profile + </b-button> + + <b-field label="Enabled" horizontal + style="margin-left: 2rem;"> + <b-checkbox v-model="editingProfileEnabled"> + {{ editingProfileEnabled ? "Yes" : "No" }} + </b-checkbox> + </b-field> + + </b-field> + + </div> + </div> + </b-modal> + + <br /> + + <h3 class="is-size-3">Misc.</h3> + + <b-field label="Supervisor Process Name" + message="This should be the complete name, including group - e.g. poser:poser_datasync" + expanded> + <b-input name="rattail.datasync.supervisor_process_name" + v-model="simpleSettings['rattail.datasync.supervisor_process_name']" + @input="settingsNeedSaved = true" + expanded> + </b-input> + </b-field> + + <b-field label="Consumer Batch Size" + message="Max number of changes to be consumed at once." + expanded> + <numeric-input name="rattail.datasync.batch_size_limit" + v-model="simpleSettings['rattail.datasync.batch_size_limit']" + @input="settingsNeedSaved = true" /> + </b-field> + + <h3 class="is-size-3">Legacy</h3> + <b-field label="Restart Command" + message="This will run as '${system_user}' system user - please configure sudoers as needed. Typical command is like: sudo supervisorctl restart poser:poser_datasync" + expanded> + <b-input name="tailbone.datasync.restart" + v-model="simpleSettings['tailbone.datasync.restart']" + @input="settingsNeedSaved = true" + expanded> + </b-input> + </b-field> + +</%def> + +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> + + ThisPageData.showConfigFilesNote = false + ThisPageData.profilesData = ${json.dumps(profiles_data)|n} + ThisPageData.showDisabledProfiles = false + + ThisPageData.editProfileShowDialog = false + ThisPageData.editingProfile = null + ThisPageData.editingProfileKey = null + ThisPageData.editingProfileWatcherSpec = null + ThisPageData.editingProfileWatcherDBKey = null + ThisPageData.editingProfileWatcherDelay = 1 + ThisPageData.editingProfileWatcherRetryAttempts = 1 + ThisPageData.editingProfileWatcherRetryDelay = 1 + ThisPageData.editingProfileWatcherDefaultRunas = null + ThisPageData.editingProfileWatcherConsumesSelf = false + ThisPageData.editingProfilePendingConsumers = [] + ThisPageData.editingProfileEnabled = true + + ThisPageData.editingConsumer = null + ThisPageData.editingConsumerKey = null + ThisPageData.editingConsumerSpec = null + ThisPageData.editingConsumerDBKey = null + ThisPageData.editingConsumerDelay = 1 + ThisPageData.editingConsumerRetryAttempts = 1 + ThisPageData.editingConsumerRetryDelay = 1 + ThisPageData.editingConsumerRunas = null + ThisPageData.editingConsumerEnabled = true + + ThisPage.computed.updateConsumerDisabled = function() { + if (!this.editingConsumerKey) { + return true + } + if (!this.editingConsumerSpec) { + return true + } + return false + } + + ThisPage.computed.updateProfileDisabled = function() { + if (this.editingConsumer) { + return true + } + if (!this.editingProfileKey) { + return true + } + if (!this.editingProfileWatcherSpec) { + return true + } + return false + } + + ThisPage.methods.toggleDisabledProfiles = function() { + 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) { + keys.push('self (watcher)') + } else { + for (let consumer of row.consumers_data) { + if (consumer.enabled) { + keys.push(consumer.key) + } + } + } + return keys.join(', ') + } + + ThisPage.methods.newProfile = function() { + this.editingProfile = {watcher_kwargs_data: []} + this.editingConsumer = null + this.editingWatcherKwargs = false + + this.editingProfileKey = null + this.editingProfileWatcherSpec = null + this.editingProfileWatcherDBKey = null + this.editingProfileWatcherDelay = 1 + this.editingProfileWatcherRetryAttempts = 1 + this.editingProfileWatcherRetryDelay = 1 + this.editingProfileWatcherDefaultRunas = null + this.editingProfileWatcherConsumesSelf = false + this.editingProfileEnabled = true + this.editingProfilePendingConsumers = [] + this.editingProfilePendingWatcherKwargs = [] + + this.editProfileShowDialog = true + this.$nextTick(() => { + this.$refs.watcherKeyInput.focus() + }) + } + + ThisPage.methods.editProfile = function(row) { + this.editingProfile = row + this.editingConsumer = null + this.editingWatcherKwargs = false + + this.editingProfileKey = row.key + this.editingProfileWatcherSpec = row.watcher_spec + this.editingProfileWatcherDBKey = row.watcher_dbkey + this.editingProfileWatcherDelay = row.watcher_delay + this.editingProfileWatcherRetryAttempts = row.watcher_retry_attempts + this.editingProfileWatcherRetryDelay = row.watcher_retry_delay + this.editingProfileWatcherDefaultRunas = row.watcher_default_runas + this.editingProfileWatcherConsumesSelf = row.watcher_consumes_self + this.editingProfileEnabled = row.enabled + + this.editingProfilePendingWatcherKwargs = [] + for (let kwarg of row.watcher_kwargs_data) { + let pending = { + original_key: kwarg.key, + key: kwarg.key, + value: kwarg.value, + } + this.editingProfilePendingWatcherKwargs.push(pending) + } + + this.editingProfilePendingConsumers = [] + for (let consumer of row.consumers_data) { + const pending = { + ...consumer, + original_key: consumer.key, + } + this.editingProfilePendingConsumers.push(pending) + } + + this.editProfileShowDialog = true + } + + ThisPageData.editingWatcherKwargs = false + ThisPageData.editingProfilePendingWatcherKwargs = [] + ThisPageData.editingWatcherKwarg = null + ThisPageData.editingWatcherKwargKey = null + ThisPageData.editingWatcherKwargValue = null + + ThisPage.methods.newWatcherKwarg = function() { + this.editingWatcherKwargKey = null + this.editingWatcherKwargValue = null + this.editingWatcherKwarg = {key: null, value: null} + this.$nextTick(() => { + this.$refs.watcherKwargKey.focus() + }) + } + + ThisPage.methods.editProfileWatcherKwarg = function(row) { + this.editingWatcherKwargKey = row.key + this.editingWatcherKwargValue = row.value + this.editingWatcherKwarg = row + } + + ThisPage.methods.updateWatcherKwarg = function() { + let pending = this.editingWatcherKwarg + let isNew = !pending.key + + pending.key = this.editingWatcherKwargKey + pending.value = this.editingWatcherKwargValue + + if (isNew) { + this.editingProfilePendingWatcherKwargs.push(pending) + } + + this.editingWatcherKwarg = null + } + + ThisPage.methods.deleteProfileWatcherKwarg = function(row) { + let i = this.editingProfilePendingWatcherKwargs.indexOf(row) + this.editingProfilePendingWatcherKwargs.splice(i, 1) + } + + ThisPage.methods.findConsumer = function(profileConsumers, key) { + for (const consumer of profileConsumers) { + if (consumer.key == key) { + return consumer + } + } + } + + ThisPage.methods.updateProfile = function() { + const row = this.editingProfile + + 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 + row.watcher_spec = this.editingProfileWatcherSpec + row.watcher_dbkey = this.editingProfileWatcherDBKey + row.watcher_delay = this.editingProfileWatcherDelay + row.watcher_retry_attempts = this.editingProfileWatcherRetryAttempts + row.watcher_retry_delay = this.editingProfileWatcherRetryDelay + row.watcher_default_runas = this.editingProfileWatcherDefaultRunas + row.watcher_consumes_self = this.editingProfileWatcherConsumesSelf + row.enabled = this.editingProfileEnabled + + // track which keys still belong (persistent) + let persistentWatcherKwargs = [] + + // transfer pending data to profile watcher kwargs + for (let pending of this.editingProfilePendingWatcherKwargs) { + persistentWatcherKwargs.push(pending.key) + if (pending.original_key) { + let kwarg = this.findOriginalWatcherKwarg(pending.original_key) + kwarg.key = pending.key + kwarg.value = pending.value + } else { + row.watcher_kwargs_data.push(pending) + } + } + + // remove any kwargs not being persisted + let removeWatcherKwargs = [] + for (let kwarg of row.watcher_kwargs_data) { + let i = persistentWatcherKwargs.indexOf(kwarg.key) + if (i < 0) { + removeWatcherKwargs.push(kwarg) + } + } + for (let kwarg of removeWatcherKwargs) { + let i = row.watcher_kwargs_data.indexOf(kwarg) + row.watcher_kwargs_data.splice(i, 1) + } + + // track which keys still belong (persistent) + let persistentConsumers = [] + + // transfer pending data to profile consumers + for (let pending of this.editingProfilePendingConsumers) { + persistentConsumers.push(pending.key) + if (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 + consumer.consumer_delay = pending.consumer_delay + consumer.consumer_retry_attempts = pending.consumer_retry_attempts + consumer.consumer_retry_delay = pending.consumer_retry_delay + consumer.consumer_runas = pending.consumer_runas + consumer.enabled = pending.enabled + } else { + row.consumers_data.push(pending) + } + } + + // remove any consumers not being persisted + let removeConsumers = [] + for (let consumer of row.consumers_data) { + let i = persistentConsumers.indexOf(consumer.key) + if (i < 0) { + removeConsumers.push(consumer) + } + } + for (let consumer of removeConsumers) { + let i = row.consumers_data.indexOf(consumer) + 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) + this.profilesData.splice(i, 1) + this.settingsNeedSaved = true + } + } + + ThisPage.methods.newConsumer = function() { + this.editingConsumerKey = null + this.editingConsumerSpec = null + this.editingConsumerDBKey = null + this.editingConsumerDelay = 1 + this.editingConsumerRetryAttempts = 1 + this.editingConsumerRetryDelay = 1 + this.editingConsumerRunas = null + this.editingConsumerEnabled = true + this.editingConsumer = {} + this.$nextTick(() => { + this.$refs.consumerKeyInput.focus() + }) + } + + ThisPage.methods.editProfileConsumer = function(row) { + this.editingConsumerKey = row.key + this.editingConsumerSpec = row.consumer_spec + this.editingConsumerDBKey = row.consumer_dbkey + this.editingConsumerDelay = row.consumer_delay + this.editingConsumerRetryAttempts = row.consumer_retry_attempts + this.editingConsumerRetryDelay = row.consumer_retry_delay + this.editingConsumerRunas = row.consumer_runas + this.editingConsumerEnabled = row.enabled + this.editingConsumer = row + } + + ThisPage.methods.updateConsumer = function() { + const pending = this.findConsumer( + this.editingProfilePendingConsumers, + this.editingConsumer.key) + const isNew = !pending.key + + pending.key = this.editingConsumerKey + pending.consumer_spec = this.editingConsumerSpec + pending.consumer_dbkey = this.editingConsumerDBKey + pending.consumer_delay = this.editingConsumerDelay + pending.consumer_retry_attempts = this.editingConsumerRetryAttempts + pending.consumer_retry_delay = this.editingConsumerRetryDelay + pending.consumer_runas = this.editingConsumerRunas + pending.enabled = this.editingConsumerEnabled + + if (isNew) { + this.editingProfilePendingConsumers.push(pending) + } + this.editingConsumer = null + } + + ThisPage.methods.deleteProfileConsumer = function(row) { + if (confirm("Are you sure you want to delete the '" + row.key + "' consumer?")) { + let i = this.editingProfilePendingConsumers.indexOf(row) + this.editingProfilePendingConsumers.splice(i, 1) + } + } + + % if request.has_perm('datasync.restart'): + ThisPageData.restartingDatasync = false + ThisPageData.restartDatasyncFormButtonText = "Restart Datasync" + ThisPage.methods.restartDatasync = function(e) { + if (this.settingsNeedSaved) { + alert("You have unsaved changes. Please save or undo them first.") + e.preventDefault() + } + } + ThisPage.methods.submitRestartDatasyncForm = function() { + this.restartingDatasync = true + this.restartDatasyncFormButtonText = "Restarting Datasync..." + } + % endif + + </script> +</%def> diff --git a/tailbone/templates/datasync/status.mako b/tailbone/templates/datasync/status.mako new file mode 100644 index 00000000..e14686f8 --- /dev/null +++ b/tailbone/templates/datasync/status.mako @@ -0,0 +1,174 @@ +## -*- coding: utf-8; -*- +<%inherit file="/page.mako" /> + +<%def name="title()">${index_title}</%def> + +<%def name="content_title()"></%def> + +<%def name="page_content()"> + % if expose_websockets and not supervisor_error: + <b-notification type="is-warning" + :active="websocketBroken" + :closable="false"> + Server connection was broken - please refresh page to see accurate status! + </b-notification> + % endif + <b-field label="Supervisor Status"> + <div style="display: flex;"> + + % if supervisor_error: + <pre class="has-background-warning">${supervisor_error}</pre> + % else: + <pre :class="(processInfo && processInfo.statename == 'RUNNING') ? 'has-background-success' : 'has-background-warning'">{{ processDescription }}</pre> + % endif + + <div style="margin-left: 1rem;"> + % if request.has_perm('datasync.restart'): + ${h.form(url('datasync.restart'), **{'@submit': 'restartProcess'})} + ${h.csrf_token(request)} + <b-button type="is-primary" + native-type="submit" + icon-pack="fas" + icon-left="redo" + :disabled="restartingProcess"> + {{ restartingProcess ? "Working, please wait..." : "Restart Process" }} + </b-button> + ${h.end_form()} + % endif + </div> + + </div> + </b-field> + + <h3 class="is-size-3">Watcher Status</h3> + + <${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_vue_vars()"> + ${parent.modify_vue_vars()} + <script> + + ThisPageData.processInfo = ${json.dumps(process_info)|n} + + ThisPage.computed.processDescription = function() { + let info = this.processInfo + if (info) { + return `${'$'}{info.group}:${'$'}{info.name} ${'$'}{info.statename} ${'$'}{info.description}` + } else { + return "NO PROCESS INFO AVAILABLE" + } + } + + ThisPageData.restartingProcess = false + ThisPageData.watchers = ${json.dumps(watcher_data)|n} + ThisPageData.consumers = ${json.dumps(consumer_data)|n} + + ThisPage.methods.restartProcess = function() { + this.restartingProcess = true + } + + % if expose_websockets and not supervisor_error: + + ThisPageData.ws = null + ThisPageData.websocketBroken = false + + ThisPage.mounted = function() { + + ## TODO: should be a cleaner way to get this url? + let url = '${url('ws.datasync.status')}' + url = url.replace(/^http(s?):/, 'ws$1:') + + this.ws = new WebSocket(url) + let that = this + + this.ws.onclose = (event) => { + // websocket closing means 1 of 2 things: + // - user navigated away from page intentionally + // - server connection was broken somehow + // only one of those is "bad" and we only want to + // display warning in 2nd case. so we simply use a + // brief delay to "rule out" the 1st scenario + setTimeout(() => { that.websocketBroken = true }, + 3000) + } + + this.ws.onmessage = (event) => { + that.processInfo = JSON.parse(event.data) + } + } + + % endif + + </script> +</%def> diff --git a/tailbone/templates/deform/autocomplete_jquery.pt b/tailbone/templates/deform/autocomplete_jquery.pt index 1533cc2b..7a15c7f0 100644 --- a/tailbone/templates/deform/autocomplete_jquery.pt +++ b/tailbone/templates/deform/autocomplete_jquery.pt @@ -3,114 +3,20 @@ 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}" service-url="${url}" v-model="${vmodel}" - initial-label="${field_display}"> + initial-label="${field_display}" + tal:attributes=":assigned-label assigned_label or 'null'; + @input input_handler|input_callback|''; + @new-label new_label_callback|'';"> + </tailbone-autocomplete> </div> diff --git a/tailbone/templates/deform/cases_units.pt b/tailbone/templates/deform/cases_units.pt index 05e06d50..b30d1d63 100644 --- a/tailbone/templates/deform/cases_units.pt +++ b/tailbone/templates/deform/cases_units.pt @@ -1,29 +1,31 @@ <!--! -*- mode: html; -*- --> <div tal:define="oid oid|field.oid; + name name|field.name; css_class css_class|field.widget.css_class; style style|field.widget.style;" i18n:domain="deform" 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 tal:define="vmodel vmodel|'field_model_' + name;" + tal:omit-tag=""> + + ${field.start_mapping()} + + <b-field label="Cases"> + <b-input name="cases" autocomplete="off" + tal:attributes="v-model string: ${vmodel + '.cases'}; + cases_attributes|field.widget.cases_attributes|{};"> + </b-input> + </b-field> + + <b-field label="Units"> + <b-input name="units" autocomplete="off" + tal:attributes="v-model string: ${vmodel + '.units'}; + units_attributes|field.widget.units_attributes|{};"> + </b-input> + </b-field> + + ${field.end_mapping()} </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> 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 59b15341..d76e5848 100644 --- a/tailbone/templates/deform/percentinput.pt +++ b/tailbone/templates/deform/percentinput.pt @@ -1,25 +1,18 @@ <span tal:define="name name|field.name; css_class css_class|field.widget.css_class; oid oid|field.oid; + field_name field_name|field.name; mask mask|field.widget.mask; mask_placeholder mask_placeholder|field.widget.mask_placeholder; style style|field.widget.style; - autocomplete autocomplete|field.widget.autocomplete|'off'; -" + autocomplete autocomplete|field.widget.autocomplete|'off';" 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 tal:define="vmodel vmodel|'field_model_' + field_name;"> + <!-- TODO: need to handle mask somehow? --> + <b-input name="${field_name}" + id="${oid}" + v-model="${vmodel}"> + </b-input> + </div> </span> 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/problem_report_days.pt b/tailbone/templates/deform/problem_report_days.pt new file mode 100644 index 00000000..ff3dd70c --- /dev/null +++ b/tailbone/templates/deform/problem_report_days.pt @@ -0,0 +1,17 @@ +<div tal:define="name name|field.name;" + tal:omit-tag=""> + <div tal:define="vmodel vmodel|'field_model_' + name;" + tal:omit-tag=""> + <b-field grouped> + <input type="hidden" name="${name}" + tal:attributes=":value 'JSON.stringify('+vmodel+')';" /> + <b-checkbox v-model="${vmodel}.day0">${day_labels[0]['abbr']}</b-checkbox> + <b-checkbox v-model="${vmodel}.day1">${day_labels[1]['abbr']}</b-checkbox> + <b-checkbox v-model="${vmodel}.day2">${day_labels[2]['abbr']}</b-checkbox> + <b-checkbox v-model="${vmodel}.day3">${day_labels[3]['abbr']}</b-checkbox> + <b-checkbox v-model="${vmodel}.day4">${day_labels[4]['abbr']}</b-checkbox> + <b-checkbox v-model="${vmodel}.day5">${day_labels[5]['abbr']}</b-checkbox> + <b-checkbox v-model="${vmodel}.day6">${day_labels[6]['abbr']}</b-checkbox> + </b-field> + </div> +</div> diff --git a/tailbone/templates/deform/select.pt b/tailbone/templates/deform/select.pt index 4d09f16f..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; @@ -69,7 +21,8 @@ native-size size; style style; v-model vmodel; - @input input_handler;"> + @input input_handler; + attributes|field.widget.attributes|{};"> <tal:loop tal:repeat="item values"> <optgroup tal:condition="isinstance(item, optgroup_class)" diff --git a/tailbone/templates/deform/select_dynamic.pt b/tailbone/templates/deform/select_dynamic.pt index 5d1de2f6..712830d1 100644 --- a/tailbone/templates/deform/select_dynamic.pt +++ b/tailbone/templates/deform/select_dynamic.pt @@ -20,12 +20,13 @@ size size; style style; v-model vmodel; - @input input_handler;"> + @input input_handler; + attributes|field.widget.attributes|{};"> <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 2e1c32ef..47621654 100644 --- a/tailbone/templates/deform/textinput.pt +++ b/tailbone/templates/deform/textinput.pt @@ -4,34 +4,16 @@ 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 name="${name}" - v-model="${vmodel}" - placeholder="${placeholder}" - autocomplete="${autocomplete}"> + <b-input tal:attributes="name name; + v-model vmodel; + placeholder placeholder; + autocomplete autocomplete; + attributes|field.widget.attributes|{};"> </b-input> </div> </span> 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 36eb0c12..f8372c88 100644 --- a/tailbone/templates/email-bounces/view.mako +++ b/tailbone/templates/email-bounces/view.mako @@ -1,59 +1,54 @@ ## -*- 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()} - <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> -</%def> - <%def name="extra_styles()"> ${parent.extra_styles()} <style type="text/css"> - #message { + .email-message-body { border: 1px solid #000000; - height: 400px; - overflow: auto; - padding: 4px; + margin-top: 2rem; + height: 500px; } </style> </%def> -<%def name="context_menu_items()"> - ${parent.context_menu_items()} - % 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 +<%def name="object_helpers()"> + ${parent.object_helpers()} + <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 + % endif + </div> + </div> + </nav> </%def> -<%def name="page_content()"> - ${parent.page_content()} - <pre id="message"> - ${message} - </pre> +<%def name="render_this_page()"> + ${parent.render_this_page()} + <pre class="email-message-body">${message}</pre> </%def> 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 5b11face..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()"> @@ -34,6 +73,9 @@ </div> <div style="display: flex; align-items: flex-start;"> + + ${before_object_helpers()} + <div class="object-helpers"> ${self.object_helpers()} </div> @@ -46,25 +88,27 @@ </div> </%def> -<%def name="render_this_page_template()"> - % if form is not Underined: - ${self.render_form()} +<%def name="before_object_helpers()"></%def> + +<%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 47c6ffd3..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) { + 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,27 +47,38 @@ 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: "Failed to send feedback: " + response.data.error, + message: "Submit failed: " + (response.data.error || + "(unknown error)"), type: 'is-danger', duration: 4000, // 4 seconds }) + if (failure) { + failure(response) + } + + } else { + success(response) } }, response => { this.$buefy.toast.open({ - message: "Failed to submit form! (unknown server error)", + message: "Submit failed! (unknown server error)", type: 'is-danger', duration: 4000, // 4 seconds }) + if (failure) { + failure(response) + } }) }, }, } + // 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 0f1ae184..00000000 --- a/tailbone/templates/forms/deform_buefy.mako +++ /dev/null @@ -1,128 +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> - % 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: - <% field = dform[field] %> - - % if form.field_visible(field.name): - <b-field horizontal - label="${form.get_label(field.name)}" - ## TODO: is this class="file" really needed? - % if isinstance(field.schema.typ, deform.FileData): - class="file" - % endif - % if field.error: - type="is-danger" - :message='${form.messages_json(field.error.messages())|n}' - % endif - > - ${field.serialize(use_buefy=True)|n} - </b-field> - % else: - ## hidden field - ${field.serialize()|n} - % endif - % endif - - % endfor - </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"> - ## 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 - % if getattr(form, 'show_reset', False): - <input type="reset" value="Reset" class="button" /> - % endif - % 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 - </div> - % endif - - % if not form.readonly: - ${h.end_form()} - % endif - </div> -</script> - -<script type="text/javascript"> - - let ${form.component_studly} = { - template: '#${form.component}-template', - components: {}, - props: {}, - 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/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 51f404ee..00000000 --- a/tailbone/templates/generate_project.mako +++ /dev/null @@ -1,336 +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="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 == '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.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 7ff33e73..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,60 +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> - % 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> @@ -91,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 63deddc9..00000000 --- a/tailbone/templates/grids/buefy.mako +++ /dev/null @@ -1,474 +0,0 @@ -## -*- coding: utf-8; -*- - -<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> - - <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="data" - ## :columns="columns" - :loading="loading" - :row-class="getRowClass" - - :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'])}" - :visible="${json.dumps(column['visible'])}"> - % if 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 ''}" - % if action.click_handler: - @click.prevent="${action.click_handler}" - % endif - > - <i class="fas fa-${action.icon}"></i> - ${action.render_label()|n} - </a> - - % endfor - </b-table-column> - % endif - </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> - - % 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', - - props: { - csrftoken: String, - }, - - computed: { - // note, can use this with v-model for hidden 'uuids' fields - selected_uuids: function() { - return this.checkedRowUUIDs().join(',') - }, - }, - - methods: { - - 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() - }, - - 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 new file mode 100644 index 00000000..2445341d --- /dev/null +++ b/tailbone/templates/importing/configure.mako @@ -0,0 +1,205 @@ +## -*- coding: utf-8; -*- +<%inherit file="/configure.mako" /> + +<%def name="form_content()"> + ${h.hidden('handlers', **{':value': 'JSON.stringify(handlersData)'})} + + <h3 class="is-size-3">Designated Handlers</h3> + + <${b}-table :data="handlersData" + narrowed + icon-pack="fas" + :default-sort="['host_title', 'asc']"> + <${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"> + <div class="card-content"> + + <b-field :label="editingHandlerDirection" horizontal expanded> + {{ editingHandlerHostTitle }} -> {{ editingHandlerLocalTitle }} + </b-field> + + <b-field label="Handler Spec" + :type="editingHandlerSpec ? null : 'is-danger'"> + <b-select v-model="editingHandlerSpec"> + <option v-for="option in editingHandlerSpecOptions" + :key="option" + :value="option"> + {{ option }} + </option> + </b-select> + </b-field> + + <b-field grouped> + + <b-field label="Command" + :type="editingHandlerCommand ? null : 'is-danger'"> + <div class="level"> + <div class="level-left"> + <div class="level-item" style="margin-right: 0;"> + bin/ + </div> + <div class="level-item" style="margin-left: 0;"> + <b-input v-model="editingHandlerCommand"> + </b-input> + </div> + </div> + </div> + </b-field> + + <b-field label="Subcommand" + :type="editingHandlerSubcommand ? null : 'is-danger'"> + <b-input v-model="editingHandlerSubcommand"> + </b-input> + </b-field> + + <b-field label="Default Runas"> + <b-input v-model="editingHandlerRunas"> + </b-input> + </b-field> + + </b-field> + + <b-field grouped> + + <b-button @click="editHandlerShowDialog = false" + class="control"> + Cancel + </b-button> + + <b-button type="is-primary" + class="control" + @click="updateHandler()" + :disabled="updateHandlerDisabled"> + Update Handler + </b-button> + + </b-field> + + </div> + </div> + </b-modal> +</%def> + +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> + + ThisPageData.handlersData = ${json.dumps(handlers_data)|n} + + ThisPageData.editHandlerShowDialog = false + ThisPageData.editingHandler = null + ThisPageData.editingHandlerHostTitle = null + ThisPageData.editingHandlerLocalTitle = null + ThisPageData.editingHandlerDirection = 'import' + ThisPageData.editingHandlerSpec = null + ThisPageData.editingHandlerSpecOptions = [] + ThisPageData.editingHandlerCommand = null + ThisPageData.editingHandlerSubcommand = null + ThisPageData.editingHandlerRunas = null + + ThisPage.computed.updateHandlerDisabled = function() { + if (!this.editingHandlerSpec) { + return true + } + if (!this.editingHandlerCommand) { + return true + } + if (!this.editingHandlerSubcommand) { + return true + } + return false + } + + ThisPage.methods.editHandler = function(row) { + this.editingHandler = row + + this.editingHandlerHostTitle = row.host_title + this.editingHandlerLocalTitle = row.local_title + this.editingHandlerDirection = row.direction_display + this.editingHandlerSpec = row.handler_spec + this.editingHandlerSpecOptions = row.spec_options + this.editingHandlerCommand = row.command + this.editingHandlerSubcommand = row.subcommand + this.editingHandlerRunas = row.default_runas + + this.editHandlerShowDialog = true + } + + ThisPage.methods.updateHandler = function() { + let row = this.editingHandler + + row.handler_spec = this.editingHandlerSpec + row.command = this.editingHandlerCommand + row.subcommand = this.editingHandlerSubcommand + row.default_runas = this.editingHandlerRunas + + this.settingsNeedSaved = true + this.editHandlerShowDialog = false + } + + </script> +</%def> diff --git a/tailbone/templates/importing/index.mako b/tailbone/templates/importing/index.mako new file mode 100644 index 00000000..c2d9c6ec --- /dev/null +++ b/tailbone/templates/importing/index.mako @@ -0,0 +1,12 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/index.mako" /> + +<%def name="render_grid_component()"> + <p class="block"> + ${request.rattail_config.get_app().get_title()} can run import / export jobs for the following: + </p> + ${parent.render_grid_component()} +</%def> + + +${parent.body()} diff --git a/tailbone/templates/importing/runjob.mako b/tailbone/templates/importing/runjob.mako new file mode 100644 index 00000000..a9625bc3 --- /dev/null +++ b/tailbone/templates/importing/runjob.mako @@ -0,0 +1,88 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/form.mako" /> + +<%def name="extra_styles()"> + ${parent.extra_styles()} + <style type="text/css"> + + .tailbone-markdown p { + margin-bottom: 1.5rem; + margin-top: 1rem; + } + + </style> +</%def> + +<%def name="title()"> + Run ${handler.direction.capitalize()}: ${handler.get_generic_title()} +</%def> + +<%def name="context_menu_items()"> + ${parent.context_menu_items()} + % if master.has_perm('view'): + <li>${h.link_to("View this {}".format(model_title), action_url('view', handler_info))}</li> + % endif +</%def> + +<%def name="render_this_page()"> + % if 'rattail.importing.runjob.notes' in request.session: + <b-notification type="is-info tailbone-markdown"> + ${request.session['rattail.importing.runjob.notes']|n} + </b-notification> + <% del request.session['rattail.importing.runjob.notes'] %> + % endif + + ${parent.render_this_page()} +</%def> + +<%def name="render_form_buttons()"> + <br /> + ${h.hidden('runjob', **{':value': 'runJob'})} + <div class="buttons"> + <once-button tag="a" href="${form.cancel_url or request.get_referrer()}" + text="Cancel"> + </once-button> + <b-button type="is-primary" + @click="submitRun()" + % if handler.safe_for_web_app: + :disabled="submittingRun" + % else: + disabled + title="Handler is not (yet) safe to run with this tool" + % endif + icon-pack="fas" + icon-left="arrow-circle-right"> + {{ submittingRun ? "Working, please wait..." : "Run this ${handler.direction.capitalize()}" }} + </b-button> + <b-button @click="submitExplain()" + :disabled="submittingExplain" + icon-pack="fas" + icon-left="question-circle"> + {{ submittingExplain ? "Working, please wait..." : "Just show me the notes" }} + </b-button> + </div> +</%def> + +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> + + ${form.vue_component}Data.submittingRun = false + ${form.vue_component}Data.submittingExplain = false + ${form.vue_component}Data.runJob = false + + ${form.vue_component}.methods.submitRun = function() { + this.submittingRun = true + this.runJob = true + this.$nextTick(() => { + this.$refs.${form.vue_component}.submit() + }) + } + + ${form.vue_component}.methods.submitExplain = function() { + this.submittingExplain = true + this.$refs.${form.vue_component}.submit() + } + + </script> +</%def> diff --git a/tailbone/templates/importing/view.mako b/tailbone/templates/importing/view.mako new file mode 100644 index 00000000..3a28737c --- /dev/null +++ b/tailbone/templates/importing/view.mako @@ -0,0 +1,22 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/view.mako" /> + +<%def name="object_helpers()"> + ${parent.object_helpers()} + % if master.has_perm('runjob'): + <nav class="panel"> + <p class="panel-heading">Tools</p> + <div class="panel-block buttons"> + <once-button type="is-primary" + tag="a" href="${url('{}.runjob'.format(route_prefix), key=handler.get_key())}" + icon-pack="fas" + icon-left="arrow-circle-right" + text="Run ${handler.direction.capitalize()} Job"> + </once-button> + </div> + </nav> + % endif +</%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 new file mode 100644 index 00000000..de364828 --- /dev/null +++ b/tailbone/templates/luigi/configure.mako @@ -0,0 +1,427 @@ +## -*- coding: utf-8; -*- +<%inherit file="/configure.mako" /> + +<%def name="form_content()"> + ${h.hidden('overnight_tasks', **{':value': 'JSON.stringify(overnightTasks)'})} + ${h.hidden('backfill_tasks', **{':value': 'JSON.stringify(backfillTasks)'})} + + <div class="level"> + <div class="level-left"> + <div class="level-item"> + <h3 class="is-size-3">Overnight Tasks</h3> + </div> + <div class="level-item"> + <b-button type="is-primary" + icon-pack="fas" + icon-left="plus" + @click="overnightTaskCreate()"> + New Task + </b-button> + </div> + </div> + </div> + <div class="block" style="padding-left: 2rem; display: flex;"> + + <${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"> + <div class="modal-card"> + + <header class="modal-card-head"> + <p class="modal-card-title">Overnight Task</p> + </header> + + <section class="modal-card-body"> + <b-field label="Key" + :type="overnightTaskKey ? null : 'is-danger'"> + <b-input v-model.trim="overnightTaskKey" + ref="overnightTaskKey" + expanded /> + </b-field> + <b-field label="Description" + :type="overnightTaskDescription ? null : 'is-danger'"> + <b-input v-model.trim="overnightTaskDescription" + ref="overnightTaskDescription" + expanded /> + </b-field> + <b-field label="Module"> + <b-input v-model.trim="overnightTaskModule" + expanded /> + </b-field> + <b-field label="Class Name"> + <b-input v-model.trim="overnightTaskClass" + expanded /> + </b-field> + <b-field label="Script"> + <b-input v-model.trim="overnightTaskScript" + expanded /> + </b-field> + <b-field label="Notes"> + <b-input v-model.trim="overnightTaskNotes" + type="textarea" + expanded /> + </b-field> + </section> + + <footer class="modal-card-foot"> + <b-button type="is-primary" + icon-pack="fas" + icon-left="save" + @click="overnightTaskSave()" + :disabled="!overnightTaskKey || !overnightTaskDescription"> + Save + </b-button> + <b-button @click="overnightTaskShowDialog = false"> + Cancel + </b-button> + </footer> + </div> + </b-modal> + + </div> + + <div class="level"> + <div class="level-left"> + <div class="level-item"> + <h3 class="is-size-3">Backfill Tasks</h3> + </div> + <div class="level-item"> + <b-button type="is-primary" + icon-pack="fas" + icon-left="plus" + @click="backfillTaskCreate()"> + New Task + </b-button> + </div> + </div> + </div> + <div class="block" style="padding-left: 2rem; display: flex;"> + + <${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"> + <div class="modal-card"> + + <header class="modal-card-head"> + <p class="modal-card-title">Backfill Task</p> + </header> + + <section class="modal-card-body"> + <b-field label="Key" + :type="backfillTaskKey ? null : 'is-danger'"> + <b-input v-model.trim="backfillTaskKey" + ref="backfillTaskKey" + expanded /> + </b-field> + <b-field label="Description" + :type="backfillTaskDescription ? null : 'is-danger'"> + <b-input v-model.trim="backfillTaskDescription" + ref="backfillTaskDescription" + expanded /> + </b-field> + <b-field label="Script" + :type="backfillTaskScript ? null : 'is-danger'"> + <b-input v-model.trim="backfillTaskScript" + expanded /> + </b-field> + <b-field grouped> + <b-field label="Orientation"> + <b-select v-model="backfillTaskForward"> + <option :value="false">Backward</option> + <option :value="true">Forward</option> + </b-select> + </b-field> + <b-field label="Target Date"> + <tailbone-datepicker v-model="backfillTaskTargetDate"> + </tailbone-datepicker> + </b-field> + </b-field> + <b-field label="Notes"> + <b-input v-model.trim="backfillTaskNotes" + type="textarea" + expanded /> + </b-field> + </section> + + <footer class="modal-card-foot"> + <b-button type="is-primary" + icon-pack="fas" + icon-left="save" + @click="backfillTaskSave()" + :disabled="!backfillTaskKey || !backfillTaskDescription || !backfillTaskScript"> + Save + </b-button> + <b-button @click="backfillTaskShowDialog = false"> + Cancel + </b-button> + </footer> + </div> + </b-modal> + + </div> + + <h3 class="is-size-3">Luigi Proper</h3> + <div class="block" style="padding-left: 2rem;"> + + <b-field label="Luigi URL" + message="This should be the URL to Luigi Task Visualiser web user interface." + expanded> + <b-input name="rattail.luigi.url" + v-model="simpleSettings['rattail.luigi.url']" + @input="settingsNeedSaved = true" + expanded> + </b-input> + </b-field> + + <b-field label="Supervisor Process Name" + message="This should be the complete name, including group - e.g. luigi:luigid" + expanded> + <b-input name="rattail.luigi.scheduler.supervisor_process_name" + v-model="simpleSettings['rattail.luigi.scheduler.supervisor_process_name']" + @input="settingsNeedSaved = true" + expanded> + </b-input> + </b-field> + + <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 luigi:luigid" + expanded> + <b-input name="rattail.luigi.scheduler.restart_command" + v-model="simpleSettings['rattail.luigi.scheduler.restart_command']" + @input="settingsNeedSaved = true" + expanded> + </b-input> + </b-field> + + </div> + +</%def> + +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> + + ThisPageData.overnightTasks = ${json.dumps(overnight_tasks)|n} + ThisPageData.overnightTaskShowDialog = false + ThisPageData.overnightTask = null + ThisPageData.overnightTaskCounter = 0 + ThisPageData.overnightTaskKey = null + ThisPageData.overnightTaskDescription = null + ThisPageData.overnightTaskModule = null + ThisPageData.overnightTaskClass = null + ThisPageData.overnightTaskScript = null + ThisPageData.overnightTaskNotes = null + + ThisPage.methods.overnightTaskCreate = function() { + this.overnightTask = {key: null, isNew: true} + this.overnightTaskKey = null + this.overnightTaskDescription = null + this.overnightTaskModule = null + this.overnightTaskClass = null + this.overnightTaskScript = null + this.overnightTaskNotes = null + this.overnightTaskShowDialog = true + this.$nextTick(() => { + this.$refs.overnightTaskKey.focus() + }) + } + + ThisPage.methods.overnightTaskEdit = function(task) { + this.overnightTask = task + this.overnightTaskKey = task.key + this.overnightTaskDescription = task.description + this.overnightTaskModule = task.module + this.overnightTaskClass = task.class_name + this.overnightTaskScript = task.script + this.overnightTaskNotes = task.notes + this.overnightTaskShowDialog = true + } + + ThisPage.methods.overnightTaskSave = function() { + this.overnightTask.key = this.overnightTaskKey + this.overnightTask.description = this.overnightTaskDescription + this.overnightTask.module = this.overnightTaskModule + this.overnightTask.class_name = this.overnightTaskClass + this.overnightTask.script = this.overnightTaskScript + this.overnightTask.notes = this.overnightTaskNotes + + if (this.overnightTask.isNew) { + this.overnightTasks.push(this.overnightTask) + this.overnightTask.isNew = false + } + + this.overnightTaskShowDialog = false + this.settingsNeedSaved = true + } + + ThisPage.methods.overnightTaskDelete = function(task) { + if (confirm("Really delete this task?")) { + let i = this.overnightTasks.indexOf(task) + this.overnightTasks.splice(i, 1) + this.settingsNeedSaved = true + } + } + + ThisPageData.backfillTasks = ${json.dumps(backfill_tasks)|n} + ThisPageData.backfillTaskShowDialog = false + ThisPageData.backfillTask = null + ThisPageData.backfillTaskCounter = 0 + ThisPageData.backfillTaskKey = null + ThisPageData.backfillTaskDescription = null + ThisPageData.backfillTaskScript = null + ThisPageData.backfillTaskForward = false + ThisPageData.backfillTaskTargetDate = null + ThisPageData.backfillTaskNotes = null + + ThisPage.methods.backfillTaskCreate = function() { + this.backfillTask = {key: null, isNew: true} + this.backfillTaskKey = null + this.backfillTaskDescription = null + this.backfillTaskScript = null + this.backfillTaskForward = false + this.backfillTaskTargetDate = null + this.backfillTaskNotes = null + this.backfillTaskShowDialog = true + this.$nextTick(() => { + this.$refs.backfillTaskKey.focus() + }) + } + + ThisPage.methods.backfillTaskEdit = function(task) { + this.backfillTask = task + this.backfillTaskKey = task.key + this.backfillTaskDescription = task.description + this.backfillTaskScript = task.script + this.backfillTaskForward = task.forward + this.backfillTaskTargetDate = task.target_date + this.backfillTaskNotes = task.notes + this.backfillTaskShowDialog = true + } + + ThisPage.methods.backfillTaskDelete = function(task) { + if (confirm("Really delete this task?")) { + let i = this.backfillTasks.indexOf(task) + this.backfillTasks.splice(i, 1) + this.settingsNeedSaved = true + } + } + + ThisPage.methods.backfillTaskSave = function() { + this.backfillTask.key = this.backfillTaskKey + this.backfillTask.description = this.backfillTaskDescription + this.backfillTask.script = this.backfillTaskScript + this.backfillTask.forward = this.backfillTaskForward + this.backfillTask.target_date = this.backfillTaskTargetDate + this.backfillTask.notes = this.backfillTaskNotes + + if (this.backfillTask.isNew) { + this.backfillTasks.push(this.backfillTask) + this.backfillTask.isNew = false + } + + this.backfillTaskShowDialog = false + this.settingsNeedSaved = true + } + + </script> +</%def> diff --git a/tailbone/templates/luigi/index.mako b/tailbone/templates/luigi/index.mako new file mode 100644 index 00000000..0dd72d01 --- /dev/null +++ b/tailbone/templates/luigi/index.mako @@ -0,0 +1,376 @@ +## -*- coding: utf-8; -*- +<%inherit file="/page.mako" /> + +<%def name="title()">View / Launch Tasks</%def> + +<%def name="page_content()"> + <br /> + <div class="form"> + + <div class="buttons"> + + <b-button tag="a" + % if luigi_url: + href="${luigi_url}" + % else: + href="#" disabled + title="Luigi URL is not configured" + % endif + icon-pack="fas" + icon-left="external-link-alt" + target="_blank"> + Luigi Task Visualiser + </b-button> + + <b-button tag="a" + % if luigi_history_url: + href="${luigi_history_url}" + % else: + href="#" disabled + title="Luigi URL is not configured" + % endif + icon-pack="fas" + icon-left="external-link-alt" + target="_blank"> + Luigi Task History + </b-button> + + % if master.has_perm('restart_scheduler'): + ${h.form(url('{}.restart_scheduler'.format(route_prefix)), **{'@submit': 'submitRestartSchedulerForm'})} + ${h.csrf_token(request)} + <b-button type="is-primary" + native-type="submit" + icon-pack="fas" + icon-left="redo" + :disabled="restartSchedulerFormSubmitting"> + {{ restartSchedulerFormSubmitting ? "Working, please wait..." : "Restart Luigi Scheduler" }} + </b-button> + ${h.end_form()} + % endif + </div> + + % if master.has_perm('launch_overnight'): + + <h3 class="block is-size-3">Overnight Tasks</h3> + + <${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!" }} + </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> + + <section class="modal-card-body" + v-if="overnightTask"> + + <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="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> + + </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 #empty> + <p class="block">No tasks defined.</p> + </template> + </${b}-table> + + % endif + + % if master.has_perm('launch_backfill'): + + <h3 class="block is-size-3">Backfill Tasks</h3> + + <${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 }} + </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}-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"> + <p class="modal-card-title">Launch Backfill Task</p> + </header> + + <section class="modal-card-body" + v-if="backfillTask"> + + <p class="block has-text-weight-bold"> + {{ backfillTask.description }} + (goes {{ backfillTask.forward ? "FORWARD" : "BACKWARD" }}) + </p> + + <b-field grouped> + <b-field label="Last Date"> + {{ backfillTask.last_date || "n/a" }} + </b-field> + <b-field label="Target Date"> + {{ backfillTask.target_date || "n/a" }} + </b-field> + </b-field> + + <b-field grouped> + + <b-field label="Start Date" + :type="backfillTaskStartDate ? null : 'is-danger'"> + <tailbone-datepicker v-model="backfillTaskStartDate"> + </tailbone-datepicker> + </b-field> + + <b-field label="End Date" + :type="backfillTaskEndDate ? null : 'is-danger'"> + <tailbone-datepicker v-model="backfillTaskEndDate"> + </tailbone-datepicker> + </b-field> + + </b-field> + + </section> + + <footer class="modal-card-foot"> + <b-button @click="backfillTaskShowLaunchDialog = false"> + Cancel + </b-button> + <b-button type="is-primary" + icon-pack="fas" + icon-left="arrow-circle-right" + @click="backfillTaskLaunchSubmit()" + :disabled="backfillTaskLaunching || !backfillTaskStartDate || !backfillTaskEndDate"> + {{ backfillTaskLaunching ? "Working, please wait..." : "Launch" }} + </b-button> + </footer> + </div> + </${b}-modal> + + % endif + + </div> +</%def> + +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> + + % if master.has_perm('restart_scheduler'): + + ThisPageData.restartSchedulerFormSubmitting = false + + ThisPage.methods.submitRestartSchedulerForm = function() { + this.restartSchedulerFormSubmitting = true + } + + % endif + + % if master.has_perm('launch_overnight'): + + ThisPageData.overnightTasks = ${json.dumps(overnight_tasks)|n} + ThisPageData.overnightTask = null + ThisPageData.overnightTaskShowLaunchDialog = false + ThisPageData.overnightTaskLaunching = false + + ThisPage.methods.overnightTextClass = function(task) { + let yesterday = '${rattail_app.today() - datetime.timedelta(days=1)}' + if (task.last_date) { + if (task.last_date == yesterday) { + return 'has-text-success' + } else { + return 'has-text-warning' + } + } else { + return 'has-text-warning' + } + } + + ThisPage.methods.overnightTaskLaunchInit = function(task) { + this.overnightTask = task + this.overnightTaskShowLaunchDialog = true + } + + ThisPage.methods.overnightTaskLaunchSubmit = function() { + this.overnightTaskLaunching = true + + let url = '${url('{}.launch_overnight'.format(route_prefix))}' + let params = {key: this.overnightTask.key} + + this.submitForm(url, params, response => { + this.$buefy.toast.open({ + message: "Task has been scheduled for immediate launch!", + type: 'is-success', + duration: 5000, // 5 seconds + }) + this.overnightTaskLaunching = false + this.overnightTaskShowLaunchDialog = false + }) + } + + % endif + + % if master.has_perm('launch_backfill'): + + ThisPageData.backfillTasks = ${json.dumps(backfill_tasks)|n} + ThisPageData.backfillTask = null + ThisPageData.backfillTaskStartDate = null + ThisPageData.backfillTaskEndDate = null + ThisPageData.backfillTaskShowLaunchDialog = false + ThisPageData.backfillTaskLaunching = false + + ThisPage.methods.backfillTextClass = function(task) { + if (task.target_date) { + if (task.last_date) { + if (task.forward) { + if (task.last_date >= task.target_date) { + return 'has-text-success' + } else { + return 'has-text-warning' + } + } else { + if (task.last_date <= task.target_date) { + return 'has-text-success' + } else { + return 'has-text-warning' + } + } + } + } + } + + ThisPage.methods.backfillTaskLaunch = function(task) { + this.backfillTask = task + this.backfillTaskStartDate = null + this.backfillTaskEndDate = null + this.backfillTaskShowLaunchDialog = true + } + + ThisPage.methods.backfillTaskLaunchSubmit = function() { + this.backfillTaskLaunching = true + + let url = '${url('{}.launch_backfill'.format(route_prefix))}' + let params = { + key: this.backfillTask.key, + start_date: this.backfillTaskStartDate, + end_date: this.backfillTaskEndDate, + } + + this.submitForm(url, params, response => { + this.$buefy.toast.open({ + message: "Task has been scheduled for immediate launch!", + type: 'is-success', + duration: 5000, // 5 seconds + }) + this.backfillTaskLaunching = false + this.backfillTaskShowLaunchDialog = false + }) + } + + % endif + + </script> +</%def> 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/configure.mako b/tailbone/templates/master/configure.mako new file mode 100644 index 00000000..bfe0574c --- /dev/null +++ b/tailbone/templates/master/configure.mako @@ -0,0 +1,34 @@ +## -*- coding: utf-8; -*- +<%inherit file="/configure.mako" /> + +<%def name="page_content()"> + ${parent.page_content()} + + <h3 class="block is-size-3">TODO</h3> + + <p class="block"> + You should create a custom template file at: + <span class="is-family-monospace">${master.get_template_prefix()}/configure.mako</span> + </p> + + <p class="block"> + Within that you should define (at least) the + <span class="is-family-monospace">page_content()</span> + def block. + </p> + + <p class="block"> + You can see the following examples for reference: + </p> + + <ul class="block"> + <li class="is-family-monospace">/datasync/configure.mako</li> + <li class="is-family-monospace">/importing/configure.mako</li> + <li class="is-family-monospace">/products/configure.mako</li> + <li class="is-family-monospace">/receiving/configure.mako</li> + </ul> + +</%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 444c4e1d..d2f517d9 100644 --- a/tailbone/templates/master/delete.mako +++ b/tailbone/templates/master/delete.mako @@ -3,85 +3,45 @@ <%def name="title()">Delete ${model_title}: ${instance_title}</%def> -<%def name="context_menu_items()"> - <li>${h.link_to("Back to {}".format(model_title_plural), url(route_prefix))}</li> - % if master.viewable and request.has_perm('{}.view'.format(permission_prefix)): - <li>${h.link_to("View this {}".format(model_title), action_url('view', instance))}</li> - % endif - % if master.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 master.creatable and master.show_create_link and request.has_perm('{}.create'.format(permission_prefix)): - % 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 febd0bcd..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 master.viewable and request.has_perm('{}.view'.format(permission_prefix)): - <li>${h.link_to("View this {}".format(model_title), action_url('view', instance))}</li> - % endif - ${self.context_menu_item_delete()} - % if master.creatable and master.show_create_link and request.has_perm('{}.create'.format(permission_prefix)): - % 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/edit_row.mako b/tailbone/templates/master/edit_row.mako index dab77592..4d6a9573 100644 --- a/tailbone/templates/master/edit_row.mako +++ b/tailbone/templates/master/edit_row.mako @@ -1,8 +1,8 @@ -## -*- coding: utf-8 -*- +## -*- coding: utf-8; -*- <%inherit file="/master/edit.mako" /> <%def name="context_menu_items()"> - <li>${h.link_to("Back to {}".format(model_title), index_url)}</li> + <li>${h.link_to("Back to {}".format(parent_model_title), parent_url)}</li> % if master.rows_viewable and request.has_perm('{}.view'.format(row_permission_prefix)): <li>${h.link_to("View this {}".format(row_model_title), row_action_url('view', instance))}</li> % endif diff --git a/tailbone/templates/master/form.mako b/tailbone/templates/master/form.mako index 6f67f77e..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 master.deletable and instance_deletable and request.has_perm('{}.delete'.format(permission_prefix)): - % 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 8e855422..a2d26c60 100644 --- a/tailbone/templates/master/index.mako +++ b/tailbone/templates/master/index.mako @@ -12,444 +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 master.creatable and master.show_create_link and request.has_perm('{}.create'.format(permission_prefix)): - % 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="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: @@ -468,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()} @@ -476,42 +296,53 @@ </%def> <%def name="render_grid_component()"> - <${grid.component} :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)}'; } @@ -524,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)}'; } @@ -536,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 @@ -560,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)) { @@ -579,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?")) { @@ -654,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 } @@ -669,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.") @@ -684,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 } @@ -697,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.") @@ -715,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 } @@ -730,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.") @@ -746,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 } @@ -761,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 @@ -774,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..." } @@ -787,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 94454bd9..118c028c 100644 --- a/tailbone/templates/master/view.mako +++ b/tailbone/templates/master/view.mako @@ -3,116 +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()"> - <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 master.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> + % 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 master.creatable and master.show_create_link and request.has_perm('{}.create'.format(permission_prefix)): - % 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.cloneable and request.has_perm('{}.clone'.format(permission_prefix)): - <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), url('{}.touch'.format(route_prefix), uuid=instance.uuid))}</li> - % endif - % if master.has_rows and master.rows_downloadable_csv and request.has_perm('{}.row_results_csv'.format(permission_prefix)): - <li>${h.link_to("Download row results as CSV", url('{}.row_results_csv'.format(route_prefix), uuid=instance.uuid))}</li> - % endif - % if 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 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 /> - <tailbone-grid></tailbone-grid> - % 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_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} - % endif - ${parent.render_this_page_template()} +<%def name="render_row_grid_component()"> + ${rows_grid.render_vue_tag(id='rowGrid', ref='rowGrid')} </%def> -<%def name="make_this_page_component()"> - % if master.has_rows: - <script type="text/javascript"> +<%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 +</%def> - TailboneGrid.data = function() { return TailboneGridData } +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> - Vue.component('tailbone-grid', TailboneGrid) + % if getattr(master, 'touchable', False) and master.has_perm('touch'): + 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 - ${parent.make_this_page_component()} </%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 66756c3e..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 master.rows_deletable and instance_deletable and request.has_perm('{}.delete'.format(permission_prefix)): - <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/menu.mako b/tailbone/templates/menu.mako index 7549e763..65acd0dd 100644 --- a/tailbone/templates/menu.mako +++ b/tailbone/templates/menu.mako @@ -4,16 +4,16 @@ % for topitem in menus: <li> - % if topitem.is_link: - ${h.link_to(topitem.title, topitem.url, target=topitem.target)} + % if topitem['is_link']: + ${h.link_to(topitem['title'], topitem['url'], target=topitem['target'])} % else: - <a>${topitem.title}</a> + <a>${topitem['title']}</a> <ul> - % for subitem in topitem.items: - % if subitem.is_sep: + % for subitem in topitem['items']: + % if subitem['is_sep']: <li>-</li> % else: - <li>${h.link_to(subitem.title, subitem.url, target=subitem.target)}</li> + <li>${h.link_to(subitem['title'], subitem['url'], target=subitem['target'])}</li> % endif % endfor </ul> 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 2d8227d4..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,64 +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', - 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 9ef956a9..00000000 --- a/tailbone/templates/people/view_profile_buefy.mako +++ /dev/null @@ -1,1663 +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="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"></b-input> - </b-field> - <b-field label="Middle Name"> - <b-input v-model.trim="personMiddleName"></b-input> - </b-field> - <b-field label="Last Name"> - <b-input v-model.trim="personLastName"></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: Object, - }, - - 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', - 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 new file mode 100644 index 00000000..cb8b51aa --- /dev/null +++ b/tailbone/templates/poser/reports/view.mako @@ -0,0 +1,74 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/view.mako" /> + +<%def name="render_form_buttons()"> + <div v-if="!showUploadForm" class="buttons"> + % if master.has_perm('replace'): + <b-button type="is-primary" + @click="showUploadForm = true"> + Upload Replacement Module + </b-button> + % endif + <once-button type="is-primary" + tag="a" + % if instance.get('error'): + href="#" disabled + % else: + href="${url('generate_specific_report', type_key=instance['report'].type_key)}" + % endif + text="Generate this Report"> + </once-button> + </div> + % if master.has_perm('replace'): + <div v-if="showUploadForm"> + ${h.form(master.get_action_url('replace', instance), enctype='multipart/form-data', **{'@submit': 'uploadSubmitting = true'})} + ${h.csrf_token(request)} + <b-field label="New Module File" horizontal> + + <b-field class="file is-primary" + :class="{'has-name': !!uploadFile}" + > + <b-upload name="replacement_module" + v-model="uploadFile" + class="file-label"> + <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="uploadFile" + class="file-name"> + {{ uploadFile.name }} + </span> + </b-field> + + <div class="buttons"> + <b-button @click="showUploadForm = false"> + Cancel + </b-button> + <b-button type="is-primary" + native-type="submit" + :disabled="uploadSubmitting || !uploadFile" + icon-pack="fas" + icon-left="save"> + {{ uploadSubmitting ? "Working, please wait..." : "Save" }} + </b-button> + </div> + + </b-field> + ${h.end_form()} + </div> + % endif + <br /> +</%def> + +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + % if master.has_perm('replace'): + <script> + ${form.vue_component}Data.showUploadForm = false + ${form.vue_component}Data.uploadFile = null + ${form.vue_component}Data.uploadSubmitting = false + </script> + % endif +</%def> diff --git a/tailbone/templates/poser/setup.mako b/tailbone/templates/poser/setup.mako new file mode 100644 index 00000000..239e7db2 --- /dev/null +++ b/tailbone/templates/poser/setup.mako @@ -0,0 +1,126 @@ +## -*- coding: utf-8; -*- +<%inherit file="/page.mako" /> + +<%def name="title()">Poser Setup</%def> + +<%def name="page_content()"> + <br /> + + % if not poser_dir_exists: + + <p class="block"> + Before you can use Poser features, ${app_title} must create the + file structure for it. + </p> + + <p class="block"> + A new folder will be created at this location: + <span class="is-family-monospace has-text-weight-bold"> + ${poser_dir} + </span> + </p> + + <p class="block"> + Once set up, ${app_title} can generate code for certain features, + in the Poser folder. You can then access these features from + within ${app_title}. + </p> + + <p class="block"> + You are free to edit most files in the Poser folder as well. + When you do so ${app_title} should pick up on the changes with no + need for app restart. + </p> + + <p class="block"> + Proceed? + </p> + + ${h.form(request.current_route_url(), **{'@submit': 'setupSubmitting = true'})} + ${h.csrf_token(request)} + <b-button type="is-primary" + native-type="submit" + :disabled="setupSubmitting"> + {{ setupSubmitting ? "Working, please wait..." : "Go for it!" }} + </b-button> + ${h.end_form()} + + % else: + + <h3 class="is-size-3 block">Root Folder</h3> + + <p class="block"> + Poser folder already exists at: + <span class="is-family-monospace has-text-weight-bold"> + ${poser_dir} + </span> + </p> + + ${h.form(request.current_route_url(), class_='block', **{'@submit': 'setupSubmitting = true'})} + ${h.csrf_token(request)} + ${h.hidden('action', value='refresh')} + <b-button type="is-primary" + native-type="submit" + :disabled="setupSubmitting" + icon-pack="fas" + icon-left="redo"> + {{ setupSubmitting ? "Working, please wait..." : "Refresh Folder" }} + </b-button> + ${h.end_form()} + + <h3 class="is-size-3 block">Modules</h3> + + <ul class="list" style="max-width: 80%;"> + <li class="list-item"> + <span class="is-family-monospace">poser</span> + <span class="is-pulled-right"> + % if poser_imported['poser']: + <span class="is-family-monospace"> + ${poser_imported['poser'].__file__} + </span> + % else: + <span class="has-background-warning"> + ${poser_import_errors['poser']} + </span> + % endif + </span> + </li> + <li class="list-item"> + <span class="is-family-monospace">poser.reports</span> + <span class="is-pulled-right"> + % if poser_imported['reports']: + <span class="is-family-monospace"> + ${poser_imported['reports'].__file__} + </span> + % else: + <span class="has-background-warning"> + ${poser_import_errors['reports']} + </span> + % endif + </span> + </li> + <li class="list-item"> + <span class="is-family-monospace">poser.web.views</span> + <span class="is-pulled-right"> + % if poser_imported['views']: + <span class="is-family-monospace"> + ${poser_imported['views'].__file__} + </span> + % else: + <span class="has-background-warning"> + ${poser_import_errors['views']} + </span> + % endif + </span> + </li> + </ul> + + % endif +</%def> + +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> + ThisPageData.setupSubmitting = false + </script> +</%def> diff --git a/tailbone/templates/poser/views/configure.mako b/tailbone/templates/poser/views/configure.mako new file mode 100644 index 00000000..cdde15c5 --- /dev/null +++ b/tailbone/templates/poser/views/configure.mako @@ -0,0 +1,36 @@ +## -*- coding: utf-8; -*- +<%inherit file="/configure.mako" /> + +<%def name="form_content()"> + + <p class="block has-text-weight-bold is-italic"> + NB. Any changes made here will require an app restart! + </p> + + % 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 topgroup.items(): + <h4 class="block is-size-4">${group_key.capitalize()}</h4> + % for key, label in group: + ${self.simple_flag(key, label)} + % endfor + % endfor + % endfor + +</%def> + +<%def name="simple_flag(key, label)"> + <b-field label="${label}" horizontal> + <b-select name="tailbone.includes.${key}" + v-model="simpleSettings['tailbone.includes.${key}']" + @input="settingsNeedSaved = true"> + <option :value="null">(disabled)</option> + % for option in view_options[key]: + <option value="${option}">${option}</option> + % endfor + </b-select> + </b-field> +</%def> + + +${parent.body()} 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 efeaac1e..db029e5a 100644 --- a/tailbone/templates/products/batch.mako +++ b/tailbone/templates/products/batch.mako @@ -7,63 +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.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> @@ -71,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... @@ -156,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 @@ -175,6 +115,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/products/configure.mako b/tailbone/templates/products/configure.mako new file mode 100644 index 00000000..a43a85d4 --- /dev/null +++ b/tailbone/templates/products/configure.mako @@ -0,0 +1,120 @@ +## -*- coding: utf-8; -*- +<%inherit file="/configure.mako" /> + +<%def name="form_content()"> + + <h3 class="block is-size-3">Display</h3> + <div class="block" style="padding-left: 2rem;"> + + <b-field grouped> + + <b-field label="Key Field"> + <b-select name="rattail.product.key" + v-model="simpleSettings['rattail.product.key']" + @input="updateKeyTitle()"> + <option value="upc">upc</option> + <option value="item_id">item_id</option> + <option value="scancode">scancode</option> + </b-select> + </b-field> + + <b-field label="Key Field Label"> + <b-input name="rattail.product.key_title" + v-model="simpleSettings['rattail.product.key_title']" + @input="settingsNeedSaved = true"> + </b-input> + </b-field> + + </b-field> + + <b-field message="If a product has an image in the DB, that will still be preferred."> + <b-checkbox name="tailbone.products.show_pod_image" + v-model="simpleSettings['tailbone.products.show_pod_image']" + native-value="true" + @input="settingsNeedSaved = true"> + Show "POD" Images as fallback + </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 lookup"> + <b-checkbox name="rattail.products.convert_type2_for_gpc_lookup" + v-model="simpleSettings['rattail.products.convert_type2_for_gpc_lookup']" + native-value="true" + @input="settingsNeedSaved = true"> + Auto-convert Type 2 UPC for sake of lookup + </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> + <div class="block" style="padding-left: 2rem;"> + + <b-field message="User must also have permission to use this feature."> + <b-checkbox name="tailbone.products.print_labels" + v-model="simpleSettings['tailbone.products.print_labels']" + native-value="true" + @input="settingsNeedSaved = true"> + Allow quick/direct label printing from Products page + </b-checkbox> + </b-field> + + <b-field label="Speed Bump Threshold" + message="Show speed bump when at least this many labels are quick-printed at once. Empty means never show speed bump."> + <b-input name="tailbone.products.quick_labels.speedbump_threshold" + v-model="simpleSettings['tailbone.products.quick_labels.speedbump_threshold']" + type="number" + @input="settingsNeedSaved = true" + style="width: 10rem;"> + </b-input> + </b-field> + + </div> +</%def> + +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> + + ThisPage.methods.getTitleForKey = function(key) { + switch (key) { + case 'item_id': + return "Item ID" + case 'scancode': + return "Scancode" + default: + return "UPC" + } + } + + ThisPage.methods.updateKeyTitle = function() { + this.simpleSettings['rattail.product.key_title'] = this.getTitleForKey( + this.simpleSettings['rattail.product.key']) + this.settingsNeedSaved = true + } + + </script> +</%def> diff --git a/tailbone/templates/products/index.mako b/tailbone/templates/products/index.mako index 06b1e7e0..5ffa9512 100644 --- a/tailbone/templates/products/index.mako +++ b/tailbone/templates/products/index.mako @@ -1,103 +1,85 @@ -## -*- coding: utf-8 -*- +## -*- coding: utf-8; -*- <%inherit file="/master/index.mako" /> -<%def name="extra_styles()"> - ${parent.extra_styles()} - <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> +<%def name="grid_tools()"> + ${parent.grid_tools()} + % if label_profiles and master.has_perm('print_labels'): + <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> -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - % if label_profiles and request.has_perm('products.print_labels'): - <script type="text/javascript"> +<%def name="render_grid_component()"> + <${grid.component} :csrftoken="csrftoken" + % if master.deletable and master.has_perm('delete') and master.delete_confirm == 'simple': + @deleteActionClicked="deleteObject" + % endif + % if label_profiles and master.has_perm('print_labels'): + @quick-label-print="quickLabelPrint" + % endif + > + </${grid.component}> +</%def> - $(function() { +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + % if label_profiles and master.has_perm('print_labels'): + <script> - $('.grid-wrapper .grid-header .tools select').selectmenu(); + ${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-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 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; - }); - }); + ${grid.vue_component}.methods.quickLabelPrint = function(row) { + + let quantity = parseInt(this.quickLabelQuantity) + if (isNaN(quantity)) { + alert("You must provide a valid label quantity.") + this.$refs.quickLabelQuantityInput.focus() + return + } + + if (this.quickLabelSpeedbumpThreshold && quantity >= this.quickLabelSpeedbumpThreshold) { + if (!confirm("Are you sure you want to print " + quantity + " labels?")) { + return + } + } + + this.$emit('quick-label-print', row.uuid, this.quickLabelProfile, quantity) + } + + ThisPage.methods.quickLabelPrint = function(product, profile, quantity) { + let url = '${url('products.print_labels')}' + + let data = new FormData() + data.append('product', product) + data.append('profile', profile) + data.append('quantity', quantity) + + this.submitForm(url, data, response => { + if (quantity == 1) { + alert("1 label has been printed.") + } else { + alert(quantity.toString() + " labels have been printed.") + } + }) + } </script> % endif </%def> - -<%def name="grid_tools()"> - % if label_profiles and request.has_perm('products.print_labels'): - <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 -</%def> - -${parent.body()} diff --git a/tailbone/templates/products/lookup.mako b/tailbone/templates/products/lookup.mako new file mode 100644 index 00000000..bb9590b2 --- /dev/null +++ b/tailbone/templates/products/lookup.mako @@ -0,0 +1,411 @@ +## -*- coding: utf-8; -*- + +<%def name="tailbone_product_lookup_template()"> + <script type="text/x-template" id="tailbone-product-lookup-template"> + <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"> + + <b-field grouped> + + <b-input v-model="searchTerm" + ref="searchTermInput" + % if not request.use_oruga: + @keydown.native="searchTermInputKeydown" + % endif + /> + + <b-button class="control" + type="is-primary" + @click="performSearch()"> + Search + </b-button> + + <b-checkbox v-model="searchProductKey" + native-value="true"> + ${request.rattail_config.product_key_title()} + </b-checkbox> + + <b-checkbox v-model="searchVendorItemCode" + native-value="true"> + Vendor Code + </b-checkbox> + + <b-checkbox v-model="searchAlternateCode" + native-value="true"> + Alt Code + </b-checkbox> + + <b-checkbox v-model="searchProductBrand" + native-value="true"> + Brand + </b-checkbox> + + <b-checkbox v-model="searchProductDescription" + native-value="true"> + Description + </b-checkbox> + + </b-field> + + <${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" + v-slot="props"> + {{ props.row.product_key }} + </${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" + v-slot="props"> + <span :class="{organic: props.row.organic}"> + {{ props.row.description }} + {{ props.row.size }} + </span> + </${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" + 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" + 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" + v-slot="props"> + {{ props.row.department_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" + 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 #empty> + <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> + </template> + </${b}-table> + + <br /> + <div class="level"> + <div class="level-left"> + <div class="level-item buttons"> + <b-button @click="cancelDialog()"> + Cancel + </b-button> + <b-button type="is-primary" + @click="selectResult()" + :disabled="!searchResultSelected"> + Choose Selected + </b-button> + </div> + </div> + <div class="level-right"> + <div class="level-item"> + <span v-if="searchResultsElided" + class="has-text-danger"> + {{ searchResultsElided }} results are not shown + </span> + </div> + </div> + </div> + + </div> + </div> + </b-modal> + + </div> + </script> +</%def> + +<%def name="tailbone_product_lookup_component()"> + <script type="text/javascript"> + + const TailboneProductLookup = { + template: '#tailbone-product-lookup-template', + props: { + product: { + type: Object, + }, + autocompleteUrl: { + type: String, + default: '${url('products.autocomplete')}', + }, + }, + data() { + return { + autocompleteValue: '', + autocompleteOptions: [], + + lookupShowDialog: false, + + searchTerm: null, + searchTermLastUsed: null, + % if request.use_oruga: + searchTermInputElement: null, + % endif + + searchProductKey: true, + searchVendorItemCode: true, + searchProductBrand: true, + searchProductDescription: true, + searchAlternateCode: true, + + searchResults: [], + searchResultsLoading: false, + searchResultsElided: 0, + 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: { + + 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 + + this.$nextTick(() => { + + this.searchTerm = this.autocompleteValue + if (this.searchTerm != this.searchTermLastUsed) { + this.searchTermLastUsed = null + this.performSearch() + } + + this.$refs.searchTermInput.focus() + }) + }, + + searchTermInputKeydown(event) { + if (event.which == 13) { + this.performSearch() + } + }, + + performSearch() { + if (this.searchResultsLoading) { + return + } + + if (!this.searchTerm || !this.searchTerm.length) { + this.$refs.searchTermInput.focus() + return + } + + this.searchResultsLoading = true + this.searchResultSelected = null + + let url = '${url('products.search')}' + let params = { + term: this.searchTerm, + search_product_key: this.searchProductKey, + search_vendor_code: this.searchVendorItemCode, + search_brand_name: this.searchProductBrand, + search_description: this.searchProductDescription, + search_alt_code: this.searchAlternateCode, + } + + this.$http.get(url, {params: params}).then((response) => { + this.searchTermLastUsed = params.term + this.searchResults = response.data.results + this.searchResultsElided = response.data.elided + 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 new file mode 100644 index 00000000..72c9c76d --- /dev/null +++ b/tailbone/templates/products/pending/view.mako @@ -0,0 +1,130 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/view.mako" /> +<%namespace name="product_lookup" file="/products/lookup.mako" /> + +<%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 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"> + ${h.form(url('{}.resolve_product'.format(route_prefix), uuid=instance.uuid), ref='resolveProductForm')} + ${h.csrf_token(request)} + + <header class="modal-card-head"> + <p class="modal-card-title">Resolve Product</p> + </header> + + <section class="modal-card-body"> + <p class="block"> + 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 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"> + <b-button @click="resolveProductShowDialog = false"> + Cancel + </b-button> + <b-button type="is-primary" + :disabled="resolveProductSubmitDisabled" + @click="resolveProductSubmit()" + icon-pack="fas" + icon-left="object-ungroup"> + {{ resolveProductSubmitting ? "Working, please wait..." : "I declare these are the same" }} + </b-button> + </footer> + ${h.end_form()} + </div> + </b-modal> + % endif +</%def> + +<%def name="render_vue_templates()"> + ${parent.render_vue_templates()} + ${product_lookup.tailbone_product_lookup_template()} +</%def> + +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> + + % 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() + } + + % 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 + } + + 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() + } + + ThisPageData.actualProduct = null + + ThisPage.methods.productSelected = function(product) { + this.actualProduct = product + this.resolveProductUUID = product ? product.uuid : null + } + + % endif + + </script> +</%def> + +<%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 08aa348a..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('upc')} - ${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)"> @@ -191,39 +80,30 @@ ${form.render_field_readonly('regular_price')} ${form.render_field_readonly('current_price')} ${form.render_field_readonly('current_price_ends')} + ${form.render_field_readonly('sale_price')} + ${form.render_field_readonly('sale_price_ends')} + ${form.render_field_readonly('tpr_price')} + ${form.render_field_readonly('tpr_price_ends')} ${form.render_field_readonly('suggested_price')} ${form.render_field_readonly('deposit_link')} ${form.render_field_readonly('tax')} </%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)"> @@ -231,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> @@ -378,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> @@ -389,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"> @@ -408,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"> @@ -427,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"> @@ -446,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"> @@ -459,96 +248,51 @@ </%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()} - % if request.rattail_config.versioning_enabled() and master.has_perm('versions'): - <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} + + % 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() { @@ -577,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() { @@ -607,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() { @@ -635,11 +379,8 @@ }) } - ThisPageData.vendorSourcesData = ${json.dumps(vendor_sources['data'])|n} - ThisPageData.lookupCodesData = ${json.dumps(lookup_codes['data'])|n} - 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() { @@ -667,9 +408,6 @@ }) } - </script> - % endif + % 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 new file mode 100644 index 00000000..a36dde43 --- /dev/null +++ b/tailbone/templates/receiving/configure.mako @@ -0,0 +1,208 @@ +## -*- 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_receiving_from_scratch" + v-model="simpleSettings['rattail.batch.purchase.allow_receiving_from_scratch']" + native-value="true" + @input="settingsNeedSaved = true"> + From Scratch + </b-checkbox> + </b-field> + + <b-field> + <b-checkbox name="rattail.batch.purchase.allow_receiving_from_invoice" + v-model="simpleSettings['rattail.batch.purchase.allow_receiving_from_invoice']" + native-value="true" + @input="settingsNeedSaved = true"> + 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> + + <b-field> + <b-checkbox name="rattail.batch.purchase.allow_receiving_from_purchase_order" + v-model="simpleSettings['rattail.batch.purchase.allow_receiving_from_purchase_order']" + native-value="true" + @input="settingsNeedSaved = true"> + From Purchase Order + </b-checkbox> + </b-field> + + <b-field> + <b-checkbox name="rattail.batch.purchase.allow_receiving_from_purchase_order_with_invoice" + v-model="simpleSettings['rattail.batch.purchase.allow_receiving_from_purchase_order_with_invoice']" + native-value="true" + @input="settingsNeedSaved = true"> + From Purchase Order, with Invoice + </b-checkbox> + </b-field> + + <b-field> + <b-checkbox name="rattail.batch.purchase.allow_truck_dump_receiving" + v-model="simpleSettings['rattail.batch.purchase.allow_truck_dump_receiving']" + native-value="true" + @input="settingsNeedSaved = true"> + Truck Dump + </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_receiving_any_vendor" + v-model="simpleSettings['rattail.batch.purchase.allow_receiving_any_vendor']" + native-value="true" + @input="settingsNeedSaved = true"> + Allow receiving for <span class="has-text-weight-bold">any</span> vendor + </b-checkbox> + </b-field> + + </div> + + <h3 class="block is-size-3">Display</h3> + <div class="block" style="padding-left: 2rem;"> + + <b-field> + <b-checkbox name="rattail.batch.purchase.receiving.show_ordered_column_in_grid" + v-model="simpleSettings['rattail.batch.purchase.receiving.show_ordered_column_in_grid']" + native-value="true" + @input="settingsNeedSaved = true"> + Show "ordered" quantities in row grid + </b-checkbox> + </b-field> + + <b-field> + <b-checkbox name="rattail.batch.purchase.receiving.show_shipped_column_in_grid" + v-model="simpleSettings['rattail.batch.purchase.receiving.show_shipped_column_in_grid']" + native-value="true" + @input="settingsNeedSaved = true"> + Show "shipped" quantities in row grid + </b-checkbox> + </b-field> + + </div> + + <h3 class="block is-size-3">Product Handling</h3> + <div class="block" style="padding-left: 2rem;"> + + <b-field message="NB. Allow Cases setting also affects Ordering behavior."> + <b-checkbox name="rattail.batch.purchase.allow_cases" + v-model="simpleSettings['rattail.batch.purchase.allow_cases']" + native-value="true" + @input="settingsNeedSaved = true"> + Allow Cases + </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']" + native-value="true" + @input="settingsNeedSaved = true"> + Allow "Expired" Credits + </b-checkbox> + </b-field> + + <b-field> + <b-checkbox name="rattail.batch.purchase.receiving.should_autofix_invoice_case_vs_unit" + v-model="simpleSettings['rattail.batch.purchase.receiving.should_autofix_invoice_case_vs_unit']" + native-value="true" + @input="settingsNeedSaved = true"> + Try to auto-correct "case vs. unit" mistakes from invoice parser + </b-checkbox> + </b-field> + + <b-field> + <b-checkbox name="rattail.batch.purchase.receiving.allow_edit_catalog_unit_cost" + v-model="simpleSettings['rattail.batch.purchase.receiving.allow_edit_catalog_unit_cost']" + native-value="true" + @input="settingsNeedSaved = true"> + Allow edit of Catalog Unit Cost + </b-checkbox> + </b-field> + + <b-field> + <b-checkbox name="rattail.batch.purchase.receiving.allow_edit_invoice_unit_cost" + v-model="simpleSettings['rattail.batch.purchase.receiving.allow_edit_invoice_unit_cost']" + native-value="true" + @input="settingsNeedSaved = true"> + Allow edit of Invoice Unit Cost + </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> + <div class="block" style="padding-left: 2rem;"> + + <b-field message="TODO: this may also affect Ordering (?)"> + <b-checkbox name="rattail.batch.purchase.mobile_images" + v-model="simpleSettings['rattail.batch.purchase.mobile_images']" + native-value="true" + @input="settingsNeedSaved = true"> + Show Product Images + </b-checkbox> + </b-field> + + <b-field message="If set, one or more "quick receive" buttons will be available for mobile receiving."> + <b-checkbox name="rattail.batch.purchase.mobile_quick_receive" + v-model="simpleSettings['rattail.batch.purchase.mobile_quick_receive']" + native-value="true" + @input="settingsNeedSaved = true"> + Allow "Quick Receive" + </b-checkbox> + </b-field> + + <b-field message="If set, only a "quick receive all" button will be shown. Only applicable if quick receive (above) is enabled."> + <b-checkbox name="rattail.batch.purchase.mobile_quick_receive_all" + v-model="simpleSettings['rattail.batch.purchase.mobile_quick_receive_all']" + native-value="true" + @input="settingsNeedSaved = true"> + Quick Receive "All or Nothing" + </b-checkbox> + </b-field> + + </div> +</%def> + + +${parent.body()} 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 6596ff1b..a377e270 100644 --- a/tailbone/templates/receiving/declare_credit.mako +++ b/tailbone/templates/receiving/declare_credit.mako @@ -1,65 +1,52 @@ ## -*- coding: utf-8; -*- -<%inherit file="/base.mako" /> +<%inherit file="/form.mako" /> <%def name="title()">Declare Credit for Row #${row.sequence}</%def> <%def name="context_menu_items()"> - % if master.rows_viewable and request.has_perm('{}.view'.format(permission_prefix)): + ${parent.context_menu_items()} + % if master.rows_viewable and master.has_perm('view'): <li>${h.link_to("View this {}".format(row_model_title), row_action_url('view', row))}</li> % endif </%def> -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - <script type="text/javascript"> +<%def name="render_form()"> - 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(); - } - } + <p class="block"> + Please select the "state" of the product, and enter the + appropriate quantity. + </p> - $(function() { + <p class="block"> + Note that this tool will + <span class="has-text-weight-bold">deduct</span> from the + "received" quantity, and + <span class="has-text-weight-bold">add</span> to the + corresponding credit quantity. + </p> - toggleFields(); + <p class="block"> + 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> - $('select[name="credit_type"]').on('selectmenuchange', function(event, ui) { - toggleFields(ui.item.value); - }); + ${parent.render_form()} - }); - </script> </%def> -<div style="display: flex; justify-content: space-between;"> +<%def name="form_body()"> - <div class="form-wrapper"> + ${form.render_field_complete('credit_type')} - <p style="padding: 1em;"> - Please select the "state" of the product, and enter the appropriate - quantity. - </p> + ${form.render_field_complete('quantity')} - <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> + ${form.render_field_complete('expiration_date', bfield_attrs={'v-show': "field_model_credit_type == 'expired'"})} - <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> +</%def> - ${form.render()|n} - </div><!-- form-wrapper --> +<%def name="render_form_template()"> + ${form.render_deform(buttons=capture(self.render_form_buttons), form_body=capture(self.form_body))|n} +</%def> - <ul id="context-menu"> - ${self.context_menu_items()} - </ul> -</div> +${parent.body()} diff --git a/tailbone/templates/receiving/receive_row.mako b/tailbone/templates/receiving/receive_row.mako index 188fbe7b..48dc6755 100644 --- a/tailbone/templates/receiving/receive_row.mako +++ b/tailbone/templates/receiving/receive_row.mako @@ -1,65 +1,49 @@ ## -*- coding: utf-8; -*- -<%inherit file="/base.mako" /> +<%inherit file="/form.mako" /> <%def name="title()">Receive for Row #${row.sequence}</%def> <%def name="context_menu_items()"> - % if master.rows_viewable and request.has_perm('{}.view'.format(permission_prefix)): + ${parent.context_menu_items()} + % if master.rows_viewable and master.has_perm('view'): <li>${h.link_to("View this {}".format(row_model_title), row_action_url('view', row))}</li> % endif </%def> -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - <script type="text/javascript"> +<%def name="render_form()"> - 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(); - } - } + <p class="block"> + Please select the "state" of the product, and enter the appropriate + quantity. + </p> - $(function() { + <p class="block"> + Note that this tool will <span class="has-text-weight-bold">add</span> + the corresponding quantities for the row. + </p> - toggleFields(); + <p class="block"> + 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> - $('select[name="mode"]').on('selectmenuchange', function(event, ui) { - toggleFields(ui.item.value); - }); + ${parent.render_form()} - }); - </script> </%def> -<div style="display: flex; justify-content: space-between;"> +<%def name="form_body()"> - <div class="form-wrapper"> + ${form.render_field_complete('mode')} - <p style="padding: 1em;"> - Please select the "state" of the product, and enter the appropriate - quantity. - </p> + ${form.render_field_complete('quantity')} - <p style="padding: 1em;"> - Note that this tool will <strong>add</strong> the corresponding - quantities for the row. - </p> + ${form.render_field_complete('expiration_date', bfield_attrs={'v-show': "field_model_mode == 'expired'"})} - <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> +</%def> - ${form.render()|n} - </div><!-- form-wrapper --> +<%def name="render_form_template()"> + ${form.render_deform(buttons=capture(self.render_form_buttons), form_body=capture(self.form_body))|n} +</%def> - <ul id="context-menu"> - ${self.context_menu_items()} - </ul> -</div> +${parent.body()} diff --git a/tailbone/templates/receiving/view.mako b/tailbone/templates/receiving/view.mako index 32c327fe..710dec4a 100644 --- a/tailbone/templates/receiving/view.mako +++ b/tailbone/templates/receiving/view.mako @@ -1,328 +1,390 @@ ## -*- coding: utf-8; -*- <%inherit file="/batch/view.mako" /> -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - % if 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(); +<%def name="extra_styles()"> + ${parent.extra_styles()} + <style type="text/css"> + % if allow_edit_catalog_unit_cost: + td.c_catalog_unit_cost { + cursor: pointer; + background-color: #fcc; } - - function start_editing_catalog_cost(td) { - start_editing(td); - editing_catalog_cost = td; + tr.catalog_cost_confirmed td.c_catalog_unit_cost { + background-color: #cfc; } - - function start_editing_invoice_cost(td) { - start_editing(td); - editing_invoice_cost = td; + % endif + % if allow_edit_invoice_unit_cost: + td.c_invoice_unit_cost { + cursor: pointer; + background-color: #fcc; } - - 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; - } + tr.invoice_cost_confirmed td.c_invoice_unit_cost { + background-color: #cfc; } + % endif + </style> +</%def> - 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> +<%def name="render_po_vs_invoice_helper()"> + % 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> + </nav> % endif </%def> -<%def name="extra_styles()"> - ${parent.extra_styles()} - % if 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; - } - .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; - } - .grid td.catalog_unit_cost input, - .grid td.invoice_unit_cost input { - width: 4rem; - } - </style> +<%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%;"> + + % if allow_confirm_all_costs: + <b-button type="is-primary" + 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"> + Auto-Receive All Items + </b-button> + <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="object_helpers()"> - ${parent.object_helpers()} - ## TODO: for now this is a truck-dump-only feature? maybe should change that - % if not request.rattail_config.production() and master.handler.allow_truck_dump_receiving(): - % if not batch.executed and not batch.complete and request.has_perm('admin'): - % if (batch.is_truck_dump_parent() and batch.truck_dump_children_first) or not batch.is_truck_dump_related(): - <div class="object-helper"> - <h3>Development Tools</h3> - <div class="object-helper-content"> - % if use_buefy: - ${h.form(url('{}.auto_receive'.format(route_prefix), uuid=batch.uuid), ref='auto_receive_all_form')} - ${h.csrf_token(request)} - <once-button type="is-primary" - @click="$refs.auto_receive_all_form.submit()" - text="Auto-Receive All Items"> - </once-button> - ${h.end_form()} - % 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> - % endif - % endif + ${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> + <span v-show="!editing"> + {{ value }} + </span> + <b-input v-model="inputValue" + ref="input" + v-show="editing" + size="is-small" + @keydown.native="inputKeyDown" + @focus="selectAll" + @blur="inputBlur" + style="width: 6rem;"> + </b-input> + </div> + </script> % endif </%def> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> -${parent.body()} + % if allow_confirm_all_costs: -% if 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()} + ThisPageData.confirmAllCostsShowDialog = false + ThisPageData.confirmAllCostsSubmitting = false - <div id="transform-unit-dialog" style="display: none;"> - <p>hello world</p> - </div> -% endif + % endif + + ThisPageData.autoReceiveShowDialog = false + ThisPageData.autoReceiveSubmitting = false + + % if po_vs_invoice_breakdown_data is not Undefined: + ThisPageData.poVsInvoiceBreakdownData = ${json.dumps(po_vs_invoice_breakdown_data)|n} + + ThisPage.methods.autoFilterPoVsInvoice = function(row) { + let filters = [] + if (row.key == 'both') { + filters = [ + {key: 'po_line_number', + verb: 'is_not_null'}, + {key: 'invoice_line_number', + verb: 'is_not_null'}, + ] + } else if (row.key == 'po_not_invoice') { + filters = [ + {key: 'po_line_number', + verb: 'is_not_null'}, + {key: 'invoice_line_number', + verb: 'is_null'}, + ] + } else if (row.key == 'invoice_not_po') { + filters = [ + {key: 'po_line_number', + verb: 'is_null'}, + {key: 'invoice_line_number', + verb: 'is_not_null'}, + ] + } else if (row.key == 'neither') { + filters = [ + {key: 'po_line_number', + verb: 'is_null'}, + {key: 'invoice_line_number', + verb: 'is_null'}, + ] + } + + if (!filters.length) { + return + } + + this.$refs.rowGrid.setFilters(filters) + document.getElementById('rowGrid').scrollIntoView({ + behavior: 'smooth', + }) + } + + % endif + + % if allow_edit_catalog_unit_cost or allow_edit_invoice_unit_cost: + + let ReceivingCostEditor = { + template: '#receiving-cost-editor-template', + mixins: [SimpleRequestMixin], + props: { + row: Object, + 'field': String, + value: String, + }, + data() { + return { + inputValue: this.value, + editing: false, + } + }, + methods: { + + selectAll() { + // nb. must traverse into the <b-input> element + let trueInput = this.$refs.input.$el.firstChild + trueInput.select() + }, + + startEdit() { + // 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() + }) + }, + + inputKeyDown(event) { + + // when user presses Enter while editing cost value, submit + // value to server for immediate persistence + if (event.which == 13) { + this.submitEdit() + + // when user presses Escape, cancel the edit + } else if (event.which == 27) { + this.cancelEdit() + } + }, + + inputBlur(event) { + // always assume user meant to cancel + this.cancelEdit() + }, + + cancelEdit() { + // reset input to discard any user entry + this.inputValue = this.value + this.editing = false + this.$emit('cancel-edit') + }, + + submitEdit() { + let url = '${url('{}.update_row_cost'.format(route_prefix), uuid=batch.uuid)}' + + let params = { + row_uuid: this.$props.row.uuid, + } + params[this.$props.field] = this.inputValue + + 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) + + // and hide the input box + this.editing = false + }) + }, + }, + } + + Vue.component('receiving-cost-editor', ReceivingCostEditor) + + % endif + + % if allow_edit_catalog_unit_cost: + + ${rows_grid.vue_component}.methods.catalogUnitCostClicked = function(row) { + + // start edit for clicked cell + this.$refs['catalogUnitCost_' + row.uuid].startEdit() + } + + ${rows_grid.vue_component}.methods.catalogCostConfirmed = function(amount, index) { + + // update display to indicate cost was confirmed + this.addRowClass(index, 'catalog_cost_confirmed') + + // 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() + } + } + + % endif + + % if allow_edit_invoice_unit_cost: + + ${rows_grid.vue_component}.methods.invoiceUnitCostClicked = function(row) { + + // start edit for clicked cell + this.$refs['invoiceUnitCost_' + row.uuid].startEdit() + } + + ${rows_grid.vue_component}.methods.invoiceCostConfirmed = function(amount, index) { + + // update display to indicate cost was confirmed + this.addRowClass(index, 'invoice_cost_confirmed') + + // 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() + } + } + } + + % endif + + </script> +</%def> diff --git a/tailbone/templates/receiving/view_row.mako b/tailbone/templates/receiving/view_row.mako index 9ba6a0bb..086754c6 100644 --- a/tailbone/templates/receiving/view_row.mako +++ b/tailbone/templates/receiving/view_row.mako @@ -1,19 +1,722 @@ ## -*- coding: utf-8; -*- <%inherit file="/master/view_row.mako" /> -<%def name="object_helpers()"> - ${parent.object_helpers()} - % if not batch.executed 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')} - </div> - </div> - </div> - % endif +<%def name="extra_styles()"> + ${parent.extra_styles()} + <style type="text/css"> + + nav.panel { + margin: 0.5rem; + } + + .header-fields { + margin-top: 1rem; + } + + .header-fields .field.is-horizontal { + margin-left: 3rem; + } + + .header-fields .field.is-horizontal .field-label .label { + white-space: nowrap; + } + + .quantity-form-fields { + margin: 2rem; + } + + .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; + } + + </style> </%def> -${parent.body()} +<%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> + </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 + % if request.use_oruga: + v-model:active="accountForProductShowDialog" + % else: + :active.sync="accountForProductShowDialog" + % endif + > + <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> + + </b-field> + + <div style="display: flex; gap: 0.5rem; align-items: center;"> + + <numeric-input v-model="accountForProductQuantity" + ref="accountForProductQuantityInput"> + </numeric-input> + + % 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> + + % 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> + + % else: + <b-field> + <input type="hidden" v-model="declareCreditUOM" /> + Units + </b-field> + % endif + + </div> + </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> + + <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 + % 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_vue_vars()"> + ${parent.modify_vue_vars()} + <script> + +## ThisPage.methods.editUnitCost = function() { +## alert("TODO: not yet implemented") +## } + + ThisPage.methods.confirmUnitCost = function() { + alert("TODO: not yet implemented") + } + + ThisPageData.rowData = ${json.dumps(row_context)|n} + ThisPageData.possibleReceivingModes = ${json.dumps(possible_receiving_modes)|n} + ThisPageData.possibleCreditTypes = ${json.dumps(possible_credit_types)|n} + + ThisPageData.accountForProductShowDialog = false + ThisPageData.accountForProductMode = null + ThisPageData.accountForProductQuantity = null + ThisPageData.accountForProductUOM = 'units' + ThisPageData.accountForProductExpiration = null + ThisPageData.accountForProductSubmitting = false + + ThisPage.computed.accountForProductTotalUnits = function() { + return this.renderQuantity(this.accountForProductQuantity, + this.accountForProductUOM) + } + + ThisPage.computed.accountForProductSubmitDisabled = function() { + if (!this.accountForProductMode) { + return true + } + if (this.accountForProductMode == 'expired' && !this.accountForProductExpiration) { + return true + } + if (!this.accountForProductQuantity || this.accountForProductQuantity == 0) { + return true + } + if (this.accountForProductSubmitting) { + return true + } + return false + } + + ThisPage.methods.accountForProductInit = function() { + this.accountForProductMode = 'received' + this.accountForProductExpiration = null + this.accountForProductQuantity = 0 + this.accountForProductUOM = 'units' + this.accountForProductShowDialog = true + this.$nextTick(() => { + this.$refs.accountForProductQuantityInput.select() + this.$refs.accountForProductQuantityInput.focus() + }) + } + + 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(() => { + this.$refs.accountForProductQuantityInput.focus() + }) + } + + ThisPage.methods.accountForProductSubmit = function() { + + let qty = parseFloat(this.accountForProductQuantity) + if (qty == NaN || !qty) { + this.$buefy.toast.open({ + message: "You must enter a quantity.", + type: 'is-warning', + duration: 4000, // 4 seconds + }) + return + } + + if (this.accountForProductMode != 'received' && qty < 0) { + this.$buefy.toast.open({ + message: "Negative amounts are only allowed for the \"received\" state.", + type: 'is-warning', + duration: 4000, // 4 seconds + }) + return + } + + this.accountForProductSubmitting = true + let url = '${url('{}.receive_row'.format(route_prefix), uuid=batch.uuid, row_uuid=row.uuid)}' + let params = { + mode: this.accountForProductMode, + quantity: {cases: null, units: null}, + expiration_date: this.accountForProductExpiration, + } + + if (this.accountForProductUOM == 'cases') { + params.quantity.cases = this.accountForProductQuantity + } else { + params.quantity.units = this.accountForProductQuantity + } + + this.submitForm(url, params, response => { + this.rowData = response.data.row + this.accountForProductSubmitting = false + this.accountForProductShowDialog = false + }, response => { + this.accountForProductSubmitting = false + }) + } + + ThisPageData.declareCreditShowDialog = false + ThisPageData.declareCreditType = null + ThisPageData.declareCreditExpiration = null + ThisPageData.declareCreditQuantity = null + ThisPageData.declareCreditUOM = 'units' + ThisPageData.declareCreditSubmitting = false + + ThisPage.methods.renderQuantity = function(qty, uom) { + qty = parseFloat(qty) + if (qty == NaN) { + return "n/a" + } + if (uom == 'cases') { + qty *= this.rowData.case_quantity + } + if (qty == NaN) { + return "n/a" + } + if (qty == 1) { + return "1 unit" + } + if (qty == -1) { + return "-1 unit" + } + if (Math.round(qty) == qty) { + return qty.toString() + " units" + } + return qty.toFixed(4) + " units" + } + + ThisPage.computed.declareCreditTotalUnits = function() { + return this.renderQuantity(this.declareCreditQuantity, + this.declareCreditUOM) + } + + ThisPage.computed.declareCreditSubmitDisabled = function() { + if (!this.declareCreditType) { + return true + } + if (this.declareCreditType == 'expired' && !this.declareCreditExpiration) { + return true + } + if (!this.declareCreditQuantity || this.declareCreditQuantity == 0) { + return true + } + if (this.declareCreditSubmitting) { + return true + } + return false + } + + ThisPage.methods.declareCreditInit = function() { + this.declareCreditType = null + this.declareCreditExpiration = null + % if allow_cases: + if (this.rowData.cases_received) { + this.declareCreditQuantity = this.rowData.cases_received + this.declareCreditUOM = 'cases' + } else { + this.declareCreditQuantity = this.rowData.units_received + this.declareCreditUOM = 'units' + } + % else: + this.declareCreditQuantity = this.rowData.units_received + this.declareCreditUOM = 'units' + % endif + this.declareCreditShowDialog = true + } + + ThisPage.methods.declareCreditSubmit = function() { + this.declareCreditSubmitting = true + let url = '${url('{}.declare_credit'.format(route_prefix), uuid=batch.uuid, row_uuid=row.uuid)}' + let params = { + credit_type: this.declareCreditType, + cases: null, + units: null, + expiration_date: this.declareCreditExpiration, + } + + % if allow_cases: + if (this.declareCreditUOM == 'cases') { + params.cases = this.declareCreditQuantity + } else { + params.units = this.declareCreditQuantity + } + % else: + params.units = this.declareCreditQuantity + % endif + + this.submitForm(url, params, response => { + this.rowData = response.data.row + this.declareCreditSubmitting = false + this.declareCreditShowDialog = false + }, response => { + this.declareCreditSubmitting = false + }) + } + + ThisPageData.removeCreditShowDialog = false + ThisPageData.removeCreditRow = {} + ThisPageData.removeCreditSubmitting = false + + ThisPage.methods.removeCreditInit = function(row) { + this.removeCreditRow = row + this.removeCreditShowDialog = true + } + + ThisPage.methods.removeCreditSubmit = function() { + this.removeCreditSubmitting = true + let url = '${url('{}.undeclare_credit'.format(route_prefix), uuid=batch.uuid, row_uuid=row.uuid)}' + let params = { + uuid: this.removeCreditRow.uuid, + } + + this.submitForm(url, params, response => { + this.rowData = response.data.row + this.removeCreditSubmitting = false + this.removeCreditShowDialog = false + }) + } + + </script> +</%def> 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/choose.mako b/tailbone/templates/reports/choose.mako deleted file mode 100644 index 58c9ee22..00000000 --- a/tailbone/templates/reports/choose.mako +++ /dev/null @@ -1,130 +0,0 @@ -## -*- coding: utf-8; -*- -<%inherit file="/form.mako" /> - -<%def name="title()">${index_title}</%def> - -<%def name="content_title()"></%def> - -<%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 - % else: - .report-selection { - margin-left: 10em; - margin-top: 3em; - } - - .report-selection h3 { - margin-top: 2em; - } - % endif - - </style> -</%def> - -<%def name="context_menu_items()"> - % if request.has_perm('report_output.list'): - ${h.link_to("View Generated Reports", url('report_output'))} - % endif -</%def> - -<%def name="render_buefy_form()"> - <div class="form"> - <p>Please select the type of report you wish to generate.</p> - <br /> - <div style="display: flex;"> - <tailbone-form v-on:report-change="reportChanged"></tailbone-form> - <div id="report-description">{{ reportDescription }}</div> - </div> - </div> -</%def> - -<%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 - % else: - <div> - <p>Please select the type of report you wish to generate.</p> - - <div class="report-selection"> - % for key in sorted_reports: - <% report = reports[key] %> - <h3>${h.link_to(report.name, url('generate_specific_report', type_key=key))}</h3> - <p>${report.__doc__}</p> - % endfor - </div> - </div> - % endif -</%def> - -<%def name="finalize_this_page_vars()"> - ${parent.finalize_this_page_vars()} - <script type="text/javascript"> - - TailboneFormData.reportDescriptions = ${json.dumps(report_descriptions)|n} - - TailboneForm.methods.reportTypeChanged = function(reportType) { - this.$emit('report-change', this.reportDescriptions[reportType]) - } - - ThisPageData.reportDescription = null - - ThisPage.methods.reportChanged = function(description) { - this.reportDescription = description - } - - </script> -</%def> - - -${parent.body()} diff --git a/tailbone/templates/reports/generate.mako b/tailbone/templates/reports/generate.mako deleted file mode 100644 index 57e72385..00000000 --- a/tailbone/templates/reports/generate.mako +++ /dev/null @@ -1,29 +0,0 @@ -## -*- coding: utf-8; -*- -<%inherit file="/form.mako" /> - -<%def name="title()">${index_title} » ${report.name}</%def> - -<%def name="content_title()">${report.name}</%def> - -<%def name="context_menu_items()"> - % if request.has_perm('report_output.list'): - ${h.link_to("View Generated Reports", url('report_output'))} - % endif -</%def> - -<%def name="render_buefy_form()"> - <div class="form"> - <p style="padding: 1em;">${report.__doc__}</p> - <br /> - <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/choose.mako b/tailbone/templates/reports/generated/choose.mako new file mode 100644 index 00000000..0921530c --- /dev/null +++ b/tailbone/templates/reports/generated/choose.mako @@ -0,0 +1,73 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/create.mako" /> + +<%def name="extra_styles()"> + ${parent.extra_styles()} + <style type="text/css"> + + % if use_form: + #report-description { + margin-left: 2em; + } + % else: + .report-selection { + margin-left: 10em; + margin-top: 3em; + } + + .report-selection h3 { + margin-top: 2em; + } + % endif + + </style> +</%def> + +<%def name="render_form()"> + <div class="form"> + <p>Please select the type of report you wish to generate.</p> + <br /> + <div style="display: flex;"> + <tailbone-form v-on:report-change="reportChanged"></tailbone-form> + <div id="report-description">{{ reportDescription }}</div> + </div> + </div> +</%def> + +<%def name="page_content()"> + % if use_form: + ${parent.page_content()} + % else: + <div> + <br /> + <p>Please select the type of report you wish to generate.</p> + + <div class="report-selection"> + % for key in sorted_reports: + <% report = reports[key] %> + <h3>${h.link_to(report.name, url('generate_specific_report', type_key=key))}</h3> + <p>${report.__doc__}</p> + % endfor + </div> + </div> + % endif +</%def> + +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> + + ${form.vue_component}Data.reportDescriptions = ${json.dumps(report_descriptions)|n} + + ${form.vue_component}.methods.reportTypeChanged = function(reportType) { + this.$emit('report-change', this.reportDescriptions[reportType]) + } + + ThisPageData.reportDescription = null + + ThisPage.methods.reportChanged = function(description) { + this.reportDescription = description + } + + </script> +</%def> diff --git a/tailbone/templates/reports/generated/configure.mako b/tailbone/templates/reports/generated/configure.mako new file mode 100644 index 00000000..50109702 --- /dev/null +++ b/tailbone/templates/reports/generated/configure.mako @@ -0,0 +1,22 @@ +## -*- coding: utf-8; -*- +<%inherit file="/configure.mako" /> + +<%def name="form_content()"> + + <h3 class="block is-size-3">Generating</h3> + <div class="block" style="padding-left: 2rem;"> + + <b-field message="If not set, reports are shown as simple list of hyperlinks."> + <b-checkbox name="tailbone.reporting.choosing_uses_form" + v-model="simpleSettings['tailbone.reporting.choosing_uses_form']" + native-value="true" + @input="settingsNeedSaved = true"> + Show report chooser as form, with dropdown + </b-checkbox> + </b-field> + + </div> +</%def> + + +${parent.body()} diff --git a/tailbone/templates/reports/generated/delete.mako b/tailbone/templates/reports/generated/delete.mako new file mode 100644 index 00000000..f60a9819 --- /dev/null +++ b/tailbone/templates/reports/generated/delete.mako @@ -0,0 +1,11 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/delete.mako" /> + +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> + % if params_data is not Undefined: + ${form.vue_component}Data.paramsData = ${json.dumps(params_data)|n} + % endif + </script> +</%def> diff --git a/tailbone/templates/reports/generated/generate.mako b/tailbone/templates/reports/generated/generate.mako new file mode 100644 index 00000000..2b8fa66c --- /dev/null +++ b/tailbone/templates/reports/generated/generate.mako @@ -0,0 +1,28 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/form.mako" /> + +<%def name="title()">${index_title} » ${report.name}</%def> + +<%def name="content_title()">New Report: ${report.name}</%def> + +<%def name="render_form()"> + <div class="form"> + <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> + + +${parent.body()} diff --git a/tailbone/templates/reports/generated/view.mako b/tailbone/templates/reports/generated/view.mako index c7d34efa..cce6f346 100644 --- a/tailbone/templates/reports/generated/view.mako +++ b/tailbone/templates/reports/generated/view.mako @@ -1,11 +1,33 @@ ## -*- coding: utf-8; -*- <%inherit file="/master/view.mako" /> -<%def name="context_menu_items()"> - ${parent.context_menu_items()} - % if request.has_perm('{}.generate'.format(permission_prefix)): - <li>${h.link_to("Generate new Report", url('generate_report'))}</li> +<%def name="object_helpers()"> + % if master.has_perm('create'): + <nav class="panel"> + <p class="panel-heading">Tools</p> + <div class="panel-block buttons"> + <div style="display: flex; flex-direction: column;"> + <once-button type="is-primary" + % if rerun_report_url: + tag="a" href="${rerun_report_url}" + % else: + disabled title="Unknown report type" + % endif + text="Re-run This Report" + icon-pack="fas" + icon-left="arrow-circle-right"> + </once-button> + </div> + </div> + </nav> % endif </%def> -${parent.body()} +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> + % if params_data is not Undefined: + ${form.vue_component}Data.paramsData = ${json.dumps(params_data)|n} + % endif + </script> +</%def> 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 new file mode 100644 index 00000000..5cdf2be5 --- /dev/null +++ b/tailbone/templates/reports/problems/view.mako @@ -0,0 +1,76 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/view.mako" /> + +<%def name="object_helpers()"> + ${parent.object_helpers()} + % if master.has_perm('execute'): + <nav class="panel"> + <p class="panel-heading">Tools</p> + <div class="panel-block buttons"> + <b-button type="is-primary" + @click="runReportShowDialog = true" + icon-pack="fas" + icon-left="arrow-circle-right"> + Run this Report + </b-button> + </div> + </nav> + + <b-modal has-modal-card + :active.sync="runReportShowDialog"> + <div class="modal-card"> + + <header class="modal-card-head"> + <p class="modal-card-title">Run Problem Report</p> + </header> + + <section class="modal-card-body"> + <p class="block"> + You can run this problem report right now if you like. + </p> + + <p class="block"> + Keep in mind the following may receive email, should the + report find any problems. + </p> + + <ul> + % for recip in instance['email_recipients']: + <li>${recip}</li> + % endfor + </ul> + </section> + + <footer class="modal-card-foot"> + <b-button @click="runReportShowDialog = false"> + Cancel + </b-button> + ${h.form(master.get_action_url('execute', instance), **{'@submit': 'runReportSubmitting = true'})} + ${h.csrf_token(request)} + <b-button type="is-primary" + native-type="submit" + :disabled="runReportSubmitting" + icon-pack="fas" + icon-left="arrow-circle-right"> + {{ runReportSubmitting ? "Working, please wait..." : "Run Problem Report" }} + </b-button> + ${h.end_form()} + </footer> + </div> + </b-modal> + % endif +</%def> + +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> + + % if weekdays_data is not Undefined: + ${form.vue_component}Data.weekdaysData = ${json.dumps(weekdays_data)|n} + % endif + + ThisPageData.runReportShowDialog = false + ThisPageData.runReportSubmitting = false + + </script> +</%def> 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 new file mode 100644 index 00000000..f9c815c2 --- /dev/null +++ b/tailbone/templates/settings/email/configure.mako @@ -0,0 +1,139 @@ +## -*- 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 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;"> + + <b-field> + <b-checkbox name="rattail.mail.record_attempts" + v-model="simpleSettings['rattail.mail.record_attempts']" + native-value="true" + @input="settingsNeedSaved = true"> + Make record of all attempts to send email + </b-checkbox> + </b-field> + + <b-field> + <b-checkbox name="rattail.mail.send_email_on_failure" + v-model="simpleSettings['rattail.mail.send_email_on_failure']" + native-value="true" + @input="settingsNeedSaved = true"> + When sending an email fails, send another to report the failure + </b-checkbox> + </b-field> + + </div> + + <h3 class="block is-size-3">Testing</h3> + <div class="block" style="padding-left: 2rem;"> + + <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" + % 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> + </div> + </div> + </div> + + </div> +</%def> + +<%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 + + ThisPage.methods.raiseBogusError = function() { + this.raisingBogusError = true + + let url = '${url('bogus_error')}' + this.$http.get(url).then(response => { + this.$buefy.toast.open({ + message: "Ironically, response was 200 which means we failed to raise an error!\n\nPlease investigate!", + type: 'is-danger', + duration: 5000, // 5 seconds + }) + this.raisingBogusError = false + }, response => { + this.$buefy.toast.open({ + message: "Error was raised; please check your email and/or logs.", + type: 'is-success', + duration: 4000, // 4 seconds + }) + this.raisingBogusError = false + }) + } + + % endif + </script> +</%def> diff --git a/tailbone/templates/settings/email/index.mako b/tailbone/templates/settings/email/index.mako new file mode 100644 index 00000000..ab8d6fa4 --- /dev/null +++ b/tailbone/templates/settings/email/index.mako @@ -0,0 +1,67 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/index.mako" /> + +<%def name="render_grid_component()"> + % if master.has_perm('configure'): + <b-field horizontal label="Showing:"> + <b-select v-model="showEmails" @input="updateVisibleEmails()"> + <option value="available">Available Emails</option> + <option value="all">All Emails</option> + <option value="hidden">Hidden Emails</option> + </b-select> + </b-field> + % endif + + ${parent.render_grid_component()} +</%def> + +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + % if master.has_perm('configure'): + <script> + + ThisPageData.showEmails = 'available' + + ThisPage.methods.updateVisibleEmails = function() { + this.$refs.grid.showEmails = this.showEmails + } + + ${grid.vue_component}Data.showEmails = 'available' + + ${grid.vue_component}.computed.visibleData = function() { + + if (this.showEmails == 'available') { + return this.data.filter(email => email.hidden == 'No') + + } else if (this.showEmails == 'hidden') { + return this.data.filter(email => email.hidden == 'Yes') + } + + // showing all + return this.data + } + + ${grid.vue_component}.methods.renderLabelToggleHidden = function(row) { + return row.hidden == 'Yes' ? "Un-hide" : "Hide" + } + + ${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, + } + this.submitForm(url, params, response => { + // 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> 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/reports/generated/index.mako b/tailbone/templates/tables/index.mako similarity index 54% rename from tailbone/templates/reports/generated/index.mako rename to tailbone/templates/tables/index.mako index 63a5b9b5..b13f0785 100644 --- a/tailbone/templates/reports/generated/index.mako +++ b/tailbone/templates/tables/index.mako @@ -3,9 +3,10 @@ <%def name="context_menu_items()"> ${parent.context_menu_items()} - % if request.has_perm('{}.generate'.format(permission_prefix)): - <li>${h.link_to("Generate new Report", url('generate_report'))}</li> + % if master.has_perm('migrations'): + <li>${h.link_to("View / Apply Migrations", url('{}.migrations'.format(route_prefix)))}</li> % endif </%def> + ${parent.body()} 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 bf8f5ee7..1869043b 100644 --- a/tailbone/templates/themes/falafel/base.mako +++ b/tailbone/templates/themes/falafel/base.mako @@ -1,637 +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> - ${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()"> - ## Vue.js (last known good @ 2.6.10) - ${h.javascript_link('https://unpkg.com/vue/dist/vue.min.js')} - - ## vue-resource - ## (needed for e.g. this.$http.get() calls, used by grid at least) - ${h.javascript_link('https://cdn.jsdelivr.net/npm/vue-resource@1.5.1')} -</%def> - -<%def name="buefy()"> - ${h.javascript_link('https://unpkg.com/buefy@0.8.6/dist/buefy.min.js')} -</%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: - ## Buefy 0.7.4 - ${h.stylesheet_link('https://unpkg.com/buefy@0.7.4/dist/buefy.min.css')} - % 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 --> - <div class="navbar-end"> - - ## 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("Logout", url('logout'), class_='navbar-item')} - </div> - </div> - % else: - ${h.link_to("Login", url('login'), class_='navbar-item')} - % endif - - </div><!-- 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>${index_title}</span> - % elif index_url: - ${h.link_to(index_title, index_url)} - % if parent_url is not Undefined: - <span> »</span> - ${h.link_to(parent_title, parent_url)} - % elif instance_url is not Undefined: - <span> »</span> - ${h.link_to(instance_title, instance_url)} - % endif - % if master.viewing and grid_index: - ${grid_index_nav()} - % endif - % else: - <span>${index_title}</span> - % endif - % elif index_title: - <span>${index_title}</span> - % 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"> - ${h.text('entry', placeholder=quickie.placeholder, autocomplete='off')} - </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 - - ## 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> - % if show_prev_next is not Undefined and show_prev_next: - <div class="level-right"> - % if prev_url: - <div class="level-item"> - ${h.link_to(u"« Older", prev_url, class_='button autodisable')} - </div> - % else: - <div class="level-item"> - ${h.link_to(u"« Older", '#', class_='button', disabled='disabled')} - </div> - % endif - % if next_url: - <div class="level-item"> - ${h.link_to(u"Newer »", next_url, class_='button autodisable')} - </div> - % else: - <div class="level-item"> - ${h.link_to(u"Newer »", '#', class_='button', disabled='disabled')} - </div> - % endif - </div> - % endif - </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(): - % for msg in request.session.pop_flash(): - <b-notification type="is-info"> - ${msg} - </b-notification> - % endfor - % endif - - <this-page - v-on:change-content-title="changeContentTitle"> - </this-page> - </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> - 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> - - </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="declare_whole_page_vars()"> - ${declare_formposter_mixin()} - ${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', - 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 new file mode 100644 index 00000000..10c57e18 --- /dev/null +++ b/tailbone/templates/trainwreck/transactions/configure.mako @@ -0,0 +1,70 @@ +## -*- coding: utf-8; -*- +<%inherit file="/configure.mako" /> + +<%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;"> + <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> + </b-field> + % endfor + </div> +</%def> + +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> + ThisPageData.hiddenDatabases = ${json.dumps(hidden_databases)|n} + </script> +</%def> diff --git a/tailbone/templates/trainwreck/transactions/rollover.mako b/tailbone/templates/trainwreck/transactions/rollover.mako new file mode 100644 index 00000000..f26515b5 --- /dev/null +++ b/tailbone/templates/trainwreck/transactions/rollover.mako @@ -0,0 +1,56 @@ +## -*- coding: utf-8; -*- +<%inherit file="/page.mako" /> + +<%def name="title()">${index_title} » Yearly Rollover</%def> + +<%def name="content_title()">Yearly Rollover</%def> + +<%def name="page_content()"> + <br /> + + % 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. + </b-notification> + % endif + + <p class="block"> + The following Trainwreck databases are configured: + </p> + + <b-table :data="engines"> + <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_vue_vars()"> + ${parent.modify_vue_vars()} + <script> + ThisPageData.engines = ${json.dumps(engines_data)|n} + </script> +</%def> diff --git a/tailbone/templates/trainwreck/transactions/view.mako b/tailbone/templates/trainwreck/transactions/view.mako new file mode 100644 index 00000000..630950cf --- /dev/null +++ b/tailbone/templates/trainwreck/transactions/view.mako @@ -0,0 +1,11 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/view.mako" /> + +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> + % if custorder_xref_markers_data is not Undefined: + ${form.vue_component}Data.custorderXrefMarkersData = ${json.dumps(custorder_xref_markers_data)|n} + % endif + </script> +</%def> diff --git a/tailbone/templates/trainwreck/transactions/view_row.mako b/tailbone/templates/trainwreck/transactions/view_row.mako new file mode 100644 index 00000000..2507492e --- /dev/null +++ b/tailbone/templates/trainwreck/transactions/view_row.mako @@ -0,0 +1,11 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/view_row.mako" /> + +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> + % if discounts_data is not Undefined: + ${form.vue_component}Data.discountsData = ${json.dumps(discounts_data)|n} + % endif + </script> +</%def> 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 new file mode 100644 index 00000000..9439f830 --- /dev/null +++ b/tailbone/templates/upgrades/configure.mako @@ -0,0 +1,163 @@ +## -*- coding: utf-8; -*- +<%inherit file="/configure.mako" /> + +<%def name="form_content()"> + ${h.hidden('upgrade_systems', **{':value': 'JSON.stringify(upgradeSystems)'})} + + <h3 class="is-size-3">Upgradable Systems</h3> + <div class="block" style="padding-left: 2rem; display: flex;"> + + <${b}-table :data="upgradeSystems" + sortable> + <${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" + icon-pack="fas" + icon-left="plus" + @click="upgradeSystemCreate()"> + New System + </b-button> + + <b-modal has-modal-card + :active.sync="upgradeSystemShowDialog"> + <div class="modal-card"> + + <header class="modal-card-head"> + <p class="modal-card-title">Upgradable System</p> + </header> + + <section class="modal-card-body"> + <b-field label="Key" + :type="upgradeSystemKey ? null : 'is-danger'"> + <b-input v-model.trim="upgradeSystemKey" + ref="upgradeSystemKey" + :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'" + expanded /> + </b-field> + <b-field label="Command" + :type="upgradeSystemCommand ? null : 'is-danger'"> + <b-input v-model.trim="upgradeSystemCommand" + ref="upgradeSystemCommand" + expanded /> + </b-field> + </section> + + <footer class="modal-card-foot"> + <b-button type="is-primary" + icon-pack="fas" + icon-left="save" + @click="upgradeSystemSave()" + :disabled="!upgradeSystemKey || !upgradeSystemLabel || !upgradeSystemCommand"> + Save + </b-button> + <b-button @click="upgradeSystemShowDialog = false"> + Cancel + </b-button> + </footer> + </div> + </b-modal> + + </div> + </div> +</%def> + +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> + + ThisPageData.upgradeSystems = ${json.dumps(upgrade_systems)|n} + ThisPageData.upgradeSystemShowDialog = false + ThisPageData.upgradeSystem = null + ThisPageData.upgradeSystemKey = null + ThisPageData.upgradeSystemLabel = null + ThisPageData.upgradeSystemCommand = null + + ThisPage.methods.upgradeSystemCreate = function() { + this.upgradeSystem = null + this.upgradeSystemKey = null + this.upgradeSystemLabel = null + this.upgradeSystemCommand = null + this.upgradeSystemShowDialog = true + this.$nextTick(() => { + this.$refs.upgradeSystemKey.focus() + }) + } + + ThisPage.methods.upgradeSystemEdit = function(system) { + this.upgradeSystem = system + this.upgradeSystemKey = system.key + this.upgradeSystemLabel = system.label + this.upgradeSystemCommand = system.command + this.upgradeSystemShowDialog = true + this.$nextTick(() => { + this.$refs.upgradeSystemCommand.focus() + }) + } + + ThisPage.methods.upgradeSystemSave = function() { + if (this.upgradeSystem) { + this.upgradeSystem.key = this.upgradeSystemKey + this.upgradeSystem.label = this.upgradeSystemLabel + this.upgradeSystem.command = this.upgradeSystemCommand + } else { + let system = {key: this.upgradeSystemKey, + label: this.upgradeSystemLabel, + command: this.upgradeSystemCommand} + this.upgradeSystems.push(system) + } + this.upgradeSystemShowDialog = false + this.settingsNeedSaved = true + } + + </script> +</%def> diff --git a/tailbone/templates/upgrades/view.mako b/tailbone/templates/upgrades/view.mako index 03fd9b6b..c3fca81d 100644 --- a/tailbone/templates/upgrades/view.mako +++ b/tailbone/templates/upgrades/view.mako @@ -1,63 +1,133 @@ ## -*- 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'); +<%def name="extra_styles()"> + ${parent.extra_styles()} + % if master.has_perm('execute'): + <style type="text/css"> + .progress-with-textout { + border: 1px solid Black; + line-height: 1.2; + overflow: auto; + padding: 1rem; } - } - - $(function() { - - show_packages('diffs'); - - $('.showing .all').click(function() { - show_packages('all'); - return false; - }); - - $('.showing .diffs').click(function() { - show_packages('diffs') - return false; - }); - - }); - - </script> + </style> % endif </%def> +<%def name="render_this_page()"> + ${parent.render_this_page()} + + % if expose_websockets and master.has_perm('execute'): + <${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"> + + <div class="level"> + <div class="level-item has-text-centered" + style="display: flex; flex-direction: column;"> + <p class="block"> + 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" + ## show-value + ## format="percent" + > + </b-progress> + % endif + </div> + <div class="level-right"> + <div class="level-item"> + <b-button type="is-warning" + icon-pack="fas" + icon-left="sad-tear" + @click="declareFailureClick()"> + Declare Failure + </b-button> + </div> + </div> + </div> + + <div class="container progress-with-textout is-family-monospace is-size-7" + ref="textout"> + <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> + + </div> + </div> + </${b}-modal> + % endif + + % if master.has_perm('execute'): + ${h.form(master.get_action_url('declare_failure', instance), ref='declareFailureForm')} + ${h.csrf_token(request)} + ${h.end_form()} + % endif +</%def> + +<%def name="render_form()"> + <div class="form"> + <${form.component} + % 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" + % endif + % endif + > + </${form.component}> + </div> +</%def> + <%def name="render_form_buttons()"> - % if not instance.executed and instance.status_code == enum.UPGRADE_STATUS_PENDING and request.has_perm('{}.execute'.format(permission_prefix)): + % if instance_executable and master.has_perm('execute'): <div class="buttons"> % if instance.enabled and not instance.executing: - % if use_buefy: - ${h.form(url('{}.execute'.format(route_prefix), uuid=instance.uuid), **{'@submit': 'submitForm'})} - % else: - ${h.form(url('{}.execute'.format(route_prefix), uuid=instance.uuid), class_='autodisable')} - % endif - ${h.csrf_token(request)} - % if use_buefy: + % if expose_websockets: <b-button type="is-primary" - native-type="submit" - :disabled="formSubmitting"> - {{ formButtonText }} + icon-pack="fas" + icon-left="arrow-circle-right" + :disabled="upgradeExecuting" + @click="$emit('execute-upgrade-click')"> + {{ upgradeExecuting ? "Working, please wait..." : "Execute this upgrade" }} </b-button> % else: - ${h.submit('execute', "Execute this upgrade", class_='button is-primary')} + ${h.form(url('{}.execute'.format(route_prefix), uuid=instance.uuid), **{'@submit': 'submitForm'})} + ${h.csrf_token(request)} + <b-button type="is-primary" + native-type="submit" + icon-pack="fas" + icon-left="arrow-circle-right" + :disabled="formSubmitting"> + {{ formSubmitting ? "Working, please wait..." : "Execute this upgrade" }} + </b-button> + ${h.end_form()} % endif - ${h.end_form()} % elif instance.enabled: <button type="button" class="button is-primary" disabled="disabled" title="This upgrade is currently executing">Execute this upgrade</button> % else: @@ -67,22 +137,153 @@ % 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' - TailboneFormData.formButtonText = "Execute this upgrade" - TailboneFormData.formSubmitting = false + % if master.has_perm('execute'): - TailboneForm.methods.submitForm = function() { - this.formSubmitting = true - this.formButtonText = "Working, please wait..." - } + % if expose_websockets: + + ThisPageData.ws = null + + ////////////////////////////// + // execute upgrade + ////////////////////////////// + + ${form.vue_component}.props.upgradeExecuting = { + type: Boolean, + default: false, + } + + ThisPageData.upgradeExecuting = false + ThisPageData.progressOutput = [] + ThisPageData.progressOutputCounter = 0 + ThisPageData.executeUpgradeComplete = false + + ThisPage.methods.adjustTextoutHeight = function() { + + // grow the textout area to fill most of screen + let textout = this.$refs.textout + let height = window.innerHeight - textout.offsetTop - 50 + textout.style.height = height + 'px' + } + + ThisPage.methods.showExecuteDialog = function() { + this.upgradeExecuting = true + document.title = "Upgrading ${system_title} ..." + this.$nextTick(() => { + this.adjustTextoutHeight() + }) + } + + ThisPage.methods.establishWebsocket = function() { + + ## TODO: should be a cleaner way to get this url? + let url = '${url('ws.upgrades.execution_progress', _query={'uuid': instance.uuid})}' + url = url.replace(/^http(s?):/, 'ws$1:') + + this.ws = new WebSocket(url) + + ## TODO: add support for this here? + // this.ws.onclose = (event) => { + // // websocket closing means 1 of 2 things: + // // - user navigated away from page intentionally + // // - server connection was broken somehow + // // only one of those is "bad" and we only want to + // // display warning in 2nd case. so we simply use a + // // brief delay to "rule out" the 1st scenario + // setTimeout(() => { that.websocketBroken = true }, + // 3000) + // } + + this.ws.onmessage = (event) => { + let data = JSON.parse(event.data) + + if (data.complete) { + + // upgrade has completed; reload page to view result + this.executeUpgradeComplete = true + this.$nextTick(() => { + location.reload() + }) + + } else if (data.stdout) { + + // add lines to textout area + this.progressOutput.push({ + key: ++this.progressOutputCounter, + text: data.stdout}) + + // scroll down to end of textout area + this.$nextTick(() => { + this.$refs.seeme.scrollIntoView({behavior: 'smooth'}) + }) + } + } + } + + % if instance.executing: + ThisPage.mounted = function() { + this.showExecuteDialog() + this.establishWebsocket() + } + % endif + + % if instance_executable: + + ThisPage.methods.executeUpgrade = function() { + this.showExecuteDialog() + + let url = '${master.get_action_url('execute', instance)}' + this.submitForm(url, {ws: true}, response => { + + this.establishWebsocket() + }) + } + + % endif + + % else: + ## no websockets + + ////////////////////////////// + // execute upgrade + ////////////////////////////// + + ${form.vue_component}Data.formSubmitting = false + + ${form.vue_component}.methods.submitForm = function() { + this.formSubmitting = true + } + + % endif + + ////////////////////////////// + // declare failure + ////////////////////////////// + + ${form.vue_component}.props.declareFailureSubmitting = { + type: Boolean, + default: false, + } + + ${form.vue_component}.methods.declareFailureClick = function() { + this.$emit('declare-failure-click') + } + + ThisPageData.declareFailureSubmitting = false + + ThisPage.methods.declareFailureClick = function() { + if (confirm("Really declare this upgrade a failure?")) { + this.declareFailureSubmitting = true + this.$refs.declareFailureForm.submit() + } + } + + % endif </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 new file mode 100644 index 00000000..ecfdd1c7 --- /dev/null +++ b/tailbone/templates/users/preferences.mako @@ -0,0 +1,50 @@ +## -*- coding: utf-8; -*- +<%inherit file="/configure.mako" /> + +<%def name="title()"> + % if current_user: + Edit Preferences + % else: + ${index_title} » ${instance_title} » Preferences + % endif +</%def> + +<%def name="content_title()">Preferences</%def> + +<%def name="intro_message()"> + <p class="block"> + % if current_user: + This page lets you modify your preferences. + % else: + This page lets you modify the preferences for ${config_title}. + % endif + </p> +</%def> + +<%def name="form_content()"> + + <h3 class="block is-size-3">Display</h3> + <div class="block" style="padding-left: 2rem;"> + + <b-field label="Theme Style"> + <b-select name="tailbone.${user.uuid}.user_css" + v-model="simpleSettings['tailbone.${user.uuid}.user_css']" + @input="settingsNeedSaved = true"> + <option v-for="option in themeStyleOptions" + :key="option.value" + :value="option.value"> + {{ option.label }} + </option> + </b-select> + + </b-field> + + </div> +</%def> + +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> + ThisPageData.themeStyleOptions = ${json.dumps(theme_style_options)|n} + </script> +</%def> diff --git a/tailbone/templates/users/view.mako b/tailbone/templates/users/view.mako index 8477ebfa..d1afd218 100644 --- a/tailbone/templates/users/view.mako +++ b/tailbone/templates/users/view.mako @@ -14,4 +14,123 @@ % endif </%def> -${parent.body()} +<%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> + + ${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 5d3100ad..19a1b89d 100644 --- a/tailbone/templates/util.mako +++ b/tailbone/templates/util.mako @@ -2,20 +2,27 @@ <%def name="view_profile_button(person)"> <div class="buttons"> - ${h.link_to(person, url('people.view_profile', uuid=person.uuid), class_='button is-primary')} + <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'): - <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 + <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> - </div> + </nav> % endif </%def> diff --git a/tailbone/templates/vendors/configure.mako b/tailbone/templates/vendors/configure.mako new file mode 100644 index 00000000..6b135346 --- /dev/null +++ b/tailbone/templates/vendors/configure.mako @@ -0,0 +1,52 @@ +## -*- coding: utf-8; -*- +<%inherit file="/configure.mako" /> + +<%def name="form_content()"> + + <h3 class="block is-size-3">Display</h3> + <div class="block" style="padding-left: 2rem;"> + + <b-field message="If not set, vendor chooser is an autocomplete field."> + <b-checkbox name="rattail.vendors.choice_uses_dropdown" + v-model="simpleSettings['rattail.vendors.choice_uses_dropdown']" + native-value="true" + @input="settingsNeedSaved = true"> + Show vendor chooser as dropdown (select) element + </b-checkbox> + </b-field> + + </div> + + <h3 class="block is-size-3">Supported Vendors</h3> + <div class="block" style="padding-left: 2rem;"> + + <p class="block"> + The following vendor "keys" are defined within various places in + the software. You must identify each explicitly with a + Vendor record, for things to work as designed. + </p> + + <b-field v-for="setting in supportedVendorSettings" + :key="setting.key" + horizontal + :label="setting.key" + :type="supportedVendorSettings[setting.key].value ? null : 'is-warning'" + style="max-width: 75%;"> + + <tailbone-autocomplete :name="'rattail.vendor.' + setting.key" + service-url="${url('vendors.autocomplete')}" + v-model="supportedVendorSettings[setting.key].value" + :initial-label="setting.label" + @input="settingsNeedSaved = true"> + </tailbone-autocomplete> + </b-field> + + </div> +</%def> + +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> + ThisPageData.supportedVendorSettings = ${json.dumps(supported_vendor_settings)|n} + </script> +</%def> 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 new file mode 100644 index 00000000..432e011d --- /dev/null +++ b/tailbone/templates/workorders/view.mako @@ -0,0 +1,218 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/view.mako" /> + +## TODO: what was this about? +<%def name="content_title()"> + ## ${instance_title} + #${instance.id} for ${instance.customer} (${enum.WORKORDER_STATUS[instance.status_code]}) +</%def> + +<%def name="object_helpers()"> + % if instance.status_code not in (enum.WORKORDER_STATUS_DELIVERED, enum.WORKORDER_STATUS_CANCELED): + ${self.render_workflow_helper()} + % endif +</%def> + +<%def name="render_workflow_helper()"> + <nav class="panel"> + <p class="panel-heading">Workflow</p> + + % if instance.status_code == enum.WORKORDER_STATUS_SUBMITTED: + <div class="panel-block"> + <div class="buttons"> + ${h.form(url('{}.receive'.format(route_prefix), uuid=instance.uuid), ref='receiveForm')} + ${h.csrf_token(request)} + <b-button type="is-primary" + icon-pack="fas" + icon-left="arrow-right" + @click="receive()" + :disabled="receiveButtonDisabled"> + {{ receiveButtonText }} + </b-button> + ${h.end_form()} + </div> + </div> + % endif + + % if instance.status_code == enum.WORKORDER_STATUS_RECEIVED: + <div class="panel-block"> + <div class="buttons"> + ${h.form(url('{}.await_estimate'.format(route_prefix), uuid=instance.uuid), ref='awaitEstimateForm')} + ${h.csrf_token(request)} + <b-button type="is-primary" + icon-pack="fas" + icon-left="arrow-right" + @click="awaitEstimate()" + :disabled="awaitEstimateButtonDisabled"> + {{ awaitEstimateButtonText }} + </b-button> + ${h.end_form()} + </div> + </div> + % endif + + % if instance.status_code in (enum.WORKORDER_STATUS_RECEIVED, enum.WORKORDER_STATUS_PENDING_ESTIMATE): + <div class="panel-block"> + <div class="buttons"> + ${h.form(url('{}.await_parts'.format(route_prefix), uuid=instance.uuid), ref='awaitPartsForm')} + ${h.csrf_token(request)} + <b-button type="is-primary" + icon-pack="fas" + icon-left="arrow-right" + @click="awaitParts()" + :disabled="awaitPartsButtonDisabled"> + {{ awaitPartsButtonText }} + </b-button> + ${h.end_form()} + </div> + </div> + % endif + + % if instance.status_code in (enum.WORKORDER_STATUS_RECEIVED, enum.WORKORDER_STATUS_PENDING_ESTIMATE, enum.WORKORDER_STATUS_WAITING_FOR_PARTS): + <div class="panel-block"> + <div class="buttons"> + ${h.form(url('{}.work_on_it'.format(route_prefix), uuid=instance.uuid), ref='workOnItForm')} + ${h.csrf_token(request)} + <b-button type="is-primary" + icon-pack="fas" + icon-left="arrow-right" + @click="workOnIt()" + :disabled="workOnItButtonDisabled"> + {{ workOnItButtonText }} + </b-button> + ${h.end_form()} + </div> + </div> + % endif + + % if instance.status_code == enum.WORKORDER_STATUS_WORKING_ON_IT: + <div class="panel-block"> + <div class="buttons"> + ${h.form(url('{}.release'.format(route_prefix), uuid=instance.uuid), ref='releaseForm')} + ${h.csrf_token(request)} + <b-button type="is-primary" + icon-pack="fas" + icon-left="arrow-right" + @click="release()" + :disabled="releaseButtonDisabled"> + {{ releaseButtonText }} + </b-button> + ${h.end_form()} + </div> + </div> + % endif + + % if instance.status_code == enum.WORKORDER_STATUS_RELEASED: + <div class="panel-block"> + <div class="buttons"> + ${h.form(url('{}.deliver'.format(route_prefix), uuid=instance.uuid), ref='deliverForm')} + ${h.csrf_token(request)} + <b-button type="is-primary" + icon-pack="fas" + icon-left="arrow-right" + @click="deliver()" + :disabled="deliverButtonDisabled"> + {{ deliverButtonText }} + </b-button> + ${h.end_form()} + </div> + </div> + % endif + + % if instance.status_code not in (enum.WORKORDER_STATUS_DELIVERED, enum.WORKORDER_STATUS_CANCELED): + <div class="panel-block"> + <p class="is-italic has-text-centered" + style="width: 100%;"> + OR + </p> + </div> + <div class="panel-block"> + <div class="buttons"> + ${h.form(url('{}.cancel'.format(route_prefix), uuid=instance.uuid), ref='cancelForm')} + ${h.csrf_token(request)} + <b-button type="is-warning" + icon-pack="fas" + icon-left="ban" + @click="confirmCancel()" + :disabled="cancelButtonDisabled"> + {{ cancelButtonText }} + </b-button> + ${h.end_form()} + </div> + </div> + % endif + + </nav> +</%def> + +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> + + ThisPageData.receiveButtonDisabled = false + ThisPageData.receiveButtonText = "I've received the order from customer" + + ThisPageData.awaitEstimateButtonDisabled = false + ThisPageData.awaitEstimateButtonText = "I'm waiting for estimate confirmation" + + ThisPageData.awaitPartsButtonDisabled = false + ThisPageData.awaitPartsButtonText = "I'm waiting for parts" + + ThisPageData.workOnItButtonDisabled = false + ThisPageData.workOnItButtonText = "I'm working on it" + + ThisPageData.releaseButtonDisabled = false + ThisPageData.releaseButtonText = "I've sent this back to customer" + + ThisPageData.deliverButtonDisabled = false + ThisPageData.deliverButtonText = "Customer has the completed order!" + + ThisPageData.cancelButtonDisabled = false + ThisPageData.cancelButtonText = "Cancel this Work Order" + + ThisPage.methods.receive = function() { + this.receiveButtonDisabled = true + this.receiveButtonText = "Working, please wait..." + this.$refs.receiveForm.submit() + } + + ThisPage.methods.awaitEstimate = function() { + this.awaitEstimateButtonDisabled = true + this.awaitEstimateButtonText = "Working, please wait..." + this.$refs.awaitEstimateForm.submit() + } + + ThisPage.methods.awaitParts = function() { + this.awaitPartsButtonDisabled = true + this.awaitPartsButtonText = "Working, please wait..." + this.$refs.awaitPartsForm.submit() + } + + ThisPage.methods.workOnIt = function() { + this.workOnItButtonDisabled = true + this.workOnItButtonText = "Working, please wait..." + this.$refs.workOnItForm.submit() + } + + ThisPage.methods.release = function() { + this.releaseButtonDisabled = true + this.releaseButtonText = "Working, please wait..." + this.$refs.releaseForm.submit() + } + + ThisPage.methods.deliver = function() { + this.deliverButtonDisabled = true + this.deliverButtonText = "Working, please wait..." + this.$refs.deliverForm.submit() + } + + ThisPage.methods.confirmCancel = function() { + if (confirm("Are you sure you wish to cancel this Work Order?")) { + this.cancelButtonDisabled = true + this.cancelButtonText = "Working, please wait..." + this.$refs.cancelForm.submit() + } + } + + </script> +</%def> 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 c0ab4e3e..71aa35e3 100644 --- a/tailbone/util.py +++ b/tailbone/util.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,15 +24,14 @@ Utilities """ -from __future__ import unicode_literals, absolute_import - import datetime +import importlib +import logging +import warnings -import six -import pytz import humanize +import markdown -from rattail.time import timezone, make_utc from rattail.files import resource_path import colander @@ -40,44 +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 should_use_buefy(request): +def get_form_data(request): """ - Returns a flag indicating whether or not the current theme supports (and - therefore should use) the Buefy JS library. + DEPECATED - use :func:`wuttaweb:wuttaweb.util.get_form_data()` + instead. """ - # 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 + 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) - # 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_global_search_options(request): + """ + Returns global search options for current request. Basically a + list of all "index views" minus the ones they aren't allowed to + access. + """ + 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 + + +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): @@ -93,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', @@ -123,16 +177,18 @@ def raw_datetime(config, value, verbose=False, as_date=False): 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)) kwargs = {} @@ -144,12 +200,10 @@ 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) - # avoid humanize error when calculating huge time diff - time_diff = None - if abs(time_ago.days) < 100000: - time_diff = humanize.naturaltime(time_ago) + time_diff = app.render_time_ago(time_ago, fallback=None) + if time_diff is not None: # by "verbose" we mean the result text to look like "YYYY-MM-DD (X days ago)" if verbose: @@ -162,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 @@ -170,8 +236,6 @@ def set_app_theme(request, theme, session=None): This also saves the setting for the new theme, and updates the running app registry settings with the new theme. """ - from rattail.db import api - theme = get_effective_theme(request.rattail_config, theme=theme, session=session) theme_path = get_theme_template_path(request.rattail_config, theme=theme, session=session) @@ -186,7 +250,16 @@ def set_app_theme(request, theme, session=None): # clear template cache for lookup object, so it will reload each (as needed) lookup._collection.clear() - api.save_setting(session, 'tailbone.theme', theme) + app = request.rattail_config.get_app() + close = False + if not session: + session = app.make_session() + close = True + app.save_setting(session, 'tailbone.theme', theme) + if close: + session.commit() + session.close() + request.registry.settings['tailbone.theme'] = theme @@ -200,26 +273,77 @@ 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 will be used; otherwise it is read from database setting. """ - from rattail.db import api + app = rattail_config.get_app() if not theme: - theme = api.get_setting(session, 'tailbone.theme') or 'default' + close = False + if not session: + session = app.make_session() + close = True + theme = app.get_setting(session, 'tailbone.theme') or 'default' + if close: + 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 @@ -251,3 +375,27 @@ def route_exists(request, route_name): mapper = reg.getUtility(IRoutesMapper) route = mapper.get_route(route_name) return bool(route) + + +def include_configured_views(pyramid_config): + """ + Include arbitrary additional views based on DB settings. + """ + rattail_config = pyramid_config.registry.settings.get('rattail_config') + app = rattail_config.get_app() + model = app.model + session = app.make_session() + + # fetch all include-related settings at once + settings = session.query(model.Setting)\ + .filter(model.Setting.name.like('tailbone.includes.%'))\ + .all() + + for setting in settings: + if setting.value: + try: + pyramid_config.include(setting.value) + except: + log.warning("pyramid failed to include: %s", exc_info=True) + + session.close() 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 new file mode 100644 index 00000000..33888654 --- /dev/null +++ b/tailbone/views/asgi/__init__.py @@ -0,0 +1,133 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2024 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. +# +################################################################################ +""" +ASGI Views +""" + +from http.cookies import SimpleCookie + +from beaker.session import SignedCookie +from pyramid.interfaces import ISessionFactory + + +class MockRequest(dict): + """ + Fake request class, needed for re-construction of the user's web + session. + """ + environ = {} + + def add_response_callback(self, func): + pass + + +class WebsocketView: + + def __init__(self, pyramid_config): + self.pyramid_config = pyramid_config + self.registry = self.pyramid_config.registry + app = self.get_rattail_app() + self.model = app.model + + @property + def rattail_config(self): + return self.registry['rattail_config'] + + def get_rattail_app(self): + return self.rattail_config.get_app() + + async def authorize(self, scope, receive, send, permission): + + # is user authorized for this socket? + authorized = await self.has_permission(scope, permission) + + # wait for client to connect + message = await receive() + assert message['type'] == 'websocket.connect' + + # allow or deny access, per authorization + if authorized: + await send({'type': 'websocket.accept'}) + else: # forbidden + await send({'type': 'websocket.close'}) + + return authorized + + async def get_user(self, scope, session=None): + app = self.get_rattail_app() + model = self.model + + # load the user's web session + user_session = self.get_user_session(scope) + if user_session: + + # determine user uuid + user_uuid = user_session.get('auth.userid') + if user_uuid: + + # use given db session, or make a new one + with app.short_session(config=self.rattail_config, + session=session) as s: + + # load user proper + return s.get(model.User, user_uuid) + + def get_user_session(self, scope): + settings = self.registry.settings + beaker_key = settings['beaker.session.key'] + beaker_secret = settings['beaker.session.secret'] + + # get ahold of session identifier cookie + headers = dict(scope['headers']) + cookie = headers.get(b'cookie') + if not cookie: + return + cookie = cookie.decode('utf_8') + cookie = SimpleCookie(cookie) + morsel = cookie[beaker_key] + + # simulate pyramid_beaker logic to get at the actual session + cookieheader = morsel.output(header='') + cookie = SignedCookie(beaker_secret, input=cookieheader) + session_id = cookie[beaker_key].value + factory = self.registry.queryUtility(ISessionFactory) + request = MockRequest() + # nb. cannot pass 'id' to our factory, but things still work + # if we assign it immediately, before load() is called + session = factory(request) + session.id = session_id + session.load() + + return session + + async def has_permission(self, scope, permission): + app = self.get_rattail_app() + auth_handler = app.get_auth_handler() + + # figure out if user is authorized for this websocket + session = app.make_session() + user = await self.get_user(scope, session=session) + authorized = auth_handler.has_permission(session, user, permission) + session.close() + + return authorized diff --git a/tailbone/views/asgi/datasync.py b/tailbone/views/asgi/datasync.py new file mode 100644 index 00000000..2dec06ea --- /dev/null +++ b/tailbone/views/asgi/datasync.py @@ -0,0 +1,86 @@ +# -*- 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/>. +# +################################################################################ +""" +DataSync Views +""" + +import asyncio +import json + +from tailbone.views.asgi import WebsocketView + + +class DatasyncWS(WebsocketView): + + async def status(self, scope, receive, send): + app = self.get_rattail_app() + datasync_handler = app.get_datasync_handler() + + # is user allowed to see this? + if not await self.authorize(scope, receive, send, 'datasync.status'): + return + + # this tracks when client disconnects + state = {'disconnected': False} + + async def wait_for_disconnect(): + message = await receive() + if message['type'] == 'websocket.disconnect': + state['disconnected'] = True + + # watch for client disconnect, while we do other things + asyncio.create_task(wait_for_disconnect()) + + # do the rest forever, until client disconnects + while not state['disconnected']: + + # give client latest supervisor process info + info = datasync_handler.get_supervisor_process_info() + await send({'type': 'websocket.send', + 'subtype': 'datasync.supervisor_process_info', + 'text': json.dumps(info)}) + + # pause for 1 second + await asyncio.sleep(1) + + @classmethod + def defaults(cls, config): + cls._defaults(config) + + @classmethod + def _defaults(cls, config): + + # status + config.add_tailbone_websocket('datasync.status', + cls, attr='status') + + +def defaults(config, **kwargs): + base = globals() + + DatasyncWS = kwargs.get('DatasyncWS', base['DatasyncWS']) + DatasyncWS.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/asgi/upgrades.py b/tailbone/views/asgi/upgrades.py new file mode 100644 index 00000000..13458f23 --- /dev/null +++ b/tailbone/views/asgi/upgrades.py @@ -0,0 +1,233 @@ +# -*- 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/>. +# +################################################################################ +""" +Upgrade Views for ASGI +""" + +import asyncio +import json +import os +from urllib.parse import parse_qs + +from tailbone.views.asgi import WebsocketView +from tailbone.progress import get_basic_session + + +class UpgradeExecutionProgressWS(WebsocketView): + + # keep track of all "global" state for this socket + global_state = { + 'upgrades': {}, + } + + new_messages = asyncio.Queue() + + async def __call__(self, scope, receive, send): + app = self.get_rattail_app() + + # is user allowed to see this? + if not await self.authorize(scope, receive, send, 'upgrades.execute'): + return + + # keep track of client state + client_state = { + 'uuid': app.make_uuid(), + 'disconnected': False, + 'scope': scope, + 'receive': receive, + 'send': send, + } + + # parse upgrade uuid from query string + query = scope['query_string'].decode('utf_8') + query = parse_qs(query) + uuid = query['uuid'][0] + + # first client to request progress for this upgrade, must + # start a task to manage the collect/transmit logic for + # progress data, on behalf of this and/or any future clients + started_task = None + if uuid not in self.global_state['upgrades']: + + # this upgrade is new to us; establish state and add first client + upgrade_state = self.global_state['upgrades'][uuid] = { + 'clients': {client_state['uuid']: client_state}, + } + + # start task for transmit of progress data to all clients + started_task = asyncio.create_task(self.manage_progress(uuid)) + + else: + + # progress task is already running, just add new client + upgrade_state = self.global_state['upgrades'][uuid] + upgrade_state['clients'][client_state['uuid']] = client_state + + async def wait_for_disconnect(): + message = await receive() + if message['type'] == 'websocket.disconnect': + client_state['disconnected'] = True + + # wait forever, until client disconnects + asyncio.create_task(wait_for_disconnect()) + while not client_state['disconnected']: + + # can stop if upgrade has completed + if uuid not in self.global_state['upgrades']: + break + + await asyncio.sleep(0.1) + + # remove client from global set, if upgrade still running + if client_state['disconnected']: + upgrade_state = self.global_state['upgrades'].get(uuid) + if upgrade_state: + del upgrade_state['clients'][client_state['uuid']] + + # must continue to wait for other clients, if this client was + # the first to request progress + if started_task: + await started_task + + async def manage_progress(self, uuid): + """ + Task which handles collect / transmit of progress data, for + sake of all attached clients. + """ + progress_session_id = 'upgrades.{}.execution_progress'.format(uuid) + progress_session = get_basic_session(self.rattail_config, + id=progress_session_id) + + # start collecting status, textout messages + asyncio.create_task(self.collect_status(uuid, progress_session)) + asyncio.create_task(self.collect_textout(uuid)) + + upgrade_state = self.global_state['upgrades'][uuid] + clients = upgrade_state['clients'] + while clients: + + msg = await self.new_messages.get() + + # send message to all clients + for client in clients.values(): + await client['send']({ + 'type': 'websocket.send', + 'subtype': 'upgrades.execute_progress', + 'text': json.dumps(msg)}) + + await asyncio.sleep(0.1) + + # no more clients, no more reason to track this upgrade + del self.global_state['upgrades'][uuid] + + async def collect_status(self, uuid, progress_session): + + upgrade_state = self.global_state['upgrades'][uuid] + clients = upgrade_state['clients'] + while True: + + # load latest progress data + progress_session.load() + + # when upgrade progress is complete... + if progress_session.get('complete'): + + # maybe set success flash msg (for all clients) + msg = progress_session.get('success_msg') + if msg: + for client in clients.values(): + user_session = self.get_user_session(client['scope']) + user_session.flash(msg) + user_session.persist() + + # push "complete" message to queue + await self.new_messages.put({'complete': True}) + + # there will be no more status coming + break + + await asyncio.sleep(0.1) + + async def collect_textout(self, uuid): + path = self.rattail_config.upgrade_filepath(uuid, filename='stdout.log') + + # wait until stdout file exists + while not os.path.exists(path): + + # bail if upgrade is complete + if uuid not in self.global_state['upgrades']: + return + + await asyncio.sleep(0.1) + + offset = 0 + while True: + + # wait until we have something new to read + size = os.path.getsize(path) - offset + while not size: + + # bail if upgrade is complete + if uuid not in self.global_state['upgrades']: + return + + # wait a whole second, then look again + # (the less frequent we look, the bigger the chunk) + await asyncio.sleep(1) + size = os.path.getsize(path) - offset + + # bail if upgrade is complete + if uuid not in self.global_state['upgrades']: + return + + # read the latest chunk and bookmark new offset + with open(path, 'rb') as f: + f.seek(offset) + chunk = f.read(size) + textout = chunk.decode('utf_8') + offset += size + + # push new chunk onto message queue + textout = textout.replace('\n', '<br />') + await self.new_messages.put({'stdout': textout}) + + await asyncio.sleep(0.1) + + @classmethod + def defaults(cls, config): + cls._defaults(config) + + @classmethod + def _defaults(cls, config): + config.add_tailbone_websocket('upgrades.execution_progress', cls) + + +def defaults(config, **kwargs): + base = globals() + + UpgradeExecutionProgressWS = kwargs.get('UpgradeExecutionProgressWS', base['UpgradeExecutionProgressWS']) + UpgradeExecutionProgressWS.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/auth.py b/tailbone/views/auth.py index d071ace7..eceab803 100644 --- a/tailbone/views/auth.py +++ b/tailbone/views/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,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,25 +44,6 @@ class UserLogin(colander.MappingSchema): widget=dfwidget.PasswordWidget()) -@colander.deferred -def current_password_correct(node, kw): - user = kw['user'] - def validate(node, value): - if not 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): @@ -84,13 +61,14 @@ class AuthenticationView(View): # Store current URL in session, for smarter redirect after login. self.request.session['next_url'] = self.request.current_route_url() next_url = self.request.route_url('login') - self.request.session.flash(msg, allow_duplicate=False) + self.request.session.flash(msg, 'warning', allow_duplicate=False) return self.redirect(next_url) def login(self, **kwargs): """ 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 @@ -98,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: @@ -119,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() @@ -170,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) - 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): """ @@ -230,11 +227,21 @@ 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') config.add_view(cls, attr='stop_root', route_name='stop_root') -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + AuthenticationView = kwargs.get('AuthenticationView', base['AuthenticationView']) AuthenticationView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index 07b7ff68..c162b579 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.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,8 +24,6 @@ Base views for maintaining "new-style" batches. """ -from __future__ import unicode_literals, absolute_import - import os import sys import json @@ -34,40 +32,30 @@ 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 load_object, 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__) -class EverythingComplete(Exception): - pass - - class BatchMasterView(MasterView): """ Base class for all "batch master" views. @@ -76,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 @@ -86,6 +76,9 @@ class BatchMasterView(MasterView): results_executable = False has_worksheet = False has_worksheet_file = False + delete_requires_progress = True + + input_file_template_config_section = 'rattail.batch' grid_columns = [ 'id', @@ -103,10 +96,11 @@ class BatchMasterView(MasterView): 'id', 'description', 'notes', - 'created', - 'created_by', + 'params', 'rowcount', 'status_code', + 'created', + 'created_by', 'executed', 'executed_by', ] @@ -118,8 +112,10 @@ class BatchMasterView(MasterView): } def __init__(self, request): - super(BatchMasterView, self).__init__(request) - self.handler = self.get_handler() + super().__init__(request) + self.batch_handler = self.get_handler() + # TODO: deprecate / remove this (?) + self.handler = self.batch_handler @classmethod def get_handler_factory(cls, rattail_config): @@ -140,12 +136,13 @@ class BatchMasterView(MasterView): ``batch_key`` attribute of the main batch model class. """ # first try to figure out if config defines a factory class + app = rattail_config.get_app() model_class = cls.get_model_class() batch_key = model_class.batch_key - spec = rattail_config.get('rattail.batch', '{}.handler'.format(batch_key), - default=cls.default_handler_spec) - if spec: # yep, so use that - return load_object(spec) + handler = app.get_batch_handler(batch_key, + default=cls.default_handler_spec) + if handler: + return handler.__class__ # fall back to whatever class was defined statically return cls.batch_handler_class @@ -159,11 +156,15 @@ class BatchMasterView(MasterView): factory = self.get_handler_factory(self.rattail_config) return factory(self.rattail_config) + @property + def input_file_template_config_prefix(self): + return '{}.input_file_template'.format(self.batch_handler.batch_key) + def download_path(self, batch, filename): 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 @@ -183,25 +184,29 @@ class BatchMasterView(MasterView): else: kwargs['why_not_execute'] = self.handler.why_not_execute(batch) - kwargs['status_breakdown'] = self.make_status_breakdown(batch) - if use_buefy: - data = [{'title': title, 'count': count} - for title, count in kwargs['status_breakdown']] - Grid = self.get_grid_factory() - kwargs['status_breakdown_grid'] = Grid('batch_row_status_breakdown', - data, ['title', 'count']) + breakdown = self.make_status_breakdown(batch) + + 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 @@ -229,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) @@ -276,16 +281,14 @@ class BatchMasterView(MasterView): 'count': 0, } breakdown[row.status_code]['count'] += 1 - breakdown = [ - (status['title'], status['count']) - for code, status in six.iteritems(breakdown)] - return breakdown + return list(breakdown.values()) def allow_worksheet(self, batch): 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) @@ -334,13 +337,20 @@ 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') f.set_renderer('id', self.render_id_str) f.set_label('id', "Batch ID") + # params + if self.creating: + f.remove('params') + else: + f.set_readonly('params') + f.set_renderer('params', self.render_params) + # created f.set_readonly('created') f.set_readonly('created_by') @@ -374,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 @@ -397,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: @@ -423,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) @@ -468,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) @@ -490,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? @@ -504,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 @@ -559,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: @@ -596,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') @@ -615,21 +622,16 @@ class BatchMasterView(MasterView): def get_row_status_enum(self): return self.model_row_class.STATUS - def render_upc(self, row, field): - upc = row.upc - if not upc: - return "" - text = upc.pretty() - if row.product_uuid: - url = self.request.route_url('products.view', uuid=row.product_uuid) - return tags.link_to(text, url) - return text + def render_upc_pretty(self, row, field): + upc = getattr(row, field) + if upc: + return upc.pretty() def render_row_status(self, row, column): 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 @@ -642,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() @@ -655,11 +657,15 @@ 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') + # upc (default rendering, just in case there is such a field + # on our row model) + f.set_renderer('upc', self.render_upc_pretty) + # status_code if self.model_row_class: f.set_enum('status_code', self.model_row_class.STATUS) @@ -674,14 +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.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): """ @@ -691,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 '') @@ -733,8 +736,55 @@ class BatchMasterView(MasterView): """ Delete all data (files etc.) for the batch. """ - self.handler.do_delete(batch) - super(BatchMasterView, self).delete_instance(batch) + app = self.get_rattail_app() + session = app.get_session(batch) + self.batch_handler.do_delete(batch) + session.flush() + + def delete_instance_with_progress(self, batch): + """ + Delete all data (files etc.) for the batch. + """ + return self.handler_action(batch, 'delete') + + def delete_thread(self, key, user_uuid, progress, **kwargs): + """ + Thread target for deleting a batch with progress indicator. + """ + app = self.get_rattail_app() + model = self.model + # nb. must make new session, separate from main thread + session = app.make_session() + batch = self.get_instance_for_key(key, session) + batch_str = str(batch) + + try: + # try to delete batch + self.handler.do_delete(batch, progress=progress, **kwargs) + + except Exception as error: + # error; log that and rollback + log.exception("delete failed for batch: %s", batch) + session.rollback() + session.close() + if progress: + progress.session.load() + progress.session['error'] = True + progress.session['error_msg'] = "Batch deletion failed: {}".format( + simple_error(error)) + progress.session.save() + + else: + # no error; finish up + session.commit() + session.close() + if progress: + progress.session.load() + progress.session['complete'] = True + progress.session['success_url'] = self.get_index_url() + progress.session['success_msg'] = "Batch has been deleted: {}".format( + batch_str) + progress.session.save() def get_fallback_templates(self, template, **kwargs): return [ @@ -782,35 +832,43 @@ class BatchMasterView(MasterView): """ defaults = {} route_prefix = self.get_route_prefix() - use_buefy = self.get_use_buefy() + schema = None if self.has_execution_options(batch): if batch is None: batch = self.model_class schema = self.make_execute_schema(batch) - for field in schema: + if schema: + for field in schema: - # if field does not yet have a default, maybe provide one from session storage - if field.default is colander.null: - key = 'batch.{}.execute_option.{}'.format(batch.batch_key, field.name) - if key in self.request.session: - defaults[field.name] = self.request.session[key] + # if field does not yet have a default, maybe provide one from session storage + if field.default is colander.null: + key = 'batch.{}.execute_option.{}'.format(batch.batch_key, field.name) + if key in self.request.session: + defaults[field.name] = self.request.session[key] - # make sure field label is preserved - if field.title: - labels = kwargs.setdefault('labels', {}) - labels[field.name] = field.title + # make sure field label is preserved + if field.title: + 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): - field.widget = dfwidget.SelectWidget(values=field.widget.values) + # 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) - else: + 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'): @@ -850,7 +908,7 @@ class BatchMasterView(MasterView): # launch thread to invoke handler action thread = Thread(target=self.action_subprocess_thread, - args=(batch.uuid, port, username, batch_action, progress), + args=((batch.uuid,), port, username, batch_action, progress), kwargs=kwargs) thread.start() @@ -859,7 +917,7 @@ class BatchMasterView(MasterView): # launch thread to populate batch; that will update session progress directly target = getattr(self, '{}_thread'.format(batch_action)) - thread = Thread(target=target, args=(batch.uuid, user_uuid, progress), kwargs=kwargs) + thread = Thread(target=target, args=((batch.uuid,), user_uuid, progress), kwargs=kwargs) thread.start() return self.render_progress(progress, { @@ -868,78 +926,6 @@ class BatchMasterView(MasterView): 'cancel_msg': "{} of batch was canceled.".format(batch_action.capitalize()), }) - def progress_thread(self, sock, success_url, progress): - """ - This method is meant to be used as a thread target. Its job is to read - progress data from ``connection`` and update the session progress - accordingly. When a final "process complete" indication is read, the - socket will be closed and the thread will end. - """ - while True: - try: - self.process_progress(sock, progress) - except EverythingComplete: - break - - # close server socket - sock.close() - - # finalize session progress - progress.session.load() - progress.session['complete'] = True - if callable(success_url): - success_url = success_url() - progress.session['success_url'] = success_url - progress.session.save() - - def process_progress(self, sock, progress): - """ - This method will accept a client connection on the given socket, and - then update the given progress object according to data written by the - client. - """ - connection, client_address = sock.accept() - active_progress = None - - # TODO: make this configurable? - suffix = "\n\n.".encode('utf_8') - data = b'' - - # listen for progress info, update session progress as needed - while True: - - # accumulate data bytestring until we see the suffix - byte = connection.recv(1) - data += byte - if data.endswith(suffix): - - # strip suffix, interpret data as JSON - data = data[:-len(suffix)] - if six.PY3: - data = data.decode('utf_8') - data = json.loads(data) - - if data.get('everything_complete'): - if active_progress: - active_progress.finish() - raise EverythingComplete - - elif data.get('process_complete'): - active_progress.finish() - active_progress = None - break - - elif 'value' in data: - if not active_progress: - active_progress = progress(data['message'], data['maximum']) - active_progress.update(data['value']) - - # reset data buffer - data = b'' - - # close client connection - connection.close() - def launch_subprocess(self, port=None, username=None, command='rattail', command_args=None, subcommand=None, subcommand_args=None): @@ -948,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]) @@ -964,9 +950,18 @@ class BatchMasterView(MasterView): # run command in subprocess log.debug("launching command in subprocess: %s", cmd) - subprocess.check_call(cmd) + try: + # 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) + output = error.output.decode('utf_8') + log.warning(output) + raise Exception(output) - def action_subprocess_thread(self, batch_uuid, port, username, handler_action, progress, **kwargs): + def action_subprocess_thread(self, key, port, username, handler_action, progress, **kwargs): """ This method is sort of an alternative thread target for batch actions, to be used in the event versioning is enabled for the main process but @@ -974,7 +969,13 @@ class BatchMasterView(MasterView): launch a separate process with versioning disabled in order to act on the batch. """ + 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, @@ -993,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) @@ -1003,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 @@ -1018,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, @@ -1051,21 +1052,24 @@ 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.warning("batch population failed: %s", batch, exc_info=True) + log.warning("population failed for batch %s: %s", batch.uuid, batch, + exc_info=True) session.close() if progress: progress.session.load() progress.session['error'] = True - progress.session['error_msg'] = "Batch population failed: {}".format( - simple_error(error)) + progress.session['error_msg'] = simple_error(error) progress.session.save() return @@ -1109,11 +1113,14 @@ 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() except Exception as error: session.rollback() log.warning("refreshing data for batch failed: {}".format(batch), exc_info=True) @@ -1161,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) @@ -1219,18 +1228,34 @@ class BatchMasterView(MasterView): """ Batch rows are editable only until batch is complete or executed. """ + if not (self.rows_editable or self.rows_editable_but_not_directly): + return False + batch = self.get_parent(row) - return self.rows_editable and not batch.executed and not batch.complete + if batch.complete or batch.executed: + return False + + return True def row_deletable(self, row): """ Batch rows are deletable only until batch is complete or executed. """ - if self.rows_deletable: - batch = self.get_parent(row) - if not batch.executed and not batch.complete: - return True - return False + if not self.rows_deletable: + return False + + batch = self.get_parent(row) + + 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): kwargs['batch_model_title'] = kwargs['parent_model_title'] @@ -1248,22 +1273,19 @@ class BatchMasterView(MasterView): """ self.handler.do_remove_row(row) - def bulk_delete_rows(self): - """ - "Delete" all rows matching the current row grid view query. This sets - the ``removed`` flag on the rows but does not truly delete them. - """ + def delete_row_objects(self, rows): + deleted = super().delete_row_objects(rows) batch = self.get_instance() - query = self.get_effective_row_data(sort=False) - # TODO: this should surely be handled by the handler... + # decrement rowcount for batch if batch.rowcount is not None: - batch.rowcount -= query.count() - query.update({'removed': True}, synchronize_session=False) + batch.rowcount -= deleted + + # refresh batch status self.Session.refresh(batch) self.handler.refresh_batch_status(batch) - return self.redirect(self.get_action_url('view', batch)) + return deleted def execute(self): """ @@ -1273,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 @@ -1288,16 +1310,18 @@ class BatchMasterView(MasterView): def execute_error_message(self, error): return "Batch execution failed: {}".format(simple_error(error)) - def execute_thread(self, batch_uuid, user_uuid, progress, **kwargs): + def execute_thread(self, key, user_uuid, progress, **kwargs): """ Thread target for executing a batch with progress indicator. """ # 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() - batch = session.query(self.model_class).get(batch_uuid) - user = session.query(model.User).get(user_uuid) + app = self.get_rattail_app() + model = self.model + session = app.make_session() + batch = self.get_instance_for_key(key, session) + user = session.get(model.User, user_uuid) try: result = self.handler.do_execute(batch, user=user, progress=progress, **kwargs) @@ -1344,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 @@ -1370,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) @@ -1410,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 @@ -1489,13 +1515,11 @@ class BatchMasterView(MasterView): config.add_tailbone_permission(permission_prefix, '{}.refresh'.format(permission_prefix), "Refresh data for {}".format(model_title)) - # bulk delete rows - if cls.rows_bulk_deletable: - config.add_route('{}.delete_rows'.format(route_prefix), '{}/{{uuid}}/rows/delete'.format(url_prefix)) - config.add_view(cls, attr='bulk_delete_rows', route_name='{}.delete_rows'.format(route_prefix), - permission='{}.delete_rows'.format(permission_prefix)) - config.add_tailbone_permission(permission_prefix, '{}.delete_rows'.format(permission_prefix), - "Bulk-delete data rows from {}".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)) @@ -1541,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 new file mode 100644 index 00000000..486d8774 --- /dev/null +++ b/tailbone/views/batch/handheld.py @@ -0,0 +1,209 @@ +# -*- 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 handheld batches +""" + +from collections import OrderedDict + +from rattail.db.model import HandheldBatch, HandheldBatchRow + +import colander +from deform import widget as dfwidget +from webhelpers2.html import tags + +from tailbone.views.batch import FileBatchMasterView + + +ACTION_OPTIONS = OrderedDict([ + ('make_label_batch', "Make a new Label Batch"), + ('make_inventory_batch', "Make a new Inventory Batch"), +]) + + +class ExecutionOptions(colander.Schema): + + action = colander.SchemaNode( + colander.String(), + validator=colander.OneOf(ACTION_OPTIONS), + widget=dfwidget.SelectWidget(values=list(ACTION_OPTIONS.items()))) + + +class HandheldBatchView(FileBatchMasterView): + """ + Master view for handheld batches. + """ + model_class = HandheldBatch + default_handler_spec = 'rattail.batch.handheld:HandheldBatchHandler' + model_title_plural = "Handheld Batches" + route_prefix = 'batch.handheld' + url_prefix = '/batch/handheld' + execution_options_schema = ExecutionOptions + editable = False + + model_row_class = HandheldBatchRow + rows_creatable = False + rows_editable = True + + grid_columns = [ + 'id', + 'device_type', + 'device_name', + 'created', + 'created_by', + 'rowcount', + 'status_code', + 'executed', + ] + + form_fields = [ + 'id', + 'device_type', + 'device_name', + 'filename', + 'created', + 'created_by', + 'rowcount', + 'status_code', + 'executed', + 'executed_by', + ] + + row_labels = { + 'upc': "UPC", + } + + row_grid_columns = [ + 'sequence', + 'upc', + 'brand_name', + 'description', + 'size', + 'cases', + 'units', + 'status_code', + ] + + row_form_fields = [ + 'sequence', + 'upc', + 'brand_name', + 'description', + 'size', + 'status_code', + 'cases', + 'units', + ] + + def configure_grid(self, 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) + + def grid_extra_class(self, batch, i): + if batch.status_code is not None and batch.status_code != batch.STATUS_OK: + return 'notice' + + def configure_form(self, f): + super().configure_form(f) + batch = f.model_instance + + # device_type + device_types = OrderedDict(sorted(self.enum.HANDHELD_DEVICE_TYPE.items(), + key=lambda item: item[1])) + f.set_enum('device_type', device_types) + f.widgets['device_type'].values.insert(0, ('', "(none)")) + + if self.creating: + f.set_fields([ + 'filename', + 'device_type', + 'device_name', + ]) + + if self.viewing: + if batch.inventory_batch: + f.append('inventory_batch') + f.set_renderer('inventory_batch', self.render_inventory_batch) + + def render_inventory_batch(self, handheld_batch, field): + batch = handheld_batch.inventory_batch + if not batch: + return "" + text = batch.id_str + url = self.request.route_url('batch.inventory.view', uuid=batch.uuid) + return tags.link_to(text, url) + + def get_batch_kwargs(self, 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().configure_row_grid(g) + g.set_type('cases', 'quantity') + g.set_type('units', 'quantity') + g.set_label('brand_name', "Brand") + + def row_grid_extra_class(self, row, i): + if row.status_code == row.STATUS_PRODUCT_NOT_FOUND: + return 'warning' + + def configure_row_form(self, f): + super().configure_row_form(f) + + # readonly fields + f.set_readonly('upc') + f.set_readonly('brand_name') + f.set_readonly('description') + f.set_readonly('size') + + # upc + f.set_renderer('upc', self.render_upc) + + def get_execute_success_url(self, batch, result, **kwargs): + if kwargs['action'] == 'make_inventory_batch': + 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().get_execute_success_url(batch) + + def get_execute_results_success_url(self, result, **kwargs): + if result is True: + # no batches were actually executed + return self.get_index_url() + batch = result + return self.get_execute_success_url(batch, result, **kwargs) + + +def defaults(config, **kwargs): + base = globals() + + HandheldBatchView = kwargs.get('HandheldBatchView', base['HandheldBatchView']) + HandheldBatchView.defaults(config) + + +def includeme(config): + defaults(config) 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 c52a5a67..7291b05e 100644 --- a/tailbone/views/batch/labels.py +++ b/tailbone/views/batch/labels.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,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 @@ -84,6 +80,11 @@ class LabelBatchView(BatchMasterView): 'upc': "UPC", 'vendor_id': "Vendor ID", 'label_profile': "Label Type", + 'sale_start': "Sale Starts", + 'sale_stop': "Sale Ends", + 'tpr_price': "TPR Price", + 'tpr_starts': "TPR Starts", + 'tpr_ends': "TPR Ends", } row_form_fields = [ @@ -101,6 +102,12 @@ class LabelBatchView(BatchMasterView): 'sale_price', 'sale_start', 'sale_stop', + 'tpr_price', + 'tpr_starts', + 'tpr_ends', + 'current_price', + 'current_starts', + 'current_ends', 'vendor_id', 'vendor_name', 'vendor_item_code', @@ -112,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: @@ -131,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: @@ -148,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") @@ -160,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') @@ -208,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 b56d008e..bd46ad52 100644 --- a/tailbone/views/batch/newproduct.py +++ b/tailbone/views/batch/newproduct.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,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 @@ -46,11 +46,20 @@ class NewProductBatchView(BatchMasterView): rows_editable = True rows_bulk_deletable = True + 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', @@ -64,14 +73,14 @@ class NewProductBatchView(BatchMasterView): row_grid_columns = [ 'sequence', - 'upc', + '_product_key_', 'brand_name', 'description', 'size', 'vendor', 'vendor_item_code', - 'department', - 'subdepartment', + 'department_name', + 'subdepartment_name', 'regular_price', 'status_code', ] @@ -79,17 +88,25 @@ class NewProductBatchView(BatchMasterView): row_form_fields = [ 'sequence', 'product', - 'upc', + '_product_key_', 'brand_name', 'description', 'size', + 'unit_size', + 'unit_of_measure_entry', 'vendor_id', 'vendor', 'vendor_item_code', 'department_number', + 'department_name', 'department', 'subdepartment_number', + 'subdepartment_name', 'subdepartment', + 'weighed', + 'tax1', + 'tax2', + 'tax3', 'case_size', 'case_cost', 'unit_cost', @@ -104,12 +121,21 @@ class NewProductBatchView(BatchMasterView): 'family', 'report_code', 'report', + 'ecommerce_available', 'status_code', 'status_text', ] + def get_input_file_templates(self): + return [ + {'key': 'default', + 'label': "Default", + 'default_url': self.request.static_url( + 'tailbone:static/files/newproduct_template.xlsx')}, + ] + def configure_form(self, f): - super(NewProductBatchView, self).configure_form(f) + super().configure_form(f) # input_filename if self.creating: @@ -118,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) @@ -127,6 +181,10 @@ class NewProductBatchView(BatchMasterView): g.set_type('pack_price', 'currency') g.set_type('suggested_price', 'currency') + g.set_link('brand_name') + g.set_link('description') + g.set_link('size') + def row_grid_extra_class(self, row, i): if row.status_code in (row.STATUS_MISSING_KEY, row.STATUS_PRODUCT_EXISTS, @@ -136,11 +194,12 @@ class NewProductBatchView(BatchMasterView): return 'warning' if row.status_code in (row.STATUS_CATEGORY_NOT_FOUND, row.STATUS_FAMILY_NOT_FOUND, - row.STATUS_REPORTCODE_NOT_FOUND): + row.STATUS_REPORTCODE_NOT_FOUND, + row.STATUS_CANNOT_CALCULATE_PRICE): 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') @@ -152,11 +211,19 @@ 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) f.set_renderer('report', self.render_report) -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + NewProductBatchView = kwargs.get('NewProductBatchView', base['NewProductBatchView']) NewProductBatchView.defaults(config) + + +def includeme(config): + defaults(config) 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 1f054e61..5b5d013b 100644 --- a/tailbone/views/batch/pricing.py +++ b/tailbone/views/batch/pricing.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,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 @@ -52,6 +48,7 @@ class PricingBatchView(BatchMasterView): bulk_deletable = True rows_editable = True rows_bulk_deletable = True + configurable = True labels = { 'min_diff_threshold': "Min $ Diff", @@ -62,11 +59,12 @@ class PricingBatchView(BatchMasterView): grid_columns = [ 'id', 'description', + 'start_date', 'created', 'created_by', 'rowcount', # 'status_code', - # 'complete', + 'complete', 'executed', 'executed_by', ] @@ -75,6 +73,7 @@ class PricingBatchView(BatchMasterView): 'id', 'input_filename', 'description', + 'start_date', 'min_diff_threshold', 'min_diff_percent', 'calculate_for_manual', @@ -84,6 +83,7 @@ class PricingBatchView(BatchMasterView): 'created_by', 'rowcount', 'shelved', + 'complete', 'executed', 'executed_by', ] @@ -147,8 +147,24 @@ class PricingBatchView(BatchMasterView): 'status_text', ] + def allow_future_pricing(self): + 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 + + if self.creating or self.editing: + if self.allow_future_pricing(): + f.set_type('start_date', 'date_jquery') + f.set_helptext('start_date', "Only set this for a \"FUTURE\" batch.") + else: + f.remove('start_date') + else: # viewing or deleting + if not self.allow_future_pricing(): + if not batch.start_date: + f.remove('start_date') f.set_type('min_diff_threshold', 'currency') @@ -172,7 +188,8 @@ 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 kwargs['calculate_for_manual'] = batch.calculate_for_manual @@ -192,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) @@ -220,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: @@ -274,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') @@ -307,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') @@ -323,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: @@ -337,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: @@ -349,6 +366,22 @@ class PricingBatchView(BatchMasterView): return xlrow + def configure_get_simple_settings(self): + return [ + + # options + {'section': 'rattail.batch', + 'option': 'pricing.allow_future', + 'type': bool}, + ] + + +def defaults(config, **kwargs): + base = globals() + + PricingBatchView = kwargs.get('PricingBatchView', base['PricingBatchView']) + PricingBatchView.defaults(config) + def includeme(config): - PricingBatchView.defaults(config) + defaults(config) 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 adcf5dff..ec8da979 100644 --- a/tailbone/views/batch/vendorcatalog.py +++ b/tailbone/views/batch/vendorcatalog.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,22 +24,18 @@ Views for maintaining vendor catalogs """ -from __future__ import unicode_literals, absolute_import - import logging -import six - -from rattail.db import model, api -from rattail.vendors.catalogs import iter_catalog_parsers +from rattail.db import model import colander from deform import widget as dfwidget from webhelpers2.html import tags from tailbone import forms -from tailbone.db import Session from tailbone.views.batch import FileBatchMasterView +from tailbone.diffs import Diff +from tailbone.db import Session log = logging.getLogger(__name__) @@ -55,11 +51,15 @@ class VendorCatalogView(FileBatchMasterView): route_prefix = 'vendorcatalogs' url_prefix = '/vendors/catalogs' template_prefix = '/batch/vendorcatalog' - editable = False + bulk_deletable = True + results_executable = True rows_bulk_deletable = True + has_input_file_templates = True + configurable = True labels = { 'vendor_id': "Vendor ID", + 'parser_key': "Parser", } grid_columns = [ @@ -74,11 +74,14 @@ class VendorCatalogView(FileBatchMasterView): form_fields = [ 'id', - 'description', - 'vendor', 'filename', + 'parser_key', + 'vendor', 'future', 'effective', + 'cache_products', + 'params', + 'description', 'notes', 'created', 'created_by', @@ -112,16 +115,6 @@ class VendorCatalogView(FileBatchMasterView): 'description', 'size', 'is_preferred_vendor', - 'old_vendor_code', - 'vendor_code', - 'old_case_size', - 'case_size', - 'old_case_cost', - 'case_cost', - 'case_cost_diff', - 'old_unit_cost', - 'unit_cost', - 'unit_cost_diff', 'suggested_retail', 'starts', 'ends', @@ -129,45 +122,64 @@ class VendorCatalogView(FileBatchMasterView): 'discount_ends', 'discount_amount', 'discount_percent', + 'case_cost_diff', + 'unit_cost_diff', 'status_code', 'status_text', ] + def get_input_file_templates(self): + return [ + {'key': 'default', + 'label': "Default", + 'default_url': self.request.static_url( + 'tailbone:static/files/vendor_catalog_template.xlsx')}, + ] + def get_parsers(self): if not hasattr(self, 'parsers'): - self.parsers = sorted(iter_catalog_parsers(), key=lambda p: p.display) + app = self.get_rattail_app() + vendor_handler = app.get_vendor_handler() + self.parsers = vendor_handler.get_supported_catalog_parsers() return self.parsers def configure_grid(self, g): super(VendorCatalogView, self).configure_grid(g) - 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) + model = self.model + + # nb. this batch has vendor_id and vendor_name fields, but in + # practice they aren't used much and normally just the vendor + # proper is set. so we remove simple filters and add the + # custom one for (referenced) vendor name + g.remove_filter('vendor_id') + g.remove_filter('vendor_name') + g.set_joiner('vendor', lambda q: q.join(model.Vendor)) + g.set_filter('vendor', model.Vendor.name, + default_active=True, + default_verb='contains') + # and this is the same thing we are showing as grid column + g.set_sorter('vendor', model.Vendor.name) + + # preferred filters + g.set_filters_sequence([ + 'id', + 'vendor', + 'description', + 'executed', + 'created', + 'filename', + 'future', + 'effective', + 'notes', + ]) g.set_link('vendor') g.set_link('filename') def configure_form(self, f): super(VendorCatalogView, self).configure_form(f) - - # vendor - f.set_renderer('vendor', self.render_vendor) - if self.creating and 'vendor' in f: - f.replace('vendor', 'vendor_uuid') - f.set_node('vendor_uuid', colander.String()) - 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']) - if vendor: - vendor_display = six.text_type(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_label('vendor_uuid', "Vendor") - else: - f.set_readonly('vendor') + app = self.get_rattail_app() + vendor_handler = app.get_vendor_handler() # filename f.set_label('filename', "Catalog File") @@ -176,16 +188,105 @@ class VendorCatalogView(FileBatchMasterView): if self.creating: if 'parser_key' not in f: f.insert_after('filename', 'parser_key') - values = [(p.key, p.display) for p in self.get_parsers()] - values.insert(0, ('', "(please choose)")) + parsers = self.get_parsers() + values = [(p.key, p.display) for p in parsers] + if len(values) == 1: + f.set_default('parser_key', parsers[0].key) f.set_widget('parser_key', dfwidget.SelectWidget(values=values)) - f.set_label('parser_key', "File Type") - - # effective - if self.creating: - f.remove('effective') else: - f.set_readonly('effective') + f.set_readonly('parser_key') + f.set_renderer('parser_key', self.render_parser_key) + + # vendor + f.set_renderer('vendor', self.render_vendor) + 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 + # have an autocomplete url registered + use_dropdown = vendor_handler.choice_uses_dropdown() + if not use_dropdown: + try: + vendors_url = self.request.route_url('vendors.autocomplete') + except KeyError: + use_dropdown = True + + if use_dropdown: + vendors = self.Session.query(model.Vendor)\ + .order_by(model.Vendor.id) + vendor_values = [(vendor.uuid, "({}) {}".format(vendor.id, + vendor.name)) + for vendor in vendors] + f.set_widget('vendor_uuid', + dfwidget.SelectWidget(values=vendor_values)) + else: + vendor_display = "" + if self.request.method == 'POST': + if self.request.POST.get('vendor_uuid'): + vendor = self.Session.get(model.Vendor, + self.request.POST['vendor_uuid']) + if vendor: + vendor_display = str(vendor) + f.set_widget('vendor_uuid', + forms.widgets.JQueryAutocompleteWidget( + field_display=vendor_display, + service_url=vendors_url, + ref='vendorAutocomplete', + assigned_label='vendorName', + input_callback='vendorChanged', + new_label_callback='vendorLabelChanging')) + else: + f.set_readonly('vendor') + + if self.batch_handler.allow_future(): + + # effective + f.set_type('effective', 'date_jquery') + + else: # future not allowed + f.remove('future', + 'effective') + + if self.creating: + f.set_node('cache_products', colander.Boolean()) + f.set_type('cache_products', 'boolean') + f.set_helptext('cache_products', + "If set, will pre-cache all products for quicker " + "lookups when loading the catalog.") + else: + f.remove('cache_products') + + def render_parser_key(self, batch, field): + key = getattr(batch, field) + if not key: + return + app = self.get_rattail_app() + vendor_handler = app.get_vendor_handler() + parser = vendor_handler.get_catalog_parser(key) + return parser.display + + def template_kwargs_create(self, **kwargs): + app = self.get_rattail_app() + vendor_handler = app.get_vendor_handler() + parsers = self.get_parsers() + parsers_data = {} + for parser in parsers: + 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 def get_batch_kwargs(self, batch): kwargs = super(VendorCatalogView, self).get_batch_kwargs(batch) @@ -198,16 +299,23 @@ class VendorCatalogView(FileBatchMasterView): kwargs['vendor_id'] = batch.vendor_id if batch.vendor_name: kwargs['vendor_name'] = batch.vendor_name - kwargs['future'] = batch.future + if self.batch_handler.allow_future(): + kwargs['future'] = batch.future + kwargs['effective'] = batch.effective return kwargs + def save_create_form(self, form): + batch = super(VendorCatalogView, self).save_create_form(form) + batch.set_param('cache_products', form.validated['cache_products']) + return batch + def configure_row_grid(self, g): super(VendorCatalogView, self).configure_row_grid(g) batch = self.get_instance() # starts if not batch.future: - g.hide_column('starts') + g.remove('starts') g.set_type('old_unit_cost', 'currency') g.set_type('unit_cost', 'currency') @@ -224,6 +332,11 @@ class VendorCatalogView(FileBatchMasterView): g.set_label('unit_cost', "New Cost") g.set_label('unit_cost_diff', "Diff. $") + g.set_link('upc') + g.set_link('brand') + g.set_link('description') + g.set_link('vendor_code') + def row_grid_extra_class(self, row, i): if row.status_code == row.STATUS_PRODUCT_NOT_FOUND: return 'warning' @@ -240,27 +353,93 @@ class VendorCatalogView(FileBatchMasterView): f.set_renderer('product', self.render_product) f.set_type('upc', 'gpc') f.set_type('discount_percent', 'percent') + f.set_type('suggested_retail', 'currency') + + def template_kwargs_view_row(self, **kwargs): + row = kwargs['instance'] + batch = row.batch + + fields = [ + 'vendor_code', + 'case_size', + 'case_cost', + 'unit_cost', + ] + old_data = dict([(field, getattr(row, 'old_{}'.format(field))) + for field in fields]) + new_data = dict([(field, getattr(row, field)) + for field in fields]) + kwargs['catalog_entry_diff'] = Diff(old_data, new_data, fields=fields, + monospace=True) - def template_kwargs_create(self, **kwargs): - parsers = self.get_parsers() - for parser in parsers: - if parser.vendor_key: - vendor = api.get_vendor(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' - kwargs['parsers'] = parsers return kwargs + def configure_get_simple_settings(self): + settings = super(VendorCatalogView, self).configure_get_simple_settings() or [] + settings.extend([ + + # key field + {'section': 'rattail.batch', + 'option': 'vendor_catalog.allow_future', + 'type': bool}, + + ]) + return settings + + def configure_get_context(self): + context = super(VendorCatalogView, self).configure_get_context() + app = self.get_rattail_app() + vendor_handler = app.get_vendor_handler() + + Parsers = vendor_handler.get_all_catalog_parsers() + Supported = vendor_handler.get_supported_catalog_parsers() + context['catalog_parsers'] = Parsers + context['catalog_parsers_data'] = dict([(Parser.key, Parser in Supported) + for Parser in Parsers]) + + return context + + def configure_gather_settings(self, data): + settings = super(VendorCatalogView, self).configure_gather_settings(data) + app = self.get_rattail_app() + vendor_handler = app.get_vendor_handler() + + supported = [] + for Parser in vendor_handler.get_all_catalog_parsers(): + name = 'catalog_parser_{}'.format(Parser.key) + if data.get(name) == 'true': + supported.append(Parser.key) + settings.append({'name': 'rattail.vendors.supported_catalog_parsers', + 'value': ', '.join(supported)}) + + return settings + + def configure_remove_settings(self): + super(VendorCatalogView, self).configure_remove_settings() + app = self.get_rattail_app() + + names = [ + 'rattail.vendors.supported_catalog_parsers', + 'tailbone.batch.vendorcatalog.supported_parsers', # deprecated + ] + + # 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: + app.delete_setting(session, name) + + # TODO: deprecate / remove this VendorCatalogsView = VendorCatalogView -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + VendorCatalogView = kwargs.get('VendorCatalogView', base['VendorCatalogView']) VendorCatalogView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/batch/vendorinvoice.py b/tailbone/views/batch/vendorinvoice.py index bd030666..4815d1f4 100644 --- a/tailbone/views/batch/vendorinvoice.py +++ b/tailbone/views/batch/vendorinvoice.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 @@ Views for maintaining vendor invoices """ -from __future__ import unicode_literals, absolute_import - -import six - -from rattail.db import model, api +from rattail.db import model from rattail.vendors.invoices import iter_invoice_parsers, require_invoice_parser # import formalchemy @@ -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,13 +163,15 @@ 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 def init_batch(self, batch): - parser = require_invoice_parser(batch.parser_key) - vendor = api.get_vendor(self.Session(), parser.vendor_key) + app = self.get_rattail_app() + vendor_handler = app.get_vendor_handler() + parser = require_invoice_parser(self.rattail_config, batch.parser_key) + vendor = vendor_handler.get_vendor(self.Session(), parser.vendor_key) if not vendor: self.request.session.flash("No vendor setting found in database for key: {}".format(parser.vendor_key)) return False @@ -181,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 314f0eb6..7afcc567 100644 --- a/tailbone/views/bouncer.py +++ b/tailbone/views/bouncer.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,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,17 @@ 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): """ View for marking a bounce as processed. @@ -155,8 +161,9 @@ class EmailBounceView(MasterView): bounce.processed = datetime.datetime.utcnow() bounce.processed_by = self.request.user self.request.session.flash("Email bounce has been marked processed.") - return self.redirect(self.request.route_url('emailbounces')) + return self.redirect(self.get_action_url('view', bounce)) + # TODO: should require POST here def unprocess(self): """ View for marking a bounce as *unprocessed*. @@ -165,22 +172,15 @@ class EmailBounceView(MasterView): bounce.processed = None bounce.processed_by = None self.request.session.flash("Email bounce has been marked UN-processed.") - return self.redirect(self.request.route_url('emailbounces')) - - 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 + return self.redirect(self.get_action_url('view', bounce)) @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) @@ -198,18 +198,13 @@ 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() -# TODO: deprecate / remove this -EmailBouncesView = EmailBounceView + EmailBounceView = kwargs.get('EmailBounceView', base['EmailBounceView']) + EmailBounceView.defaults(config) def includeme(config): - EmailBounceView.defaults(config) + defaults(config) diff --git a/tailbone/views/brands.py b/tailbone/views/brands.py index b73060a3..109c80a7 100644 --- a/tailbone/views/brands.py +++ b/tailbone/views/brands.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. # @@ -38,6 +38,7 @@ class BrandView(MasterView): model_class = model.Brand has_versions = True bulk_deletable = True + results_downloadable = True supports_autocomplete = True mergeable = True @@ -135,5 +136,12 @@ class BrandView(MasterView): self.Session.delete(removing) -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + BrandView = kwargs.get('BrandView', base['BrandView']) BrandView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/categories.py b/tailbone/views/categories.py index 229d60ef..941257b8 100644 --- a/tailbone/views/categories.py +++ b/tailbone/views/categories.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. # @@ -40,7 +40,7 @@ class CategoryView(MasterView): model_title_plural = "Categories" route_prefix = 'categories' has_versions = True - results_downloadable_xlsx = True + results_downloadable = True grid_columns = [ 'code', @@ -110,5 +110,12 @@ class CategoryView(MasterView): CategoriesView = CategoryView -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + CategoryView = kwargs.get('CategoryView', base['CategoryView']) CategoryView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/common.py b/tailbone/views/common.py index 37b2c4a4..f4d98c05 100644 --- a/tailbone/views/common.py +++ b/tailbone/views/common.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,21 +24,14 @@ Various common views """ -from __future__ import unicode_literals, absolute_import +import os +import warnings +from collections import OrderedDict -import six - -import rattail -from rattail.db import model from rattail.batch import consume_batch_id -from rattail.mail import send_email -from rattail.util import OrderedDict +from rattail.util import get_pkg_version, simple_error from rattail.files import resource_path -from pyramid import httpexceptions -from pyramid.response import Response - -import tailbone from tailbone import forms from tailbone.forms.common import Feedback from tailbone.db import Session @@ -51,36 +44,68 @@ class CommonView(View): """ Base class for common views; override as needed. """ - project_title = "Tailbone" - project_version = tailbone.__version__ robots_txt_path = resource_path('tailbone.static:robots.txt') def home(self, **kwargs): """ 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 @@ -88,34 +113,40 @@ 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): + app = self.get_rattail_app() + return app.get_title() + + def get_project_version(self): + + # TODO: deprecate this + if hasattr(self, 'project_version'): + return self.project_version + + app = self.get_rattail_app() + return app.get_version() + def exception(self): """ Generic exception view """ - return {'project_title': self.project_title} + return {'project_title': self.get_project_title()} def about(self): """ Generic view to show "about project" info page. """ - use_buefy = self.get_use_buefy() - context = { - 'project_title': self.project_title, - 'project_version': self.project_version, + 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): """ @@ -123,8 +154,8 @@ class CommonView(View): 'about' page. """ return OrderedDict([ - ('rattail', rattail.__version__), - ('Tailbone', tailbone.__version__), + ('rattail', get_pkg_version('rattail')), + ('Tailbone', get_pkg_version('Tailbone')), ]) def change_theme(self): @@ -139,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): """ @@ -161,17 +191,20 @@ class CommonView(View): """ Generic view to handle the user feedback form. """ + app = self.get_rattail_app() + 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 - send_email(self.rattail_config, 'user_feedback', data=data) + 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): """ @@ -188,6 +221,75 @@ class CommonView(View): """ raise Exception("Congratulations, you have triggered a bogus error.") + def poser_setup(self): + if not self.request.is_root: + raise self.forbidden() + + app = self.get_rattail_app() + 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) + + if self.request.method == 'POST': + + # maybe refresh poser dir + if self.request.POST.get('action') == 'refresh': + poser_handler.refresh_poser_dir() + self.request.session.flash("Poser folder has been refreshed.") + + else: # otherwise make poser dir + + if poser_dir_exists: + self.request.session.flash("Poser folder already exists!", 'error') + else: + try: + path = poser_handler.make_poser_dir() + except Exception as error: + self.request.session.flash(simple_error(error), 'error') + else: + self.request.session.flash("Poser folder created at: {}".format(path)) + self.request.session.flash("Please restart the web app!", 'warning') + return self.redirect(self.request.route_url('home')) + + try: + from poser import reports + reports_error = None + except Exception as error: + reports = None + reports_error = simple_error(error) + + try: + from poser.web import views + views_error = None + except Exception as error: + views = None + views_error = simple_error(error) + + try: + import poser + poser_error = None + except Exception as error: + poser = None + poser_error = simple_error(error) + + return { + 'app_title': app_title, + 'index_title': app_title, + 'poser_dir': poser_dir, + 'poser_dir_exists': poser_dir_exists, + 'poser_imported': { + 'poser': poser, + 'reports': reports, + 'views': views, + }, + 'poser_import_errors': { + 'poser': poser_error, + 'reports': reports_error, + 'views': views_error, + }, + } + @classmethod def defaults(cls, config): cls._defaults(config) @@ -205,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', '/') @@ -222,7 +331,9 @@ class CommonView(View): config.add_tailbone_permission('common', 'common.change_db_engine', "Change which Database Engine is active (for user)") config.add_route('change_db_engine', '/change-db-engine', request_method='POST') - config.add_view(cls, attr='change_db_engine', route_name='change_db_engine') + config.add_view(cls, attr='change_db_engine', + route_name='change_db_engine', + permission='common.change_db_engine') # change theme config.add_tailbone_permission('common', 'common.change_app_theme', @@ -247,6 +358,21 @@ class CommonView(View): config.add_route('bogus_error', '/bogus-error') config.add_view(cls, attr='bogus_error', route_name='bogus_error', permission='errors.bogus') + # make poser dir + config.add_route('poser_setup', '/poser-setup') + config.add_view(cls, attr='poser_setup', + route_name='poser_setup', + renderer='/poser/setup.mako', + # nb. root only + permission='admin') + + +def defaults(config, **kwargs): + base = globals() + + CommonView = kwargs.get('CommonView', base['CommonView']) + CommonView.defaults(config) + def includeme(config): - CommonView.defaults(config) + defaults(config) diff --git a/tailbone/views/core.py b/tailbone/views/core.py index bcb5b01b..88b2519f 100644 --- a/tailbone/views/core.py +++ b/tailbone/views/core.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,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,14 +119,15 @@ 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): + def make_progress(self, key, **kwargs): """ Create and return a :class:`tailbone.progress.SessionProgress` instance, with the given key. """ - return SessionProgress(self.request, key) + return SessionProgress(self.request, key, **kwargs) # TODO: this signature seems wonky def render_progress(self, progress, kwargs, template=None): @@ -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/customergroups.py b/tailbone/views/customergroups.py index 02138346..98cea8e0 100644 --- a/tailbone/views/customergroups.py +++ b/tailbone/views/customergroups.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. # @@ -80,5 +80,12 @@ class CustomerGroupView(MasterView): CustomerGroupsView = CustomerGroupView -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + CustomerGroupView = kwargs.get('CustomerGroupView', base['CustomerGroupView']) CustomerGroupView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/customers.py b/tailbone/views/customers.py index 65618c1a..7e49ccef 100644 --- a/tailbone/views/customers.py +++ b/tailbone/views/customers.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,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,25 +37,28 @@ 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 people_detachable = True touchable = True supports_autocomplete = True + configurable = True # whether to show "view full profile" helper for customer view show_profiles_helper = True labels = { 'id': "ID", + 'name': "Account Name", 'default_phone': "Phone Number", 'default_email': "Email Address", 'default_address': "Physical Address", @@ -64,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', @@ -87,65 +89,168 @@ class CustomerView(MasterView): 'wholesale', 'active_in_pos', 'active_in_pos_sticky', + 'shoppers', 'people', 'groups', 'members', ] + mergeable = True + + merge_coalesce_fields = [ + 'email_addresses', + 'phone_numbers', + ] + + merge_fields = merge_coalesce_fields + [ + 'uuid', + '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( + 'rattail', 'customers.active_in_pos', + 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) - # 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) + # 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_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' + + 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('id') - g.set_link('number') 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: + return 'warning' + 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 @@ -156,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) @@ -203,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)")) @@ -215,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') @@ -233,10 +354,10 @@ 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') # members if self.creating: @@ -246,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 = { @@ -262,9 +402,11 @@ class CustomerView(MasterView): 'last_name': person.last_name, '_action_url_view': self.request.route_url('people.view', uuid=person.uuid), - '_action_url_edit': self.request.route_url('people.edit', - uuid=person.uuid), } + if self.editable and self.request.has_perm('people.edit'): + data['_action_url_edit'] = self.request.route_url( + 'people.edit', + uuid=person.uuid) if self.people_detachable and self.has_perm('detach_person'): data['_action_url_detach'] = self.request.route_url( 'customers.detach_person', @@ -273,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: @@ -286,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', @@ -363,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 @@ -391,23 +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() @@ -419,6 +574,69 @@ class CustomerView(MasterView): return self.redirect(self.request.get_referrer()) + def get_merge_data(self, customer): + return { + 'uuid': customer.uuid, + 'name': customer.name, + 'email_addresses': [e.address for e in customer.emails], + 'phone_numbers': [p.number for p in customer.phones], + } + + def merge_objects(self, removing, keeping): + coalesce = self.get_merge_coalesce_fields() + if coalesce: + + if 'email_addresses' in coalesce: + keeping_emails = [e.address for e in keeping.emails] + for email in removing.emails: + if email.address not in keeping_emails: + keeping.add_email(address=email.address, + type=email.type, + invalid=email.invalid) + keeping_emails.append(email.address) + + if 'phone_numbers' in coalesce: + keeping_phones = [e.number for e in keeping.phones] + for phone in removing.phones: + if phone.number not in keeping_phones: + keeping.add_phone(number=phone.number, + type=phone.type) + keeping_phones.append(phone.number) + + self.Session.delete(removing) + + def configure_get_simple_settings(self): + 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', + 'option': 'customers.active_in_pos', + 'type': bool}, + + ] + @classmethod def defaults(cls, config): cls._defaults(config) @@ -438,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', @@ -450,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' @@ -490,22 +787,85 @@ class PendingCustomerView(MasterView): 'address_zipcode', 'address_type', 'status_code', + 'created', + 'user', ] 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 = 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) + # created + if self.creating: + f.remove('created') + else: + f.set_readonly('created') + + # user + if self.creating: + f.remove('user') + else: + f.set_readonly('user') + f.set_renderer('user', self.render_user) + + def editable_instance(self, pending): + if pending.status_code == self.enum.PENDING_CUSTOMER_STATUS_RESOLVED: + return False + return True + + def resolve_person(self): + model = self.model + pending = self.get_instance() + redirect = self.redirect(self.get_action_url('view', pending)) + + uuid = self.request.POST['person_uuid'] + person = self.Session.get(model.Person, uuid) + if not person: + self.request.session.flash("Person not found!", 'error') + return redirect + + app = self.get_rattail_app() + people_handler = app.get_people_handler() + people_handler.resolve_person(pending, person, self.request.user) + self.Session.flush() + return redirect + + @classmethod + def defaults(cls, config): + cls._defaults(config) + cls._pending_customer_defaults(config) + + @classmethod + def _pending_customer_defaults(cls, config): + route_prefix = cls.get_route_prefix() + instance_url_prefix = cls.get_instance_url_prefix() + permission_prefix = cls.get_permission_prefix() + model_title = cls.get_model_title() + + # resolve person + config.add_tailbone_permission(permission_prefix, + '{}.resolve_person'.format(permission_prefix), + "Resolve a {} as a Person".format(model_title)) + config.add_route('{}.resolve_person'.format(route_prefix), + '{}/resolve-person'.format(instance_url_prefix), + request_method='POST') + config.add_view(cls, attr='resolve_person', + route_name='{}.resolve_person'.format(route_prefix), + permission='{}.resolve_person'.format(permission_prefix)) + # # TODO: this is referenced by some custom apps, but should be moved?? # def unique_id(value, field): @@ -520,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") @@ -529,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 { @@ -540,12 +902,27 @@ def customer_info(request): } -def includeme(config): +def defaults(config, **kwargs): + base = globals() - # info + # TODO: deprecate / remove this config.add_route('customer.info', '/customers/info') + customer_info = kwargs.get('customer_info', base['customer_info']) config.add_view(customer_info, route_name='customer.info', renderer='json', permission='customers.view') + CustomerView = kwargs.get('CustomerView', + base['CustomerView']) CustomerView.defaults(config) + + CustomerShopperView = kwargs.get('CustomerShopperView', + base['CustomerShopperView']) + CustomerShopperView.defaults(config) + + PendingCustomerView = kwargs.get('PendingCustomerView', + base['PendingCustomerView']) PendingCustomerView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/custorders/batch.py b/tailbone/views/custorders/batch.py index d70e5e77..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 = [ @@ -63,6 +59,7 @@ class CustomerOrderBatchView(BatchMasterView): 'customer', 'person', 'pending_customer', + 'contact_name', 'phone_number', 'email_address', 'params', @@ -73,7 +70,6 @@ class CustomerOrderBatchView(BatchMasterView): ] row_labels = { - 'product_upc': "UPC", 'product_brand': "Brand", 'product_description': "Description", 'product_size': "Size", @@ -82,7 +78,7 @@ class CustomerOrderBatchView(BatchMasterView): row_grid_columns = [ 'sequence', - 'product_upc', + '_product_key_', 'product_brand', 'product_description', 'product_size', @@ -93,8 +89,40 @@ class CustomerOrderBatchView(BatchMasterView): 'status_code', ] + product_key_fields = { + 'upc': 'product_upc', + 'item_id': 'product_item_id', + 'scancode': 'product_scancode', + } + + row_form_fields = [ + 'sequence', + 'item_entry', + 'product', + 'pending_product', + '_product_key_', + 'product_brand', + 'product_description', + 'product_size', + 'product_weighed', + 'product_unit_of_measure', + 'department_number', + 'department_name', + 'product_unit_cost', + 'case_quantity', + 'unit_price', + 'price_needs_confirmation', + 'order_quantity', + 'order_uom', + 'discount_percent', + 'total_price', + 'paid_amount', + # 'payment_transaction_number', + 'status_code', + ] + def configure_grid(self, g): - super(CustomerOrderBatchView, self).configure_grid(g) + super().configure_grid(g) g.set_type('total_price', 'currency') @@ -103,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') @@ -120,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)) @@ -140,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)) @@ -162,16 +190,18 @@ 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) def row_grid_extra_class(self, row, i): if row.status_code == row.STATUS_PRODUCT_NOT_FOUND: return 'warning' + if row.status_code == row.STATUS_PENDING_PRODUCT: + 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') @@ -185,9 +215,12 @@ 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) + + f.set_renderer('product_upc', self.render_upc) f.set_type('case_quantity', 'quantity') f.set_type('cases_ordered', 'quantity') @@ -196,3 +229,4 @@ class CustomerOrderBatchView(BatchMasterView): f.set_enum('order_uom', self.enum.UNIT_OF_MEASURE) f.set_type('unit_price', 'currency') f.set_type('total_price', 'currency') + f.set_type('paid_amount', 'currency') diff --git a/tailbone/views/custorders/creating.py b/tailbone/views/custorders/creating.py index c14448eb..cbdf6d5e 100644 --- a/tailbone/views/custorders/creating.py +++ b/tailbone/views/custorders/creating.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2020 Lance Edgar +# Copyright © 2010-2022 Lance Edgar # # This file is part of Rattail. # @@ -40,10 +40,17 @@ class CreateCustomerOrderBatchView(CustomerOrderBatchView): """ route_prefix = 'new_custorders' url_prefix = '/new-customer-orders' - model_title = "New Customer Order" - model_title_plural = "New Customer Orders" + model_title = "New Customer Order Batch" + model_title_plural = "New Customer Order Batches" creatable = False -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + CreateCustomerOrderBatchView = kwargs.get('CreateCustomerOrderBatchView', base['CreateCustomerOrderBatchView']) CreateCustomerOrderBatchView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/custorders/items.py b/tailbone/views/custorders/items.py index 2dcd43a5..e7edf3aa 100644 --- a/tailbone/views/custorders/items.py +++ b/tailbone/views/custorders/items.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,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 @@ -52,6 +48,7 @@ class CustomerOrderItemView(MasterView): deletable = False labels = { + 'order': "Customer Order", 'order_id': "Order ID", 'order_uom': "Order UOM", 'status_code': "Status", @@ -60,92 +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_enum('status_code', self.enum.CUSTORDER_ITEM_STATUS) - + # 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 @@ -153,37 +183,68 @@ 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, + 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) - # product - f.set_renderer('product', self.render_product) + # contact + if self.batch_handler.new_order_requires_customer(): + f.remove('person') + else: + f.remove('customer') - # product uom + # 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) + if self.viewing: + if item.product and not item.pending_product: + f.remove('pending_product') + elif item.pending_product and not item.product: + f.remove('product') + + # 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 + f.set_renderer('product_brand', self.highlight_pending_field) + f.set_renderer('product_description', self.highlight_pending_field) + f.set_renderer('product_size', self.highlight_pending_field) + f.set_renderer('case_quantity', self.highlight_pending_field_quantity) + # quantity fields - f.set_type('case_quantity', 'quantity') f.set_type('cases_ordered', 'quantity') f.set_type('units_ordered', 'quantity') f.set_type('order_quantity', 'quantity') - f.set_enum('order_uom', self.enum.UNIT_OF_MEASURE) - # currency fields - f.set_type('unit_price', 'currency') - f.set_type('total_price', 'currency') + # price fields + f.set_renderer('unit_price', self.render_price_with_confirmation) + f.set_renderer('total_price', self.render_price_with_confirmation) + f.set_renderer('price_needs_confirmation', self.render_price_needs_confirmation) f.set_type('paid_amount', 'currency') # person @@ -192,18 +253,113 @@ 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: + value = getattr(item, field) + if not item.product_uuid and item.pending_product_uuid: + return HTML.tag('span', c=[value], + class_='has-text-success') + return value + + def highlight_pending_field_quantity(self, item, field): + app = self.get_rattail_app() + value = getattr(item, field) + value = app.render_quantity(value) + return self.highlight_pending_field(item, field, value) + + def render_price_with_confirmation(self, item, field): + price = getattr(item, field) + app = self.get_rattail_app() + text = app.render_currency(price) + if not item.product_uuid and item.pending_product_uuid: + text = HTML.tag('span', c=[text], + class_='has-text-success') + if item.price_needs_confirmation: + return HTML.tag('span', class_='has-background-warning', + c=[text]) + return text + + def render_price_needs_confirmation(self, item, field): + + value = item.price_needs_confirmation + text = "Yes" if value else "No" + items = [text] + + if value and self.has_perm('confirm_price'): + button = HTML.tag('b-button', type='is-primary', c="Confirm Price", + style='margin-left: 1rem;', + icon_pack='fas', icon_left='check', + **{'@click': "$emit('confirm-price')"}) + items.append(button) + + left = HTML.tag('div', class_='level-left', c=items) + outer = HTML.tag('div', class_='level', c=[left]) + 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', @@ -214,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'): @@ -249,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 @@ -263,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], @@ -282,26 +456,78 @@ 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): + """ + View for confirming price of an order item. + """ + item = self.get_instance() + redirect = self.redirect(self.get_action_url('view', item)) + + # locate user responsible for change + user = self.request.user + + # grab user-provided note to attach to event + note = self.request.POST.get('note') + + # declare item no longer in need of price confirmation + item.price_needs_confirmation = False + item.add_event(self.enum.CUSTORDER_ITEM_EVENT_PRICE_CONFIRMED, + user, note=note) + + # advance item to next status + if item.status_code == self.enum.CUSTORDER_ITEM_STATUS_INITIATED: + item.status_code = self.enum.CUSTORDER_ITEM_STATUS_READY + item.status_text = "price has been confirmed" + + 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)) @@ -316,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) @@ -342,6 +568,9 @@ class CustomerOrderItemView(MasterView): # change status item.status_code = new_status_code + # nb. must blank this out, b/c user cannot specify new + # text and the old text no longer applies + item.status_text = None self.request.session.flash("Status has been updated to: {}".format( self.enum.CUSTORDER_ITEM_STATUS[new_status_code])) @@ -352,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) @@ -416,13 +614,34 @@ 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() model_title_plural = cls.get_model_title_plural() # fix permission group name config.add_tailbone_permission_group(permission_prefix, model_title_plural) + # confirm price + config.add_tailbone_permission(permission_prefix, + '{}.confirm_price'.format(permission_prefix), + "Confirm price for a {}".format(model_title)) + config.add_route('{}.confirm_price'.format(route_prefix), + '{}/confirm-price'.format(instance_url_prefix), + request_method='POST') + config.add_view(cls, attr='confirm_price', + 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), @@ -434,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), @@ -451,5 +678,12 @@ class CustomerOrderItemView(MasterView): CustomerOrderItemsView = CustomerOrderItemView -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + CustomerOrderItemView = kwargs.get('CustomerOrderItemView', base['CustomerOrderItemView']) CustomerOrderItemView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/custorders/orders.py b/tailbone/views/custorders/orders.py index a7129d68..b1a9831a 100644 --- a/tailbone/views/custorders/orders.py +++ b/tailbone/views/custorders/orders.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,34 +24,34 @@ Customer Order Views """ -from __future__ import unicode_literals, absolute_import - import decimal +import logging -import six from sqlalchemy import orm -from rattail import pod -from rattail.db import model -from rattail.util import pretty_quantity +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 +from webhelpers2.html import tags, HTML -from tailbone.db import Session from tailbone.views import MasterView +log = logging.getLogger(__name__) + + 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", } @@ -59,8 +59,9 @@ class CustomerOrderView(MasterView): 'id', 'customer', 'person', - 'created', 'status_code', + 'created', + 'created_by', ] form_fields = [ @@ -78,7 +79,7 @@ class CustomerOrderView(MasterView): ] has_rows = True - model_row_class = model.CustomerOrderItem + model_row_class = CustomerOrderItem rows_viewable = False row_labels = { @@ -87,55 +88,110 @@ 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().__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 - g.set_joiner('customer', lambda q: q.outerjoin(model.Customer)) - g.set_joiner('person', lambda q: q.outerjoin(model.Person)) + # id + g.set_link('id') + g.filters['id'].default_active = True + g.filters['id'].default_verb = 'equal' - g.filters['customer'] = g.make_filter('customer', model.Customer.name, - label="Customer Name", - default_active=True, - default_verb='contains') - g.filters['person'] = g.make_filter('person', model.Person.display_name, - label="Person Name", - default_active=True, - default_verb='contains') + # import ipdb; ipdb.set_trace() - g.set_sorter('customer', model.Customer.name) - g.set_sorter('person', model.Person.display_name) + # 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, + label="Customer Name", + default_active=True, + 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, + label="Person Name", + 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') f.set_renderer('store', self.render_store) + + # (pending) customer f.set_renderer('customer', self.render_customer) f.set_renderer('person', self.render_person) f.set_renderer('pending_customer', self.render_pending_customer) + if self.viewing: + if self.batch_handler.new_order_requires_customer(): + f.remove('person') + if order.customer and not order.pending_customer: + f.remove('pending_customer') + elif order.pending_customer and not order.customer: + f.remove('customer') + else: + f.remove('customer') + if order.person and not order.pending_customer: + f.remove('pending_customer') + elif order.pending_customer and not order.person: + f.remove('person') + + # contact info + f.set_renderer('phone_number', self.highlight_pending_field) + f.set_renderer('email_address', self.highlight_pending_field) f.set_type('total_price', 'currency') @@ -146,11 +202,25 @@ class CustomerOrderView(MasterView): f.set_readonly('created_by') f.set_renderer('created_by', self.render_user) + def highlight_pending_field(self, order, field): + value = getattr(order, field) + pending = False + if self.batch_handler.new_order_requires_customer(): + if not order.customer_uuid and order.pending_customer_uuid: + pending = True + else: + if not order.person_uuid and order.pending_customer_uuid: + pending = True + if pending: + return HTML.tag('span', c=[value], + class_='has-text-success') + return value + def render_person(self, order, field): 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) @@ -158,11 +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) + 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) @@ -170,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 @@ -183,16 +257,28 @@ 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') g.set_type('units_ordered', 'quantity') - g.set_type('total_price', 'currency') + + if handler.product_price_may_be_questionable(): + g.set_renderer('total_price', self.render_price_with_confirmation) + else: + g.set_type('total_price', 'currency') g.set_enum('order_uom', self.enum.UNIT_OF_MEASURE) - g.set_enum('status_code', self.enum.CUSTORDER_ITEM_STATUS) + g.set_renderer('status_code', self.render_row_status_code) g.set_label('sequence', "Seq.") g.filters['sequence'].label = "Sequence" @@ -206,9 +292,30 @@ class CustomerOrderView(MasterView): g.set_link('product_brand') g.set_link('product_description') + def row_grid_extra_class(self, item, i): + if not item.product_uuid and item.pending_product_uuid: + return 'has-text-success' + + def render_price_with_confirmation(self, item, field): + price = getattr(item, field) + app = self.get_rattail_app() + text = app.render_currency(price) + if item.price_needs_confirmation: + return HTML.tag('span', class_='has-background-warning', + c=[text]) + return text + + def render_row_status_code(self, item, field): + text = self.enum.CUSTORDER_ITEM_STATUS.get(item.status_code, + str(item.status_code)) + if item.status_text: + return HTML.tag('span', title=item.status_text, c=[text]) + return text + def get_batch_handler(self): - return get_batch_handler( - self.rattail_config, 'custorder', + app = self.get_rattail_app() + return app.get_batch_handler( + 'custorder', default='rattail.batch.custorder:CustomerOrderBatchHandler') def create(self, form=None, template='create'): @@ -218,7 +325,9 @@ class CustomerOrderView(MasterView): submits the order, at which point the batch is converted to a proper order. """ - self.handler = self.get_batch_handler() + app = self.get_rattail_app() + # TODO: deprecate / remove this + self.handler = self.batch_handler batch = self.get_current_batch() if self.request.method == 'POST': @@ -243,8 +352,8 @@ class CustomerOrderView(MasterView): 'update_pending_customer', 'get_customer_info', # 'set_customer_data', - 'find_product_by_upc', 'get_product_info', + 'get_past_items', 'add_item', 'update_item', 'delete_item', @@ -257,31 +366,68 @@ class CustomerOrderView(MasterView): items = [self.normalize_row(row) for row in batch.active_rows()] - if self.handler.has_custom_product_autocomplete: - route_prefix = self.get_route_prefix() - product_autocomplete = '{}.product_autocomplete'.format(route_prefix) - else: - product_autocomplete = 'products.autocomplete' - context = self.get_context_contact(batch) context.update({ 'batch': batch, 'normalized_batch': self.normalize_batch(batch), - 'new_order_requires_customer': self.handler.new_order_requires_customer(), - 'allow_contact_info_choice': self.handler.allow_contact_info_choice(), - 'restrict_contact_info': self.handler.should_restrict_contact_info(), + 'new_order_requires_customer': self.batch_handler.new_order_requires_customer(), + 'product_price_may_be_questionable': self.batch_handler.product_price_may_be_questionable(), + '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_autocomplete_url': self.request.route_url(product_autocomplete), + '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(), }) + if self.batch_handler.allow_case_orders(): + context['default_uom'] = self.enum.UNIT_OF_MEASURE_CASE + elif self.batch_handler.allow_unit_orders(): + context['default_uom'] = self.enum.UNIT_OF_MEASURE_EACH + return self.render_to_response(template, context) + def get_department_options(self): + model = self.model + departments = self.Session.query(model.Department)\ + .order_by(model.Department.name)\ + .all() + options = [] + for department in departments: + options.append({'label': department.name, + '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)\ @@ -293,7 +439,7 @@ class CustomerOrderView(MasterView): except orm.exc.NoResultFound: # no batch yet for this user, so make one - batch = self.handler.make_batch( + batch = self.batch_handler.make_batch( self.Session(), created_by=user, mode=self.enum.CUSTORDER_BATCH_MODE_CREATING) self.Session.add(batch) @@ -302,16 +448,7 @@ class CustomerOrderView(MasterView): return batch def start_over_entirely(self, batch): - - # delete pending customer if present - pending = batch.pending_customer - if pending: - batch.pending_customer = None - self.Session.delete(pending) - - # just delete current batch outright - # TODO: should use self.handler.do_delete() instead? - self.Session.delete(batch) + self.batch_handler.do_delete(batch) self.Session.flush() # send user back to normal "create" page; a new batch will be generated @@ -321,50 +458,43 @@ class CustomerOrderView(MasterView): return self.redirect(url) def delete_batch(self, batch): - - # delete pending customer if present - pending = batch.pending_customer - if pending: - batch.pending_customer = None - self.Session.delete(pending) - - # just delete current batch outright - # TODO: should use self.handler.do_delete() instead? - self.Session.delete(batch) + self.batch_handler.do_delete(batch) self.Session.flush() # set flash msg just to be more obvious self.request.session.flash("New customer order has been deleted.") # send user back to customer orders page, w/ no new batch generated - route_prefix = self.get_route_prefix() - url = self.request.route_url(route_prefix) + url = self.get_index_url() return self.redirect(url) def customer_autocomplete(self): """ Customer autocomplete logic, which invokes the handler. """ - self.handler = self.get_batch_handler() + # TODO: deprecate / remove this + self.handler = self.batch_handler term = self.request.GET['term'] - return self.handler.customer_autocomplete(self.Session(), term, - user=self.request.user) + return self.batch_handler.customer_autocomplete(self.Session(), term, + user=self.request.user) def person_autocomplete(self): """ Person autocomplete logic, which invokes the handler. """ - self.handler = self.get_batch_handler() + # TODO: deprecate / remove this + self.handler = self.batch_handler term = self.request.GET['term'] - return self.handler.person_autocomplete(self.Session(), term, - user=self.request.user) + return self.batch_handler.person_autocomplete(self.Session(), term, + user=self.request.user) def get_customer_info(self, batch, data): uuid = data.get('uuid') 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"} @@ -373,7 +503,7 @@ class CustomerOrderView(MasterView): def info_for_customer(self, batch, data, customer): # most info comes from handler - info = self.handler.get_customer_info(batch) + info = self.batch_handler.get_customer_info(batch) # maybe add profile URL if info['person_uuid']: @@ -384,30 +514,31 @@ 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 uuid = data['uuid'] - if self.handler.new_order_requires_customer(): + 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 # invoke handler to assign contact try: - self.handler.assign_contact(batch, **kwargs) + 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) @@ -421,9 +552,9 @@ class CustomerOrderView(MasterView): 'phone_number': batch.phone_number, 'contact_display': batch.contact_name, 'email_address': batch.email_address, - 'contact_phones': self.handler.get_contact_phones(batch), - 'contact_emails': self.handler.get_contact_emails(batch), - 'contact_notes': self.handler.get_contact_notes(batch), + 'contact_phones': self.batch_handler.get_contact_phones(batch), + 'contact_emails': self.batch_handler.get_contact_emails(batch), + 'contact_notes': self.batch_handler.get_contact_notes(batch), 'add_phone_number': bool(batch.get_param('add_phone_number')), 'add_email_address': bool(batch.get_param('add_email_address')), 'contact_profile_url': None, @@ -449,7 +580,7 @@ class CustomerOrderView(MasterView): # we have a pending customer then it's definitely *not* known, # but if no pending customer yet then we can still "assume" it # is known, by default, until user specifies otherwise. - contact = self.handler.get_contact(batch) + contact = self.batch_handler.get_contact(batch) if contact: context['contact_is_known'] = True else: @@ -464,7 +595,7 @@ class CustomerOrderView(MasterView): return context def unassign_contact(self, batch, data): - self.handler.unassign_contact(batch) + self.batch_handler.unassign_contact(batch) self.Session.flush() context = self.get_context_contact(batch) context['success'] = True @@ -506,9 +637,10 @@ class CustomerOrderView(MasterView): def update_pending_customer(self, batch, data): try: - self.handler.update_pending_customer(batch, self.request.user, data) + 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) @@ -519,131 +651,200 @@ class CustomerOrderView(MasterView): """ Custom product autocomplete logic, which invokes the handler. """ - self.handler = self.get_batch_handler() term = self.request.GET['term'] - return self.handler.custom_product_autocomplete(self.Session(), term, - user=self.request.user) - def find_product_by_upc(self, batch, data): - upc = data.get('upc') - if not upc: - return {'error': "Must specify a product UPC"} + # if handler defines custom autocomplete, use that + handler = self.get_batch_handler() + if handler.has_custom_product_autocomplete: + return handler.custom_product_autocomplete(self.Session(), term, + user=self.request.user) - product = self.handler.locate_product_for_entry( - self.Session(), upc, product_key='upc') - if not product: - return {'error': "Product not found"} - - return self.info_for_product(batch, data, product) + # otherwise we use 'products.neworder' autocomplete + app = self.get_rattail_app() + autocomplete = app.get_autocompleter('products.neworder') + return autocomplete.autocomplete(self.Session(), term) def get_product_info(self, batch, data): uuid = data.get('uuid') 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"} return self.info_for_product(batch, data, product) def uom_choices_for_product(self, product): - choices = [] + return self.batch_handler.uom_choices_for_product(product) - # Each - if not product or not product.weighed: - unit_name = self.enum.UNIT_OF_MEASURE[self.enum.UNIT_OF_MEASURE_EACH] - choices.append({'key': self.enum.UNIT_OF_MEASURE_EACH, - 'value': unit_name}) - - # Pound - if not product or product.weighed: - unit_name = self.enum.UNIT_OF_MEASURE[self.enum.UNIT_OF_MEASURE_POUND] - choices.append({ - 'key': self.enum.UNIT_OF_MEASURE_POUND, - 'value': unit_name, - }) - - # Case - case_text = None - case_size = self.handler.get_case_size_for_product(product) - if case_size is None: - case_text = "{} (× ?? {})".format( - self.enum.UNIT_OF_MEASURE[self.enum.UNIT_OF_MEASURE_CASE], - unit_name) - elif case_size > 1: - case_text = "{} (× {} {})".format( - self.enum.UNIT_OF_MEASURE[self.enum.UNIT_OF_MEASURE_CASE], - pretty_quantity(case_size), - unit_name) - if case_text: - choices.append({'key': self.enum.UNIT_OF_MEASURE_CASE, - 'value': case_text}) - - return choices + def uom_choices_for_row(self, row): + return self.batch_handler.uom_choices_for_row(row) def info_for_product(self, batch, data, product): - return { - 'uuid': product.uuid, - 'upc': six.text_type(product.upc), - 'upc_pretty': product.upc.pretty(), - 'full_description': product.full_description, - 'image_url': pod.get_image_url(self.rattail_config, product.upc), - 'uom_choices': self.uom_choices_for_product(product), - } + try: + info = self.batch_handler.get_product_info(batch, product) + except Exception as error: + return {'error': str(error)} + else: + info['url'] = self.request.route_url('products.view', uuid=info['uuid']) + 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 = [] + + for product in past_products: + try: + item = self.batch_handler.get_product_info(batch, product) + except: + # 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} 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, } + def get_unit_price_display(self, obj): + """ + Returns a display string for the given object's unit price. + The object can be either a ``Product`` instance, or a batch + row. + """ + app = self.get_rattail_app() + model = self.model + if isinstance(obj, model.Product): + products = app.get_products_handler() + return products.render_price(obj.regular_price) + else: # row + return app.render_currency(obj.unit_price) + def normalize_row(self, row): - product = row.product - department = product.department if product else None - cost = product.cost if product else None + 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, 'product_brand': row.product_brand, 'product_description': row.product_description, 'product_size': row.product_size, - 'product_full_description': product.full_description if product else row.product_description, '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_product(product), + 'order_uom_choices': self.uom_choices_for_row(row), + 'discount_percent': self.app.render_quantity(row.discount_percent), - 'department_display': department.name if department else None, - 'vendor_display': cost.vendor.name if cost else None, + 'department_display': row.department_name, - 'unit_price': six.text_type(row.unit_price) if row.unit_price is not None else None, - 'unit_price_display': "${:0.2f}".format(row.unit_price) if row.unit_price is not None else None, - 'total_price': six.text_type(row.total_price) if row.total_price is not None else None, - 'total_price_display': "${:0.2f}".format(row.total_price) if row.total_price is not None else None, + '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': self.app.render_currency(row.total_price), 'status_code': row.status_code, 'status_text': row.status_text, } + if row.unit_regular_price: + data['unit_regular_price'] = float(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'] = self.app.render_currency(row.unit_sale_price) + if row.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 + + if row.product or row.pending_product: + data['product_full_description'] = products_handler.make_full_description( + row.product or row.pending_product) + + if row.product: + cost = row.product.cost + if cost: + data['vendor_display'] = cost.vendor.name + elif row.pending_product: + data['vendor_display'] = row.pending_product.vendor_name + + if row.pending_product: + pending = row.pending_product + data['pending_product'] = { + 'uuid': pending.uuid, + '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, + 'description': pending.description, + 'size': pending.size, + 'department_uuid': pending.department_uuid, + 'regular_price_amount': float(pending.regular_price_amount) if pending.regular_price_amount is not None else None, + 'vendor_name': pending.vendor_name, + 'vendor_item_code': pending.vendor_item_code, + 'unit_cost': float(pending.unit_cost) if pending.unit_cost is not None else None, + 'case_size': float(pending.case_size) if pending.case_size is not None else None, + 'notes': pending.notes, + } + + 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'] = 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.app.get_product_key_field() + if key == 'upc': + data['product_key'] = data['product_upc_pretty'] + elif key == 'item_id': + data['product_key'] = row.product_item_id + elif key == 'scancode': + data['product_key'] = row.product_scancode + else: # TODO: this seems not useful + data['product_key'] = getattr(row.product, key, data['product_upc_pretty']) + + if row.product: + data.update({ + 'product_url': self.request.route_url('products.view', uuid=row.product.uuid), + 'product_image_url': products_handler.get_image_url(row.product), + }) + elif row.product_upc: + data['product_image_url'] = products_handler.get_image_url(upc=row.product_upc) + unit_uom = self.enum.UNIT_OF_MEASURE_POUND if data['product_weighed'] else self.enum.UNIT_OF_MEASURE_EACH if row.order_uom == self.enum.UNIT_OF_MEASURE_CASE: if row.case_quantity is None: 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'], @@ -656,35 +857,65 @@ 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): + model = self.app.model + + order_quantity = decimal.Decimal(data.get('order_quantity') or '0') + order_uom = data.get('order_uom') + discount_percent = decimal.Decimal(data.get('discount_percent') or '0') + if data.get('product_is_known'): uuid = data.get('product_uuid') 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"} - row = self.handler.make_row() - row.item_entry = product.uuid - row.product = product - row.order_quantity = decimal.Decimal(data.get('order_quantity') or '0') - row.order_uom = data.get('order_uom') - self.handler.add_row(batch, row) - self.Session.flush() - self.Session.refresh(row) + kwargs = {} + if self.batch_handler.product_price_may_be_questionable(): + kwargs['price_needs_confirmation'] = data.get('price_needs_confirmation') - else: # product is not known - raise NotImplementedError # TODO + if self.batch_handler.allow_item_discounts(): + kwargs['discount_percent'] = discount_percent + row = self.batch_handler.add_product(batch, product, + order_quantity, order_uom, + **kwargs) + + else: # unknown product; add pending + pending_info = dict(data['pending_product']) + + if 'upc' in pending_info: + 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: + 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 + + kwargs = {} + if self.batch_handler.allow_item_discounts(): + kwargs['discount_percent'] = discount_percent + + row = self.batch_handler.add_pending_product(batch, + pending_info, + order_quantity, order_uom, + **kwargs) + + self.Session.flush() return {'batch': self.normalize_batch(batch), 'row': self.normalize_row(row)} @@ -693,34 +924,56 @@ 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"} if row not in batch.active_rows(): return {'error': "Row is not active for the batch"} + order_quantity = decimal.Decimal(data.get('order_quantity') or '0') + order_uom = data.get('order_uom') + discount_percent = decimal.Decimal(data.get('discount_percent') or '0') + if data.get('product_is_known'): uuid = data.get('product_uuid') 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"} row.item_entry = product.uuid row.product = product - row.order_quantity = decimal.Decimal(data.get('order_quantity') or '0') - row.order_uom = data.get('order_uom') - self.handler.refresh_row(row) - self.Session.flush() - self.Session.refresh(row) + row.order_quantity = order_quantity + row.order_uom = order_uom + + if self.batch_handler.product_price_may_be_questionable(): + row.price_needs_confirmation = data.get('price_needs_confirmation') + + if self.batch_handler.allow_item_discounts(): + row.discount_percent = discount_percent + + self.batch_handler.refresh_row(row) else: # product is not known - raise NotImplementedError # TODO + # set these first, since row will be refreshed below + row.order_quantity = order_quantity + row.order_uom = order_uom + + if self.batch_handler.allow_item_discounts(): + row.discount_percent = discount_percent + + # nb. this will refresh the row + pending_info = dict(data['pending_product']) + self.batch_handler.update_pending_product(row, pending_info) + + self.Session.flush() + self.Session.refresh(row) return {'batch': self.normalize_batch(batch), 'row': self.normalize_row(row)} @@ -730,30 +983,165 @@ 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"} if row not in batch.active_rows(): return {'error': "Row is not active for this batch"} - self.handler.do_remove_row(row) + self.batch_handler.do_remove_row(row) return {'ok': True, 'batch': self.normalize_batch(batch)} def submit_new_order(self, batch, data): - result = self.execute_new_order_batch(batch, data) - if not result: - return {'error': "Batch failed to execute"} - next_url = None + 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: + log.warning("failed to execute new order batch: %s", batch, + exc_info=True) + return {'error': simple_error(error)} + else: + if not result: + return {'error': "Batch failed to execute"} + + return { + 'ok': True, + 'next_url': self.get_next_url_after_submit_new_order(batch, result), + } + + def get_next_url_after_submit_new_order(self, batch, result, **kwargs): + model = self.model + if isinstance(result, model.CustomerOrder): - next_url = self.get_action_url('view', result) - - return {'ok': True, 'next_url': next_url} + return self.get_action_url('view', result) def execute_new_order_batch(self, batch, data): - return self.handler.do_execute(batch, self.request.user) + 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): + settings = [ + + # customer handling + {'section': 'rattail.custorders', + 'option': 'new_order_requires_customer', + 'type': bool}, + {'section': 'rattail.custorders', + 'option': 'new_orders.allow_contact_info_choice', + 'type': bool}, + {'section': 'rattail.custorders', + 'option': 'new_orders.allow_contact_info_create', + 'type': bool}, + + # product handling + {'section': 'rattail.custorders', + 'option': 'allow_case_orders', + 'type': bool}, + {'section': 'rattail.custorders', + 'option': 'allow_unit_orders', + 'type': bool}, + {'section': 'rattail.custorders', + 'option': 'product_price_may_be_questionable', + '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): @@ -764,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), @@ -792,10 +1195,25 @@ 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 -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + CustomerOrderView = kwargs.get('CustomerOrderView', base['CustomerOrderView']) CustomerOrderView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/datasync.py b/tailbone/views/datasync.py index f4434447..2b955b5f 100644 --- a/tailbone/views/datasync.py +++ b/tailbone/views/datasync.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,28 +24,379 @@ DataSync Views """ -from __future__ import unicode_literals, absolute_import - +import json import subprocess import logging -from rattail.db import model +import sqlalchemy as sa + +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 log = logging.getLogger(__name__) +class DataSyncThreadView(MasterView): + """ + Master view for DataSync itself. + + This should (eventually) show all running threads in the main + index view, with status for each, sort of akin to "dashboard". + For now it only serves the config view. + """ + model_title = "DataSync Thread" + model_title_plural = "DataSync Status" + model_key = 'key' + route_prefix = 'datasync' + url_prefix = '/datasync' + listable = False + viewable = False + creatable = False + editable = False + deletable = False + filterable = False + pageable = False + + configurable = True + config_title = "DataSync" + + grid_columns = [ + 'key', + ] + + def __init__(self, request, context=None): + 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. + + If this view receives a non-empty 'partial' parameter in the query + string, then the view will return the rendered grid only. Otherwise + returns the full page. + """ + app = self.get_rattail_app() + model = self.model + + try: + process_info = self.datasync_handler.get_supervisor_process_info() + supervisor_error = None + except Exception as error: + log.warning("failed to get supervisor process info", exc_info=True) + process_info = None + supervisor_error = simple_error(error) + + try: + profiles = self.datasync_handler.get_configured_profiles() + except Exception as error: + log.warning("could not load profiles!", exc_info=True) + self.request.session.flash(simple_error(error), 'error') + profiles = {} + + sql = """ + select source, consumer, count(*) as changes + from datasync_change + group by source, consumer + """ + result = self.Session.execute(sa.text(sql)) + all_changes = {} + for row in result: + all_changes[(row.source, row.consumer)] = row.changes + + watcher_data = [] + consumer_data = [] + now = app.localtime() + for key, profile in profiles.items(): + watcher = profile.watcher + + lastrun = self.datasync_handler.get_watcher_lastrun( + watcher.key, local=True, session=self.Session()) + + status = "okay" + if (now - lastrun).total_seconds() >= (watcher.delay * 2): + status = "dead watcher" + + watcher_data.append({ + 'key': watcher.key, + 'spec': profile.watcher_spec, + 'dbkey': watcher.dbkey, + 'delay': watcher.delay, + 'lastrun': raw_datetime(self.rattail_config, lastrun, verbose=True), + 'status': status, + }) + + for consumer in profile.consumers: + if consumer.watcher is watcher: + + changes = all_changes.get((watcher.key, consumer.key), 0) + if changes: + oldest = self.Session.query(sa.func.min(model.DataSyncChange.obtained))\ + .filter(model.DataSyncChange.source == watcher.key)\ + .filter(model.DataSyncChange.consumer == consumer.key)\ + .scalar() + oldest = app.localtime(oldest, from_utc=True) + changes = "{} (oldest from {})".format( + changes, + app.render_time_ago(now - oldest)) + + status = "okay" + if changes: + status = "processing changes" + + consumer_data.append({ + 'key': '{} -> {}'.format(watcher.key, consumer.key), + 'spec': consumer.spec, + 'dbkey': consumer.dbkey, + 'delay': consumer.delay, + 'changes': changes, + 'status': status, + }) + + watcher_data.sort(key=lambda w: w['key']) + consumer_data.sort(key=lambda c: c['key']) + + context = { + 'index_title': "DataSync Status", + 'index_url': None, + 'process_info': process_info, + 'supervisor_error': supervisor_error, + 'watcher_data': watcher_data, + 'consumer_data': consumer_data, + } + return self.render_to_response('status', context) + + def get_data(self, session=None): + data = [] + return data + + def restart(self): + try: + self.datasync_handler.restart_supervisor_process() + self.request.session.flash("DataSync daemon has been restarted.") + + except Exception as error: + self.request.session.flash(simple_error(error), 'error') + + return self.redirect(self.request.get_referrer( + default=self.request.route_url('datasyncchanges'))) + + 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): + data = { + 'key': profile.key, + 'watcher_spec': profile.watcher_spec, + 'watcher_dbkey': profile.watcher.dbkey, + 'watcher_delay': profile.watcher.delay, + 'watcher_retry_attempts': profile.watcher.retry_attempts, + 'watcher_retry_delay': profile.watcher.retry_delay, + 'watcher_default_runas': profile.watcher.default_runas, + 'watcher_consumes_self': profile.watcher.consumes_self, + 'watcher_kwargs_data': [{'key': key, + 'value': profile.watcher_kwargs[key]} + for key in sorted(profile.watcher_kwargs)], + # 'notes': None, # TODO + 'enabled': profile.enabled, + } + + consumers = [] + if profile.watcher.consumes_self: + pass + else: + for consumer in sorted(profile.consumers, key=lambda c: c.key): + consumers.append({ + 'key': consumer.key, + 'consumer_spec': consumer.spec, + 'consumer_dbkey': consumer.dbkey, + 'consumer_runas': getattr(consumer, 'runas', None), + 'consumer_delay': consumer.delay, + 'consumer_retry_attempts': consumer.retry_attempts, + 'consumer_retry_delay': consumer.retry_delay, + 'enabled': consumer.enabled, + }) + data['consumers_data'] = consumers + profiles_data.append(data) + + context['profiles_data'] = profiles_data + return context + + def configure_gather_settings(self, data, **kwargs): + """ """ + settings = super().configure_gather_settings(data, **kwargs) + + if data.get('rattail.datasync.use_profile_settings') == 'true': + watch = [] + + for profile in json.loads(data['profiles']): + pkey = profile['key'] + if profile['enabled']: + watch.append(pkey) + + settings.extend([ + {'name': 'rattail.datasync.{}.watcher.spec'.format(pkey), + 'value': profile['watcher_spec']}, + {'name': 'rattail.datasync.{}.watcher.db'.format(pkey), + 'value': profile['watcher_dbkey']}, + {'name': 'rattail.datasync.{}.watcher.delay'.format(pkey), + 'value': profile['watcher_delay']}, + {'name': 'rattail.datasync.{}.watcher.retry_attempts'.format(pkey), + 'value': profile['watcher_retry_attempts']}, + {'name': 'rattail.datasync.{}.watcher.retry_delay'.format(pkey), + 'value': profile['watcher_retry_delay']}, + {'name': 'rattail.datasync.{}.consumers.runas'.format(pkey), + 'value': profile['watcher_default_runas']}, + ]) + + for kwarg in profile['watcher_kwargs_data']: + settings.append({ + 'name': 'rattail.datasync.{}.watcher.kwarg.{}'.format( + pkey, kwarg['key']), + 'value': kwarg['value'], + }) + + consumers = [] + if profile['watcher_consumes_self']: + consumers = ['self'] + else: + + for consumer in profile['consumers_data']: + ckey = consumer['key'] + if consumer['enabled']: + consumers.append(ckey) + settings.extend([ + {'name': f'rattail.datasync.{pkey}.consumer.{ckey}.spec', + 'value': consumer['consumer_spec']}, + {'name': 'rattail.datasync.{}.consumer.{}.db'.format(pkey, ckey), + 'value': consumer['consumer_dbkey']}, + {'name': 'rattail.datasync.{}.consumer.{}.delay'.format(pkey, ckey), + 'value': consumer['consumer_delay']}, + {'name': 'rattail.datasync.{}.consumer.{}.retry_attempts'.format(pkey, ckey), + 'value': consumer['consumer_retry_attempts']}, + {'name': 'rattail.datasync.{}.consumer.{}.retry_delay'.format(pkey, ckey), + 'value': consumer['consumer_retry_delay']}, + {'name': 'rattail.datasync.{}.consumer.{}.runas'.format(pkey, ckey), + 'value': consumer['consumer_runas']}, + ]) + + settings.extend([ + {'name': 'rattail.datasync.{}.consumers.list'.format(pkey), + 'value': ', '.join(consumers)}, + ]) + + if watch: + settings.append({'name': 'rattail.datasync.watch', + 'value': ', '.join(watch)}) + + return settings + + def configure_remove_settings(self, **kwargs): + """ """ + super().configure_remove_settings(**kwargs) + + purge_datasync_settings(self.rattail_config, self.Session()) + + @classmethod + def defaults(cls, config): + cls._defaults(config) + cls._datasync_defaults(config) + + @classmethod + def _datasync_defaults(cls, config): + permission_prefix = cls.get_permission_prefix() + route_prefix = cls.get_route_prefix() + url_prefix = cls.get_url_prefix() + index_title = cls.get_index_title() + + # view status + config.add_tailbone_permission(permission_prefix, + '{}.status'.format(permission_prefix), + "View status for DataSync daemon") + # nb. simple 'datasync' route points to 'datasync.status' for now.. + config.add_route(route_prefix, + '{}/status/'.format(url_prefix)) + config.add_route('{}.status'.format(route_prefix), + '{}/status/'.format(url_prefix)) + config.add_view(cls, attr='status', + route_name=route_prefix, + permission='{}.status'.format(permission_prefix)) + config.add_view(cls, attr='status', + route_name='{}.status'.format(route_prefix), + permission='{}.status'.format(permission_prefix)) + config.add_tailbone_index_page(route_prefix, index_title, + '{}.status'.format(permission_prefix)) + + # restart + config.add_tailbone_permission(permission_prefix, + '{}.restart'.format(permission_prefix), + label="Restart the DataSync daemon") + config.add_route('{}.restart'.format(route_prefix), + '{}/restart'.format(url_prefix), + request_method='POST') + config.add_view(cls, attr='restart', + route_name='{}.restart'.format(route_prefix), + permission='{}.restart'.format(permission_prefix)) + + class DataSyncChangeView(MasterView): """ Master view for the DataSyncChange model. """ - model_class = model.DataSyncChange + model_class = DataSyncChange url_prefix = '/datasync/changes' - permission_prefix = 'datasync' + permission_prefix = 'datasync_changes' creatable = False - editable = False bulk_deletable = True labels = { @@ -63,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.") @@ -77,35 +439,29 @@ class DataSyncChangeView(MasterView): kwargs['allow_filemon_restart'] = bool(self.rattail_config.get('tailbone', 'filemon.restart')) return kwargs - def restart(self): - # TODO: Add better validation (e.g. CSRF) here? - if self.request.method == 'POST': - cmd = self.rattail_config.getlist('tailbone', 'datasync.restart', default='/bin/sleep 3') # simulate by default - log.debug("attempting datasync restart with command: {}".format(cmd)) - result = subprocess.call(cmd) - if result == 0: - self.request.session.flash("DataSync daemon has been restarted.") - else: - self.request.session.flash("DataSync daemon could not be restarted; result was: {}".format(result), 'error') - return self.redirect(self.request.get_referrer(default=self.request.route_url('datasyncchanges'))) + def configure_form(self, f): + super().configure_form(f) - @classmethod - def defaults(cls, config): - rattail_config = config.registry.settings.get('rattail_config') + f.set_readonly('obtained') - # fix permission group title - config.add_tailbone_permission_group('datasync', label="DataSync") - - # restart datasync - config.add_tailbone_permission('datasync', 'datasync.restart', label="Restart DataSync Daemon") - config.add_route('datasync.restart', '/datasync/restart') - config.add_view(cls, attr='restart', route_name='datasync.restart', permission='datasync.restart') - - cls._defaults(config) # TODO: deprecate / remove this DataSyncChangesView = DataSyncChangeView -def includeme(config): +def defaults(config, **kwargs): + base = globals() + rattail_config = config.registry['rattail_config'] + + DataSyncThreadView = kwargs.get('DataSyncThreadView', base['DataSyncThreadView']) + DataSyncThreadView.defaults(config) + + DataSyncChangeView = kwargs.get('DataSyncChangeView', base['DataSyncChangeView']) DataSyncChangeView.defaults(config) + + if should_expose_websockets(rattail_config): + config.include('tailbone.views.asgi.datasync') + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/departments.py b/tailbone/views/departments.py index 8c841f6b..47de8dca 100644 --- a/tailbone/views/departments.py +++ b/tailbone/views/departments.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,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,9 +35,10 @@ class DepartmentView(MasterView): """ Master view for the Department class. """ - model_class = model.Department + model_class = Department touchable = True has_versions = True + results_downloadable = True supports_autocomplete = True grid_columns = [ @@ -50,6 +46,8 @@ class DepartmentView(MasterView): 'name', 'product', 'personnel', + 'tax', + 'food_stampable', 'exempt_from_gross_sales', ] @@ -58,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", @@ -81,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) @@ -104,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', @@ -121,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 @@ -165,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() @@ -174,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) @@ -181,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() @@ -203,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)\ @@ -211,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): @@ -240,5 +243,12 @@ class DepartmentView(MasterView): cls._defaults(config) -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + DepartmentView = kwargs.get('DepartmentView', base['DepartmentView']) DepartmentView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/depositlinks.py b/tailbone/views/depositlinks.py index 42f83460..1c9abde1 100644 --- a/tailbone/views/depositlinks.py +++ b/tailbone/views/depositlinks.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. # @@ -64,5 +64,12 @@ class DepositLinkView(MasterView): DepositLinksView = DepositLinkView -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + DepositLinkView = kwargs.get('DepositLinkView', base['DepositLinkView']) DepositLinkView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/email.py b/tailbone/views/email.py index 58a0320b..98bd4295 100644 --- a/tailbone/views/email.py +++ b/tailbone/views/email.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,24 +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 api, 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). @@ -54,6 +56,8 @@ class EmailSettingView(MasterView): pageable = False creatable = False deletable = False + configurable = True + config_title = "Email" grid_columns = [ 'key', @@ -61,6 +65,7 @@ class EmailSettingView(MasterView): 'subject', 'to', 'enabled', + 'hidden', ] form_fields = [ @@ -75,37 +80,70 @@ class EmailSettingView(MasterView): 'cc', 'bcc', 'enabled', + 'hidden', ] def __init__(self, request): - super(EmailSettingView, self).__init__(request) - self.handler = self.get_handler() + super().__init__(request) + self.email_handler = self.get_handler() + + @property + def handler(self): + warnings.warn("the `handler` property is deprecated! " + "please use `email_handler` instead", + DeprecationWarning, stacklevel=2) + return self.email_handler def get_handler(self): app = self.get_rattail_app() - return app.get_mail_handler() + return app.get_email_handler() def get_data(self, session=None): data = [] - for email in self.handler.iter_emails(): - key = email.key or email.__name__ - email = email(self.rattail_config, key) - data.append(self.normalize(email)) + if self.has_perm('configure'): + emails = self.email_handler.get_all_emails() + else: + emails = self.email_handler.get_available_emails() + for key, Email in emails.items(): + email = Email(self.rattail_config, key) + 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') + g.set_searchable('key') + g.set_searchable('subject') + # 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.set_type('hidden', 'boolean') + else: + g.remove('hidden') + + # toggle hidden + if self.has_perm('configure'): + g.actions.append( + self.make_action('toggle_hidden', url='#', icon='ban', + click_handler='toggleHidden(props.row)', + factory=ToggleHidden)) def render_to_short(self, email, column): profile = email['_email'] @@ -128,7 +166,7 @@ class EmailSettingView(MasterView): if recips: return ', '.join(recips) data = email.obtain_sample_data(self.request) - return { + normal = { '_email': email, 'key': email.key, 'fallback_key': email.fallback_key, @@ -142,10 +180,13 @@ class EmailSettingView(MasterView): 'bcc': get_recips('bcc') or '', 'enabled': email.get_enabled(), } + if self.has_perm('configure'): + normal['hidden'] = self.email_handler.email_is_hidden(email.key) + return normal def get_instance(self): key = self.request.matchdict['key'] - return self.normalize(self.handler.get_email(key)) + return self.normalize(self.email_handler.get_email(key)) def get_instance_title(self, email): return email['_email'].get_complete_subject(render=False) @@ -161,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 @@ -202,32 +243,149 @@ class EmailSettingView(MasterView): # enabled f.set_type('enabled', 'boolean') + # hidden + if self.has_perm('configure'): + f.set_type('hidden', 'boolean') + else: + f.remove('hidden') + def make_form_schema(self): - return EmailProfileSchema() + schema = EmailProfileSchema() + + if not self.has_perm('configure'): + hidden = schema.get('hidden') + schema.children.remove(hidden) + + return schema def save_edit_form(self, form): key = self.request.matchdict['key'] data = self.form_deserialized + app = self.get_rattail_app() session = self.Session() - api.save_setting(session, 'rattail.mail.{}.prefix'.format(key), data['prefix']) - api.save_setting(session, 'rattail.mail.{}.subject'.format(key), data['subject']) - api.save_setting(session, 'rattail.mail.{}.from'.format(key), data['sender']) - api.save_setting(session, 'rattail.mail.{}.replyto'.format(key), data['replyto']) - api.save_setting(session, 'rattail.mail.{}.to'.format(key), (data['to'] or '').replace('\n', ', ')) - api.save_setting(session, 'rattail.mail.{}.cc'.format(key), (data['cc'] or '').replace('\n', ', ')) - api.save_setting(session, 'rattail.mail.{}.bcc'.format(key), (data['bcc'] or '').replace('\n', ', ')) - api.save_setting(session, 'rattail.mail.{}.enabled'.format(key), six.text_type(data['enabled']).lower()) + app.save_setting(session, 'rattail.mail.{}.prefix'.format(key), data['prefix']) + app.save_setting(session, 'rattail.mail.{}.subject'.format(key), data['subject']) + app.save_setting(session, 'rattail.mail.{}.from'.format(key), data['sender']) + app.save_setting(session, 'rattail.mail.{}.replyto'.format(key), data['replyto']) + 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), str(data['enabled']).lower()) + if self.has_perm('configure'): + 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.handler.get_email(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', + 'type': bool}, + {'section': 'rattail.mail', + 'option': 'send_email_on_failure', + '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 + name = 'rattail.mail.{}.hidden'.format(data['key']) + app.save_setting(self.Session(), name, + '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) + cls._defaults(config) + + @classmethod + def _email_defaults(cls, config): + route_prefix = cls.get_route_prefix() + url_prefix = cls.get_url_prefix() + permission_prefix = cls.get_permission_prefix() + model_title_plural = cls.get_model_title_plural() + + # toggle hidden + config.add_route('{}.toggle_hidden'.format(route_prefix), + '{}/toggle-hidden'.format(url_prefix), + request_method='POST') + config.add_view(cls, attr='toggle_hidden', + route_name='{}.toggle_hidden'.format(route_prefix), + 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 +class ToggleHidden(grids.GridAction): + """ + Grid action for toggling the 'hidden' flag for an email profile. + """ + + def render_label(self): + return '{{ renderLabelToggleHidden(props.row) }}' + + class RecipientsType(colander.String): """ Custom schema type for email recipients. This is used to present the @@ -268,6 +426,8 @@ class EmailProfileSchema(colander.MappingSchema): enabled = colander.SchemaNode(colander.Boolean()) + hidden = colander.SchemaNode(colander.Boolean()) + class EmailPreview(View): """ @@ -275,12 +435,23 @@ class EmailPreview(View): """ def __init__(self, request): - super(EmailPreview, self).__init__(request) - self.handler = self.get_handler() + super().__init__(request) - def get_handler(self): - app = self.get_rattail_app() - return app.get_mail_handler() + if hasattr(self, 'get_handler'): + warnings.warn("defining a get_handler() method is deprecated; " + "please use AppHandler.get_email_handler() instead", + DeprecationWarning, stacklevel=2) + self.email_handler = get_handler() + else: + app = self.get_rattail_app() + self.email_handler = app.get_email_handler() + + @property + def handler(self): + warnings.warn("the `handler` property is deprecated! " + "please use `email_handler` instead", + DeprecationWarning, stacklevel=2) + return self.email_handler def __call__(self): @@ -303,23 +474,31 @@ class EmailPreview(View): if recipient: key = self.request.POST.get('email_key') if key: - email = self.handler.get_email(key) - data = email.obtain_sample_data(self.request) + email = self.email_handler.get_email(key) - self.handler.send_message(email, data, - subject_prefix="[PREVIEW] ", - to=[recipient], - cc=None, bcc=None) + context = self.email_handler.make_context() + context.update(email.obtain_sample_data(self.request)) - self.request.session.flash( - "Preview for '{}' was emailed to {}".format( - key, recipient)) + try: + self.email_handler.send_message(email, context, + subject_prefix="[PREVIEW] ", + to=[recipient], + cc=None, bcc=None) + except Exception as error: + self.request.session.flash(simple_error(error), 'error') + else: + self.request.session.flash( + "Preview for '{}' was emailed to {}".format( + key, recipient)) def preview_template(self, key, type_): - email = self.handler.get_email(key) + email = self.email_handler.get_email(key) template = email.get_template(type_) - data = email.obtain_sample_data(self.request) - self.request.response.text = template.render(**data) + + context = self.email_handler.make_context() + context.update(email.obtain_sample_data(self.request)) + + self.request.response.text = template.render(**context) if type_ == 'txt': self.request.response.content_type = str('text/plain') return self.request.response @@ -339,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 @@ -372,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') @@ -402,19 +581,32 @@ 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) # status_code f.set_enum('status_code', self.enum.EMAIL_ATTEMPT) -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + EmailSettingView = kwargs.get('EmailSettingView', base['EmailSettingView']) EmailSettingView.defaults(config) + + EmailPreview = kwargs.get('EmailPreview', base['EmailPreview']) EmailPreview.defaults(config) + + EmailAttemptView = kwargs.get('EmailAttemptView', base['EmailAttemptView']) EmailAttemptView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/employees.py b/tailbone/views/employees.py index 3ad331ab..debd8fcb 100644 --- a/tailbone/views/employees.py +++ b/tailbone/views/employees.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,9 +24,6 @@ Employee Views """ -from __future__ import unicode_literals, absolute_import - -import six import sqlalchemy as sa from rattail.db import model @@ -47,6 +44,8 @@ class EmployeeView(MasterView): has_versions = True touchable = True supports_autocomplete = True + results_downloadable = True + configurable = True labels = { 'id': "ID", @@ -80,8 +79,24 @@ 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() # phone @@ -100,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'): @@ -111,49 +137,66 @@ class EmployeeView(MasterView): g.set_sorter('username', model.User.username) g.set_renderer('username', self.grid_render_username) else: - g.hide_column('username') + g.remove('username') # id - if self.request.has_perm('{}.edit'.format(route_prefix)): + if self.has_perm('edit'): g.set_link('id') else: - g.hide_column('id') + g.remove('id') del g.filters['id'] # status - if self.request.has_perm('{}.edit'.format(route_prefix)): + if self.has_perm('view_all'): g.set_enum('status', self.enum.EMPLOYEE_STATUS) g.filters['status'].default_active = True g.filters['status'].default_verb = 'equal' - # TODO: why must we set unicode string value here? - g.filters['status'].default_value = six.text_type(self.enum.EMPLOYEE_STATUS_CURRENT) + g.filters['status'].default_value = str(self.enum.EMPLOYEE_STATUS_CURRENT) else: - g.hide_column('status') + 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) - if not self.request.has_perm('employees.edit'): - q = q.filter(model.Employee.status == self.enum.EMPLOYEE_STATUS_CURRENT) - return q + query = super().query(session) + query = query.join(model.Person) + if not self.has_perm('view_all'): + 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 @@ -182,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) @@ -197,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), @@ -209,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), @@ -236,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 @@ -251,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): @@ -264,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): @@ -279,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) @@ -288,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): @@ -297,10 +340,15 @@ 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): + app = self.get_rattail_app() + employment = app.get_employment_handler() + employment.touch_employee(self.Session(), employee) + def get_version_child_classes(self): return [ (model.Person, 'uuid', 'person_uuid'), @@ -310,6 +358,43 @@ 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) + cls._employee_defaults(config) + + @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 + config.add_tailbone_permission(permission_prefix, + '{}.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() + + EmployeeView = kwargs.get('EmployeeView', base['EmployeeView']) + EmployeeView.defaults(config) + def includeme(config): - EmployeeView.defaults(config) + defaults(config) diff --git a/tailbone/views/essentials.py b/tailbone/views/essentials.py new file mode 100644 index 00000000..08d2e0c4 --- /dev/null +++ b/tailbone/views/essentials.py @@ -0,0 +1,59 @@ +# -*- 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.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): + defaults(config) diff --git a/tailbone/views/exports.py b/tailbone/views/exports.py index c136359a..44df359f 100644 --- a/tailbone/views/exports.py +++ b/tailbone/views/exports.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,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,22 +145,16 @@ 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 - def render_download(self, export, field): - path = self.get_file_path(export) - text = "{} ({})".format(export.filename, self.readable_size(path)) - url = self.request.route_url('{}.download'.format(self.get_route_prefix()), uuid=export.uuid) - return tags.link_to(text, url) - def render_created_by(self, export, field): 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) @@ -175,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): @@ -195,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/families.py b/tailbone/views/families.py index b2a5ebe3..2d445b78 100644 --- a/tailbone/views/families.py +++ b/tailbone/views/families.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. # @@ -39,6 +39,7 @@ class FamilyView(MasterView): model_title_plural = "Families" route_prefix = 'families' has_versions = True + results_downloadable = True grid_key = 'families' grid_columns = [ @@ -54,12 +55,8 @@ class FamilyView(MasterView): has_rows = True model_row_class = model.Product - row_labels = { - 'upc': "UPC", - } - row_grid_columns = [ - 'upc', + '_product_key_', 'brand', 'description', 'size', @@ -94,7 +91,9 @@ class FamilyView(MasterView): g.set_renderer('regular_price', self.render_price) g.set_renderer('current_price', self.render_price) - g.set_sort_defaults('upc') + key = self.rattail_config.product_key() + field = self.product_key_fields.get(key, key) + g.set_sort_defaults(field) def render_price(self, product, field): if not product.not_for_sale: @@ -109,5 +108,12 @@ class FamilyView(MasterView): FamiliesView = FamilyView -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + FamilyView = kwargs.get('FamilyView', base['FamilyView']) FamilyView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/features.py b/tailbone/views/features.py index f9c2b5c7..d9417452 100644 --- a/tailbone/views/features.py +++ b/tailbone/views/features.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,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, @@ -112,5 +104,12 @@ class GenerateFeatureView(View): renderer='/generate_feature.mako') -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + GenerateFeatureView = kwargs.get('GenerateFeatureView', base['GenerateFeatureView']) GenerateFeatureView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/filemon.py b/tailbone/views/filemon.py index 1d164c83..b0c81b45 100644 --- a/tailbone/views/filemon.py +++ b/tailbone/views/filemon.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2018 Lance Edgar +# Copyright © 2010-2022 Lance Edgar # # This file is part of Rattail. # @@ -63,5 +63,12 @@ class FilemonView(View): config.add_view(cls, attr='restart', route_name='filemon.restart', permission='filemon.restart') -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + FilemonView = kwargs.get('FilemonView', base['FilemonView']) FilemonView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/handheld.py b/tailbone/views/handheld.py index b0392c13..34211c30 100644 --- a/tailbone/views/handheld.py +++ b/tailbone/views/handheld.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2020 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -21,186 +21,17 @@ # ################################################################################ """ -Views for handheld batches +(DEPRECATED) Views for handheld batches """ -from __future__ import unicode_literals, absolute_import +import warnings -import os - -from rattail.db import model -from rattail.util import OrderedDict - -import colander -from webhelpers2.html import tags - -from tailbone import forms -from tailbone.db import Session -from tailbone.views.batch import FileBatchMasterView - - -ACTION_OPTIONS = OrderedDict([ - ('make_label_batch', "Make a new Label Batch"), - ('make_inventory_batch', "Make a new Inventory Batch"), -]) - - -class ExecutionOptions(colander.Schema): - - action = colander.SchemaNode( - colander.String(), - validator=colander.OneOf(ACTION_OPTIONS), - widget=forms.widgets.PlainSelectWidget(values=ACTION_OPTIONS.items())) - - -class HandheldBatchView(FileBatchMasterView): - """ - Master view for handheld batches. - """ - model_class = model.HandheldBatch - default_handler_spec = 'rattail.batch.handheld:HandheldBatchHandler' - model_title_plural = "Handheld Batches" - route_prefix = 'batch.handheld' - url_prefix = '/batch/handheld' - execution_options_schema = ExecutionOptions - editable = False - - model_row_class = model.HandheldBatchRow - rows_creatable = False - rows_editable = True - - grid_columns = [ - 'id', - 'device_type', - 'device_name', - 'created', - 'created_by', - 'rowcount', - 'status_code', - 'executed', - ] - - form_fields = [ - 'id', - 'device_type', - 'device_name', - 'filename', - 'created', - 'created_by', - 'rowcount', - 'status_code', - 'executed', - 'executed_by', - ] - - row_labels = { - 'upc': "UPC", - } - - row_grid_columns = [ - 'sequence', - 'upc', - 'brand_name', - 'description', - 'size', - 'cases', - 'units', - 'status_code', - ] - - row_form_fields = [ - 'sequence', - 'upc', - 'brand_name', - 'description', - 'size', - 'status_code', - 'cases', - 'units', - ] - - def configure_grid(self, g): - super(HandheldBatchView, self).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) - - def grid_extra_class(self, batch, i): - if batch.status_code is not None and batch.status_code != batch.STATUS_OK: - return 'notice' - - def configure_form(self, f): - super(HandheldBatchView, self).configure_form(f) - batch = f.model_instance - - # device_type - device_types = OrderedDict(sorted(self.enum.HANDHELD_DEVICE_TYPE.items(), - key=lambda item: item[1])) - f.set_enum('device_type', device_types) - f.widgets['device_type'].values.insert(0, ('', "(none)")) - - if self.creating: - f.set_fields([ - 'filename', - 'device_type', - 'device_name', - ]) - - if self.viewing: - if batch.inventory_batch: - f.append('inventory_batch') - f.set_renderer('inventory_batch', self.render_inventory_batch) - - def render_inventory_batch(self, handheld_batch, field): - batch = handheld_batch.inventory_batch - if not batch: - return "" - text = batch.id_str - url = self.request.route_url('batch.inventory.view', uuid=batch.uuid) - return tags.link_to(text, url) - - def get_batch_kwargs(self, batch): - kwargs = super(HandheldBatchView, self).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) - g.set_type('cases', 'quantity') - g.set_type('units', 'quantity') - g.set_label('brand_name', "Brand") - - def row_grid_extra_class(self, row, i): - if row.status_code == row.STATUS_PRODUCT_NOT_FOUND: - return 'warning' - - def configure_row_form(self, f): - super(HandheldBatchView, self).configure_row_form(f) - - # readonly fields - f.set_readonly('upc') - f.set_readonly('brand_name') - f.set_readonly('description') - f.set_readonly('size') - - # upc - f.set_renderer('upc', self.render_upc) - - def get_execute_success_url(self, batch, result, **kwargs): - if kwargs['action'] == 'make_inventory_batch': - 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) - - def get_execute_results_success_url(self, result, **kwargs): - if result is True: - # no batches were actually executed - return self.get_index_url() - batch = result - return self.get_execute_success_url(batch, result, **kwargs) +# nb. this is imported only for sake of legacy callers +from tailbone.views.batch.handheld import HandheldBatchView def includeme(config): - HandheldBatchView.defaults(config) + warnings.warn("tailbone.views.handheld is a deprecated module; " + "please use tailbone.views.batch.handheld instead", + DeprecationWarning, stacklevel=2) + config.include('tailbone.views.batch.handheld') diff --git a/tailbone/views/ifps.py b/tailbone/views/ifps.py index be3bb0f8..af626ef3 100644 --- a/tailbone/views/ifps.py +++ b/tailbone/views/ifps.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2020 Lance Edgar +# Copyright © 2010-2022 Lance Edgar # # This file is part of Rattail. # @@ -90,5 +90,12 @@ class IFPS_PLUView(MasterView): -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + IFPS_PLUView = kwargs.get('IFPS_PLUView', base['IFPS_PLUView']) IFPS_PLUView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/importing.py b/tailbone/views/importing.py new file mode 100644 index 00000000..48b32cc2 --- /dev/null +++ b/tailbone/views/importing.py @@ -0,0 +1,675 @@ +# -*- 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/>. +# +################################################################################ +""" +View for running arbitrary import/export jobs +""" + +import getpass +import json +import logging +import socket +import subprocess +import sys +import time + +import sqlalchemy as sa + +from rattail.threads import Thread + +import colander +import markdown +from deform import widget as dfwidget +from webhelpers2.html import HTML + +from tailbone.views import MasterView + + +log = logging.getLogger(__name__) + + +class ImportingView(MasterView): + """ + View for running arbitrary import/export jobs + """ + normalized_model_name = 'importhandler' + model_title = "Import / Export Handler" + model_key = 'key' + route_prefix = 'importing' + url_prefix = '/importing' + index_title = "Importing / Exporting" + creatable = False + editable = False + deletable = False + filterable = False + pageable = False + + configurable = True + config_title = "Import / Export" + + labels = { + 'host_title': "Data Source", + 'local_title': "Data Target", + 'direction_display': "Direction", + } + + grid_columns = [ + 'host_title', + 'local_title', + 'direction_display', + 'handler_spec', + ] + + form_fields = [ + 'key', + 'local_key', + 'host_key', + 'handler_spec', + 'host_title', + 'local_title', + 'direction_display', + 'models', + ] + + runjob_form_fields = [ + 'handler_spec', + 'host_title', + 'local_title', + 'models', + 'create', + 'update', + 'delete', + # 'runas', + 'versioning', + 'dry_run', + 'warnings', + ] + + def get_data(self, session=None): + app = self.get_rattail_app() + data = [] + + for handler in app.get_designated_import_handlers( + ignore_errors=True, sort=True): + data.append(self.normalize(handler)) + + return data + + def normalize(self, handler, keep_handler=True): + data = { + 'key': handler.get_key(), + 'generic_title': handler.get_generic_title(), + 'host_key': handler.host_key, + 'host_title': handler.get_generic_host_title(), + 'local_key': handler.local_key, + 'local_title': handler.get_generic_local_title(), + 'handler_spec': handler.get_spec(), + 'direction': handler.direction, + 'direction_display': handler.direction.capitalize(), + 'safe_for_web_app': handler.safe_for_web_app, + } + + if keep_handler: + data['_handler'] = handler + + alternates = getattr(handler, 'alternate_handlers', None) + if alternates: + data['alternates'] = [] + for alternate in alternates: + data['alternates'].append(self.normalize( + alternate, keep_handler=keep_handler)) + + cmd = self.get_cmd_for_handler(handler, ignore_errors=True) + if cmd: + data['cmd'] = ' '.join(cmd) + data['command'] = cmd[0] + data['subcommand'] = cmd[1] + + runas = self.get_runas_for_handler(handler) + if runas: + data['default_runas'] = runas + + return data + + def configure_grid(self, 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): + """ + Fetch the current model instance by inspecting the route kwargs and + doing a database lookup. If the instance cannot be found, raises 404. + """ + key = self.request.matchdict['key'] + app = self.get_rattail_app() + handler = app.get_import_handler(key, ignore_errors=True) + if handler: + return self.normalize(handler) + raise self.notfound() + + def get_instance_title(self, handler_info): + handler = handler_info['_handler'] + return handler.get_generic_title() + + def make_form_schema(self): + return ImportHandlerSchema() + + def make_form_kwargs(self, **kwargs): + kwargs = super().make_form_kwargs(**kwargs) + + # nb. this is set as sort of a hack, to prevent SA model + # inspection logic + kwargs['renderers'] = {} + + return kwargs + + def configure_form(self, f): + super().configure_form(f) + + f.set_renderer('models', self.render_models) + + def render_models(self, handler, field): + handler = handler['_handler'] + items = [] + for key in handler.get_importer_keys(): + items.append(HTML.tag('li', c=[key])) + return HTML.tag('ul', c=items) + + def template_kwargs_view(self, **kwargs): + kwargs = super().template_kwargs_view(**kwargs) + handler_info = kwargs['instance'] + kwargs['handler'] = handler_info['_handler'] + return kwargs + + def runjob(self): + """ + View for running an import / export job + """ + handler_info = self.get_instance() + handler = handler_info['_handler'] + form = self.make_runjob_form(handler_info) + + if self.request.method == 'POST': + if self.validate_form(form): + + self.cache_runjob_form_values(handler, form) + + try: + return self.do_runjob(handler_info, form) + except Exception as error: + self.request.session.flash(str(error), 'error') + return self.redirect(self.request.current_route_url()) + + return self.render_to_response('runjob', { + 'handler_info': handler_info, + 'handler': handler, + 'form': form, + }) + + def cache_runjob_form_values(self, handler, form): + handler_key = handler.get_key() + + def make_key(field): + return 'rattail.importing.{}.{}'.format(handler_key, field) + + for field in form.fields: + key = make_key(field) + self.request.session[key] = form.validated[field] + + def read_cached_runjob_values(self, handler, form): + handler_key = handler.get_key() + + def make_key(field): + return 'rattail.importing.{}.{}'.format(handler_key, field) + + for field in form.fields: + key = make_key(field) + if key in self.request.session: + form.set_default(field, self.request.session[key]) + + def make_runjob_form(self, handler_info, **kwargs): + """ + Creates a new form for the given model class/instance + """ + handler = handler_info['_handler'] + factory = self.get_form_factory() + fields = list(self.runjob_form_fields) + schema = RunJobSchema() + + kwargs = self.make_runjob_form_kwargs(handler_info, **kwargs) + form = factory(fields, schema, **kwargs) + self.configure_runjob_form(handler, form) + + self.read_cached_runjob_values(handler, form) + + return form + + def make_runjob_form_kwargs(self, handler_info, **kwargs): + route_prefix = self.get_route_prefix() + handler = handler_info['_handler'] + defaults = { + 'request': self.request, + 'model_instance': handler, + 'cancel_url': self.request.route_url('{}.view'.format(route_prefix), + key=handler.get_key()), + # nb. these next 2 are set as sort of a hack, to prevent + # SA model inspection logic + 'renderers': {}, + 'appstruct': handler_info, + } + defaults.update(kwargs) + return defaults + + def configure_runjob_form(self, handler, f): + self.set_labels(f) + + f.set_readonly('handler_spec') + f.set_renderer('handler_spec', lambda handler, field: handler.get_spec()) + + f.set_readonly('host_title') + f.set_readonly('local_title') + + keys = handler.get_importer_keys() + f.set_widget('models', dfwidget.SelectWidget(values=[(k, k) for k in keys], + multiple=True, + size=len(keys))) + + 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'] + handler_key = handler.get_key() + + if self.request.POST.get('runjob') == 'true': + + # will invoke handler to run job.. + + # ..but only if it is safe to do so + if not handler.safe_for_web_app: + self.request.session.flash("Handler is not (yet) safe to run " + "with this tool", 'error') + return self.redirect(self.request.current_route_url()) + + # TODO: this socket progress business was lifted from + # tailbone.views.batch.core:BatchMasterView.handler_action + # should probably refactor to share somehow + + # make progress object + key = 'rattail.importing.{}'.format(handler_key) + progress = self.make_progress(key) + + # make socket for progress thread to listen to action thread + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.bind(('127.0.0.1', 0)) + sock.listen(1) + port = sock.getsockname()[1] + + # launch thread to monitor progress + success_url = self.request.current_route_url() + thread = Thread(target=self.progress_thread, + args=(sock, success_url, progress)) + thread.start() + + true_cmd = self.make_runjob_cmd(handler, form, 'true', port=port) + + # launch thread to invoke handler + thread = Thread(target=self.do_runjob_thread, + args=(handler, true_cmd, port, progress)) + thread.start() + + return self.render_progress(progress, { + 'can_cancel': False, + 'cancel_url': self.request.current_route_url(), + }) + + else: # explain only + notes_cmd = self.make_runjob_cmd(handler, form, 'notes') + self.cache_runjob_notes(handler, notes_cmd) + + return self.redirect(self.request.current_route_url()) + + def do_runjob_thread(self, handler, cmd, port, progress): + + # invoke handler command via subprocess + try: + result = subprocess.run(cmd, check=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT) + output = result.stdout.decode('utf_8').strip() + + except Exception as error: + log.warning("failed to invoke handler cmd: %s", cmd, exc_info=True) + if progress: + progress.session.load() + progress.session['error'] = True + msg = """\ +{} failed! Here is the command I tried to run: + +``` +{} +``` + +And here is the output: + +``` +{} +``` +""".format(handler.direction.capitalize(), + ' '.join(cmd), + error.stdout.decode('utf_8').strip()) + msg = markdown.markdown(msg, extensions=['fenced_code']) + msg = HTML.literal(msg) + msg = HTML.tag('div', class_='tailbone-markdown', c=[msg]) + progress.session['error_msg'] = msg + progress.session.save() + + else: # success + + if progress: + progress.session.load() + msg = self.get_runjob_success_msg(handler, output) + progress.session['complete'] = True + progress.session['success_url'] = self.request.current_route_url() + progress.session['success_msg'] = msg + progress.session.save() + + suffix = "\n\n.".encode('utf_8') + cxn = socket.create_connection(('127.0.0.1', port)) + data = json.dumps({ + 'everything_complete': True, + }) + data = data.encode('utf_8') + cxn.send(data) + cxn.send(suffix) + cxn.close() + + def get_runjob_success_msg(self, handler, output): + notes = """\ +{} went okay, here is the output: + +``` +{} +``` +""".format(handler.direction.capitalize(), output) + + notes = markdown.markdown(notes, extensions=['fenced_code']) + notes = HTML.literal(notes) + return HTML.tag('div', class_='tailbone-markdown', c=[notes]) + + def get_cmd_for_handler(self, handler, ignore_errors=False): + return handler.get_cmd(ignore_errors=ignore_errors) + + def get_runas_for_handler(self, handler): + handler_key = handler.get_key() + runas = self.rattail_config.get('rattail.importing', + '{}.runas'.format(handler_key)) + if runas: + return runas + return self.rattail_config.get('rattail', 'runas.default') + + def make_runjob_cmd(self, handler, form, typ, port=None): + command, subcommand = self.get_cmd_for_handler(handler) + runas = self.get_runas_for_handler(handler) + data = form.validated + + if typ == 'true': + cmd = [ + '{}/bin/{}'.format(sys.prefix, command), + '--config={}/app/quiet.conf'.format(sys.prefix), + '--progress', + '--progress-socket=127.0.0.1:{}'.format(port), + ] + else: + cmd = [ + 'sudo', '-u', getpass.getuser(), + 'bin/{}'.format(command), + '-c', 'app/quiet.conf', + '-P', + ] + + if runas: + if typ == 'true': + cmd.append('--runas={}'.format(runas)) + else: + cmd.extend(['--runas', runas]) + + cmd.append(subcommand) + + cmd.extend(data['models']) + + if data['create']: + if typ == 'true': + cmd.append('--create') + else: + cmd.append('--no-create') + + if data['update']: + if typ == 'true': + cmd.append('--update') + else: + cmd.append('--no-update') + + if data['delete']: + cmd.append('--delete') + else: + if typ == 'true': + cmd.append('--no-delete') + + if data['versioning']: + if typ == 'true': + cmd.append('--versioning') + else: + cmd.append('--no-versioning') + + if data['dry_run']: + cmd.append('--dry-run') + + if data['warnings']: + if typ == 'true': + cmd.append('--warnings') + else: + cmd.append('-W') + + return cmd + + def cache_runjob_notes(self, handler, notes_cmd): + notes = """\ +You can run this {direction} job manually via command line: + +```sh +cd {prefix} +{cmd} +``` +""".format(direction=handler.direction, + prefix=sys.prefix, + cmd=' '.join(notes_cmd)) + + self.request.session['rattail.importing.runjob.notes'] = markdown.markdown( + notes, extensions=['fenced_code', 'codehilite']) + + def configure_get_context(self): + app = self.get_rattail_app() + handlers_data = [] + + for handler in app.get_designated_import_handlers( + with_alternates=True, + ignore_errors=True, sort=True): + + data = self.normalize(handler, keep_handler=False) + + data['spec_options'] = [handler.get_spec()] + for alternate in handler.alternate_handlers: + data['spec_options'].append(alternate.get_spec()) + data['spec_options'].sort() + + handlers_data.append(data) + + return { + 'handlers_data': handlers_data, + } + + def configure_gather_settings(self, data): + settings = [] + + for handler in json.loads(data['handlers']): + key = handler['key'] + + settings.extend([ + {'name': 'rattail.importing.{}.handler'.format(key), + 'value': handler['handler_spec']}, + {'name': 'rattail.importing.{}.cmd'.format(key), + 'value': '{} {}'.format(handler['command'], + handler['subcommand'])}, + {'name': 'rattail.importing.{}.runas'.format(key), + 'value': handler['default_runas']}, + ]) + + return settings + + def configure_remove_settings(self): + app = self.get_rattail_app() + model = self.model + session = self.Session() + + to_delete = session.query(model.Setting)\ + .filter(sa.or_( + model.Setting.name.like('rattail.importing.%.handler'), + model.Setting.name.like('rattail.importing.%.cmd'), + model.Setting.name.like('rattail.importing.%.runas')))\ + .all() + + for setting in to_delete: + app.delete_setting(session, setting) + + @classmethod + def defaults(cls, config): + cls._defaults(config) + cls._importing_defaults(config) + + @classmethod + def _importing_defaults(cls, config): + route_prefix = cls.get_route_prefix() + permission_prefix = cls.get_permission_prefix() + instance_url_prefix = cls.get_instance_url_prefix() + + # run job + config.add_tailbone_permission(permission_prefix, + '{}.runjob'.format(permission_prefix), + "Run an arbitrary Import / Export Job") + config.add_route('{}.runjob'.format(route_prefix), + '{}/runjob'.format(instance_url_prefix)) + config.add_view(cls, attr='runjob', + route_name='{}.runjob'.format(route_prefix), + permission='{}.runjob'.format(permission_prefix)) + + +class ImportHandlerSchema(colander.MappingSchema): + + host_key = colander.SchemaNode(colander.String()) + + local_key = colander.SchemaNode(colander.String()) + + host_title = colander.SchemaNode(colander.String()) + + local_title = colander.SchemaNode(colander.String()) + + handler_spec = colander.SchemaNode(colander.String()) + + +class RunJobSchema(colander.MappingSchema): + + handler_spec = colander.SchemaNode(colander.String(), + missing=colander.null) + + host_title = colander.SchemaNode(colander.String(), + missing=colander.null) + + local_title = colander.SchemaNode(colander.String(), + missing=colander.null) + + models = colander.SchemaNode(colander.List()) + + create = colander.SchemaNode(colander.Bool()) + + update = colander.SchemaNode(colander.Bool()) + + delete = colander.SchemaNode(colander.Bool()) + + # runas = colander.SchemaNode(colander.String()) + + versioning = colander.SchemaNode(colander.Bool()) + + dry_run = colander.SchemaNode(colander.Bool()) + + warnings = colander.SchemaNode(colander.Bool()) + + +def defaults(config, **kwargs): + base = globals() + + ImportingView = kwargs.get('ImportingView', base['ImportingView']) + ImportingView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/inventory.py b/tailbone/views/inventory.py index 7cf5d8d0..4622fa9f 100644 --- a/tailbone/views/inventory.py +++ b/tailbone/views/inventory.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. # @@ -70,5 +70,12 @@ class InventoryAdjustmentReasonView(MasterView): InventoryAdjustmentReasonsView = InventoryAdjustmentReasonView -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + InventoryAdjustmentReasonView = kwargs.get('InventoryAdjustmentReasonView', base['InventoryAdjustmentReasonView']) InventoryAdjustmentReasonView.defaults(config) + + +def includeme(config): + defaults(config) 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 3dfe07ab..fa878448 100644 --- a/tailbone/views/labels/profiles.py +++ b/tailbone/views/labels/profiles.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,8 +24,6 @@ Label Profile Views """ -from __future__ import unicode_literals, absolute_import - from rattail.db import model import colander @@ -62,6 +60,11 @@ class LabelProfileView(MasterView): 'sync_me', ] + def __init__(self, request): + super(LabelProfileView, self).__init__(request) + app = self.get_rattail_app() + self.label_handler = app.get_label_handler() + def configure_grid(self, g): super(LabelProfileView, self).configure_grid(g) g.set_sort_defaults('ordinal') @@ -75,12 +78,19 @@ 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) def after_edit(self, profile): if not profile.format: - formatter = profile.get_formatter(self.rattail_config) + formatter = self.label_handler.get_formatter(profile) if formatter: try: profile.format = formatter.default_format @@ -88,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? @@ -122,17 +130,17 @@ class LabelProfileView(MasterView): View for editing extended Printer Settings, for a given Label Profile. """ profile = self.get_instance() - read_profile = self.redirect(self.get_action_url('view', profile)) + redirect = self.redirect(self.get_action_url('view', profile)) - printer = profile.get_printer(self.rattail_config) + printer = self.label_handler.get_printer(profile) if not printer: msg = "Label profile \"{}\" does not have a functional printer spec.".format(profile) self.request.session.flash(msg) - return read_profile + return redirect if not printer.required_settings: msg = "Printer class for label profile \"{}\" does not require any settings.".format(profile) self.request.session.flash(msg) - return read_profile + return redirect form = self.make_printer_settings_form(profile, printer) @@ -140,8 +148,9 @@ class LabelProfileView(MasterView): if self.request.method == 'POST': for setting in printer.required_settings: if setting in self.request.POST: - profile.save_printer_setting(setting, self.request.POST[setting]) - return read_profile + self.label_handler.save_printer_setting( + profile, setting, self.request.POST[setting]) + return redirect return self.render_to_response('printer', { 'form': form, @@ -171,5 +180,12 @@ class LabelProfileView(MasterView): ProfilesView = LabelProfileView -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + LabelProfileView = kwargs.get('LabelProfileView', base['LabelProfileView']) LabelProfileView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/luigi.py b/tailbone/views/luigi.py new file mode 100644 index 00000000..568183ad --- /dev/null +++ b/tailbone/views/luigi.py @@ -0,0 +1,291 @@ +# -*- 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 Luigi +""" + +import json +import logging +import os +import re +import shlex + +import sqlalchemy as sa + +from rattail.util import simple_error + +from tailbone.views import MasterView + + +log = logging.getLogger(__name__) + + +class LuigiTaskView(MasterView): + """ + Simple views for Luigi tasks. + """ + normalized_model_name = 'luigitasks' + model_key = 'key' + model_title = "Luigi Task" + route_prefix = 'luigi' + url_prefix = '/luigi' + + viewable = False + creatable = False + editable = False + deletable = False + configurable = True + + def __init__(self, request, context=None): + super(LuigiTaskView, self).__init__(request, context=context) + app = self.get_rattail_app() + + # 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', { + 'index_url': None, + 'luigi_url': luigi_url, + 'luigi_history_url': history_url, + 'overnight_tasks': self.get_overnight_tasks(), + 'backfill_tasks': self.get_backfill_tasks(), + }) + + def launch_overnight(self): + app = self.get_rattail_app() + data = self.request.json_body + + key = data.get('key') + task = self.luigi_handler.get_overnight_task(key) if key else None + if not task: + return self.json_response({'error': "Task not found"}) + + try: + self.luigi_handler.launch_overnight_task(task, app.yesterday(), + keep_config=False, + email_if_empty=True, + wait=False) + except Exception as error: + log.warning("failed to launch overnight task: %s", task, + exc_info=True) + return self.json_response({'error': simple_error(error)}) + return self.json_response({'ok': True}) + + def launch_backfill(self): + app = self.get_rattail_app() + data = self.request.json_body + + key = data.get('key') + task = self.luigi_handler.get_backfill_task(key) if key else None + if not task: + return self.json_response({'error': "Task not found"}) + + 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, + 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) + return self.json_response({'error': simple_error(error)}) + return self.json_response({'ok': True}) + + def restart_scheduler(self): + try: + self.luigi_handler.restart_supervisor_process() + self.request.session.flash("Luigi scheduler has been restarted.") + + except Exception as error: + log.warning("restart failed", exc_info=True) + self.request.session.flash(simple_error(error), 'error') + + return self.redirect(self.request.get_referrer( + default=self.get_index_url())) + + def configure_get_simple_settings(self): + return [ + + # luigi proper + {'section': 'rattail.luigi', + 'option': 'url'}, + {'section': 'rattail.luigi', + 'option': 'scheduler.supervisor_process_name'}, + {'section': 'rattail.luigi', + 'option': 'scheduler.restart_command'}, + + ] + + def configure_get_context(self, **kwargs): + context = super(LuigiTaskView, self).configure_get_context(**kwargs) + context['overnight_tasks'] = self.get_overnight_tasks() + context['backfill_tasks'] = self.get_backfill_tasks() + return context + + def get_overnight_tasks(self): + 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'] = str(task['last_date']) + return tasks + + def get_backfill_tasks(self): + 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'] = str(task['last_date']) + if task['target_date']: + task['target_date'] = str(task['target_date']) + return tasks + + def configure_gather_settings(self, data): + settings = super(LuigiTaskView, self).configure_gather_settings(data) + app = self.get_rattail_app() + + # overnight tasks + keys = [] + for task in json.loads(data['overnight_tasks']): + key = task['key'] + keys.append(key) + settings.extend([ + {'name': 'rattail.luigi.overnight.task.{}.description'.format(key), + 'value': task['description']}, + {'name': 'rattail.luigi.overnight.task.{}.module'.format(key), + 'value': task['module']}, + {'name': 'rattail.luigi.overnight.task.{}.class_name'.format(key), + 'value': task['class_name']}, + {'name': 'rattail.luigi.overnight.task.{}.script'.format(key), + 'value': task['script']}, + {'name': 'rattail.luigi.overnight.task.{}.notes'.format(key), + 'value': task['notes']}, + ]) + if keys: + settings.append({'name': 'rattail.luigi.overnight.tasks', + 'value': ', '.join(keys)}) + + # backfill tasks + keys = [] + for task in json.loads(data['backfill_tasks']): + key = task['key'] + keys.append(key) + settings.extend([ + {'name': 'rattail.luigi.backfill.task.{}.description'.format(key), + 'value': task['description']}, + {'name': 'rattail.luigi.backfill.task.{}.script'.format(key), + 'value': task['script']}, + {'name': 'rattail.luigi.backfill.task.{}.forward'.format(key), + 'value': 'true' if task['forward'] else 'false'}, + {'name': 'rattail.luigi.backfill.task.{}.notes'.format(key), + 'value': task['notes']}, + {'name': 'rattail.luigi.backfill.task.{}.target_date'.format(key), + 'value': str(task['target_date'])}, + ]) + if keys: + settings.append({'name': 'rattail.luigi.backfill.tasks', + 'value': ', '.join(keys)}) + + return settings + + def configure_remove_settings(self): + super(LuigiTaskView, self).configure_remove_settings() + + self.luigi_handler.purge_overnight_settings(self.Session()) + self.luigi_handler.purge_backfill_settings(self.Session()) + + @classmethod + def defaults(cls, config): + cls._defaults(config) + cls._luigi_defaults(config) + + @classmethod + def _luigi_defaults(cls, config): + route_prefix = cls.get_route_prefix() + permission_prefix = cls.get_permission_prefix() + url_prefix = cls.get_url_prefix() + model_title_plural = cls.get_model_title_plural() + + # launch overnight + config.add_tailbone_permission(permission_prefix, + '{}.launch_overnight'.format(permission_prefix), + label="Launch any Overnight Task") + config.add_route('{}.launch_overnight'.format(route_prefix), + '{}/launch-overnight'.format(url_prefix), + request_method='POST') + config.add_view(cls, attr='launch_overnight', + route_name='{}.launch_overnight'.format(route_prefix), + permission='{}.launch_overnight'.format(permission_prefix)) + + # launch backfill + config.add_tailbone_permission(permission_prefix, + '{}.launch_backfill'.format(permission_prefix), + label="Launch any Backfill Task") + config.add_route('{}.launch_backfill'.format(route_prefix), + '{}/launch-backfill'.format(url_prefix), + request_method='POST') + config.add_view(cls, attr='launch_backfill', + route_name='{}.launch_backfill'.format(route_prefix), + permission='{}.launch_backfill'.format(permission_prefix)) + + # restart luigid scheduler + config.add_tailbone_permission(permission_prefix, + '{}.restart_scheduler'.format(permission_prefix), + label="Restart the Luigi Scheduler daemon") + config.add_route('{}.restart_scheduler'.format(route_prefix), + '{}/restart-scheduler'.format(url_prefix), + request_method='POST') + config.add_view(cls, attr='restart_scheduler', + route_name='{}.restart_scheduler'.format(route_prefix), + permission='{}.restart_scheduler'.format(permission_prefix)) + + +def defaults(config, **kwargs): + base = globals() + + LuigiTaskView = kwargs.get('LuigiTaskView', base['LuigiTaskView']) + LuigiTaskView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/master.py b/tailbone/views/master.py index ce7fcca7..21a5e58f 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.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,47 +24,50 @@ Model Master View """ -from __future__ import unicode_literals, absolute_import - +import io import os import csv import datetime -import tempfile +import getpass +import shutil import logging +from collections import OrderedDict -import six +import json 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 from tailbone import forms, grids, diffs from tailbone.views import View +from tailbone.db import Session from tailbone.config import global_help_url log = logging.getLogger(__name__) +class EverythingComplete(Exception): + pass + + class MasterView(View): """ Base "master" view class. All model master views should derive from this. @@ -94,21 +97,28 @@ class MasterView(View): viewable = True editable = True deletable = True + delete_requires_progress = False delete_confirm = 'full' bulk_deletable = False set_deletable = False supports_autocomplete = False supports_set_enabled_toggle = False + supports_grid_totals = False populatable = False mergeable = False + merge_handler = None downloadable = False cloneable = False touchable = False 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 # expose / leverage the ``local_only`` object flag @@ -128,6 +138,7 @@ class MasterView(View): deleting = False executing = False cloning = False + configuring = False has_pk_fields = False has_image = False has_thumbnail = False @@ -146,28 +157,37 @@ class MasterView(View): use_index_links = False has_versions = False + default_help_url = None help_url = None labels = {'uuid': "UUID"} + customer_key_fields = {} + member_key_fields = {} + product_key_fields = {} + # ROW-RELATED ATTRS FOLLOW: has_rows = False model_row_class = None + rows_title = None rows_pageable = True rows_sortable = True rows_filterable = True rows_viewable = True rows_creatable = False rows_editable = False + rows_editable_but_not_directly = False rows_deletable = False rows_deletable_speedbump = True rows_bulk_deletable = False - rows_default_pagesize = 20 + rows_default_pagesize = None rows_downloadable_csv = False rows_downloadable_xlsx = False - row_labels = {} + row_labels = { + 'upc': "UPC", + } @property def Session(self): @@ -199,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): @@ -209,6 +230,12 @@ class MasterView(View): """ return getattr(cls, 'grid_factory', grids.Grid) + @classmethod + def get_rows_title(cls): + # nb. we do not provide a default value for this, since it + # will not always make sense to show a row title + return cls.rows_title + @classmethod def get_row_grid_factory(cls): """ @@ -227,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): @@ -235,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'): @@ -242,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): @@ -280,6 +299,18 @@ class MasterView(View): return self.request.has_perm('{}.{}'.format( self.get_permission_prefix(), name)) + def has_any_perm(self, *names): + for name in names: + if self.has_perm(name): + return True + return False + + @classmethod + def get_config_url(cls): + if hasattr(cls, 'config_url'): + return cls.config_url + return '{}/configure'.format(cls.get_url_prefix()) + ############################## # Available Views ############################## @@ -292,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, } @@ -347,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 """ @@ -356,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 @@ -375,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): """ @@ -408,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. @@ -432,18 +500,22 @@ class MasterView(View): # hide "local only" grid filter, unless global access allowed if self.secure_global_objects: if not self.has_perm('view_global'): - grid.hide_column('local_only') + 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)) @@ -455,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. """ @@ -472,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 @@ -493,40 +589,47 @@ class MasterView(View): 'filterable': self.rows_filterable, 'use_byte_string_filters': self.use_byte_string_filters, 'sortable': self.rows_sortable, - 'pageable': self.rows_pageable, - 'default_pagesize': self.rows_default_pagesize, + '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.has_rows and 'main_actions' not in defaults: + if self.rows_default_pagesize: + defaults['pagesize'] = self.rows_default_pagesize + + 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): """ Returns string of extra class(es) for the table row corresponding to @@ -550,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 @@ -573,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 @@ -610,7 +711,7 @@ class MasterView(View): """ self.creating = True if form is None: - form = self.make_form(self.get_model_class()) + form = self.make_create_form() if self.request.method == 'POST': if self.validate_form(form): # let save_create_form() return alternate object if necessary @@ -623,6 +724,9 @@ class MasterView(View): context['dform'] = form.make_deform_form() return self.render_to_response(template, context) + def make_create_form(self): + return self.make_form() + def save_create_form(self, form): uploads = self.normalize_uploads(form) self.before_create(form) @@ -635,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): @@ -665,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'] @@ -701,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 @@ -739,15 +857,27 @@ class MasterView(View): if obj.emails: return obj.emails[0].address - def render_product_key_value(self, obj): + # TODO: deprecate / remove this + def render_product_key_value(self, obj, field=None): """ Render the "canonical" product key value for the given object. + + nb. the ``field`` kwarg is ignored if present """ product_key = self.rattail_config.product_key() if product_key == 'upc': return obj.upc.pretty() if obj.upc else '' return getattr(obj, product_key) + def render_upc(self, obj, field): + """ + Render a :class:`~rattail:rattail.gpc.GPC` field. + """ + value = getattr(obj, field) + if value: + app = self.rattail_config.get_app() + return app.render_gpc(value) + def render_store(self, obj, field): store = getattr(obj, field) if store: @@ -755,14 +885,46 @@ 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) + def render_pending_product(self, obj, field): + pending = getattr(obj, field) + if not pending: + return + text = str(pending) + url = self.request.route_url('pending_products.view', uuid=pending.uuid) + return tags.link_to(text, url, + class_='has-background-warning') + def render_vendor(self, obj, field): vendor = getattr(obj, field) if not vendor: @@ -771,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: @@ -791,6 +968,14 @@ class MasterView(View): url = self.request.route_url('subdepartments.view', uuid=subdepartment.uuid) return tags.link_to(text, url) + def render_brand(self, obj, field): + brand = getattr(obj, field) + if not brand: + return + text = brand.name + url = self.request.route_url('brands.view', uuid=brand.uuid) + return tags.link_to(text, url) + def render_category(self, obj, field): category = getattr(obj, field) if not category: @@ -819,15 +1004,23 @@ 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) + def render_person_profile(self, obj, field): + person = getattr(obj, field) + if not person: + return "" + text = str(person) + url = self.request.route_url('people.view_profile', uuid=person.uuid) + return tags.link_to(text, url) + def render_user(self, obj, field): 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) @@ -843,14 +1036,62 @@ 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) + else: + email_key = obj[field] + if not email_key: + return + + if self.request.has_perm('emailprofiles.view'): + url = self.request.route_url('emailprofiles.view', key=email_key) + return tags.link_to(email_key, url) + + 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 @@ -894,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: @@ -933,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) @@ -946,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, @@ -967,16 +1206,20 @@ class MasterView(View): 'instance_deletable': self.deletable_instance(instance), 'form': form, } + if self.executable: + context['instance_executable'] = self.executable_instance(instance) if hasattr(form, 'make_deform_form'): 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) @@ -1073,11 +1316,8 @@ class MasterView(View): """ Perform actual "touch" logic for the given object. """ - change = model.Change() - change.class_name = obj.__class__.__name__ - change.instance_uuid = obj.uuid - change = self.Session.merge(change) - change.deleted = False + app = self.get_rattail_app() + app.touch_object(self.Session(), obj) def versions(self): """ @@ -1086,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, @@ -1116,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. """ @@ -1124,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 = [] @@ -1143,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() @@ -1174,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, @@ -1195,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() @@ -1302,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: @@ -1336,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): @@ -1419,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): @@ -1444,6 +1804,46 @@ class MasterView(View): Return a content type for a file download, if known. """ + def download_input_file_template(self): + """ + View for downloading an input file template. + """ + key = self.request.GET['key'] + filespec = self.request.GET['file'] + + matches = [tmpl for tmpl in self.get_input_file_templates() + if tmpl['key'] == key] + if not matches: + raise self.notfound() + + template = matches[0] + templatesdir = os.path.join(self.rattail_config.datadir(), + 'templates', 'input_files', + self.get_route_prefix()) + basedir = os.path.join(templatesdir, template['key']) + 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. @@ -1493,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() @@ -1505,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': @@ -1514,15 +1915,20 @@ class MasterView(View): if isinstance(result, httpexceptions.HTTPException): return result - self.delete_instance(instance) - self.request.session.flash("{} has been deleted: {}".format( - self.get_model_title(), instance_title)) - return self.redirect(self.get_after_delete_url(instance)) + if self.delete_requires_progress: + return self.delete_instance_with_progress(instance) + else: + self.delete_instance(instance) + self.request.session.flash("{} has been deleted: {}".format( + self.get_model_title(), instance_title)) + return self.redirect(self.get_after_delete_url(instance)) form.readonly = True return self.render_to_response('delete', { 'instance': instance, 'instance_title': instance_title, + 'instance_editable': self.editable_instance(instance), + 'instance_deletable': self.deletable_instance(instance), 'form': form}) def bulk_delete(self): @@ -1542,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() @@ -1592,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) @@ -1642,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) @@ -1666,42 +2087,80 @@ class MasterView(View): elif importer.allow_create: return importer.create_object(key, host_data) + def executable_instance(self, instance): + """ + Returns boolean indicating whether or not the given instance + can be considered "executable". Returns ``True`` by default; + override as necessary. + """ + return True + def execute(self): """ Execute an object. """ obj = self.get_instance() model_title = self.get_model_title() - if self.request.method == 'POST': - progress = self.make_execute_progress(obj) - kwargs = {'progress': progress} - thread = Thread(target=self.execute_thread, args=(obj.uuid, self.request.user.uuid), kwargs=kwargs) - thread.start() + # caller must explicitly request websocket behavior; otherwise + # we will assume traditional behavior for progress + ws = False + if ((self.request.is_xhr or self.request.content_type == 'application/json') + and self.request.json_body.get('ws')): + ws = True - return self.render_progress(progress, { - 'instance': obj, - 'initial_msg': self.execute_progress_initial_msg, - 'cancel_url': self.get_action_url('view', obj), - 'cancel_msg': "{} execution was canceled".format(model_title), - }, template=self.execute_progress_template) + # make our progress tracker + progress = self.make_execute_progress(obj, ws=ws) - self.request.session.flash("Sorry, you must POST to execute a {}.".format(model_title), 'error') - return self.redirect(self.get_action_url('view', obj)) + # start execution in a separate thread + kwargs = {'progress': progress} + key = [self.request.matchdict[k] + for k in self.get_model_key(as_tuple=True)] + thread = Thread(target=self.execute_thread, + args=(key, self.request.user.uuid), + kwargs=kwargs) + thread.start() - def make_execute_progress(self, obj): - key = '{}.execute'.format(self.get_grid_key()) - return self.make_progress(key) + # we're done here if using websockets + if ws: + return self.json_response({'ok': True}) - def execute_thread(self, uuid, user_uuid, progress=None, **kwargs): + # traditional behavior sends user to dedicated progress page + 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) + + def make_execute_progress(self, obj, ws=False): + if ws: + key = '{}.{}.execution_progress'.format(self.get_route_prefix(), obj.uuid) + else: + key = '{}.execute'.format(self.get_grid_key()) + return self.make_progress(key, ws=ws) + + def get_instance_for_key(self, key, session): + model_key = self.get_model_key(as_tuple=True) + if len(model_key) == 1 and model_key[0] == 'uuid': + uuid = key[0] + 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() - obj = session.query(self.model_class).get(uuid) - user = session.query(model.User).get(user_uuid) + app = self.get_rattail_app() + model = self.app.model + session = app.make_session() + obj = self.get_instance_for_key(key, session) + user = session.get(model.User, user_uuid) try: - self.execute_instance(obj, user, progress=progress, **kwargs) + success_msg = self.execute_instance(obj, user, + progress=progress, + **kwargs) # If anything goes wrong, rollback and log the error etc. except Exception as error: @@ -1717,13 +2176,21 @@ class MasterView(View): # If no error, check result flag (false means user canceled). else: session.commit() - session.refresh(obj) + try: + needs_refresh = obj in session + except: + pass + else: + if needs_refresh: + session.refresh(obj) success_url = self.get_execute_success_url(obj) session.close() if progress: progress.session.load() progress.session['complete'] = True progress.session['success_url'] = success_url + if success_msg: + progress.session['success_msg'] = success_msg progress.session.save() def execute_error_message(self, error): @@ -1733,56 +2200,166 @@ class MasterView(View): def get_execute_success_url(self, obj, **kwargs): return self.get_action_url('view', obj, **kwargs) + def progress_thread(self, sock, success_url, progress): + """ + This method is meant to be used as a thread target. Its job is to read + progress data from ``connection`` and update the session progress + accordingly. When a final "process complete" indication is read, the + socket will be closed and the thread will end. + """ + while True: + try: + self.process_progress(sock, progress) + except EverythingComplete: + break + + # close server socket + sock.close() + + # finalize session progress + progress.session.load() + progress.session['complete'] = True + if callable(success_url): + success_url = success_url() + progress.session['success_url'] = success_url + progress.session.save() + + def process_progress(self, sock, progress): + """ + This method will accept a client connection on the given socket, and + then update the given progress object according to data written by the + client. + """ + connection, client_address = sock.accept() + active_progress = None + + # TODO: make this configurable? + suffix = "\n\n.".encode('utf_8') + data = b'' + + # listen for progress info, update session progress as needed + while True: + + # accumulate data bytestring until we see the suffix + byte = connection.recv(1) + data += byte + if data.endswith(suffix): + + # strip suffix, interpret data as JSON + data = data[:-len(suffix)] + data = data.decode('utf_8') + data = json.loads(data) + + if data.get('everything_complete'): + if active_progress: + active_progress.finish() + raise EverythingComplete + + elif data.get('process_complete'): + active_progress.finish() + active_progress = None + break + + elif 'value' in data: + if not active_progress: + active_progress = progress(data['message'], data['maximum']) + active_progress.update(data['value']) + + # reset data buffer + data = b'' + + # close client connection + connection.close() + def get_merge_fields(self): if hasattr(self, 'merge_fields'): return self.merge_fields + + if self.merge_handler: + fields = self.merge_handler.get_merge_preview_fields() + return [field['name'] for field in fields] + mapper = orm.class_mapper(self.get_model_class()) return mapper.columns.keys() def get_merge_coalesce_fields(self): if hasattr(self, 'merge_coalesce_fields'): return self.merge_coalesce_fields + + if self.merge_handler: + fields = self.merge_handler.get_merge_preview_fields() + return [field['name'] for field in fields + if field.get('coalesce')] + return [] def get_merge_additive_fields(self): if hasattr(self, 'merge_additive_fields'): return self.merge_additive_fields + + if self.merge_handler: + fields = self.merge_handler.get_merge_preview_fields() + return [field['name'] for field in fields + if field.get('additive')] + 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): """ @@ -1790,9 +2367,17 @@ class MasterView(View): the requested merge is valid, in your context. If it is not - for *any reason* - you should raise an exception; the type does not matter. """ + if self.merge_handler: + reason = self.merge_handler.why_not_merge(removing, keeping) + if reason: + raise Exception(reason) def get_merge_data(self, obj): - raise NotImplementedError("please implement `{}.get_merge_data()`".format(self.__class__.__name__)) + if self.merge_handler: + return self.merge_handler.get_merge_preview_data(obj) + + 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) @@ -1813,7 +2398,13 @@ class MasterView(View): Merge the two given objects. You should probably override this; default behavior is merely to delete the 'removing' object. """ - self.Session.delete(removing) + if self.merge_handler: + self.merge_handler.perform_merge(removing, keeping, + user=self.request.user) + + else: + # nb. default "merge" does not update kept object! + self.Session.delete(removing) ############################## # Core Stuff @@ -1850,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()) @@ -1903,8 +2514,10 @@ class MasterView(View): the master view class. This is the plural, lower-cased name of the model class by default, e.g. 'products'. """ + if hasattr(cls, 'route_prefix'): + return cls.route_prefix model_name = cls.get_normalized_model_name() - return getattr(cls, 'route_prefix', '{0}s'.format(model_name)) + return '{}s'.format(model_name) @classmethod def get_url_prefix(cls): @@ -1935,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): """ @@ -1945,6 +2561,16 @@ class MasterView(View): """ return getattr(cls, 'index_title', cls.get_model_title_plural()) + @classmethod + def get_config_title(cls): + """ + Returns the view's "config title". + """ + if hasattr(cls, 'config_title'): + return cls.config_title + + return cls.get_model_title_plural() + def get_action_url(self, action, instance, **kwargs): """ Generate a URL for the given action on the given instance @@ -1966,11 +2592,115 @@ 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 + if self.default_help_url: + return self.default_help_url + 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. @@ -1981,26 +2711,38 @@ 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(), 'permission_prefix': self.get_permission_prefix(), 'index_title': self.get_index_title(), 'index_url': self.get_index_url(), + 'config_title': self.get_config_title(), '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, } - if self.expose_quickie_search: + context['customer_key_field'] = self.get_customer_key_field() + context['customer_key_label'] = self.get_customer_key_label() + + 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: context['grid_count'] = self.grid_count if self.has_rows: + context['rows_title'] = self.get_rows_title() context['row_permission_prefix'] = self.get_row_permission_prefix() context['row_model_title'] = self.get_row_model_title() context['row_model_title_plural'] = self.get_row_model_title_plural() @@ -2008,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) @@ -2094,39 +2843,372 @@ class MasterView(View): kwargs['expose_db_picker'] = False if self.supports_multiple_engines: - # view declares support for multiple engines, but we only want to - # show the picker if we have more than one engine configured - engines = self.get_db_engines() - if len(engines) > 1: + # DB picker is only shown for permissioned users + if self.request.has_perm('common.change_db_engine'): - # user session determines "current" db engine *of this type* - # (note that many master views may declare the same type, and - # 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_selected'] = selected + # view declares support for multiple engines, but we only want to + # show the picker if we have more than one engine configured + engines = self.get_db_engines() + if len(engines) > 1: + + # user session determines "current" db engine *of this type* + # (note that many master views may declare the same type, and + # would therefore share the "current" engine) + selected = self.get_current_engine_dbkey() + kwargs['expose_db_picker'] = True + 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): + return [] + + def normalize_input_file_templates(self, templates=None, + include_file_options=False): + if templates is None: + templates = self.get_input_file_templates() + + route_prefix = self.get_route_prefix() + + if include_file_options: + templatesdir = os.path.join(self.rattail_config.datadir(), + 'templates', 'input_files', + route_prefix) + + for template in templates: + + if 'config_section' not in template: + template['config_section'] = self.input_file_template_config_section + section = template['config_section'] + + if 'config_prefix' not in template: + template['config_prefix'] = '{}.{}'.format( + self.input_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_input_file_template'.format(route_prefix), + _query={'key': template['key'], + 'file': template['file']}) + else: + template['effective_url'] = template['default_url'] + + 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. + """ + return kwargs + def template_kwargs_create(self, **kwargs): """ Method stub, so subclass can always invoke super() for it. """ return kwargs + def template_kwargs_clone(self, **kwargs): + """ + Method stub, so subclass can always invoke super() for it. + """ + return kwargs + def template_kwargs_view(self, **kwargs): """ 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. """ return kwargs + def template_kwargs_delete(self, **kwargs): + """ + Method stub, so subclass can always invoke super() for it. + """ + return kwargs + + def template_kwargs_view_row(self, **kwargs): + """ + Method stub, so subclass can always invoke super() for it. + """ + return kwargs + def get_db_engines(self): """ Must return a dict (or even better, OrderedDict) which contains all @@ -2163,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, [] @@ -2204,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()) @@ -2230,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()), @@ -2261,27 +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: - return {self.model_key: row[self.model_key]} + try: + 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(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): @@ -2314,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 @@ -2441,7 +3590,7 @@ class MasterView(View): os.remove(path) # generate file for download - results = results.with_session(session).all() + results = results.with_session(session) self.download_results_setup(fields, progress=progress) self.download_results_generate(session, results, path, fmt, fields, progress=progress) @@ -2485,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): @@ -2498,7 +3643,7 @@ class MasterView(View): csvrow = self.download_results_coerce_csv(data, fields) writer.writerow(csvrow) - self.progress_loop(write, results, progress, + self.progress_loop(write, results.all(), progress, message="Writing data to CSV file") csv_file.close() @@ -2515,7 +3660,7 @@ class MasterView(View): xlrow = [row[field] for field in fields] xlrows.append(xlrow) - self.progress_loop(write, results, progress, + self.progress_loop(write, results.all(), progress, message="Collecting data for Excel") def finalize(x, i): @@ -2554,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 @@ -2580,7 +3725,7 @@ class MasterView(View): if value is None: value = '' else: - value = six.text_type(value) + value = str(value) csvrow[field] = value @@ -2591,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 @@ -2648,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): @@ -2722,6 +3863,29 @@ class MasterView(View): def results_xlsx_session(self): return self.make_isolated_session() + def results_write_xlsx(self, path, fields, results, session, progress=None): + writer = ExcelWriter(path, fields, sheet_title=self.get_model_title_plural()) + writer.write_header() + + rows = [] + def write(obj, i): + data = self.get_xlsx_row(obj, fields) + row = [data[field] for field in fields] + rows.append(row) + + self.progress_loop(write, results, progress, + message="Collecting data for Excel") + + def finalize(x, i): + writer.write_rows(rows) + writer.auto_freeze() + writer.auto_filter() + writer.auto_resize() + writer.save() + + self.progress_loop(finalize, [1], progress, + message="Writing Excel file to disk") + def results_xlsx_thread(self, results, user_uuid, progress): """ Thread target, responsible for actually generating the Excel file which @@ -2743,27 +3907,9 @@ class MasterView(View): results = results.with_session(session).all() fields = self.get_xlsx_fields() - writer = ExcelWriter(path, fields, sheet_title=self.get_model_title_plural()) - writer.write_header() - rows = [] - def write(obj, i): - data = self.get_xlsx_row(obj, fields) - row = [data[field] for field in fields] - rows.append(row) - - self.progress_loop(write, results, progress, - message="Collecting data for Excel") - - def finalize(x, i): - writer.write_rows(rows) - writer.auto_freeze() - writer.auto_filter() - writer.auto_resize() - writer.save() - - self.progress_loop(finalize, [1], progress, - message="Writing Excel file to disk") + # write output file + self.results_write_xlsx(path, fields, results, session, progress=progress) except Exception as error: msg = "generating XLSX file for download failed!" @@ -3009,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): @@ -3057,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 @@ -3083,7 +4225,7 @@ class MasterView(View): if value is None: value = '' else: - value = six.text_type(value) + value = str(value) csvrow[field] = value @@ -3094,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] @@ -3104,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 @@ -3114,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() @@ -3155,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 @@ -3183,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 @@ -3221,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 ############################## @@ -3306,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): @@ -3332,7 +4480,10 @@ class MasterView(View): raise NotImplementedError def render_downloadable_file(self, obj, field): - filename = getattr(obj, field) + if hasattr(obj, field): + filename = getattr(obj, field) + else: + filename = obj[field] if not filename: return "" path = self.download_path(obj, filename) @@ -3409,30 +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 @@ -3458,9 +4635,13 @@ 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): + app = self.get_rattail_app() if 'default_email' in data: address = data['default_email'] @@ -3474,7 +4655,7 @@ class MasterView(View): contact.add_email_address(address) if 'default_phone' in data: - number = data['default_phone'] + number = app.format_phone_number(data['default_phone']) if contact.phones: if number: phone = contact.phones[0] @@ -3524,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 @@ -3725,6 +4911,7 @@ class MasterView(View): return self.render_to_response('edit_row', { 'instance': row, 'row_parent': parent, + 'parent_model_title': self.get_model_title(), 'parent_title': self.get_instance_title(parent), 'parent_url': self.get_action_url('view', parent), 'parent_instance': parent, @@ -3757,6 +4944,8 @@ class MasterView(View): considered "deletable". Returns ``True`` by default; override as necessary. """ + if not self.rows_deletable: + return False return True def delete_row_object(self, row): @@ -3776,6 +4965,30 @@ class MasterView(View): self.delete_row_object(row) return self.redirect(self.get_action_url('view', self.get_parent(row))) + def bulk_delete_rows(self): + """ + Delete all row objects matching the current row grid query. + """ + obj = self.get_instance() + rows = self.get_effective_row_data(sort=False).all() + + # TODO: this should use a separate thread with progress + self.delete_row_objects(rows) + self.Session.refresh(obj) + + return self.redirect(self.get_action_url('view', obj)) + + def delete_row_objects(self, rows): + """ + Perform the actual deletion of given row objects. + """ + deleted = 0 + for row in rows: + if self.row_deletable(row): + self.delete_row_object(row) + deleted += 1 + return deleted + def get_parent(self, row): raise NotImplementedError @@ -3786,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 @@ -3830,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()) @@ -3859,12 +5071,89 @@ 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: + field = self.get_product_key_field() + g.replace('_product_key_', field) + g.set_label(field, self.get_product_key_label()) + g.set_link(field) + if field == 'upc': + g.set_renderer(field, self.render_upc) + + def configure_field_product_key(self, f): + if '_product_key_' in f: + field = self.get_product_key_field() + f.replace('_product_key_', field) + 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): """ Generate a URL for the given action on the given row. @@ -3886,8 +5175,416 @@ 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) + ############################## - # Config Stuff + # Configuration Views + ############################## + + def configure(self): + """ + 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() + self.request.session.flash("All settings for {} have been " + "removed.".format(self.get_config_title()), + 'warning') + return self.redirect(self.request.current_route_url()) + else: + data = self.request.POST + + # collect any uploaded files + uploads = {} + for key, value in data.items(): + if isinstance(value, cgi_FieldStorage): + tempdir = app.make_temp_dir() + filename = os.path.basename(value.filename) + filepath = os.path.join(tempdir, filename) + with open(filepath, 'wb') as f: + f.write(value.file.read()) + uploads[key] = { + 'filedir': tempdir, + 'filename': filename, + 'filepath': filepath, + } + + # process any uploads first + if uploads: + self.configure_process_uploads(uploads, data) + + # then gather/save settings + settings = self.configure_gather_settings(data) + self.configure_remove_settings() + self.configure_save_settings(settings) + self.configure_flash_settings_saved() + return self.redirect(self.request.current_route_url()) + + context = self.configure_get_context() + return self.render_to_response('configure', context) + + def template_kwargs_configure(self, **kwargs): + kwargs['system_user'] = getpass.getuser() + return kwargs + + def configure_flash_settings_saved(self): + self.request.session.flash("Settings have been saved.") + + def configure_process_uploads(self, uploads, data): + if self.has_input_file_templates: + templatesdir = os.path.join(self.rattail_config.datadir(), + 'templates', 'input_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_input_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']) + + 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 + just needs to be rendered as a separate field, then you can + declare them via this method. + + You should return a list of settings; each setting should be + represented as a dict with various pieces of info, e.g.:: + + { + 'section': 'rattail.batch', + 'option': 'purchase.allow_cases', + 'name': 'rattail.batch.purchase.allow_cases', + 'type': bool, + 'value': config.getbool('rattail.batch', + 'purchase.allow_cases'), + 'save_if_empty': False, + } + + Note that some of the above is optional, in particular it + works like this: + + If you pass ``section`` and ``option`` then you do not need to + pass ``name`` since that can be deduced. Also in this case + you need not pass ``value`` as the normal view logic can fetch + the value automatically. Note that when fetching, it honors + ``type`` which, if you do not specify, would be ``str`` by + default. + + However if you pass ``name`` then you need not pass + ``section`` or ``option``, but you must pass ``value`` since + that cannot be automatically fetched in this case. + + :returns: List of simple setting info dicts, as described + above. + """ + + def configure_get_name_for_simple_setting(self, simple): + if 'name' in simple: + return simple['name'] + return '{}.{}'.format(simple['section'], + simple['option']) + + def configure_get_context(self, simple_settings=None, + input_file_templates=True, + output_file_templates=True): + """ + Returns the full context dict, for rendering the configure + page template. + + Default context will include the "simple" settings, as well as + any "input file template" settings. + + You may need to override this method, to add additional + "custom" settings. + + :param simple_settings: Optional list of simple settings, if + already initialized. + + :returns: Context dict for the page template. + """ + context = {} + if simple_settings is None: + simple_settings = self.configure_get_simple_settings() + if simple_settings: + + config = self.rattail_config + settings = {} + for simple in simple_settings: + + name = self.configure_get_name_for_simple_setting(simple) + + if 'value' in simple: + value = simple['value'] + elif simple.get('type') is bool: + value = config.getbool(simple['section'], + simple['option'], + default=simple.get('default', False)) + else: + value = config.get(simple['section'], + simple['option']) + + settings[name] = value + + context['simple_settings'] = settings + + # add settings for downloadable input file templates, if any + if input_file_templates and self.has_input_file_templates: + settings = {} + file_options = {} + file_option_dirs = {} + for template in self.normalize_input_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['input_file_template_settings'] = settings + 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, + output_file_templates=True): + settings = [] + + # maybe collect "simple" settings + if simple_settings is None: + simple_settings = self.configure_get_simple_settings() + if simple_settings: + + for simple in simple_settings: + name = self.configure_get_name_for_simple_setting(simple) + value = data.get(name) + + if simple.get('type') is bool: + value = str(bool(value)).lower() + elif simple.get('type') is int: + value = str(int(value or '0')) + elif value is None: + value = '' + else: + value = str(value) + + # only want to save this setting if we received a + # value, or if empty values are okay to save + if value or simple.get('save_if_empty'): + settings.append({'name': name, + 'value': value}) + + # maybe also collect input file template settings + if input_file_templates and self.has_input_file_templates: + for template in self.normalize_input_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'])}) + + # 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, + output_file_templates=True): + app = self.get_rattail_app() + model = self.app.model + names = [] + + if simple_settings is None: + simple_settings = self.configure_get_simple_settings() + if simple_settings: + names.extend([self.configure_get_name_for_simple_setting(simple) + for simple in simple_settings]) + + if input_file_templates and self.has_input_file_templates: + for template in self.normalize_input_file_templates(): + names.extend([ + template['setting_mode'], + template['setting_file'], + 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 + session = Session() + for name in names: + app.delete_setting(session, name) + + def configure_save_settings(self, settings): + app = self.get_rattail_app() + + # nb. using thread-local session here; we do not use + # self.Session b/c it may not point to Rattail + session = Session() + for setting in settings: + app.save_setting(session, setting['name'], setting['value'], + force_create=True) + + ############################## + # Pyramid View Config ############################## @classmethod @@ -3930,18 +5627,51 @@ class MasterView(View): model_key = cls.get_model_key() model_title = cls.get_model_title() model_title_plural = cls.get_model_title_plural() + config_title = cls.get_config_title() if cls.has_rows: row_model_title = cls.get_row_model_title() + row_model_title_plural = cls.get_row_model_title_plural() config.add_tailbone_permission_group(permission_prefix, model_title_plural, overwrite=False) + # 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) + + # 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)) + kwargs = {'http_cache': 0} if prevent_cache else {} config.add_view(cls, attr='index', route_name=route_prefix, - permission='{}.list'.format(permission_prefix)) + permission='{}.list'.format(permission_prefix), + **kwargs) # download results # this is the "new" more flexible approach, but we only want to @@ -3986,6 +5716,29 @@ 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, + '{}.configure'.format(permission_prefix), + label="Configure {}".format(config_title)) + config.add_route('{}.configure'.format(route_prefix), + cls.get_config_url()) + config.add_view(cls, attr='configure', + route_name='{}.configure'.format(route_prefix), + permission='{}.configure'.format(permission_prefix)) + config.add_tailbone_config_page('{}.configure'.format(route_prefix), + config_title, + '{}.configure'.format(permission_prefix)) + # quickie (search) if cls.supports_quickie_search: config.add_tailbone_permission(permission_prefix, '{}.quickie'.format(permission_prefix), @@ -4067,37 +5820,26 @@ class MasterView(View): config.add_tailbone_permission(permission_prefix, '{}.merge'.format(permission_prefix), "Merge 2 {}".format(model_title_plural)) + # download input file template + if cls.has_input_file_templates and cls.creatable: + config.add_route('{}.download_input_file_template'.format(route_prefix), + '{}/download-input-file-template'.format(url_prefix)) + config.add_view(cls, attr='download_input_file_template', + 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) - config.add_view(cls, attr='view', route_name='{}.view'.format(route_prefix), - permission='{}.view'.format(permission_prefix)) - - # 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: @@ -4123,7 +5865,12 @@ class MasterView(View): if cls.touchable: config.add_tailbone_permission(permission_prefix, '{}.touch'.format(permission_prefix), "\"Touch\" a {} to trigger datasync for it".format(model_title)) - config.add_route('{}.touch'.format(route_prefix), '{}/touch'.format(instance_url_prefix)) + config.add_route('{}.touch'.format(route_prefix), + '{}/touch'.format(instance_url_prefix), + # TODO: should add this restriction after the old + # jquery theme is no longer in use + #request_method='POST' + ) config.add_view(cls, attr='touch', route_name='{}.touch'.format(route_prefix), permission='{}.touch'.format(permission_prefix)) @@ -4147,7 +5894,9 @@ class MasterView(View): if cls.executable: config.add_tailbone_permission(permission_prefix, '{}.execute'.format(permission_prefix), "Execute {}".format(model_title)) - config.add_route('{}.execute'.format(route_prefix), '{}/execute'.format(instance_url_prefix)) + config.add_route('{}.execute'.format(route_prefix), + '{}/execute'.format(instance_url_prefix), + request_method='POST') config.add_view(cls, attr='execute', route_name='{}.execute'.format(route_prefix), permission='{}.execute'.format(permission_prefix)) @@ -4191,19 +5940,38 @@ class MasterView(View): config.add_view(cls, attr='create_row', route_name='{}.create_row'.format(route_prefix), permission='{}.create_row'.format(permission_prefix)) + # bulk-delete rows + # nb. must be defined before view_row b/c of url similarity + if cls.rows_bulk_deletable: + config.add_tailbone_permission(permission_prefix, + '{}.delete_rows'.format(permission_prefix), + "Bulk-delete {} from {}".format( + row_model_title_plural, model_title)) + config.add_route('{}.delete_rows'.format(route_prefix), + '{}/rows/delete'.format(instance_url_prefix), + # TODO: should enforce this + # request_method='POST' + ) + config.add_view(cls, attr='bulk_delete_rows', + route_name='{}.delete_rows'.format(route_prefix), + permission='{}.delete_rows'.format(permission_prefix)) + # view row if cls.has_rows: if cls.rows_viewable: - config.add_route('{}.view_row'.format(route_prefix), '{}/{{uuid}}/rows/{{row_uuid}}'.format(url_prefix)) + config.add_route('{}.view_row'.format(route_prefix), + '{}/rows/{{row_uuid}}'.format(instance_url_prefix)) config.add_view(cls, attr='view_row', route_name='{}.view_row'.format(route_prefix), permission='{}.view'.format(permission_prefix)) # edit row if cls.has_rows: - if cls.rows_editable: + if cls.rows_editable or cls.rows_editable_but_not_directly: config.add_tailbone_permission(permission_prefix, '{}.edit_row'.format(permission_prefix), - "Edit individual {} rows".format(model_title)) - config.add_route('{}.edit_row'.format(route_prefix), '{}/{{uuid}}/rows/{{row_uuid}}/edit'.format(url_prefix)) + "Edit individual {}".format(row_model_title_plural)) + if cls.rows_editable: + config.add_route('{}.edit_row'.format(route_prefix), + '{}/rows/{{row_uuid}}/edit'.format(instance_url_prefix)) config.add_view(cls, attr='edit_row', route_name='{}.edit_row'.format(route_prefix), permission='{}.edit_row'.format(permission_prefix)) @@ -4211,7 +5979,233 @@ class MasterView(View): if cls.has_rows: if cls.rows_deletable: config.add_tailbone_permission(permission_prefix, '{}.delete_row'.format(permission_prefix), - "Delete individual {} rows".format(model_title)) - config.add_route('{}.delete_row'.format(route_prefix), '{}/{{uuid}}/rows/{{row_uuid}}/delete'.format(url_prefix)) + "Delete individual {}".format(row_model_title_plural)) + config.add_route('{}.delete_row'.format(route_prefix), + '{}/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 070b543a..46ed7e4b 100644 --- a/tailbone/views/members.py +++ b/tailbone/views/members.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2020 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,14 +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): - MemberView.defaults(config) + defaults(config) diff --git a/tailbone/views/menus.py b/tailbone/views/menus.py new file mode 100644 index 00000000..b606e4e7 --- /dev/null +++ b/tailbone/views/menus.py @@ -0,0 +1,189 @@ +# -*- 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/>. +# +################################################################################ +""" +Base class for Config Views +""" + +import json + +import sqlalchemy as sa + +from tailbone.views import View +from tailbone.db import Session + + +class MenuConfigView(View): + """ + View for configuring the main menu. + """ + + def configure(self): + """ + Main entry point to menu config views. + """ + if self.request.method == 'POST': + if self.request.POST.get('remove_settings'): + self.configure_remove_settings() + self.request.session.flash("All settings for Menus have been removed.", + 'warning') + return self.redirect(self.request.current_route_url()) + else: + data = self.request.POST + + # gather/save settings + settings = self.configure_gather_settings(data) + self.configure_remove_settings() + self.configure_save_settings(settings) + self.request.session.flash("Settings have been saved.") + return self.redirect(self.request.current_route_url()) + + context = { + 'config_title': "Menus", + 'index_title': "App Details", + 'index_url': self.request.route_url('appinfo'), + } + + possible_index_options = sorted( + self.request.registry.settings['tailbone_index_pages'], + key=lambda p: p['label']) + + index_options = [] + for option in possible_index_options: + perm = option['permission'] + option['perm'] = perm + option['url'] = self.request.route_url(option['route']) + index_options.append(option) + + context['index_route_options'] = index_options + 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 = menus._make_menu_key(self.rattail_config, topitem['title']) + main_keys.append(key) + + settings.extend([ + {'name': 'tailbone.menu.menu.{}.label'.format(key), + 'value': topitem['title']}, + ]) + + item_keys = [] + for item in topitem['items']: + item_type = item.get('type', 'item') + if item_type == 'item': + if item.get('route'): + item_key = item['route'] + else: + item_key = menus._make_menu_key(self.rattail_config, item['title']) + item_keys.append(item_key) + + settings.extend([ + {'name': 'tailbone.menu.menu.{}.item.{}.label'.format(key, item_key), + 'value': item['title']}, + ]) + + if item.get('route'): + settings.extend([ + {'name': 'tailbone.menu.menu.{}.item.{}.route'.format(key, item_key), + 'value': item['route']}, + ]) + + elif item.get('url'): + settings.extend([ + {'name': 'tailbone.menu.menu.{}.item.{}.url'.format(key, item_key), + 'value': item['url']}, + ]) + + if item.get('perm'): + settings.extend([ + {'name': 'tailbone.menu.menu.{}.item.{}.perm'.format(key, item_key), + 'value': item['perm']}, + ]) + + elif item_type == 'sep': + item_keys.append('SEP') + + settings.extend([ + {'name': 'tailbone.menu.menu.{}.items'.format(key), + 'value': ' '.join(item_keys)}, + ]) + + settings.append({'name': 'tailbone.menu.menus', + 'value': ' '.join(main_keys)}) + return settings + + def configure_remove_settings(self): + model = self.model + Session.query(model.Setting)\ + .filter(sa.or_( + model.Setting.name == 'tailbone.menu.from_settings', + model.Setting.name == 'tailbone.menu.menus', + model.Setting.name.like('tailbone.menu.menu.%.label'), + model.Setting.name.like('tailbone.menu.menu.%.items'), + model.Setting.name.like('tailbone.menu.menu.%.item.%.label'), + model.Setting.name.like('tailbone.menu.menu.%.item.%.route'), + model.Setting.name.like('tailbone.menu.menu.%.item.%.perm'), + model.Setting.name.like('tailbone.menu.menu.%.item.%.url')))\ + .delete(synchronize_session=False) + + def configure_save_settings(self, settings): + model = self.model + session = Session() + for setting in settings: + session.add(model.Setting(name=setting['name'], + value=setting['value'])) + + @classmethod + def defaults(cls, config): + cls._defaults(config) + + @classmethod + def _defaults(cls, config): + + # configure menus + config.add_route('configure_menus', + '/configure-menus') + config.add_view(cls, attr='configure', + route_name='configure_menus', + permission='appinfo.configure', + renderer='/configure-menus.mako') + config.add_tailbone_config_page('configure_menus', "Menus", 'admin') + + +def defaults(config, **kwargs): + base = globals() + + MenuConfigView = kwargs.get('MenuConfigView', base['MenuConfigView']) + MenuConfigView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/messages.py b/tailbone/views/messages.py index 7371e2e9..9199c025 100644 --- a/tailbone/views/messages.py +++ b/tailbone/views/messages.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,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 @@ -575,23 +538,36 @@ class RecipientsWidgetBuefy(dfwidget.Widget): return field.renderer(template, **values) -def includeme(config): +def defaults(config, **kwargs): + base = globals() - config.add_tailbone_permission('messages', 'messages.list', "List/Search Messages") + config.add_tailbone_permission('messages', 'messages.list', + "List/Search Messages") # inbox + InboxView = kwargs.get('InboxView', base['InboxView']) config.add_route('messages.inbox', '/messages/inbox/') - config.add_view(InboxView, attr='index', route_name='messages.inbox', + config.add_view(InboxView, attr='index', + route_name='messages.inbox', permission='messages.list') # archive + ArchiveView = kwargs.get('ArchiveView', base['ArchiveView']) config.add_route('messages.archive', '/messages/archive/') - config.add_view(ArchiveView, attr='index', route_name='messages.archive', + config.add_view(ArchiveView, attr='index', + route_name='messages.archive', permission='messages.list') # sent + SentView = kwargs.get('SentView', base['SentView']) config.add_route('messages.sent', '/messages/sent/') - config.add_view(SentView, attr='index', route_name='messages.sent', + config.add_view(SentView, attr='index', + route_name='messages.sent', permission='messages.list') + MessageView = kwargs.get('MessageView', base['MessageView']) MessageView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/people.py b/tailbone/views/people.py index 8e8374c4..405b1ca3 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.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,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,14 +94,17 @@ 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 - app = self.get_rattail_app() - self.handler = app.get_people_handler() + self.people_handler = app.get_people_handler() + self.merge_handler = self.people_handler + # TODO: deprecate / remove this + 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'): @@ -110,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' @@ -136,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)\ @@ -162,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: @@ -198,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: @@ -244,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): """ @@ -253,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() @@ -276,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") @@ -332,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): @@ -358,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: @@ -368,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: @@ -378,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'), @@ -396,30 +447,22 @@ class PersonView(MasterView): (model.VendorContact, 'person_uuid'), ] - def get_merge_fields(self): - fields = self.handler.get_merge_preview_fields() - return [field['name'] for field in fields] + 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_merge_additive_fields(self): - fields = self.handler.get_merge_preview_fields() - return [field['name'] for field in fields - if field.get('additive')] + def do_quickie_lookup(self, entry): + app = self.get_rattail_app() + return app.get_people_handler().quickie_lookup(entry, self.Session()) - def get_merge_coalesce_fields(self): - fields = self.handler.get_merge_preview_fields() - return [field['name'] for field in fields - if field.get('coalesce')] + def get_quickie_placeholder(self): + app = self.get_rattail_app() + return app.get_people_handler().get_quickie_search_placeholder() - def get_merge_data(self, person): - return self.handler.get_merge_preview_data(person) - - def validate_merge(self, removing, keeping): - reason = self.handler.why_not_merge(removing, keeping) - if reason: - raise Exception(reason) - - def merge_objects(self, removing, keeping): - self.handler.perform_merge(removing, keeping, user=self.request.user) + def get_quickie_result_url(self, person): + return self.get_action_url('view_profile', person) def view_profile(self): """ @@ -427,45 +470,205 @@ class PersonView(MasterView): 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), + '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) - def template_kwargs_view_profile_buefy(self, **kwargs): + 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 stub, so subclass can always invoke super() for it. + 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): + """ + Stub method so subclass can call `super()` for it. """ return kwargs def get_max_lengths(self): + app = self.get_rattail_app() model = self.model - return { - '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): """ @@ -507,13 +710,57 @@ class PersonView(MasterView): 'view_profile_url': self.get_action_url('view_profile', person), 'phones': self.get_context_phones(person), 'emails': self.get_context_emails(person), + '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 str(person) + def get_context_address(self, address): context = { 'uuid': address.uuid, @@ -522,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 @@ -533,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. @@ -593,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 @@ -602,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 @@ -617,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. @@ -624,16 +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), - } + return self.profile_changed_response(person) def get_context_phones(self, person): data = [] @@ -663,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."} @@ -693,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: @@ -716,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: @@ -739,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 = [] @@ -778,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."} @@ -804,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: @@ -827,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: @@ -851,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): """ @@ -864,21 +1216,70 @@ class PersonView(MasterView): data = dict(self.request.json_body) # update person address - address = person.address - if not address: - address = person.add_address() - address.street = data['street'] - address.street2 = data['street2'] - address.city = data['city'] - address.state = data['state'] - address.zipcode = data['zipcode'] - - self.handler.mark_address_invalid(person, address, data['invalid']) + address = self.people_handler.ensure_address(person) + 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): @@ -898,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): """ @@ -927,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."} @@ -962,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): """ @@ -983,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'] @@ -1017,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: @@ -1091,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) @@ -1122,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', @@ -1217,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') @@ -1244,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), @@ -1291,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 @@ -1317,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, @@ -1338,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') @@ -1357,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: @@ -1382,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 @@ -1412,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) @@ -1426,37 +2144,54 @@ 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)" -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + PersonView = kwargs.get('PersonView', base['PersonView']) PersonView.defaults(config) + + PersonNoteView = kwargs.get('PersonNoteView', base['PersonNoteView']) PersonNoteView.defaults(config) + + MergePeopleRequestView = kwargs.get('MergePeopleRequestView', base['MergePeopleRequestView']) MergePeopleRequestView.defaults(config) + + +def includeme(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/permissions.py b/tailbone/views/permissions.py new file mode 100644 index 00000000..5168d544 --- /dev/null +++ b/tailbone/views/permissions.py @@ -0,0 +1,65 @@ +# -*- 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/>. +# +################################################################################ +""" +Raw Permission Views +""" + +from __future__ import unicode_literals, absolute_import + +from sqlalchemy import orm + +from rattail.db import model + +from tailbone.views import MasterView + + +class PermissionView(MasterView): + """ + Master view for the permissions model. + """ + model_class = model.Permission + model_title = "Raw Permission" + editable = False + bulk_deletable = True + + grid_columns = [ + 'role', + 'permission', + ] + + def query(self, session): + model = self.model + query = super(PermissionView, self).query(session) + query = query.options(orm.joinedload(model.Permission.role)) + return query + + +def defaults(config, **kwargs): + base = globals() + + PermissionView = kwargs.get('PermissionView', base['PermissionView']) + PermissionView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/poser/__init__.py b/tailbone/views/poser/__init__.py new file mode 100644 index 00000000..b81580d4 --- /dev/null +++ b/tailbone/views/poser/__init__.py @@ -0,0 +1,32 @@ +# -*- 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/>. +# +################################################################################ +""" +Poser Views +""" + +from __future__ import unicode_literals, absolute_import + + +def includeme(config): + config.include('tailbone.views.poser.reports') + config.include('tailbone.views.poser.views') diff --git a/tailbone/views/poser/master.py b/tailbone/views/poser/master.py new file mode 100644 index 00000000..1f04fe61 --- /dev/null +++ b/tailbone/views/poser/master.py @@ -0,0 +1,73 @@ +# -*- 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/>. +# +################################################################################ +""" +Poser Views for Views... +""" + +from __future__ import unicode_literals, absolute_import + +from rattail.util import simple_error + +from webhelpers2.html import HTML, tags + +from tailbone.views import MasterView + + +class PoserMasterView(MasterView): + """ + Master view base class for Poser + """ + model_key = 'key' + filterable = False + pageable = False + + def __init__(self, request): + super(PoserMasterView, self).__init__(request) + app = self.get_rattail_app() + self.poser_handler = app.get_poser_handler() + + # nb. pre-load all data b/c all views potentially need access + self.data = self.get_data() + + def get_data(self, session=None): + if hasattr(self, 'data'): + return self.data + + try: + return self.get_poser_data(session) + + except Exception as error: + self.request.session.flash(simple_error(error), 'error') + + if not self.request.is_root: + self.request.session.flash("You must become root in order " + "to do Poser Setup.", 'error') + else: + link = tags.link_to("Poser Setup", + self.request.route_url('poser_setup')) + msg = HTML.literal("Please see the {} page.".format(link)) + self.request.session.flash(msg, 'error') + return [] + + def get_poser_data(self, session=None): + raise NotImplementedError("TODO: you must implement this in subclass") diff --git a/tailbone/views/poser/reports.py b/tailbone/views/poser/reports.py new file mode 100644 index 00000000..ded80b18 --- /dev/null +++ b/tailbone/views/poser/reports.py @@ -0,0 +1,314 @@ +# -*- 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/>. +# +################################################################################ +""" +Poser Report Views +""" + +import os + +from rattail.util import simple_error + +import colander +from deform import widget as dfwidget +from webhelpers2.html import HTML + +from .master import PoserMasterView + + +class PoserReportView(PoserMasterView): + """ + Master view for Poser reports + """ + normalized_model_name = 'poser_report' + model_title = "Poser Report" + model_key = 'report_key' + route_prefix = 'poser_reports' + url_prefix = '/poser/reports' + editable = False # TODO: should allow this somehow? + downloadable = True + + labels = { + 'report_key': "Poser Key", + } + + grid_columns = [ + 'report_key', + 'report_name', + 'description', + 'error', + ] + + form_fields = [ + 'report_key', + 'report_name', + 'description', + 'flavor', + 'include_comments', + 'module_file', + 'module_file_path', + 'error', + ] + + has_rows = True + + @property + def model_row_class(self): + return self.model.ReportOutput + + row_labels = { + 'id': "ID", + } + + row_grid_columns = [ + 'id', + 'report_name', + 'filename', + 'created', + 'created_by', + ] + + def get_poser_data(self, session=None): + return self.poser_handler.get_all_reports(ignore_errors=False) + + def configure_grid(self, 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) + + g.set_renderer('error', self.render_report_error) + + g.set_sort_defaults('report_name') + + g.set_link('report_key') + g.set_link('report_name') + g.set_link('description') + g.set_link('error') + + g.set_searchable('report_key') + g.set_searchable('report_name') + g.set_searchable('description') + + if self.request.has_perm('report_output.create'): + g.actions.append(self.make_action( + 'generate', icon='arrow-circle-right', + url=self.get_generate_url)) + + def get_generate_url(self, report, i=None): + if not report.get('error'): + return self.request.route_url('generate_specific_report', + type_key=report['report'].type_key) + + def render_report_error(self, report, field): + error = report.get('error') + if error: + return HTML.tag('span', class_='has-background-warning', c=[error]) + + def get_instance(self): + report_key = self.request.matchdict['report_key'] + for report in self.get_data(): + if report['report_key'] == report_key: + return report + raise self.notfound() + + def get_instance_title(self, report): + return report['report_name'] + + def make_form_schema(self): + return PoserReportSchema() + + def make_create_form(self): + return self.make_form({}) + + def save_create_form(self, form): + self.before_create(form) + + report = self.poser_handler.make_report( + form.validated['report_key'], + form.validated['report_name'], + form.validated['description'], + flavor=form.validated['flavor'], + include_comments=form.validated['include_comments']) + + return report + + def configure_form(self, f): + super().configure_form(f) + report = f.model_instance + + # report_key + f.set_default('report_key', 'cool_widgets') + f.set_helptext('report_key', "Unique computer-friendly key for the report type.") + if self.creating: + f.set_validator('report_key', self.unique_report_key) + + # report_name + f.set_default('report_name', "Cool Widgets Weekly") + f.set_helptext('report_name', "Human-friendly display name for the report.") + + # description + f.set_default('description', "How many cool widgets we come across each week") + f.set_helptext('description', "Brief description of the report.") + + # flavor + if self.creating: + 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 flavors.items()] + f.set_widget('flavor', dfwidget.SelectWidget(values=values)) + f.set_validator('flavor', colander.OneOf(flavors)) + if flavors: + f.set_default('flavor', list(flavors)[0]) + else: + f.remove('flavor') + + # include_comments + if not self.creating: + f.remove('include_comments') + + # module_file + if self.creating: + f.remove('module_file') + else: + # nb. set this key as workaround for render method, which + # expects object to have this field + report['module_file'] = os.path.basename(report['module_file_path']) + f.set_renderer('module_file', self.render_downloadable_file) + + # error + if self.creating or not report.get('error'): + f.remove('error') + else: + f.set_renderer('error', self.render_report_error) + + def unique_report_key(self, node, value): + for report in self.get_data(): + if report['report_key'] == value: + raise node.raise_invalid("Poser report key must be unique") + + def download_path(self, report, filename): + return report['module_file_path'] + + def get_row_data(self, report): + model = self.model + + if report.get('error'): + return [] + + return self.Session.query(model.ReportOutput)\ + .filter(model.ReportOutput.report_type == report['report'].type_key) + + def get_parent(self, output): + key = output.report_type + for report in self.get_data(): + if not report.get('error'): + if report['report'].type_key == key: + return report + + def configure_row_grid(self, g): + super().configure_row_grid(g) + + g.set_renderer('id', self.render_id_str) + + g.set_sort_defaults('created', 'desc') + + g.set_link('id') + g.set_link('filename') + g.set_link('created') + + def row_view_action_url(self, output, i): + return self.request.route_url('report_output.view', uuid=output.uuid) + + def delete_instance(self, report): + self.poser_handler.delete_report(report['report_key']) + + def replace(self): + app = self.get_rattail_app() + report = self.get_instance() + + value = self.request.POST['replacement_module'] + tempdir = app.make_temp_dir() + filepath = os.path.join(tempdir, os.path.basename(value.filename)) + with open(filepath, 'wb') as f: + f.write(value.file.read()) + + try: + newreport = self.poser_handler.replace_report(report['report_key'], + filepath) + except Exception as error: + self.request.session.flash(simple_error(error), 'error') + else: + report = newreport + finally: + os.remove(filepath) + os.rmdir(tempdir) + + return self.redirect(self.get_action_url('view', report)) + + @classmethod + def defaults(cls, config): + cls._defaults(config) + cls._poser_report_defaults(config) + + @classmethod + def _poser_report_defaults(cls, config): + route_prefix = cls.get_route_prefix() + permission_prefix = cls.get_permission_prefix() + instance_url_prefix = cls.get_instance_url_prefix() + model_title = cls.get_model_title() + + # replace module + config.add_tailbone_permission(permission_prefix, + '{}.replace'.format(permission_prefix), + "Upload replacement module for {}".format(model_title)) + config.add_route('{}.replace'.format(route_prefix), + '{}/replace'.format(instance_url_prefix), + request_method='POST') + config.add_view(cls, attr='replace', + route_name='{}.replace'.format(route_prefix), + permission='{}.replace'.format(permission_prefix)) + + +class PoserReportSchema(colander.MappingSchema): + + report_key = colander.SchemaNode(colander.String()) + + report_name = colander.SchemaNode(colander.String()) + + description = colander.SchemaNode(colander.String()) + + flavor = colander.SchemaNode(colander.String()) + + include_comments = colander.SchemaNode(colander.Bool()) + + +def defaults(config, **kwargs): + base = globals() + + PoserReportView = kwargs.get('PoserReportView', base['PoserReportView']) + PoserReportView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/poser/views.py b/tailbone/views/poser/views.py new file mode 100644 index 00000000..27efd549 --- /dev/null +++ b/tailbone/views/poser/views.py @@ -0,0 +1,330 @@ +# -*- 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/>. +# +################################################################################ +""" +Poser Views for Views... +""" + +import colander + +from .master import PoserMasterView +from tailbone.providers import get_all_providers + + +class PoserViewView(PoserMasterView): + """ + Master view for Poser views + """ + normalized_model_name = 'poser_view' + model_title = "Poser View" + route_prefix = 'poser_views' + url_prefix = '/poser/views' + configurable = True + config_title = "Included Views" + + # TODO + creatable = False + editable = False + deletable = False + # downloadable = True + + grid_columns = [ + 'key', + 'class_name', + 'description', + 'error', + ] + + def get_poser_data(self, session=None): + return self.poser_handler.get_all_tailbone_views() + + def make_form_schema(self): + return PoserViewSchema() + + def make_create_form(self): + return self.make_form({}) + + def configure_form(self, f): + super().configure_form(f) + view = f.model_instance + + # key + f.set_default('key', 'cool_widget') + f.set_helptext('key', "Unique key for the view; used as basis for filename.") + if self.creating: + f.set_validator('view_key', self.unique_view_key) + + # class_name + f.set_default('class_name', "CoolWidget") + f.set_helptext('class_name', "Python-friendly basis for view class name.") + + # description + f.set_default('description', "Master view for Cool Widgets") + f.set_helptext('description', "Brief description of the view.") + + def unique_view_key(self, node, value): + for view in self.get_data(): + if view['key'] == value: + raise node.raise_invalid("Poser view key must be unique") + + def collect_available_view_settings(self): + + # TODO: this probably should be more dynamic? definitely need + # to let integration packages register some more options... + + everything = {'rattail': { + + 'people': { + + # TODO: need some way for integration / extension + # packages to register alternate view options for some + # of these. that is the main reason these are dicts + # even though at the moment it's a bit overkill. + + 'tailbone.views.customers': { + # 'spec': 'tailbone.views.customers', + 'label': "Customers", + }, + 'tailbone.views.customergroups': { + # 'spec': 'tailbone.views.customergroups', + 'label': "Customer Groups", + }, + 'tailbone.views.employees': { + # 'spec': 'tailbone.views.employees', + 'label': "Employees", + }, + 'tailbone.views.members': { + # 'spec': 'tailbone.views.members', + 'label': "Members", + }, + }, + + 'products': { + + 'tailbone.views.departments': { + # 'spec': 'tailbone.views.departments', + 'label': "Departments", + }, + + 'tailbone.views.ifps': { + # 'spec': 'tailbone.views.ifps', + 'label': "IFPS PLU Codes", + }, + + 'tailbone.views.subdepartments': { + # 'spec': 'tailbone.views.subdepartments', + 'label': "Subdepartments", + }, + + 'tailbone.views.vendors': { + # 'spec': 'tailbone.views.vendors', + 'label': "Vendors", + }, + + 'tailbone.views.products': { + # 'spec': 'tailbone.views.products', + 'label': "Products", + }, + + 'tailbone.views.brands': { + # 'spec': 'tailbone.views.brands', + 'label': "Brands", + }, + + 'tailbone.views.categories': { + # 'spec': 'tailbone.views.categories', + 'label': "Categories", + }, + + 'tailbone.views.depositlinks': { + # 'spec': 'tailbone.views.depositlinks', + 'label': "Deposit Links", + }, + + 'tailbone.views.families': { + # 'spec': 'tailbone.views.families', + 'label': "Families", + }, + + 'tailbone.views.reportcodes': { + # 'spec': 'tailbone.views.reportcodes', + 'label': "Report Codes", + }, + }, + + 'batches': { + + 'tailbone.views.batch.delproduct': { + 'label': "Delete Product", + }, + 'tailbone.views.batch.inventory': { + 'label': "Inventory", + }, + 'tailbone.views.batch.labels': { + 'label': "Labels", + }, + 'tailbone.views.batch.newproduct': { + 'label': "New Product", + }, + 'tailbone.views.batch.pricing': { + 'label': "Pricing", + }, + 'tailbone.views.batch.product': { + 'label': "Product", + }, + 'tailbone.views.batch.vendorcatalog': { + 'label': "Vendor Catalog", + }, + }, + + 'other': { + + 'tailbone.views.datasync': { + # 'spec': 'tailbone.views.datasync', + 'label': "DataSync", + }, + + 'tailbone.views.importing': { + # 'spec': 'tailbone.views.importing', + 'label': "Importing / Exporting", + }, + + 'tailbone.views.stores': { + # 'spec': 'tailbone.views.stores', + 'label': "Stores", + }, + + 'tailbone.views.taxes': { + # 'spec': 'tailbone.views.taxes', + 'label': "Taxes", + }, + }, + }} + + 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 providers.values(): + + # loop thru provider top-level groups + 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 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 views.items(): + view['options'] = [vkey] + + else: # otherwise must "update" existing group + + # get ref to existing ("standard") group + stdgroup = topgroup[key] + + # loop thru views within provider group + for vkey, view in views.items(): + + # add view to group if it's new + if vkey not in stdgroup: + view['options'] = [vkey] + stdgroup[vkey] = view + + else: # otherwise "update" existing view + stdgroup[vkey]['options'].append(view['spec']) + + return everything + + def configure_get_simple_settings(self): + settings = [] + + view_settings = self.collect_available_view_settings() + 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}) + + return settings + + def configure_get_context(self, simple_settings=None, + input_file_templates=True): + + # first get normal 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 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 view_settings.items(): + for key in list(topgroup): + settings = topgroup[key] + settings = [(key, setting.get('label', key)) + for key, setting in settings.items()] + settings.sort(key=lambda itm: itm[1]) + topgroup[key] = settings + context['view_settings'] = view_settings + + return context + + def configure_flash_settings_saved(self): + super().configure_flash_settings_saved() + self.request.session.flash("Please restart the web app!", 'warning') + + +class PoserViewSchema(colander.MappingSchema): + + key = colander.SchemaNode(colander.String()) + + class_name = colander.SchemaNode(colander.String()) + + description = colander.SchemaNode(colander.String()) + + # include_comments = colander.SchemaNode(colander.Bool()) + + +def defaults(config, **kwargs): + base = globals() + + PoserViewView = kwargs.get('PoserViewView', base['PoserViewView']) + PoserViewView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/principal.py b/tailbone/views/principal.py index b3d032ab..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,15 +24,11 @@ "Principal" master view """ -from __future__ import unicode_literals, absolute_import - import copy +from collections import OrderedDict -from rattail.db.auth import has_permission from rattail.core import Object -from rattail.util import OrderedDict -import wtforms from webhelpers2.html import HTML from tailbone.db import Session @@ -47,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 @@ -57,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: @@ -114,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) @@ -127,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)) @@ -144,6 +183,9 @@ class PermissionsRenderer(Object): return self.render() def render(self): + app = self.request.rattail_config.get_app() + auth = app.get_auth_handler() + principal = self.principal html = '' for groupkey in sorted(self.permissions, key=lambda k: self.permissions[k]['label'].lower()): @@ -151,9 +193,9 @@ class PermissionsRenderer(Object): perms = self.permissions[groupkey]['perms'] rendered = False for key in sorted(perms, key=lambda p: perms[p]['label'].lower()): - checked = has_permission(Session(), principal, key, - include_guest=self.include_guest, - include_authenticated=self.include_authenticated) + checked = auth.has_permission(Session(), principal, key, + include_anonymous=self.include_guest, + include_authenticated=self.include_authenticated) if checked: label = perms[key]['label'] span = HTML.tag('span', c="[X]" if checked else "[ ]") diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 3419ccfe..8461ae03 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.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,33 +24,27 @@ 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.batch import get_batch_handler -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 -from tailbone.db import Session from tailbone.views import MasterView from tailbone.util import raw_datetime @@ -81,22 +75,30 @@ 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", 'tax3': "Tax 3", + 'tpr_price': "TPR Price", + 'tpr_price_ends': "TPR Price Ends", } grid_columns = [ - 'upc', + '_product_key_', 'brand', 'description', 'size', @@ -107,9 +109,7 @@ class ProductView(MasterView): ] form_fields = [ - 'item_id', - 'scancode', - 'upc', + '_product_key_', 'brand', 'description', 'unit_size', @@ -131,6 +131,10 @@ class ProductView(MasterView): 'regular_price', 'current_price', 'current_price_ends', + 'sale_price', + 'sale_price_ends', + 'tpr_price', + 'tpr_price_ends', 'vendor', 'cost', 'deposit_link', @@ -157,66 +161,43 @@ 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) - def __init__(self, request): - super(ProductView, self).__init__(request) - self.print_labels = request.rattail_config.getbool('tailbone', 'products.print_labels', default=False) + super().__init__(request) + self.expose_label_printing = self.rattail_config.getbool( + 'tailbone', 'products.print_labels', default=False) app = self.get_rattail_app() - self.handler = app.get_products_handler() + self.products_handler = app.get_products_handler() + self.merge_handler = self.products_handler + # TODO: deprecate / remove these + self.product_handler = self.products_handler + 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): + """ + 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 configure_grid(self, g): - super(ProductView, self).configure_grid(g) + super().configure_grid(g) app = self.get_rattail_app() - - 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) + model = self.model ProductCostCode = orm.aliased(model.ProductCost) ProductCostCodeAny = orm.aliased(model.ProductCost) @@ -231,30 +212,62 @@ class ProductView(MasterView): return q.outerjoin(ProductCostCodeAny, ProductCostCodeAny.product_uuid == model.Product.uuid) - g.joiners['brand'] = lambda q: q.outerjoin(model.Brand) + # product 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.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)) g.set_sorter('department', model.Department.name) - department_choices = app.cache_model(self.Session(), model.Department, - order_by=model.Department.name, - normalizer=lambda d: d.name) + departments = self.get_departments() + department_choices = OrderedDict([('', "(any)")] + + [(d.uuid, d.name) for d in departments]) 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 - g.joiners['vendor_code'] = join_vendor_code - g.joiners['vendor_code_any'] = join_vendor_code_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) @@ -272,47 +285,77 @@ 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['upc'].default_active = True - g.filters['upc'].default_verb = 'equal' 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.filters['vendor_code'] = g.make_filter('vendor_code', ProductCostCode.code) - g.filters['vendor_code_any'] = g.make_filter('vendor_code_any', ProductCostCodeAny.code) + + # g.joiners['vendor_code_any'] = join_vendor_code_any + # g.filters['vendor_code_any'] = g.make_filter('vendor_code_any', ProductCostCodeAny.code) + # g.joiners['vendor_code'] = join_vendor_code + # g.filters['vendor_code'] = g.make_filter('vendor_code', ProductCostCode.code) + + # vendor_code* + g.set_joiner('vendor_code', join_vendor_code) + g.set_filter('vendor_code', ProductCostCode.code) + g.set_label('vendor_code', "Vendor Code (preferred)") + g.set_joiner('vendor_code_any', join_vendor_code_any) + g.set_filter('vendor_code_any', ProductCostCodeAny.code) + g.set_label('vendor_code_any', "Vendor Code (any)") # category - g.set_joiner('category', lambda q: q.outerjoin(model.Category)) - g.set_filter('category', model.Category.name) + CategoryByCode = orm.aliased(model.Category) + CategoryByName = orm.aliased(model.Category) + g.set_joiner('category_code', + lambda q: q.outerjoin(CategoryByCode, + CategoryByCode.uuid == model.Product.category_uuid)) + g.set_filter('category_code', CategoryByCode.code) + g.set_joiner('category_name', + lambda q: q.outerjoin(CategoryByName, + CategoryByName.uuid == model.Product.category_uuid)) + g.set_filter('category_name', CategoryByName.name) # family 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( + 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( + 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) @@ -327,41 +370,348 @@ 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) - g.set_sort_defaults('upc') - - if self.print_labels and self.request.has_perm('products.print_labels'): - g.more_actions.append(grids.GridAction('print_label', icon='print')) - - g.set_type('upc', 'gpc') + if self.expose_label_printing and self.has_perm('print_labels'): + 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) g.set_renderer('on_order', self.render_on_order) - g.set_link('upc') 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', "Pref. Vendor") + 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 - # upc - f.set_type('upc', 'gpc') - # unit_size f.set_type('unit_size', 'quantity') # 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 @@ -420,6 +770,34 @@ class ProductView(MasterView): f.set_readonly('current_price_ends') f.set_renderer('current_price_ends', self.render_current_price_ends) + # sale_price + if self.creating: + f.remove_field('sale_price') + else: + f.set_readonly('sale_price') + f.set_renderer('sale_price', self.render_price) + + # sale_price_ends + if self.creating: + f.remove_field('sale_price_ends') + else: + f.set_readonly('sale_price_ends') + f.set_renderer('sale_price_ends', self.render_sale_price_ends) + + # tpr_price + if self.creating: + f.remove_field('tpr_price') + else: + f.set_readonly('tpr_price') + f.set_renderer('tpr_price', self.render_price) + + # tpr_price_ends + if self.creating: + f.remove_field('tpr_price_ends') + else: + f.set_readonly('tpr_price_ends') + f.set_renderer('tpr_price_ends', self.render_tpr_price_ends) + # vendor if self.creating: f.remove_field('vendor') @@ -457,288 +835,11 @@ 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.handler.render_price(price) - - def render_current_price_for_grid(self, product, field): - text = self.render_price(product, field) - - 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 text and 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 "" - return "{:0.3f} %".format(product.volatile.true_margin * 100) - - 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): - if self.print_labels: - kwargs['label_profiles'] = Session.query(model.LabelProfile)\ - .filter(model.LabelProfile.visible == True)\ - .order_by(model.LabelProfile.ordinal)\ - .all() - return kwargs - - - def grid_extra_class(self, product, i): - classes = [] - if product.not_for_sale: - classes.append('not-for-sale') - 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') - - 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 - - return row - - def get_instance(self): - key = self.request.matchdict['uuid'] - product = Session.query(model.Product).get(key) - if product: - return product - price = 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: f.replace('department', 'department_uuid') - departments = self.Session.query(model.Department)\ - .order_by(model.Department.number) + departments = self.get_departments() dept_values = [(d.uuid, "{} {}".format(d.number, d.name)) for d in departments] require_department = False @@ -798,7 +899,7 @@ class ProductView(MasterView): f.set_label('family_uuid', "Family") else: f.set_readonly('family') - # f.set_renderer('family', self.render_family) + f.set_renderer('family', self.render_family) # report_code if self.creating or self.editing: @@ -855,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') @@ -870,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)) @@ -900,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: @@ -908,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: @@ -986,13 +1095,30 @@ class ProductView(MasterView): return "" return raw_datetime(self.request.rattail_config, value) + def render_sale_price_ends(self, product, field): + if not product.sale_price: + return + ends = product.sale_price.ends + if not ends: + return + return raw_datetime(self.rattail_config, ends) + + def render_tpr_price_ends(self, product, field): + if not product.tpr_price: + return + ends = product.tpr_price.ends + if not ends: + return + return raw_datetime(self.rattail_config, ends) + def render_inventory_on_hand(self, product, field): if not product.inventory: return "" 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: @@ -1000,12 +1126,14 @@ 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): """ AJAX view for fetching various types of price history for a product. """ + app = self.get_rattail_app() product = self.get_instance() typ = self.request.params.get('type', 'regular') @@ -1019,14 +1147,15 @@ class ProductView(MasterView): for history in data: history = dict(history) price = history['price'] - history['price'] = float(price) - history['price_display'] = "${:0.2f}".format(price) - changed = localtime(self.rattail_config, history['changed'], from_utc=True) - history['changed'] = six.text_type(changed) + if price is not None: + history['price'] = float(price) + history['price_display'] = app.render_currency(price) + 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 @@ -1034,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) @@ -1047,47 +1177,29 @@ 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() - # TODO: pretty sure this is no longer needed? guess we'll find out - # kwargs['image'] = False - - # maybe provide image URL for product; we prefer image from our DB if - # present, but otherwise a "POD" image URL can be attempted. - if product.image: - kwargs['image_url'] = self.request.route_url('products.image', uuid=product.uuid) - - elif product.upc: - if self.rattail_config.getbool('tailbone', 'products.show_pod_image', default=False): - # here we try to give a URL to a so-called "POD" image for the product - kwargs['image_url'] = pod.get_image_url(self.rattail_config, product.upc) - kwargs['image_path'] = pod.get_image_path(self.rattail_config, product.upc) - - # maybe use "image not found" placeholder image - if not kwargs.get('image_url'): - kwargs['image_url'] = self.request.static_url('tailbone:static/img/product.png') + kwargs['image_url'] = self.products_handler.get_image_url(product) # add price history, if user has access 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', @@ -1099,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', @@ -1120,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', @@ -1137,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', @@ -1162,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", @@ -1199,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) @@ -1221,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', @@ -1248,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 @@ -1317,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 @@ -1459,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 @@ -1528,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 @@ -1595,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'), @@ -1607,40 +1777,176 @@ 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 + def print_labels(self): + app = self.get_rattail_app() + label_handler = app.get_label_handler() + model = self.model + + profile = self.request.params.get('profile') + 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.get(model.Product, product) if product else None + if not product: + return {'error': "Product not found"} + + quantity = self.request.params.get('quantity') + if not quantity.isdigit(): + return {'error': "Quantity must be numeric"} + quantity = int(quantity) + + 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} + def search(self): + """ + Perform a product search across multiple fields, and return + 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 + return self.search_v1() + + term = self.request.GET.get('term') + if not term: + return {'ok': True, 'results': []} + + supported_fields = [ + 'product_key', + 'vendor_code', + 'alt_code', + 'brand_name', + 'description', + ] + + search_fields = [] + for field in supported_fields: + key = 'search_{}'.format(field) + if self.request.GET.get(key) == 'true': + search_fields.append(field) + + final_results = [] + session = self.Session() + model = self.model + + lookup_fields = [] + if 'product_key' in search_fields: + lookup_fields.append('_product_key_') + if 'vendor_code' in search_fields: + lookup_fields.append('vendor_code') + if 'alt_code' in search_fields: + lookup_fields.append('alt_code') + if lookup_fields: + product = self.products_handler.locate_product_for_entry( + session, term, lookup_fields=lookup_fields, + first_if_multiple=True) + if product: + final_results.append(self.search_normalize_result(product)) + + # base wildcard query + query = session.query(model.Product) + if 'brand_name' in search_fields: + query = query.outerjoin(model.Brand) + + # now figure out wildcard criteria + criteria = [] + for word in term.split(): + if 'brand_name' in search_fields and 'description' in search_fields: + criteria.append(sa.or_( + model.Brand.name.ilike('%{}%'.format(word)), + model.Product.description.ilike('%{}%'.format(word)))) + elif 'brand_name' in search_fields: + criteria.append(model.Brand.name.ilike('%{}%'.format(word))) + elif 'description' in search_fields: + criteria.append(model.Product.description.ilike('%{}%'.format(word))) + + # execute wildcard query if applicable + max_results = 30 # TODO: make conifgurable? + elided = 0 + if criteria: + query = query.filter(sa.and_(*criteria)) + count = query.count() + if count > max_results: + elided = count - max_results + for product in query[:max_results]: + final_results.append(self.search_normalize_result(product)) + + return {'ok': True, 'results': final_results, 'elided': elided} + + def search_normalize_result(self, product, **kwargs): + return self.products_handler.normalize_product(product, fields=[ + 'product_key', + 'url', + 'image_url', + 'brand_name', + 'description', + 'size', + 'full_description', + 'department_name', + 'unit_price', + 'unit_price_display', + 'sale_price', + 'sale_price_display', + 'sale_ends_display', + 'vendor_name', + # TODO: should be case_size + 'case_quantity', + '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. 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) if upc: - product = api.get_product_by_upc(Session(), upc) + product = api.get_product_by_upc(self.Session(), upc) if not product: # Try again, assuming caller did not include check digit. upc = GPC(upc, calc_check_digit='upc') - product = api.get_product_by_upc(Session(), upc) + product = api.get_product_by_upc(self.Session(), upc) 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 = 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) @@ -1655,14 +1961,15 @@ class ProductView(MasterView): return {'product': data} def get_supported_batches(self): + app = self.get_rattail_app() + pricing = app.get_batch_handler('pricing') return OrderedDict([ ('labels', { 'spec': self.rattail_config.get('rattail.batch', 'labels.handler', default='rattail.batch.labels:LabelBatchHandler'), }), ('pricing', { - 'spec': self.rattail_config.get('rattail.batch', 'pricing.handler', - default='rattail.batch.pricing:PricingBatchHandler'), + 'spec': pricing.get_spec(), }), ('delproduct', { 'spec': self.rattail_config.get('rattail.batch', 'delproduct.handler', @@ -1674,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()) @@ -1709,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 @@ -1722,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 @@ -1766,7 +2074,9 @@ class ProductView(MasterView): """ Return params schema for making a pricing batch. """ - return colander.SchemaNode( + app = self.get_rattail_app() + + schema = colander.SchemaNode( colander.Mapping(), colander.SchemaNode(colander.Decimal(), name='min_diff_threshold', quant='1.00', missing=colander.null, @@ -1777,6 +2087,17 @@ class ProductView(MasterView): colander.SchemaNode(colander.Boolean(), name='calculate_for_manual'), ) + pricing = app.get_batch_handler('pricing') + if pricing.allow_future(): + schema.insert(0, colander.SchemaNode( + colander.Date(), + name='start_date', + missing=colander.null, + title="Start Date (FUTURE only)", + widget=forms.widgets.JQueryDateWidget())) + + return schema + def make_batch_params_schema_delproduct(self): """ Return params schema for making a "delete products" batch. @@ -1792,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: @@ -1833,6 +2155,38 @@ class ProductView(MasterView): if batch.batch_key == 'delproduct': return self.request.route_url('batch.delproduct.view', uuid=batch.uuid) + def configure_get_simple_settings(self): + return [ + + # display + {'section': 'rattail', + 'option': 'product.key'}, + {'section': 'rattail', + 'option': 'product.key_title'}, + {'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', + 'option': 'products.print_labels', + 'type': bool}, + {'section': 'tailbone', + 'option': 'products.quick_labels.speedbump_threshold', + 'type': int}, + + ] + @classmethod def defaults(cls, config): cls._product_defaults(config) @@ -1847,10 +2201,18 @@ class ProductView(MasterView): template_prefix = cls.get_template_prefix() permission_prefix = cls.get_permission_prefix() model_title = cls.get_model_title() + model_title_plural = cls.get_model_title_plural() # print labels - config.add_tailbone_permission('products', 'products.print_labels', - "Print labels for products") + config.add_tailbone_permission(permission_prefix, + '{}.print_labels'.format(permission_prefix), + "Print labels for {}".format(model_title_plural)) + config.add_route('{}.print_labels'.format(route_prefix), + '{}/labels'.format(url_prefix)) + config.add_view(cls, attr='print_labels', + route_name='{}.print_labels'.format(route_prefix), + permission='{}.print_labels'.format(permission_prefix), + renderer='json') # view deleted products config.add_tailbone_permission('products', 'products.view_deleted', @@ -1864,10 +2226,10 @@ class ProductView(MasterView): renderer='{}/batch.mako'.format(template_prefix), permission='{}.make_batch'.format(permission_prefix)) - # search (by upc) + # search config.add_route('products.search', '/products/search') config.add_view(cls, attr='search', route_name='products.search', - renderer='json', permission='products.view') + renderer='json', permission='products.list') # product image config.add_route('products.image', '/products/{uuid}/image') @@ -1888,38 +2250,509 @@ class ProductView(MasterView): permission='{}.versions'.format(permission_prefix)) -def print_labels(request): - profile = request.params.get('profile') - profile = Session.query(model.LabelProfile).get(profile) if profile else None - if not profile: - return {'error': "Label profile not found"} +class PendingProductView(MasterView): + """ + Master view for the Pending Product class. + """ + model_class = PendingProduct + route_prefix = 'pending_products' + url_prefix = '/products/pending' + bulk_deletable = True - product = request.params.get('product') - product = Session.query(model.Product).get(product) if product else None - if not product: - return {'error': "Product not found"} + labels = { + 'regular_price_amount': "Regular Price", + 'status_code': "Status", + 'user': "Created by", + } - quantity = request.params.get('quantity') - if not quantity.isdigit(): - return {'error': "Quantity must be numeric"} - quantity = int(quantity) + grid_columns = [ + '_product_key_', + 'brand_name', + 'description', + 'size', + 'department_name', + 'created', + 'user', + 'status_code', + ] - printer = profile.get_printer(request.rattail_config) - if not printer: - return {'error': "Couldn't get printer from label profile"} + form_fields = [ + '_product_key_', + 'product', + 'brand_name', + 'brand', + 'description', + 'size', + 'department_name', + 'department', + 'vendor_name', + 'vendor', + 'vendor_item_code', + 'unit_cost', + 'case_size', + 'regular_price_amount', + 'special_order', + 'notes', + 'status_code', + 'created', + 'user', + 'resolved', + 'resolved_by', + ] - try: - printer.print_labels([(product, quantity, {})]) - except Exception as error: - log.warning("error occurred while printing labels", exc_info=True) - return {'error': six.text_type(error)} - return {} + 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().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().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: + f.remove('department_name') + f.replace('department', 'department_uuid') + f.set_widget('department_uuid', forms.widgets.DepartmentWidget(self.request, required=False)) + f.set_label('department_uuid', "Department") + else: + f.set_renderer('department', self.render_department) + if pending.department: + f.remove('department_name') + + # brand + if self.creating or self.editing: + f.remove('brand_name') + f.replace('brand', 'brand_uuid') + f.set_label('brand_uuid', "Brand") + + f.set_node('brand_uuid', colander.String(), missing=colander.null) + brand_display = "" + if self.request.method == 'POST': + if self.request.POST.get('brand_uuid'): + brand = self.Session.get(model.Brand, self.request.POST['brand_uuid']) + if brand: + brand_display = str(brand) + elif self.editing: + 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)) + else: + f.set_renderer('brand', self.render_brand) + if pending.brand: + f.remove('brand_name') + elif pending.brand_name: + f.remove('brand') + + # description + f.set_required('description') + + # vendor + if self.creating or self.editing: + if 'vendor' in f: + f.remove('vendor_name') + f.replace('vendor', 'vendor_uuid') + f.set_node('vendor_uuid', colander.String()) + vendor_display = "" + if self.request.method == 'POST': + if self.request.POST.get('vendor_uuid'): + vendor = self.Session.get(model.Vendor, self.request.POST['vendor_uuid']) + if 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'))) + f.set_label('vendor_uuid', "Vendor") + else: + f.set_renderer('vendor', self.render_vendor) + if pending.vendor: + f.remove('vendor_name') + elif pending.vendor_name: + f.remove('vendor') + + # case_size + f.set_type('case_size', 'quantity') + + # regular_price_amount + f.set_type('regular_price_amount', 'currency') + + # notes + f.set_type('notes', 'text') + + # created + if self.creating: + f.remove('created') + 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') + else: + f.set_readonly('user') + f.set_renderer('user', self.render_user) + + # resolved* + if self.creating: + f.remove('resolved', 'resolved_by') + elif pending.resolved: + f.set_renderer('resolved_by', self.render_user) + else: + 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 + + def objectify(self, form, data=None): + if data is None: + data = form.validated + + pending = super().objectify(form, data) + + if not pending.user: + pending.user = self.request.user + + self.Session.add(pending) + self.Session.flush() + self.Session.refresh(pending) + + if pending.department: + pending.department_name = pending.department.name + + if pending.brand: + pending.brand_name = pending.brand.name + + return pending + + def before_delete(self, pending): + """ + Event hook, called just before deletion is attempted. + """ + model = self.model + model_title = self.get_model_title() + count = self.Session.query(model.CustomerOrderItem)\ + .filter(model.CustomerOrderItem.pending_product == pending)\ + .count() + if count: + self.request.session.flash("Cannot delete this {} because it is still " + "referenced by {} Customer Orders.".format(model_title, count), + 'error') + return self.redirect(self.get_action_url('view', pending)) + + count = self.Session.query(model.CustomerOrderBatchRow)\ + .filter(model.CustomerOrderBatchRow.pending_product == pending)\ + .count() + if count: + self.request.session.flash("Cannot delete this {} because it is still " + "referenced by {} \"new\" Customer Order Batches.".format(model_title, count), + 'error') + return self.redirect(self.get_action_url('view', pending)) + + def resolve_product(self): + model = self.model + pending = self.get_instance() + redirect = self.redirect(self.get_action_url('view', pending)) + + uuid = self.request.POST['product_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() + 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) + cls._pending_product_defaults(config) + + @classmethod + def _pending_product_defaults(cls, config): + route_prefix = cls.get_route_prefix() + instance_url_prefix = cls.get_instance_url_prefix() + permission_prefix = cls.get_permission_prefix() + model_title = cls.get_model_title() + + # resolve product + config.add_tailbone_permission(permission_prefix, + '{}.resolve_product'.format(permission_prefix), + "Resolve a {} as a Product".format(model_title)) + config.add_route('{}.resolve_product'.format(route_prefix), + '{}/resolve-product'.format(instance_url_prefix), + request_method='POST') + config.add_view(cls, attr='resolve_product', + 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() + + ProductView = kwargs.get('ProductView', base['ProductView']) + ProductView.defaults(config) + + PendingProductView = kwargs.get('PendingProductView', base['PendingProductView']) + PendingProductView.defaults(config) + + ProductCostView = kwargs.get('ProductCostView', base['ProductCostView']) + ProductCostView.defaults(config) def includeme(config): - - config.add_route('products.print_labels', '/products/labels') - config.add_view(print_labels, route_name='products.print_labels', - renderer='json', permission='products.print_labels') - - ProductView.defaults(config) + defaults(config) diff --git a/tailbone/views/progress.py b/tailbone/views/progress.py index 5d06f158..3f47ba3e 100644 --- a/tailbone/views/progress.py +++ b/tailbone/views/progress.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 @@ 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'): @@ -65,9 +61,17 @@ def cancel(request): return {} -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + progress = kwargs.get('progress', base['progress']) config.add_route('progress', '/progress/{key}') config.add_view(progress, route_name='progress', renderer='json') + cancel = kwargs.get('cancel', base['cancel']) config.add_route('progress.cancel', '/progress/{key}/cancel') config.add_view(cancel, route_name='progress.cancel', renderer='json') + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/projects.py b/tailbone/views/projects.py index 1770e021..bcc4cb5d 100644 --- a/tailbone/views/projects.py +++ b/tailbone/views/projects.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2020 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,164 +24,429 @@ 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 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.handler = self.get_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 ('rattail', 'byjove', 'fabric'): - raise ValueError("Unknown project type: {}".format(project_type)) + else: # no project_type - if project_type == 'byjove': - schema = GenerateByjoveProject - elif project_type == 'fabric': - schema = GenerateFabricProject - 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() + + GeneratedProjectView = kwargs.get('GeneratedProjectView', base['GeneratedProjectView']) + GeneratedProjectView.defaults(config) def includeme(config): - GenerateProjectView.defaults(config) + defaults(config) diff --git a/tailbone/views/purchases/core.py b/tailbone/views/purchases/core.py index 2cd28be8..e7bebdff 100644 --- a/tailbone/views/purchases/core.py +++ b/tailbone/views/purchases/core.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,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 @@ -42,7 +38,6 @@ class PurchaseView(MasterView): """ model_class = model.Purchase creatable = False - editable = False has_rows = True model_row_class = model.PurchaseItem @@ -73,7 +68,6 @@ class PurchaseView(MasterView): 'status', 'buyer', 'date_ordered', - 'date_received', 'po_number', 'po_total', 'ship_method', @@ -81,6 +75,7 @@ class PurchaseView(MasterView): 'invoice_date', 'invoice_number', 'invoice_total', + 'date_received', 'created', 'created_by', 'batches', @@ -144,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) @@ -199,24 +201,59 @@ 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) + f.set_readonly('id') f.set_renderer('store', self.render_store) + + # vendor f.set_renderer('vendor', self.render_vendor) + f.set_readonly('vendor') + + # department f.set_renderer('department', self.render_department) + # buyer + f.set_readonly('buyer') + + # date_ordered + f.set_type('date_ordered', 'date_jquery') + + # po_number + f.set_label('po_number', "PO Number") + + # po_total + f.set_type('po_total', 'currency') + f.set_readonly('po_total') + f.set_label('po_total', "PO Total") + + # notes_to_vendor + f.set_type('notes_to_vendor', 'text_wrapped') + + # date_received + f.set_type('date_received', 'date_jquery') + + # invoice_date + f.set_type('invoice_date', 'date_jquery') + + # invoice_total + f.set_type('invoice_total', 'currency') + f.set_readonly('invoice_total') + + # status f.set_readonly('status') f.set_enum('status', self.enum.PURCHASE_STATUS) - f.set_label('po_number', "PO Number") - f.set_label('po_total', "PO Total") - f.set_type('po_total', 'currency') - - f.set_type('invoice_total', 'currency') - + # batches f.set_renderer('batches', self.render_batches) + f.set_readonly('batches') + + # created + f.set_readonly('created') + f.set_readonly('created_by') if self.viewing: purchase = f.model_instance @@ -288,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') @@ -311,15 +348,15 @@ class PurchaseView(MasterView): purchase = self.get_instance() if purchase.status == self.enum.PURCHASE_STATUS_ORDERED: - g.hide_column('cases_received') - g.hide_column('units_received') - g.hide_column('invoice_total') + g.remove('cases_received', + 'units_received', + 'invoice_total') elif purchase.status in (self.enum.PURCHASE_STATUS_RECEIVED, self.enum.PURCHASE_STATUS_COSTED): - g.hide_column('po_total') + 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') @@ -374,5 +411,12 @@ class PurchaseView(MasterView): permission='{}.receiving_worksheet'.format(permission_prefix)) -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + PurchaseView = kwargs.get('PurchaseView', base['PurchaseView']) PurchaseView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/purchases/credits.py b/tailbone/views/purchases/credits.py index 79530fe2..7da096eb 100644 --- a/tailbone/views/purchases/credits.py +++ b/tailbone/views/purchases/credits.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,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 @@ -201,5 +201,12 @@ class PurchaseCreditView(MasterView): permission='{}.change_status'.format(permission_prefix)) -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + PurchaseCreditView = kwargs.get('PurchaseCreditView', base['PurchaseCreditView']) PurchaseCreditView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/purchasing/batch.py b/tailbone/views/purchasing/batch.py index c00267a9..5e00704e 100644 --- a/tailbone/views/purchasing/batch.py +++ b/tailbone/views/purchasing/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,20 +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.time import localtime -from rattail.vendors.invoices import iter_invoice_parsers +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 @@ -46,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 @@ -74,6 +69,8 @@ class PurchasingBatchView(BatchMasterView): 'store', 'buyer', 'vendor', + 'description', + 'workflow', 'department', 'purchase', 'vendor_email', @@ -100,8 +97,10 @@ class PurchasingBatchView(BatchMasterView): 'upc': "UPC", 'item_id': "Item ID", 'brand_name': "Brand", + 'case_quantity': "Case Size", 'po_line_number': "PO Line Number", 'po_unit_cost': "PO Unit Cost", + 'po_case_size': "PO Case Size", 'po_total': "PO Total", } @@ -130,16 +129,24 @@ class PurchasingBatchView(BatchMasterView): 'description', 'size', 'case_quantity', + 'ordered', 'cases_ordered', 'units_ordered', + 'received', 'cases_received', 'units_received', + 'damaged', 'cases_damaged', 'units_damaged', + 'expired', 'cases_expired', 'units_expired', + 'mispick', 'cases_mispick', 'units_mispick', + 'missing', + 'cases_missing', + 'units_missing', 'po_line_number', 'po_unit_cost', 'po_total', @@ -155,26 +162,198 @@ 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.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) + g.set_joiner('buyer', lambda q: q.join(model.Employee).join(model.Person)) + g.set_filter('buyer', model.Person.display_name) + g.set_sorter('buyer', model.Person.display_name) # TODO: we used to include the 'complete' filter by default, but it # seems to likely be confusing for newcomers, so it is no longer @@ -205,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: @@ -218,20 +397,41 @@ class PurchasingBatchView(BatchMasterView): f.set_type('po_total_calculated', 'currency') def configure_form(self, f): - super(PurchasingBatchView, self).configure_form(f) + 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 - today = localtime(self.rattail_config).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') @@ -255,24 +455,24 @@ class PurchasingBatchView(BatchMasterView): if self.creating: f.replace('vendor', 'vendor_uuid') f.set_label('vendor_uuid', "Vendor") - use_autocomplete = self.rattail_config.getbool( - 'rattail', 'vendor.use_autocomplete', default=True) - if use_autocomplete: - 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']) - if vendor: - vendor_display = six.text_type(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)) - else: + use_dropdown = vendor_handler.choice_uses_dropdown() + if use_dropdown: vendors = self.Session.query(model.Vendor)\ .order_by(model.Vendor.id) vendor_values = [(vendor.uuid, "({}) {}".format(vendor.id, vendor.name)) for vendor in vendors] f.set_widget('vendor_uuid', dfwidget.SelectWidget(values=vendor_values)) + else: + vendor_display = "" + if self.request.method == 'POST': + if self.request.POST.get('vendor_uuid'): + vendor = self.Session.get(model.Vendor, self.request.POST['vendor_uuid']) + if 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') @@ -300,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) @@ -324,13 +548,20 @@ class PurchasingBatchView(BatchMasterView): # invoice_parser_key if self.creating: - parsers = sorted(iter_invoice_parsers(), key=lambda p: p.display) + 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 = self.batch_handler.get_supported_invoice_parsers(**kwargs) parser_values = [(p.key, p.display) for p in parsers] - parser_values.insert(0, ('', "(please choose)")) - if use_buefy: - f.set_widget('invoice_parser_key', dfwidget.SelectWidget(values=parser_values)) - else: - f.set_widget('invoice_parser_key', forms.widgets.JQuerySelectWidget(values=parser_values)) + if len(parsers) == 1: + f.set_default('invoice_parser_key', parsers[0].key) + + f.set_widget('invoice_parser_key', dfwidget.SelectWidget(values=parser_values)) else: f.remove_field('invoice_parser_key') @@ -384,6 +615,35 @@ class PurchasingBatchView(BatchMasterView): 'vendor_contact', 'status_code') + # 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 if not store: @@ -393,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: @@ -409,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) @@ -429,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) @@ -450,12 +730,8 @@ class PurchasingBatchView(BatchMasterView): return [(v.uuid, "({}) {}".format(v.id, v.name)) for v in vendors] - def get_vendor_values(self): - vendors = self.get_vendors() - return [(v.uuid, "({}) {}".format(v.id, v.name)) - 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)\ @@ -463,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] @@ -480,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: @@ -532,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: @@ -558,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 @@ -590,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') @@ -626,6 +885,11 @@ class PurchasingBatchView(BatchMasterView): g.set_label('catalog_unit_cost', "Catalog Cost") g.filters['catalog_unit_cost'].label = "Catalog Unit Cost" + # po_unit_cost + g.set_renderer('po_unit_cost', self.render_row_grid_cost) + g.set_label('po_unit_cost', "PO Cost") + g.filters['po_unit_cost'].label = "PO Unit Cost" + # invoice_unit_cost g.set_renderer('invoice_unit_cost', self.render_row_grid_cost) g.set_label('invoice_unit_cost', "Invoice Cost") @@ -642,6 +906,10 @@ class PurchasingBatchView(BatchMasterView): g.set_label('po_total', "Total") g.set_label('credits', "Credits?") + g.set_link('upc') + g.set_link('vendor_code') + g.set_link('description') + def render_row_grid_cost(self, row, field): cost = getattr(row, field) if cost is None: @@ -663,11 +931,12 @@ class PurchasingBatchView(BatchMasterView): row.STATUS_OUT_OF_STOCK, row.STATUS_ON_PO_NOT_INVOICE, row.STATUS_ON_INVOICE_NOT_PO, - row.STATUS_COST_INCREASE): + row.STATUS_COST_INCREASE, + row.STATUS_DID_NOT_RECEIVE): 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() @@ -678,7 +947,17 @@ class PurchasingBatchView(BatchMasterView): f.set_readonly('case_quantity') # quantity fields + f.set_renderer('ordered', self.render_row_quantity) + f.set_renderer('shipped', self.render_row_quantity) + f.set_renderer('received', self.render_row_quantity) + f.set_renderer('damaged', self.render_row_quantity) + f.set_renderer('expired', self.render_row_quantity) + f.set_renderer('mispick', self.render_row_quantity) + f.set_renderer('missing', self.render_row_quantity) + f.set_type('case_quantity', 'quantity') + f.set_type('po_case_size', 'quantity') + f.set_type('invoice_case_size', 'quantity') f.set_type('cases_ordered', 'quantity') f.set_type('units_ordered', 'quantity') f.set_type('cases_shipped', 'quantity') @@ -691,6 +970,8 @@ class PurchasingBatchView(BatchMasterView): f.set_type('units_expired', 'quantity') f.set_type('cases_mispick', 'quantity') f.set_type('units_mispick', 'quantity') + f.set_type('cases_missing', 'quantity') + f.set_type('units_missing', 'quantity') # currency fields # nb. we only show "total" fields as currency, but not case or @@ -713,7 +994,8 @@ class PurchasingBatchView(BatchMasterView): # credits f.set_readonly('credits') - f.set_renderer('credits', self.render_row_credits) + if self.viewing: + f.set_renderer('credits', self.render_row_credits) if self.creating: f.remove_fields( @@ -749,47 +1031,53 @@ class PurchasingBatchView(BatchMasterView): else: f.remove_field('product') - def render_row_credits(self, row, field): - if not row.credits: - return "" + def render_row_quantity(self, row, field): + app = self.get_rattail_app() + cases = getattr(row, 'cases_{}'.format(field)) + units = getattr(row, 'units_{}'.format(field)) + # nb. do not render anything if empty quantities + if cases or units: + return app.render_cases_units(cases, units) + def make_row_credits_grid(self, row): route_prefix = self.get_route_prefix() - columns = [ - 'credit_type', - 'cases_shorted', - 'units_shorted', - 'credit_total', - ] - g = grids.Grid( - key='{}.row_credits'.format(route_prefix), - data=row.credits, - columns=columns, - labels={'credit_type': "Type", - 'cases_shorted': "Cases", - 'units_shorted': "Units"}) - g.set_type('cases_shorted', 'quantity') - g.set_type('units_shorted', 'quantity') - g.set_type('credit_total', 'currency') - return HTML.literal(g.render_grid()) + factory = self.get_grid_factory() -# 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") + g = factory( + self.request, + key=f'{route_prefix}.row_credits', + data=[], + columns=[ + 'credit_type', + 'shorted', + 'credit_total', + 'expiration_date', + # 'mispick_upc', + # 'mispick_brand_name', + # 'mispick_description', + # 'mispick_size', + ], + labels={ + 'credit_type': "Type", + 'shorted': "Quantity", + 'credit_total': "Total", + # 'mispick_upc': "Mispick UPC", + # 'mispick_brand_name': "MP Brand", + # 'mispick_description': "MP Description", + # 'mispick_size': "MP Size", + }) + + g.set_type('credit_total', 'currency') + + if not self.batch_handler.allow_expired_credits(): + g.remove('expiration_date') + + return g + + def render_row_credits(self, row, field): + g = self.make_row_credits_grid(row) + return HTML.literal( + g.render_table_element(data_prop='rowData.credits')) # def before_create_row(self, form): # row = form.fieldset.model @@ -858,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) @@ -882,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 daf016a9..ec4e3ee3 100644 --- a/tailbone/views/purchasing/costing.py +++ b/tailbone/views/purchasing/costing.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,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", } @@ -110,6 +104,7 @@ class CostingBatchView(PurchasingBatchView): 'department_name', 'cases_received', 'units_received', + 'case_quantity', 'catalog_unit_cost', 'invoice_unit_cost', # 'invoice_total_calculated', @@ -128,6 +123,8 @@ class CostingBatchView(PurchasingBatchView): 'size', 'department_name', 'case_quantity', + 'cases_ordered', + 'units_ordered', 'cases_shipped', 'units_shipped', 'cases_received', @@ -185,56 +182,48 @@ 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]) # configure vendor field - use_autocomplete = self.rattail_config.getbool( - 'rattail', 'vendor.use_autocomplete', default=True) - if use_autocomplete: - 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)) - else: + app = self.get_rattail_app() + vendor_handler = app.get_vendor_handler() + use_dropdown = vendor_handler.choice_uses_dropdown() + if use_dropdown: vendors = self.Session.query(model.Vendor)\ .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.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)) # 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), @@ -250,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') @@ -268,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') @@ -300,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'] @@ -318,10 +306,16 @@ class CostingBatchView(PurchasingBatchView): if info: return info['display'] + def configure_row_grid(self, g): + super(CostingBatchView, self).configure_row_grid(g) + + g.set_label('case_quantity', "Case Qty") + g.filters['case_quantity'].label = "Case Quantity" + g.set_type('case_quantity', 'quantity') + @classmethod def defaults(cls, config): cls._costing_defaults(config) - cls._purchasing_defaults(config) cls._batch_defaults(config) cls._defaults(config) @@ -366,5 +360,12 @@ class NewCostingBatch(colander.Schema): validator=valid_workflow) -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + CostingBatchView = kwargs.get('CostingBatchView', base['CostingBatchView']) CostingBatchView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/purchasing/ordering.py b/tailbone/views/purchasing/ordering.py index 69e361ed..c7cc7bfc 100644 --- a/tailbone/views/purchasing/ordering.py +++ b/tailbone/views/purchasing/ordering.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,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 @@ -53,6 +46,9 @@ class OrderingBatchView(PurchasingBatchView): index_title = "Ordering" 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", @@ -61,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', @@ -134,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 @@ -157,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: @@ -243,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: @@ -310,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, @@ -328,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 @@ -375,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: @@ -389,7 +398,7 @@ class OrderingBatchView(PurchasingBatchView): if cases_ordered == '': cases_ordered = 0 else: - cases_ordered = int(cases_ordered) + cases_ordered = int(float(cases_ordered)) if cases_ordered >= 100000: # TODO: really this depends on underlying column return {'error': "Invalid value for cases ordered: {}".format(cases_ordered)} @@ -400,12 +409,12 @@ class OrderingBatchView(PurchasingBatchView): if units_ordered == '': units_ordered = 0 else: - units_ordered = int(units_ordered) + units_ordered = int(float(units_ordered)) if units_ordered >= 100000: # TODO: really this depends on underlying column 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"} @@ -432,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 @@ -464,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]) @@ -483,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) @@ -526,5 +597,12 @@ class OrderingBatchView(PurchasingBatchView): "Download {} as Excel".format(model_title)) -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + OrderingBatchView = kwargs.get('OrderingBatchView', base['OrderingBatchView']) OrderingBatchView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index 0af4afe7..01858c98 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.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,33 +24,43 @@ 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 wuttaweb.util import get_form_data + +from tailbone import forms from tailbone.views.purchasing import PurchasingBatchView log = logging.getLogger(__name__) +POSSIBLE_RECEIVING_MODES = [ + 'received', + 'damaged', + 'expired', + # 'mispick', + 'missing', +] + +POSSIBLE_CREDIT_TYPES = [ + 'damaged', + 'expired', + # 'mispick', + 'missing', +] + class ReceivingBatchView(PurchasingBatchView): """ @@ -63,12 +73,15 @@ class ReceivingBatchView(PurchasingBatchView): index_title = "Receiving" downloadable = True bulk_deletable = True - rows_editable = True + configurable = True + config_title = "Receiving" + default_help_url = 'https://rattailproject.org/docs/rattail-manual/features/purchasing/receiving/index.html' + + rows_editable = False + rows_editable_but_not_directly = True default_uom_is_case = True - purchase_order_fieldname = 'purchase' - labels = { 'truck_dump_batch': "Truck Dump Parent", 'invoice_parser_key': "Invoice Parser", @@ -80,7 +93,6 @@ class ReceivingBatchView(PurchasingBatchView): 'truck_dump', 'description', 'department', - 'buyer', 'date_ordered', 'created', 'created_by', @@ -95,8 +107,8 @@ class ReceivingBatchView(PurchasingBatchView): 'batch_type', # TODO: ideally would get rid of this one 'store', 'vendor', - 'receiving_workflow', 'description', + 'workflow', 'truck_dump', 'truck_dump_children_first', 'truck_dump_children', @@ -106,14 +118,15 @@ class ReceivingBatchView(PurchasingBatchView): 'invoice_parser_key', 'department', 'purchase', + 'params', 'vendor_email', 'vendor_fax', 'vendor_contact', 'vendor_phone', 'date_ordered', - 'date_received', 'po_number', 'po_total', + 'date_received', 'invoice_date', 'invoice_number', 'invoice_total', @@ -133,13 +146,14 @@ class ReceivingBatchView(PurchasingBatchView): row_grid_columns = [ 'sequence', - 'upc', - # 'item_id', + '_product_key_', 'vendor_code', 'brand_name', 'description', 'size', 'department_name', + 'cases_ordered', + 'units_ordered', 'cases_shipped', 'units_shipped', 'cases_received', @@ -153,33 +167,46 @@ class ReceivingBatchView(PurchasingBatchView): ] row_form_fields = [ + 'sequence', 'item_entry', - 'upc', - 'item_id', + '_product_key_', 'vendor_code', 'product', 'brand_name', 'description', 'size', 'case_quantity', + 'ordered', 'cases_ordered', 'units_ordered', + 'shipped', 'cases_shipped', 'units_shipped', + 'received', 'cases_received', 'units_received', + 'damaged', 'cases_damaged', 'units_damaged', + 'expired', 'cases_expired', 'units_expired', + 'mispick', 'cases_mispick', 'units_mispick', + 'missing', + 'cases_missing', + 'units_missing', + 'catalog_unit_cost', 'po_line_number', 'po_unit_cost', + 'po_case_size', 'po_total', + 'invoice_number', 'invoice_line_number', 'invoice_unit_cost', 'invoice_cost_confirmed', + 'invoice_case_size', 'invoice_total', 'invoice_total_calculated', 'status_code', @@ -202,140 +229,37 @@ class ReceivingBatchView(PurchasingBatchView): def batch_mode(self): return self.enum.PURCHASE_BATCH_MODE_RECEIVING - 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. + def configure_grid(self, g): + super().configure_grid(g) - 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 not self.handler.allow_truck_dump_receiving(): + g.remove('truck_dump') - # 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'): - - # 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 self.redirect(self.request.route_url('{}.create'.format(route_prefix))) - - # 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 - use_autocomplete = self.rattail_config.getbool( - 'rattail', 'vendor.use_autocomplete', default=True) - if use_autocomplete: - 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)) - else: - vendors = self.Session.query(model.Vendor)\ - .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)) - - # 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.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): - batch = row.batch - # don't allow if master view has disabled that entirely - if not self.rows_deletable: + # first run it through the normal logic, if that doesn't like + # it then we won't either + if not super().row_deletable(row): return False - # can never delete rows for complete/executed batches - # TODO: not so sure about the 'complete' part though..? - if batch.executed or batch.complete: - return False - - # 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(): @@ -343,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.handler.allow_truck_dump_receiving() + 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()) @@ -452,19 +369,28 @@ class ReceivingBatchView(PurchasingBatchView): f.set_widget('store_uuid', dfwidget.HiddenWidget()) # purchase - if (self.creating and workflow == 'from_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_RECEIVING) - 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') - else: + 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: @@ -474,12 +400,29 @@ 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.)") if self.creating: f.remove('invoice_total_calculated') + # hide all invoice fields if batch does not have invoice file + if not self.creating and not self.batch_handler.has_invoice_file(batch): + f.remove('invoice_file', + 'invoice_date', + 'invoice_number', + 'invoice_total') + # receiving_complete if self.creating: f.remove('receiving_complete') @@ -493,14 +436,41 @@ class ReceivingBatchView(PurchasingBatchView): 'invoice_parser_key') elif workflow == 'from_invoice': - f.remove('truck_dump_batch_uuid') f.set_required('invoice_file') f.set_required('invoice_parser_key') + f.remove('truck_dump_batch_uuid', + 'po_number', + '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', + 'po_number', 'invoice_file', - 'invoice_parser_key') + 'invoice_parser_key', + 'invoice_date', + 'invoice_number') + + elif workflow == 'from_po_with_invoice': + f.set_required('invoice_file') + f.set_required('invoice_parser_key') + f.remove('truck_dump_batch_uuid', + 'date_ordered', + 'po_number', + 'invoice_date', + 'invoice_number') elif workflow == 'truck_dump_children_first': f.remove('truck_dump_batch_uuid', @@ -520,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)\ @@ -540,36 +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'] - 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 == 'truck_dump_children_first': + 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 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 @@ -578,6 +567,151 @@ class ReceivingBatchView(PurchasingBatchView): raise NotImplementedError return kwargs + def make_po_vs_invoice_breakdown(self, batch): + """ + Returns a simple breakdown as list of 2-tuples, each of which + has the display title as first member, and number of rows as + second member. + """ + grouped = {} + labels = OrderedDict([ + ('both', "Found in both PO and Invoice"), + ('po_not_invoice', "Found in PO but not Invoice"), + ('invoice_not_po', "Found in Invoice but not PO"), + ('neither', "Not found in PO nor Invoice"), + ]) + + for row in batch.active_rows(): + if row.po_line_number and not row.invoice_line_number: + grouped.setdefault('po_not_invoice', []).append(row) + elif row.invoice_line_number and not row.po_line_number: + grouped.setdefault('invoice_not_po', []).append(row) + elif row.po_line_number and row.invoice_line_number: + grouped.setdefault('both', []).append(row) + else: + grouped.setdefault('neither', []).append(row) + + breakdown = [] + + for key, label in labels.items(): + if key in grouped: + breakdown.append({ + 'key': key, + 'title': label, + 'count': len(grouped[key]), + }) + + return breakdown + + def allow_edit_catalog_unit_cost(self, batch): + + # 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): + + # 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().template_kwargs_view(**kwargs) + batch = kwargs['instance'] + + 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() + + 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): + app = self.get_rattail_app() + credits_data = [] + for credit in row.credits: + credits_data.append({ + 'uuid': credit.uuid, + 'credit_type': credit.credit_type, + '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, + credit.units_shorted), + 'credit_total': app.render_currency(credit.credit_total), + 'mispick_upc': '-', + 'mispick_brand_name': '-', + 'mispick_description': '-', + 'mispick_size': '-', + }) + return credits_data + + def template_kwargs_view_row(self, **kwargs): + kwargs = super().template_kwargs_view_row(**kwargs) + app = self.get_rattail_app() + products_handler = app.get_products_handler() + row = kwargs['instance'] + + kwargs['allow_cases'] = self.batch_handler.allow_cases() + + if row.product: + kwargs['image_url'] = products_handler.get_image_url(row.product) + elif row.upc: + kwargs['image_url'] = products_handler.get_image_url(upc=row.upc) + + 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 + + return kwargs + def department_for_purchase(self, purchase): pass @@ -589,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) @@ -691,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) @@ -720,50 +856,102 @@ class ReceivingBatchView(PurchasingBatchView): batch.department_uuid = department.uuid def configure_row_grid(self, g): - super(ReceivingBatchView, self).configure_row_grid(g) + super().configure_row_grid(g) + model = self.model + batch = self.get_instance() # vendor_code g.filters['vendor_code'].default_active = True g.filters['vendor_code'].default_verb = 'contains' - # 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)()) + # catalog_unit_cost + 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', + 'this.catalogUnitCostClicked') + + # invoice_unit_cost + 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', + 'this.invoiceUnitCostClicked') + + show_ordered = self.rattail_config.getbool( + 'rattail.batch', 'purchase.receiving.show_ordered_column_in_grid', + default=False) + if not show_ordered: + g.remove('cases_ordered', + 'units_ordered') + + show_shipped = self.rattail_config.getbool( + 'rattail.batch', 'purchase.receiving.show_shipped_column_in_grid', + default=False) + if not show_shipped: + g.remove('cases_shipped', + 'units_shipped') # hide 'ordered' columns for truck dump parent, if its "children first" # flag is set, since that batch type is only concerned with receiving - batch = self.get_instance() if batch.is_truck_dump_parent() and not batch.truck_dump_children_first: - g.hide_column('cases_ordered') - g.hide_column('units_ordered') + g.remove('cases_ordered', + 'units_ordered') # add "Transform to Unit" action, if appropriate 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(): - g.hide_column('truck_dump_status') + g.remove('truck_dump_status') 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', + 'v-model': 'props.row.catalog_unit_cost', + ':ref': "'catalogUnitCost_' + props.row.uuid", + ':row': 'props.row', + '@input': 'catalogCostConfirmed', + }) + + def render_invoice_unit_cost(self): + return HTML.tag('receiving-cost-editor', **{ + 'field': 'invoice_unit_cost', + 'v-model': 'props.row.invoice_unit_cost', + ':ref': "'invoiceUnitCost_' + props.row.uuid", + ':row': 'props.row', + '@input': 'invoiceCostConfirmed', + }) + 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 '') @@ -773,6 +961,13 @@ class ReceivingBatchView(PurchasingBatchView): return css_class + def get_row_instance_title(self, row): + if row.product: + return str(row.product) + if row.upc: + return row.upc.pretty() + 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 if self.row_editable(row): @@ -780,17 +975,92 @@ class ReceivingBatchView(PurchasingBatchView): if row.product and row.product.is_pack_item(): return self.get_row_action_url('transform_unit', row) + def make_row_credits_grid(self, row): + + # first make grid like normal + g = super().make_row_credits_grid(row) + + if (self.has_perm('edit_row') + and self.row_editable(row)): + + # add the Un-Declare action + g.actions.append(self.make_action( + 'remove', label="Un-Declare", + url='#', icon='trash', + link_class='has-text-danger', + click_handler='removeCreditInit(props.row)')) + + return g + + def vuejs_convert_quantity(self, cstruct): + result = dict(cstruct) + if result['cases'] is colander.null: + result['cases'] = None + elif isinstance(result['cases'], decimal.Decimal): + result['cases'] = float(result['cases']) + if result['units'] is colander.null: + result['units'] = None + elif isinstance(result['units'], decimal.Decimal): + result['units'] = float(result['units']) + return result + def receive_row(self, **kwargs): """ 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() + + # 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() + + # 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'] + + # 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)}) + + 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() possible_modes = [ @@ -813,19 +1083,26 @@ 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: - form.set_widget('mode', dfwidget.SelectWidget(values=mode_values)) - else: - form.set_widget('mode', forms.widgets.JQuerySelectWidget(values=mode_values)) + mode_widget = dfwidget.SelectWidget(values=mode_values) + form.set_widget('mode', mode_widget) + + # quantity form.set_widget('quantity', forms.widgets.CasesUnitsWidget(amount_required=True, one_amount_only=True)) + form.set_vuejs_field_converter('quantity', self.vuejs_convert_quantity) + + # expiration_date form.set_type('expiration_date', 'date_jquery') + + # 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) @@ -865,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: @@ -875,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']) @@ -932,11 +1209,56 @@ class ReceivingBatchView(PurchasingBatchView): quantity, to a credit of some sort. """ row = self.get_row_instance() + + # 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() + + # 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']) + + try: + result = self.handler.can_declare_credit(row, **kwargs) + + except Exception as error: + return self.json_response({'error': str(error)}) + + else: + if result: + self.handler.declare_credit(row, **kwargs) + + 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)}) + batch = row.batch - possible_credit_types = [ - 'damaged', - 'expired', - ] context = { 'row': row, 'batch': batch, @@ -951,13 +1273,22 @@ class ReceivingBatchView(PurchasingBatchView): schema = DeclareCreditForm() form = forms.Form(schema=schema, request=self.request) - form.set_widget('credit_type', forms.widgets.JQuerySelectWidget( - values=[(m, m) for m in possible_credit_types])) + form.cancel_url = self.get_row_action_url('view', row) + + # credit_type + values = [(m, m) for m in POSSIBLE_CREDIT_TYPES] + widget = dfwidget.SelectWidget(values=values) + form.set_widget('credit_type', widget) + + # quantity form.set_widget('quantity', forms.widgets.CasesUnitsWidget( amount_required=True, one_amount_only=True)) + form.set_vuejs_field_converter('quantity', self.vuejs_convert_quantity) + + # 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) @@ -981,16 +1312,65 @@ class ReceivingBatchView(PurchasingBatchView): context['parent_title'] = self.get_instance_title(batch) return self.render_to_response('declare_credit', context) + def undeclare_credit(self): + """ + View for un-declaring a credit, i.e. moving the credit amounts + back into the "received" tally. + """ + model = self.model + row = self.get_row_instance() + data = self.request.json_body + + # make sure edit is allowed + if not (self.has_perm('edit_row') and self.row_editable(row)): + raise self.forbidden() + + # figure out which credit to un-declare + credit = None + uuid = data.get('uuid') + if uuid: + credit = self.Session.get(model.PurchaseBatchCredit, uuid) + if not credit: + return {'error': "Credit not found"} + + # un-declare it + self.batch_handler.undeclare_credit(row, credit) + self.Session.flush() + self.Session.refresh(row) + + return {'ok': True, + 'row': self.get_context_row(row)} + + def get_context_row(self, row): + app = self.get_rattail_app() + return { + 'sequence': row.sequence, + 'case_quantity': float(row.case_quantity) if row.case_quantity is not None else None, + 'ordered': self.render_row_quantity(row, 'ordered'), + 'shipped': self.render_row_quantity(row, 'shipped'), + 'received': self.render_row_quantity(row, 'received'), + 'cases_received': float(row.cases_received) if row.cases_received is not None else None, + 'units_received': float(row.units_received) if row.units_received is not None else None, + 'damaged': self.render_row_quantity(row, 'damaged'), + 'expired': self.render_row_quantity(row, 'expired'), + 'mispick': self.render_row_quantity(row, 'mispick'), + 'missing': self.render_row_quantity(row, 'missing'), + 'credits': self.get_context_credits(row), + 'invoice_total_calculated': app.render_currency(row.invoice_total_calculated), + 'status': row.STATUS[row.status_code], + } + def transform_unit_row(self): """ View which transforms the given row, which is assumed to associate with 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: @@ -1031,9 +1411,18 @@ 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 + # the 'upc' field to help with troubleshooting + # TODO: this maybe should be optional..? + if self.viewing and 'upc' not in f: + row = self.get_row_instance() + if not row.product: + f.append('upc') + # allow input for certain fields only; all others are readonly mutable = [ 'invoice_unit_cost', @@ -1096,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 @@ -1200,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) @@ -1337,14 +1727,16 @@ class ReceivingBatchView(PurchasingBatchView): def update_row_cost(self): """ - AJAX view for updating the invoice (actual) unit cost for a row. + 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(self.request.POST) + 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"} @@ -1355,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: @@ -1364,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), }, } @@ -1387,47 +1781,44 @@ class ReceivingBatchView(PurchasingBatchView): if self.rattail_config.getbool('rattail.batch', 'purchase.mobile_images', default=True): return pod.get_image_url(self.rattail_config, row.upc) + def can_auto_receive(self, batch): + return self.handler.can_auto_receive(batch) + 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) - user = session.query(model.User).get(user_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) @@ -1439,10 +1830,88 @@ class ReceivingBatchView(PurchasingBatchView): progress.session['success_url'] = success_url progress.session.save() + def configure_get_simple_settings(self): + config = self.rattail_config + return [ + + # workflows + {'section': 'rattail.batch', + 'option': 'purchase.allow_receiving_from_scratch', + 'type': bool}, + {'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}, + {'section': 'rattail.batch', + 'option': 'purchase.allow_receiving_from_purchase_order_with_invoice', + 'type': bool}, + {'section': 'rattail.batch', + 'option': 'purchase.allow_truck_dump_receiving', + '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}, + + # display + {'section': 'rattail.batch', + 'option': 'purchase.receiving.show_ordered_column_in_grid', + 'type': bool}, + {'section': 'rattail.batch', + 'option': 'purchase.receiving.show_shipped_column_in_grid', + 'type': bool}, + + # product handling + {'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}, + {'section': 'rattail.batch', + 'option': 'purchase.receiving.should_autofix_invoice_case_vs_unit', + 'type': bool}, + {'section': 'rattail.batch', + 'option': 'purchase.receiving.allow_edit_catalog_unit_cost', + 'type': bool}, + {'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', + 'option': 'purchase.mobile_images', + 'type': bool}, + {'section': 'rattail.batch', + 'option': 'purchase.mobile_quick_receive', + 'type': bool}, + {'section': 'rattail.batch', + 'option': 'purchase.mobile_quick_receive_all', + 'type': bool}, + ] + @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) @@ -1450,16 +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), @@ -1470,6 +1934,14 @@ class ReceivingBatchView(PurchasingBatchView): config.add_view(cls, attr='declare_credit', route_name='{}.declare_credit'.format(route_prefix), permission='{}.edit_row'.format(permission_prefix)) + # un-declare credit + config.add_route('{}.undeclare_credit'.format(route_prefix), + '{}/rows/{{row_uuid}}/undeclare-credit'.format(instance_url_prefix)) + config.add_view(cls, attr='undeclare_credit', + route_name='{}.undeclare_credit'.format(route_prefix), + permission='{}.edit_row'.format(permission_prefix), + renderer='json') + # update row cost config.add_route('{}.update_row_cost'.format(route_prefix), '{}/update-row-cost'.format(instance_url_prefix)) config.add_view(cls, attr='update_row_cost', route_name='{}.update_row_cost'.format(route_prefix), @@ -1486,50 +1958,29 @@ 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 - if not rattail_config.production(): - config.add_route('{}.auto_receive'.format(route_prefix), '{}/auto-receive'.format(instance_url_prefix), - request_method='POST') - config.add_view(cls, attr='auto_receive', route_name='{}.auto_receive'.format(route_prefix), - permission='admin') - - -@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) + config.add_tailbone_permission(permission_prefix, + '{}.auto_receive'.format(permission_prefix), + "Auto-receive all items for a {}".format(model_title)) + config.add_route('{}.auto_receive'.format(route_prefix), '{}/auto-receive'.format(instance_url_prefix), + request_method='POST') + config.add_view(cls, attr='auto_receive', route_name='{}.auto_receive'.format(route_prefix), + permission='{}.auto_receive'.format(permission_prefix)) class ReceiveRowForm(colander.MappingSchema): mode = colander.SchemaNode(colander.String(), - validator=colander.OneOf([ - 'received', - 'damaged', - 'expired', - # 'mispick', - ])) + validator=colander.OneOf( + POSSIBLE_RECEIVING_MODES)) quantity = forms.types.ProductQuantity() @@ -1539,15 +1990,21 @@ class ReceiveRowForm(colander.MappingSchema): quick_receive = colander.SchemaNode(colander.Boolean()) + def deserialize(self, *args): + result = super().deserialize(*args) + + if result['mode'] == 'expired' and not result['expiration_date']: + msg = "Expiration date is required for items with 'expired' mode." + self.raise_invalid(msg, node=self.get('expiration_date')) + + return result + class DeclareCreditForm(colander.MappingSchema): credit_type = colander.SchemaNode(colander.String(), - validator=colander.OneOf([ - 'damaged', - 'expired', - # 'mispick', - ])) + validator=colander.OneOf( + POSSIBLE_CREDIT_TYPES)) quantity = forms.types.ProductQuantity() @@ -1556,5 +2013,12 @@ class DeclareCreditForm(colander.MappingSchema): missing=colander.null) -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + ReceivingBatchView = kwargs.get('ReceivingBatchView', base['ReceivingBatchView']) ReceivingBatchView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/reportcodes.py b/tailbone/views/reportcodes.py index ee9a009e..ef090c22 100644 --- a/tailbone/views/reportcodes.py +++ b/tailbone/views/reportcodes.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. # @@ -38,6 +38,7 @@ class ReportCodeView(MasterView): model_class = model.ReportCode model_title = "Report Code" has_versions = True + touchable = True results_downloadable_xlsx = True grid_columns = [ @@ -106,5 +107,12 @@ class ReportCodeView(MasterView): ReportCodesView = ReportCodeView -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + ReportCodeView = kwargs.get('ReportCodeView', base['ReportCodeView']) ReportCodeView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/reports.py b/tailbone/views/reports.py index 6f6b1660..099224be 100644 --- a/tailbone/views/reports.py +++ b/tailbone/views/reports.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,32 +24,29 @@ 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.reporting import get_report_handler 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 from mako.template import Template from pyramid.response import Response -from webhelpers2.html import HTML +from webhelpers2.html import HTML, tags -from tailbone import forms, grids +from tailbone import forms from tailbone.db import Session from tailbone.views import View -from tailbone.views.exports import ExportMasterView +from tailbone.views.exports import ExportMasterView, MasterView plu_upc_pattern = re.compile(r'^000000000(\d{5})$') @@ -63,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]) @@ -83,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' @@ -106,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) @@ -129,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, @@ -138,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) @@ -158,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'): @@ -179,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) @@ -192,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'), @@ -210,10 +212,15 @@ 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 downloadable = True + bulk_deletable = True + configurable = True + config_title = "Reporting" + config_url = '/reports/configure' grid_columns = [ 'id', @@ -233,19 +240,60 @@ class ReportOutputView(ExportMasterView): 'created_by', ] + def __init__(self, request): + super().__init__(request) + self.report_handler = self.get_report_handler() + + def get_report_handler(self): + app = self.get_rattail_app() + 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' + 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) # params f.set_renderer('params', self.render_params) - # filename - if self.viewing: - f.set_renderer('filename', self.render_download) + 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() + poser_handler = app.get_poser_handler() + poser_key = type_key[6:] + report = poser_handler.normalize_report(poser_key) + if not report.get('error'): + url = self.request.route_url('poser_reports.view', + report_key=poser_key) + rendered = tags.link_to(type_key, url) + + # 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 @@ -258,45 +306,57 @@ class ReportOutputView(ExportMasterView): params.sort(key=lambda param: param['key']) route_prefix = self.get_route_prefix() - g = grids.Grid( - key='{}.params'.format(route_prefix), + factory = self.get_grid_factory() + g = factory( + self.request, + key=f'{route_prefix}.params', data=params, columns=['key', 'value'], + labels={'key': "Name"}, ) - return HTML.literal(g.render_grid()) + return HTML.literal( + g.render_table_element(data_prop='paramsData')) - def render_download(self, report, field): - path = report.filepath(self.rattail_config) - url = self.get_action_url('download', report) - return self.render_file_field(path, url=url) + def get_params_context(self, report): + params_data = [] + for name, value in (report.params or {}).items(): + params_data.append({ + 'key': name, + 'value': value, + }) + return params_data - def download(self): - report = self.get_instance() - path = report.filepath(self.rattail_config) - return self.file_response(path) + def template_kwargs_view(self, **kwargs): + kwargs = super().template_kwargs_view(**kwargs) + output = kwargs['instance'] + kwargs['params_data'] = self.get_params_context(output) -class GenerateReport(View): - """ - View for generating a new report. - """ + # build custom URL to re-build this report + url = None + if output.report_type: + url = self.request.route_url('generate_specific_report', + type_key=output.report_type, + _query=output.params) + kwargs['rerun_report_url'] = url - def __init__(self, request): - super(GenerateReport, self).__init__(request) - self.handler = self.get_handler() + return kwargs - def get_handler(self): - return get_report_handler(self.rattail_config) + def template_kwargs_delete(self, **kwargs): + kwargs = super().template_kwargs_delete(**kwargs) - def choose(self): + report = kwargs['instance'] + kwargs['params_data'] = self.get_params_context(report) + + return kwargs + + def create(self): """ 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.handler.get_reports() + reports = self.report_handler.get_reports() if isinstance(reports, OrderedDict): sorted_reports = list(reports) else: @@ -304,7 +364,7 @@ class GenerateReport(View): # 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') @@ -312,30 +372,26 @@ class GenerateReport(View): # 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, size=10)) - 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'])) - return { - 'index_title': "Generate Report", + return self.render_to_response('choose', { 'form': form, 'dform': form.make_deform_form(), 'reports': reports, 'sorted_reports': sorted_reports, 'report_descriptions': dict([(r.type_key, r.__doc__) for r in reports.values()]), - 'use_form': self.rattail_config.getbool('tailbone', 'reporting.choosing_uses_form', - default=True), - 'use_buefy': use_buefy, - } + 'use_form': self.rattail_config.getbool( + 'tailbone', 'reporting.choosing_uses_form', + default=False), + }) def generate(self): """ @@ -343,10 +399,13 @@ class GenerateReport(View): input parameters specific to the report type, then creates a new report and redirects user to view the output. """ - use_buefy = self.get_use_buefy() + app = self.get_rattail_app() type_key = self.request.matchdict['type_key'] - report = self.handler.get_report(type_key) + report = self.report_handler.get_report(type_key) + if not report: + return self.notfound() report_params = report.make_params(Session()) + route_prefix = self.get_route_prefix() NODE_TYPES = { bool: colander.Boolean, @@ -355,6 +414,7 @@ class GenerateReport(View): } schema = colander.Schema() + helptext = {} for param in report_params: # make a new node of appropriate schema type @@ -368,18 +428,22 @@ class GenerateReport(View): # allow empty value if param is optional if not param.required: - node.missing = colander.null + node.missing = None # maybe set default value if hasattr(param, 'default'): node.default = param.default + # set docstring + # 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) + form = forms.Form(schema=schema, request=self.request, helptext=helptext) form.submit_label = "Generate this Report" - form.cancel_url = self.request.route_url('generate_report') + form.cancel_url = self.request.get_referrer( + default=self.request.route_url('{}.create'.format(route_prefix))) # must declare jquery support for date fields, ugh # TODO: obviously would be nice for this to be automatic? @@ -387,8 +451,26 @@ class GenerateReport(View): if param.type is datetime.date: form.set_type(param.name, 'date_jquery') + # auto-select default choice for fields which have only one + for param in report_params: + if param.type == 'choice' and param.required: + values = form.schema[param.name].widget.values + if len(values) == 1: + form.set_default(param.name, values[0][0]) + + # set default field values according to query string, if applicable + if self.request.GET: + for param in report_params: + if param.name in self.request.GET: + 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} @@ -401,14 +483,16 @@ class GenerateReport(View): 'cancel_msg': "Report generation was canceled", }) - return { - 'index_title': "Generate Report", - 'index_url': self.request.route_url('generate_report'), + # hide the "Create New" button for this page, b/c user is + # already in the process of creating new.. + # TODO: this seems hacky, but works + self.show_create_link = False + + return self.render_to_response('generate', { 'report': report, 'form': form, 'dform': form.make_deform_form(), - 'use_buefy': self.get_use_buefy(), - } + }) def generate_thread(self, report, params, user_uuid, progress=None): """ @@ -416,10 +500,12 @@ class GenerateReport(View): 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.handler.generate_output(session, report, params, user, progress=progress) + output = self.report_handler.generate_output(session, report, params, user, progress=progress) # if anything goes wrong, rollback and log the error etc. except Exception as error: @@ -444,24 +530,36 @@ class GenerateReport(View): progress.session['success_url'] = success_url progress.session.save() + def download(self): + report = self.get_instance() + path = report.filepath(self.rattail_config) + return self.file_response(path) + + def configure_get_simple_settings(self): + config = self.rattail_config + return [ + + # generating + {'section': 'tailbone', + 'option': 'reporting.choosing_uses_form', + 'type': bool}, + ] + @classmethod def defaults(cls, config): + cls._defaults(config) + cls._report_output_defaults(config) - # note that we include this in the "Generated Reports" permissions group - config.add_tailbone_permission('report_output', 'report_output.generate', - "Generate new report (of any type)") + @classmethod + def _report_output_defaults(cls, config): + url_prefix = cls.get_url_prefix() + permission_prefix = cls.get_permission_prefix() - # "generate report" (which is really "choose") - config.add_route('generate_report', '/reports/generate') - config.add_view(cls, attr='choose', route_name='generate_report', - permission='report_output.generate', - renderer='/reports/choose.mako') - - # "generate specific report" (accept custom params, truly generate) - config.add_route('generate_specific_report', '/reports/generate/{type_key}') + # generate report (accept custom params, truly create) + config.add_route('generate_specific_report', + '{}/new/{{type_key}}'.format(url_prefix)) config.add_view(cls, attr='generate', route_name='generate_specific_report', - permission='report_output.generate', - renderer='/reports/generate.mako') + permission='{}.create'.format(permission_prefix)) @colander.deferred @@ -483,23 +581,274 @@ class NewReport(colander.Schema): validator=valid_report_type) +class ProblemReportView(MasterView): + """ + Master view for problem reports + """ + model_title = "Problem Report" + model_key = ('system_key', 'problem_key') + route_prefix = 'problem_reports' + url_prefix = '/reports/problems' + + creatable = False + deletable = False + filterable = False + pageable = False + executable = True + + labels = { + 'system_key': "System", + 'days': "Schedule", + } + + grid_columns = [ + 'system_key', + # 'problem_key', + 'problem_title', + 'email_recipients', + ] + + def __init__(self, request): + super().__init__(request) + + app = self.get_rattail_app() + self.problem_handler = app.get_problem_report_handler() + # TODO: deprecate / remove this + self.handler = self.problem_handler + + def normalize(self, report, keep_report=True): + data = self.problem_handler.normalize_problem_report( + report, include_schedule=True, include_recipients=True) + if keep_report: + data['_report'] = report + return data + + def get_data(self, session=None): + data = [] + + reports = self.handler.get_all_problem_reports() + organized = self.handler.organize_problem_reports(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().configure_grid(g) + + g.set_searchable('system_key') + + g.set_renderer('email_recipients', self.render_email_recipients) + + g.set_searchable('problem_title') + + g.set_link('problem_key') + g.set_link('problem_title') + + def get_instance(self): + system_key = self.request.matchdict['system_key'] + problem_key = self.request.matchdict['problem_key'] + return self.get_instance_for_key((system_key, problem_key), + None) + + def get_instance_for_key(self, key, session): + report = self.handler.get_problem_report(*key) + if report: + return self.normalize(report) + raise self.notfound() + + def get_instance_title(self, report_info): + return report_info['problem_title'] + + def make_form_schema(self): + return ProblemReportSchema() + + def configure_form(self, f): + super().configure_form(f) + + # email_* + if self.editing: + f.remove('email_key', + 'email_recipients') + else: + f.set_renderer('email_key', self.render_email_key) + f.set_renderer('email_recipients', self.render_email_recipients) + + # enabled + f.set_type('enabled', 'boolean') + + # days + f.set_renderer('days', self.render_days) + f.set_widget('days', DaysWidget()) + f.set_vuejs_field_converter('days', self.convert_vuejs_days) + f.set_helptext('days', "NB. enabling a given day means you want the " + "report to be available that morning (assuming that " + "reports run overnight)") + + # only allow edit of certain fields + if self.editing: + editable = ('enabled', 'days') + for field in f: + if field not in editable: + f.set_readonly(field) + + def convert_vuejs_days(self, days): + days = dict(days) + for key in days: + if days[key] is colander.null: + days[key] = 'null' + return days + + def render_email_recipients(self, report_info, field): + recips = report_info['email_recipients'] + return ', '.join(recips) + + def render_days(self, report_info, field): + 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().template_kwargs_view(**kwargs) + report_info = kwargs['instance'] + + data = [] + for i in range(7): + data.append({ + 'weekday': i, + 'weekday_name': calendar.day_name[i], + 'enabled': "Yes" if report_info['day{}'.format(i)] else "No", + }) + kwargs['weekdays_data'] = data + + return kwargs + + def save_edit_form(self, form): + app = self.get_rattail_app() + session = self.Session() + data = form.validated + report = self.get_instance() + key = '{}.{}'.format(report['system_key'], + report['problem_key']) + + app.save_setting(session, 'rattail.problems.{}.enabled'.format(key), + str(data['enabled']).lower()) + + for i in range(7): + daykey = 'day{}'.format(i) + app.save_setting(session, 'rattail.problems.{}.{}'.format(key, daykey), + str(data['days'][daykey]).lower()) + + def execute_instance(self, report_info, user, progress=None, **kwargs): + report = report_info['_report'] + problems = self.handler.run_problem_report(report, progress=progress, + force=True) + return "Report found {} problems".format(len(problems)) + + +class ProblemReportDays(colander.MappingSchema): + + day0 = colander.SchemaNode(colander.Boolean(), + title=calendar.day_abbr[0]) + day1 = colander.SchemaNode(colander.Boolean(), + title=calendar.day_abbr[1]) + day2 = colander.SchemaNode(colander.Boolean(), + title=calendar.day_abbr[2]) + day3 = colander.SchemaNode(colander.Boolean(), + title=calendar.day_abbr[3]) + day4 = colander.SchemaNode(colander.Boolean(), + title=calendar.day_abbr[4]) + day5 = colander.SchemaNode(colander.Boolean(), + title=calendar.day_abbr[5]) + day6 = colander.SchemaNode(colander.Boolean(), + title=calendar.day_abbr[6]) + + +class ProblemReportSchema(colander.MappingSchema): + + system_key = colander.SchemaNode(colander.String(), + missing=colander.null) + + problem_key = colander.SchemaNode(colander.String(), + missing=colander.null) + + problem_title = colander.SchemaNode(colander.String(), + missing=colander.null) + + description = colander.SchemaNode(colander.String(), + missing=colander.null) + + email_key = colander.SchemaNode(colander.String(), + missing=colander.null) + + email_recipients = colander.SchemaNode(colander.String(), + missing=colander.null) + + enabled = colander.SchemaNode(colander.Boolean()) + + days = ProblemReportDays() + + +class DaysWidget(dfwidget.Widget): + template = 'problem_report_days' + + def serialize(self, field, cstruct, **kw): + if cstruct in (colander.null, None): + cstruct = "" + readonly = kw.get("readonly", self.readonly) + template = self.template + values = dict(kw) + if 'day_labels' not in values: + values['day_labels'] = self.get_day_labels() + values = self.get_template_values(field, cstruct, values) + return field.renderer(template, **values) + + def get_day_labels(self): + labels = {} + for i in range(7): + labels[i] = {'name': calendar.day_name[i], + 'abbr': calendar.day_abbr[i]} + return labels + + def deserialize(self, field, pstruct): + from deform.compat import string_types + if pstruct is colander.null: + return colander.null + elif not isinstance(pstruct, string_types): + raise colander.Invalid(field.schema, "Pstruct is not a string") + pstruct = json.loads(pstruct) + return pstruct + + def add_routes(config): config.add_route('reports.ordering', '/reports/ordering') config.add_route('reports.inventory', '/reports/inventory') -def includeme(config): - add_routes(config) +def defaults(config, **kwargs): + base = globals() + # TODO: not in love with this pattern, but works for now + add_routes(config) + OrderingWorksheet = kwargs.get('OrderingWorksheet', base['OrderingWorksheet']) config.add_view(OrderingWorksheet, route_name='reports.ordering', renderer='/reports/ordering.mako') - + InventoryWorksheet = kwargs.get('InventoryWorksheet', base['InventoryWorksheet']) config.add_view(InventoryWorksheet, route_name='reports.inventory', renderer='/reports/inventory.mako') - # fix permission group - config.add_tailbone_permission_group('report_output', "Generated Reports") - - # note that GenerateReport must come first, per route matching - GenerateReport.defaults(config) + ReportOutputView = kwargs.get('ReportOutputView', base['ReportOutputView']) ReportOutputView.defaults(config) + + ProblemReportView = kwargs.get('ProblemReportView', base['ProblemReportView']) + ProblemReportView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/roles.py b/tailbone/views/roles.py index 8dde78b8..e8a6d8a2 100644 --- a/tailbone/views/roles.py +++ b/tailbone/views/roles.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,17 +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 (has_permission, grant_permission, revoke_permission, - administrator_role, guest_role, authenticated_role) +from rattail.db.model import Role from rattail.excel import ExcelWriter import colander @@ -50,30 +45,38 @@ class RoleView(PrincipalMasterView): """ Master view for the Role model. """ - model_class = model.Role + model_class = Role has_versions = True touchable = True + labels = { + 'adminish': "Admin-ish", + 'sync_me': "Sync Attrs & Perms", + } + grid_columns = [ 'name', 'session_timeout', 'sync_me', + 'sync_users', 'node_type', 'notes', ] form_fields = [ 'name', + 'adminish', 'session_timeout', 'notes', 'sync_me', + 'sync_users', 'node_type', 'users', 'permissions', ] def configure_grid(self, g): - super(RoleView, self).configure_grid(g) + super().configure_grid(g) # name g.filters['name'].default_active = True @@ -103,16 +106,23 @@ 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 + if role.adminish: + 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 @@ -131,13 +141,20 @@ 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 + if role.adminish: + return self.request.is_admin + # current user can delete their own roles, only if they have permission user = self.request.user if user and role in user.roles: @@ -146,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: @@ -155,67 +173,81 @@ 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() # name f.set_validator('name', self.unique_name) + # adminish + if self.request.is_admin: + f.set_helptext('adminish', + "If checked, only Administrators may add/remove " + "users for the role.") + else: + f.remove('adminish') + # 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', 'node_type') + f.remove('sync_me', 'sync_users', 'node_type') else: if not self.has_perm('edit_node_sync'): f.set_readonly('sync_me') + f.set_readonly('sync_users') f.set_readonly('node_type') # notes f.set_type('notes', 'text_wrapped') # users - if use_buefy and self.viewing or self.deleting: + if self.viewing: f.set_renderer('users', self.render_users) else: f.remove('users') # permissions self.tailbone_permissions = self.get_available_permissions() - f.set_renderer('permissions', PermissionsRenderer(permissions=self.tailbone_permissions)) + f.set_renderer('permissions', PermissionsRenderer(request=self.request, + 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 has_permission(self.Session(), role, key, include_guest=False, include_authenticated=False): + if auth.has_permission(self.Session(), role, key, + include_anonymous=False, + include_authenticated=False): granted.append(key) f.set_default('permissions', granted) elif self.deleting: 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.") @@ -223,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', @@ -236,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): """ @@ -254,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: @@ -269,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] = { @@ -282,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): """ @@ -298,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 @@ -309,76 +344,102 @@ class RoleView(PrincipalMasterView): permissions, but rather each "available" permission (depends on current user) will be examined individually, and updated as needed. """ + app = self.get_rattail_app() + 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: - grant_permission(role, pkey) + auth.grant_permission(role, pkey) else: - revoke_permission(role, pkey) + 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? all_roles = session.query(model.Role)\ .order_by(model.Role.name)\ .options(orm.joinedload(model.Role._permissions)) roles = [] for role in all_roles: - if 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)\ .order_by(model.Role.name)\ .all() @@ -427,7 +488,8 @@ class RoleView(PrincipalMasterView): # and show an 'X' for any role which has this perm for col, role in enumerate(roles, 2): - if has_permission(self.Session(), role, key, include_guest=False): + if auth.has_permission(self.Session(), role, key, + include_anonymous=False): sheet.cell(row=writing_row, column=col, value="X") writing_row += 1 @@ -494,5 +556,12 @@ class PermissionsWidget(dfwidget.Widget): return field.renderer(template, **values) -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + RoleView = kwargs.get('RoleView', base['RoleView']) RoleView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/settings.py b/tailbone/views/settings.py index ed63b857..10a0c2eb 100644 --- a/tailbone/views/settings.py +++ b/tailbone/views/settings.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,30 +24,174 @@ Settings Views """ -from __future__ import unicode_literals, absolute_import - +import json import re -import six - -from rattail.db import model, api -from rattail.settings import Setting -from rattail.util import import_module_path -from rattail.config import parse_bool - import colander -from webhelpers2.html import tags -from tailbone import forms +from rattail.db.model import Setting +from rattail.settings import Setting as AppSetting +from rattail.util import import_module_path + +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") @@ -80,11 +225,26 @@ class SettingView(MasterView): return not bool(self.feedback.match(setting.name)) return True + def after_edit(self, setting): + # nb. force cache invalidation - normally this happens when a + # setting is saved via app handler, but here that is being + # bypassed and it is saved directly via standard ORM calls + self.rattail_config.beaker_invalidate_setting(setting.name) + def deletable_instance(self, setting): if self.rattail_config.demo(): return not bool(self.feedback.match(setting.name)) return True + def delete_instance(self, setting): + + # nb. force cache invalidation + self.rattail_config.beaker_invalidate_setting(setting.name) + + # otherwise delete like normal + super().delete_instance(setting) + + # TODO: deprecate / remove this SettingsView = SettingView @@ -111,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: @@ -125,28 +285,33 @@ class AppSettingsView(View): if not current_group: current_group = self.request.session.get('appsettings.current_group') - use_buefy = self.get_use_buefy() + possible_config_options = sorted( + self.request.registry.settings['tailbone_config_pages'], + key=lambda p: p['label']) + + config_options = [] + for option in possible_config_options: + perm = option.get('perm', option['route']) + if self.request.has_perm(perm): + option['url'] = self.request.route_url(option['route']) + config_options.append(option) + 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]) @@ -159,14 +324,27 @@ class AppSettingsView(View): 'data_type': setting.data_type.__name__, 'choices': setting.choices, 'helptext': form.render_helptext(field.name) if form.has_helptext(field.name) else None, - 'error': field.error, + 'error': False, # nb. may set to True below } - value = self.get_setting_value(setting) - if setting.data_type is bool: - value = parse_bool(value) + + # we want the value from the form, i.e. in case of a POST + # request with validation errors. we also want to make + # sure value is JSON-compatible, but we must represent it + # as Python value here, and it will be JSON-encoded later. + value = form.get_vuejs_model_value(field) + value = json.loads(value) s['value'] = value + + # specify error / message if applicable + # TODO: not entirely clear to me why some field errors are + # represented differently? if field.error: - s['error_messages'] = field.error_messages() + s['error'] = True + if isinstance(field.error, colander.Invalid): + s['error_messages'] = [field.errormsg] + else: + s['error_messages'] = field.error_messages() + grouped[setting.group].append(s) data = [] @@ -214,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 @@ -246,8 +433,9 @@ class AppSettingsView(View): for entry in value.split('\n')] value = ', '.join(entries) else: - value = six.text_type(value) - api.save_setting(Session(), legacy_name, value) + value = str(value) + app = self.get_rattail_app() + app.save_setting(Session(), legacy_name, value) def clean_list_entry(self, value): value = value.strip() @@ -268,6 +456,18 @@ class AppSettingsView(View): permission='settings.edit') -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + AppInfoView = kwargs.get('AppInfoView', base['AppInfoView']) + AppInfoView.defaults(config) + + AppSettingsView = kwargs.get('AppSettingsView', base['AppSettingsView']) AppSettingsView.defaults(config) + + SettingView = kwargs.get('SettingView', base['SettingView']) SettingView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/shifts/core.py b/tailbone/views/shifts/core.py index b33ae0f0..53bfc446 100644 --- a/tailbone/views/shifts/core.py +++ b/tailbone/views/shifts/core.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,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: @@ -207,6 +211,15 @@ class WorkedShiftView(MasterView): WorkedShiftsView = WorkedShiftView -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + ScheduledShiftView = kwargs.get('ScheduledShiftView', base['ScheduledShiftView']) ScheduledShiftView.defaults(config) + + WorkedShiftView = kwargs.get('WorkedShiftView', base['WorkedShiftView']) WorkedShiftView.defaults(config) + + +def includeme(config): + defaults(config) 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 efaf4e33..c8b82724 100644 --- a/tailbone/views/shifts/schedule.py +++ b/tailbone/views/shifts/schedule.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,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) @@ -212,5 +210,12 @@ class ScheduleView(TimeSheetView): permission='schedule.print') -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + ScheduleView = kwargs.get('ScheduleView', base['ScheduleView']) ScheduleView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/shifts/timesheet.py b/tailbone/views/shifts/timesheet.py index 84d303e9..a8874127 100644 --- a/tailbone/views/shifts/timesheet.py +++ b/tailbone/views/shifts/timesheet.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,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 @@ -133,5 +131,12 @@ class TimeSheetView(BaseTimeSheetView): permission='timesheet.edit') -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + TimeSheetView = kwargs.get('TimeSheetView', base['TimeSheetView']) TimeSheetView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/stores.py b/tailbone/views/stores.py index b3107a83..5d507745 100644 --- a/tailbone/views/stores.py +++ b/tailbone/views/stores.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. # @@ -42,6 +42,7 @@ class StoreView(MasterView): """ model_class = model.Store has_versions = True + touchable = True grid_columns = [ 'id', @@ -120,5 +121,12 @@ class StoreView(MasterView): StoresView = StoreView -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + StoreView = kwargs.get('StoreView', base['StoreView']) StoreView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/subdepartments.py b/tailbone/views/subdepartments.py index b94b0f1b..43648ea6 100644 --- a/tailbone/views/subdepartments.py +++ b/tailbone/views/subdepartments.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,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 @@ -37,7 +39,9 @@ class SubdepartmentView(MasterView): Master view for the Subdepartment class. """ model_class = model.Subdepartment + supports_autocomplete = True touchable = True + results_downloadable = True has_versions = True grid_columns = [ @@ -83,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' @@ -93,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 { @@ -152,5 +183,12 @@ class SubdepartmentView(MasterView): SubdepartmentsView = SubdepartmentView -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + SubdepartmentView = kwargs.get('SubdepartmentView', base['SubdepartmentView']) SubdepartmentView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/tables.py b/tailbone/views/tables.py index df03a692..bfd52f2b 100644 --- a/tailbone/views/tables.py +++ b/tailbone/views/tables.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,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,18 +97,363 @@ 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) - g.sorters['row_count'] = g.make_simple_sorter('row_count') - g.set_sort_defaults('name') + super().configure_grid(g) -# TODO: deprecate / remove this -TablesView = TableView + # 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') + + def configure_form(self, f): + super().configure_form(f) + + # 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): + base = globals() + + TableView = kwargs.get('TableView', base['TableView']) + TableView.defaults(config) def includeme(config): - TableView.defaults(config) + defaults(config) diff --git a/tailbone/views/taxes.py b/tailbone/views/taxes.py index 96a404c7..b2afaeb9 100644 --- a/tailbone/views/taxes.py +++ b/tailbone/views/taxes.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,8 +24,6 @@ Tax Views """ -from __future__ import unicode_literals, absolute_import - from rattail.db import model from tailbone.views import MasterView @@ -53,16 +51,37 @@ 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 -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + TaxView = kwargs.get('TaxView', base['TaxView']) TaxView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/tempmon/appliances.py b/tailbone/views/tempmon/appliances.py index eeb22882..4ce52009 100644 --- a/tailbone/views/tempmon/appliances.py +++ b/tailbone/views/tempmon/appliances.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,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() @@ -187,5 +193,12 @@ class TempmonApplianceView(MasterView): raise NotImplementedError("too many uploads?") -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + TempmonApplianceView = kwargs.get('TempmonApplianceView', base['TempmonApplianceView']) TempmonApplianceView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/tempmon/clients.py b/tailbone/views/tempmon/clients.py index 1952c56d..1b2d49d8 100644 --- a/tailbone/views/tempmon/clients.py +++ b/tailbone/views/tempmon/clients.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 @@ 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) @@ -276,5 +283,12 @@ class TempmonClientView(MasterView): permission='{}.restart'.format(permission_prefix)) -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + TempmonClientView = kwargs.get('TempmonClientView', base['TempmonClientView']) TempmonClientView.defaults(config) + + +def includeme(config): + defaults(config) 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 321f8c83..515eabc9 100644 --- a/tailbone/views/tempmon/dashboard.py +++ b/tailbone/views/tempmon/dashboard.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2020 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"} @@ -158,5 +147,12 @@ class TempmonDashboardView(View): renderer='json') -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + TempmonDashboardView = kwargs.get('TempmonDashboardView', base['TempmonDashboardView']) TempmonDashboardView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/tempmon/probes.py b/tailbone/views/tempmon/probes.py index de0ca42d..573f9a2d 100644 --- a/tailbone/views/tempmon/probes.py +++ b/tailbone/views/tempmon/probes.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,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 @@ -344,5 +326,12 @@ class TempmonProbeView(MasterView): cls._defaults(config) -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + TempmonProbeView = kwargs.get('TempmonProbeView', base['TempmonProbeView']) TempmonProbeView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/tempmon/readings.py b/tailbone/views/tempmon/readings.py index a9c6d2c2..02e3fc51 100644 --- a/tailbone/views/tempmon/readings.py +++ b/tailbone/views/tempmon/readings.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,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,10 +121,17 @@ 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) -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + TempmonReadingView = kwargs.get('TempmonReadingView', base['TempmonReadingView']) TempmonReadingView.defaults(config) + + +def includeme(config): + defaults(config) 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 60a0f873..d5f077aa 100644 --- a/tailbone/views/trainwreck/base.py +++ b/tailbone/views/trainwreck/base.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,9 @@ Trainwreck views """ -from __future__ import unicode_literals, absolute_import +from webhelpers2.html import HTML, tags -import six - -from rattail.time import localtime -from rattail.util import OrderedDict - -from tailbone.db import TrainwreckSession, ExtraTrainwreckSessions +from tailbone.db import Session, TrainwreckSession, ExtraTrainwreckSessions from tailbone.views import MasterView @@ -47,12 +42,15 @@ class TransactionView(MasterView): creatable = False editable = False deletable = False + results_downloadable = True supports_multiple_engines = True engine_type_key = 'trainwreck' SessionDefault = TrainwreckSession SessionExtras = ExtraTrainwreckSessions + configurable = True + labels = { 'store_id': "Store", 'cashback': "Cash Back", @@ -88,11 +86,15 @@ class TransactionView(MasterView): 'shopper_id', 'shopper_name', 'shopper_level_number', + 'custorder_xref_markers', 'subtotal', 'discounted_subtotal', 'tax', 'cashback', 'total', + 'patronage', + 'equity_current', + 'self_updated', 'void', ] @@ -121,6 +123,7 @@ class TransactionView(MasterView): ] row_form_fields = [ + 'transaction', 'sequence', 'item_type', 'item_scancode', @@ -130,35 +133,70 @@ class TransactionView(MasterView): 'subdepartment_number', 'subdepartment_name', 'description', + 'custorder_item_xref', 'unit_quantity', 'subtotal', + 'discounts', + 'discounted_subtotal', 'tax', 'total', 'exempt_from_gross_sales', + 'net_sales', + 'gross_sales', 'void', ] def get_db_engines(self): - engines = OrderedDict(self.rattail_config.trainwreck_engines) - hidden = self.rattail_config.getlist('tailbone', 'engines.trainwreck.hidden', - default=None) - if hidden: - for key in hidden: - engines.pop(key, None) - return engines + app = self.get_rattail_app() + 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') + g.set_type('patronage', 'currency') g.set_label('terminal_id', "Terminal") g.set_label('receipt_number', "Receipt No.") g.set_label('customer_id', "Customer ID") @@ -176,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) @@ -187,6 +225,10 @@ class TransactionView(MasterView): f.set_type('tax', 'currency') f.set_type('cashback', 'currency') f.set_type('total', 'currency') + f.set_type('patronage', 'currency') + + # custorder_xref_markers + f.set_renderer('custorder_xref_markers', self.render_custorder_xref_markers) # label overrides f.set_label('system_id', "System ID") @@ -195,6 +237,64 @@ class TransactionView(MasterView): f.set_label('customer_id', "Customer ID") f.set_label('shopper_id', "Shopper ID") + def render_custorder_xref_markers(self, txn, field): + markers = getattr(txn, field) + if not markers: + return + + route_prefix = self.get_route_prefix() + factory = self.get_grid_factory() + + g = factory( + self.request, + key=f'{route_prefix}.custorder_xref_markers', + data=[], + columns=['custorder_xref', 'custorder_item_xref']) + + return HTML.literal( + g.render_table_element(data_prop='custorderXrefMarkersData')) + + def template_kwargs_view(self, **kwargs): + kwargs = super().template_kwargs_view(**kwargs) + config = self.rattail_config + + 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) @@ -203,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') @@ -212,12 +312,21 @@ class TransactionView(MasterView): g.set_type('tax', 'currency') g.set_type('total', 'currency') + g.set_link('item_scancode') + g.set_link('description') + def row_grid_extra_class(self, row, i): if row.void: return 'warning' + def get_row_instance_title(self, instance): + 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) # quantity fields f.set_type('unit_quantity', 'quantity') @@ -228,3 +337,179 @@ class TransactionView(MasterView): f.set_type('discounted_subtotal', 'currency') f.set_type('tax', 'currency') f.set_type('total', 'currency') + + # discounts + f.set_renderer('discounts', self.render_discounts) + + def render_transaction(self, item, field): + txn = getattr(item, field) + text = str(txn) + url = self.get_action_url('view', txn) + return tags.link_to(text, url) + + def render_discounts(self, item, field): + if not item.discounts: + return + + route_prefix = self.get_route_prefix() + factory = self.get_grid_factory() + + g = factory( + self.request, + key=f'{route_prefix}.discounts', + data=[], + columns=['discount_type', 'description', 'amount'], + labels={'discount_type': "Type"}) + + return HTML.literal( + g.render_table_element(data_prop='discountsData')) + + def template_kwargs_view_row(self, **kwargs): + 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 + + return kwargs + + def rollover(self): + """ + View for performing yearly rollover functions. + """ + app = self.get_rattail_app() + trainwreck_handler = app.get_trainwreck_handler() + trainwreck_engines = trainwreck_handler.get_trainwreck_engines() + current_year = app.localtime().year + + # find oldest and newest dates for each database + engines_data = [] + for key, engine in trainwreck_engines.items(): + + if key == 'default': + session = self.Session() + else: + session = ExtraTrainwreckSessions[key]() + + error = False + oldest = None + newest = None + try: + oldest = trainwreck_handler.get_oldest_transaction_date(session) + newest = trainwreck_handler.get_newest_transaction_date(session) + except: + error = True + + engines_data.append({ + 'key': key, + 'oldest_date': app.render_date(oldest) if oldest else None, + 'newest_date': app.render_date(newest) if newest else None, + 'error': error, + }) + + return self.render_to_response('rollover', { + 'instance_title': "Yearly Rollover", + 'trainwreck_handler': trainwreck_handler, + 'current_year': current_year, + 'next_year': current_year + 1, + 'trainwreck_engines': trainwreck_engines, + '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().configure_get_context() + + app = self.get_rattail_app() + trainwreck_handler = app.get_trainwreck_handler() + trainwreck_engines = trainwreck_handler.get_trainwreck_engines() + + context['trainwreck_engines'] = trainwreck_engines + context['hidden_databases'] = dict([ + (key, trainwreck_handler.engine_is_hidden(key)) + for key in trainwreck_engines]) + + return context + + def configure_gather_settings(self, data): + settings = super().configure_gather_settings(data) + + app = self.get_rattail_app() + trainwreck_handler = app.get_trainwreck_handler() + trainwreck_engines = trainwreck_handler.get_trainwreck_engines() + + hidden = [] + for key in trainwreck_engines: + name = 'hidedb_{}'.format(key) + if data.get(name) == 'true': + hidden.append(key) + settings.append({'name': 'trainwreck.db.hide', + 'value': ', '.join(hidden)}) + + return settings + + def configure_remove_settings(self): + super().configure_remove_settings() + app = self.get_rattail_app() + + names = [ + 'trainwreck.db.hide', + 'tailbone.engines.trainwreck.hidden', # deprecated + ] + + # 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: + app.delete_setting(session, name) + + @classmethod + def defaults(cls, config): + cls._trainwreck_defaults(config) + cls._defaults(config) + + @classmethod + def _trainwreck_defaults(cls, config): + route_prefix = cls.get_route_prefix() + url_prefix = cls.get_url_prefix() + permission_prefix = cls.get_permission_prefix() + model_title_plural = cls.get_model_title_plural() + + # fix perm group title + config.add_tailbone_permission_group(permission_prefix, + model_title_plural) + + # rollover + config.add_tailbone_permission(permission_prefix, + '{}.rollover'.format(permission_prefix), + label="Perform yearly rollover for Trainwreck") + config.add_route('{}.rollover'.format(route_prefix), + '{}/rollover'.format(url_prefix)) + config.add_view(cls, attr='rollover', + route_name='{}.rollover'.format(route_prefix), + permission='{}.rollover'.format(permission_prefix)) diff --git a/tailbone/views/trainwreck/defaults.py b/tailbone/views/trainwreck/defaults.py index 68b08a42..85c61fae 100644 --- a/tailbone/views/trainwreck/defaults.py +++ b/tailbone/views/trainwreck/defaults.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. # @@ -39,5 +39,12 @@ class TransactionView(base.TransactionView): model_row_class = trainwreck.TransactionItem -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + TransactionView = kwargs.get('TransactionView', base['TransactionView']) TransactionView.defaults(config) + + +def includeme(config): + defaults(config) 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/uoms.py b/tailbone/views/uoms.py index 964401f1..0b7b060f 100644 --- a/tailbone/views/uoms.py +++ b/tailbone/views/uoms.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. # @@ -126,5 +126,12 @@ class UnitOfMeasureView(MasterView): permission='{}.collect_wild_uoms'.format(permission_prefix)) -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + UnitOfMeasureView = kwargs.get('UnitOfMeasureView', base['UnitOfMeasureView']) UnitOfMeasureView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/upgrades.py b/tailbone/views/upgrades.py index 0484dabc..ffa88032 100644 --- a/tailbone/views/upgrades.py +++ b/tailbone/views/upgrades.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,26 +24,24 @@ 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 -from sqlalchemy import orm +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.upgrades import get_upgrade_handler from deform import widget as dfwidget from webhelpers2.html import tags, HTML from tailbone.views import MasterView from tailbone.progress import get_progress_session #, SessionProgress +from tailbone.config import should_expose_websockets log = logging.getLogger(__name__) @@ -53,12 +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", @@ -68,6 +68,7 @@ class UpgradeView(MasterView): } grid_columns = [ + 'system', 'created', 'description', # 'not_until', @@ -78,6 +79,7 @@ class UpgradeView(MasterView): ] form_fields = [ + 'system', 'description', # 'not_until', # 'requirements', @@ -96,29 +98,42 @@ class UpgradeView(MasterView): ] def __init__(self, request): - super(UpgradeView, self).__init__(request) - self.handler = self.get_handler() + super().__init__(request) - def get_handler(self): - """ - Returns the ``UpgradeHandler`` instance for the view. The handler - factory for this may be defined by config, e.g.: + if hasattr(self, 'get_handler'): + warnings.warn("defining get_handler() is deprecated. please " + "override AppHandler.get_upgrade_handler() instead", + DeprecationWarning, stacklevel=2) + self.upgrade_handler = self.get_handler() - .. code-block:: ini + else: + app = self.get_rattail_app() + self.upgrade_handler = app.get_upgrade_handler() - [rattail.upgrades] - handler = myapp.upgrades:CustomUpgradeHandler - """ - return get_upgrade_handler(self.rattail_config) + @property + def handler(self): + warnings.warn("handler attribute is deprecated; " + "please use upgrade_handler instead", + DeprecationWarning, stacklevel=2) + 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() + systems_enum = dict([(s['key'], s['label']) for s in systems]) + g.set_enum('system', systems_enum) + g.set_joiner('executed_by', lambda q: q.join(model.User, model.User.uuid == model.Upgrade.executed_by_uuid).outerjoin(model.Person)) g.set_sorter('executed_by', model.Person.display_name) g.set_enum('status_code', self.enum.UPGRADE_STATUS) g.set_type('created', 'datetime') g.set_type('executed', 'datetime') g.set_sort_defaults('created', 'desc') + + g.set_link('system') g.set_link('created') g.set_link('description') # g.set_link('not_until') @@ -131,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 @@ -154,32 +178,49 @@ 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 + systems = self.upgrade_handler.get_all_systems() + systems_enum = OrderedDict([(s['key'], s['label']) + for s in systems]) + f.set_enum('system', systems_enum) + f.set_required('system') + if self.creating: + if len(systems) == 1: + f.set_default('system', list(systems_enum)[0]) # status_code if self.creating: f.remove_field('status_code') else: f.set_enum('status_code', self.enum.UPGRADE_STATUS) - # f.set_readonly('status_code') + f.set_renderer('status_code', self.render_status_code) # executing if not self.editing: f.remove('executing') f.set_type('created', 'datetime') - f.set_type('enabled', 'boolean') f.set_type('executed', 'datetime') # f.set_widget('not_until', dfwidget.DateInputWidget()) f.set_widget('notes', dfwidget.TextAreaWidget(cols=80, rows=8)) f.set_renderer('stdout_file', self.render_stdout_file) f.set_renderer('stderr_file', self.render_stdout_file) - f.set_renderer('package_diff', self.render_package_diff) + + # package_diff + if self.viewing and upgrade.executed and ( + upgrade.system == 'rattail' + or not upgrade.system): + f.set_renderer('package_diff', self.render_package_diff) + else: + f.remove_field('package_diff') + # f.set_readonly('created') # f.set_readonly('created_by') f.set_readonly('executed') f.set_readonly('executed_by') - upgrade = f.model_instance if self.creating or self.editing: f.remove_field('created') f.remove_field('created_by') @@ -188,28 +229,57 @@ class UpgradeView(MasterView): if self.creating or not upgrade.executed: f.remove_field('executed') f.remove_field('executed_by') - if self.editing and upgrade.executed: - f.remove_field('enabled') - elif f.model_instance.executed: - f.remove_field('enabled') - - else: + elif not upgrade.executed: f.remove_field('executed') f.remove_field('executed_by') f.remove_field('stdout_file') f.remove_field('stderr_file') + # enabled + if not self.creating and upgrade.executed: + f.remove('enabled') + else: + f.set_type('enabled', 'boolean') + f.set_default('enabled', True) + if not self.viewing or not upgrade.executed: - f.remove_field('package_diff') f.remove_field('exit_code') + def render_status_code(self, upgrade, field): + code = getattr(upgrade, field) + text = self.enum.UPGRADE_STATUS[code] + + if code == self.enum.UPGRADE_STATUS_EXECUTING: + + 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'}) + + 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 + def configure_clone_form(self, f): - f.fields = ['description', 'notes', 'enabled'] + f.fields = ['system', 'description', 'notes', 'enabled'] def clone_instance(self, original): + app = self.get_rattail_app() cloned = self.model_class() - cloned.created = make_utc() + cloned.system = original.system + cloned.created = app.make_utc() cloned.created_by = self.request.user cloned.description = original.description cloned.notes = original.notes @@ -231,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, @@ -246,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 + " / " @@ -277,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'" @@ -289,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 @@ -387,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) @@ -411,11 +446,29 @@ class UpgradeView(MasterView): # key = '{}.execute'.format(self.get_grid_key()) # return SessionProgress(self.request, key, session_type='file') - def execute_instance(self, upgrade, user, **kwargs): - session = orm.object_session(upgrade) - self.handler.mark_executing(upgrade) + def executable_instance(self, upgrade): + if upgrade.executed: + return False + if upgrade.status_code != self.enum.UPGRADE_STATUS_PENDING: + return False + return True + + def execute_instance(self, upgrade, user, progress=None, **kwargs): + app = self.get_rattail_app() + session = app.get_session(upgrade) + + # record the fact that execution has begun for this ugprade + self.upgrade_handler.mark_executing(upgrade) session.commit() - self.handler.do_execute(upgrade, user, **kwargs) + + # let handler execute the upgrade + self.upgrade_handler.do_execute(upgrade, user, **kwargs) + + # success msg + msg = "Execution has finished, for better or worse." + if not upgrade.system or upgrade.system == 'rattail': + msg += " You may need to restart your web app." + return msg def execute_progress(self): upgrade = self.get_instance() @@ -443,24 +496,105 @@ class UpgradeView(MasterView): return data + def declare_failure(self): + upgrade = self.get_instance() + if upgrade.executing and upgrade.status_code == self.enum.UPGRADE_STATUS_EXECUTING: + upgrade.executing = False + upgrade.status_code = self.enum.UPGRADE_STATUS_FAILED + self.request.session.flash("Upgrade was declared a failure.", 'warning') + else: + self.request.session.flash("Upgrade was not currently executing! " + "So it was not declared a failure.", + 'error') + return self.redirect(self.get_action_url('view', upgrade)) + 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().configure_get_context(**kwargs) + + context['upgrade_systems'] = self.upgrade_handler.get_all_systems() + + return context + + def configure_gather_settings(self, data): + settings = super().configure_gather_settings(data) + + keys = [] + for system in json.loads(data['upgrade_systems']): + key = system['key'] + if key == 'rattail': + settings.append({'name': 'rattail.upgrades.command', + 'value': system['command']}) + else: + keys.append(key) + settings.append({'name': 'rattail.upgrades.system.{}.label'.format(key), + 'value': system['label']}) + settings.append({'name': 'rattail.upgrades.system.{}.command'.format(key), + 'value': system['command']}) + if keys: + settings.append({'name': 'rattail.upgrades.systems', + 'value': ', '.join(keys)}) + + return settings + + def configure_remove_settings(self): + super().configure_remove_settings() + app = self.get_rattail_app() + model = self.model + + to_delete = self.Session.query(model.Setting)\ + .filter(sa.or_( + model.Setting.name == 'rattail.upgrades.command', + model.Setting.name == 'rattail.upgrades.systems', + model.Setting.name.like('rattail.upgrades.system.%.label'), + model.Setting.name.like('rattail.upgrades.system.%.command')))\ + .all() + + for setting in to_delete: + app.delete_setting(self.Session(), setting.name) @classmethod def defaults(cls, config): + cls._defaults(config) + cls._upgrade_defaults(config) + + @classmethod + def _upgrade_defaults(cls, config): route_prefix = cls.get_route_prefix() - url_prefix = cls.get_url_prefix() permission_prefix = cls.get_permission_prefix() + instance_url_prefix = cls.get_instance_url_prefix() model_key = cls.get_model_key() # execution progress - config.add_route('{}.execute_progress'.format(route_prefix), '{}/{{{}}}/execute/progress'.format(url_prefix, model_key)) - config.add_view(cls, attr='execute_progress', route_name='{}.execute_progress'.format(route_prefix), - permission='{}.execute'.format(permission_prefix), renderer='json') + config.add_route('{}.execute_progress'.format(route_prefix), + '{}/execute/progress'.format(instance_url_prefix)) + config.add_view(cls, attr='execute_progress', + route_name='{}.execute_progress'.format(route_prefix), + permission='{}.execute'.format(permission_prefix), + renderer='json') - cls._defaults(config) + # declare failure + config.add_route('{}.declare_failure'.format(route_prefix), + '{}/declare-failure'.format(instance_url_prefix), + request_method='POST') + config.add_view(cls, attr='declare_failure', + route_name='{}.declare_failure'.format(route_prefix), + permission='{}.execute'.format(permission_prefix)) + + +def defaults(config, **kwargs): + base = globals() + rattail_config = config.registry['rattail_config'] + + UpgradeView = kwargs.get('UpgradeView', base['UpgradeView']) + UpgradeView.defaults(config) + + if should_expose_websockets(rattail_config): + config.include('tailbone.views.asgi.upgrades') def includeme(config): - UpgradeView.defaults(config) + defaults(config) diff --git a/tailbone/views/users.py b/tailbone/views/users.py index b30034b4..dfed0a11 100644 --- a/tailbone/views/users.py +++ b/tailbone/views/users.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,34 +24,33 @@ User Views """ -from __future__ import unicode_literals, absolute_import - -import copy - -import six +import sqlalchemy as sa from sqlalchemy import orm -from rattail.db import model -from rattail.db.auth import administrator_role, guest_role, authenticated_role, set_user_password, has_permission +from rattail.db.model import User, UserEvent import colander from deform import widget as dfwidget from webhelpers2.html import HTML, tags from tailbone import forms -from tailbone.db import Session -from tailbone.views import MasterView +from tailbone.views import MasterView, View from tailbone.views.principal import PrincipalMasterView, PermissionsRenderer +from tailbone.util import raw_datetime class UserView(PrincipalMasterView): """ Master view for the User model. """ - model_class = model.User - has_rows = True - model_row_class = model.UserEvent + model_class = User has_versions = True + touchable = True + mergeable = True + + labels = { + 'api_tokens': "API Tokens", + } grid_columns = [ 'username', @@ -69,34 +68,44 @@ 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', ] - mergeable = True - merge_additive_fields = [ - 'sent_message_count', - 'received_message_count', - ] - merge_coalesce_fields = [ - 'person_uuid', - 'person_name', - 'active', - ] - merge_fields = merge_additive_fields + [ - 'uuid', - 'username', - 'person_uuid', - 'person_name', - 'role_count', - ] + def __init__(self, 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) query = query.outerjoin(model.Person)\ @@ -105,7 +114,8 @@ 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'] g.filters['username'].default_active = True @@ -133,6 +143,10 @@ class UserView(PrincipalMasterView): g.set_link('last_name') g.set_link('display_name') + def grid_extra_class(self, user, i): + if not user.active: + return 'warning' + def editable_instance(self, user): """ If the given user is "protected" then we only allow edit if current @@ -154,6 +168,7 @@ class UserView(PrincipalMasterView): return not self.user_is_protected(user) def unique_username(self, node, value): + model = self.model query = self.Session.query(model.User)\ .filter(model.User.username == value) if self.editing: @@ -162,8 +177,20 @@ class UserView(PrincipalMasterView): if query.count(): raise colander.Invalid(node, "Username must be unique") + def valid_person(self, node, value): + """ + Make sure ``value`` corresponds to an existing + ``Person.uuid``. + """ + if value: + model = self.model + 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 # username @@ -178,14 +205,19 @@ 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") # person name(s) @@ -201,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: @@ -212,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: @@ -236,37 +282,123 @@ class UserView(PrincipalMasterView): # fs.confirm_password.attrs(autocomplete='new-password') if self.viewing: - permissions = self.request.registry.settings.get('tailbone_permissions', {}) - f.append('permissions') - f.set_renderer('permissions', PermissionsRenderer(permissions=permissions, - include_guest=True, + permissions = self.request.registry.settings.get('wutta_permissions', {}) + f.set_renderer('permissions', PermissionsRenderer(request=self.request, + permissions=permissions, + include_anonymous=True, include_authenticated=True)) + else: + f.remove('permissions') if self.viewing or self.deleting: f.remove('set_password') + 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 admin role membership + # 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) - return self.Session.query(model.Role)\ - .filter(~model.Role.uuid.in_(excluded))\ - .order_by(model.Role.name) + # basic list, minus exclusions so far + roles = self.Session.query(model.Role)\ + .filter(~model.Role.uuid.in_(excluded)) + + # only allow "admin" user to change admin-ish role memberships + if not self.request.is_admin: + roles = roles.filter(sa.or_( + model.Role.adminish == False, + model.Role.adminish == None)) + + return roles.order_by(model.Role.name) def objectify(self, form, data=None): + 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 = {} @@ -295,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) @@ -309,9 +441,12 @@ class UserView(PrincipalMasterView): if 'roles' not in data: return + 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 @@ -333,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. @@ -348,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) @@ -358,10 +493,10 @@ 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 = user.roles + roles = sorted(user.roles, key=lambda r: r.name) items = [] for role in roles: text = role.name @@ -370,24 +505,29 @@ class UserView(PrincipalMasterView): return HTML.tag('ul', c=items) def get_row_data(self, user): + model = self.model return self.Session.query(model.UserEvent)\ .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 return [ (model.UserRole, 'user_uuid'), ] def find_principals_with_permission(self, session, permission): + app = self.get_rattail_app() + auth = app.get_auth_handler() + model = self.model + # TODO: this should search Permission table instead, and work backward to User? all_users = session.query(model.User)\ .filter(model.User.active == True)\ @@ -397,34 +537,146 @@ class UserView(PrincipalMasterView): .joinedload(model.Role._permissions)) users = [] for user in all_users: - if has_permission(session, user, permission): + if auth.has_permission(session, user, permission): users.append(user) return users - def get_merge_data(self, user): - return { - 'uuid': user.uuid, - 'username': user.username, - 'person_uuid': user.person_uuid, - 'person_name': user.person.display_name if user.person else None, - '_roles': user.roles, - 'role_count': len(user.roles), - 'active': user.active, - 'sent_message_count': len(user.sent_messages), - 'received_message_count': len(user._messages), - } + def find_by_perm_configure_results_grid(self, g): + g.append('username') + g.set_link('username') - def get_merge_resulting_data(self, remove, keep): - result = super(UserView, self).get_merge_resulting_data(remove, keep) - result['role_count'] = len(set(remove['_roles'] + keep['_roles'])) - return result + g.append('person') + g.set_link('person') - def merge_objects(self, removing, keeping): - # TODO: merge roles, messages - assert not removing.sent_messages - assert not removing._messages - assert not removing._roles - self.Session.delete(removing) + 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. + """ + current_user = True + if not user: + current_user = False + user = self.get_instance() + + # TODO: this is of course largely copy/pasted from the + # MasterView.configure() method..should refactor? + if self.request.method == 'POST': + if self.request.POST.get('remove_settings'): + self.preferences_remove_settings(user) + self.request.session.flash("Settings have been removed.") + return self.redirect(self.request.current_route_url()) + else: + data = self.request.POST + + # then gather/save settings + settings = self.preferences_gather_settings(data, user) + self.preferences_remove_settings(user) + self.configure_save_settings(settings) + self.request.session.flash("Settings have been saved.") + return self.redirect(self.request.current_route_url()) + + context = self.preferences_get_context(user, current_user) + return self.render_to_response('preferences', context) + + def my_preferences(self): + """ + View to modify preferences for the current user. + """ + user = self.request.user + if not user: + raise self.forbidden() + return self.preferences(user=user) + + def preferences_get_context(self, user, current_user): + simple_settings = self.preferences_get_simple_settings(user) + context = self.configure_get_context(simple_settings=simple_settings, + input_file_templates=False) + + instance_title = self.get_instance_title(user) + context.update({ + 'user': user, + 'instance': user, + 'instance_title': instance_title, + 'instance_url': self.get_action_url('view', user), + 'config_title': instance_title, + 'config_preferences': True, + 'current_user': current_user, + }) + + if current_user: + context.update({ + 'index_url': None, + 'index_title': instance_title, + }) + + # theme style options + options = [{'value': None, 'label': "default"}] + styles = self.rattail_config.getlist('tailbone', 'themes.styles', + default=[]) + for name in styles: + 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['theme_style_options'] = options + + return context + + def preferences_get_simple_settings(self, user): + """ + This method is conceptually the same as for + :meth:`~tailbone.views.master.MasterView.configure_get_simple_settings()`. + See its docs for more info. + + 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': 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) + 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): @@ -437,7 +689,9 @@ class UserView(PrincipalMasterView): """ Provide extra default configuration for the User master view. """ + route_prefix = cls.get_route_prefix() permission_prefix = cls.get_permission_prefix() + instance_url_prefix = cls.get_instance_url_prefix() model_title = cls.get_model_title() # view/edit roles @@ -446,6 +700,42 @@ 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), + "Edit preferences for any {}".format(model_title)) + config.add_route('{}.preferences'.format(route_prefix), + '{}/preferences'.format(instance_url_prefix)) + config.add_view(cls, attr='preferences', + route_name='{}.preferences'.format(route_prefix), + permission='{}.preferences'.format(permission_prefix)) + + # edit "my" preferences (for current user) + config.add_route('my.preferences', + '/my/preferences') + config.add_view(cls, attr='my_preferences', + route_name='my.preferences') + + # TODO: deprecate / remove this UsersView = UserView @@ -454,7 +744,7 @@ class UserEventView(MasterView): """ Master view for all user events """ - model_class = model.UserEvent + model_class = UserEvent url_prefix = '/user-events' viewable = False creatable = False @@ -469,11 +759,13 @@ 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) g.set_sorter('person', model.Person.display_name) @@ -498,6 +790,19 @@ class UserEventView(MasterView): UserEventsView = UserEventView -def includeme(config): +def defaults(config, **kwargs): + base = globals() + + UserView = kwargs.get('UserView', base['UserView']) UserView.defaults(config) + + UserEventView = kwargs.get('UserEventView', base['UserEventView']) UserEventView.defaults(config) + + +def includeme(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 6a31777c..210df39e 100644 --- a/tailbone/views/vendors/__init__.py +++ b/tailbone/views/vendors/__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. # @@ -24,10 +24,14 @@ Views pertaining to vendors """ -from __future__ import unicode_literals, absolute_import - from .core import VendorView +def defaults(config, **kwargs): + from .core import defaults + return 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 ceac1c71..addf153c 100644 --- a/tailbone/views/vendors/core.py +++ b/tailbone/views/vendors/core.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,15 +24,12 @@ Vendor Views """ -from __future__ import unicode_literals, absolute_import - -import six - from rattail.db import model from webhelpers2.html import tags from tailbone.views import MasterView +from tailbone.db import Session class VendorView(MasterView): @@ -42,7 +39,9 @@ class VendorView(MasterView): model_class = model.Vendor has_versions = True touchable = True + results_downloadable = True supports_autocomplete = True + configurable = True labels = { 'id': "ID", @@ -57,6 +56,7 @@ class VendorView(MasterView): 'phone', 'email', 'contact', + 'terms', ] form_fields = [ @@ -70,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' @@ -87,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') @@ -106,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: @@ -118,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: @@ -140,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: @@ -150,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) @@ -162,12 +166,84 @@ 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'), ] + def configure_get_simple_settings(self): + config = self.rattail_config + return [ + + # display + {'section': 'rattail', + 'option': 'vendors.choice_uses_dropdown', + 'type': bool}, + ] + + def configure_get_context(self, **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().configure_gather_settings( + data, **kwargs) + + supported_vendor_settings = self.configure_get_supported_vendor_settings() + for setting in supported_vendor_settings.values(): + name = 'rattail.vendor.{}'.format(setting['key']) + settings.append({'name': name, + 'value': data[name]}) + + return settings + + def configure_remove_settings(self, **kwargs): + super().configure_remove_settings(**kwargs) + app = self.get_rattail_app() + names = [] + + supported_vendor_settings = self.configure_get_supported_vendor_settings() + for setting in supported_vendor_settings.values(): + names.append('rattail.vendor.{}'.format(setting['key'])) + + if names: + # 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: + app.delete_setting(session, name) + + def configure_get_supported_vendor_settings(self): + app = self.get_rattail_app() + vendor_handler = app.get_vendor_handler() + batch_handler = app.get_batch_handler('purchase') + settings = {} + + for parser in batch_handler.get_supported_invoice_parsers(): + key = parser.vendor_key + if not key: + continue + + vendor = vendor_handler.get_vendor(self.Session(), key) + settings[key] = { + 'key': key, + 'value': vendor.uuid if vendor else None, + 'label': str(vendor) if vendor else None, + } + + return settings + + +def defaults(config, **kwargs): + base = globals() + + VendorView = kwargs.get('VendorView', base['VendorView']) + VendorView.defaults(config) + def includeme(config): - VendorView.defaults(config) + defaults(config) 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/versions.py b/tailbone/views/versions.py new file mode 100644 index 00000000..6c370996 --- /dev/null +++ b/tailbone/views/versions.py @@ -0,0 +1,89 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2021 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/>. +# +################################################################################ +""" +Master view for version tables +""" + +from __future__ import unicode_literals, absolute_import + +import sqlalchemy_continuum as continuum + +from tailbone.views import MasterView +from tailbone.util import raw_datetime + + +class VersionMasterView(MasterView): + """ + Base class for version master views + """ + creatable = False + editable = False + deletable = False + + labels = { + 'transaction_issued_at': "Changed", + 'transaction_user': "Changed by", + 'transaction_id': "Transaction ID", + } + + grid_columns = [ + 'transaction_issued_at', + 'transaction_user', + 'version_parent', + 'transaction_id', + ] + + def query(self, session): + Transaction = continuum.transaction_class(self.true_model_class) + + query = session.query(self.model_class)\ + .join(Transaction, + Transaction.id == self.model_class.transaction_id) + + return query + + def configure_grid(self, g): + super(VersionMasterView, self).configure_grid(g) + Transaction = continuum.transaction_class(self.true_model_class) + + g.set_sorter('transaction_issued_at', Transaction.issued_at) + g.set_sorter('transaction_id', Transaction.id) + g.set_sort_defaults('transaction_issued_at', 'desc') + + g.set_renderer('transaction_issued_at', self.render_transaction_issued_at) + g.set_renderer('transaction_user', self.render_transaction_user) + g.set_renderer('transaction_id', self.render_transaction_id) + + g.set_link('transaction_issued_at') + g.set_link('transaction_user') + g.set_link('version_parent') + + def render_transaction_issued_at(self, version, field): + value = version.transaction.issued_at + return raw_datetime(self.rattail_config, value) + + def render_transaction_user(self, version, field): + return version.transaction.user + + def render_transaction_id(self, version, field): + return version.transaction.id 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 new file mode 100644 index 00000000..d8094e4b --- /dev/null +++ b/tailbone/views/workorders.py @@ -0,0 +1,416 @@ +# -*- 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/>. +# +################################################################################ +""" +Work Order Views +""" + +import sqlalchemy as sa + +from rattail.db.model import WorkOrder, WorkOrderEvent + +from webhelpers2.html import HTML + +from tailbone import forms, grids +from tailbone.views import MasterView + + +class WorkOrderView(MasterView): + """ + Master view for work orders + """ + model_class = WorkOrder + route_prefix = 'workorders' + url_prefix = '/workorders' + bulk_deletable = True + + labels = { + 'id': "ID", + 'status_code': "Status", + } + + grid_columns = [ + 'id', + 'customer', + 'date_received', + 'date_released', + 'status_code', + ] + + form_fields = [ + 'id', + 'customer', + 'notes', + 'date_submitted', + 'date_received', + 'date_released', + 'date_delivered', + 'status_code', + ] + + has_rows = True + model_row_class = WorkOrderEvent + rows_viewable = False + + row_labels = { + 'type_code': "Event Type", + } + + row_grid_columns = [ + 'type_code', + 'occurred', + 'user', + 'note', + ] + + def __init__(self, request): + super().__init__(request) + app = self.get_rattail_app() + self.workorder_handler = app.get_workorder_handler() + + def configure_grid(self, g): + super().configure_grid(g) + model = self.model + + # customer + g.set_joiner('customer', lambda q: q.join(model.Customer)) + g.set_sorter('customer', model.Customer.name) + g.set_filter('customer', model.Customer.name) + + # status + g.set_filter('status_code', model.WorkOrder.status_code, + factory=StatusFilter, + default_active=True, + default_verb='is_active') + g.set_enum('status_code', self.enum.WORKORDER_STATUS) + + g.set_sort_defaults('id', 'desc') + + g.set_link('id') + g.set_link('customer') + + def grid_extra_class(self, workorder, i): + if workorder.status_code == self.enum.WORKORDER_STATUS_CANCELED: + return 'warning' + + def configure_form(self, f): + super().configure_form(f) + model = self.model + SelectWidget = forms.widgets.JQuerySelectWidget + + # id + if self.creating: + f.remove_field('id') + else: + f.set_readonly('id') + + # customer + if self.creating: + f.replace('customer', 'customer_uuid') + f.set_label('customer_uuid', "Customer") + f.set_widget('customer_uuid', + forms.widgets.make_customer_widget(self.request)) + f.set_input_handler('customer_uuid', 'customerChanged') + else: + f.set_readonly('customer') + f.set_renderer('customer', self.render_customer) + + # notes + f.set_type('notes', 'text') + + # status_code + if self.creating: + f.remove('status_code') + else: + f.set_enum('status_code', self.enum.WORKORDER_STATUS) + f.set_renderer('status_code', self.render_status_code) + if not self.has_perm('edit_status'): + f.set_readonly('status_code') + + # date fields + f.set_type('date_submitted', 'date_jquery') + f.set_type('date_received', 'date_jquery') + f.set_type('date_released', 'date_jquery') + f.set_type('date_delivered', 'date_jquery') + if self.creating: + f.remove('date_submitted', + 'date_received', + 'date_released', + 'date_delivered') + elif not self.has_perm('edit_status'): + f.set_readonly('date_submitted') + f.set_readonly('date_received') + f.set_readonly('date_released') + f.set_readonly('date_delivered') + + def objectify(self, form, data=None): + """ + Supplements the default logic as follows: + + If creating a new Work Order, will automatically set its status to + "submitted" and its ``date_submitted`` to the current date. + """ + if data is None: + data = form.validated + + # first let deform do its thing. if editing, this will update + # the record like we want. but if creating, this will + # populate the initial object *without* adding it to session, + # which is also what we want, so that we can "replace" the new + # object with one the handler creates, below + workorder = form.schema.objectify(data, context=form.model_instance) + + if self.creating: + + # now make the "real" work order + data = dict([(key, getattr(workorder, key)) + for key in data]) + workorder = self.workorder_handler.make_workorder(self.Session(), **data) + + return workorder + + def render_status_code(self, obj, field): + status_code = getattr(obj, field) + if status_code is None: + return "" + if status_code in self.enum.WORKORDER_STATUS: + text = self.enum.WORKORDER_STATUS[status_code] + if status_code == self.enum.WORKORDER_STATUS_CANCELED: + return HTML.tag('span', class_='has-text-danger', c=text) + return text + return str(status_code) + + def get_row_data(self, workorder): + model = self.model + return self.Session.query(model.WorkOrderEvent)\ + .filter(model.WorkOrderEvent.workorder == workorder) + + def get_parent(self, event): + return event.workorder + + def configure_row_grid(self, g): + super().configure_row_grid(g) + g.set_enum('type_code', self.enum.WORKORDER_EVENT) + g.set_sort_defaults('occurred') + + def receive(self): + """ + Sets work order status to "received". + """ + workorder = self.get_instance() + self.workorder_handler.receive(workorder) + self.Session.flush() + return self.redirect(self.get_action_url('view', workorder)) + + def await_estimate(self): + """ + Sets work order status to "awaiting estimate confirmation". + """ + workorder = self.get_instance() + self.workorder_handler.await_estimate(workorder) + self.Session.flush() + return self.redirect(self.get_action_url('view', workorder)) + + def await_parts(self): + """ + Sets work order status to "awaiting parts". + """ + workorder = self.get_instance() + self.workorder_handler.await_parts(workorder) + self.Session.flush() + return self.redirect(self.get_action_url('view', workorder)) + + def work_on_it(self): + """ + Sets work order status to "working on it". + """ + workorder = self.get_instance() + self.workorder_handler.work_on_it(workorder) + self.Session.flush() + return self.redirect(self.get_action_url('view', workorder)) + + def release(self): + """ + Sets work order status to "released". + """ + workorder = self.get_instance() + self.workorder_handler.release(workorder) + self.Session.flush() + return self.redirect(self.get_action_url('view', workorder)) + + def deliver(self): + """ + Sets work order status to "delivered". + """ + workorder = self.get_instance() + self.workorder_handler.deliver(workorder) + self.Session.flush() + return self.redirect(self.get_action_url('view', workorder)) + + def cancel(self): + """ + Sets work order status to "canceled". + """ + workorder = self.get_instance() + self.workorder_handler.cancel(workorder) + self.Session.flush() + return self.redirect(self.get_action_url('view', workorder)) + + @classmethod + def defaults(cls, config): + cls._defaults(config) + cls._workorder_defaults(config) + + @classmethod + def _workorder_defaults(cls, config): + route_prefix = cls.get_route_prefix() + permission_prefix = cls.get_permission_prefix() + instance_url_prefix = cls.get_instance_url_prefix() + model_title = cls.get_model_title() + + # perm for editing status + config.add_tailbone_permission( + permission_prefix, + '{}.edit_status'.format(permission_prefix), + "Directly edit status and related fields for {}".format(model_title)) + + # receive + config.add_route('{}.receive'.format(route_prefix), + '{}/receive'.format(instance_url_prefix), + request_method='POST') + config.add_view(cls, attr='receive', + route_name='{}.receive'.format(route_prefix), + permission='{}.edit'.format(permission_prefix)) + + # await_estimate + config.add_route('{}.await_estimate'.format(route_prefix), + '{}/await-estimate'.format(instance_url_prefix), + request_method='POST') + config.add_view(cls, attr='await_estimate', + route_name='{}.await_estimate'.format(route_prefix), + permission='{}.edit'.format(permission_prefix)) + + # await_parts + config.add_route('{}.await_parts'.format(route_prefix), + '{}/await-parts'.format(instance_url_prefix), + request_method='POST') + config.add_view(cls, attr='await_parts', + route_name='{}.await_parts'.format(route_prefix), + permission='{}.edit'.format(permission_prefix)) + + # work_on_it + config.add_route('{}.work_on_it'.format(route_prefix), + '{}/work-on-it'.format(instance_url_prefix), + request_method='POST') + config.add_view(cls, attr='work_on_it', + route_name='{}.work_on_it'.format(route_prefix), + permission='{}.edit'.format(permission_prefix)) + + # release + config.add_route('{}.release'.format(route_prefix), + '{}/release'.format(instance_url_prefix), + request_method='POST') + config.add_view(cls, attr='release', + route_name='{}.release'.format(route_prefix), + permission='{}.edit'.format(permission_prefix)) + + # deliver + config.add_route('{}.deliver'.format(route_prefix), + '{}/deliver'.format(instance_url_prefix), + request_method='POST') + config.add_view(cls, attr='deliver', + route_name='{}.deliver'.format(route_prefix), + permission='{}.edit'.format(permission_prefix)) + + # cancel + config.add_route('{}.cancel'.format(route_prefix), + '{}/cancel'.format(instance_url_prefix), + request_method='POST') + config.add_view(cls, attr='cancel', + route_name='{}.cancel'.format(route_prefix), + permission='{}.edit'.format(permission_prefix)) + + +class StatusFilter(grids.filters.AlchemyIntegerFilter): + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + from drild import enum + + self.active_status_codes = [ + # enum.WORKORDER_STATUS_CREATED, + enum.WORKORDER_STATUS_SUBMITTED, + enum.WORKORDER_STATUS_RECEIVED, + enum.WORKORDER_STATUS_PENDING_ESTIMATE, + enum.WORKORDER_STATUS_WAITING_FOR_PARTS, + enum.WORKORDER_STATUS_WORKING_ON_IT, + enum.WORKORDER_STATUS_RELEASED, + ] + + @property + def verb_labels(self): + 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().valueless_verbs) + verbs.extend([ + 'is_active', + 'not_active', + ]) + return verbs + + @property + def default_verbs(self): + 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 + + def filter_is_active(self, query, value): + return query.filter( + WorkOrder.status_code.in_(self.active_status_codes)) + + def filter_not_active(self, query, value): + return query.filter(sa.or_( + ~WorkOrder.status_code.in_(self.active_status_codes), + WorkOrder.status_code == None, + )) + + +def defaults(config, **kwargs): + base = globals() + + WorkOrderView = kwargs.get('WorkOrderView', base['WorkOrderView']) + WorkOrderView.defaults(config) + + +def includeme(config): + defaults(config) 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 e2ba5670..6983dbea 100644 --- a/tasks.py +++ b/tasks.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,26 +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(ctx, skip_tests=False): +def release(c, skip_tests=False): """ Release a new version of 'Tailbone'. """ if not skip_tests: - ctx.run('tox') + c.run('pytest') - shutil.rmtree('Tailbone.egg-info') - ctx.run('python setup.py sdist --formats=gztar') - ctx.run('twine upload dist/Tailbone-{}.tar.gz'.format(__version__)) + 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/*') 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 2ac683e2..3896befb 100644 --- a/tox.ini +++ b/tox.ini @@ -1,38 +1,19 @@ + [tox] -envlist = py27, py35 +envlist = py38, py39, py310, py311 [testenv] -deps = - coverage - fixture - mock - nose -commands = - pip install --upgrade pip - pip install --upgrade --upgrade-strategy eager Tailbone rattail[auth,bouncer,db] rattail-tempmon - nosetests {posargs} - -[testenv:py27] -# TODO: this only adds the sa-utils restriction, per python2 -commands = - pip install --upgrade pip - pip install --upgrade --upgrade-strategy eager Tailbone rattail[auth,bouncer,db] rattail-tempmon SQLAlchemy-Utils<0.36.7 - nosetests {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 rattail[auth,bouncer,db] rattail-tempmon - nosetests {posargs:--with-coverage --cover-html-dir={envtmpdir}/coverage} +extras = tests +commands = pytest --cov=tailbone --cov-report=html [testenv:docs] basepython = python3 -deps = - Sphinx - sphinx-rtd-theme changedir = docs -commands = - pip install --upgrade pip - pip install --upgrade --upgrade-strategy eager Tailbone 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